From f8795ff426d4fc2158ebb04f497a6a1e20f6ce01 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Thu, 29 Oct 2026 07:26:15 -0700 Subject: [PATCH 01/55] fix(routes): report an upstream routing outage as unavailable When the OSRM routing service failed (a 5xx, a 403 block, a 404), answered with a body that was not JSON, or did not answer at all (timeout, network error), /api/route returned HTTP 200 with 'no route found' or 'route proxy error'. plan_route turned that into invalid_arguments ("No walk route from ...: no route found"), blaming the caller for an outage, and the directions layer reported "No route found between A and B". The proxy now answers all of these with 502 {ok:false, error:'routing service unavailable'}: a rejected upstream fetch, any failure status but 400, a non-JSON content type, and a body read that fails or does not parse. An exception in the proxy's own code after that point is still a 200 'route proxy error'. createRouteService throws for a 5xx before reading the body's ok:false, so plan_route reports unavailable, and the directions source treats any 5xx as "Routing unavailable (HTTP n)", also when the failure page in front of the proxy is not JSON. OSRM's own refusal (a 400 such as NoRoute, or a 200 without a route) still reads "no route found". The 12 s upstream timeout now starts when the request leaves rather than before it waits for an outbound slot, so a wait in the proxy's own queue (up to about 8 s at the 1 s spacing) is not reported as an outage. Co-Authored-By: Claude Opus 5.5 --- docs/CURRENT-STATE.md | 12 ++- server/providers/places/routes.js | 72 +++++++++++++---- src/layers/directions/source.js | 12 ++- src/layers/directions/source.test.mjs | 12 +++ src/tooling/placeProviders.test.mjs | 108 +++++++++++++++++++++++--- src/tools/places.js | 5 +- src/tools/queries/places.test.mjs | 36 +++++++++ 7 files changed, 223 insertions(+), 34 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 8a8a5aa1d..178369f66 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4375,7 +4375,15 @@ service's usage policy states; past a bounded queue the answer is a 429 with a `Retry-After` rather than a queue that grows until everything times out. The upstream host is pinned and a redirect is refused rather than followed, and a rate limit from the routing service is reported as one instead of as "no route -found". `src/data/routeSteps.js` turns OSRM maneuver type / modifier / exit / +found". So is an outage: any failure status but 400 (a 5xx, a 403 block, a +404), no answer (a 12 s timeout counted from when the request leaves, not +while it waits for an outbound slot; a network error) or a body that is not +JSON answers +`502 routing service unavailable`, which `plan_route` +reports as `unavailable` and the layer as `Routing unavailable (HTTP 502)`; +only OSRM's own refusal (a 400, or a 200 without a route) reads "no route +found". +`src/data/routeSteps.js` turns OSRM maneuver type / modifier / exit / road name into one plain-English sentence per decision, folding `exit roundabout` steps into the roundabout they leave, and caps one route at 200 maneuvers — a route with more is reported as cut off, in the row summary @@ -5694,7 +5702,7 @@ apply to `public/` assets or built-preview serving. - `/api/google/text-search` keeps the Google key server-side for view-biased Places recovery used by annotation resolution. - `/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/route` proxies bounded OSRM route requests for annotation routes, with profile allowlisting, distance caps, response caps, caching, and sanitized "no route found" errors; a routing-service outage answers 502 instead. - Track endpoints: `/api/vessels/track?mmsi=` (server-accumulated ring buffers; sub-route handled before the rows snapshot), `/api/flights/track?icao24=` (OAuth, 60s cache, sanitized errors, independent OpenSky credit bucket), `/api/military/track?hex=` (400 for an invalid hex, otherwise `503 history_unavailable` with `Retry-After: 1800` and `Cache-Control: no-store`, no upstream request; military tracking uses its locally accumulated trail). - 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/places/routes.js b/server/providers/places/routes.js index 42e24ad32..34e64d20d 100644 --- a/server/providers/places/routes.js +++ b/server/providers/places/routes.js @@ -72,6 +72,17 @@ class UpstreamRateLimitError extends Error { } } +/** + * Thrown when the routing service failed, did not answer (timeout, network + * error, refused redirect) or did not answer with JSON. + */ +class UpstreamUnavailableError extends Error { + constructor() { + super('routing service unavailable'); + this.name = 'UpstreamUnavailableError'; + } +} + /** * Run one outbound request no sooner than the shared gate allows. * @template T @@ -167,18 +178,26 @@ async function fetchRoute({ `${base.replace(/\/$/, '')}/route/v1/${osrmProfile}/${coords}` + `?overview=full&geometries=geojson&alternatives=false&steps=${withSteps ? 'true' : 'false'}`; const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), 12000); + let timer; let osrm; try { - const upstreamRes = await throughUpstreamGate(() => - fetchImpl(upstream, { - signal: controller.signal, - // The endpoint is configured above; a redirect is the one way out of - // it, so it is refused rather than followed. - redirect: 'error', - headers: { 'User-Agent': 'gods-eye-view/dev (local)' }, - }), - ); + const upstreamRes = await throughUpstreamGate(async () => { + // The 12 s budget starts when the request leaves, not while it waits + // for an outbound slot, so a wait in our own queue is never reported + // as the routing service being unavailable. + timer = setTimeout(() => controller.abort(), 12000); + try { + return await fetchImpl(upstream, { + signal: controller.signal, + // The endpoint is configured above; a redirect is the one way out of + // it, so it is refused rather than followed. + redirect: 'error', + headers: { 'User-Agent': 'gods-eye-view/dev (local)' }, + }); + } catch { + throw new UpstreamUnavailableError(); + } + }); if (upstreamRes.status === 429) { await cancelBody(upstreamRes); const retryAfter = Number(upstreamRes.headers.get('retry-after')); @@ -186,20 +205,31 @@ async function fetchRoute({ Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : 5, ); } - if (!upstreamRes.ok) { + // OSRM answers 400 for a request it cannot route (NoRoute, NoSegment); + // any other failure status (a 5xx, a 403 block, a 404) is the service's. + if (upstreamRes.status === 400) { await cancelBody(upstreamRes); return { payload: null, error: 'no route found' }; } + if (!upstreamRes.ok) { + await cancelBody(upstreamRes); + throw new UpstreamUnavailableError(); + } const ctype = upstreamRes.headers.get('content-type') || ''; if (!ctype.includes('json')) { await cancelBody(upstreamRes); - return { payload: null, error: 'no route found' }; + throw new UpstreamUnavailableError(); + } + // A body cut off by the timeout or the network, an oversized one and one + // that is not JSON are all the service's failure; an exception after this + // point is ours and stays a "route proxy error". + try { + osrm = JSON.parse( + await readResponseTextCapped(upstreamRes, ROUTE_MAX_RESPONSE_BYTES), + ); + } catch { + throw new UpstreamUnavailableError(); } - const text = await readResponseTextCapped( - upstreamRes, - ROUTE_MAX_RESPONSE_BYTES, - ); - osrm = JSON.parse(text); } finally { clearTimeout(timer); } @@ -238,6 +268,10 @@ export function installRouteMiddleware( }); res.end(JSON.stringify({ ok: false, error: msg })); }; + const unavailable = (msg) => { + res.writeHead(502, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: false, error: msg })); + }; try { if (!_routeRateLimiter(clientKey(req))) { rateLimited('rate limited', 5); @@ -350,6 +384,10 @@ export function installRouteMiddleware( upstreamError.retryAfterSec, ); } + if (upstreamError instanceof UpstreamUnavailableError) { + // Same reason: an outage says nothing about whether a route exists. + return unavailable(upstreamError.message); + } throw upstreamError; } if (error || !payload) return fail(error || 'no route found'); diff --git a/src/layers/directions/source.js b/src/layers/directions/source.js index 2766f83d4..52ffc0888 100644 --- a/src/layers/directions/source.js +++ b/src/layers/directions/source.js @@ -21,7 +21,13 @@ export function createDirectionsSource({ signal, headers: { Accept: 'application/json' }, }); - const payload = await response.json(); + // A failure page in front of the proxy need not be JSON. + let payload = null; + try { + payload = await response.json(); + } catch (error) { + if (response.ok) throw error; + } signal?.throwIfAborted(); // Failures keep their English message and carry the HTTP status and the // server's refusal text, so the layer can show them in the language. @@ -39,7 +45,9 @@ export function createDirectionsSource({ { status: 429, refusal }, ); } - if (!response.ok && !payload?.error) + // A 5xx is a routing outage whatever its body says; only the proxy's + // refusals carry an error text worth showing. + if (response.status >= 500 || (!response.ok && !payload?.error)) throw Object.assign( new Error(`Routing unavailable (HTTP ${response.status})`), { status: response.status }, diff --git a/src/layers/directions/source.test.mjs b/src/layers/directions/source.test.mjs index 824b1221b..467b8ee52 100644 --- a/src/layers/directions/source.test.mjs +++ b/src/layers/directions/source.test.mjs @@ -41,3 +41,15 @@ test('route failures are surfaced without fallback requests', async () => { assert.equal(calls, 1); } }); + +test('a routing outage stays an outage when its body carries an error', async () => { + const source = createDirectionsSource({ fetchImpl: async () => + Response.json({ ok: false, error: 'routing service unavailable' }, { status: 502 }) }); + await assert.rejects(source.getRoute(query), { status: 502, message: 'Routing unavailable (HTTP 502)' }); +}); + +test('a routing outage page that is not JSON still reads as an outage', async () => { + const source = createDirectionsSource({ fetchImpl: async () => + new Response('Bad Gateway', { status: 502, headers: { 'content-type': 'text/html' } }) }); + await assert.rejects(source.getRoute(query), { status: 502, message: 'Routing unavailable (HTTP 502)' }); +}); diff --git a/src/tooling/placeProviders.test.mjs b/src/tooling/placeProviders.test.mjs index d533b7fb9..1540d455b 100644 --- a/src/tooling/placeProviders.test.mjs +++ b/src/tooling/placeProviders.test.mjs @@ -236,23 +236,107 @@ test('OSRM routing preserves aliases, cache, span guards and upstream failure be assert.equal((await request(route, url)).body.error, error); assert.equal(calls, 1); now += 600001; - t.mock.method( - globalThis, - 'fetch', - async () => new Response('offline', { status: 503 }), + // OSRM's own "no route" is a 400 with a JSON code, and stays one. + t.mock.method(globalThis, 'fetch', async () => + Response.json( + { code: 'NoRoute', message: 'Impossible route between points' }, + { status: 400 }, + ), ); - assert.deepEqual((await request(route, query)).body, { - ok: false, - error: 'no route found', - }); - t.mock.method( - globalThis, - 'fetch', - async () => new Response('x', { headers: { 'content-type': 'text/html' } }), + const noRoute = await request(route, query); + assert.equal(noRoute.statusCode, 200); + assert.deepEqual(noRoute.body, { ok: false, error: 'no route found' }); + t.mock.method(globalThis, 'fetch', async () => + Response.json({ code: 'NoRoute', routes: [] }), ); assert.equal((await request(route, query)).body.error, 'no route found'); }); +test('a routing outage is reported as one, not as "no route found"', async (t) => { + const route = '/api/route'; + const query = '?profile=car&coords=-97,30;-97.01,30.01'; + for (const response of [ + () => new Response('offline', { status: 503 }), + () => new Response('blocked', { status: 403 }), + () => new Response('x', { headers: { 'content-type': 'text/html' } }), + () => + new Response('', { + headers: { 'content-type': 'application/json' }, + }), + () => Promise.reject(new TypeError('fetch failed')), + () => Promise.reject(new DOMException('aborted', 'AbortError')), + ]) { + _resetRouteUpstreamForTest(); + const request = install(installRouteMiddleware); + t.mock.method(globalThis, 'fetch', async () => response()); + const result = await request(route, query); + assert.equal(result.statusCode, 502); + assert.deepEqual(result.body, { + ok: false, + error: 'routing service unavailable', + }); + } +}); + +test('callers sharing one failed upstream call all hear the outage, and it is not cached', async (t) => { + _resetRouteUpstreamForTest(); + _setRouteUpstreamIntervalForTest(0); + t.after(() => _resetRouteUpstreamForTest()); + const request = install(installRouteMiddleware); + let calls = 0; + let release; + const gate = new Promise((resolve) => { + release = resolve; + }); + t.mock.method(globalThis, 'fetch', async () => { + calls += 1; + await gate; + return new Response('offline', { status: 503 }); + }); + const route = '/api/route'; + const query = '?profile=car&coords=-97,30;-97.01,30.01'; + const all = [ + request(route, `${query}&steps=1`), + request(route, `${query}&steps=1`), + request(route, query), + ]; + await Promise.resolve(); + release(); + for (const result of await Promise.all(all)) { + assert.equal(result.statusCode, 502); + assert.equal(result.body.error, 'routing service unavailable'); + } + assert.equal(calls, 1); + assert.equal((await request(route, query)).statusCode, 502); + assert.equal(calls, 2, 'the next request asks the routing service again'); +}); + +test('a wait for an outbound slot does not count toward the upstream timeout', async (t) => { + _resetRouteUpstreamForTest(); + // The second request waits 13 s for its slot, longer than the 12 s budget. + _setRouteUpstreamIntervalForTest(13_000); + t.after(() => _resetRouteUpstreamForTest()); + t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now: Date.now() }); + const request = install(installRouteMiddleware); + t.mock.method(globalThis, 'fetch', async (_url, { signal }) => { + signal.throwIfAborted(); + return Response.json(osrmRouteWithSteps(2)); + }); + const first = request( + '/api/route', + '?profile=car&coords=-97,30;-97.01,30.01', + ); + const second = request( + '/api/route', + '?profile=foot&coords=-97,30;-97.01,30.01', + ); + assert.equal((await first).body.ok, true); + t.mock.timers.tick(13_000); + const result = await second; + assert.equal(result.statusCode, 200); + assert.equal(result.body.ok, true); +}); + /** One OSRM route with `count` maneuvers, for the step tests. */ function osrmRouteWithSteps(count) { return { diff --git a/src/tools/places.js b/src/tools/places.js index 4a715f389..8d67ec5d1 100644 --- a/src/tools/places.js +++ b/src/tools/places.js @@ -189,7 +189,10 @@ export function createRouteService({ } catch { /* the status below is authoritative */ } - if (response.status === 429) throw httpFailure(response, 'Routing'); + // A rate limit or an outage (5xx) is the routing service's failure, not + // a missing route, whatever the body says. + if (response.status === 429 || response.status >= 500) + throw httpFailure(response, 'Routing'); if (payload?.ok === false || response.status === 400) return { ok: false, error: payload?.error || 'no route found' }; if (!response.ok) throw httpFailure(response, 'Routing'); diff --git a/src/tools/queries/places.test.mjs b/src/tools/queries/places.test.mjs index 89cad9ba8..7da838b45 100644 --- a/src/tools/queries/places.test.mjs +++ b/src/tools/queries/places.test.mjs @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { composeCatalog, coreTools } from '../index.js'; +import { createMcpServer } from '../mcp/index.js'; import { createPlaceSearchService, createRouteService } from '../places.js'; const place = (id, latitude, longitude, extra = {}) => ({ @@ -249,6 +250,41 @@ test('the place and route services read the app routes and translate failures', ); }); +test('a routing outage is reported as unavailable, not as invalid arguments', async () => { + for (const fetchImpl of [ + async () => + Response.json( + { ok: false, error: 'routing service unavailable' }, + { status: 502 }, + ), + async () => { + throw new TypeError('fetch failed'); + }, + ]) { + const catalog = composeCatalog({ + tools: coreTools, + services: { routing: createRouteService({ fetchImpl }), places }, + }); + const server = createMcpServer({ + catalog, + name: 'test', + version: '1.0.0', + }); + const { result } = await server.handle({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: 'plan_route', + arguments: { from: { place: 'Austin' }, to: { place: 'Dallas' } }, + }, + }); + assert.equal(result.isError, true); + assert.equal(result.structuredContent.error, 'unavailable'); + assert.doesNotMatch(result.content[0].text, /No walk route/); + } +}); + test('place answers say when the search limits may have left places out', async () => { const five = Array.from({ length: 5 }, (_, index) => place(`p${index}`, 30.27, -97.74), From 536f371ddee7520b07dd6a4043dfcce0c9fdf240 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Mon, 2 Nov 2026 08:52:35 -0800 Subject: [PATCH 02/55] docs(changelog): credit #1509 in the fragment Co-Authored-By: Claude Opus 5.5 --- changelog.d/1509-route-outage-unavailable.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelog.d/1509-route-outage-unavailable.md diff --git a/changelog.d/1509-route-outage-unavailable.md b/changelog.d/1509-route-outage-unavailable.md new file mode 100644 index 000000000..3f78f9d7c --- /dev/null +++ b/changelog.d/1509-route-outage-unavailable.md @@ -0,0 +1,4 @@ +- A routing service outage (an error status, no answer or a non-JSON body) + is reported as "routing service unavailable" instead of "no route found", + so voice route planning and the Directions layer no longer blame the + request (quanticsoul4772, #1509). From 1bff1961b4afc773b75908076aeedc7c77fa90ea Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Mon, 2 Nov 2026 09:21:08 -0800 Subject: [PATCH 03/55] fix(routes): keep the 12 s budget from arrival and name the outage to voice Starting the upstream timeout after the outbound queue let a request run up to about 19 s, past the Directions layer's 15 s; the budget again runs from arrival, queue wait included, and a budget spent before the request leaves the queue answers 429 "routing busy" rather than an outage. plan_route threw "Routing HTTP 502" for an outage, and voice passes only the message on; it now throws "Routing service unavailable (HTTP 502)". Co-Authored-By: Claude Opus 5.5 --- changelog.d/1509-route-outage-unavailable.md | 8 ++++---- docs/CURRENT-STATE.md | 16 +++++++++------- server/providers/places/routes.js | 12 +++++++----- src/tooling/placeProviders.test.mjs | 14 ++++++++------ src/tools/places.js | 10 +++++++--- src/tools/queries/places.test.mjs | 18 ++++++++++++++++++ 6 files changed, 53 insertions(+), 25 deletions(-) diff --git a/changelog.d/1509-route-outage-unavailable.md b/changelog.d/1509-route-outage-unavailable.md index 3f78f9d7c..89b573c4d 100644 --- a/changelog.d/1509-route-outage-unavailable.md +++ b/changelog.d/1509-route-outage-unavailable.md @@ -1,4 +1,4 @@ -- A routing service outage (an error status, no answer or a non-JSON body) - is reported as "routing service unavailable" instead of "no route found", - so voice route planning and the Directions layer no longer blame the - request (quanticsoul4772, #1509). +- When the routing service is down (an error status, no answer or a non-JSON + body), voice route planning hears "Routing service unavailable" and the + Directions layer shows "Routing unavailable" instead of "no route found", + so neither blames the request (quanticsoul4772, #1509). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 178369f66..1b00542fb 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4376,13 +4376,15 @@ service's usage policy states; past a bounded queue the answer is a 429 with a upstream host is pinned and a redirect is refused rather than followed, and a rate limit from the routing service is reported as one instead of as "no route found". So is an outage: any failure status but 400 (a 5xx, a 403 block, a -404), no answer (a 12 s timeout counted from when the request leaves, not -while it waits for an outbound slot; a network error) or a body that is not -JSON answers -`502 routing service unavailable`, which `plan_route` -reports as `unavailable` and the layer as `Routing unavailable (HTTP 502)`; -only OSRM's own refusal (a 400, or a 200 without a route) reads "no route -found". +404), no answer (timeout, network error) or a body that is not JSON answers +`502 routing service unavailable`. `plan_route` throws `Routing service +unavailable (HTTP 502)`, which is what voice hears, and the layer shows +`Routing unavailable (HTTP 502)`; only OSRM's own refusal (a 400, or a 200 +without a route) reads "no route found". The 12 s timeout runs from the +request's arrival, queue wait included; a budget spent before the request +leaves the queue answers 429 "routing busy". A request that waited in the +queue gives OSRM the rest of the 12 s, so a slow answer then still reads as +unavailable. `src/data/routeSteps.js` turns OSRM maneuver type / modifier / exit / road name into one plain-English sentence per decision, folding `exit roundabout` steps into the roundabout they leave, and caps one route at diff --git a/server/providers/places/routes.js b/server/providers/places/routes.js index 34e64d20d..5f4c41e3a 100644 --- a/server/providers/places/routes.js +++ b/server/providers/places/routes.js @@ -178,14 +178,16 @@ async function fetchRoute({ `${base.replace(/\/$/, '')}/route/v1/${osrmProfile}/${coords}` + `?overview=full&geometries=geojson&alternatives=false&steps=${withSteps ? 'true' : 'false'}`; const controller = new AbortController(); - let timer; + // One 12 s budget from arrival, queue wait included, so callers sized to it + // (the Directions layer gives up at 15 s) hear back in time. + const timer = setTimeout(() => controller.abort(), 12000); let osrm; try { const upstreamRes = await throughUpstreamGate(async () => { - // The 12 s budget starts when the request leaves, not while it waits - // for an outbound slot, so a wait in our own queue is never reported - // as the routing service being unavailable. - timer = setTimeout(() => controller.abort(), 12000); + // A budget spent waiting in our own queue is our backlog, not the + // routing service failing. + if (controller.signal.aborted) + throw new RouteBusyError(_upstreamIntervalMs); try { return await fetchImpl(upstream, { signal: controller.signal, diff --git a/src/tooling/placeProviders.test.mjs b/src/tooling/placeProviders.test.mjs index 1540d455b..7dd45fae3 100644 --- a/src/tooling/placeProviders.test.mjs +++ b/src/tooling/placeProviders.test.mjs @@ -311,15 +311,16 @@ test('callers sharing one failed upstream call all hear the outage, and it is no assert.equal(calls, 2, 'the next request asks the routing service again'); }); -test('a wait for an outbound slot does not count toward the upstream timeout', async (t) => { +test('a budget spent in the outbound queue is reported as busy, not as an outage', async (t) => { _resetRouteUpstreamForTest(); - // The second request waits 13 s for its slot, longer than the 12 s budget. + // The second request waits 13 s for its slot, past the 12 s budget. _setRouteUpstreamIntervalForTest(13_000); t.after(() => _resetRouteUpstreamForTest()); t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now: Date.now() }); const request = install(installRouteMiddleware); - t.mock.method(globalThis, 'fetch', async (_url, { signal }) => { - signal.throwIfAborted(); + let calls = 0; + t.mock.method(globalThis, 'fetch', async () => { + calls += 1; return Response.json(osrmRouteWithSteps(2)); }); const first = request( @@ -333,8 +334,9 @@ test('a wait for an outbound slot does not count toward the upstream timeout', a assert.equal((await first).body.ok, true); t.mock.timers.tick(13_000); const result = await second; - assert.equal(result.statusCode, 200); - assert.equal(result.body.ok, true); + assert.equal(result.statusCode, 429); + assert.match(result.body.error, /routing busy/); + assert.equal(calls, 1, 'the spent request never reaches the routing service'); }); /** One OSRM route with `count` maneuvers, for the step tests. */ diff --git a/src/tools/places.js b/src/tools/places.js index 8d67ec5d1..3ecef4855 100644 --- a/src/tools/places.js +++ b/src/tools/places.js @@ -190,9 +190,13 @@ export function createRouteService({ /* the status below is authoritative */ } // A rate limit or an outage (5xx) is the routing service's failure, not - // a missing route, whatever the body says. - if (response.status === 429 || response.status >= 500) - throw httpFailure(response, 'Routing'); + // a missing route, whatever the body says. Voice hears this message. + if (response.status === 429) throw httpFailure(response, 'Routing'); + if (response.status >= 500) + throw new ToolError( + 'unavailable', + `Routing service unavailable (HTTP ${response.status})`, + ); if (payload?.ok === false || response.status === 400) return { ok: false, error: payload?.error || 'no route found' }; if (!response.ok) throw httpFailure(response, 'Routing'); diff --git a/src/tools/queries/places.test.mjs b/src/tools/queries/places.test.mjs index 7da838b45..4e795e3f1 100644 --- a/src/tools/queries/places.test.mjs +++ b/src/tools/queries/places.test.mjs @@ -283,6 +283,24 @@ test('a routing outage is reported as unavailable, not as invalid arguments', as assert.equal(result.structuredContent.error, 'unavailable'); assert.doesNotMatch(result.content[0].text, /No walk route/); } + // Voice passes only the message on, so it names the outage itself. + const outage = createRouteService({ + fetchImpl: async () => + Response.json( + { ok: false, error: 'routing service unavailable' }, + { status: 502 }, + ), + }); + await assert.rejects( + outage.route( + [ + { lat: 30, lon: -97 }, + { lat: 30.01, lon: -97.01 }, + ], + 'foot', + ), + { message: 'Routing service unavailable (HTTP 502)' }, + ); }); test('place answers say when the search limits may have left places out', async () => { From 9d27366b3ec42eccf96ccc323dab13579426d7f5 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Thu, 29 Oct 2026 07:06:57 -0700 Subject: [PATCH 04/55] fix(fires): show a 47.6-hour-old detection as 2d, not 48h formatAge and formatAgeLabel picked the hour unit on the raw value and rounded afterwards, so a detection between 47.5 and 48 hours old read 48h, while one exactly 48 hours old read 2d. Round to whole hours first, then choose the unit. Co-Authored-By: Claude Opus 5.5 --- docs/CURRENT-STATE.md | 1 + src/layers/firms/model.js | 8 +++++--- src/layers/firms/model.test.mjs | 24 ++++++++++++++++++++++++ 3 files changed, 30 insertions(+), 3 deletions(-) create mode 100644 src/layers/firms/model.test.mjs diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 87b918a4e..71aa36964 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2221,6 +2221,7 @@ accessible name remains "Don't show this again". Control behavior is unchanged. ## FIRMS source status The FIRMS CSV parser rejects impossible acquisition calendar dates before the proxy filters records to the trailing 24 hours, while retaining valid UTC acquisition times. +FIRMS ages round to whole hours before choosing a unit, so a 47.6-hour age reads "2d", not "48h". The `get_active_fires` tool reports acquisition times as UTC ISO timestamps, including zero-padded hours and minutes; missing or invalid dates and times remain `null`, including impossible calendar dates from a custom source. diff --git a/src/layers/firms/model.js b/src/layers/firms/model.js index 9e3cf7f95..7dcf255c8 100644 --- a/src/layers/firms/model.js +++ b/src/layers/firms/model.js @@ -526,7 +526,8 @@ export function createModel({ if (!Number.isFinite(deltaMs) || deltaMs < 0) return ''; const hours = deltaMs / 3600000; if (hours < 1) return '<1h'; - if (hours < 48) return `${Math.round(hours)}h`; + const wholeHours = Math.round(hours); + if (wholeHours < 48) return `${wholeHours}h`; return `${Math.round(hours / 24)}d`; } @@ -561,8 +562,9 @@ export function createModel({ if (!Number.isFinite(deltaMs) || deltaMs < 0) return ''; const hours = deltaMs / 3600000; if (hours < 1) return t('<1h', { context: 'age' }); - if (hours < 48) - return t('{count}h', { context: 'age', count: Math.round(hours) }); + const wholeHours = Math.round(hours); + if (wholeHours < 48) + return t('{count}h', { context: 'age', count: wholeHours }); return t('{count}d', { context: 'age', count: Math.round(hours / 24) }); } diff --git a/src/layers/firms/model.test.mjs b/src/layers/firms/model.test.mjs new file mode 100644 index 000000000..7ff12cf3f --- /dev/null +++ b/src/layers/firms/model.test.mjs @@ -0,0 +1,24 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { getLocale, setLocale } from '../../i18n/index.js'; +import { createModel } from './model.js'; + +const HOUR_MS = 3600000; + +test('a fire age that rounds to 48 hours reads in days, as 48 hours does', () => { + const previousLocale = getLocale(); + const { formatAge, formatAgeLabel } = createModel({ + services: { anchors: {} }, + }); + try { + setLocale('en'); + assert.equal(formatAge(47.4 * HOUR_MS), '47h'); + assert.equal(formatAge(47.6 * HOUR_MS), '2d'); + assert.equal(formatAge(48 * HOUR_MS), '2d'); + assert.equal(formatAgeLabel(47.4 * HOUR_MS), '47h'); + assert.equal(formatAgeLabel(47.6 * HOUR_MS), formatAgeLabel(48 * HOUR_MS)); + assert.equal(formatAgeLabel(48 * HOUR_MS), '2d'); + } finally { + setLocale(previousLocale); + } +}); From a4bd028bd2626d71d7c237f8bc675b36de5c3f3f Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Tue, 3 Nov 2026 11:47:41 -0800 Subject: [PATCH 05/55] changelog: FIRMS age rounding (#1531) Co-Authored-By: Claude Opus 5.5 --- changelog.d/1531-firms-age-rounding.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1531-firms-age-rounding.md diff --git a/changelog.d/1531-firms-age-rounding.md b/changelog.d/1531-firms-age-rounding.md new file mode 100644 index 000000000..e8cc12c4b --- /dev/null +++ b/changelog.d/1531-firms-age-rounding.md @@ -0,0 +1,3 @@ +- FIRMS fire ages round to whole hours before choosing a unit, so a detection + 47.5–48 hours old reads "2d", as one exactly 48 hours old does, not "48h" + (@quanticsoul4772, #1531). From ceff3eb6c97e7995938488417a66ed3e1b5af0ac Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Thu, 5 Nov 2026 05:43:40 -0800 Subject: [PATCH 06/55] test(fires): pin the 47.5-hour flip to days and name where the age shows Co-Authored-By: Claude Opus 5.5 --- changelog.d/1531-firms-age-rounding.md | 8 +++++--- docs/CURRENT-STATE.md | 2 +- src/layers/firms/model.test.mjs | 2 ++ 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/changelog.d/1531-firms-age-rounding.md b/changelog.d/1531-firms-age-rounding.md index e8cc12c4b..cd9bcfc5d 100644 --- a/changelog.d/1531-firms-age-rounding.md +++ b/changelog.d/1531-firms-age-rounding.md @@ -1,3 +1,5 @@ -- FIRMS fire ages round to whole hours before choosing a unit, so a detection - 47.5–48 hours old reads "2d", as one exactly 48 hours old does, not "48h" - (@quanticsoul4772, #1531). +- FIRMS fire ages round to whole hours before choosing a unit, so an age of + 47.5–48 hours reads "2d", as exactly 48 hours does, not "48h". Detections + are filtered to the trailing 24 hours, so this shows mostly in the + `STALE · cached {age}` stats line after about two days without a successful + refresh (@quanticsoul4772, #1531). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 726201138..1e1230833 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2382,7 +2382,7 @@ reporting are unchanged. ## FIRMS source status The FIRMS CSV parser rejects impossible acquisition calendar dates before the proxy filters records to the trailing 24 hours, while retaining valid UTC acquisition times. -FIRMS ages round to whole hours before choosing a unit, so a 47.6-hour age reads "2d", not "48h". +FIRMS ages round to whole hours before choosing a unit, so a 47.6-hour age reads "2d", not "48h"; with detections filtered to 24 hours, this shows mostly in the `STALE · cached {age}` stats line. The `get_active_fires` tool reports acquisition times as UTC ISO timestamps, including zero-padded hours and minutes; missing or invalid dates and times remain `null`, including impossible calendar dates from a custom source. diff --git a/src/layers/firms/model.test.mjs b/src/layers/firms/model.test.mjs index 7ff12cf3f..a0df2eb2c 100644 --- a/src/layers/firms/model.test.mjs +++ b/src/layers/firms/model.test.mjs @@ -13,9 +13,11 @@ test('a fire age that rounds to 48 hours reads in days, as 48 hours does', () => try { setLocale('en'); assert.equal(formatAge(47.4 * HOUR_MS), '47h'); + assert.equal(formatAge(47.5 * HOUR_MS), '2d'); assert.equal(formatAge(47.6 * HOUR_MS), '2d'); assert.equal(formatAge(48 * HOUR_MS), '2d'); assert.equal(formatAgeLabel(47.4 * HOUR_MS), '47h'); + assert.equal(formatAgeLabel(47.5 * HOUR_MS), '2d'); assert.equal(formatAgeLabel(47.6 * HOUR_MS), formatAgeLabel(48 * HOUR_MS)); assert.equal(formatAgeLabel(48 * HOUR_MS), '2d'); } finally { From fc6944951b4bb53d64965a0d1930779b4e57e45b Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Mon, 9 Nov 2026 05:35:23 -0800 Subject: [PATCH 07/55] refactor(fires): read both age formatters' unit from one helper formatAge and formatAgeLabel repeated the validity guard, the <1h case, the rounding and the 48-hour switch, which is why the rounding fix had to land twice. Both now read { unit, count } from a private ageParts, so they cannot pick different units again. Output is unchanged. Co-Authored-By: Claude Opus 5.5 --- src/layers/firms/model.js | 36 +++++++++++++++++++++------------ src/layers/firms/model.test.mjs | 19 +++++++++++++++++ 2 files changed, 42 insertions(+), 13 deletions(-) diff --git a/src/layers/firms/model.js b/src/layers/firms/model.js index 4f12593f2..00873c2b2 100644 --- a/src/layers/firms/model.js +++ b/src/layers/firms/model.js @@ -535,15 +535,26 @@ export function createModel({ return `${isolate(latPart)} ${isolate(lonPart)}`; } + /** + * Millisecond delta → `{ unit: '<1h' | 'h' | 'd', count }`, or null for + * invalid input. Both age formatters read it, so they pick the same unit. + */ + + function ageParts(deltaMs) { + if (!Number.isFinite(deltaMs) || deltaMs < 0) return null; + const hours = deltaMs / 3600000; + if (hours < 1) return { unit: '<1h', count: 0 }; + const wholeHours = Math.round(hours); + if (wholeHours < 48) return { unit: 'h', count: wholeHours }; + return { unit: 'd', count: Math.round(hours / 24) }; + } + /** Millisecond delta → "<1h" / "Xh" / "Xd", or '' for invalid input. */ function formatAge(deltaMs) { - if (!Number.isFinite(deltaMs) || deltaMs < 0) return ''; - const hours = deltaMs / 3600000; - if (hours < 1) return '<1h'; - const wholeHours = Math.round(hours); - if (wholeHours < 48) return `${wholeHours}h`; - return `${Math.round(hours / 24)}d`; + const age = ageParts(deltaMs); + if (!age) return ''; + return age.unit === '<1h' ? '<1h' : `${age.count}${age.unit}`; } /** Millisecond delta → "<1m ago" / "Xm ago" / "Xh ago" (fresh-feed readout). */ @@ -574,13 +585,12 @@ export function createModel({ /** formatAge for a card: "<1h", "24h", "3d" in the UI language ("24 ч"). */ function formatAgeLabel(deltaMs) { - if (!Number.isFinite(deltaMs) || deltaMs < 0) return ''; - const hours = deltaMs / 3600000; - if (hours < 1) return t('<1h', { context: 'age' }); - const wholeHours = Math.round(hours); - if (wholeHours < 48) - return t('{count}h', { context: 'age', count: wholeHours }); - return t('{count}d', { context: 'age', count: Math.round(hours / 24) }); + const age = ageParts(deltaMs); + if (!age) return ''; + if (age.unit === '<1h') return t('<1h', { context: 'age' }); + return age.unit === 'h' + ? t('{count}h', { context: 'age', count: age.count }) + : t('{count}d', { context: 'age', count: age.count }); } /** Normalized 0..1 confidence → low/nominal/high display bucket. */ diff --git a/src/layers/firms/model.test.mjs b/src/layers/firms/model.test.mjs index a0df2eb2c..2c42227f8 100644 --- a/src/layers/firms/model.test.mjs +++ b/src/layers/firms/model.test.mjs @@ -24,3 +24,22 @@ test('a fire age that rounds to 48 hours reads in days, as 48 hours does', () => setLocale(previousLocale); } }); + +test('both fire age formatters agree on invalid, sub-hour and multi-day ages', () => { + const previousLocale = getLocale(); + const { formatAge, formatAgeLabel } = createModel({ + services: { anchors: {} }, + }); + try { + setLocale('en'); + for (const format of [formatAge, formatAgeLabel]) { + assert.equal(format(-1), ''); + assert.equal(format(Number.NaN), ''); + assert.equal(format(0.5 * HOUR_MS), '<1h'); + assert.equal(format(1 * HOUR_MS), '1h'); + assert.equal(format(72 * HOUR_MS), '3d'); + } + } finally { + setLocale(previousLocale); + } +}); From fdd153b73dc4a8c419a64c1046ff0e65812eb57c Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Mon, 9 Nov 2026 08:38:03 -0800 Subject: [PATCH 08/55] ci: re-run checks after a failed Chrome download on the runner Co-Authored-By: Claude Opus 5.5 From 830c08080383a8a89e9b91e692fec92f2c57ceb2 Mon Sep 17 00:00:00 2001 From: Denis Groshev Date: Wed, 11 Nov 2026 14:11:10 +0100 Subject: [PATCH 09/55] feat(rail): add a Rail layer with keyless live trains Live trains from 30 keyless operator feeds: GPS positions from 29 and Irish Rail's station reports, shown as the operators publish them. 28 work out of the box; Renfe long and medium distance and KTMB appear once their pack is built, because their vehicles name no route. Packs are opt-in (scripts/build-rail-paths.mjs): the operator's own route lines, stop names, next stops and trip-update delays for live trains. Nothing downloads when the app starts, no train is placed from a timetable, and no request carries a deployer's key. --- .env.example | 8 + .gitignore | 2 + DATA_SOURCES.md | 34 +- README.md | 1 + SECURITY.md | 1 + changelog.d/0-rail-layer.md | 24 + docs/CURRENT-STATE.md | 276 +- package.json | 5 + scripts/build-rail-paths.mjs | 2356 ++++++++++++++++++ scripts/format-scope.json | 39 + scripts/package-boundaries.json | 99 + server/providers/local.js | 2 + server/providers/rail.js | 285 +++ src/app/constructCatalog.js | 2 + src/app/constructCatalog.test.mjs | 4 +- src/app/layers/rail.js | 50 + src/app/sourceComposition.js | 1 + src/app/sourceComposition.test.mjs | 17 +- src/data/dataCredits.js | 18 + src/data/dataCredits.test.mjs | 9 + src/data/irishRail.js | 257 ++ src/data/irishRail.test.mjs | 193 ++ src/data/layerState.js | 13 + src/data/layerState.test.mjs | 12 +- src/data/layerStateTokenLedger.test.mjs | 30 +- src/data/layerStateTokenReservations.json | 1 + src/data/railFeeds.js | 1632 ++++++++++++ src/data/railFeeds.test.mjs | 474 ++++ src/data/railFeedsAsia.test.mjs | 63 + src/data/railFeedsDocs.test.mjs | 97 + src/data/railFeedsFrance.test.mjs | 86 + src/data/railFeedsNational.test.mjs | 103 + src/data/railFeedsUs.test.mjs | 46 + src/data/railFeedsUsNoTerms.test.mjs | 62 + src/data/railFeedsUsUrban.test.mjs | 57 + src/data/railIreland.test.mjs | 134 + src/data/railNoPositions.js | 1092 ++++++++ src/data/railNoPositions.test.mjs | 297 +++ src/data/railPaths.js | 288 +++ src/data/railPaths.test.mjs | 163 ++ src/data/railProxy.js | 518 ++++ src/data/railProxy.test.mjs | 586 +++++ src/data/railSchedule.js | 741 ++++++ src/data/railSchedule.test.mjs | 444 ++++ src/data/railScheduleIndex.js | 124 + src/data/railSiriVm.js | 188 ++ src/data/railSiriVm.test.mjs | 182 ++ src/data/railTimeZones.js | 174 ++ src/data/railTripUpdates.js | 278 +++ src/data/railTripUpdates.test.mjs | 262 ++ src/data/spriteOrder.js | 1 + src/i18n/locales/ar.json | 15 + src/i18n/locales/de.json | 15 + src/i18n/locales/es.json | 15 + src/i18n/locales/fr.json | 15 + src/i18n/locales/he.json | 15 + src/i18n/locales/it.json | 15 + src/i18n/locales/ja.json | 15 + src/i18n/locales/pt.json | 15 + src/i18n/locales/ru.json | 15 + src/i18n/locales/sv.json | 15 + src/i18n/locales/uk.json | 15 + src/i18n/locales/zh-CN.json | 15 + src/i18n/locales/zh-Hant.json | 15 + src/layers/rail/controls.js | 202 ++ src/layers/rail/dedupe.js | 394 +++ src/layers/rail/dedupe.test.mjs | 364 +++ src/layers/rail/follow.js | 230 ++ src/layers/rail/follow.test.mjs | 454 ++++ src/layers/rail/groundHeight.test.mjs | 499 ++++ src/layers/rail/index.js | 196 ++ src/layers/rail/ingestion.js | 527 ++++ src/layers/rail/layerStats.test.mjs | 101 + src/layers/rail/lifecycle.js | 170 ++ src/layers/rail/liveOnly.test.mjs | 386 +++ src/layers/rail/pathBinding.js | 243 ++ src/layers/rail/pathBinding.test.mjs | 203 ++ src/layers/rail/policy.js | 577 +++++ src/layers/rail/policy.test.mjs | 521 ++++ src/layers/rail/rendering.js | 724 ++++++ src/layers/rail/routeDisplay.js | 788 ++++++ src/layers/rail/routeLines.js | 335 +++ src/layers/rail/routeLines.test.mjs | 331 +++ src/layers/rail/schedules.js | 518 ++++ src/layers/rail/schedules.test.mjs | 593 +++++ src/layers/rail/selection.js | 169 ++ src/layers/rail/source.js | 68 + src/layers/rail/source.test.mjs | 205 ++ src/layers/rail/stopLabels.js | 275 ++ src/layers/rail/stopLabels.test.mjs | 275 ++ src/layers/rail/viewport.js | 224 ++ src/navigationPolicy.js | 1 + src/share/viewCodec.js | 7 + src/sources/railService.js | 968 +++++++ src/standalone/layerSources.js | 2 + src/testSupport/railLayerHarness.mjs | 409 +++ src/tooling/buildRailPaths.test.mjs | 682 +++++ src/tooling/buildRailPathsCli.test.mjs | 305 +++ src/tooling/buildRailPathsLocal.test.mjs | 182 ++ src/tooling/buildRailPathsRoutes.test.mjs | 143 ++ src/tooling/buildRailPathsTripEnds.test.mjs | 162 ++ src/tooling/buildRailPathsUnlocated.test.mjs | 199 ++ src/tooling/buildRailPathsWrite.test.mjs | 318 +++ src/tooling/railCatalogPacks.test.mjs | 235 ++ src/tooling/railService.test.mjs | 877 +++++++ src/tooling/railServiceFaults.test.mjs | 214 ++ src/tooling/railServicePacks.test.mjs | 337 +++ src/tooling/transitQa.test.mjs | 11 +- src/ui/layerNames.js | 1 + src/ui/layerPanel.js | 1 + src/ui/layerPanel.test.mjs | 4 +- src/voice/layerManifest.js | 1 + src/worldFocus.js | 3 + src/worldFocus.test.mjs | 5 + 114 files changed, 26639 insertions(+), 26 deletions(-) create mode 100644 changelog.d/0-rail-layer.md create mode 100644 scripts/build-rail-paths.mjs create mode 100644 server/providers/rail.js create mode 100644 src/app/layers/rail.js create mode 100644 src/data/irishRail.js create mode 100644 src/data/irishRail.test.mjs create mode 100644 src/data/railFeeds.js create mode 100644 src/data/railFeeds.test.mjs create mode 100644 src/data/railFeedsAsia.test.mjs create mode 100644 src/data/railFeedsDocs.test.mjs create mode 100644 src/data/railFeedsFrance.test.mjs create mode 100644 src/data/railFeedsNational.test.mjs create mode 100644 src/data/railFeedsUs.test.mjs create mode 100644 src/data/railFeedsUsNoTerms.test.mjs create mode 100644 src/data/railFeedsUsUrban.test.mjs create mode 100644 src/data/railIreland.test.mjs create mode 100644 src/data/railNoPositions.js create mode 100644 src/data/railNoPositions.test.mjs create mode 100644 src/data/railPaths.js create mode 100644 src/data/railPaths.test.mjs create mode 100644 src/data/railProxy.js create mode 100644 src/data/railProxy.test.mjs create mode 100644 src/data/railSchedule.js create mode 100644 src/data/railSchedule.test.mjs create mode 100644 src/data/railScheduleIndex.js create mode 100644 src/data/railSiriVm.js create mode 100644 src/data/railSiriVm.test.mjs create mode 100644 src/data/railTimeZones.js create mode 100644 src/data/railTripUpdates.js create mode 100644 src/data/railTripUpdates.test.mjs create mode 100644 src/layers/rail/controls.js create mode 100644 src/layers/rail/dedupe.js create mode 100644 src/layers/rail/dedupe.test.mjs create mode 100644 src/layers/rail/follow.js create mode 100644 src/layers/rail/follow.test.mjs create mode 100644 src/layers/rail/groundHeight.test.mjs create mode 100644 src/layers/rail/index.js create mode 100644 src/layers/rail/ingestion.js create mode 100644 src/layers/rail/layerStats.test.mjs create mode 100644 src/layers/rail/lifecycle.js create mode 100644 src/layers/rail/liveOnly.test.mjs create mode 100644 src/layers/rail/pathBinding.js create mode 100644 src/layers/rail/pathBinding.test.mjs create mode 100644 src/layers/rail/policy.js create mode 100644 src/layers/rail/policy.test.mjs create mode 100644 src/layers/rail/rendering.js create mode 100644 src/layers/rail/routeDisplay.js create mode 100644 src/layers/rail/routeLines.js create mode 100644 src/layers/rail/routeLines.test.mjs create mode 100644 src/layers/rail/schedules.js create mode 100644 src/layers/rail/schedules.test.mjs create mode 100644 src/layers/rail/selection.js create mode 100644 src/layers/rail/source.js create mode 100644 src/layers/rail/source.test.mjs create mode 100644 src/layers/rail/stopLabels.js create mode 100644 src/layers/rail/stopLabels.test.mjs create mode 100644 src/layers/rail/viewport.js create mode 100644 src/sources/railService.js create mode 100644 src/testSupport/railLayerHarness.mjs create mode 100644 src/tooling/buildRailPaths.test.mjs create mode 100644 src/tooling/buildRailPathsCli.test.mjs create mode 100644 src/tooling/buildRailPathsLocal.test.mjs create mode 100644 src/tooling/buildRailPathsRoutes.test.mjs create mode 100644 src/tooling/buildRailPathsTripEnds.test.mjs create mode 100644 src/tooling/buildRailPathsUnlocated.test.mjs create mode 100644 src/tooling/buildRailPathsWrite.test.mjs create mode 100644 src/tooling/railCatalogPacks.test.mjs create mode 100644 src/tooling/railService.test.mjs create mode 100644 src/tooling/railServiceFaults.test.mjs create mode 100644 src/tooling/railServicePacks.test.mjs diff --git a/.env.example b/.env.example index 573c5fab4..18b7c759e 100644 --- a/.env.example +++ b/.env.example @@ -352,3 +352,11 @@ GEMINI_CHAT_MODEL=gemini-3.5-flash-lite # the free plan). After that Local says the daily limit is reached. # Raise it on a paid plan. # WORLDNEWSAPI_DAILY_READS=30 + +# Rail layer: every feed is keyless, so nothing needs setting. Packs are an +# opt-in extra for live trains (the operator's lines, stop names, next stops): +# scripts/build-rail-paths.mjs (--feed ... or --all) writes path and +# schedule packs, and the server reads them from this directory +# (default .cache/rail-paths). The build reads it from its own environment, +# not from this file: export it, or pass the same folder as --out. +# RAIL_PATHS_DIR= diff --git a/.gitignore b/.gitignore index 11e081e7d..2d63faed0 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,8 @@ qa-shots/ output/ .gev-logs/ .gev-cache/ +# Local build output, e.g. rail path packs (scripts/build-rail-paths.mjs). +.cache/ # Browser QA gate evidence (screenshots, console and network dumps). qa-artifacts/ # Playwright MCP session logs: browser consoles can carry tokens in URLs. diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 9b8d0d6b6..1398e52f4 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -110,6 +110,36 @@ 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 | +| **Metro-North** (api-endpoint.mta.info, rrgtfsfeeds.s3.amazonaws.com) | Rail layer trains, New York, NY: Metro-North Railroad (MTA). Live GPS positions. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. With a built schedule pack, trip updates (api-endpoint.mta.info) add delays at its stops. | [MTA Data Feed Terms and Conditions](https://www.mta.info/developers/terms-and-conditions): "Developers must download and store the MTA data feed on a non-MTA server and redistribute the data to users from that server." This proxy is that server: browsers never contact MTA. The feed stamps every vehicle with the header time, so a fix is aged from the snapshot, not from the train. | "Trains (MTA): [MTA Data Feed Terms and Conditions](https://www.mta.info/developers/terms-and-conditions)" | +| **LIRR** (api-endpoint.mta.info, rrgtfsfeeds.s3.amazonaws.com) | Rail layer trains, New York, NY: Long Island Rail Road (MTA). Live GPS positions. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. With a built schedule pack, trip updates (api-endpoint.mta.info) add delays at its stops. | [MTA Data Feed Terms and Conditions](https://www.mta.info/developers/terms-and-conditions): "Developers must download and store the MTA data feed on a non-MTA server and redistribute the data to users from that server." The feed was seen frozen for two days on 2026-11-06; the layer ages trains by their own report time, so a frozen feed empties instead of showing old trains as live. | "Trains (MTA): [MTA Data Feed Terms and Conditions](https://www.mta.info/developers/terms-and-conditions)" | +| **MBTA rail** (cdn.mbta.com) | Rail layer trains, Boston, MA: Massachusetts Bay Transportation Authority. Live GPS positions. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. With a built schedule pack, trip updates (cdn.mbta.com) add delays at its stops. | [MassDOT Developers License Agreement](https://cdn.mbta.com/sites/default/files/2023-08/mbta-massdot-develop-license-agreement.pdf): "MassDOT ... hereby grants You (Licensee) non-exclusive, limited, and revocable rights to use, reproduce, and redistribute the Data. ... Clearly acknowledge MassDOT as the provider of the Data." The same feed the Transit layer polls; this layer keeps only Commuter Rail, rapid transit and light rail. | "Trains (MBTA / MassDOT): [MassDOT Developers License Agreement](https://cdn.mbta.com/sites/default/files/2023-08/mbta-massdot-develop-license-agreement.pdf)" | +| **SEPTA Regional Rail** (www3.septa.org, github.com) | Rail layer trains, Philadelphia, PA: Southeastern Pennsylvania Transportation Authority. Live GPS positions. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. With a built schedule pack, trip updates (www3.septa.org) add delays at its stops. | [SEPTA Trip Planning Data Agreement](https://www3.septa.org/developer/): "non-exclusive, limited and revocable right to use, reproduce and redistribute the Trip Planning Data". The agreement names the static data; SEPTA publishes the realtime feeds on the same developer page with no separate terms. Credit is text only: SEPTA trademarks may not be used. | "Trains (SEPTA): [SEPTA Trip Planning Data Agreement](https://www3.septa.org/developer/)" | +| **SEPTA trolleys** (www3.septa.org, github.com) | Rail layer trains, Philadelphia, PA: Southeastern Pennsylvania Transportation Authority. Live GPS positions. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. With a built schedule pack, trip updates (www3.septa.org) add delays at its stops. | [SEPTA Trip Planning Data Agreement](https://www3.septa.org/developer/): "non-exclusive, limited and revocable right to use, reproduce and redistribute the Trip Planning Data". The bus feed, filtered to the trolley routes and the Norristown High Speed Line. The Market-Frankford and Broad Street lines are in no SEPTA feed. Routes T1–T5, G1, D1 and D2 (trams) and M1, the Norristown High Speed Line (metro). | "Trains (SEPTA): [SEPTA Trip Planning Data Agreement](https://www3.septa.org/developer/)" | +| **MARC** (mdotmta-gtfs-rt.s3.amazonaws.com, feeds.mta.maryland.gov) | Rail layer trains, Baltimore–Washington: Maryland Transit Administration. Live GPS positions. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. With a built schedule pack, trip updates (mdotmta-gtfs-rt.s3.amazonaws.com) add delays at its stops. | [MDOT MTA developer feeds (no endorsement implied)](https://www.mta.maryland.gov/developer-resources): "MTA does not guarantee the accuracy of the data nor does it warrant or endorse any application that uses this data." Published for open fetch with this disclaimer and no licence; the site terms cover only the website and apps. Shown with attribution and the no-endorsement wording, and switched off if MDOT MTA publishes restrictive terms. | "Trains (Maryland Transit Administration): [MDOT MTA developer feeds (no endorsement implied)](https://www.mta.maryland.gov/developer-resources)" | +| **Tri-Rail** (gtfsr.tri-rail.com, gtfs.tri-rail.com) | Rail layer trains, South Florida: South Florida Regional Transportation Authority. Live GPS positions. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. With a built schedule pack, trip updates (gtfsr.tri-rail.com) add delays at its stops. | [No licence stated (open feed pages)](https://gtfsr.tri-rail.com/): "Download Realtime Feeds Below:". Used without a published licence (project owner decision, 2026-11-10). gtfsr.tri-rail.com (the page quoted above, read 2026-11-10) links the GTFS-RT trip updates, alerts and position updates with no terms, and the static GTFS (gtfs.tri-rail.com/gtfs.zip, publisher ETA Transit Systems) states none either; no SFRTA data terms were found. The realtime vehicles carry the train number as their trip id and no route. | "Trains (South Florida Regional Transportation Authority (Tri-Rail)): [No licence stated (open feed pages)](https://gtfsr.tri-rail.com/)" | +| **RTD** (open-data.rtd-denver.com, www.rtd-denver.com) | Rail layer trains, Denver, CO: Regional Transportation District. Live GPS positions. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. With a built schedule pack, trip updates (open-data.rtd-denver.com) add delays at its stops. | [RTD GTFS-Realtime License Agreement](https://www.rtd-denver.com/business-center/open-spatial-data/gtfs-realtime-license-agreement): "non-exclusive, limited and revocable rights to use, reproduce, and redistribute the Data". RTD marks may not be used with the data, so the credit is text only. The published `www` URL redirects off its own origin, which the proxy refuses, so the final host is the one registered. | "Trains (RTD): [RTD GTFS-Realtime License Agreement](https://www.rtd-denver.com/business-center/open-spatial-data/gtfs-realtime-license-agreement)" | +| **CapMetro MetroRail** (data.texas.gov) | Rail layer trains, Austin, TX: Capital Metropolitan Transportation Authority. Live GPS positions. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. | [CapMetro Open Data Terms](https://www.capmetro.org/metrolabs/): "non-exclusive, limited and revocable rights to use, reproduce, and redistribute CMTA Data". The bus feed the Transit layer polls; MetroRail is route 550. | "Trains (CapMetro): [CapMetro Open Data Terms](https://www.capmetro.org/metrolabs/)" | +| **METRO light rail** (svc.metrotransit.org) | Rail layer trains, Minneapolis–St Paul, MN: Metro Transit (Metropolitan Council). Live GPS positions. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. | [No licence published](https://svc.metrotransit.org/). Publishes no licence or terms: the data feeds page linked above (read 2026-11-10) lists the GTFS and GTFS-realtime files and shows only "© 2020 Metro Transit", so there is nothing to quote. Used without a published licence, on the same basis as the Transit layer's Metro Transit feed, which polls the same file (DATA_SOURCES.md). Blue Line 901 and Green Line 902 only; Northstar ended on 2026-01-04 and route 888 is now a bus. | "Trains (Metro Transit / Metropolitan Council): [No licence published](https://svc.metrotransit.org/)" | +| **LYNX** (gtfsrealtime.ridetransit.org) | Rail layer trains, Charlotte, NC: Charlotte Area Transit System. Live GPS positions. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. With a built schedule pack, trip updates (gtfsrealtime.ridetransit.org) add delays at its stops. | [No licence published](https://www.charlottenc.gov/cats). Publishes no terms; used without a published licence (project owner decision, 2026-11-10). The feed host (gtfsrealtime.ridetransit.org) serves the files with no page and no terms, so there is nothing to quote (2026-11-10: 126 vehicles, 13 of them trains; 205 trip updates, 11 on the two lines; every realtime trip id is a static trip id). No CATS page with terms for them was found: charlottenc.gov answered 403 to automated requests, and the CATS Terms and Conditions found there by search govern the CATS mobile app. Routes 501 LYNX Blue Line (light rail) and 510 CityLYNX Gold Line (streetcar), both route_type 0: transit, tram. | "Trains (Charlotte Area Transit System): [No licence published](https://www.charlottenc.gov/cats)" | +| **GRT ION** (webapps.regionofwaterloo.ca) | Rail layer trains, Waterloo Region, ON: Grand River Transit (Region of Waterloo). Live GPS positions. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. | [Region of Waterloo Open Data Licence v2.0](https://www.regionofwaterloo.ca/government-and-council/transparency-and-accountability/open-data/): "You are free to: Copy, publish, distribute and transmit the Information ... Use the Information commercially". The registered URL is the `/2` light-rail feed; the suffix-less URL serves a file frozen since February 2026 and is not used. | "Trains (Contains information licensed under the Open Data Licence – Region of Waterloo): [Region of Waterloo Open Data Licence v2.0](https://www.regionofwaterloo.ca/government-and-council/transparency-and-accountability/open-data/)" | +| **Queensland Rail Citytrain** (gtfsrt.api.translink.com.au) | Rail layer trains, South East Queensland, Australia: Translink (Queensland Government). Live GPS positions. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. With a built schedule pack, trip updates (gtfsrt.api.translink.com.au) add delays at its stops. | [CC BY 4.0](https://translink.com.au/about-translink/open-data): "Our data is licensed under a Creative Commons Attribution 4.0 International License." The terms add: "You must not represent that the State in any way endorses your Application." | "Trains (Translink / Queensland Government): [CC BY 4.0](https://translink.com.au/about-translink/open-data)" | +| **G:link** (gtfsrt.api.translink.com.au) | Rail layer trains, Gold Coast, Australia: Translink (Queensland Government). Live GPS positions. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. With a built schedule pack, trip updates (gtfsrt.api.translink.com.au) add delays at its stops. | [CC BY 4.0](https://translink.com.au/about-translink/open-data): "Our data is licensed under a Creative Commons Attribution 4.0 International License." | "Trains (Translink / Queensland Government): [CC BY 4.0](https://translink.com.au/about-translink/open-data)" | +| **Adelaide Metro** (gtfs.adelaidemetro.com.au) | Rail layer trains, Adelaide, Australia: Adelaide Metro (Rail Commissioner). Live GPS positions. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. With a built schedule pack, trip updates (gtfs.adelaidemetro.com.au) add delays at its stops. | [CC BY 4.0](https://data.sa.gov.au/data/dataset/https-gtfs-adelaidemetro-com-au): "Creative Commons Attribution 4.0". Licence as catalogued on data.sa.gov.au; the operator developer page sits behind a bot check. The feed is labelled beta. Its gateway answers base64 text unless the request sends `Accept: application/x-google-protobuf`, so the proxy sends that header for this feed. | "Trains (Adelaide Metro / Government of South Australia): [CC BY 4.0](https://data.sa.gov.au/data/dataset/https-gtfs-adelaidemetro-com-au)" | +| **Irish Rail** (api.irishrail.ie, www.transportforireland.ie) | Rail layer trains, Ireland: Iarnród Éireann / Irish Rail. Placed at the station or timing point last reported, not by GPS. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. With a built schedule pack, the delay each train's report states moves its stops. | [No licence published (positions); CC BY 4.0 (NTA timetable)](https://developer.nationaltransport.ie/usagepolicy). The train positions publish no licence or terms: the Irish Rail realtime API page (api.irishrail.ie/realtime/, read 2026-11-10) documents the calls and says only that "Irish Rail provides this information as is" (quoted under `limits`), so there is nothing to quote. They are used without a published licence, credited "Iarnród Éireann / Irish Rail". The NTA's CC BY 4.0 covers only the NTA's GTFS data (the timetable), not these positions. Positions are the station or timing point the signalling system last reported, not GPS. | "Trains (Iarnród Éireann / Irish Rail; timetable: National Transport Authority, provided as is): [No licence published (positions); CC BY 4.0 (NTA timetable)](https://developer.nationaltransport.ie/usagepolicy)" | +| **VR (Finland)** (rata.digitraffic.fi) | Rail layer trains, Finland: VR, via Fintraffic Digitraffic. Live GPS positions. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. With a built schedule pack, trip updates (rata.digitraffic.fi) add delays at its stops. | [CC BY 4.0](https://www.digitraffic.fi/en/terms-of-service/): "Fintraffic's open data is licensed under the Creative Commons 4.0 By license. It gives the right to distribute, remix, tweak, and build upon our data, even commercially, as long as you credit the source for the original creation." The Digitraffic-User header names the application, never a person. | "Trains (Source: Fintraffic / digitraffic.fi, license CC 4.0 BY): [CC BY 4.0](https://www.digitraffic.fi/en/terms-of-service/)" | +| **Renfe long and medium distance** (gtfsrt.renfe.com, ssl.renfe.com) | Rail layer trains, Spain: Renfe (AVE, Avlo, Alvia, Euromed, Intercity, Media Distancia). Live GPS positions. Its vehicles name no route, so it is listed only once its path and schedule packs are built from its static GTFS; trip updates (gtfsrt.renfe.com) then add delays at its stops. | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/): "CC-BY-4.0". Licence field of the data.renfe.com datasets (posicion-vehiculos-av-ld-md and the static GTFS). "Esta información se actualiza cada 15 segundos". Iryo and OUIGO España are not in Renfe’s feeds. | "Trains (Renfe): [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/)" | +| **Renfe Cercanías** (gtfsrt.renfe.com, ssl.renfe.com) | Rail layer trains, Spain: Renfe Cercanías and Rodalies. Live GPS positions. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. With a built schedule pack, trip updates (gtfsrt.renfe.com) add delays at its stops. | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/): "CC-BY-4.0". Licence field of the data.renfe.com Cercanías datasets. Every vehicle in this feed is a Cercanías or Rodalies train. | "Trains (Renfe): [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/)" | +| **Vy** (api.entur.io, storage.googleapis.com) | Rail layer trains, Norway: Vy (Norway), via Entur. Live GPS positions. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. | [NLOD 2.0](https://data.norge.no/nlod/en/2.0): "Entur's open data is published under the Norwegian Licence for Open Government Data (NLOD). NLOD permits free reuse for any purpose, including commercial use, subject to attribution requirements". | "Trains (Inneholder data under Norsk lisens for offentlige data (NLOD) tilgjengeliggjort av Entur AS): [NLOD 2.0](https://data.norge.no/nlod/en/2.0)" | +| **Go-Ahead** (api.entur.io, storage.googleapis.com) | Rail layer trains, Norway: Go-Ahead Norge, via Entur. Live GPS positions. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. With a built schedule pack, trip updates (api.entur.io) add delays at its stops. | [NLOD 2.0](https://data.norge.no/nlod/en/2.0): "Entur's open data is published under the Norwegian Licence for Open Government Data (NLOD). NLOD permits free reuse for any purpose, including commercial use, subject to attribution requirements". | "Trains (Inneholder data under Norsk lisens for offentlige data (NLOD) tilgjengeliggjort av Entur AS): [NLOD 2.0](https://data.norge.no/nlod/en/2.0)" | +| **Flytoget** (api.entur.io, storage.googleapis.com) | Rail layer trains, Norway: Flytoget, via Entur. Live GPS positions. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. With a built schedule pack, trip updates (api.entur.io) add delays at its stops. | [NLOD 2.0](https://data.norge.no/nlod/en/2.0): "Entur's open data is published under the Norwegian Licence for Open Government Data (NLOD). NLOD permits free reuse for any purpose, including commercial use, subject to attribution requirements". | "Trains (Inneholder data under Norsk lisens for offentlige data (NLOD) tilgjengeliggjort av Entur AS): [NLOD 2.0](https://data.norge.no/nlod/en/2.0)" | +| **SJ Nord** (api.entur.io, storage.googleapis.com) | Rail layer trains, Norway: SJ Nord, via Entur. Live GPS positions, read from SIRI VM. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. With a built schedule pack, the delay each train's report states moves its stops. | [NLOD 2.0](https://data.norge.no/nlod/en/2.0): "Entur's open data is published under the Norwegian Licence for Open Government Data (NLOD). NLOD permits free reuse for any purpose, including commercial use, subject to attribution requirements". SJ Nord trains are missing from Entur’s GTFS-RT vehicle positions (0 vehicles on 2026-11-06); they are read from SIRI VM instead (13 trains on 2026-11-09). Its journey refs are DatedServiceJourneys, not the GTFS trip ids, so a train is matched to its run by line and position. | "Trains (Inneholder data under Norsk lisens for offentlige data (NLOD) tilgjengeliggjort av Entur AS): [NLOD 2.0](https://data.norge.no/nlod/en/2.0)" | +| **KTMB** (api.data.gov.my) | Rail layer trains, Peninsular Malaysia: Keretapi Tanah Melayu (ETS, Intercity, KTM Komuter, Shuttle Tebrau). Live GPS positions. Its vehicles name no route, so it is listed only once its path and schedule packs are built from its static GTFS. | [CC BY 4.0](https://developer.data.gov.my/realtime-api/gtfs-realtime): "All GTFS Realtime feeds are licensed under CC BY 4.0". developer.data.gov.my, read 2026-11-09; its GTFS Static page says the same of "All GTFS Static feeds". The static GTFS has no shapes. | "Trains (KTMB, via data.gov.my (Government of Malaysia)): [CC BY 4.0](https://developer.data.gov.my/realtime-api/gtfs-realtime)" | +| **Seattle Streetcar** (s3.amazonaws.com, metro.kingcounty.gov) | Rail layer trains, Seattle, WA: City of Seattle (South Lake Union and First Hill lines), via King County Metro. Live GPS positions. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. With a built schedule pack, trip updates (s3.amazonaws.com) add delays at its stops. | [King County Transit Data Terms of Use](https://kingcounty.gov/en/dept/metro/rider-tools/mobile-and-web-apps): "King County grants you a limited, revocable license to use, reproduce, and redistribute the Data in accordance with these Terms." Transit Data Terms of Use on the page linked above (undated; read 2026-11-10), which links the GTFS and GTFS-RT used here and covers "schedule, geographic, real-time, or any other data posted by King County". King County also permits users "to copy, distribute, store (electronically or otherwise), create derivative works based on and perform and display (publicly or otherwise) such Data, and make such Data available to end users", and asks for the legend the credit carries; no King County service marks beyond it. Commercial use is not mentioned. The feed bundles King County Metro, Sound Transit and City of Seattle routes; only the City of Seattle streetcars (agency 23) are kept, `100340` South Lake Union and `102638` First Hill (transit, tram). Link light rail is not in this GTFS. | "Trains (Transit scheduling, geographic, and real-time data provided by permission of King County): [King County Transit Data Terms of Use](https://kingcounty.gov/en/dept/metro/rider-tools/mobile-and-web-apps)" | +| **Connector streetcar** (tmgtfsprd.sorttrpcloud.com, www.go-metro.com) | Rail layer trains, Cincinnati, OH: Southwest Ohio Regional Transit Authority (SORTA/Metro). Live GPS positions. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. With a built schedule pack, trip updates (tmgtfsprd.sorttrpcloud.com) add delays at its stops. | [SORTA/Metro developer data Terms](https://www.go-metro.com/about/developer-data/): "The Southwest Ohio Regional Transit Authority (SORTA/Metro) hereby grants you (Licensee) non-exclusive, limited and revocable rights to use, reproduce, and redistribute SORTA/Metro’s transit route schedules, pricing, and other associated data (“Data”) subject to the following Terms:". The developer data page linked above (read 2026-11-10), which links the GTFS and these GTFS-RT files. SORTA/Metro trademarks "may not be used in association with Data unless approved in writing by SORTA/Metro", so the credit is text only; "Any modification, alteration, combination or arrangement of the Data is solely at your own risk." Commercial use is not mentioned. Seen 2026-11-10: 2 streetcars among 147 vehicles, 6 streetcar trips among 265 updates, trip ids those of the static file. The file of 2026-10-01 ends on 2026-12-05. One rail route, `100` Streetcar - OTR - Banks (route_type 0): transit, tram. | "Trains (Southwest Ohio Regional Transit Authority (SORTA/Metro)): [SORTA/Metro developer data Terms](https://www.go-metro.com/about/developer-data/)" | +| **Manyōsen** (gtfs-rt-files.buscatch.jp, api.gtfs-data.jp) | Rail layer trains, Toyama, Japan: 万葉線 Manyōsen (Takaoka–Kōshinokata tram). Live GPS positions. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. With a built schedule pack, trip updates (gtfs-rt-files.buscatch.jp) add delays at its stops. | [CC0 1.0](https://api.gtfs-data.jp/v2/organizations/manyosen/feeds/manyosen): ""feed_license": "CC0 1.0"". GTFS Data Repository record of the feed "万葉線", read 2026-11-10: the record lists the GTFS-RT VehiclePositions and TripUpdates (buscatch.jp, keyless) with the CC0 feed. Tram: transit. | "Trains (万葉線株式会社, via the GTFS Data Repository (gtfs-data.jp)): [CC0 1.0](https://api.gtfs-data.jp/v2/organizations/manyosen/feeds/manyosen)" | +| **Toyama tram** (gtfs-rt-files.buscatch.jp) | Rail layer trains, Toyama, Japan: 富山地方鉄道 Toyama Chihō Railway city tram (city lines, Toyamakō Line, loop line). Live GPS positions. No static GTFS is registered, so there are no path or schedule packs, and its trip updates are not used. | [CC0 1.0](https://api.gtfs-data.jp/v2/organizations/chitetsu/feeds/chitetsushinaidensha): ""feed_license": "CC0 1.0"". GTFS Data Repository record of the feed "富山地方鉄道市内電車", read 2026-11-10: the record lists the GTFS-RT VehiclePositions and TripUpdates (buscatch.jp, keyless) with the CC0 feed. The tram-only feed (the company's buses are a separate feed). Tram: transit. | "Trains (富山地方鉄道株式会社, via the GTFS Data Repository (gtfs-data.jp)): [CC0 1.0](https://api.gtfs-data.jp/v2/organizations/chitetsu/feeds/chitetsushinaidensha)" | +| **Bordeaux tram** (bdx.mecatran.com) | Rail layer trains, Bordeaux, France: TBM (Bordeaux Métropole): trams A to F. Live GPS positions. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. With a built schedule pack, trip updates (bdx.mecatran.com) add delays at its stops. | [Licence Ouverte / Open Licence 1.0](https://transport.data.gouv.fr/datasets/offres-de-services-bus-tram-et-scolaire-au-format-gtfs-netex-gtfs-rt-siri-lite): "fr-lo". Licence field of the transport.data.gouv.fr dataset "Réseau urbain et scolaire TBM" (publisher Bordeaux Métropole; read through its API on 2026-11-10), covering its GTFS and its GTFS-RT VehiclePositions and TripUpdates; Licence Ouverte 1.0 (Etalab) allows reuse, commercial included, with attribution. Tram: transit, tram. | "Trains (Bordeaux Métropole (TBM)): [Licence Ouverte / Open Licence 1.0](https://transport.data.gouv.fr/datasets/offres-de-services-bus-tram-et-scolaire-au-format-gtfs-netex-gtfs-rt-siri-lite)" | +| **Montpellier tram** (gtfsproxy.e-tam.fr) | Rail layer trains, Montpellier, France: TaM (Montpellier Méditerranée Métropole): trams 1 to 5. Live GPS positions. Optional path and schedule packs built from its static GTFS add its lines, stop names and next stops. With a built schedule pack, trip updates (gtfsproxy.e-tam.fr) add delays at its stops. | [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/): "odc-odbl". Licence field of the transport.data.gouv.fr dataset "Réseau urbain TaM" (publisher Montpellier Méditerranée Métropole; read through its API on 2026-11-10), covering its GTFS and its GTFS-RT VehiclePositions and TripUpdates. ODbL is share-alike, with the access point's special conditions (doc.transport.data.gouv.fr "Licence ODbL", read 2026-11-10): the share-alike clause covers information "de même nature, de même granularité, de mêmes conditions temporelles et de même emprise géographique", and "Les bases de données ainsi repartagées doivent être publiées sur le Point d'accès national transport.data.gouv.fr, dans le format d'origine." The path and schedule packs built from this GTFS are such a database once shared: offered under ODbL with the attribution kept. Tram: transit, tram. | "Trains (TaM / Montpellier Méditerranée Métropole): [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-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 a number whose range gives the mode and the line (metro `L991` is A, train `L1004` is S4, ferry `L1801` is P1), read from PID’s GTFS routes.txt. | Pražská integrovaná doprava (PID) / Golemio | @@ -237,6 +267,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **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 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. +- **Rail (GTFS-Realtime trains).** Each feed in `src/data/railFeeds.js` records its licence, its credit and the passage of its terms the decision rests on, and a feed is switched on only when those terms were read and cover this use, or, for those that publish no licence at all, on the basis its entry records. Such a basis is never a licence, and the credit does not present it as one: its label says there is no licence ("No licence published", "No licence stated") or names the publication the use rests on. Five enabled feeds publish no licence for their train positions. MARC is used on what its publication says (open publication with a no-endorsement disclaimer), and METRO light rail on the same basis as the Transit layer's Metro Transit feed. Tri-Rail and Charlotte's LYNX are used without a published licence by project owner decision of 2026-11-10. The fifth is Irish Rail, whose train positions publish no licence while its NTA timetable is CC BY 4.0. The registry lists 30 feeds, all switched on, and every one is keyless: its realtime feed and its static timetable are published for anyone to fetch, so no feed needs a key, an account or a registration, and the server holds no rail credential. No rail request carries a credential of the deployer's; one feed's published URL includes a fixed public value its publisher prints for everyone (Bordeaux's `apiKey`). `/api/rail/feeds` lists the offered feeds, `/api/rail/trains/` returns one feed's train snapshot, `/api/rail/updates/` its trip updates, and `/api/rail/paths/` and `/api/rail/schedule/` its path and schedule packs, when built. The proxy requests only registered feeds and reuses the Transit proxy's request policy (a short snapshot cache with single-flight refresh, serve-stale, admission limits, the failure cooldown ladder, redirects followed hop by hop only on the feed's own https origin, and an 8 MiB body cap unless a feed sets its own, at most 64 MiB). Many operators publish trains in the same feed as their buses, so each feed has a classifier: the proxy keeps only vehicles it recognises as trains, tags each with a category (`transit`, `local`, `regional`, `national` or `international`) and a mode (`rail`, `metro` or `tram`), and drops everything else server-side, counted in the snapshot as `dropped`; the browser never receives a bus. Every train shown is live, where its operator last reported it: 29 feeds publish live GPS positions (SJ Nord's through SIRI VM), and Irish Rail reports the station or timing point a train last passed. No train is placed or moved from a timetable; a feed whose live positions fail shows as unavailable, and its trains age out as any quiet train does. 22 feeds register trip updates, polled while one of their trains is selected and that feed's schedule pack is loaded. 28 feeds work out of the box. Packs are an opt-in extra for live trains: nothing is downloaded when the app starts, and `node scripts/build-rail-paths.mjs --feed ` (repeatable) or `--all` downloads a feed's static GTFS (29 of the 30 have one) and writes a path pack (the operator's track shapes) and a schedule pack (the stop times for a window of days) to `.cache/rail-paths/` (or to `RAIL_PATHS_DIR` when set, the directory the server then serves). With them, a live train is drawn on its operator's route line and its card names its stops, its next stop and, from the trip updates, their delays. Renfe long and medium distance and KTMB, whose vehicles name no route, are classified from their pack's routes, so `/api/rail/feeds` lists them only once that pack is built. Lines are the operator's own shapes as published: nothing is routed or redrawn from other map data, and no line is drawn where an operator publishes no shape. **Share-alike sources bind the deployer.** A pack built from a ShareAlike or ODbL source is a derived database of it, so a deployer who serves it publicly makes it subject to the same licence and must offer it under that licence with the attribution kept. That covers the Montpellier packs (ODbL). For the ODbL datasets of France's national access point, its special conditions also require that "Les bases de données ainsi repartagées doivent être publiées sur le Point d'accès national transport.data.gouv.fr, dans le format d'origine" ([Licence ODbL](https://doc.transport.data.gouv.fr/le-point-d-acces-national/cadre-juridique/conditions-dutilisation-des-donnees/licence-odbl), read 2026-11-10): a deployer who serves the Montpellier packs publicly takes on republishing that derived data there, as GTFS. The build downloads each static GTFS itself, over https and with no credential. A feed's `staticRouteTypes` lists the route types it keeps when its classifier accepts every route of a mode-specific feed. A frequency-based trip becomes one run per departure on its template's stops and run times. Packs are build output, not part of the repository, and cover a fixed window of days, so they are rebuilt before it ends, and more often where an operator republishes more often. A build in which any feed fails exits with code 2, the other feeds still built. A stop the GTFS gives no coordinates, which some feeds write as (0, 0), is left out of the run, so no line is ever drawn to it and no stop is placed by guesswork. Where several loaded feeds carry the same train, it is drawn once. Copies are matched by train number within 100 km, or, for a short line name (S1, RB 1, U6), on the same line in the same category within 15 km. A train number is three or more digits, or a shorter number after a long-distance product (EC, ECE, EN, ICE, NJ, RJ, RJX, TGV; not IC, where a short number names a line); a trip id that is an operator prefix and a number, or a headsign that is a bare number, is read too. The copy drawn is scored by its position source (live GPS, then station reports), then by its line (on its operator's shape, then without one), and stays drawn until another scores strictly higher. No train is ever placed in Ukraine, a safety rule: the build leaves out every run with a stop there, a stop whose id names Ukraine or whose position is inside its recognised borders (either is enough: an id naming another country does not keep a stop inside them), and the proxy drops every live position inside those borders (`src/data/railNoPositions.js`). Trains use the Transit layer's delayed playback between reports and move along their line in the path pack where there is one, else in straight lines. No train position is archived to disk, there is no proxy history, and nothing is collected in the background. Operator credits appear when that feed's trains are shown and do not imply endorsement. - **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. @@ -268,6 +299,7 @@ The [Bhote Koshi event pack](public/events/bhote-koshi-2026/README.md), under `p | **Datacenters** (4,352) | `datacenters/` | **ODbL 1.0** (OpenStreetMap extract) | ✅ (attribution + share-alike on data) | © OpenStreetMap contributors (shared credit) | | **Dams** (704) | `dams/` | **ODbL 1.0** (OpenInfraMap / OSM extract) | ✅ (attribution + share-alike on data) | © OpenStreetMap contributors (shared credit) + Open Infrastructure Map | | **Military area names** (36,466) | `osm_military_names/` | **ODbL 1.0** (OpenStreetMap, via Overture Maps) | ✅ (attribution + share-alike on data) | © OpenStreetMap contributors (shared credit) + Overture Maps Foundation | +| **Ukraine outline** (Rail layer no-positions rule; 1 ring, 7,801 points) | `src/data/railNoPositions.js` | **ODbL 1.0** (OpenStreetMap, via geoBoundaries gbOpen UKR ADM0, commit 9469f09; simplified to 0.0005°) | ✅ (attribution + share-alike on data) | © OpenStreetMap contributors (shared credit) + geoBoundaries | | **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" | @@ -326,7 +358,7 @@ credits it anyway as `SPECS: CCTV-DATABASE.COM` beside any spec line it renders. ### Bundled OpenStreetMap data -Bundled OSM-derived data is distributed under [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/), separately from the MIT code: [datacenters](src/data/local_data/datacenters/), [dams](src/data/local_data/dams/) (via Open Infrastructure Map), [military names](src/data/local_data/osm_military_names/), and [OSM test fixtures](src/data/fixtures/). Keep © OpenStreetMap contributors and offer modified databases under ODbL. +Bundled OSM-derived data is distributed under [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/), separately from the MIT code: [datacenters](src/data/local_data/datacenters/), [dams](src/data/local_data/dams/) (via Open Infrastructure Map), [military names](src/data/local_data/osm_military_names/), the Rail layer's Ukraine outline in [`railNoPositions.js`](src/data/railNoPositions.js) (OpenStreetMap via geoBoundaries gbOpen, Runfola et al. 2020, simplified; the module's code stays MIT), and [OSM test fixtures](src/data/fixtures/). Keep © OpenStreetMap contributors and offer modified databases under ODbL. ### NASA FIRMS acknowledgement diff --git a/README.md b/README.md index f70bd2f81..34cf2ba79 100644 --- a/README.md +++ b/README.md @@ -446,6 +446,7 @@ Twenty-three layers and map sources. **Twenty-one have a keyless path.** Some of | 📷 **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 (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 | 🟢 | +| 🚆 **Rail** | Live trains across the US, Canada, Australia, Ireland, Finland, Spain, Norway, Malaysia, Japan and France, from 30 keyless operator feeds: live GPS positions from 29 of them and Irish Rail’s station reports, 28 live out of the box, and Renfe long and medium distance and KTMB once their pack is built. No train is placed from a timetable. Opt-in packs (`node scripts/build-rail-paths.mjs --feed ` or `--all`) add the operator’s own route lines, stop names, next stops and trip-update delays. Coloured by service category (urban, local, regional, national, international) with a filter chip and legend for each; trains are drawn once when several feeds carry them, and open a card per train. Buses in mixed feeds are dropped by the server | Operator GTFS-Realtime feeds (static GTFS for opt-in packs) | 🟢 | | 🚲 **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/SECURITY.md b/SECURITY.md index e12a77025..bb3b1c362 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -74,6 +74,7 @@ The data proxies under `server/providers/` are written so the browser cannot tur - **Québec 511 video plays in the browser, opt-in and after Play.** With `CCTV_QUEBEC_VIDEO_ENABLED=1`, `/api/cctv/sources` lists, for each MTQ camera, one URL built server-side from its numeric id on `https://www.quebec511.info/Carte/Fenetres/camera.ashx`; the overlay accepts no other host, path or format. The browser loads that clip only after the viewer presses Play for the selected camera, so Québec 511 sees the viewer's IP address, and the panel says so before Play. Selecting a camera by click, auto-hop, a scene or voice never loads it, and switching cameras stops playback. GEV never proxies, caches or reads the video's pixels. With the flag unset (the default) the browser makes no request to Québec 511. - **No verbatim client headers upstream.** The CCTV media route is the one route that relays a request header (`Range`, for video seeking). It is parsed and canonicalized before it is forwarded: one `bytes=` range only, with every accepted form — explicit span, open-ended and suffix — bounded to the same 64 MiB ceiling the relay applies to a declared response body. Multi-range, malformed, inverted and non-`bytes` values are dropped and the request proceeds without a `Range`, as RFC 7233 §3.1 prescribes. Bounding the request bounds what is asked for: a response that declares no length — live streamed media, or a chunked body from an upstream that ignores the `Range` — has no ceiling. An upstream request the browser has stopped waiting for is cancelled rather than left running, whether the viewer leaves before the headers arrive or during the body. - **Transit fetches registered feeds only.** `/api/transit/vehicles/` resolves the id against `src/data/transitFeeds.js`; the browser never supplies a URL, and a feed that is registered but disabled does not resolve at all. Redirects are followed manually and each hop is validated against the feed's own https origin before it is requested, so an off-origin or downgraded hop is refused rather than contacted. Bodies are capped at 8 MB, the protobuf is decoded server-side under entity-count and string-length ceilings, a differential feed is refused, and snapshots live 15 s in memory with no disk cache. A per-feed admission limiter and a failure cooldown ladder bound what this process can ask of any operator. +- **Rail fetches registered feeds only, and none needs a credential.** `/api/rail/feeds` lists the offered feeds; `/api/rail/trains/`, `/api/rail/updates/`, `/api/rail/paths/` and `/api/rail/schedule/` resolve the id against `src/data/railFeeds.js` (`RAIL_FEED_ID_PATTERN`: lowercase letters, digits and hyphens). The browser never supplies a URL, and an id that is not registered, or a trip-updates route for a feed that registers none, does not resolve at all; any method but GET answers 405 (`Allow: GET`, never cached). Every registered feed and every static timetable is open: no rail request carries a credential of the deployer's; one feed's published URL includes a fixed public value its publisher prints for everyone (Bordeaux's `apiKey`), and the only rail setting the server reads is `RAIL_PATHS_DIR`. Upstream requests follow the Transit proxy's policy: https only, each redirect hop validated against the feed's own https origin before it is requested (three hops at most), a request timeout, bodies capped at 8 MiB unless the registry sets a feed's own cap (never above 64 MiB), and a per-feed admission limiter and the failure cooldown ladder bound what the process asks of any operator. Responses are decoded server-side: GTFS-Realtime under entity-count and string-length ceilings, a differential feed is refused, and an empty or header-less GTFS-Realtime reply (an error page served as 200, say) is refused as unreadable, so the last good snapshot is served stale or the request fails with 502. The Irish Rail and SIRI VM XML is read by small scanners, not an XML library: a document that declares a DTD or an entity is refused, and the whole text, the number of trains and every field are capped. Only vehicles the feed's classifier recognises as trains leave the server, and no train position inside Ukraine is ever sent (`src/data/railNoPositions.js`). Snapshots and trip updates live in memory for the feed's own freshness window, with serve-stale after a failure; there is no disk cache and no history. Path and schedule packs are optional build output, read from disk (`.cache/rail-paths/` or `RAIL_PATHS_DIR`) by a reader that re-checks the id against the same pattern, so a name never leaves that directory. A pack over 24 MiB is refused before it is read and answers 503, never a partial file, and so does a pack that cannot be read: only a missing pack is 404; the rest pass through as bytes, through a 64 MiB in-memory read cache keyed by each file's modification time and size, and are served as JSON holding timetables and track shapes only, `no-cache` with an ETag from that time and size (304 when unchanged). A schedule pack of another format version or another feed, or whose window has ended, is not used, and a feed that cannot be read without it answers 503 rather than an empty list. The pack build (`scripts/build-rail-paths.mjs`) runs only when a person runs it, never on app start: it downloads over https only (a redirect to plain http is refused), caps each download in size and time, sends no credential, and refuses an `--out` folder (or a `RAIL_PATHS_DIR` it writes to) inside `src/` or `public/`. - **Local receiver feeds are operator-configured and local-only.** `/api/local-receivers/aircraft` reads only the `aircraft.json` URLs in the server's `LOCAL_RECEIVER_FEEDS`; the browser never supplies an address. Each host must pass the shared tap address rule (`src/data/tapAddress.js`: loopback, RFC1918, `localhost`, `*.local`; no link-local, IPv6 or other names), the scheme must be http(s), the path must end in `aircraft.json`, and credentials, queries and fragments are refused. Invalid entries are logged at startup and never fetched. Reads use a 2 s timeout, refuse redirects, cap bodies at 2 MB and share one read per second; responses name each feed by band only and never carry upstream error text. - **Voice place search runs only on the operator's Overpass.** `/api/osm/features` answers only when `OVERPASS_UPSTREAMS` is set and never falls back to a public instance. The client sends a curated preset id, a box, a mode and a limit; the server writes and quotes the Overpass query itself, so no client or model text reaches it. Boxes are capped (36 deg² for a count, 4 deg² for a listing), as are the feature limit, the Overpass timeout and memory, and the response bytes read. Each client (IPv6 clients keyed by /64, as on `/api/overpass`) runs one search at a time and two run at once in all, under a per-client rate limit (12/min) and a global one (30/min); a search gets 22 s across every configured endpoint in turn (Overpass itself is told 20 s), which ends before the voice answer stops waiting at 25 s. The route sits behind the cross-site gate below. Answers are cached in memory and on disk (`.gev-cache/osm-features/`, at most 400 entries and 256 MB, kept a day), keyed to the configured instance so a repointed `OVERPASS_UPSTREAMS` never serves the old one's answers; a malformed answer is never cached. - **Validated response formats.** Proxied bodies are relayed only in the format the route serves. CCTV frames and the Street View fallback accept JPEG, PNG, GIF, WebP and AVIF only, sent under their canonical type; any other type falls through to the next fallback. The one exception is a source registered as an MJPEG still (the Taiwan Highway Bureau feeds): its `multipart/x-mixed-replace` answer is read only up to its first JPEG part, capped at 2 MB, and sent as `image/jpeg`; a multipart answer from any other camera falls through. A camera whose API needs an operator credential (the City of Ottawa certificate, `OTTAWA_CAMERA_CERTIFICATE`) has a keyless catalog URL; the frame proxy adds the credential only at fetch time, only for a URL inside that camera's pinned path, so `/api/cctv/sources` and `/api/cctv/stream` never carry it. Live CCTV media must match its feed kind (`video/mp4`, `video/webm`, or `multipart/x-mixed-replace` with a boundary) before it is relayed or the camera counts as healthy, and its redirects stay on the registered origin. GBFS bodies must parse as JSON and are re-serialized as `application/json; charset=utf-8`. Camera catalog URLs are pinned by parsed URL — scheme, exact host, no credentials, default port, and a normalized path inside the agency's image directory — and a redirect on a pinned host must stay inside the same directory. Every `/api/cctv` route uses the keyless cross-site checks described below, so cameras stay reachable behind a reverse proxy; a request carrying forwarding headers never reaches the key-bearing Street View fallback and gets the placeholder frame instead, as when no key is configured. diff --git a/changelog.d/0-rail-layer.md b/changelog.d/0-rail-layer.md new file mode 100644 index 000000000..14eac616f --- /dev/null +++ b/changelog.d/0-rail-layer.md @@ -0,0 +1,24 @@ +- Add Rail in the Movement group, off by default: live trains from 30 keyless + operator feeds in the United States, Canada, Australia, Ireland, Finland, + Spain, Norway, Malaysia, Japan and France, from commuter and long-distance + rail to the streetcars and trams of Seattle, Cincinnati, Toyama, Bordeaux + and Montpellier. A new `/api/rail` proxy fetches registered feeds only, + needs no key and drops buses from mixed feeds. Every train shown is live: + 29 feeds publish GPS positions and Irish Rail its station reports, and 28 + of them show with no setup. No train is placed or moved from a timetable; + a feed whose live positions fail shows as unavailable. Packs are an opt-in + extra: `node scripts/build-rail-paths.mjs --feed ` (or `--all`) builds + a feed's path and schedule packs into `.cache/rail-paths/`, which add the + operator's own route lines, stop names, the next stop and trip-update + delays to its live trains; Renfe long and medium distance and KTMB, whose + vehicles name no route, appear once their pack is built. Nothing is + downloaded when the app starts, and lines are only ever the operator's own + shapes. Trains are coloured by category (urban, local, regional, national, + international), each with a chip and legend entry shared via token `02`, + glide between reports with Transit's delayed playback, are drawn once when + several feeds carry them, and open a card on click. A selected train that + is moving is followed by the camera, as an aircraft is, and any other is + flown to once; the selected train and its line stay on the map from orbit. + No train is placed in Ukraine. Feeds that publish no licence are used on + the basis each one's entry records, and their credit never presents that + as a licence (Denis Groshev, @ZloyS, #0). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 5590b8c68..4c4424bf0 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4911,6 +4911,7 @@ its criteria cannot be silently ignored. | CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS, Kartverket ground heights) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + Iceland (Vegagerðin / IRCA) + Hong Kong Transport Department + FL511 (FL, opt-in) + Freeway Bureau, MOTC (Taiwan) + Houston TranStar (opt-in) + Autostrada del Brennero (IT, A22 stills) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) | | Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh | | Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) | +| Rail 🚆 | Live operator train positions (GPS, or station reports for Irish Rail) and trip updates, trains only, with opt-in route packs built from static GTFS (30 keyless feeds, all on, 28 out of the box; `src/data/railFeeds.js`) | `src/layers/rail/` via `src/app/layers/rail.js` | `/api/rail` | 15s (poll + delayed playback) | | Bikeshare 🚲 | GBFS (Lyft + BCycle + Social Bicycles + PBSC incl. Bike Share Toronto, Bike Itaú (Brazil), Bicing (Barcelona) and seven other Spanish cities + Urban Sharing, Entur, JCDecaux (Lillestrøm, Valencia, Seville) + nextbike (AMBici)) | `src/data/bikeshare.js` | `/api/gbfs` | 60s | | Directions 🧭 | OSRM on FOSSGIS servers (OpenStreetMap) | `src/data/directions.js` | `/api/route` (`steps=1`) | on placement / mode change | | Datacenters ▣ | OSM extract (bundled) | `src/data/infrastructure.js` | — | static | @@ -5363,7 +5364,7 @@ answered yet opens nothing rather than a card hanging at sea level. The cells are re-read for up to 90 seconds, which outlasts a cold terrain round trip; reroute, CLEAR and disable cancel that. Transit is off by default, and share links use token `j`. The movement panel order -places Transit directly after Street Traffic and before Bike Share. Cameras and +places Transit directly after Street Traffic and before Rail. 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. Forty-nine keyless regions are available: Boston, Austin, Minneapolis–St Paul, @@ -5588,6 +5589,279 @@ refreshes. A 304 confirms contact without appending history. Missing vehicles are removed after two missed polls; reports older than ten minutes and feeds silent for five minutes are removed. Credits appear when the feed is used. +**Rail.** Rail is off by default, and share links use token `02`, with five +boolean options for its categories: `t` (urban), `l` (local), `r` +(regional), `n` (national) and `i` (international), all on by default. It sits +in the Movement panel directly after Transit and before Bike Share. The layer +lives in `src/layers/rail/` and is built by `src/app/layers/rail.js`; feeds are +registered in `src/data/railFeeds.js`. + +Every train shown is live: it is where its operator last reported it, by GPS +or, for Irish Rail, by the station or timing point the signalling last +reported. No train is placed or moved from a timetable, and a feed whose live +positions fail shows as unavailable while its trains age out as any quiet +train does. Below a 3,000 km altitude gate (with 150 km hysteresis), the layer +polls every 15 seconds each listed feed whose coverage circle contains the +centre of the view; a feed already polled stays active 40 km past its edge. A +feed's trip updates are polled every 30 seconds while one of its trains is +selected and its schedule pack is loaded. The registry lists 30 feeds, all +switched on and all keyless: every realtime feed and every static timetable +is published for anyone to fetch, so no feed needs a key, an account or a +registration, and no rail request carries a credential. + +**Opt-in packs.** Live feeds work out of the box, and nothing is downloaded +when the app starts. A path pack and a schedule pack, built only when someone +runs `scripts/build-rail-paths.mjs` for a feed (Packs below), are an extra for +its live trains: the operator's own route lines, stop names, the next stop +and, from the trip updates, the delays at each stop. Renfe long and medium +distance and KTMB, whose vehicles name no route, are classified from the +pack's routes (`railFeedNeedsSchedulePack`), so `/api/rail/feeds` lists them +only once `.schedule.json` is in the pack directory (`publicRailCatalog`). +The server checks that the file exists, never reading it for this, and keeps +each answer for 60 seconds; the catalog itself is sent with `max-age=300`. So +a host that has built nothing shows 28 feeds, in 22 regions, and the other two +appear as their packs are built. The browser reads the catalog once per layer +instance; until it answers, or while it fails (asked again after 30 s, +doubling to 10 minutes), only the feeds that need no pack are polled. A feed +the catalog lists without live positions is never polled. + +Five enabled feeds publish no licence for their train positions and are used +on the basis their entries record, which is never a licence and never +credited as one: MARC on what its publication says, and METRO light rail on +the basis the Transit layer uses for the same file. Tri-Rail and Charlotte's +LYNX are used without a published licence by project owner decision of +2026-11-10. The fifth is Irish Rail, whose train positions publish no licence +while its NTA timetable is CC BY 4.0. The +Montpellier tram's GTFS is ODbL, so the packs built from it are ODbL too: a +deployer who serves them publicly makes them subject to the same licence, +offered under it with the attribution kept, and France's national access +point adds that a re-shared derived database must be published on +transport.data.gouv.fr in its original format. + +By position source, 29 feeds publish live GPS. 13 are in the US: Metro-North, +LIRR, MBTA, SEPTA Regional Rail and trolleys, MARC, Tri-Rail, RTD, CapMetro, +METRO, Charlotte's LYNX, the Seattle Streetcar and Cincinnati's Connector. Three are in Australia: Citytrain, G:link and Adelaide Metro. The +remaining 13 are GRT ION in Canada, VR in Finland, Renfe long and medium +distance and Cercanías in Spain, the four Entur operators in Norway (SJ Nord +through SIRI VM), KTMB in Malaysia, the Bordeaux and Montpellier trams in +France, and the Manyōsen and Toyama trams in Japan. Irish Rail publishes the +station or timing point the signalling last reported. + +22 feeds register trip updates; on a feed whose pack is built their delays +reach the selected train's stops. Irish Rail and SJ Nord publish none, but +each train's report states its delay: with the pack built, that delay moves +the train's stops and the run its report is matched to, in the browser and, +for Irish Rail's train codes, on the server, so a late train is not shown +past a stop it has not reached. `DATA_SOURCES.md` lists every feed, one row +each, with its licence, terms and attribution. + +**Proxy.** `GET /api/rail/feeds` returns the public catalog of listed feeds, +`GET /api/rail/trains/` one feed's snapshot, +`GET /api/rail/updates/` its trip updates, and +`GET /api/rail/paths/` and `GET /api/rail/schedule/` its path +and schedule packs once built (`src/sources/railService.js`, +`src/data/railProxy.js`, served in development and preview by +`server/providers/rail.js`, which reads the packs from `.cache/rail-paths/` or +`RAIL_PATHS_DIR`). The request policy is the Transit proxy's: a memory cache +with single-flight refresh (15 s, or the feed's own poll interval; 30 s for +trip updates, or the feed's own), serve-stale for up to 10 minutes after a +failure (an empty or header-less GTFS-Realtime reply is refused as unreadable +and counts as one: stale, or 502 without a snapshot), per-feed and global +admission limits, the failure cooldown ladder, redirects followed hop by hop +only on the feed's own https origin, and an 8 MiB body cap unless a feed sets +its own, at most 64 MiB. A feed whose positions or trip updates can be read +only with its schedule pack (routes taken from the pack) answers +503 "Rail schedule unavailable" (`X-Rail-Backoff: schedule`, never cached) +whenever every record it sent was dropped, rather than an empty list: the +pack is missing or refused, or names none of the trips the feed runs now (a +stale pack: Renfe's trip ids carry their date). The server refuses a schedule +pack, as it does one it cannot read, when it is of another format version or +another feed, or once the last run it holds has arrived (its window has +ended), and reads it again after five minutes. A feed that needs +its pack to be listed is served only once that pack is built: until then +`/trains` and `/updates` answer 503 "Rail schedule unavailable" +(`X-Rail-Backoff: schedule`) without contacting the operator. Without a +`hasSchedulePack` probe, the server asks `schedulePackVersion` or +`readSchedulePack` (null: not built); with neither, no such feed is listed. +Only a missing pack is 404: a pack that cannot be read answers 503 with +`Retry-After: 60`. A pack is sent `no-cache` with an ETag from its file's +modification time and size, and a request whose If-None-Match names it is +answered 304 without reading the file: a feed's path and schedule packs pair +by `builtAt`, so a browser never keeps one of them past a rebuild. The browser asks again for a path or schedule pack after +30 s, doubling to 10 minutes. LIRR and Metro-North, whose positions and trip +updates are one document, are fetched once per freshness window for both. +Each feed's classifier runs on every decoded vehicle; only trains leave the +server, each tagged with a `category` (`transit`, `local`, `regional`, +`national`, `international`) and a `mode` (`rail`, `metro`, `tram`). +Everything else, buses in a mixed feed included, is dropped and counted as +`dropped`. There is no disk cache and no history. + +**Packs.** `scripts/build-rail-paths.mjs` builds, for each feed it is given +that has a static GTFS (29 of the 30), a path pack (`.json`: the +operator's track shapes) and a schedule pack (`.schedule.json`: routes, +stops, stop patterns, run-time profiles and the service dates of a window) into `--out`, else `RAIL_PATHS_DIR` when the build's own environment sets it (the folder the server reads), else `.cache/rail-paths/`, which is gitignored. A +build names its feeds (`--feed `, repeatable) or passes `--all` for every +enabled feed (many downloads and a long run); a bare run exits 1, and +`--help` prints the usage. A build in which any feed fails exits 2 once the +others are built (1 stays for a build that cannot start: a bad argument, or +another build holding the lock). `--out` may not be `src/` or `public/` or a +folder inside them. The window is four service dates by default (yesterday, +today and the next two days), 1 to 14 with `--days`, starting at `--from`. +`--local-gtfs =` builds a feed from a zip already on disk instead of +downloading it, a copy of the GTFS the feed publishes; the zip is only read, +and the packs record its file name, not its path. The server serves a pack of +up to 24 MiB, passed through as bytes from a 64 MiB in-memory read cache keyed +by the file's modification time and size (`server/providers/rail.js`), and +answers a larger one with 503 "Rail paths too large" or "Rail schedule too +large". The build refuses to write a pack over that cap (the feed counts as +failed and its previous packs stay), and a pack whose trips fall under a fifth +of the pack it would replace, or to none, which reads as an upstream fault, +unless `--force` is given. A feed's two packs are written together: to +temporary names, then renamed into place, the path pack first, only once both +are written; with `--no-schedule` the feed's old schedule pack, whose patterns +index the old path pack, is deleted, and a feed named only through its +schedule pack (Renfe long and medium distance, KTMB) is refused and counts +as failed. A lock file in the `--out` folder +(`.build-rail-paths.lock`) makes a second build there refuse to start. A +schedule pack records the `builtAt` of the path pack its shape indices refer +to (`pathsBuiltAt`); where the two no longer match, its shape indices are not +used. Every download is open and made over https only (a redirect to plain +http is refused), capped in size and time, with the script's User-Agent and +any identifying header the operator asks for (`staticHeaders`). Shapes are +taken at face value: the operator's `shapes.txt` as published; nothing is +redrawn from other map data, and no line is drawn where the operator +publishes none. A shape with a gap of more than 5 km between two points is a +chord, not track, and is dropped; a trip without a usable shape gets none, and +its train moves between reports in straight lines. A feed's +`staticRouteTypes` lists the route types it keeps when its classifier accepts +every route of a mode-specific feed. A frequency-based trip +(`frequencies.txt`) becomes one run per departure, `@`, on +its template's stops and run times. A stop the GTFS gives no coordinates, +which some feeds write as (0, 0), is left out of the run with its times, so no +line is drawn to it; a run needs two located stops. The Toyama tram has no +static GTFS registered, so it has no packs and its trains move in straight +lines between reports. + +**Duplicates across feeds.** Where loaded feeds carry the same train, +`src/layers/rail/dedupe.js` draws it once. Copies from different feeds match +by train number within 100 km of each other. A train number is three or more +digits, or a shorter number after a long-distance product (EC, ECE, EN, ICE, +NJ, RJ, RJX, TGV; not IC, where a short number names a line); a trip id that +is an operator prefix and a number, or a headsign that is a bare number, is +read too, and numbers that differ are different trains. A short line name +(S1, RB 1, U6) names a line many trains run: copies on different lines are +different trains, and copies on one line match only in the same category +within 15 km, the nearest pairs first. The copy drawn is scored by its +position source (live GPS, then station reports), then by its line (bound to +its operator's shape in a built pack, then without one). A drawn copy stays +until another scores strictly higher, and the comparison runs once per poll, +never per frame. + +**Ukraine.** No train is placed in Ukraine, a safety rule +(`src/data/railNoPositions.js`: its recognised borders as mapped in +OpenStreetMap, via geoBoundaries, simplified to about 50 m, and stop ids that +name the country). The path build leaves out every run with a stop there: a +stop whose id names Ukraine or whose position is inside its borders, either +being enough, so an id naming another country does not keep a stop that +lies inside. The proxy drops every live position inside it, for every feed. + +**Categories and markers.** Each train is a disc in its category colour (urban +`#FF7A59`, local `#F2D04B`, regional `#7BE495`, national `#5AB8FF`, +international `#C58BFF`) with a white glyph for its mode: a train front, a tram +with a pantograph, or an "M" for metro. The layer row shows one chip per +category, which hides or shows that category, and a colour legend with live +counts for the categories present. On photorealistic tiles a train's height +is sampled from the rendered mesh at points on its own line +(about every 55 m, for trains within 8 km of a camera at most 5 km above the +ground there, and only once the tiles in view have loaded), where the draped +line is the track bed or the deck. A reading more than 25 m under or 80 m over +the floor there is a coarse tile and is dropped; a kept one is read again +after 30 s, and the new reading replaces it when the camera is no higher than +it was, or once the old one is five minutes old. Elsewhere it stands on the +shared ground floor that Transit and aircraft use, and on a floor cell not +sampled yet it keeps a neighbouring cell's floor or its last one. Only a train +with no floor yet, or a stop of the selected trip, stands on the ellipsoid, +and only until its cell is warm: the stops are read again every second until +they stand on a floor. Rail warms those floor cells on one shared budget that +refills at 1,200 cells a minute and holds at most 320, so no minute spends +more than 1,520 cells (about 24 of the 90 requests of 64 points that +`/api/terrain/heights` allows a client each minute), and only under trains +within 30 km of the camera and at the selected trip's stops, the selected +train and its stops first and then the nearest, in one batch a second, so a +view full of trains cannot use up the limit every layer shares. Its drawn +height changes no faster than 5% of the distance it moves (plus 0.5 m/s while +it stands), so a roof or overpass sampled under it is a bump, not a jump, and +a followed train rides smoothly; it snaps when a better height replaces a +fallback (a real floor, or any floor replacing the ellipsoid) and after a jump +of more than 2 km. + +**Playback, lines and cards.** Trains use the Transit layer's delayed +playback between reports, so a train glides at 1× behind real time rather +than jumping. Between reports they move along the line in the feed's path +pack where there is one, else in straight lines. A loaded feed's faint +network is drawn in full below 150 km and not at all above 200 km. A feed +out of use for ten minutes gives back its network, path pack and schedule +pack; they load again when it is next used. The selected train's line, its +operator's shape from the path pack, is drawn ahead of it and, fainter, +behind it. Clicking a train opens a card titled with its route or headsign, +with at most six lines under it: category, mode, feed and region; displayed +motion and position source ("Live GPS" or "Estimated from station reports"); +the next and previous stops with their times and delays where the schedule +pack gives them; report age (marked "feed time" when the feed stamps the +header rather than the train) and display delay; reported speed and heading; +the last reported stop status where no stops are known; and the vehicle id. +Metro trains are shown at street level, as in Transit, and the card says so. + +**Camera.** Selecting a moving train gives it the camera as selecting an +aircraft does: Cesium's tracked entity rides on the train's marker, framed +2.5 km out and 35° down along the camera's heading (`WORLD_FOCUS_FRAMING.train`), +and a drag or a zoom orbits or zooms around the train. A train counts as +moving while it glides between reports, waits at a stop with its departure +already reported, or waits for its next report after its last two moved it +at least 15 m. Any other train (parked, known from one report, or not heard +from for 90 seconds) gets one fly-to with the same framing, as a vessel does; +in the Cockpit that fly-to is refused with "Exit cockpit to fly to a train". +The follow lets go, leaving the camera where it is, when the train is +deselected (a click on anything else, Escape, its category hidden, the train +leaving its feed, the layer turned off) or another train is selected, which +takes the camera in turn. An aircraft or satellite tracked by another layer +clears the selection too; a navigation that only releases the follow camera +(a search, a fly-to) keeps the train selected. When the duplicate filter moves +the selection to another feed's copy of the train, the follow stays and +glides to the new copy over 1.2 seconds. + +**The selected train from far out.** While a train is selected its feed stays +polled at any altitude and wherever the camera looks, so the train, its line +and its stops stay on the map from orbit. While nothing else would keep that +feed loaded, it carries the selected train alone: its other trains and its +faint network go as they would with the feed. On the far side of the globe +the selected train and its stop dots are hidden. Deselecting returns the feed +to the usual rules. + +**Limits.** A train a feed stops listing for two polls is removed, as is any +report older than ten minutes; a train without its own timestamp is aged from +the snapshot's fetch time, so a frozen feed empties rather than showing old +trains as live. A feed whose operator has not answered for 90 seconds reads as +stale, and its trains are removed after five minutes. The layer holds at most +5,000 trains drawn (a copy hidden as another feed's does not count); past that +the layer row reads PARTIAL. Rail is not voice-controllable yet: it is listed in +`VOICE_OFF_LAYERS` for prompt budget, and the "trains" alias still names +Transit. Credits appear when a feed's trains are shown. + +**Upkeep.** + +- Packs cover a fixed window of service dates, so they are rebuilt before it + runs out: daily at the default four days, or weekly with `--all --days 7` + from a `--from` date that starts the coming week. The window and build time + of a pack are in its `window` and `builtAt` fields. +- After a rebuild, the server picks up the new pack within a minute: it + checks each schedule pack's file time and size once a minute and rebuilds + what it reads from a changed pack (a host with its own `readSchedulePack` + and no `schedulePackVersion` keeps it up to an hour). A feed whose + first pack was just built is listed within a minute; a browser that has + read the catalog sees it after a reload, once its five-minute cache has + passed. + `src/data/militaryAwareness.js` remains registered internally as the Contacts coordinator, but it is not a user-visible Data Layers entry. Its visible entry point is the right-side `CONTEXT` chooser's `CONTACTS` mode. diff --git a/package.json b/package.json index 3dd51ae2e..b8f5c581a 100644 --- a/package.json +++ b/package.json @@ -285,6 +285,11 @@ "./layers/transit/standalone": "./src/data/transit.js", "./layers/transit/source": "./src/layers/transit/source.js", "./sources/transit-service": "./src/sources/transitService.js", + "./server/providers/rail": { + "node": "./server/providers/rail.js" + }, + "./sources/rail-service": "./src/sources/railService.js", + "./layers/rail/source": "./src/layers/rail/source.js", "./sources/map-features": "./src/sources/overpassFeatures.js", "./server/providers/mapillary": { "node": "./server/providers/mapillary.js" diff --git a/scripts/build-rail-paths.mjs b/scripts/build-rail-paths.mjs new file mode 100644 index 000000000..140ec3995 --- /dev/null +++ b/scripts/build-rail-paths.mjs @@ -0,0 +1,2356 @@ +#!/usr/bin/env node +/** + * Build per-feed rail path packs and schedule packs from each operator's + * static GTFS. + * + * node scripts/build-rail-paths.mjs (--feed ... | --all) + * [--out ] [--work ] [--keep] [--no-schedule] [--force] + * [--days ] [--from ] + * [--local-gtfs = ...] [--help] + * + * The feeds are named with `--feed` (repeatable), or `--all` builds every + * enabled feed: many downloads and hours of work, so it is never the + * default. `--help` prints this text. Packs are opt-in: nothing is + * downloaded when the app starts, only when this script is run. They add to + * live trains what their feed leaves out (the operator's lines, stop names, + * next stops); no train is ever placed from them. + * + * For every enabled rail feed with a `staticGtfsUrl` (src/data/railFeeds.js) + * the script downloads the static GTFS, streams routes.txt, trips.txt and + * shapes.txt straight out of the zip (nothing is unpacked to disk except a + * GTFS zip nested inside the download), keeps the routes the feed's + * classifier accepts and whose route_type is rail (or, with + * `staticRouteTypes`, one of the types it lists: `keepsRouteType`), and writes + * `/.json`: + * + * { version: 1, feedId, builtAt, + * source: { url, file?, entry?, lastModified, feedVersion? }, + * shapes: [[lat, lon, lat, lon, ...]], // 1e-5 degrees, identical shapes once + * dist: [[0, m, m, ...]], // cumulative metres, 0.1 m + * shapeSources: ['operator', ...], + * trips: { tripId: shapeIndex }, + * routes: { routeId: [shapeIndex, ...] } } + * + * Shapes are taken at face value: every one is the operator's shapes.txt as + * published (`shapeSources`: `operator`); nothing is redrawn from other map + * data, and no line is invented where the operator publishes none. + * `source.file` is a `--local-gtfs` zip's name, `source.entry` the GTFS zip + * read from inside the download (`staticGtfsEntry`), `source.feedVersion` + * the GTFS `feed_info.txt` `feed_version`. + * + * Quality gate: a shape with any vertex gap over 5 km is a straight chord, not + * a track, and is dropped (and logged). Trains on dropped shapes keep the + * layer's straight-line motion. Kept shapes are simplified to within 1 m, or + * the feed's `staticSimplifyM` (Douglas-Peucker), before rounding duplicates + * away. A trip without a usable shape gets none: the layer draws no line + * for it, only its stops. + * + * Unless `--no-schedule` is given, the timetable is written next to it as + * `/.schedule.json` (read by src/data/railSchedule.js); its + * `pathsBuiltAt` is the `builtAt` of the path pack whose shape indices its + * patterns use: + * + * { version: 1, feedId, builtAt, pathsBuiltAt, source, + * timezone, // agency_timezone (IANA) + * window: { from, to }, // service dates, YYYY-MM-DD + * routes: { routeId: { type, short?, long?, color?, textColor? } }, + * stops: [{ id, name, lat, lon, parent?, platform?, tz?, cc? }], + * patterns: [{ shape, stops, seq, dist, pass? }], + * profiles: [[arr0, dep0, arr1, dep1, ...]], // s after the trip start + * services: { serviceId: ['YYYY-MM-DD', ...] }, + * trips: { tripId: { pattern, profile, startSec, route, service, + * headsign?, name?, direction? } } } + * + * A stop the GTFS gives no coordinates (none, or (0, 0)) is left out of its + * runs with its times: it is never placed by guesswork, and no line is drawn + * to it; a run needs two located stops. + * + * Writing: a feed's packs go to temporary names of this process and are + * renamed into place together, the path pack first, only once both are + * written; nothing is replaced when either fails. A pack over the largest + * the server serves (RAIL_PATH_PACK_MAX_BYTES, src/data/railProxy.js; the + * error names its size) is not written: the feed fails and its previous + * packs stay. So does a build whose trips fall + * to under a fifth of the pack it would replace (the path pack's trips on + * the operator's shapes, the schedule's trips per day of its window), or to + * none: an upstream change, not a timetable; `--force` writes it anyway. + * With `--no-schedule` the path pack's shape list changes, so the feed's + * previous schedule pack, whose patterns index the old one, is removed; a + * feed whose trains are named only through its schedule pack (Renfe long + * and medium distance, KTMB: railFeedNeedsSchedulePack) is refused instead, + * and counts as failed. + * One build runs at a time per `--out`: it holds `/.build-rail-paths.lock` + * (process id and time, refreshed while it runs), and a second one refuses + * to start; a lock whose process is gone, or older than 6 hours, is taken + * over. + * + * Calendars are resolved to concrete dates for `--days` days (default 4) + * from `--from` (default yesterday in the agency zone), plus enough earlier + * dates for a multi-day run still out; only trips that run in the window are + * kept. stop_times.txt is streamed and only rows of those trips are held. + * A frequency-based trip (frequencies.txt) becomes one trip per departure, + * `@`, its template's stop times moved to that start. + * + * `--local-gtfs =` reads that feed's static GTFS from a zip already + * on disk instead of downloading it: a copy of the GTFS the feed publishes. + * The zip is only opened for reading, never moved, changed or deleted, and + * the packs record its file name and modification time, not its path. + * + * Every download is open: no account, key or sign-in. It is made over https + * only (a redirect to plain http is refused), capped in size and time, and + * sent with this script's User-Agent and any identifying headers the + * operator asks for (`staticHeaders`). + * + * Output goes to `--out`, else to RAIL_PATHS_DIR when it is set in this + * script's environment (the directory the server reads; the script does not + * read `.env`), else to `.cache/rail-paths/` (gitignored). It is served by + * `/api/rail/paths/` and `/api/rail/schedule/`. + * + * Downloads go to `--work` (kept) or a temporary directory (removed). A feed + * that fails is reported and skipped; the others still build, and the + * script then exits with code 2 (code 1: nothing could start, such as a bad + * argument or a build already holding the lock). Memory stays + * small: CSV is parsed as a stream and only rail shapes are held. A feed is + * abandoned once resident memory passes 1.75 GiB. + */ +import { randomBytes } from 'node:crypto'; +import { createReadStream, createWriteStream } from 'node:fs'; +import { + mkdir, + mkdtemp, + open, + readFile, + readdir, + rename, + rm, + stat, + writeFile, +} from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { StringDecoder } from 'node:string_decoder'; +import { pipeline } from 'node:stream/promises'; +import { Readable, Transform } from 'node:stream'; +import { fileURLToPath } from 'node:url'; +import { createInflateRaw, gzipSync } from 'node:zlib'; +import { + RAIL_FEED_ID_PATTERN, + classifyTrain, + railEnabledFeeds, + railFeedNeedsSchedulePack, +} from '../src/data/railFeeds.js'; +import { RAIL_PATH_PACK_MAX_BYTES } from '../src/data/railProxy.js'; +import { decodeRing, polygonsContain } from '../src/data/adminBoundaries.js'; +import { + RAIL_PATH_PACK_VERSION, + createRailPath, + pointAtS, + projectOnPath, +} from '../src/data/railPaths.js'; +import { RAIL_SCHEDULE_PACK_VERSION } from '../src/data/railSchedule.js'; +import { + dateFromDayNumber, + dayNumberOf, + isTimeZone, + localDate, +} from '../src/data/railTimeZones.js'; +import { isNoTrainPositionStop } from '../src/data/railNoPositions.js'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const DEFAULT_OUT = path.join(ROOT, '.cache', 'rail-paths'); +const USER_AGENT = + 'gods-eye-view-rail-paths/1.0 (+https://github.com/bilawalsidhu/gods-eye-view)'; + +/** Vertex gap that marks a shape as a straight chord (m). */ +const MAX_VERTEX_GAP_M = 5000; +/** Douglas-Peucker tolerance applied to kept shapes (m). */ +const SIMPLIFY_TOLERANCE_M = 1; +const MAX_DOWNLOAD_BYTES = 512 * 1024 * 1024; +/** Largest file inflated from a zip: a national stop_times.txt runs to GB. */ +const MAX_ENTRY_BYTES = 4 * 1024 * 1024 * 1024; +const DOWNLOAD_TIMEOUT_MS = 5 * 60_000; +const FEED_DEADLINE_MS = 15 * 60_000; +const MAX_REDIRECTS = 5; +/** Resident memory past which a feed is abandoned rather than the machine strained. */ +const MAX_RSS_BYTES = 1.75 * 1024 * 1024 * 1024; +let peakRss = 0; +const MAX_CSV_RECORD_CHARS = 1024 * 1024; +/** The lock a build holds in its `--out` folder (acquireDirLock). */ +export const BUILD_LOCK = '.build-rail-paths.lock'; +/** + * Share of the trips of the pack it replaces under which a build is taken + * for an upstream fault and not written (without `--force`). + */ +export const MIN_TRIP_SHARE = 0.2; +/** Exit code of a build in which one or more feeds failed. */ +export const FEED_FAILED_EXIT_CODE = 2; + +/** + * The exit code of a finished build: 0 when every feed built, otherwise + * FEED_FAILED_EXIT_CODE, even when the other feeds built (a scheduled run + * must notice one stale feed). + */ +export function buildExitCode(failed) { + return failed > 0 ? FEED_FAILED_EXIT_CODE : 0; +} + +/** + * GTFS route types that run on rails: tram, subway, rail, cable tram, + * funicular (7, and extended 1400), monorail, and the extended railway + * (100s), urban rail (400s) and tram (900s) families. + */ +export function isRailRouteType(value) { + const type = Number.parseInt(String(value ?? '').trim(), 10); + if (!Number.isInteger(type)) return false; + if ([0, 1, 2, 5, 7, 12, 1400].includes(type)) return true; + return ( + (type >= 100 && type < 200) || + (type >= 400 && type < 500) || + (type >= 900 && type < 1000) + ); +} + +/** + * Whether a feed keeps the routes.txt rows of this route_type. Without + * `staticRouteTypes` that is every rail type; with it, only the types it + * lists. The list may name a non-rail type for an operator that codes its + * trains as buses (route_type 3); that feed's classifier (or route rename) + * then keeps only the train routes. + */ +export function keepsRouteType(feed, value) { + if (!feed?.staticRouteTypes) return isRailRouteType(value); + const type = Number.parseInt(String(value ?? '').trim(), 10); + return Number.isInteger(type) && feed.staticRouteTypes.includes(type); +} + +// ---------------------------------------------------------------- arguments + +/** + * A registered feed's static GTFS read from a zip on disk + * (`--local-gtfs id=path`) instead of its `staticGtfsUrl`. + * @returns {{id: string, file: string}} The path made absolute. + */ +export function parseLocalGtfs(text) { + const at = String(text ?? '').indexOf('='); + const id = at > 0 ? text.slice(0, at) : ''; + const file = at > 0 ? text.slice(at + 1).trim() : ''; + if (!RAIL_FEED_ID_PATTERN.test(id) || !file) + throw new Error(`--local-gtfs needs =, got ${text}`); + return { id, file: path.resolve(file) }; +} + +/** + * Stand in for a download with a zip already on disk. Only its name and + * modification time reach the packs; it is opened read-only later. + * @returns {Promise<{file: string, lastModified: string, localName: string}>} + */ +export async function localStatic(file) { + const info = await stat(file); + if (!info.isFile() || info.size === 0) + throw new Error(`--local-gtfs ${path.basename(file)} is not a file`); + return { + file, + lastModified: info.mtime.toUTCString(), + localName: path.basename(file), + }; +} + +/** + * Whether `target` is `dir` or inside it. A name that merely starts with two + * dots (`src/..cache`) is inside; only a `..` path segment leaves. + */ +export function isInside(dir, target) { + const relative = path.relative(dir, target); + return ( + relative !== '..' && + !relative.startsWith(`..${path.sep}`) && + !path.isAbsolute(relative) + ); +} + +/** + * The command line. `{help: true}` for `--help`; otherwise the feeds must be + * named (`--feed`) or `--all` given, so a bare run never starts a build of + * every feed. `--out` defaults to RAIL_PATHS_DIR when the environment sets + * it (the directory the server serves), else to DEFAULT_OUT. + * @param {string[]} argv + * @param {Record} [env] + */ +export function parseBuildArgs(argv, env = process.env) { + const args = { + out: env?.RAIL_PATHS_DIR ? path.resolve(env.RAIL_PATHS_DIR) : DEFAULT_OUT, + work: null, + feeds: [], + all: false, + keep: false, + schedule: true, + force: false, + days: DEFAULT_SCHEDULE_DAYS, + from: null, + localGtfs: new Map(), + }; + if (argv.includes('--help') || argv.includes('-h')) return { help: true }; + for (let i = 0; i < argv.length; i++) { + const flag = argv[i]; + const value = () => { + const next = argv[++i]; + if (!next) throw new Error(`${flag} needs a value`); + return next; + }; + if (flag === '--out') args.out = path.resolve(value()); + else if (flag === '--work') args.work = path.resolve(value()); + else if (flag === '--feed') args.feeds.push(value()); + else if (flag === '--all') args.all = true; + else if (flag === '--force') args.force = true; + else if (flag === '--keep') args.keep = true; + else if (flag === '--no-schedule') args.schedule = false; + else if (flag === '--days') { + args.days = Number(value()); + if (!Number.isInteger(args.days) || args.days < 1 || args.days > 14) + throw new Error('--days must be 1 to 14'); + } else if (flag === '--from') { + args.from = value(); + if (!Number.isFinite(dayNumberOf(args.from))) + throw new Error('--from must be YYYY-MM-DD'); + } else if (flag === '--local-gtfs') { + const { id, file } = parseLocalGtfs(value()); + args.localGtfs.set(id, file); + } else throw new Error(`Unknown argument ${flag}`); + } + for (const forbidden of ['src', 'public']) + if (isInside(path.join(ROOT, forbidden), args.out)) + throw new Error(`--out must not be inside ${forbidden}/`); + if (args.all && args.feeds.length) + throw new Error('--all builds every feed: give it or --feed, not both'); + if (!args.all && !args.feeds.length) + throw new Error( + 'Name the feeds to build (--feed ...), or pass --all to build every enabled feed (many downloads, hours); --help for usage', + ); + return args; +} + +/** + * Why a feed is not built with these arguments, or null. `--no-schedule` + * removes a feed's schedule pack, and a feed whose trains are named only + * through it (railFeedNeedsSchedulePack) would leave the catalog with it. + * @param {object} feed + * @param {{schedule: boolean}} args + * @returns {string|null} + */ +export function buildRefusal(feed, args) { + if (args.schedule || !railFeedNeedsSchedulePack(feed)) return null; + return `--no-schedule: ${feed.id} names its trains only through its schedule pack, which --no-schedule would remove; build it without --no-schedule`; +} + +/** + * The `source` a feed's packs record: the URL the GTFS was downloaded from + * (the last redirect's target, `finalUrl`), else the registered one; a + * `--local-gtfs` zip's file name; the entry read inside the download. + * @param {object} feed + * @param {{finalUrl?: string, localName?: string, + * lastModified?: string|null}} got downloadStatic or localStatic's answer. + * @returns {object} + */ +export function packSource(feed, got) { + return { + url: got.finalUrl ?? feed.staticGtfsUrl, + ...(got.localName ? { file: got.localName } : {}), + ...(feed.staticGtfsEntry ? { entry: feed.staticGtfsEntry } : {}), + lastModified: got.lastModified ?? null, + }; +} + +// ---------------------------------------------------------------- download + +/** + * Download over https only, following each redirect hop by hand so a hop to + * plain http is refused, with a byte cap and a time limit. Nothing secret is + * sent: the User-Agent, and the identifying headers an operator asks for. + * @param {string} url + * @param {string} dest + * @param {Record|null} [extraHeaders] A feed's + * `staticHeaders`. + * @param {{timeoutMs?: number, maxBytes?: number}} [limits] The time limit + * for the whole download and the byte cap (defaults: 5 minutes, 512 MiB). + * @returns {Promise<{finalUrl: string, lastModified: string|null, bytes: number}>} + */ +export async function downloadStatic( + url, + dest, + extraHeaders = null, + { timeoutMs = DOWNLOAD_TIMEOUT_MS, maxBytes = MAX_DOWNLOAD_BYTES } = {}, +) { + let current = url; + const signal = AbortSignal.timeout(timeoutMs); + for (let hop = 0; hop <= MAX_REDIRECTS; hop++) { + const target = new URL(current); + if (target.protocol !== 'https:') + throw new Error(`refusing non-https URL ${current}`); + const response = await fetch(target.href, { + redirect: 'manual', + signal, + headers: { + 'User-Agent': USER_AGENT, + Accept: '*/*', + // Identify-yourself headers an operator asks for (Digitraffic, + // Entur). + ...(extraHeaders || {}), + }, + }); + if ([301, 302, 303, 307, 308].includes(response.status)) { + await response.body?.cancel(); + const location = response.headers.get('location'); + if (!location) throw new Error('redirect without a location'); + current = new URL(location, current).href; + continue; + } + if (!response.ok || !response.body) + throw new Error(`HTTP ${response.status} from ${current}`); + const declared = Number(response.headers.get('content-length')); + if (declared > maxBytes) throw new Error(`download is ${declared} bytes`); + let bytes = 0; + await pipeline( + Readable.fromWeb(response.body), + new Transform({ + transform(chunk, _encoding, done) { + bytes += chunk.length; + if (bytes > maxBytes) + done(new Error('download exceeds the size cap')); + else done(null, chunk); + }, + }), + createWriteStream(dest), + ); + return { + finalUrl: current, + lastModified: response.headers.get('last-modified'), + bytes, + }; + } + throw new Error('too many redirects'); +} + +// ---------------------------------------------------------------- zip + +/** + * Read a zip's central directory. Entries are streamed on demand; nothing is + * extracted. Stored and deflated entries, zip64 sizes; encrypted entries are + * refused. + * @param {string} file + */ +export async function openZip(file) { + const handle = await open(file, 'r'); + try { + const { size } = await handle.stat(); + const tailSize = Math.min(size, 65_557); + const tail = Buffer.alloc(tailSize); + await handle.read(tail, 0, tailSize, size - tailSize); + let eocd = -1; + for (let i = tailSize - 22; i >= 0; i--) { + if (tail.readUInt32LE(i) === 0x06054b50) { + eocd = i; + break; + } + } + if (eocd < 0) throw new Error('not a zip file'); + let count = tail.readUInt16LE(eocd + 10); + let cdSize = tail.readUInt32LE(eocd + 12); + let cdOffset = tail.readUInt32LE(eocd + 16); + if (count === 0xffff || cdSize === 0xffffffff || cdOffset === 0xffffffff) { + const locator = eocd - 20; + if (locator < 0 || tail.readUInt32LE(locator) !== 0x07064b50) + throw new Error('zip64 locator missing'); + const at = Number(tail.readBigUInt64LE(locator + 8)); + const record = Buffer.alloc(56); + await handle.read(record, 0, 56, at); + if (record.readUInt32LE(0) !== 0x06064b50) + throw new Error('zip64 record missing'); + count = Number(record.readBigUInt64LE(32)); + cdSize = Number(record.readBigUInt64LE(40)); + cdOffset = Number(record.readBigUInt64LE(48)); + } + if (cdOffset + cdSize > size) throw new Error('zip directory out of range'); + const cd = Buffer.alloc(cdSize); + await handle.read(cd, 0, cdSize, cdOffset); + const entries = new Map(); + let p = 0; + for (let k = 0; k < count; k++) { + if (cd.readUInt32LE(p) !== 0x02014b50) + throw new Error('bad zip directory entry'); + const flags = cd.readUInt16LE(p + 8); + const method = cd.readUInt16LE(p + 10); + let compressed = cd.readUInt32LE(p + 20); + let uncompressed = cd.readUInt32LE(p + 24); + const nameLength = cd.readUInt16LE(p + 28); + const extraLength = cd.readUInt16LE(p + 30); + const commentLength = cd.readUInt16LE(p + 32); + let offset = cd.readUInt32LE(p + 42); + const name = cd.toString('utf8', p + 46, p + 46 + nameLength); + let e = p + 46 + nameLength; + const extraEnd = e + extraLength; + while (e + 4 <= extraEnd) { + const id = cd.readUInt16LE(e); + const length = cd.readUInt16LE(e + 2); + if (id === 0x0001) { + let q = e + 4; + if (uncompressed === 0xffffffff) { + uncompressed = Number(cd.readBigUInt64LE(q)); + q += 8; + } + if (compressed === 0xffffffff) { + compressed = Number(cd.readBigUInt64LE(q)); + q += 8; + } + if (offset === 0xffffffff) offset = Number(cd.readBigUInt64LE(q)); + } + e += 4 + length; + } + p = extraEnd + commentLength; + if (name.endsWith('/')) continue; + entries.set(name, { + name, + method, + encrypted: (flags & 1) === 1, + compressed, + uncompressed, + offset, + }); + } + return { file, size, entries, handle }; + } catch (error) { + await handle.close(); + throw error; + } +} + +/** The entry named `base`, at the root or in a single top-level folder. */ +function findEntry(zip, base) { + const exact = zip.entries.get(base); + if (exact) return exact; + for (const entry of zip.entries.values()) { + if ( + entry.name.split('/').pop() === base && + !entry.name.startsWith('__MACOSX') + ) + return entry; + } + return null; +} + +/** + * A readable stream of one entry's uncompressed bytes, refused or ended + * with an error past `maxBytes` (declared or inflated). + * @param {object} zip From openZip. + * @param {object} entry + * @param {{maxBytes?: number}} [options] Default MAX_ENTRY_BYTES; a caller + * that holds the whole entry in memory gives a smaller cap. + */ +export async function entryStream( + zip, + entry, + { maxBytes = MAX_ENTRY_BYTES } = {}, +) { + if (entry.encrypted) throw new Error(`${entry.name} is encrypted`); + if (entry.method !== 0 && entry.method !== 8) + throw new Error(`${entry.name} uses compression method ${entry.method}`); + if (entry.uncompressed > maxBytes) + throw new Error(`${entry.name} is ${entry.uncompressed} bytes`); + const header = Buffer.alloc(30); + await zip.handle.read(header, 0, 30, entry.offset); + if (header.readUInt32LE(0) !== 0x04034b50) + throw new Error(`bad local header for ${entry.name}`); + const start = + entry.offset + 30 + header.readUInt16LE(26) + header.readUInt16LE(28); + if (start + entry.compressed > zip.size) + throw new Error(`${entry.name} runs past the end of the zip`); + const raw = createReadStream(zip.file, { + start, + end: Math.max(start, start + entry.compressed - 1), + }); + if (entry.compressed === 0) { + raw.destroy(); + return Readable.from([]); + } + let produced = 0; + const cap = new Transform({ + transform(chunk, _encoding, done) { + produced += chunk.length; + if (produced > maxBytes) + done(new Error(`${entry.name} inflates past the cap`)); + else done(null, chunk); + }, + }); + if (entry.method === 0) return raw.pipe(cap); + const inflate = createInflateRaw(); + raw.on('error', (error) => inflate.destroy(error)); + inflate.on('error', (error) => cap.destroy(error)); + return raw.pipe(inflate).pipe(cap); +} + +// ---------------------------------------------------------------- csv + +function countQuotes(text) { + let n = 0; + for (let i = text.indexOf('"'); i >= 0; i = text.indexOf('"', i + 1)) n++; + return n; +} + +/** One CSV record (RFC 4180 quoting), fields trimmed. */ +export function parseRecord(line) { + if (line.endsWith('\r')) line = line.slice(0, -1); + if (!line.includes('"')) return line.split(',').map((f) => f.trim()); + const fields = []; + let field = ''; + let quoted = false; + for (let i = 0; i < line.length; i++) { + const c = line[i]; + if (quoted) { + if (c === '"') { + if (line[i + 1] === '"') { + field += '"'; + i++; + } else quoted = false; + } else field += c; + } else if (c === '"') quoted = true; + else if (c === ',') { + fields.push(field.trim()); + field = ''; + } else field += c; + } + fields.push(field.trim()); + return fields; +} + +/** + * Stream a CSV entry as objects keyed by the (trimmed) header, in batches. + * Records spanning lines inside quotes are joined; only one chunk and one + * partial record are held at a time. + * @param {import('node:stream').Readable} stream + * @param {(row: Record) => void} onRow + */ +export async function readCsv(stream, onRow) { + const decoder = new StringDecoder('utf8'); + let carry = ''; + let header = null; + let first = true; + let rows = 0; + const emit = (line) => { + if (!line || line === '\r') return; + const fields = parseRecord(line); + if (!header) { + header = fields; + return; + } + const row = {}; + for (let i = 0; i < header.length; i++) row[header[i]] = fields[i] ?? ''; + onRow(row); + rows++; + }; + const consume = (text, final) => { + if (first) { + if (text.charCodeAt(0) === 0xfeff) text = text.slice(1); + first = false; + } + const lines = text.split('\n'); + carry = final ? '' : lines.pop(); + let pending = null; + for (const line of lines) { + const record = pending === null ? line : `${pending}\n${line}`; + if (countQuotes(record) % 2 === 1) { + pending = record; + continue; + } + pending = null; + emit(record); + } + if (pending !== null) { + if (final) emit(pending); + else carry = `${pending}\n${carry}`; + } + if (carry.length > MAX_CSV_RECORD_CHARS) + throw new Error('CSV record too long'); + }; + for await (const chunk of stream) { + consume(carry + decoder.write(chunk), false); + checkMemory(); + } + consume(carry + decoder.end(), true); + return rows; +} + +/** + * Abandon the work in hand when resident memory is over the build's cap, + * 1.75 GiB. + * @throws when over the cap. + */ +export function checkMemory() { + const rss = process.memoryUsage.rss(); + if (rss > peakRss) peakRss = rss; + if (rss > MAX_RSS_BYTES) + throw new Error( + `resident memory ${Math.round(rss / 2 ** 20)} MiB over cap`, + ); +} + +// ---------------------------------------------------------------- geometry + +/** + * Whether a stop has coordinates: both finite and not both zero. Some feeds + * write (0, 0) for a stop they cannot place (a foreign station their + * international trains serve), and that is read as no coordinates at all. + */ +export function hasStopCoordinates(lat, lon) { + return ( + Number.isFinite(lat) && Number.isFinite(lon) && !(lat === 0 && lon === 0) + ); +} + +function haversineM(lat1, lon1, lat2, lon2) { + const r = 6_371_008.8; + const toRad = Math.PI / 180; + const dLat = (lat2 - lat1) * toRad; + const dLon = (lon2 - lon1) * toRad; + const a = + Math.sin(dLat / 2) ** 2 + + Math.cos(lat1 * toRad) * Math.cos(lat2 * toRad) * Math.sin(dLon / 2) ** 2; + return 2 * r * Math.asin(Math.min(1, Math.sqrt(a))); +} + +const round5 = (value) => Math.round(value * 1e5) / 1e5; +const round1 = (value) => Math.round(value * 10) / 10; + +/** + * Turn raw shape points into a rounded flat polyline with cumulative + * distances, or a reason it was dropped: a vertex gap over + * MAX_VERTEX_GAP_M marks a chord. + * @param {number[]} triples `[sequence, lat, lon, ...]` + * @param {{toleranceM?: number}} [options] Simplification tolerance (m). + */ +export function finishShape( + triples, + { toleranceM = SIMPLIFY_TOLERANCE_M } = {}, +) { + const order = []; + for (let i = 0; i < triples.length; i += 3) order.push(i); + order.sort((a, b) => triples[a] - triples[b]); + const flat = []; + for (const i of order) { + const lat = round5(triples[i + 1]); + const lon = round5(triples[i + 2]); + if (!Number.isFinite(lat) || !Number.isFinite(lon) || Math.abs(lat) > 90) + continue; + const n = flat.length; + if (n && flat[n - 2] === lat && flat[n - 1] === lon) continue; + flat.push(lat, lon); + } + if (flat.length < 4) return { dropped: 'fewer than two points' }; + let maxGap = 0; + let gaps = 0; + for (let i = 2; i < flat.length; i += 2) { + const gap = haversineM(flat[i - 2], flat[i - 1], flat[i], flat[i + 1]); + if (gap > maxGap) maxGap = gap; + if (gap > MAX_VERTEX_GAP_M) gaps++; + } + // The gate reads the operator's own vertices; simplifying first would + // manufacture long gaps on straight track. + if (maxGap > MAX_VERTEX_GAP_M) + return { dropped: `vertex gap ${Math.round(maxGap)} m`, maxGap, gaps }; + const line = simplifyFlat(flat, toleranceM); + const dist = [0]; + let total = 0; + for (let i = 2; i < line.length; i += 2) { + total += haversineM(line[i - 2], line[i - 1], line[i], line[i + 1]); + dist.push(round1(total)); + } + return { flat: line, dist, maxGap, rawPoints: flat.length / 2, gaps }; +} + +/** + * Douglas-Peucker on a flat lat/lon polyline in a local metric frame. Every + * dropped vertex lies within `toleranceM` of the kept line, far inside what + * a train marker shows; dense operator shapes shrink several-fold. + * @param {number[]} flat + * @param {number} toleranceM + * @returns {number[]} + */ +export function simplifyFlat(flat, toleranceM) { + const n = flat.length / 2; + if (n <= 2) return flat.slice(); + let sumLat = 0; + for (let i = 0; i < n; i++) sumLat += flat[2 * i]; + const kx = 111_320 * Math.cos(((sumLat / n) * Math.PI) / 180); + const ky = 111_320; + const x = new Float64Array(n); + const y = new Float64Array(n); + for (let i = 0; i < n; i++) { + x[i] = (flat[2 * i + 1] - flat[1]) * kx; + y[i] = (flat[2 * i] - flat[0]) * ky; + } + const keep = new Uint8Array(n); + keep[0] = 1; + keep[n - 1] = 1; + const stack = [0, n - 1]; + const tolerance2 = toleranceM * toleranceM; + while (stack.length) { + const last = stack.pop(); + const first = stack.pop(); + const dx = x[last] - x[first]; + const dy = y[last] - y[first]; + const len2 = dx * dx + dy * dy; + let worst = -1; + let worstD2 = tolerance2; + for (let i = first + 1; i < last; i++) { + let t = + len2 > 0 ? ((x[i] - x[first]) * dx + (y[i] - y[first]) * dy) / len2 : 0; + t = Math.max(0, Math.min(1, t)); + const ex = x[first] + dx * t - x[i]; + const ey = y[first] + dy * t - y[i]; + const d2 = ex * ex + ey * ey; + if (d2 > worstD2) { + worstD2 = d2; + worst = i; + } + } + if (worst < 0) continue; + keep[worst] = 1; + stack.push(first, worst, worst, last); + } + const out = []; + for (let i = 0; i < n; i++) + if (keep[i]) out.push(flat[2 * i], flat[2 * i + 1]); + return out; +} + +// ---------------------------------------------------------------- per feed + +/** + * Spain's provinces (ISO 3166-2) grouped into the autonomous communities the + * category rule uses as states. Elsewhere the Natural Earth unit is the state. + */ +const STATE_GROUPS = Object.freeze({ + ES: Object.fromEntries( + Object.entries({ + AN: ['AL', 'CA', 'CO', 'GR', 'H', 'J', 'MA', 'SE'], + AR: ['HU', 'TE', 'Z'], + AS: ['O'], + IB: ['PM'], + CN: ['GC', 'TF'], + CB: ['S'], + CL: ['AV', 'BU', 'LE', 'P', 'SA', 'SG', 'SO', 'VA', 'ZA'], + CM: ['AB', 'CR', 'CU', 'GU', 'TO'], + CT: ['B', 'GI', 'L', 'T'], + VC: ['A', 'CS', 'V'], + EX: ['BA', 'CC'], + GA: ['C', 'LU', 'OR', 'PO'], + MD: ['M'], + MC: ['MU'], + NC: ['NA'], + PV: ['VI', 'BI', 'SS'], + RI: ['LO'], + CE: ['CE'], + ML: ['ML'], + }).flatMap(([community, provinces]) => + provinces.map((province) => ['ES-' + province, 'ES-' + community]), + ), + ), +}); +/** Nearest unit within this distance claims a point no polygon contains. */ +const STATE_SNAP_KM = 25; + +let statesPromise = null; +/** Natural Earth admin-1 units with decoded polygons and boxes, loaded once. */ +async function loadStates() { + statesPromise ??= (async () => { + const file = path.join( + ROOT, + 'src/data/local_data/natural_earth/states_provinces.json', + ); + const { meta, features } = JSON.parse( + await readFile(file, { encoding: 'utf8' }), + ); + return features + .filter((unit) => unit.iso2 && Array.isArray(unit.polygons)) + .map((unit) => { + const polygons = unit.polygons.map((poly) => + poly.map((ring) => decodeRing(ring, unit.d ?? meta.decimals)), + ); + let west = Infinity; + let south = Infinity; + let east = -Infinity; + let north = -Infinity; + for (const [outer] of polygons) + for (const [lon, lat] of outer) { + west = Math.min(west, lon); + east = Math.max(east, lon); + south = Math.min(south, lat); + north = Math.max(north, lat); + } + return { unit, polygons, box: { west, south, east, north } }; + }); + })(); + return statesPromise; +} + +/** + * The state a point lies in, for the category rules: `{country, state}` + * (ISO codes; Spain's provinces grouped into communities), or null. A point + * just outside every simplified polygon (a coastal station) goes to the + * nearest unit within STATE_SNAP_KM. + */ +export async function createStateLookup() { + const states = await loadStates(); + const result = (unit) => ({ + country: unit.iso2, + state: STATE_GROUPS[unit.iso2]?.[unit.iso] ?? unit.iso ?? unit.name, + }); + return (lat, lon) => { + if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null; + for (const { unit, polygons, box } of states) { + if ( + lat < box.south || + lat > box.north || + lon < box.west || + lon > box.east + ) + continue; + if (polygonsContain(polygons, lat, lon)) return result(unit); + } + let best = null; + const margin = STATE_SNAP_KM / 111; + for (const { unit, polygons, box } of states) { + if ( + lat < box.south - margin || + lat > box.north + margin || + lon < box.west - margin * 2 || + lon > box.east + margin * 2 + ) + continue; + for (const [outer] of polygons) + for (const [vlon, vlat] of outer) { + const km = haversineM(lat, lon, vlat, vlon) / 1000; + if (km <= STATE_SNAP_KM && (!best || km < best.km)) + best = { km, unit }; + } + } + return best ? result(best.unit) : null; + }; +} + +/** + * Where each rail trip begins and ends, for a feed whose category rule needs + * it (`staticTripEnds: true`): the first and last stop by stop_sequence, each + * as `{id, name, lat, lon, country, state}` (`createStateLookup`; null + * country and state for a point no unit claims), and every country the trip + * stops in. Only trips on rail routes are held, so memory follows the + * feed's trains, not its buses. + * @returns {Promise>} + */ +export async function readTripEnds(feed, zip) { + const routes = new Set(); + await csvEntry(zip, 'routes.txt', (row) => { + if (row.route_id && keepsRouteType(feed, row.route_type)) + routes.add(row.route_id); + }); + const runs = new Map(); + await csvEntry(zip, 'trips.txt', (row) => { + if (row.trip_id && routes.has(row.route_id)) runs.set(row.trip_id, null); + }); + const stops = new Map(); + await csvEntry(zip, 'stops.txt', (row) => { + const lat = Number(row.stop_lat); + const lon = Number(row.stop_lon); + if (row.stop_id && hasStopCoordinates(lat, lon)) + stops.set(row.stop_id, { + id: row.stop_id, + name: row.stop_name || '', + lat, + lon, + }); + }); + const stateAt = await createStateLookup(); + const placed = (id) => { + const stop = stops.get(id); + if (stop && !('country' in stop)) { + const at = stateAt(stop.lat, stop.lon); + stop.country = at?.country ?? null; + stop.state = at?.state ?? null; + } + return stop ?? null; + }; + await csvEntry(zip, 'stop_times.txt', (row) => { + if (!runs.has(row.trip_id)) return; + const stop = placed(row.stop_id); + const seq = Number(row.stop_sequence); + if (!stop || !Number.isFinite(seq)) return; + let run = runs.get(row.trip_id); + if (!run) + runs.set( + row.trip_id, + (run = { first: seq, last: seq, from: stop, to: stop, countries: '' }), + ); + if (seq < run.first) [run.first, run.from] = [seq, stop]; + if (seq > run.last) [run.last, run.to] = [seq, stop]; + // ISO codes joined by `|`: small enough for a national feed's year of + // trips (over a million). + if (stop.country && !run.countries.includes(stop.country)) + run.countries += `${run.countries ? '|' : ''}${stop.country}`; + }); + for (const [tripId, run] of runs) { + if (!run) runs.delete(tripId); + else + runs.set(tripId, { + from: run.from, + to: run.to, + countries: run.countries ? run.countries.split('|').sort() : [], + }); + } + return runs; +} + +/** + * How a feed's route ids are written into its packs. Most feeds keep the + * operator's ids. A feed whose classifier needs more than the id gives a + * `staticRouteRename(route, {stop, stateAt, agencyName})` (from the + * routes.txt row; `agencyName(agency_id)` reads agency.txt) or a + * `staticTripRename(trip, routeId, {ends})` (per trips.txt row; `ends` is the + * trip's `readTripEnds` entry, or null, when the feed sets `staticTripEnds`) + * that returns an id carrying it (see railFeeds.js `scopedRoute`); the + * realtime proxy reads the same ids back from the schedule pack. + * @returns {Promise<{route: (row: object) => string|null, + * trip: (row: object, routeId: string) => string|null}>} + */ +export async function createRouteNames(feed, zip) { + const tripEnds = + feed.staticTripEnds === true && typeof feed.staticTripRename === 'function' + ? await readTripEnds(feed, zip) + : null; + let helpers = null; + if (typeof feed.staticRouteRename === 'function') { + const stops = new Map(); + await csvEntry( + zip, + 'stops.txt', + (row) => { + const lat = Number(row.stop_lat); + const lon = Number(row.stop_lon); + if (row.stop_id && hasStopCoordinates(lat, lon)) + stops.set(row.stop_id.trim(), { lat, lon }); + }, + { required: false }, + ); + const stateAt = await createStateLookup(); + // agency_id → agency_name, for a feed that sorts its routes by operator. + const agencies = new Map(); + await csvEntry( + zip, + 'agency.txt', + (row) => { + agencies.set(String(row.agency_id ?? '').trim(), row.agency_name ?? ''); + }, + { required: false }, + ); + helpers = { + stop: (id) => stops.get(id) ?? null, + stateAt, + agencyName: (id) => agencies.get(String(id ?? '').trim()) ?? null, + }; + } + return { + /** The pack id of a routes.txt row this feed keeps, else null. */ + route(row) { + if (!row.route_id || !keepsRouteType(feed, row.route_type)) return null; + const id = helpers + ? (feed.staticRouteRename(row, helpers) ?? row.route_id) + : row.route_id; + // A per-trip rename decides at the trip. + if (typeof feed.staticTripRename === 'function') return id; + return classifyTrain(feed, id) ? id : null; + }, + /** The pack id of a trips.txt row on a kept route, else null. */ + trip(row, routeId) { + if (typeof feed.staticTripRename !== 'function') return routeId; + const id = + (tripEnds + ? feed.staticTripRename(row, routeId, { + ends: tripEnds.get(row.trip_id) ?? null, + }) + : feed.staticTripRename(row, routeId)) ?? routeId; + return classifyTrain(feed, id, { tripId: row.trip_id }) ? id : null; + }, + }; +} + +async function csvEntry(zip, base, onRow, { required = true } = {}) { + const entry = findEntry(zip, base); + if (!entry) { + if (required) throw new Error(`${base} missing`); + return 0; + } + return readCsv(await entryStream(zip, entry), onRow); +} + +/** + * `feed_info.txt` `feed_version`, or null: the packs record which version + * of the operator's timetable they were built from. + */ +export async function readFeedVersion(zip) { + let version = null; + await csvEntry( + zip, + 'feed_info.txt', + (row) => { + const value = String(row.feed_version ?? '').trim(); + if (version === null && value && value.length <= 64) version = value; + }, + { required: false }, + ); + return version; +} + +/** + * Build one feed's pack from an open GTFS zip: the operator's shapes as + * published, through the quality gate and simplification. + */ +export async function buildPack(feed, zip, source, names = null) { + names ??= await createRouteNames(feed, zip); + const deadline = Date.now() + FEED_DEADLINE_MS; + const overdue = () => { + if (Date.now() > deadline) + throw new Error('feed build ran past its deadline'); + }; + /** routes.txt id → pack id, for the routes this feed keeps. */ + const railRoutes = new Map(); + let routeRows = 0; + await csvEntry(zip, 'routes.txt', (row) => { + routeRows++; + const id = names.route(row); + if (id) railRoutes.set(row.route_id, id); + }); + overdue(); + const tripShape = new Map(); + const routeShapes = new Map(); + let railTrips = 0; + let shapelessTrips = 0; + await csvEntry(zip, 'trips.txt', (row) => { + const base = railRoutes.get(row.route_id); + const routeId = base ? names.trip(row, base) : null; + if (!routeId) return; + railTrips++; + if (!row.shape_id) { + shapelessTrips++; + return; + } + if (row.trip_id) tripShape.set(row.trip_id, row.shape_id); + let shapes = routeShapes.get(routeId); + if (!shapes) routeShapes.set(routeId, (shapes = new Set())); + shapes.add(row.shape_id); + }); + overdue(); + const wanted = new Map(); + for (const shapes of routeShapes.values()) + for (const id of shapes) if (!wanted.has(id)) wanted.set(id, []); + if (wanted.size) + await csvEntry(zip, 'shapes.txt', (row) => { + const points = wanted.get(row.shape_id); + if (!points) return; + points.push( + Number(row.shape_pt_sequence), + Number(row.shape_pt_lat), + Number(row.shape_pt_lon), + ); + }); + overdue(); + + let sparseShapes = 0; + const shapes = []; + const dist = []; + const shapeSources = []; + const indexByKey = new Map(); + const indexByShapeId = new Map(); + const dropped = []; + let rawPoints = 0; + for (const [shapeId, triples] of wanted) { + // A feed whose operator shapes are dense enough to break the pack cap + // (Vy: 1.2 million points at 1 m) sets a coarser `staticSimplifyM`. + const result = finishShape(triples, { + toleranceM: feed.staticSimplifyM ?? SIMPLIFY_TOLERANCE_M, + }); + if (result.maxGap > MAX_VERTEX_GAP_M && !result.dropped) sparseShapes++; + wanted.set(shapeId, null); + if (result.dropped) { + dropped.push({ shapeId, reason: result.dropped }); + continue; + } + const key = result.flat.join(','); + if (!indexByKey.has(key)) rawPoints += result.rawPoints; + let index = indexByKey.get(key); + if (index === undefined) { + index = shapes.length; + shapes.push(result.flat); + dist.push(result.dist); + shapeSources.push('operator'); + indexByKey.set(key, index); + } + indexByShapeId.set(shapeId, index); + } + const trips = Object.create(null); + let tripsMapped = 0; + for (const [tripId, shapeId] of tripShape) { + const index = indexByShapeId.get(shapeId); + if (index === undefined) continue; + trips[tripId] = index; + tripsMapped++; + } + const routes = Object.create(null); + for (const [routeId, ids] of routeShapes) { + const list = [ + ...new Set([...ids].map((id) => indexByShapeId.get(id))), + ].filter((index) => index !== undefined); + if (list.length) routes[routeId] = list.sort((a, b) => a - b); + } + const pack = { + version: RAIL_PATH_PACK_VERSION, + feedId: feed.id, + builtAt: new Date().toISOString(), + source, + shapes, + dist, + shapeSources, + trips, + routes, + }; + + return { + pack, + stats: { + routeRows, + railRoutes: railRoutes.size, + routesWithShapes: Object.keys(routes).length, + railTrips, + shapelessTrips, + tripsMapped, + shapeIds: indexByShapeId.size + dropped.length, + shapesKept: shapes.length, + shapesDropped: dropped.length, + sparseShapes, + points: shapes.reduce((sum, flat) => sum + flat.length / 2, 0), + rawPoints, + dropped, + }, + }; +} + +// ---------------------------------------------------------------- schedule + +/** Service dates in a pack by default: yesterday, today and two days on. */ +const DEFAULT_SCHEDULE_DAYS = 4; +/** Longest run (days) a pack looks back for: a multi-day train still out. */ +const MAX_LOOKBACK_DAYS = 7; +/** A stop farther than this from its trip's shape unties the pattern from it (m). */ +const MAX_STOP_OFFSET_M = 1000; +/** A stop's match on a shape may be this much worse than its best (m). */ +const STOP_MATCH_SLACK_M = 50; +/** Arc stride of the earliest-match search along a shape (m). */ +const STOP_SEARCH_STEP_M = 2000; +const WEEKDAYS = [ + 'sunday', + 'monday', + 'tuesday', + 'wednesday', + 'thursday', + 'friday', + 'saturday', +]; + +/** GTFS 'H:MM:SS' (hours may pass 24) to seconds, or NaN when blank. */ +export function parseGtfsTime(text) { + const match = /^\s*(\d{1,3}):([0-5]\d):([0-5]\d)\s*$/.exec(text ?? ''); + return match + ? Number(match[1]) * 3600 + Number(match[2]) * 60 + Number(match[3]) + : NaN; +} + +/** Most runs one frequencies.txt row may expand to (a minute apart all day). */ +const MAX_FREQUENCY_RUNS = 1440; + +/** + * The departures of a frequency-based trip (frequencies.txt): from each + * row's start_time every headway_secs while before its end_time, the GTFS + * reading for both `exact_times` values. Rows are flat `[start, end, + * headway, ...]` in seconds; a malformed row adds nothing, and one row adds + * at most MAX_FREQUENCY_RUNS runs. Sorted, without duplicates. + * @param {number[]} windows + * @returns {number[]} + */ +export function frequencyDepartures(windows) { + const out = new Set(); + for (let i = 0; i + 2 < windows.length; i += 3) { + const [start, end, headway] = windows.slice(i, i + 3); + if (!Number.isFinite(start) || !Number.isFinite(end)) continue; + if (!Number.isInteger(headway) || headway <= 0) continue; + for ( + let at = start, n = 0; + at < end && n < MAX_FREQUENCY_RUNS; + at += headway, n++ + ) + out.add(at); + } + return [...out].sort((a, b) => a - b); +} + +/** Seconds after midnight as GTFS 'HH:MM:SS' (hours may pass 24). */ +function gtfsTimeText(sec) { + const pad = (n) => String(n).padStart(2, '0'); + return `${pad(Math.floor(sec / 3600))}:${pad(Math.floor(sec / 60) % 60)}:${pad(sec % 60)}`; +} + +/** + * Resolve calendar.txt and calendar_dates.txt rows to the concrete service + * dates between two UTC-day numbers (inclusive). + * @param {object[]} calendar + * @param {object[]} exceptions + * @param {number} fromDay + * @param {number} toDay + * @returns {Map} service_id → sorted day numbers. + */ +export function resolveServiceDays(calendar, exceptions, fromDay, toDay) { + const days = new Map(); + const setFor = (id) => { + let set = days.get(id); + if (!set) days.set(id, (set = new Set())); + return set; + }; + for (const row of calendar) { + const start = dayNumberOf(row.start_date); + const end = dayNumberOf(row.end_date); + if (!row.service_id || !Number.isFinite(start) || !Number.isFinite(end)) + continue; + const set = setFor(row.service_id); + const last = Math.min(end, toDay); + // Day 0 (1970-01-01) was a Thursday. + for (let day = Math.max(start, fromDay); day <= last; day++) + if (row[WEEKDAYS[(((day + 4) % 7) + 7) % 7]] === '1') set.add(day); + } + for (const row of exceptions) { + const day = dayNumberOf(row.date); + if ( + !row.service_id || + !Number.isFinite(day) || + day < fromDay || + day > toDay + ) + continue; + const set = setFor(row.service_id); + if (row.exception_type === '1') set.add(day); + else if (row.exception_type === '2') set.delete(day); + } + const out = new Map(); + for (const [id, set] of days) + if (set.size) + out.set( + id, + [...set].sort((a, b) => a - b), + ); + return out; +} + +/** + * Arc length of each stop along a path, in stop order and never decreasing. + * Each stop takes the earliest point at or after the previous stop whose + * distance is within STOP_MATCH_SLACK_M of its best forward match, so a loop + * that passes a station twice binds the station to the pass in order. + * @param {object} path From createRailPath. + * @param {Array<{lat: number, lon: number}>} stops + * @returns {{dist: number[], maxOffsetM: number}|null} + */ +export function projectStops(path, stops) { + const dist = []; + let prev = 0; + let maxOffsetM = 0; + for (const { lat, lon } of stops) { + const rest = Math.max(0, path.length - prev); + const whole = projectOnPath(path, lat, lon, prev + rest / 2, rest / 2 + 1); + if (!whole) return null; + let pick = { s: whole.s, offsetM: whole.offsetM }; + const accept = whole.offsetM + STOP_MATCH_SLACK_M; + for ( + let a = prev; + a < whole.s - STOP_SEARCH_STEP_M; + a += STOP_SEARCH_STEP_M + ) { + const hit = projectOnPath( + path, + lat, + lon, + a + STOP_SEARCH_STEP_M / 2, + STOP_SEARCH_STEP_M / 2, + ); + if (hit && hit.offsetM <= accept && hit.s + 1 >= prev) { + pick = { s: hit.s, offsetM: hit.offsetM }; + break; + } + } + const s = Math.max(prev, pick.s); + dist.push(round1(s)); + prev = s; + // The path's flat projection stretches east-west distance away from its + // middle latitude (2% at Newark on a Roanoke to New York line), so the + // offset is the great-circle distance to the matched point. + const foot = pointAtS(path, pick.s); + const offsetM = foot + ? haversineM(lat, lon, foot.lat, foot.lon) + : pick.offsetM; + if (offsetM > maxOffsetM) maxOffsetM = offsetM; + } + return { dist, maxOffsetM }; +} + +/** Cumulative great-circle metres along a list of points. */ +function straightDist(points) { + const dist = [0]; + for (let k = 1; k < points.length; k++) { + const a = points[k - 1]; + const b = points[k]; + dist.push(round1(dist[k - 1] + haversineM(a.lat, a.lon, b.lat, b.lon))); + } + return dist; +} + +/** + * Fill blank stop times: a stop with one of arrival/departure takes it for + * both; a stop with neither (GTFS allows that between timepoints) is placed + * by distance between the timed stops around it. + * @returns {boolean} false when the first or last stop has no time. + */ +export function fillTimes(arr, dep, dist) { + const n = arr.length; + for (let k = 0; k < n; k++) { + if (!Number.isFinite(arr[k])) arr[k] = dep[k]; + if (!Number.isFinite(dep[k])) dep[k] = arr[k]; + } + if (!Number.isFinite(dep[0]) || !Number.isFinite(arr[n - 1])) return false; + let last = 0; + for (let k = 1; k < n; k++) { + if (!Number.isFinite(arr[k])) continue; + const span = dist[k] - dist[last]; + for (let j = last + 1; j < k; j++) { + const f = + span > 0 ? (dist[j] - dist[last]) / span : (j - last) / (k - last); + arr[j] = dep[j] = Math.round(dep[last] + (arr[k] - dep[last]) * f); + } + last = k; + } + return true; +} + +/** + * Build one feed's schedule pack from an open GTFS zip and the path pack just + * built from it. A stop with no coordinates (hasStopCoordinates) is left out + * of its runs, so no line is drawn to it and nothing is guessed; a run needs + * two located stops. A pattern whose trips have no usable operator shape + * takes `shape: null`, and the layer draws no line for it. + * @param {object} feed + * @param {object} zip + * @param {object} source + * @param {object} pathPack Read only. + * @param {{from?: string|null, days?: number, now?: number, + * names?: object}} [options] + */ +export async function buildSchedule(feed, zip, source, pathPack, options = {}) { + const names = options.names ?? (await createRouteNames(feed, zip)); + const deadline = Date.now() + FEED_DEADLINE_MS; + const overdue = () => { + if (Date.now() > deadline) + throw new Error('schedule build ran past its deadline'); + }; + let timezone = null; + await csvEntry(zip, 'agency.txt', (row) => { + if (!timezone && row.agency_timezone) timezone = row.agency_timezone; + }); + if (!isTimeZone(timezone)) + throw new Error(`agency_timezone ${timezone} is not an IANA zone`); + const days = Math.max(1, Math.min(14, options.days ?? DEFAULT_SCHEDULE_DAYS)); + const fromDay = options.from + ? dayNumberOf(options.from) + : dayNumberOf(localDate(options.now ?? Date.now(), timezone)) - 1; + if (!Number.isFinite(fromDay)) throw new Error(`bad --from ${options.from}`); + const toDay = fromDay + days - 1; + + const calendar = []; + const exceptions = []; + await csvEntry(zip, 'calendar.txt', (row) => calendar.push(row), { + required: false, + }); + // Only dates inside the window can change it; a national feed lists a + // year of exceptions (hundreds of MB), too many rows to hold. + await csvEntry( + zip, + 'calendar_dates.txt', + (row) => { + const day = dayNumberOf(row.date); + if (day >= fromDay - MAX_LOOKBACK_DAYS && day <= toDay) + exceptions.push(row); + }, + { required: false }, + ); + const serviceDays = resolveServiceDays( + calendar, + exceptions, + fromDay - MAX_LOOKBACK_DAYS, + toDay, + ); + calendar.length = 0; + exceptions.length = 0; + overdue(); + + const routes = Object.create(null); + /** routes.txt id → pack id, for the routes this feed keeps. */ + const routeIds = new Map(); + await csvEntry(zip, 'routes.txt', (row) => { + const id = names.route(row); + if (!id) return; + routeIds.set(row.route_id, id); + const info = { type: Number.parseInt(row.route_type, 10) }; + if (row.route_short_name) info.short = row.route_short_name; + if (row.route_long_name) info.long = row.route_long_name; + if (/^[0-9a-f]{6}$/i.test(row.route_color ?? '')) + info.color = row.route_color.toUpperCase(); + if (/^[0-9a-f]{6}$/i.test(row.route_text_color ?? '')) + info.textColor = row.route_text_color.toUpperCase(); + routes[id] = info; + }); + const trips = new Map(); + let railTrips = 0; + await csvEntry(zip, 'trips.txt', (row) => { + const base = routeIds.get(row.route_id); + if (!base || !row.trip_id) return; + const routeId = names.trip(row, base); + if (!routeId) return; + // A per-trip rename makes a new pack route with its line's details. + if (!Object.hasOwn(routes, routeId)) routes[routeId] = { ...routes[base] }; + railTrips++; + if (!serviceDays.has(row.service_id)) return; + trips.set(row.trip_id, { + route: routeId, + service: row.service_id, + headsign: row.trip_headsign || '', + name: row.trip_short_name || '', + direction: + row.direction_id === '0' ? 0 : row.direction_id === '1' ? 1 : null, + rows: [], + }); + }); + // Frequency-based runs repeat one template trip (Santiago's Tren Nos): + // the template's stop times are kept and expanded below into one run per + // departure, `@`, all sharing its pattern and run times. + /** trip_id → flat [start, end, headway, ...] seconds. */ + const frequencyWindows = new Map(); + await csvEntry( + zip, + 'frequencies.txt', + (row) => { + if (!trips.has(row.trip_id)) return; + let windows = frequencyWindows.get(row.trip_id); + if (!windows) frequencyWindows.set(row.trip_id, (windows = [])); + windows.push( + parseGtfsTime(row.start_time), + parseGtfsTime(row.end_time), + Number(row.headway_secs), + ); + }, + { required: false }, + ); + const frequencyTrips = frequencyWindows.size; + overdue(); + + const allStops = new Map(); + await csvEntry(zip, 'stops.txt', (row) => { + if (!row.stop_id) return; + allStops.set(row.stop_id, { + name: row.stop_name || '', + lat: row.stop_lat ? Number(row.stop_lat) : NaN, + lon: row.stop_lon ? Number(row.stop_lon) : NaN, + parent: row.parent_station || '', + tz: row.stop_timezone || '', + platform: row.platform_code || '', + }); + }); + overdue(); + + // The heavy file is streamed; only rows of kept trips are held, as flat + // tuples [sequence, arrival, departure, stop id, pass-through]. + let stopTimeRows = 0; + await csvEntry(zip, 'stop_times.txt', (row) => { + stopTimeRows++; + const trip = trips.get(row.trip_id); + if (!trip) return; + trip.rows.push( + Number(row.stop_sequence), + parseGtfsTime(row.arrival_time), + parseGtfsTime(row.departure_time), + row.stop_id, + row.pickup_type === '1' && row.drop_off_type === '1' ? 1 : 0, + ); + }); + overdue(); + + const paths = new Map(); + const pathAt = (index) => { + if (!paths.has(index)) + paths.set( + index, + createRailPath(pathPack.shapes[index], pathPack.dist[index]), + ); + return paths.get(index); + }; + const patterns = []; + const patternByKey = new Map(); + const profiles = []; + const profileByKey = new Map(); + const outTrips = Object.create(null); + const stats = { + railTrips, + tripsInWindow: trips.size, + frequencyTrips, + /** Runs the kept frequency-based trips expanded to. */ + frequencyRuns: 0, + stopTimeRows, + tripsKept: 0, + tripsUntimed: 0, + tripsOutOfWindow: 0, + tripsUnknownStop: 0, + tripsNoPositions: 0, + /** Runs that kept their located stops when some had no coordinates. */ + tripsStopsLeftOut: 0, + stopsLeftOut: 0, + patternsOffShape: 0, + patternsShapeless: 0, + maxStopOffsetM: 0, + }; + let lookbackDays = 0; + + /** Stops projected onto an operator shape, or null when they do not fit. */ + const projections = new Map(); + const projectOnto = (shape, stopIds) => { + const key = `${shape}|${stopIds.join('\u0001')}`; + if (!projections.has(key)) { + const path = pathAt(shape); + const projected = path + ? projectStops( + path, + stopIds.map((id) => allStops.get(id)), + ) + : null; + projections.set( + key, + projected && projected.maxOffsetM <= MAX_STOP_OFFSET_M + ? projected + : null, + ); + } + return projections.get(key); + }; + + const patternFor = (shapeIndex, stopIds, seqs, pass) => { + const key = `${shapeIndex ?? ''}|${stopIds.join('\u0001')}|${seqs.join(',')}|${pass.join('')}`; + const known = patternByKey.get(key); + if (known !== undefined) return known; + const coords = stopIds.map((id) => allStops.get(id)); + let shape = shapeIndex; + let dist = null; + if (shape !== null) { + const projected = projectOnto(shape, stopIds); + if (projected) { + dist = projected.dist; + stats.maxStopOffsetM = Math.max( + stats.maxStopOffsetM, + Math.round(projected.maxOffsetM), + ); + } else { + stats.patternsOffShape++; + shape = null; + } + } + if (shape === null) { + stats.patternsShapeless++; + dist = straightDist(coords); + } + const pattern = { shape, stops: stopIds, seq: seqs, dist }; + if (pass.includes(1)) pattern.pass = pass; + const index = patterns.length; + patterns.push(pattern); + patternByKey.set(key, index); + return index; + }; + + /** Stops without coordinates, left out of their runs below. */ + const leftOut = new Set(); + + // The trips that run in the window, their stops in order. + const prepared = []; + for (const [tripId, trip] of trips) { + const rows = trip.rows; + trip.rows = null; + if (rows.length / 5 < 2) { + stats.tripsUntimed++; + continue; + } + const all = Array.from({ length: rows.length / 5 }, (_, k) => k).sort( + (a, b) => rows[5 * a] - rows[5 * b], + ); + const located = (k) => { + const stop = allStops.get(rows[5 * k + 3]); + return hasStopCoordinates(stop?.lat, stop?.lon); + }; + // A stop with no coordinates is left out of the run, its times with it, + // so no line is drawn to it; a run needs two located stops. + const order = all.filter(located); + if (order.length < all.length) + for (const k of all) if (!located(k)) leftOut.add(rows[5 * k + 3]); + if (order.length < 2) { + stats.tripsUnknownStop++; + continue; + } + if (order.length < all.length) stats.tripsStopsLeftOut++; + const count = order.length; + const stopIds = order.map((k) => rows[5 * k + 3]); + // A trip that stops in a no-position country (Ukraine, + // src/data/railNoPositions.js) is left out of the pack. A stop left out + // above still counts, by its id. + if ( + all.some((k) => { + const id = rows[5 * k + 3]; + const stop = allStops.get(id); + return isNoTrainPositionStop({ id, lat: stop?.lat, lon: stop?.lon }); + }) + ) { + stats.tripsNoPositions++; + continue; + } + const seqs = order.map((k) => rows[5 * k]); + const pass = order.map((k) => rows[5 * k + 4]); + const arr = order.map((k) => rows[5 * k + 1]); + const dep = order.map((k) => rows[5 * k + 2]); + const first = Number.isFinite(dep[0]) ? dep[0] : arr[0]; + const last = Number.isFinite(arr[count - 1]) + ? arr[count - 1] + : dep[count - 1]; + if (!Number.isFinite(first) || !Number.isFinite(last)) { + stats.tripsUntimed++; + continue; + } + // A frequency-based template runs from each departure, its stop times + // moved by the departure's distance from its own first one. + const departures = frequencyWindows.has(tripId) + ? frequencyDepartures(frequencyWindows.get(tripId)) + : null; + if (departures && !departures.length) { + stats.tripsUntimed++; + continue; + } + // A run that left before the window opened still counts while it is out. + const reach = Math.floor( + (departures ? last - first + departures.at(-1) : last) / 86_400, + ); + const runDays = serviceDays + .get(trip.service) + .filter((day) => day <= toDay && day >= fromDay - reach); + if (!runDays.length) { + stats.tripsOutOfWindow++; + continue; + } + const shapeIndex = Object.hasOwn(pathPack.trips, tripId) + ? pathPack.trips[tripId] + : null; + prepared.push({ + tripId, + trip, + count, + stopIds, + seqs, + pass, + arr, + dep, + reach, + shapeIndex, + first, + departures, + }); + } + trips.clear(); + frequencyWindows.clear(); + stats.stopsLeftOut = leftOut.size; + overdue(); + + for (const { + tripId, + trip, + count, + stopIds, + seqs, + pass, + arr, + dep, + reach, + shapeIndex, + first, + departures, + } of prepared) { + const patternIndex = patternFor(shapeIndex, stopIds, seqs, pass); + fillTimes(arr, dep, patterns[patternIndex].dist); + const startSec = Math.min(arr[0], dep[0]); + const offsets = []; + for (let k = 0; k < count; k++) + offsets.push(arr[k] - startSec, Math.max(arr[k], dep[k]) - startSec); + lookbackDays = Math.max(lookbackDays, Math.min(reach, MAX_LOOKBACK_DAYS)); + const profileKey = offsets.join(','); + let profile = profileByKey.get(profileKey); + if (profile === undefined) { + profile = profiles.length; + profiles.push(offsets); + profileByKey.set(profileKey, profile); + } + const out = { + pattern: patternIndex, + profile, + startSec, + route: trip.route, + service: trip.service, + }; + if (trip.headsign) out.headsign = trip.headsign; + if (trip.name) out.name = trip.name; + if (trip.direction !== null) out.direction = trip.direction; + if (!departures) { + outTrips[tripId] = out; + stats.tripsKept++; + continue; + } + for (const departure of departures) { + const run = `${tripId}@${gtfsTimeText(departure)}`; + if (Object.hasOwn(outTrips, run)) continue; + outTrips[run] = { ...out, startSec: startSec + departure - first }; + stats.tripsKept++; + stats.frequencyRuns++; + } + } + prepared.length = 0; + overdue(); + + // Stops: those the kept trips serve, then their parent stations. + const stopIndex = new Map(); + const stopList = []; + const addStop = (id) => { + if (stopIndex.has(id)) return stopIndex.get(id); + const stop = allStops.get(id); + const index = stopList.length; + stopIndex.set(id, index); + const entry = { + id, + name: stop.name, + lat: round5(stop.lat), + lon: round5(stop.lon), + }; + stopList.push(entry); + if (stop.platform) entry.platform = stop.platform; + // A child without its own zone takes its parent's (GTFS stop_timezone). + const zone = stop.tz || allStops.get(stop.parent)?.tz || ''; + if (zone && zone !== timezone && isTimeZone(zone)) entry.tz = zone; + if ( + stop.parent && + stop.parent !== id && + hasStopCoordinates( + allStops.get(stop.parent)?.lat, + allStops.get(stop.parent)?.lon, + ) + ) + entry.parent = addStop(stop.parent); + return index; + }; + for (const pattern of patterns) for (const id of pattern.stops) addStop(id); + for (const pattern of patterns) + pattern.stops = pattern.stops.map((id) => stopIndex.get(id)); + allStops.clear(); + + const services = Object.create(null); + for (const trip of Object.values(outTrips)) { + if (services[trip.service]) continue; + services[trip.service] = serviceDays + .get(trip.service) + .filter((day) => day >= fromDay - lookbackDays && day <= toDay) + .map(dateFromDayNumber); + } + const usedRoutes = new Set(Object.values(outTrips).map((t) => t.route)); + for (const id of Object.keys(routes)) + if (!usedRoutes.has(id)) delete routes[id]; + + const schedule = { + version: RAIL_SCHEDULE_PACK_VERSION, + feedId: feed.id, + builtAt: new Date().toISOString(), + pathsBuiltAt: pathPack.builtAt, + source, + timezone, + window: { from: dateFromDayNumber(fromDay), to: dateFromDayNumber(toDay) }, + routes, + stops: stopList, + patterns: patterns, + profiles, + services, + trips: outTrips, + }; + return { + schedule, + stats: { + ...stats, + stops: stopList.length, + patterns: patterns.length, + profiles: profiles.length, + services: Object.keys(services).length, + lookbackDays, + }, + }; +} + +// ---------------------------------------------------------------- main + +/** + * A script's usage for `--help`: the doc comment at the head of the file, + * where its flags are written down, without the comment markers. + * @param {string} moduleUrl The script's `import.meta.url`. + * @returns {Promise} + */ +export async function headerHelp(moduleUrl) { + const text = await readFile(fileURLToPath(moduleUrl), 'utf8'); + const block = /\/\*\*([\s\S]*?)\*\//.exec(text)?.[1] ?? ''; + return block + .split('\n') + .map((line) => line.replace(/^\s*\* ?/, '')) + .join('\n') + .trim(); +} + +async function prepareWorkDir(dir) { + if (!dir) return mkdtemp(path.join(os.tmpdir(), 'gev-rail-paths-')); + await mkdir(dir, { recursive: true }); + if ((await readdir(dir)).length) + throw new Error(`--work ${dir} must be a new or empty directory`); + return dir; +} + +const kib = (bytes) => `${(bytes / 1024).toFixed(1)} KiB`; + +/** A pack already on disk, parsed, or null (missing or unreadable). */ +async function readPackIfAny(file) { + try { + return JSON.parse(await readFile(file, 'utf8')); + } catch { + return null; + } +} + +/** + * Trips of a path pack bound to the shapes buildPack makes, the operator's. + * A shape of any other source (a pack from an older build) is not counted. + */ +export function operatorShapeTrips(pack) { + const sources = Array.isArray(pack?.shapeSources) ? pack.shapeSources : []; + let count = 0; + for (const index of Object.values(pack?.trips ?? {})) + if ((sources[index] ?? 'operator') === 'operator') count++; + return count; +} + +/** A schedule pack's trips per service day of its window. */ +export function scheduleTripsPerDay(schedule) { + const trips = Object.keys(schedule?.trips ?? {}).length; + const from = dayNumberOf(schedule?.window?.from ?? ''); + const to = dayNumberOf(schedule?.window?.to ?? ''); + const days = + Number.isFinite(from) && Number.isFinite(to) && to >= from + ? to - from + 1 + : 1; + return trips / days; +} + +/** + * Why a new pack's trips look like an upstream fault rather than a + * timetable, or null: under MIN_TRIP_SHARE of the pack it replaces (none at + * all included). + */ +export function tripFloorProblem(label, now, before, share = MIN_TRIP_SHARE) { + if (!(before > 0) || now >= before * share) return null; + const round = (n) => Math.round(n * 10) / 10; + return `${label} ${round(now)} against ${round(before)} in the pack it would replace`; +} + +/** How many points a path pack holds, and what thins them. */ +function shrinkHint(pack) { + const points = pack.shapes.reduce((sum, flat) => sum + flat.length / 2, 0); + return `its shapes hold ${points} points (simplified to the feed's staticSimplifyM, default ${SIMPLIFY_TOLERANCE_M} m: set it coarser)`; +} + +/** + * Write one feed's path pack and, unless null, its schedule pack, as a pair. + * + * Both texts are made and checked before anything is written: a pack over + * `maxBytes` (what the server serves), or trips under MIN_TRIP_SHARE of the + * packs being replaced (operatorShapeTrips; scheduleTripsPerDay) unless + * `force`, throws and leaves the previous packs as they were. Both are then + * written to temporary names of this process; only when both are written is + * the path pack renamed into place, then the schedule. Without a schedule, + * the previous one is removed: its patterns index the replaced path pack's + * shapes. Should the second step fail, the previous schedule is removed for + * the same reason, and the error says so. + * @param {string} out + * @param {string} feedId + * @param {{pack: object, schedule?: object|null, force?: boolean, + * maxBytes?: number}} packs + * @returns {Promise<{pathText: string, scheduleText: string|null, + * removedSchedule: boolean}>} + */ +export async function writeFeedPacks( + out, + feedId, + { pack, schedule = null, force = false, maxBytes = RAIL_PATH_PACK_MAX_BYTES }, +) { + const pathFile = path.join(out, `${feedId}.json`); + const scheduleFile = path.join(out, `${feedId}.schedule.json`); + const pathText = JSON.stringify(pack); + const scheduleText = schedule ? JSON.stringify(schedule) : null; + const pathBytes = Buffer.byteLength(pathText); + if (pathBytes > maxBytes) + throw new Error( + `paths are ${kib(pathBytes)}, over the ${kib(maxBytes)} the server serves, so the previous packs are kept; ${shrinkHint(pack)}`, + ); + if (scheduleText !== null && Buffer.byteLength(scheduleText) > maxBytes) + throw new Error( + `the schedule is ${kib(Buffer.byteLength(scheduleText))}, over the ${kib(maxBytes)} the server serves, so the previous packs are kept; narrow it with --days`, + ); + if (!force) { + const problems = [ + tripFloorProblem( + 'trips on operator shapes', + operatorShapeTrips(pack), + operatorShapeTrips(await readPackIfAny(pathFile)), + ), + ]; + if (schedule) + problems.push( + tripFloorProblem( + 'schedule trips per day', + scheduleTripsPerDay(schedule), + scheduleTripsPerDay(await readPackIfAny(scheduleFile)), + ), + ); + const found = problems.filter(Boolean); + if (found.length) + throw new Error( + `${found.join('; ')}: an upstream change, not a timetable, so the previous packs are kept (--force writes these)`, + ); + } + const pathTemp = tempPathFor(pathFile); + const scheduleTemp = scheduleText !== null ? tempPathFor(scheduleFile) : null; + const clean = () => + Promise.all( + [pathTemp, scheduleTemp] + .filter(Boolean) + .map((file) => rm(file, { force: true }).catch(() => {})), + ); + try { + await writeFile(pathTemp, pathText); + if (scheduleTemp) await writeFile(scheduleTemp, scheduleText); + await rename(pathTemp, pathFile); + } catch (error) { + await clean(); + throw error; + } + // The path pack is in place: the schedule beside it must match it or go. + let removedSchedule = false; + try { + if (scheduleTemp) await rename(scheduleTemp, scheduleFile); + else { + removedSchedule = await stat(scheduleFile).then( + () => true, + () => false, + ); + await rm(scheduleFile, { force: true }); + } + } catch (error) { + await clean(); + await rm(scheduleFile, { force: true }).catch(() => {}); + throw new Error( + `the path pack was written but the schedule beside it could not be ${scheduleTemp ? 'replaced' : 'removed'} (${error?.message || error}); it indexes the previous path pack's shapes, so it is removed where it can be`, + ); + } + return { pathText, scheduleText, removedSchedule }; +} + +// ---------------------------------------------------------------- lock + +/** + * A temporary name next to `target`, of this process and this call alone, + * so two writers of the same file never write into one temporary file. + */ +export function tempPathFor(target) { + return `${target}.${process.pid}-${randomBytes(4).toString('hex')}.tmp`; +} + +/** A lock not refreshed for this long was left by a run that died (ms). */ +export const BUILD_LOCK_STALE_MS = 6 * 3_600_000; +/** How often a held lock's time is refreshed (ms). */ +const BUILD_LOCK_REFRESH_MS = 10 * 60_000; +/** A lock file younger than this is held even while it reads as empty (ms). */ +const BUILD_LOCK_WRITE_GRACE_MS = 60_000; + +/** Whether a process id is running on this machine. */ +function processAlive(pid) { + if (!Number.isInteger(pid) || pid <= 0) return false; + if (pid === process.pid) return true; + try { + process.kill(pid, 0); + return true; + } catch (error) { + // EPERM: it runs, under another user. + return error?.code === 'EPERM'; + } +} + +/** + * Take the lock file `name` in `dir` for a build, or refuse when another + * build holds it. The file records the holder's process id and the time it + * was taken or last refreshed (every 10 minutes while held). A lock whose + * process is not running, or that was not refreshed for `staleMs`, is taken + * over. Locks are advisory: they keep two builds from writing into the same + * folder at once, nothing more. + * @param {string} dir Created when missing. + * @param {string} name + * @param {{what?: string, staleMs?: number, now?: () => number, + * alive?: (pid: number) => boolean}} [options] + * @returns {Promise<{file: string, release: () => Promise}>} + */ +export async function acquireDirLock( + dir, + name, + { + what = 'build', + staleMs = BUILD_LOCK_STALE_MS, + now = Date.now, + alive = processAlive, + } = {}, +) { + await mkdir(dir, { recursive: true }); + const file = path.join(dir, name); + const body = () => + JSON.stringify({ pid: process.pid, time: new Date(now()).toISOString() }); + const ours = async () => { + try { + return JSON.parse(await readFile(file, 'utf8'))?.pid === process.pid; + } catch { + return false; + } + }; + for (let attempt = 0; attempt < 3; attempt++) { + try { + await writeFile(file, body(), { flag: 'wx' }); + } catch (error) { + if (error?.code !== 'EEXIST') throw error; + let held = null; + let modified = NaN; + try { + modified = (await stat(file)).mtimeMs; + held = JSON.parse(await readFile(file, 'utf8')); + } catch { + held = null; + } + const since = Date.parse(held?.time ?? ''); + const fresh = held + ? alive(held.pid) && Number.isFinite(since) && now() - since <= staleMs + : // Unreadable: another build may be writing it this moment. + Number.isFinite(modified) && + now() - modified <= BUILD_LOCK_WRITE_GRACE_MS; + if (fresh) + throw new Error( + `another ${what} holds ${file} (pid ${held?.pid ?? '?'}, ${held?.time ?? 'just now'}); ` + + `wait for it to finish, or delete the file if that process is gone`, + ); + await rm(file, { force: true }); + continue; + } + const timer = setInterval(() => { + ours() + .then((yes) => (yes ? writeFile(file, body()) : null)) + .catch(() => {}); + }, BUILD_LOCK_REFRESH_MS); + timer.unref?.(); + return { + file, + async release() { + clearInterval(timer); + if (await ours()) await rm(file, { force: true }); + }, + }; + } + throw new Error(`could not take ${file}`); +} + +// ---------------------------------------------------------------- run + +async function main() { + const args = parseBuildArgs(process.argv.slice(2)); + if (args.help) { + console.log(await headerHelp(import.meta.url)); + return; + } + // One build at a time per output folder. + const lock = await acquireDirLock(args.out, BUILD_LOCK, { + what: 'rail path build', + }); + try { + await buildFeeds(args); + } finally { + await lock.release(); + } +} + +/** Build the feeds the command line names, holding the --out lock. */ +async function buildFeeds(args) { + const wanted = (feed) => args.all || args.feeds.includes(feed.id); + const enabled = railEnabledFeeds(); + const feeds = enabled.filter((feed) => feed.staticGtfsUrl && wanted(feed)); + for (const id of args.feeds) + if (!feeds.some((feed) => feed.id === id)) + throw new Error(`No enabled feed ${id} with a staticGtfsUrl`); + for (const id of args.localGtfs.keys()) + if (!feeds.some((feed) => feed.id === id)) + throw new Error( + `--local-gtfs ${id}: no enabled feed ${id} in this build`, + ); + const work = await prepareWorkDir(args.work); + await mkdir(args.out, { recursive: true }); + console.log(`[rail-paths] ${feeds.length} feeds → ${args.out}`); + console.log(`[rail-paths] downloads in ${work}`); + const downloads = new Map(); + const failures = []; + try { + for (const [n, feed] of feeds.entries()) { + const started = Date.now(); + peakRss = process.memoryUsage.rss(); + let zip = null; + let inner = null; + const local = args.localGtfs.get(feed.id) ?? null; + try { + const refusal = buildRefusal(feed, args); + if (refusal) throw new Error(refusal); + let fetched = local + ? localStatic(local) + : downloads.get(feed.staticGtfsUrl); + if (!fetched) { + const file = path.join(work, `download-${downloads.size + 1}.zip`); + fetched = downloadStatic( + feed.staticGtfsUrl, + file, + feed.staticHeaders || null, + ).then((info) => ({ ...info, file })); + downloads.set(feed.staticGtfsUrl, fetched); + } + const got = await fetched; + zip = await openZip(got.file); + if (feed.staticGtfsEntry) { + const entry = zip.entries.get(feed.staticGtfsEntry); + if (!entry) + throw new Error(`${feed.staticGtfsEntry} not in download`); + const nested = path.join( + work, + `${feed.id}-${path.basename(entry.name)}`, + ); + await pipeline( + await entryStream(zip, entry), + createWriteStream(nested), + ); + inner = zip; + zip = await openZip(nested); + } + const source = packSource(feed, got); + const feedVersion = await readFeedVersion(zip); + if (feedVersion) source.feedVersion = feedVersion; + const names = await createRouteNames(feed, zip); + const { pack, stats } = await buildPack(feed, zip, source, names); + // Both packs are written together, once both are built. + const timetable = args.schedule + ? await buildSchedule(feed, zip, source, pack, { + from: args.from, + days: args.days, + names, + }) + : null; + const written = await writeFeedPacks(args.out, feed.id, { + pack, + schedule: timetable?.schedule ?? null, + force: args.force, + }); + const text = written.pathText; + const gz = gzipSync(text).length; + console.log( + `[rail-paths] ${n + 1}/${feeds.length} ${feed.id}: ` + + `rail routes ${stats.railRoutes} (of ${stats.routeRows}), ` + + `with shapes ${stats.routesWithShapes}; ` + + `shapes kept ${stats.shapesKept} (from ${stats.shapeIds} ids), dropped ${stats.shapesDropped}, sparse kept ${stats.sparseShapes}; ` + + `points ${stats.points} (from ${stats.rawPoints}); ` + + `trips mapped ${stats.tripsMapped}/${stats.railTrips} (no shape_id ${stats.shapelessTrips}); ` + + `size ${kib(Buffer.byteLength(text))}, gzip ${kib(gz)}; ` + + `${((Date.now() - started) / 1000).toFixed(1)} s, rss ${Math.round(process.memoryUsage.rss() / 2 ** 20)} MiB, peak ${Math.round(peakRss / 2 ** 20)} MiB`, + ); + for (const { shapeId, reason } of stats.dropped) + console.log(`[rail-paths] dropped shape ${shapeId}: ${reason}`); + if (written.removedSchedule) + console.log( + `[rail-paths] ${feed.id}: removed the previous schedule pack (--no-schedule): its patterns index the shapes of the path pack just replaced`, + ); + if (timetable) { + const body = written.scheduleText; + const bytes = Buffer.byteLength(body); + const t = timetable.stats; + const w = timetable.schedule.window; + console.log( + `[rail-paths] schedule ${feed.id} (${timetable.schedule.timezone}, ${w.from}..${w.to}, lookback ${t.lookbackDays} d): ` + + `trips ${t.tripsKept} (rail ${t.railTrips}, in calendar ${t.tripsInWindow}, ` + + `out of window ${t.tripsOutOfWindow}, untimed ${t.tripsUntimed}, unknown stop ${t.tripsUnknownStop}, no positions ${t.tripsNoPositions}, frequency ${t.frequencyTrips} → ${t.frequencyRuns} runs); ` + + `unlocated stops left out ${t.stopsLeftOut} (from ${t.tripsStopsLeftOut} trips); ` + + `stops ${t.stops}; patterns ${t.patterns} (off shape ${t.patternsOffShape}, shapeless ${t.patternsShapeless}); ` + + `profiles ${t.profiles}; services ${t.services}; ` + + `max stop offset ${t.maxStopOffsetM} m; stop_times rows ${t.stopTimeRows}; ` + + `size ${kib(bytes)}, gzip ${kib(gzipSync(body).length)}; ` + + `rss ${Math.round(process.memoryUsage.rss() / 2 ** 20)} MiB`, + ); + } + } catch (error) { + failures.push(feed.id); + console.error( + `[rail-paths] ${n + 1}/${feeds.length} ${feed.id} skipped: ${error?.message || error}`, + ); + } finally { + await zip?.handle.close().catch(() => {}); + await inner?.handle.close().catch(() => {}); + } + } + } finally { + if (!args.work && !args.keep) + await rm(work, { recursive: true, force: true }); + } + console.log( + `[rail-paths] done: ${feeds.length - failures.length} built` + + (failures.length ? `, failed: ${failures.join(', ')}` : ''), + ); + const code = buildExitCode(failures.length); + if (code) process.exitCode = code; +} + +if ( + process.argv[1] && + path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) + main().catch((error) => { + console.error(`[rail-paths] ${error?.message || error}`); + process.exitCode = 1; + }); diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 7d3962cce..b49781778 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -847,6 +847,45 @@ "scripts/qa-transit-recovery.mjs", "scripts/format-runtime.json", "src/tooling/transitService.test.mjs", + "src/tooling/railService.test.mjs", + "src/data/railFeeds.test.mjs", + "src/data/railProxy.test.mjs", + "src/layers/rail/policy.test.mjs", + "src/layers/rail/source.test.mjs", + "src/layers/rail/pathBinding.test.mjs", + "src/layers/rail/routeLines.test.mjs", + "src/layers/rail/stopLabels.test.mjs", + "src/layers/rail/schedules.test.mjs", + "src/data/railPaths.test.mjs", + "src/data/railSchedule.test.mjs", + "src/data/railTripUpdates.test.mjs", + "src/data/irishRail.test.mjs", + "src/tooling/buildRailPaths.test.mjs", + "src/tooling/buildRailPathsRoutes.test.mjs", + "scripts/build-rail-paths.mjs", + "src/tooling/buildRailPathsUnlocated.test.mjs", + "src/layers/rail/follow.test.mjs", + "src/layers/rail/groundHeight.test.mjs", + "src/layers/rail/liveOnly.test.mjs", + "src/testSupport/railLayerHarness.mjs", + "src/layers/rail/dedupe.test.mjs", + "src/data/railIreland.test.mjs", + "src/data/railNoPositions.test.mjs", + "src/data/railFeedsNational.test.mjs", + "src/data/railSiriVm.test.mjs", + "src/tooling/buildRailPathsTripEnds.test.mjs", + "src/data/railFeedsUs.test.mjs", + "src/data/railFeedsAsia.test.mjs", + "src/data/railFeedsFrance.test.mjs", + "src/data/railFeedsUsNoTerms.test.mjs", + "src/data/railFeedsUsUrban.test.mjs", + "src/tooling/buildRailPathsLocal.test.mjs", + "src/tooling/buildRailPathsWrite.test.mjs", + "src/tooling/railServiceFaults.test.mjs", + "src/tooling/railCatalogPacks.test.mjs", + "src/tooling/railServicePacks.test.mjs", + "src/tooling/buildRailPathsCli.test.mjs", + "src/data/railFeedsDocs.test.mjs", "src/sources/overpassFeatures.test.mjs", "src/data/cctvHlsStream.test.mjs", "src/layers/cctv/videoPlayback.test.mjs", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 6335d7fa7..8da3b848d 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -3249,6 +3249,27 @@ "src/i18n/marker.js", "src/sources/nominatim.js", "src/app/layers/transit.js", + "src/app/layers/rail.js", + "src/data/railFeeds.js", + "src/layers/rail/index.js", + "src/layers/rail/source.js", + "src/layers/rail/policy.js", + "src/layers/rail/ingestion.js", + "src/layers/rail/dedupe.js", + "src/layers/rail/rendering.js", + "src/layers/rail/selection.js", + "src/layers/rail/follow.js", + "src/layers/rail/viewport.js", + "src/layers/rail/controls.js", + "src/layers/rail/lifecycle.js", + "src/layers/rail/pathBinding.js", + "src/layers/rail/routeDisplay.js", + "src/layers/rail/routeLines.js", + "src/layers/rail/schedules.js", + "src/layers/rail/stopLabels.js", + "src/data/railPaths.js", + "src/data/railSchedule.js", + "src/data/railTimeZones.js", "src/data/transit.js", "src/data/transitFeeds.js", "src/data/rioRouteNames.js", @@ -4072,6 +4093,27 @@ "src/i18n/marker.js", "src/sources/nominatim.js", "src/app/layers/transit.js", + "src/app/layers/rail.js", + "src/data/railFeeds.js", + "src/layers/rail/index.js", + "src/layers/rail/source.js", + "src/layers/rail/policy.js", + "src/layers/rail/ingestion.js", + "src/layers/rail/dedupe.js", + "src/layers/rail/rendering.js", + "src/layers/rail/selection.js", + "src/layers/rail/follow.js", + "src/layers/rail/viewport.js", + "src/layers/rail/controls.js", + "src/layers/rail/lifecycle.js", + "src/layers/rail/pathBinding.js", + "src/layers/rail/routeDisplay.js", + "src/layers/rail/routeLines.js", + "src/layers/rail/schedules.js", + "src/layers/rail/stopLabels.js", + "src/data/railPaths.js", + "src/data/railSchedule.js", + "src/data/railTimeZones.js", "src/data/transitFeeds.js", "src/data/rioRouteNames.js", "src/data/transitIcons.js", @@ -5090,6 +5132,63 @@ ], "external": [] }, + "rail-provider": { + "runtime": "node", + "exports": ["./server/providers/rail"], + "modules": [ + "server/providers/rail.js", + "src/data/railFeeds.js", + "src/data/railProxy.js", + "src/data/irishRail.js", + "src/data/railSiriVm.js", + "src/data/railTripUpdates.js", + "src/data/railScheduleIndex.js", + "src/data/railSchedule.js", + "src/data/railTimeZones.js", + "src/data/railNoPositions.js", + "src/data/transitFeeds.js", + "src/data/rioRouteNames.js", + "src/geoDistance.js", + "src/data/transitProxy.js", + "src/data/gtfsRealtime.js", + "src/sources/httpBody.js", + "src/i18n/marker.js", + "src/sources/transitHistory.js", + "src/sources/railService.js", + "src/sources/rateLimit.js" + ], + "external": ["pbf"] + }, + "rail-source-factory": { + "exports": ["./layers/rail/source"], + "modules": ["src/layers/rail/source.js"], + "external": [] + }, + "rail-service": { + "exports": ["./sources/rail-service"], + "modules": [ + "src/data/railFeeds.js", + "src/data/railProxy.js", + "src/data/irishRail.js", + "src/data/railSiriVm.js", + "src/data/railTripUpdates.js", + "src/data/railScheduleIndex.js", + "src/data/railSchedule.js", + "src/data/railTimeZones.js", + "src/data/railNoPositions.js", + "src/data/transitFeeds.js", + "src/data/rioRouteNames.js", + "src/geoDistance.js", + "src/data/transitProxy.js", + "src/data/gtfsRealtime.js", + "src/sources/httpBody.js", + "src/i18n/marker.js", + "src/sources/transitHistory.js", + "src/sources/railService.js", + "src/sources/rateLimit.js" + ], + "external": ["pbf"] + }, "transit-service": { "exports": ["./sources/transit-service"], "modules": [ diff --git a/server/providers/local.js b/server/providers/local.js index f343e549e..f7c5b36ec 100644 --- a/server/providers/local.js +++ b/server/providers/local.js @@ -16,6 +16,7 @@ import { radioBrowserProxy } from './radio.js'; import { gbfsProxy } from './gbfs.js'; import { localReceiversProxy } from './local-receivers.js'; import { transitProxy } from './transit.js'; +import { railProxy } from './rail.js'; import { adsbLolProxy } from './aircraft/adsb-lol.js'; import { aisLiveProxy } from './vessels/ais-live.js'; import { trackBackfillProxies } from './aircraft/tracks.js'; @@ -71,6 +72,7 @@ function localProviderPlugins({ gbfsProxy(), localReceiversProxy(), transitProxy(), + railProxy(), adsbLolProxy(), aisLiveProxy(), trackBackfillProxies(), diff --git a/server/providers/rail.js b/server/providers/rail.js new file mode 100644 index 000000000..c026331ca --- /dev/null +++ b/server/providers/rail.js @@ -0,0 +1,285 @@ +import { open, stat } from 'node:fs/promises'; +import path from 'node:path'; +import { Readable } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; +import { fileURLToPath } from 'node:url'; +import { createRailService } from '../../src/sources/railService.js'; +import { RAIL_FEED_ID_PATTERN } from '../../src/data/railFeeds.js'; + +/** + * Where development and preview look for path and schedule packs when + * RAIL_PATHS_DIR is unset: the default output of scripts/build-rail-paths.mjs. + */ +export const DEFAULT_RAIL_PATHS_DIR = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../../.cache/rail-paths', +); + +/** + * Pack bytes the development server keeps in memory, across path and + * schedule packs: a few of the largest national packs. + */ +export const RAIL_PACK_CACHE_MAX_BYTES = 64 * 1024 * 1024; + +/** + * A bounded cache of pack files' bytes, by path, least recently read first + * out. An entry is used only while the file's modification time and size + * are the ones it was read with, so a rebuilt pack is read again; a file + * larger than the whole byte budget is never kept. Reads of the same file at + * the same time share one read. + * @param {number} [maxBytes] + * @returns {{load: (file: string, info: {mtimeMs: number, size: number}, + * read: () => Promise) => Promise, bytes: () => number}} + */ +export function createRailPackCache(maxBytes = RAIL_PACK_CACHE_MAX_BYTES) { + /** @type {Map} */ + const entries = new Map(); + /** @type {Map>} */ + const pending = new Map(); + let total = 0; + const drop = (file) => { + const entry = entries.get(file); + if (!entry) return; + entries.delete(file); + total -= entry.bytes.byteLength; + }; + return { + async load(file, info, read) { + const entry = entries.get(file); + if (entry && entry.mtimeMs === info.mtimeMs && entry.size === info.size) { + // Most recently read last: the first entry is the next one out. + entries.delete(file); + entries.set(file, entry); + return entry.bytes; + } + const version = `${file}|${info.mtimeMs}|${info.size}`; + const reading = pending.get(version); + if (reading) return reading; + const promise = (async () => { + const bytes = await read(); + drop(file); + if (bytes.byteLength <= maxBytes) { + entries.set(file, { mtimeMs: info.mtimeMs, size: info.size, bytes }); + total += bytes.byteLength; + for (const [oldest] of entries) { + if (total <= maxBytes) break; + drop(oldest); + } + } + return bytes; + })().finally(() => pending.delete(version)); + pending.set(version, promise); + return promise; + }, + bytes: () => total, + }; +} + +/** + * A pack reader for the rail service that reads `/`. + * Ids are re-checked against the registry's id pattern, so a name can never + * leave the directory; a missing file is "no pack", and a file over the cap is + * refused without being read (an error with `code: 'PACK_TOO_LARGE'`). The + * pack is returned as the file's bytes, never decoded to text; with a + * `cache` (createRailPackCache), an unchanged file is read once. + * @param {string} dir + * @param {string} suffix `.json` (path packs) or `.schedule.json`. + * @param {ReturnType|null} [cache] + * @returns {(feedId: string, options?: {maxBytes?: number}) => + * Promise} + */ +function createFsPackReader(dir, suffix, cache = null) { + const root = path.resolve(dir); + return async (feedId, { maxBytes = Infinity } = {}) => { + if (typeof feedId !== 'string' || !RAIL_FEED_ID_PATTERN.test(feedId)) + return null; + const file = path.join(root, `${feedId}${suffix}`); + let handle; + try { + handle = await open(file, 'r'); + } catch (error) { + if (error?.code === 'ENOENT') return null; + throw error; + } + try { + const info = await handle.stat(); + if (!info.isFile()) return null; + if (info.size > maxBytes) { + const error = new Error(`pack is ${info.size} bytes`); + error.code = 'PACK_TOO_LARGE'; + throw error; + } + return cache + ? await cache.load(file, info, () => handle.readFile()) + : await handle.readFile(); + } finally { + await handle.close(); + } + }; +} + +/** + * A `readPathPack` that reads `/.json`. + * @param {string} dir + * @param {{cache?: ReturnType|null}} [options] + */ +export function createFsPathPackReader(dir, { cache = null } = {}) { + return createFsPackReader(dir, '.json', cache); +} + +/** + * A `readSchedulePack` that reads `/.schedule.json`. + * @param {string} dir + * @param {{cache?: ReturnType|null}} [options] + */ +export function createFsSchedulePackReader(dir, { cache = null } = {}) { + return createFsPackReader(dir, '.schedule.json', cache); +} + +/** + * A `hasSchedulePack` for the rail service: whether + * `/.schedule.json` is a file. Only the directory entry is + * read, never the pack; a missing file is false, any other error is thrown + * (the service then lists the feed, so the read shows the failure). + * @param {string} dir + * @returns {(feedId: string) => Promise} + */ +export function createFsSchedulePackProbe(dir) { + const root = path.resolve(dir); + return async (feedId) => { + if (typeof feedId !== 'string' || !RAIL_FEED_ID_PATTERN.test(feedId)) + return false; + try { + return (await stat(path.join(root, `${feedId}.schedule.json`))).isFile(); + } catch (error) { + if (error?.code === 'ENOENT') return false; + throw error; + } + }; +} + +/** + * A pack version for the rail service: the modification time and size of + * `/`, or null when there is no such file. Only the + * directory entry is read; a rebuilt pack has a new version. + * @param {string} dir + * @param {string} suffix + * @returns {(feedId: string) => Promise} + */ +function createFsPackVersion(dir, suffix) { + const root = path.resolve(dir); + return async (feedId) => { + if (typeof feedId !== 'string' || !RAIL_FEED_ID_PATTERN.test(feedId)) + return null; + try { + const info = await stat(path.join(root, `${feedId}${suffix}`)); + return info.isFile() ? `${info.mtimeMs}:${info.size}` : null; + } catch (error) { + if (error?.code === 'ENOENT') return null; + throw error; + } + }; +} + +/** + * A `schedulePackVersion` for the rail service: the modification time and + * size of `/.schedule.json`, or null when there is no such + * file. + * @param {string} dir + * @returns {(feedId: string) => Promise} + */ +export function createFsSchedulePackVersion(dir) { + return createFsPackVersion(dir, '.schedule.json'); +} + +/** + * A `pathPackVersion` for the rail service: the same for + * `/.json`. + * @param {string} dir + * @returns {(feedId: string) => Promise} + */ +export function createFsPathPackVersion(dir) { + return createFsPackVersion(dir, '.json'); +} + +/** + * Write a service Response to a Node response, streaming its body: a pack's + * bytes go to the socket as they are, with no text decode and no copy. + * @param {Response} response + * @param {import('node:http').ServerResponse} res + * @returns {Promise} + */ +export async function sendRailResponse(response, res) { + res.writeHead(response.status, Object.fromEntries(response.headers)); + if (!response.body) { + res.end(); + return; + } + await pipeline(Readable.fromWeb(response.body), res); +} + +/** Connect the reusable rail request service to development and preview. */ +export function railProxy(options = {}) { + const pathsDir = + options.pathsDir || process.env.RAIL_PATHS_DIR || DEFAULT_RAIL_PATHS_DIR; + // One byte budget for both kinds of pack. + const cache = createRailPackCache(); + const service = createRailService({ + ...options, + readPathPack: + options.readPathPack || createFsPathPackReader(pathsDir, { cache }), + readSchedulePack: + options.readSchedulePack || + createFsSchedulePackReader(pathsDir, { cache }), + // Probe the same directory only when its fs reader is the one in use. + hasSchedulePack: + options.hasSchedulePack || + (options.readSchedulePack ? null : createFsSchedulePackProbe(pathsDir)), + schedulePackVersion: + options.schedulePackVersion || + (options.readSchedulePack ? null : createFsSchedulePackVersion(pathsDir)), + pathPackVersion: + options.pathPackVersion || + (options.readPathPack ? null : createFsPathPackVersion(pathsDir)), + }); + // A throw here would escape into the HTTP server's event loop and end the + // whole dev server (Node exits on an unhandled rejection or a throwing + // 'close' listener), so failures stay inside the rail routes. + function closeService() { + try { + service.close(); + } catch (error) { + console.warn('[rail] provider close failed', error); + } + } + function install(server) { + server.middlewares.use('/api/rail', async (req, res) => { + try { + const response = await service.handle({ + url: `http://localhost/api/rail${req.url || '/'}`, + method: req.method, + // Read for If-None-Match on a pack. + headers: req.headers, + }); + await sendRailResponse(response, res); + } catch (error) { + // An answer already under way (a viewer that left mid-pack) has + // nothing to add: the stream is closed and that is all. + if (res.headersSent) { + res.destroy(); + return; + } + console.warn('[rail] request failed', error); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Rail provider error' })); + } + }); + server.httpServer?.once('close', closeService); + } + return { + name: 'rail-proxy', + closeBundle: closeService, + configureServer: install, + configurePreviewServer: install, + }; +} diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index 42b0d8008..772316300 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -18,6 +18,7 @@ import { createApplicationDirections } from './layers/directions.js'; import { createApplicationRecentImagery } from './layers/recentImagery.js'; import { createApplicationOsmPlaces } from './layers/osmPlaces.js'; import { createApplicationTransit } from './layers/transit.js'; +import { createApplicationRail } from './layers/rail.js'; import { createApplicationInstallations } from './layers/militaryInstallations.js'; import { createApplicationSatellites } from './layers/satellites.js'; import { createApplicationLaunches } from './layers/rocketLaunches.js'; @@ -191,6 +192,7 @@ export function createApplicationCatalog({ ...createOsmOverlayLayers(), ...createInfrastructureLayers(localGeoJsonServices), createApplicationCables({ source: sources.cables }), + createApplicationRail({ surface, source: sources.rail }), createApplicationFirms({ surface, id: 'local-firms', diff --git a/src/app/constructCatalog.test.mjs b/src/app/constructCatalog.test.mjs index 8c0f889db..dadf2c541 100644 --- a/src/app/constructCatalog.test.mjs +++ b/src/app/constructCatalog.test.mjs @@ -40,7 +40,7 @@ test('catalogs construct distinct layers and classification from their supplied signal: b.signal, surface: fixtureSurface(b.signal), }); - assert.equal(first.layers.length, 40); + assert.equal(first.layers.length, 41); assert.ok(first.get('local-adsb'), 'Local ADS-B is registered'); assert.deepEqual( first.metadata.find(({ id }) => id === 'osm-places'), @@ -162,7 +162,7 @@ test('Mapillary can be omitted or replaced without changing catalog membership', surface: fixtureSurface(lifetime.signal), }; const catalog = createApplicationCatalog(options); - assert.equal(catalog.layers.length, 40); + assert.equal(catalog.layers.length, 41); assert.deepEqual(catalog.get('street-level').providerIds, []); assert.equal( catalogControlServices(catalog).streetLevelLayer, diff --git a/src/app/layers/rail.js b/src/app/layers/rail.js new file mode 100644 index 000000000..9b5a3bb0e --- /dev/null +++ b/src/app/layers/rail.js @@ -0,0 +1,50 @@ +import { createRailLayer } from '../../layers/rail/index.js'; +import * as render from '../../renderGovernor.js'; +import * as sprites from '../../data/spriteOrder.js'; +import * as picking from '../../data/pickRegistry.js'; +import * as overlays from '../../overlays/worldOverlay.js'; +import { applyTrackedCameraFrame } from '../../data/trackedCamera.js'; +import { WORLD_FOCUS_FRAMING, requestWorldFocus } from '../../worldFocus.js'; +import { + railFeedCredit, + registerDynamicCredit, +} from '../../data/dataCredits.js'; + +/** + * Construct the Rail layer on the application's scene owners and its shared + * ground floor, the same floor Transit and the aircraft stand on. + * @param {{surface: object, source?: object}} options + * @returns {object} + */ +export function createApplicationRail({ surface, source }) { + if (!surface?.groundFloor) + throw new TypeError('Rail needs the application surface services'); + const { groundFloor } = surface; + return createRailLayer({ + source, + services: { + render, + sprites, + picking, + overlays, + credits: { registerDynamicCredit, railFeedCredit }, + ground: { + GROUND_FLOOR_LIFT_M: groundFloor.GROUND_FLOOR_LIFT_M, + cachedGroundFloor: groundFloor.cachedGroundFloor, + warmGroundFloor: groundFloor.warmGroundFloor, + neighborFloorM: groundFloor.neighborFloorM, + coarseFloorCoord: groundFloor.coarseFloorCoord, + }, + // A selected train is followed as a tracked aircraft is, or flown to + // once as a vessel is (src/layers/rail/follow.js). + camera: { + framing: WORLD_FOCUS_FRAMING.train, + requestWorldFocus, + applyTrackedCameraFrame, + isCockpitActive: () => + globalThis.document?.body?.classList?.contains('cockpit-mode') === + true, + }, + }, + }); +} diff --git a/src/app/sourceComposition.js b/src/app/sourceComposition.js index 42b90c05b..fef0136c1 100644 --- a/src/app/sourceComposition.js +++ b/src/app/sourceComposition.js @@ -55,6 +55,7 @@ export const CATALOG_SOURCE_CONTRACTS = Object.freeze({ 'wildlife-sightings': contract(['wildlife-sightings'], ['getSnapshot']), cables: contract(['telegeography-submarine-cables'], ['fetch']), transit: contract(['transit'], ['requestSnapshot', 'getHistory']), + rail: contract(['rail'], ['requestSnapshot', 'getFeeds']), }); /** An omitted provider is unavailable, never an authoritative empty dataset. */ diff --git a/src/app/sourceComposition.test.mjs b/src/app/sourceComposition.test.mjs index fc93c40e0..466572d84 100644 --- a/src/app/sourceComposition.test.mjs +++ b/src/app/sourceComposition.test.mjs @@ -37,7 +37,7 @@ test('every acquisition source can be omitted without losing catalog membership' test('all acquisition sources may be absent, and enable fails before initializing or fetching', async (t) => { const actual = catalog({}, t); - assert.equal(actual.layers.length, 40); + assert.equal(actual.layers.length, 41); const manager = new LayerLifecycle( {}, { getSourceAvailability: actual.getSourceAvailability }, @@ -247,3 +247,18 @@ test('absent military acquisition does not poll, suppress flights, or erase supp assert.equal(registry.isMilitaryLayerActive(), false); assert.equal(fetches, 0); }); + +test('a rail source must carry the feed catalog that keeps unbuilt packs out', () => { + // Without getFeeds the layer could never learn which schedule packs are + // built, so the live feeds that read their routes from one would never run. + assert.throws( + () => + resolveCatalogSources({ rail: { requestSnapshot: async () => null } }), + /Invalid catalog source: rail/, + ); + const source = { + requestSnapshot: async () => null, + getFeeds: async () => [], + }; + assert.equal(resolveCatalogSources({ rail: source }).sources.rail, source); +}); diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index 62e3b2aee..a452fa0cf 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -503,6 +503,10 @@ export const DATA_CREDITS = [ key: 'gtfs-rt', html: 'Transit vehicles: operator GTFS-Realtime feeds (each operator is credited below when its vehicles are shown)', }, + { + key: 'rail-gtfs-rt', + html: 'Trains: operator realtime feeds (each operator is credited below when its trains are shown)', + }, { key: 'radio-browser', html: @@ -759,6 +763,20 @@ export function transitFeedCredit(feed, snapshot = null) { }; } +/** + * Per-feed rail credit, registered the first time that feed's trains render + * (see `src/data/railFeeds.js` for the licence of each). + * @param {{ id: string, attribution: string, license: string, licenseUrl: string }} feed + * @returns {{ key: string, html: string }} + */ +export function railFeedCredit(feed) { + const transit = transitFeedCredit(feed); + return { + key: `rail-${feed.id}`, + html: transit.html.replace(/^Transit \(/, 'Trains ('), + }; +} + /** Registered when the Bhote Koshi event reconstruction activates. */ export const BHOTE_KOSHI_CREDIT = { key: 'bhote-koshi-2026', diff --git a/src/data/dataCredits.test.mjs b/src/data/dataCredits.test.mjs index e3129f378..8e7179338 100644 --- a/src/data/dataCredits.test.mjs +++ b/src/data/dataCredits.test.mjs @@ -244,3 +244,12 @@ test('Warsaw and Poznań credits carry the data date their licences ask for', () assert.equal(mbta.key, 'transit-mbta'); assert.doesNotMatch(mbta.html, /data of/); }); + +test('the always-on rail credit names live feeds only, never timetables', () => { + // Every train shown is where its operator reported it; none is placed + // from a timetable. + const rail = DATA_CREDITS.find((entry) => entry.key === 'rail-gtfs-rt'); + assert.ok(rail); + assert.match(rail.html, /realtime feeds/); + assert.doesNotMatch(rail.html, /timetable|placed from/i); +}); diff --git a/src/data/irishRail.js b/src/data/irishRail.js new file mode 100644 index 000000000..a76e96536 --- /dev/null +++ b/src/data/irishRail.js @@ -0,0 +1,257 @@ +/** + * @module irishRail + * @description Adapter for the Irish Rail realtime API's running-trains list + * (`getCurrentTrainsXML`), turning it into the train records the GTFS-RT path + * produces. + * + * The document is a flat list of `` elements, each with + * seven text children (TrainStatus, TrainLatitude, TrainLongitude, TrainCode, + * TrainDate, PublicMessage, Direction). That schema is all this reads, so it + * is read with a small scanner instead of an XML library: no DTDs, no entity + * definitions, no attributes, no nesting beyond those two levels. A document + * that declares a DTD or entities is refused, and every size is capped (whole + * text, train count, field length). + * + * Positions are NOT GPS: the operator places each train at the station or + * timing point its signalling system last reported. The record says so with + * `positionSource: 'event'` (added by the rail proxy). The feed carries no + * time for that report, so a record's time is the fetch time, labelled + * `timestampSource: 'fetch'`, which the layer ages conservatively. + * + * Pure: no Node built-ins, safe for node:test and any server runtime. + */ + +import { isPlausibleVehiclePosition } from './gtfsRealtime.js'; + +/** Longest document read (characters). A full day's list is about 35 KB. */ +export const IRISH_RAIL_MAX_TEXT_CHARS = 1024 * 1024; +/** Most trains read from one document; about 80 run at once. */ +export const IRISH_RAIL_MAX_TRAINS = 2000; +/** Longest accepted field (characters); the public message is the longest. */ +export const IRISH_RAIL_MAX_FIELD_CHARS = 512; + +const FIELDS = Object.freeze([ + 'TrainStatus', + 'TrainLatitude', + 'TrainLongitude', + 'TrainCode', + 'TrainDate', + 'PublicMessage', + 'Direction', +]); + +/** Error for a document this adapter will not read. */ +export class IrishRailFormatError extends Error { + constructor(message) { + super(message); + this.name = 'IrishRailFormatError'; + } +} + +const NAMED_ENTITIES = Object.freeze({ + amp: '&', + lt: '<', + gt: '>', + quot: '"', + apos: "'", +}); + +/** + * Decode the five predefined XML entities and numeric character references. + * An unknown entity is left as written (no DTD can define one here). + * @param {string} text + * @returns {string} + */ +export function decodeXmlText(text) { + return String(text).replace( + /&(#x[0-9a-fA-F]{1,6}|#[0-9]{1,7}|[a-zA-Z]{2,4});/g, + (whole, body) => { + if (body[0] === '#') { + const code = + body[1] === 'x' || body[1] === 'X' + ? Number.parseInt(body.slice(2), 16) + : Number.parseInt(body.slice(1), 10); + return Number.isInteger(code) && code > 0 && code <= 0x10ffff + ? String.fromCodePoint(code) + : whole; + } + return Object.hasOwn(NAMED_ENTITIES, body) ? NAMED_ENTITIES[body] : whole; + }, + ); +} + +/** + * The text of `...` inside one train element, `''` for an empty + * or self-closing element, null when absent or over the field cap. + */ +function readField(block, name) { + const open = `<${name}>`; + const start = block.indexOf(open); + if (start < 0) { + return block.includes(`<${name}/>`) || block.includes(`<${name} />`) + ? '' + : null; + } + const from = start + open.length; + const end = block.indexOf(``, from); + if (end < 0 || end - from > IRISH_RAIL_MAX_FIELD_CHARS * 6) return null; + const value = decodeXmlText(block.slice(from, end)).trim(); + return value.length <= IRISH_RAIL_MAX_FIELD_CHARS ? value : null; +} + +/** + * Split the running-trains document into raw train rows. + * @param {string} text + * @returns {{rows: Array>, truncated: boolean}} + */ +export function parseIrishRailTrainsXml(text) { + if (typeof text !== 'string') throw new IrishRailFormatError('not text'); + if (text.length > IRISH_RAIL_MAX_TEXT_CHARS) + throw new IrishRailFormatError(`document is ${text.length} characters`); + if (/'; + const CLOSE = ''; + let at = text.indexOf(OPEN); + while (at >= 0) { + const end = text.indexOf(CLOSE, at + OPEN.length); + if (end < 0) break; + if (rows.length >= IRISH_RAIL_MAX_TRAINS) { + truncated = true; + break; + } + const block = text.slice(at + OPEN.length, end); + const row = {}; + for (const name of FIELDS) row[name] = readField(block, name); + rows.push(row); + at = text.indexOf(OPEN, end + CLOSE.length); + } + return { rows, truncated }; +} + +/** + * Read the three lines of a public message. The operator separates lines + * with a literal backslash-n; a real newline is accepted too. + * + * A118\n14:50 - Dublin Connolly to Belfast (0 mins late)\nDeparted Dundalk next stop Newry + * E244\nHowth to Bray\nExpected Departure 16:35 + * A311\n15:05 - Tralee to Mallow(-2 mins late)\nTERMINATED Mallow at 16:34 + * + * @param {string|null} message + * @returns {{origin: string|null, destination: string|null, + * scheduledDeparture: string|null, delaySec: number|null, + * event: 'departed'|'arrived'|'expected'|'terminated'|null, + * lastStop: string|null, nextStop: string|null}} + */ +export function parsePublicMessage(message) { + const result = { + origin: null, + destination: null, + scheduledDeparture: null, + delaySec: null, + event: null, + lastStop: null, + nextStop: null, + }; + const lines = String(message ?? '') + .split(/\\n|\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + const route = + /^(?:(\d{1,2}:\d{2})\s*-\s*)?(.+?)\s+to\s+(.+?)(?:\s*\((-?\d{1,4})\s*mins?\s+late\))?$/i.exec( + lines[1] || '', + ); + if (route) { + result.scheduledDeparture = route[1] || null; + result.origin = route[2].trim(); + result.destination = route[3].trim(); + if (route[4] !== undefined) result.delaySec = Number(route[4]) * 60; + } + const event = lines[2] || ''; + let match; + if ( + (match = /^(Departed|Arrived)\s+(.+?)\s+next stop\s+(.+)$/i.exec(event)) + ) { + result.event = + match[1].toLowerCase() === 'departed' ? 'departed' : 'arrived'; + result.lastStop = match[2].trim(); + result.nextStop = match[3].trim(); + } else if (/^Expected Departure\b/i.test(event)) { + result.event = 'expected'; + } else if ( + (match = /^TERMINATED\s+(.+?)\s+at\s+\d{1,2}:\d{2}/i.exec(event)) + ) { + result.event = 'terminated'; + result.lastStop = match[1].trim(); + } + return result; +} + +/** + * Turn the running-trains document into unclassified train records in the + * shape the GTFS-RT path produces (src/data/gtfsRealtime.js), plus the + * delay the public message states (`delaySec`). The message's last and next + * stop and the row's direction only decide `status`; nothing reads them + * further, so they do not leave the server. + * + * Status `T` (terminated) trains are dropped: they are out of service, parked + * at the end of their run. Status `N` trains are due to start within ten + * minutes and stand at their origin (`STOPPED_AT`). A running train is + * `STOPPED_AT` after "Arrived" and `IN_TRANSIT_TO` its next stop after + * "Departed". + * + * @param {string} text The XML document. + * @param {number} fetchedAtS Fetch time (epoch seconds), the only time the + * feed lets a record carry. + * @returns {{entityCount: number, truncated: boolean, vehicles: object[]}} + */ +export function decodeIrishRailTrains(text, fetchedAtS) { + const { rows, truncated } = parseIrishRailTrainsXml(text); + const seen = new Set(); + const vehicles = []; + for (const row of rows) { + const code = row.TrainCode; + if (!code || !/^[A-Z0-9]{1,8}$/.test(code) || seen.has(code)) continue; + const status = row.TrainStatus; + if (status !== 'R' && status !== 'N') continue; + const lat = Number.parseFloat(row.TrainLatitude ?? ''); + const lon = Number.parseFloat(row.TrainLongitude ?? ''); + if (!isPlausibleVehiclePosition(lat, lon)) continue; + seen.add(code); + const message = parsePublicMessage(row.PublicMessage); + const routeId = + message.origin && message.destination + ? `${message.origin} - ${message.destination}` + : null; + vehicles.push({ + id: code, + lat: Number(lat.toFixed(6)), + lon: Number(lon.toFixed(6)), + bearing: null, + speedMps: null, + timestamp: fetchedAtS, + timestampSource: 'fetch', + routeId, + tripId: code, + directionId: null, + label: routeId + ? `${code} ${message.origin} to ${message.destination}` + : code, + stopId: null, + status: + // i18n-ignore: GTFS-Realtime status codes, not UI text + status === 'N' || message.event === 'arrived' + ? 'STOPPED_AT' + : message.event === 'departed' + ? 'IN_TRANSIT_TO' + : null, + occupancy: null, + delaySec: message.delaySec, + }); + } + return { entityCount: rows.length, truncated, vehicles }; +} diff --git a/src/data/irishRail.test.mjs b/src/data/irishRail.test.mjs new file mode 100644 index 000000000..d6c3665e9 --- /dev/null +++ b/src/data/irishRail.test.mjs @@ -0,0 +1,193 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + IRISH_RAIL_MAX_TEXT_CHARS, + IRISH_RAIL_MAX_TRAINS, + IrishRailFormatError, + decodeIrishRailTrains, + decodeXmlText, + parseIrishRailTrainsXml, + parsePublicMessage, +} from './irishRail.js'; + +/** One `` element, written by hand for these tests. */ +function train({ + status = 'R', + lat = '53.3531', + lon = '-6.24591', + code = 'A118', + date = '06 Nov 2026', + message = 'A118\\n14:50 - Dublin Connolly to Belfast (3 mins late)\\nDeparted Dundalk next stop Newry', + direction = 'Northbound', +} = {}) { + return ` + ${status} + ${lat} + ${lon} + ${code} + ${date} + ${message} + ${direction} + `; +} + +function document(...trains) { + return ` + +${trains.join('\n')} +`; +} + +const FETCHED_S = 1_794_000_000; + +test('a running-trains document becomes GTFS-shaped train records', () => { + const { vehicles, entityCount, truncated } = decodeIrishRailTrains( + document( + train(), + train({ + code: 'E244', + status: 'N', + message: 'E244\\nHowth to Bray\\nExpected Departure 16:35', + direction: 'Southbound', + }), + train({ + code: 'D825', + message: + 'D825\\n16:40 - Dun Laoghaire to Dundalk (0 mins late)\\nArrived Dun Laoghaire next stop Blackrock', + }), + ), + FETCHED_S, + ); + assert.equal(entityCount, 3); + assert.equal(truncated, false); + assert.equal(vehicles.length, 3); + const [enterprise, dart, commuter] = vehicles; + assert.deepEqual(enterprise, { + id: 'A118', + lat: 53.3531, + lon: -6.24591, + bearing: null, + speedMps: null, + timestamp: FETCHED_S, + timestampSource: 'fetch', + routeId: 'Dublin Connolly - Belfast', + tripId: 'A118', + directionId: null, + label: 'A118 Dublin Connolly to Belfast', + stopId: null, + status: 'IN_TRANSIT_TO', + occupancy: null, + delaySec: 180, + }); + // The message's stops and the row's direction decide the status only. + for (const field of ['serviceState', 'lastStop', 'nextStop', 'direction']) + assert.equal(field in dart, false, field); + assert.equal(dart.status, 'STOPPED_AT'); + assert.equal(dart.routeId, 'Howth - Bray'); + assert.equal(dart.delaySec, null); + assert.equal(commuter.status, 'STOPPED_AT'); + assert.equal(commuter.delaySec, 0); +}); + +test('terminated, unplaced, malformed and repeated trains are dropped', () => { + const { vehicles, entityCount } = decodeIrishRailTrains( + document( + train({ + code: 'A311', + status: 'T', + message: + 'A311\\n15:05 - Tralee to Mallow(-2 mins late)\\nTERMINATED Mallow at 16:34', + }), + train({ code: 'A1', lat: '0', lon: '0' }), + train({ code: 'A2', lat: 'north', lon: '-6.2' }), + train({ code: 'A3', status: 'X' }), + train({ code: 'bad code!' }), + train({ code: 'A4' }), + train({ code: 'A4', lat: '53.0' }), + ), + FETCHED_S, + ); + assert.equal(entityCount, 7); + assert.deepEqual( + vehicles.map((vehicle) => [vehicle.id, vehicle.lat]), + [['A4', 53.3531]], + ); +}); + +test('public messages: the three line shapes, with and without spacing', () => { + assert.deepEqual( + parsePublicMessage( + 'A311\\n15:05 - Tralee to Mallow(-2 mins late)\\nTERMINATED Mallow at 16:34', + ), + { + origin: 'Tralee', + destination: 'Mallow', + scheduledDeparture: '15:05', + delaySec: -120, + event: 'terminated', + lastStop: 'Mallow', + nextStop: null, + }, + ); + const real = parsePublicMessage( + 'D319\n16:27 - Docklands to M3 Parkway (1 mins late)\nArrived Pelletstown next stop Ashtown', + ); + assert.equal(real.destination, 'M3 Parkway'); + assert.equal(real.event, 'arrived'); + assert.equal(real.nextStop, 'Ashtown'); + assert.equal(parsePublicMessage('').origin, null); + assert.equal(parsePublicMessage(null).event, null); + assert.equal(parsePublicMessage('X1\\nno route here\\n?').origin, null); +}); + +test('entities are decoded and DTDs refused', () => { + assert.equal( + decodeXmlText('Tom & Jerry <> "' AB &bogus;'), + 'Tom & Jerry <> "\' AB &bogus;', + ); + assert.equal(decodeXmlText('� �'), '� �'); + const { rows } = parseIrishRailTrainsXml( + document(train({ direction: 'To Dun Laoghaire & Bray' })), + ); + assert.equal(rows[0].Direction, 'To Dun Laoghaire & Bray'); + assert.throws( + () => + parseIrishRailTrainsXml( + `]>${document(train())}`, + ), + IrishRailFormatError, + ); + assert.throws( + () => parseIrishRailTrainsXml('error'), + IrishRailFormatError, + ); + assert.throws(() => parseIrishRailTrainsXml(42), IrishRailFormatError); +}); + +test('sizes are capped: document, train count and field length', () => { + assert.throws( + () => + parseIrishRailTrainsXml( + document(train()) + ' '.repeat(IRISH_RAIL_MAX_TEXT_CHARS), + ), + /characters/, + ); + const many = Array.from({ length: IRISH_RAIL_MAX_TRAINS + 3 }, (_, i) => + train({ code: `E${i}` }), + ); + const capped = parseIrishRailTrainsXml(document(...many)); + assert.equal(capped.rows.length, IRISH_RAIL_MAX_TRAINS); + assert.equal(capped.truncated, true); + const { rows } = parseIrishRailTrainsXml( + document(train({ message: 'm'.repeat(600) }), ''), + ); + assert.equal(rows.length, 1, 'an unclosed element is not a train'); + assert.equal(rows[0].PublicMessage, null, 'an over-long field is refused'); + const empty = parseIrishRailTrainsXml( + document( + 'E1', + ), + ); + assert.equal(empty.rows[0].Direction, ''); + assert.equal(empty.rows[0].TrainStatus, null); +}); diff --git a/src/data/layerState.js b/src/data/layerState.js index ede1a60a4..4fef4ba3b 100644 --- a/src/data/layerState.js +++ b/src/data/layerState.js @@ -313,6 +313,13 @@ const OPTION_GROUPS = Object.freeze({ high: 'h', }), ]), + rail: Object.freeze([ + booleanOption('transit', 't', true), + booleanOption('local', 'l', true), + booleanOption('regional', 'r', true), + booleanOption('national', 'n', true), + booleanOption('international', 'i', true), + ]), 'wildlife-sightings': Object.freeze([ booleanOption('confirmed', 'c', false), enumOption( @@ -700,6 +707,12 @@ export const LAYER_STATE_REGISTRY = Object.freeze([ disposition: 'enabled+options', optionOwner: 'radio', }), + Object.freeze({ + id: 'rail', + token: '02', + disposition: 'enabled+options', + optionOwner: 'rail', + }), Object.freeze({ id: 'recent-imagery', token: '1', diff --git a/src/data/layerState.test.mjs b/src/data/layerState.test.mjs index 7614e80d0..116a77c88 100644 --- a/src/data/layerState.test.mjs +++ b/src/data/layerState.test.mjs @@ -203,8 +203,8 @@ function encode(state) { test('production registry is exact, canonical, and rejects incomplete contracts', async () => { assert.equal(validateLayerStateRegistry(), true); - assert.equal(REGISTERED_LAYER_IDS.length, 38); - assert.equal(new Set(REGISTERED_LAYER_IDS).size, 38); + assert.equal(REGISTERED_LAYER_IDS.length, 39); + assert.equal(new Set(REGISTERED_LAYER_IDS).size, 39); assert.ok(REGISTERED_LAYER_IDS.includes('transit')); assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort()); assert.deepEqual(LEGACY_LAYER_STATE_TOKENS, { @@ -243,12 +243,14 @@ test('production registry is exact, canonical, and rejects incomplete contracts' for (const [id, token] of Object.entries(LEGACY_LAYER_STATE_TOKENS)) { assert.equal(LAYER_STATE_TOKEN_RESERVATIONS[id], token); } - // News holds 00 and Shipwrecks 01, the first pairs: the next token is 02. - assert.equal(nextLayerStateToken(), '02'); - // The allocation rules below read the ledger before either took a pair. + // News holds 00, Shipwrecks 01 and Rail 02, the first pairs: the next token + // is 03. + assert.equal(nextLayerStateToken(), '03'); + // The allocation rules below read the ledger before any of them took a pair. const beforeNews = { ...LAYER_STATE_TOKEN_RESERVATIONS }; delete beforeNews.news; delete beforeNews.shipwrecks; + delete beforeNews.rail; assert.equal(nextLayerStateToken(beforeNews), '00'); assert.equal( nextLayerStateToken({ diff --git a/src/data/layerStateTokenLedger.test.mjs b/src/data/layerStateTokenLedger.test.mjs index 067904a35..a17be2142 100644 --- a/src/data/layerStateTokenLedger.test.mjs +++ b/src/data/layerStateTokenLedger.test.mjs @@ -97,10 +97,11 @@ function withPublishedBase( } } -// The ledger before Wildlife Sightings took the last digit and News and -// Shipwrecks the first pairs, for the allocation rules written against it. +// The ledger before Wildlife Sightings took the last digit and News, +// Shipwrecks and Rail the first pairs, for the allocation rules written +// against it. const beforeLastDigitRows = reservationRows.filter( - ([id]) => !['wildlife-sightings', 'news', 'shipwrecks'].includes(id), + ([id]) => !['wildlife-sightings', 'news', 'shipwrecks', 'rail'].includes(id), ); function writeCodecFixture(cwd, rows, entries) { @@ -112,6 +113,7 @@ function writeCodecFixture(cwd, rows, entries) { readFileSync(new URL(`./${name}`, import.meta.url)), ); let source = readFileSync(CODEC_SOURCE_PATH, 'utf8'); + // Layers published after the fixture's ledger are not in its registry. if (!rows.some(([id]) => id === 'wildlife-sightings')) source = source.replace( / Object\.freeze\(\{\s*id: 'wildlife-sightings',[\s\S]*?\}\),\n/, @@ -127,6 +129,11 @@ function writeCodecFixture(cwd, rows, entries) { / Object\.freeze\(\{\s*id: 'shipwrecks',[\s\S]*?\}\),\n/, '', ); + if (!rows.some(([id]) => id === 'rail')) + source = source.replace( + / Object\.freeze\(\{\s*id: 'rail',[\s\S]*?\}\),\n/, + '', + ); const registryEnd = ']);\n\nexport const REGISTERED_LAYER_IDS'; assert.equal(source.split(registryEnd).length, 2); const additions = entries @@ -150,7 +157,7 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed { ...parseLayerStateTokenReservations(reservationRows) }, { ...LAYER_STATE_TOKEN_RESERVATIONS }, ); - assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 38); + assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 39); assert.deepEqual( { ...LAYER_STATE_TOKEN_RESERVATIONS }, { @@ -165,6 +172,7 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed 'wildlife-sightings': '9', news: '00', shipwrecks: '01', + rail: '02', }, ); assert.throws( @@ -190,9 +198,9 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed /Duplicate layer-state token reservation/, ); assert.equal( - parseLayerStateTokenReservations([...reservationRows, ['future', '02']]) + parseLayerStateTokenReservations([...reservationRows, ['future', '03']]) .future, - '02', + '03', ); assert.throws( () => @@ -423,19 +431,19 @@ test('an isolated valid two-character fixture round-trips an l field beyond the test('checker reads a complete future base ledger and rejects retired-token reuse', () => { withPublishedBase( { - rows: [...reservationRows, ['retired-layer', '02']], + rows: [...reservationRows, ['retired-layer', '03']], source: - 'export const LAYER_STATE_TOKEN_RESERVATIONS = Object.freeze({ ["retired-layer"]: "02" });\n', + 'export const LAYER_STATE_TOKEN_RESERVATIONS = Object.freeze({ ["retired-layer"]: "03" });\n', }, (cwd) => { const published = readPublishedLayerStateReservations('HEAD', cwd); - assert.equal(Object.keys(published).length, 39); - assert.equal(published['retired-layer'], '02'); + assert.equal(Object.keys(published).length, 40); + assert.equal(published['retired-layer'], '03'); assert.throws( () => validateLayerStateAllocations(published, { ...LAYER_STATE_TOKEN_RESERVATIONS, - newcomer: '02', + newcomer: '03', }), /changed or removed/, ); diff --git a/src/data/layerStateTokenReservations.json b/src/data/layerStateTokenReservations.json index f320ca0f7..965a0693d 100644 --- a/src/data/layerStateTokenReservations.json +++ b/src/data/layerStateTokenReservations.json @@ -20,6 +20,7 @@ ["military-installations", "i"], ["news", "00"], ["radio", "r"], + ["rail", "02"], ["recent-imagery", "1"], ["rocket-launches", "x"], ["s2-ghostmaps", "5"], diff --git a/src/data/railFeeds.js b/src/data/railFeeds.js new file mode 100644 index 000000000..f1c1eec1d --- /dev/null +++ b/src/data/railFeeds.js @@ -0,0 +1,1632 @@ +import { greatCircleKm } from '../geoDistance.js'; + +/** + * @module railFeeds + * @description Registry of the live train feeds the Rail layer shows (GPS + * positions, or Irish Rail's station reports, with trip updates where the + * operator publishes them), with the licence or other basis each is used on, + * and the rules that sort each feed's vehicles into service categories. + * + * Every entry is a URL the SERVER fetches: the browser only ever asks + * `/api/rail/trains/` for a registered id (SECURITY.md: proxies never fetch + * client-supplied URLs). Adding a feed means adding a row here, a + * DATA_SOURCES.md row with its licence, and a credit in dataCredits.js. + * + * `defaultEnabled` is true when the operator's published terms were read and + * cover this use, or, where the operator publishes no licence or terms at + * all, on the basis the entry records: a project owner decision to use the + * data without a published licence, which is never presented as a licence + * (DATA_SOURCES.md, "Rail"). A feed also needs real coordinates: no train is + * placed or moved from a timetable. `terms` records the passage the decision + * rests on; an empty `quote` means there is no published text to quote, and + * its `note` says why. A registered feed that is switched off is never polled + * and never offered to the browser. + * + * Every feed is keyless: its realtime feed and its static timetable are + * published for anyone to fetch. Values are shown as the source gives them: + * positions where the operator reports them, and, once a host has built a + * feed's optional pack (scripts/build-rail-paths.mjs), route lines from the + * operator's own shapes. + * + * Many operators publish trains in the same feed as their buses. A feed's + * `classify` decides, per vehicle, whether it is a train at all and which + * category it belongs to; anything it returns null for is dropped before the + * snapshot leaves the server, so the browser never sees a bus. + * + * Pure data + pure helpers: imported by the browser layer, the Vite proxy, and + * node:test. No Cesium, no Node built-ins. + */ + +/** + * Service categories, narrowest first. `transit` is rail inside the urban + * fabric (metro, light rail, tram); `local` is suburban and commuter rail and + * short branch lines; `regional` stays inside one state or province; + * `national` crosses one; `international` crosses a national border. + */ +export const RAIL_CATEGORIES = Object.freeze([ + 'transit', + 'local', + 'regional', + 'national', + 'international', +]); + +/** + * What runs on the track, for the icon: `rail` (main-line trains), `metro` + * (grade-separated urban rail), `tram` (light rail and streetcars). + */ +export const RAIL_MODES = Object.freeze(['rail', 'metro', 'tram']); + +/** + * Where a feed's positions come from, which the layer states to the user: + * `gps` (the train reports where it is) or `event` (the train was last seen + * at a station or timing point by the signalling system, not tracked between + * them). + */ +export const RAIL_POSITION_SOURCES = Object.freeze(['gps', 'event']); + +/** One classified train: the category the layer filters on and the icon mode. */ +function train(category, mode) { + return Object.freeze({ category, mode }); +} +const LOCAL_RAIL = train('local', 'rail'); +const REGIONAL_RAIL = train('regional', 'rail'); +const NATIONAL_RAIL = train('national', 'rail'); +const INTERNATIONAL_RAIL = train('international', 'rail'); +const TRANSIT_METRO = train('transit', 'metro'); +const TRANSIT_TRAM = train('transit', 'tram'); + +/** Every vehicle in a rail-only feed is the same kind of train. */ +function always(kind) { + return () => kind; +} + +/** + * Irish Rail province of each terminus an InterCity train can start or end + * at. Northern Ireland stations are `NI`: a train with one end there and the + * other in the Republic (the Enterprise) is international. + */ +const IRISH_RAIL_PROVINCE = new Map( + [ + [ + 'Leinster', + [ + 'Dublin Heuston', + 'Dublin Connolly', + 'Dublin Pearse', + 'Grand Canal Dock', + 'Drogheda', + 'Dundalk', + 'Maynooth', + 'Kildare', + 'Newbridge', + 'Hazelhatch', + 'Portlaoise', + 'Portarlington', + 'Ballybrophy', + 'Athy', + 'Carlow', + 'Kilkenny', + 'Wexford', + 'Rosslare Europort', + 'Rosslare Strand', + 'Gorey', + 'Arklow', + 'Wicklow', + 'Greystones', + 'Bray', + 'Athlone', + 'Mullingar', + 'Longford', + 'Tullamore', + ], + ], + [ + 'Munster', + [ + 'Cork', + 'Cobh', + 'Midleton', + 'Mallow', + 'Tralee', + 'Killarney', + 'Limerick', + 'Limerick Junction', + 'Ennis', + 'Waterford', + 'Thurles', + 'Nenagh', + 'Roscrea', + 'Clonmel', + 'Charleville', + ], + ], + [ + 'Connacht', + [ + 'Galway', + 'Athenry', + 'Westport', + 'Ballina', + 'Manulla Junction', + 'Sligo', + 'Carrick on Shannon', + 'Roscommon', + 'Claremorris', + ], + ], + [ + 'NI', + [ + 'Belfast', + 'Belfast Grand Central', + 'Belfast Lanyon Place', + 'Lisburn', + 'Portadown', + 'Newry', + ], + ], + ].flatMap(([province, stations]) => + stations.map((station) => [station.toLowerCase(), province]), + ), +); + +/** + * Irish Rail trains, from the train code and the route the adapter builds + * from the public message (` - `). Code prefixes, as + * published by the Irish Rail realtime API: `E` DART and `D`/`P` Commuter + * (both local), `A`/`B` InterCity. An InterCity train is national when its + * two ends are in different provinces, regional when both are in the same + * province (Mallow–Tralee, Limerick–Limerick Junction), and international + * when one end is in Northern Ireland (the Enterprise, Dublin–Belfast). An + * InterCity train whose ends are not both known stays national, the + * operator's own grouping. Any other prefix is an Irish Rail passenger train + * of an unknown kind and is shown as local. + */ +function irishRailTrain(routeId, context) { + // A train matched to its pack run: the pack route id carries the code + // letter and the scope (irishRailTripRename). + const scoped = scopedRoute(routeId); + if (scoped) return irishRailPackTrain(scoped); + const code = String(context?.tripId ?? ''); + if (!/^[A-Z]\d{1,4}$/.test(code)) return null; + const prefix = code[0]; + if (prefix !== 'A' && prefix !== 'B') return LOCAL_RAIL; + const [from, to] = String(routeId ?? '') + .split(' - ') + .map((name) => IRISH_RAIL_PROVINCE.get(name.trim().toLowerCase())); + if ((from === 'NI') !== (to === 'NI')) return INTERNATIONAL_RAIL; + if (from && to && from === to) return REGIONAL_RAIL; + return NATIONAL_RAIL; +} + +/** Route ids mapped to a kind; any other route (a bus) is dropped. */ +function byRoute(table) { + const routes = new Map(Object.entries(table)); + return (routeId) => routes.get(String(routeId ?? '')) ?? null; +} + +/** + * MBTA route ids are mode-typed: commuter rail is `CR-*`, heavy rapid transit + * is Red/Orange/Blue, the Green Line branches and the Mattapan trolley are + * light rail. Buses, ferries and shuttles are dropped. + */ +function mbtaTrain(routeId) { + const id = String(routeId ?? ''); + if (/^CR-/.test(id)) return LOCAL_RAIL; + if (/^(Red|Orange|Blue)$/.test(id)) return TRANSIT_METRO; + if (/^(Green-[BCDE]|Mattapan)$/.test(id)) return TRANSIT_TRAM; + return null; +} + +/** + * RTD Denver: commuter rail is the A Line plus the 113 and 117 corridors + * (`113B`, `113G`, `117N`); light rail is the 101, 103 and 107 corridors with a + * line letter (`101E`, `103W`, `107R`). Everything else in the feed is a bus. + */ +function rtdTrain(routeId) { + const id = String(routeId ?? ''); + if (id === 'A' || /^11[37][A-Z]$/.test(id)) return LOCAL_RAIL; + if (/^10[137][A-Z]$/.test(id)) return TRANSIT_TRAM; + return null; +} + +/** + * TransLink SEQ route ids end in the GTFS route_type family: `-5` is + * Queensland Rail Citytrain (`BRCA-5`). The rail-only URL is used, so the + * suffix is a guard rather than the only filter. + */ +function translinkRailTrain(routeId) { + return /-5$/.test(String(routeId ?? '')) ? LOCAL_RAIL : null; +} + +/** + * Finland (Digitraffic GTFS-RT, the same ids as its passenger GTFS): route + * ids are `____`, station codes + * as the operator writes them (2026-11-06). Types: `HL` commuter (local); + * `H`/`HDM` the operator's regional trains (regional); `IC`, `S` + * (Pendolino), `P` and `PYO` (night train) long-distance (national). Any + * train with an end at `HPA` (Haaparanta, in Sweden) is international. + * Unknown types are dropped. + */ +const FINLAND_ABROAD = new Set(['HPA']); +function finlandTrain(routeId) { + const match = /^([^_]+)_([^_]+)_[^_]+_([A-Z]+)_\d+$/.exec( + String(routeId ?? ''), + ); + if (!match) return null; + const [, from, to, type] = match; + let kind = null; + if (type === 'HL') kind = LOCAL_RAIL; + else if (type === 'H' || type === 'HDM') kind = REGIONAL_RAIL; + else if (['IC', 'S', 'P', 'PYO'].includes(type)) kind = NATIONAL_RAIL; + if (!kind) return null; + return FINLAND_ABROAD.has(from) || FINLAND_ABROAD.has(to) + ? INTERNATIONAL_RAIL + : kind; +} + +/** + * Pack route ids that carry what the classifier reads. The path build + * (scripts/build-rail-paths.mjs, `staticRouteRename` / `staticTripRename`) + * writes `~~`, scope being where the + * run's two ends lie: `S` in one state, `X` in two states of one country, + * `B` across a national border. The proxy reads the same ids back from the + * schedule pack for realtime trips that carry no route (`routesFrom`). + */ +function scopedRoute(routeId) { + const match = /^([^~]+)~([SXB])~/.exec(String(routeId ?? '')); + return match ? { product: match[1], scope: match[2] } : null; +} + +/** + * Renfe Alta Velocidad / Larga y Media Distancia. Product is the static + * GTFS `route_short_name`; the scope is computed from the two stations the + * route id names (`...`), Spain's + * states being its 17 autonomous communities (and Ceuta and Melilla): + * - international: `AVE INT`, `TRENCELTA` (Vigo–Porto), or any end abroad. + * - national: `AVE`, `AVLO`, `ALVIA`, `EUROMED` (it crosses communities), + * `Intercity`. + * - Media Distancia and the other regional products (`MD`, `REGIONAL`, + * `REG.EXP.`, `PROXIMDAD`, `AVANT`, `AVANT EXP`): regional when both ends + * are in one community, national when they are not. + */ +const RENFE_NATIONAL = new Set([ + 'AVE', + 'AVLO', + 'ALVIA', + 'EUROMED', + 'Intercity', +]); +const RENFE_COMMUNITY_RULE = new Set([ + 'MD', + 'REGIONAL', + 'REG.EXP.', + 'PROXIMDAD', + 'AVANT', + 'AVANT EXP', +]); +const RENFE_INTERNATIONAL = new Set(['AVE INT', 'TRENCELTA']); +function renfeLongDistanceTrain(routeId) { + const route = scopedRoute(routeId); + if (!route) return null; + if (RENFE_INTERNATIONAL.has(route.product) || route.scope === 'B') + return INTERNATIONAL_RAIL; + if (RENFE_NATIONAL.has(route.product)) return NATIONAL_RAIL; + if (RENFE_COMMUNITY_RULE.has(route.product)) + return route.scope === 'S' ? REGIONAL_RAIL : NATIONAL_RAIL; + return null; +} + +/** + * Renfe route-id rewrite for the packs: product from `route_short_name`, + * scope from the community (`stateAt`) of the two stations the id names. + * A station the GTFS does not place leaves the route unscoped (`X`). + */ +function renfeRouteRename(route, { stop, stateAt }) { + const product = String(route.route_short_name ?? '').trim(); + const id = String(route.route_id ?? ''); + if (!product || !/^\d{10}/.test(id)) return null; + const ends = [id.slice(0, 5), id.slice(5, 10)].map((code) => { + const at = stop(code); + return at ? stateAt(at.lat, at.lon) : null; + }); + let scope = 'X'; + if (ends.every(Boolean)) { + if (ends.some((end) => end.country !== 'ES')) scope = 'B'; + else if (ends[0].state === ends[1].state) scope = 'S'; + } + return `${product}~${scope}~${id}`; +} + +/** + * Norway (Entur), by line, with the counties (fylker, as of 2024) at each + * end of the line, read from each codespace's GTFS `route_long_name` + * (2026-11-06): + * - local: the `L` lines (L1, L2, L2x, L4 Bergen–Arna, L5 Jærbanen) and the + * Flytoget airport expresses (FLY1, FLY2). + * - international, to Sweden: F1 Oslo–Karlstad, RE20/RX20 Oslo–Göteborg, + * R71 Meråkerbanen (Trondheim–Storlien). + * - national, crossing a county line: the long-distance F4 Bergensbanen, + * F5 Sørlandsbanen, F6 Dovrebanen, F7 Nordlandsbanen; and the regional + * lines whose ends are in different counties (R12 Buskerud–Akershus, R13, + * R14, R21, R22, R23, R31, RE10, RE11/RX11, RE30, R40 Vestland–Buskerud, + * R60 Rørosbanen, R65 Raumabanen). + * - regional, inside one county: R55 (Telemark), R50 Sørtoget lokal + * (Agder), R70 Trønderbanen (Trøndelag), R75 Saltenpendelen (Nordland), + * and R45 Flåmsbana (Vestland, a tourist line). + * Vy runs replacement buses under the train line ids; their journeys are + * `ServiceJourney:BUS-*` and are dropped. + */ +const NORWAY_TOURIST_REGIONAL = Object.freeze({ + ...REGIONAL_RAIL, + tourist: true, +}); +const NORWAY_LINES = new Map( + [ + [LOCAL_RAIL, ['VYG:Line:L1', 'VYG:Line:L2', 'VYG:Line:L2x', 'VYG:Line:L4']], + [LOCAL_RAIL, ['GOA:Line:59', 'FLT:Line:FLY1', 'FLT:Line:FLY2']], + [ + INTERNATIONAL_RAIL, + ['VYG:Line:F1', 'VYG:Line:RE20', 'VYG:Line:RX20', 'SJN:Line:72'], + ], + [ + NATIONAL_RAIL, + [ + 'VYG:Line:F4', + 'GOA:Line:50', + 'SJN:Line:21', + 'SJN:Line:71', + 'VYG:Line:R12', + 'VYG:Line:R13', + 'VYG:Line:R13x', + 'VYG:Line:R14', + 'VYG:Line:R21', + 'VYG:Line:R22', + 'VYG:Line:R23', + 'VYG:Line:R23x', + 'VYG:Line:R31', + 'VYG:Line:RE10', + 'VYG:Line:RE11', + 'VYG:Line:RX11', + 'VYG:Line:RE30', + 'VYG:Line:R40', + 'SJN:Line:25', + 'SJN:Line:22', + ], + ], + [ + REGIONAL_RAIL, + ['VYG:Line:R55', 'GOA:Line:53', 'SJN:Line:26', 'SJN:Line:79'], + ], + [NORWAY_TOURIST_REGIONAL, ['VYG:Line:R45']], + ].flatMap(([kind, ids]) => ids.map((id) => [id, kind])), +); + +function norwayTrain(routeId, context) { + if (/:ServiceJourney:BUS-/.test(String(context?.tripId ?? ''))) return null; + return NORWAY_LINES.get(String(routeId ?? '')) ?? null; +} + +const CC_BY_4_URL = 'https://creativecommons.org/licenses/by/4.0/'; + +/** + * Entur (Norway). `ET-Client-Name` is mandatory (`-`); + * unidentified clients may be blocked. The feeds refresh every 15 s. + */ +const ENTUR_HEADERS = Object.freeze({ 'ET-Client-Name': 'godseyeview-rail' }); +const ENTUR_LIMITS = Object.freeze({ + quote: 'This dataset is updated every 15 seconds.', + note: 'No realtime rate limit is published; each codespace is polled at most every 15 s, filtered with ?datasource= so only that operator is fetched.', +}); +const NLOD_URL = 'https://data.norge.no/nlod/en/2.0'; +const ENTUR_ATTRIBUTION = + 'Inneholder data under Norsk lisens for offentlige data (NLOD) tilgjengeliggjort av Entur AS'; +const ENTUR_TERMS = Object.freeze({ + quote: + "Entur's open data is published under the Norwegian Licence for Open Government Data (NLOD). NLOD permits free reuse for any purpose, including commercial use, subject to attribution requirements", +}); + +const TFI_GTFS_URL = + 'https://www.transportforireland.ie/transitData/Data/GTFS_Realtime.zip'; + +// ------------------------------------------- United States +// Helpers for the United States entries (Tri-Rail). They sit before the +// registry, which uses them. + +/** + * Tri-Rail (SFRTA). The static GTFS (gtfs.tri-rail.com, read 2026-11-10) + * has two train routes, `1` Mainline (Mangonia Park–Miami Airport) and `2` + * Downtown Miami Link (to MiamiCentral): commuter rail through Palm Beach, + * Broward and Miami-Dade counties, the Miami metropolitan area, so local. + * The realtime vehicles carry no route, only the trip id, which is the train + * number (`P617`; Downtown Miami Link trips are `P601X`) and the static + * trip's id. Route `4` is the Fort Lauderdale airport shuttle bus + * (route_type 3), which never reaches the packs. + */ +function triRailTrain(routeId, context) { + const route = String(routeId ?? ''); + if (route === '1' || route === '2') return LOCAL_RAIL; + if (route) return null; + return /^P\d{3}X?$/.test(String(context?.tripId ?? '')) ? LOCAL_RAIL : null; +} + +// ------------------------------------------ United States +// Helpers for the feeds that publish no terms and are used without a +// published licence by project owner decision of 2026-11-10 (Charlotte +// LYNX). They sit before the registry, which uses them. + +/** + * The basis each of those feeds records, before its own facts. It records a + * decision to use the data, not a licence: none was granted. + */ +const OWNER_AUTHORIZED_NO_TERMS = + 'Publishes no terms; used without a published licence (project owner decision, 2026-11-10).'; + +/** + * Registry of feeds. Order is presentation order in the stats/credit text. + * `loadRadiusKm` is the distance from `center` inside which the feed is polled. + * + * `format` picks the decoder: `gtfs-rt` (VehiclePositions protobuf), + * `irish-rail-xml` (src/data/irishRail.js) or `siri-vm` (SIRI Vehicle + * Monitoring, src/data/railSiriVm.js); `url` is the positions feed. + * `positions` says where positions come from (RAIL_POSITION_SOURCES, default + * `gps`). `headers` are sent with every realtime request, `maxBodyBytes` + * sets the feed's own response cap in place of the proxy's 8 MiB (never above + * 64 MiB: railMaxBodyBytes; the feeds that set one publish small documents + * and set a lower cap), and `headerClock: 'untrusted'` marks a feed whose + * header time is wrong and whose vehicles carry none: every train is then + * stamped with the fetch time. + * `tripUpdatesUrl` is the operator's GTFS-RT TripUpdates feed, served as + * `/api/rail/updates/`. + * + * `pollIntervalMs` and `tripUpdatesIntervalMs` are how long a snapshot is + * served from cache before the operator is asked again: never faster than + * the operator asks. `limits` records the published figures the settings + * rest on. + * + * The `static*` fields are read only by the opt-in pack build + * (scripts/build-rail-paths.mjs), never by the proxy, and never sent to the + * browser. `staticGtfsUrl` is the operator's static GTFS; when the GTFS is a + * zip inside that zip, `staticGtfsEntry` names it, and `staticHeaders` are + * the headers its download needs. `staticRouteTypes` narrows the static + * routes kept when the realtime classifier accepts every route of a + * mode-specific feed. `staticSimplifyM` coarsens shape simplification for a + * feed whose shapes would not fit the pack cap at the default 1 m. + * `staticRouteRename(route, helpers)` and `staticTripRename(trip, routeId, + * {ends})` rewrite pack route ids into ids the classifier reads (with + * `staticTripEnds`, each trip's first and last stop are passed as `ends`). + * + * `routesFrom: 'schedule'` marks a feed whose realtime vehicles carry a trip + * id but no route: the route is read from the feed's pack, and a GTFS-RT feed + * of this kind is offered only once that pack is built + * (railFeedNeedsSchedulePack). `positionsByTrainNumber` (Irish Rail) matches + * each reported train to its pack run by train code instead of trip id. + * @type {ReadonlyArray, headerClock?: 'untrusted', + * pollIntervalMs?: number, tripUpdatesUrl?: string, + * tripUpdatesIntervalMs?: number, maxBodyBytes?: number, + * limits?: {quote: string, note?: string}, + * routesFrom?: 'schedule', positionsByTrainNumber?: boolean, + * staticGtfsUrl?: string, staticGtfsEntry?: string, + * staticHeaders?: Record, + * staticRouteTypes?: ReadonlyArray, + * staticTripEnds?: boolean, staticSimplifyM?: number, + * staticRouteRename?: (route: object, helpers: object) => string|null, + * staticTripRename?: (trip: object, routeId: string, + * context?: {ends: object|null}) => string|null, + * license: string, licenseUrl: string, attribution: string, + * defaultEnabled: boolean, terms: {quote: string, note?: string}, + * classify: (routeId: string|null, context?: {tripId?: string|null}) + * => ({category: string, mode: string, tourist?: boolean}|null), + * }>>} + */ +export const RAIL_FEED_REGISTRY = Object.freeze([ + Object.freeze({ + id: 'mta-metro-north', + name: 'Metro-North', + operator: 'Metro-North Railroad (MTA)', + region: 'New York, NY', + center: Object.freeze({ lat: 41.2, lon: -73.6 }), + loadRadiusKm: 180, + format: 'gtfs-rt', + url: 'https://api-endpoint.mta.info/Dataservice/mtagtfsfeeds/mnr%2Fgtfs-mnr', + // One combined feed: the same file carries trip updates. + tripUpdatesUrl: + 'https://api-endpoint.mta.info/Dataservice/mtagtfsfeeds/mnr%2Fgtfs-mnr', + staticGtfsUrl: 'https://rrgtfsfeeds.s3.amazonaws.com/gtfsmnr.zip', + license: 'MTA Data Feed Terms and Conditions', + licenseUrl: 'https://www.mta.info/developers/terms-and-conditions', + attribution: 'MTA', + defaultEnabled: true, + terms: Object.freeze({ + quote: + 'Developers must download and store the MTA data feed on a non-MTA server and redistribute the data to users from that server.', + note: 'This proxy is that server: browsers never contact MTA. The feed stamps every vehicle with the header time, so a fix is aged from the snapshot, not from the train.', + }), + classify: always(LOCAL_RAIL), + }), + Object.freeze({ + id: 'mta-lirr', + name: 'LIRR', + operator: 'Long Island Rail Road (MTA)', + region: 'New York, NY', + center: Object.freeze({ lat: 40.75, lon: -73.3 }), + loadRadiusKm: 160, + format: 'gtfs-rt', + url: 'https://api-endpoint.mta.info/Dataservice/mtagtfsfeeds/lirr%2Fgtfs-lirr', + tripUpdatesUrl: + 'https://api-endpoint.mta.info/Dataservice/mtagtfsfeeds/lirr%2Fgtfs-lirr', + staticGtfsUrl: 'https://rrgtfsfeeds.s3.amazonaws.com/gtfslirr.zip', + license: 'MTA Data Feed Terms and Conditions', + licenseUrl: 'https://www.mta.info/developers/terms-and-conditions', + attribution: 'MTA', + defaultEnabled: true, + terms: Object.freeze({ + quote: + 'Developers must download and store the MTA data feed on a non-MTA server and redistribute the data to users from that server.', + note: 'The feed was seen frozen for two days on 2026-11-06; the layer ages trains by their own report time, so a frozen feed empties instead of showing old trains as live.', + }), + classify: always(LOCAL_RAIL), + }), + Object.freeze({ + id: 'mbta-rail', + name: 'MBTA', + operator: 'Massachusetts Bay Transportation Authority', + region: 'Boston, MA', + center: Object.freeze({ lat: 42.3601, lon: -71.0589 }), + loadRadiusKm: 110, + format: 'gtfs-rt', + url: 'https://cdn.mbta.com/realtime/VehiclePositions.pb', + // All modes, about 1 MB: the rail filter keeps a fraction of it. + tripUpdatesUrl: 'https://cdn.mbta.com/realtime/TripUpdates.pb', + staticGtfsUrl: 'https://cdn.mbta.com/MBTA_GTFS.zip', + license: 'MassDOT Developers License Agreement', + licenseUrl: + 'https://cdn.mbta.com/sites/default/files/2023-08/mbta-massdot-develop-license-agreement.pdf', + attribution: 'MBTA / MassDOT', + defaultEnabled: true, + terms: Object.freeze({ + quote: + 'MassDOT ... hereby grants You (Licensee) non-exclusive, limited, and revocable rights to use, reproduce, and redistribute the Data. ... Clearly acknowledge MassDOT as the provider of the Data.', + note: 'The same feed the Transit layer polls; this layer keeps only Commuter Rail, rapid transit and light rail.', + }), + classify: mbtaTrain, + }), + Object.freeze({ + id: 'septa-regional-rail', + name: 'SEPTA Regional Rail', + operator: 'Southeastern Pennsylvania Transportation Authority', + region: 'Philadelphia, PA', + center: Object.freeze({ lat: 40.05, lon: -75.25 }), + loadRadiusKm: 110, + format: 'gtfs-rt', + url: 'https://www3.septa.org/gtfsrt/septarail-pa-us/Vehicle/rtVehiclePosition.pb', + tripUpdatesUrl: + 'https://www3.septa.org/gtfsrt/septarail-pa-us/Trip/rtTripUpdates.pb', + staticGtfsUrl: + 'https://github.com/septadev/GTFS/releases/latest/download/gtfs_public.zip', + staticGtfsEntry: 'google_rail.zip', + license: 'SEPTA Trip Planning Data Agreement', + licenseUrl: 'https://www3.septa.org/developer/', + attribution: 'SEPTA', + defaultEnabled: true, + terms: Object.freeze({ + quote: + 'non-exclusive, limited and revocable right to use, reproduce and redistribute the Trip Planning Data', + note: 'The agreement names the static data; SEPTA publishes the realtime feeds on the same developer page with no separate terms. Credit is text only: SEPTA trademarks may not be used.', + }), + classify: always(LOCAL_RAIL), + }), + Object.freeze({ + id: 'septa-trolley', + name: 'SEPTA trolleys', + operator: 'Southeastern Pennsylvania Transportation Authority', + region: 'Philadelphia, PA', + center: Object.freeze({ lat: 39.98, lon: -75.2 }), + loadRadiusKm: 60, + format: 'gtfs-rt', + url: 'https://www3.septa.org/gtfsrt/septa-pa-us/Vehicle/rtVehiclePosition.pb', + // The bus feed's trip updates (about 1 MB), filtered like its vehicles. + tripUpdatesUrl: + 'https://www3.septa.org/gtfsrt/septa-pa-us/Trip/rtTripUpdates.pb', + staticGtfsUrl: + 'https://github.com/septadev/GTFS/releases/latest/download/gtfs_public.zip', + staticGtfsEntry: 'google_bus.zip', + license: 'SEPTA Trip Planning Data Agreement', + licenseUrl: 'https://www3.septa.org/developer/', + attribution: 'SEPTA', + defaultEnabled: true, + terms: Object.freeze({ + quote: + 'non-exclusive, limited and revocable right to use, reproduce and redistribute the Trip Planning Data', + note: 'The bus feed, filtered to the trolley routes and the Norristown High Speed Line. The Market-Frankford and Broad Street lines are in no SEPTA feed.', + }), + classify: byRoute({ + T1: TRANSIT_TRAM, + T2: TRANSIT_TRAM, + T3: TRANSIT_TRAM, + T4: TRANSIT_TRAM, + T5: TRANSIT_TRAM, + G1: TRANSIT_TRAM, + D1: TRANSIT_TRAM, + D2: TRANSIT_TRAM, + M1: TRANSIT_METRO, + }), + }), + Object.freeze({ + id: 'marc', + name: 'MARC', + operator: 'Maryland Transit Administration', + region: 'Baltimore–Washington', + center: Object.freeze({ lat: 39.1, lon: -77.0 }), + loadRadiusKm: 140, + format: 'gtfs-rt', + url: 'https://mdotmta-gtfs-rt.s3.amazonaws.com/MARC+RT/marc-vp.pb', + tripUpdatesUrl: + 'https://mdotmta-gtfs-rt.s3.amazonaws.com/MARC+RT/marc-tu.pb', + staticGtfsUrl: 'https://feeds.mta.maryland.gov/gtfs/marc', + license: 'MDOT MTA developer feeds (no endorsement implied)', + licenseUrl: 'https://www.mta.maryland.gov/developer-resources', + attribution: 'Maryland Transit Administration', + defaultEnabled: true, + terms: Object.freeze({ + quote: + 'MTA does not guarantee the accuracy of the data nor does it warrant or endorse any application that uses this data.', + note: 'Published for open fetch with this disclaimer and no licence; the site terms cover only the website and apps. Shown with attribution and the no-endorsement wording, and switched off if MDOT MTA publishes restrictive terms.', + }), + classify: always(LOCAL_RAIL), + }), + // ------------------------------------------- United States + // Added 2026-11-10; licences read at the source that day unless the note + // says otherwise. Helpers are above the registry. + Object.freeze({ + id: 'tri-rail', + name: 'Tri-Rail', + operator: 'South Florida Regional Transportation Authority', + region: 'South Florida', + center: Object.freeze({ lat: 26.27, lon: -80.15 }), + loadRadiusKm: 90, + format: 'gtfs-rt', + url: 'https://gtfsr.tri-rail.com/download.aspx?file=position_updates.pb', + tripUpdatesUrl: + 'https://gtfsr.tri-rail.com/download.aspx?file=trip_updates.pb', + // Vehicles report every 20 to 40 s (seen 2026-11-10); the positions are + // asked every 30 s, the trip updates every minute. + pollIntervalMs: 30_000, + tripUpdatesIntervalMs: 60_000, + staticGtfsUrl: 'https://gtfs.tri-rail.com/gtfs.zip', + license: 'No licence stated (open feed pages)', + licenseUrl: 'https://gtfsr.tri-rail.com/', + attribution: 'South Florida Regional Transportation Authority (Tri-Rail)', + defaultEnabled: true, + terms: Object.freeze({ + quote: 'Download Realtime Feeds Below:', + note: 'Used without a published licence (project owner decision, 2026-11-10). gtfsr.tri-rail.com (the page quoted above, read 2026-11-10) links the GTFS-RT trip updates, alerts and position updates with no terms, and the static GTFS (gtfs.tri-rail.com/gtfs.zip, publisher ETA Transit Systems) states none either; no SFRTA data terms were found. The realtime vehicles carry the train number as their trip id and no route.', + }), + classify: triRailTrain, + }), + Object.freeze({ + id: 'rtd-denver-rail', + name: 'RTD', + operator: 'Regional Transportation District', + region: 'Denver, CO', + center: Object.freeze({ lat: 39.74, lon: -104.99 }), + loadRadiusKm: 70, + format: 'gtfs-rt', + // The www URL redirects twice and lands on this host; the proxy refuses a + // redirect that leaves the registered origin, so the final URL is the one + // registered. + url: 'https://open-data.rtd-denver.com/files/gtfs-rt/rtd/VehiclePosition.pb', + tripUpdatesUrl: + 'https://open-data.rtd-denver.com/files/gtfs-rt/rtd/TripUpdate.pb', + staticGtfsUrl: 'https://www.rtd-denver.com/files/gtfs/google_transit.zip', + license: 'RTD GTFS-Realtime License Agreement', + licenseUrl: + 'https://www.rtd-denver.com/business-center/open-spatial-data/gtfs-realtime-license-agreement', + attribution: 'RTD', + defaultEnabled: true, + terms: Object.freeze({ + quote: + 'non-exclusive, limited and revocable rights to use, reproduce, and redistribute the Data', + note: 'RTD marks may not be used with the data, so the credit is text only.', + }), + classify: rtdTrain, + }), + Object.freeze({ + id: 'capmetro-rail', + name: 'CapMetro MetroRail', + operator: 'Capital Metropolitan Transportation Authority', + region: 'Austin, TX', + center: Object.freeze({ lat: 30.4, lon: -97.75 }), + loadRadiusKm: 70, + format: 'gtfs-rt', + url: 'https://data.texas.gov/download/eiei-9rpf/application%2Foctet-stream', + staticGtfsUrl: + 'https://data.texas.gov/download/r4v4-vz24/application%2Fzip', + license: 'CapMetro Open Data Terms', + licenseUrl: 'https://www.capmetro.org/metrolabs/', + attribution: 'CapMetro', + defaultEnabled: true, + terms: Object.freeze({ + quote: + 'non-exclusive, limited and revocable rights to use, reproduce, and redistribute CMTA Data', + note: 'The bus feed the Transit layer polls; MetroRail is route 550.', + }), + classify: byRoute({ 550: LOCAL_RAIL }), + }), + Object.freeze({ + id: 'metrotransit-rail', + name: 'METRO light rail', + operator: 'Metro Transit (Metropolitan Council)', + region: 'Minneapolis–St Paul, MN', + center: Object.freeze({ lat: 44.95, lon: -93.2 }), + loadRadiusKm: 50, + format: 'gtfs-rt', + url: 'https://svc.metrotransit.org/mtgtfs/vehiclepositions.pb', + staticGtfsUrl: 'https://svc.metrotransit.org/mtgtfs/gtfs.zip', + license: 'No licence published', + licenseUrl: 'https://svc.metrotransit.org/', + attribution: 'Metro Transit / Metropolitan Council', + defaultEnabled: true, + terms: Object.freeze({ + quote: '', + note: 'Publishes no licence or terms: the data feeds page linked above (read 2026-11-10) lists the GTFS and GTFS-realtime files and shows only "© 2020 Metro Transit", so there is nothing to quote. Used without a published licence, on the same basis as the Transit layer\'s Metro Transit feed, which polls the same file (DATA_SOURCES.md). Blue Line 901 and Green Line 902 only; Northstar ended on 2026-01-04 and route 888 is now a bus.', + }), + classify: byRoute({ 901: TRANSIT_TRAM, 902: TRANSIT_TRAM }), + }), + Object.freeze({ + id: 'charlotte-lynx', + name: 'LYNX', + operator: 'Charlotte Area Transit System', + region: 'Charlotte, NC', + center: Object.freeze({ lat: 35.22, lon: -80.84 }), + loadRadiusKm: 40, + format: 'gtfs-rt', + url: 'https://gtfsrealtime.ridetransit.org/GTFSRealTime/Vehicle/VehiclePositions.pb', + // The URLs the Transitland and Mobility Database catalogues list for + // CATS. The trip updates carry every bus too; only the two lines' are + // kept. + tripUpdatesUrl: + 'https://gtfsrealtime.ridetransit.org/GTFSRealTime/TripUpdate/Tripupdates.pb', + staticGtfsUrl: + 'https://gtfsrealtime.ridetransit.org/GTFSStatic/api/GTFSDownload/GTFS.zip', + license: 'No licence published', + licenseUrl: 'https://www.charlottenc.gov/cats', + attribution: 'Charlotte Area Transit System', + defaultEnabled: true, + terms: Object.freeze({ + quote: '', + note: `${OWNER_AUTHORIZED_NO_TERMS} The feed host (gtfsrealtime.ridetransit.org) serves the files with no page and no terms, so there is nothing to quote (2026-11-10: 126 vehicles, 13 of them trains; 205 trip updates, 11 on the two lines; every realtime trip id is a static trip id). No CATS page with terms for them was found: charlottenc.gov answered 403 to automated requests, and the CATS Terms and Conditions found there by search govern the CATS mobile app. Routes 501 LYNX Blue Line (light rail) and 510 CityLYNX Gold Line (streetcar), both route_type 0: transit, tram.`, + }), + classify: byRoute({ 501: TRANSIT_TRAM, 510: TRANSIT_TRAM }), + }), + Object.freeze({ + id: 'grt-ion', + name: 'ION', + operator: 'Grand River Transit (Region of Waterloo)', + region: 'Waterloo Region, ON', + center: Object.freeze({ lat: 43.45, lon: -80.5 }), + loadRadiusKm: 40, + format: 'gtfs-rt', + // The suffix-less URL catalogued in Transitland serves a file frozen in + // February 2026; `/2` is the light-rail feed the status page publishes. + url: 'https://webapps.regionofwaterloo.ca/api/grt-routes/api/vehiclepositions/2', + staticGtfsUrl: + 'https://webapps.regionofwaterloo.ca/api/grt-routes/api/staticfeeds/2', + license: 'Region of Waterloo Open Data Licence v2.0', + licenseUrl: + 'https://www.regionofwaterloo.ca/government-and-council/transparency-and-accountability/open-data/', + attribution: + 'Contains information licensed under the Open Data Licence – Region of Waterloo', + defaultEnabled: true, + terms: Object.freeze({ + quote: + 'You are free to: Copy, publish, distribute and transmit the Information ... Use the Information commercially', + }), + classify: byRoute({ 301: TRANSIT_TRAM }), + }), + Object.freeze({ + id: 'translink-seq-rail', + name: 'Queensland Rail Citytrain', + operator: 'Translink (Queensland Government)', + region: 'South East Queensland, Australia', + center: Object.freeze({ lat: -27.47, lon: 153.02 }), + loadRadiusKm: 160, + format: 'gtfs-rt', + url: 'https://gtfsrt.api.translink.com.au/api/realtime/SEQ/VehiclePositions/Rail', + tripUpdatesUrl: + 'https://gtfsrt.api.translink.com.au/api/realtime/SEQ/TripUpdates/Rail', + staticGtfsUrl: 'https://gtfsrt.api.translink.com.au/GTFS/SEQ_GTFS.zip', + license: 'CC BY 4.0', + licenseUrl: 'https://translink.com.au/about-translink/open-data', + attribution: 'Translink / Queensland Government', + defaultEnabled: true, + terms: Object.freeze({ + quote: + 'Our data is licensed under a Creative Commons Attribution 4.0 International License.', + note: 'The terms add: "You must not represent that the State in any way endorses your Application."', + }), + classify: translinkRailTrain, + }), + Object.freeze({ + id: 'translink-seq-tram', + name: 'G:link', + operator: 'Translink (Queensland Government)', + region: 'Gold Coast, Australia', + center: Object.freeze({ lat: -27.98, lon: 153.4 }), + loadRadiusKm: 50, + format: 'gtfs-rt', + url: 'https://gtfsrt.api.translink.com.au/api/realtime/SEQ/VehiclePositions/Tram', + tripUpdatesUrl: + 'https://gtfsrt.api.translink.com.au/api/realtime/SEQ/TripUpdates/Tram', + staticGtfsUrl: 'https://gtfsrt.api.translink.com.au/GTFS/SEQ_GTFS.zip', + // The static GTFS is all of SEQ; only its tram routes are G:link. + staticRouteTypes: Object.freeze([0]), + license: 'CC BY 4.0', + licenseUrl: 'https://translink.com.au/about-translink/open-data', + attribution: 'Translink / Queensland Government', + defaultEnabled: true, + terms: Object.freeze({ + quote: + 'Our data is licensed under a Creative Commons Attribution 4.0 International License.', + }), + classify: always(TRANSIT_TRAM), + }), + Object.freeze({ + id: 'adelaide-metro', + name: 'Adelaide Metro', + operator: 'Adelaide Metro (Rail Commissioner)', + region: 'Adelaide, Australia', + center: Object.freeze({ lat: -34.93, lon: 138.6 }), + loadRadiusKm: 70, + format: 'gtfs-rt', + url: 'https://gtfs.adelaidemetro.com.au/v1/realtime/vehicle_positions', + tripUpdatesUrl: + 'https://gtfs.adelaidemetro.com.au/v1/realtime/trip_updates', + // "stop predictions are refreshed every 60 seconds" (Swagger). + tripUpdatesIntervalMs: 60_000, + // The gateway negotiates on Accept: anything but its own protobuf type + // (including the proxy's default `application/x-protobuf`) is answered + // with base64 text labelled application/json, which no decoder reads. + headers: Object.freeze({ Accept: 'application/x-google-protobuf' }), + staticGtfsUrl: + 'https://gtfs.adelaidemetro.com.au/v1/static/latest/google_transit.zip', + license: 'CC BY 4.0', + licenseUrl: + 'https://data.sa.gov.au/data/dataset/https-gtfs-adelaidemetro-com-au', + attribution: 'Adelaide Metro / Government of South Australia', + defaultEnabled: true, + terms: Object.freeze({ + quote: 'Creative Commons Attribution 4.0', + note: 'Licence as catalogued on data.sa.gov.au; the operator developer page sits behind a bot check. The feed is labelled beta.', + }), + classify: byRoute({ + BEL: LOCAL_RAIL, + FLNDRS: LOCAL_RAIL, + GAW: LOCAL_RAIL, + GAWC: LOCAL_RAIL, + GRNG: LOCAL_RAIL, + NOAR: LOCAL_RAIL, + OSBORN: LOCAL_RAIL, + OUTHA: LOCAL_RAIL, + PTDOCK: LOCAL_RAIL, + SALIS: LOCAL_RAIL, + SEAFRD: LOCAL_RAIL, + GLNELG: TRANSIT_TRAM, + BTANIC: TRANSIT_TRAM, + FESTVL: TRANSIT_TRAM, + }), + }), + Object.freeze({ + id: 'irish-rail', + name: 'Irish Rail', + operator: 'Iarnród Éireann / Irish Rail', + region: 'Ireland', + center: Object.freeze({ lat: 53.3, lon: -7.7 }), + loadRadiusKm: 260, + format: 'irish-rail-xml', + positions: 'event', + // The API documentation shows http; the same path answers over https, + // which is the only scheme this proxy fetches. + url: 'https://api.irishrail.ie/realtime/realtime.asmx/getCurrentTrainsXML', + headers: Object.freeze({ Accept: 'text/xml, application/xml;q=0.9' }), + pollIntervalMs: 30_000, + maxBodyBytes: 1024 * 1024, + limits: Object.freeze({ + quote: + 'Irish Rail provides this information as is and do not offer any support', + note: 'No rate limit is published. Polled every 30 s; one file is about 35 KB.', + }), + // The TFI static GTFS. Its trip_short_name is the train code the XML + // names: each reported train takes the run that code is on now, so it + // is drawn on its line with its stops. + positionsByTrainNumber: true, + routesFrom: 'schedule', + staticGtfsUrl: TFI_GTFS_URL, + staticRouteTypes: Object.freeze([2]), + staticTripEnds: true, + staticTripRename: irishRailTripRename, + license: 'No licence published (positions); CC BY 4.0 (NTA timetable)', + // The NTA's policy page: it names the CC BY 4.0 licence of the NTA's + // GTFS data (the timetable) and is the NTA link its section 7 asks + // public use to carry. The positions' API page publishes no licence. + licenseUrl: 'https://developer.nationaltransport.ie/usagepolicy', + attribution: + 'Iarnród Éireann / Irish Rail; timetable: National Transport Authority, provided as is', + defaultEnabled: true, + terms: Object.freeze({ + quote: '', + note: 'The train positions publish no licence or terms: the Irish Rail realtime API page (api.irishrail.ie/realtime/, read 2026-11-10) documents the calls and says only that "Irish Rail provides this information as is" (quoted under `limits`), so there is nothing to quote. They are used without a published licence, credited "Iarnród Éireann / Irish Rail". The NTA\'s CC BY 4.0 covers only the NTA\'s GTFS data (the timetable), not these positions. Positions are the station or timing point the signalling system last reported, not GPS.', + }), + classify: irishRailTrain, + }), + Object.freeze({ + id: 'fintraffic-vr', + name: 'VR (Finland)', + operator: 'VR, via Fintraffic Digitraffic', + region: 'Finland', + center: Object.freeze({ lat: 63.5, lon: 25.5 }), + loadRadiusKm: 600, + format: 'gtfs-rt', + // Digitraffic's GTFS-RT locations: passenger trains only, with the + // passenger GTFS's route and trip ids (the route id names the train + // type). The REST pair (train-locations + live-trains) would need the + // 6.5 MB live-trains list on every poll to learn each train's type. + url: 'https://rata.digitraffic.fi/api/v1/trains/gtfs-rt-locations', + tripUpdatesUrl: 'https://rata.digitraffic.fi/api/v1/trains/gtfs-rt-updates', + headers: Object.freeze({ 'Digitraffic-User': 'GodsEyeView/RailLayer' }), + staticHeaders: Object.freeze({ + 'Digitraffic-User': 'GodsEyeView/RailLayer', + }), + limits: Object.freeze({ + quote: 'Real time feed is updated every 10 seconds', + note: 'Requests without a Digitraffic-User header are limited to 60 a minute per IP; compression is mandatory (the proxy asks for gzip). Positions every 15 s, trip updates every 30 s.', + }), + staticGtfsUrl: + 'https://rata.digitraffic.fi/api/v1/trains/gtfs-passenger-stops.zip', + license: 'CC BY 4.0', + licenseUrl: 'https://www.digitraffic.fi/en/terms-of-service/', + attribution: 'Source: Fintraffic / digitraffic.fi, license CC 4.0 BY', + defaultEnabled: true, + terms: Object.freeze({ + quote: + "Fintraffic's open data is licensed under the Creative Commons 4.0 By license. It gives the right to distribute, remix, tweak, and build upon our data, even commercially, as long as you credit the source for the original creation.", + note: 'The Digitraffic-User header names the application, never a person.', + }), + classify: finlandTrain, + }), + Object.freeze({ + id: 'renfe-larga-distancia', + name: 'Renfe long and medium distance', + operator: 'Renfe (AVE, Avlo, Alvia, Euromed, Intercity, Media Distancia)', + region: 'Spain', + center: Object.freeze({ lat: 40.2, lon: -3.7 }), + loadRadiusKm: 700, + format: 'gtfs-rt', + url: 'https://gtfsrt.renfe.com/vehicle_positions_LD.pb', + tripUpdatesUrl: 'https://gtfsrt.renfe.com/trip_updates_LD.pb', + // The header clock runs an hour behind (local time written as UTC) and + // vehicles carry no time: every train is stamped with the fetch time. + headerClock: 'untrusted', + // Vehicles carry a trip id but no route: the route (with its product) + // is read from the schedule pack. + routesFrom: 'schedule', + staticGtfsUrl: + 'https://ssl.renfe.com/gtransit/Fichero_AV_LD/google_transit.zip', + staticRouteRename: renfeRouteRename, + license: 'CC BY 4.0', + licenseUrl: CC_BY_4_URL, + attribution: 'Renfe', + defaultEnabled: true, + terms: Object.freeze({ + quote: 'CC-BY-4.0', + note: 'Licence field of the data.renfe.com datasets (posicion-vehiculos-av-ld-md and the static GTFS). "Esta información se actualiza cada 15 segundos". Iryo and OUIGO España are not in Renfe’s feeds.', + }), + classify: renfeLongDistanceTrain, + }), + Object.freeze({ + id: 'renfe-cercanias', + name: 'Renfe Cercanías', + operator: 'Renfe Cercanías and Rodalies', + region: 'Spain', + center: Object.freeze({ lat: 40.2, lon: -3.7 }), + loadRadiusKm: 700, + format: 'gtfs-rt', + url: 'https://gtfsrt.renfe.com/vehicle_positions.pb', + tripUpdatesUrl: 'https://gtfsrt.renfe.com/trip_updates.pb', + staticGtfsUrl: + 'https://ssl.renfe.com/ftransit/Fichero_CER_FOMENTO/fomento_transit.zip', + license: 'CC BY 4.0', + licenseUrl: CC_BY_4_URL, + attribution: 'Renfe', + defaultEnabled: true, + terms: Object.freeze({ + quote: 'CC-BY-4.0', + note: 'Licence field of the data.renfe.com Cercanías datasets. Every vehicle in this feed is a Cercanías or Rodalies train.', + }), + classify: always(LOCAL_RAIL), + }), + ...[ + ['VYG', 'entur-vy', 'Vy', 'Vy (Norway)', 61.0, 9.5, 450], + ['GOA', 'entur-go-ahead', 'Go-Ahead', 'Go-Ahead Norge', 58.8, 7.5, 280], + ['FLT', 'entur-flytoget', 'Flytoget', 'Flytoget', 59.9, 10.9, 80], + ].map(([codespace, id, name, operator, lat, lon, radius]) => + Object.freeze({ + id, + name, + operator: `${operator}, via Entur`, + region: 'Norway', + center: Object.freeze({ lat, lon }), + loadRadiusKm: radius, + format: 'gtfs-rt', + url: `https://api.entur.io/realtime/v1/gtfs-rt/vehicle-positions?datasource=${codespace}`, + // Vy's shapes are dense enough (1.2 million points at 1 m) to break + // the pack cap (16 MiB when measured, 24 MiB now); 5 m keeps them on + // the track at map scale. + ...(codespace === 'VYG' ? { staticSimplifyM: 5 } : {}), + ...(codespace === 'VYG' + ? {} + : { + tripUpdatesUrl: `https://api.entur.io/realtime/v1/gtfs-rt/trip-updates?datasource=${codespace}`, + }), + headers: ENTUR_HEADERS, + staticHeaders: ENTUR_HEADERS, + limits: ENTUR_LIMITS, + staticGtfsUrl: `https://storage.googleapis.com/marduk-production/outbound/gtfs/rb_${codespace.toLowerCase()}-aggregated-gtfs.zip`, + license: 'NLOD 2.0', + licenseUrl: NLOD_URL, + attribution: ENTUR_ATTRIBUTION, + defaultEnabled: true, + terms: ENTUR_TERMS, + classify: norwayTrain, + }), + ), + Object.freeze({ + id: 'entur-sj-nord', + name: 'SJ Nord', + operator: 'SJ Nord, via Entur', + region: 'Norway', + center: Object.freeze({ lat: 65.0, lon: 12.5 }), + loadRadiusKm: 600, + // SJ Nord is missing from Entur's GTFS-RT positions; its trains report + // in SIRI VM (SIRI Lite, about 7 KB for the codespace), read by + // src/data/railSiriVm.js. The static GTFS still gives the stops. + format: 'siri-vm', + url: 'https://api.entur.io/realtime/v1/rest/vm?datasetId=SJN', + headers: Object.freeze({ ...ENTUR_HEADERS, Accept: 'application/xml' }), + maxBodyBytes: 2 * 1024 * 1024, + limits: ENTUR_LIMITS, + staticHeaders: ENTUR_HEADERS, + staticSimplifyM: 5, + staticGtfsUrl: + 'https://storage.googleapis.com/marduk-production/outbound/gtfs/rb_sjn-aggregated-gtfs.zip', + license: 'NLOD 2.0', + licenseUrl: NLOD_URL, + attribution: ENTUR_ATTRIBUTION, + defaultEnabled: true, + terms: Object.freeze({ + ...ENTUR_TERMS, + note: 'SJ Nord trains are missing from Entur’s GTFS-RT vehicle positions (0 vehicles on 2026-11-06); they are read from SIRI VM instead (13 trains on 2026-11-09). Its journey refs are DatedServiceJourneys, not the GTFS trip ids, so a train is matched to its run by line and position.', + }), + classify: norwayTrain, + }), + // National sources. Licences were read at the source on 2026-11-09; their + // classifier helpers follow the registry. + Object.freeze({ + id: 'ktmb', + name: 'KTMB', + operator: + 'Keretapi Tanah Melayu (ETS, Intercity, KTM Komuter, Shuttle Tebrau)', + region: 'Peninsular Malaysia', + center: Object.freeze({ lat: 4.0, lon: 101.8 }), + loadRadiusKm: 450, + format: 'gtfs-rt', + // The documented URL (no trailing slash) answers 301 to this one. + url: 'https://api.data.gov.my/gtfs-realtime/vehicle-position/ktmb/', + pollIntervalMs: 30_000, + limits: Object.freeze({ + quote: 'GTFS Realtime 4 requests per minute', + note: 'developer.data.gov.my/rate-limit (a 429 past it). "All Vehicle Position feeds are updated every 30 seconds": polled every 30 s, 2 a minute. Trip updates are "in our pipeline for 2026".', + }), + // Vehicles carry a trip id but no route: the scoped route is read from + // the schedule pack. + routesFrom: 'schedule', + staticGtfsUrl: 'https://api.data.gov.my/gtfs-static/ktmb', + staticTripEnds: true, + staticTripRename: ktmbTripRename, + license: 'CC BY 4.0', + licenseUrl: 'https://developer.data.gov.my/realtime-api/gtfs-realtime', + attribution: 'KTMB, via data.gov.my (Government of Malaysia)', + defaultEnabled: true, + terms: Object.freeze({ + quote: 'All GTFS Realtime feeds are licensed under CC BY 4.0', + note: 'developer.data.gov.my, read 2026-11-09; its GTFS Static page says the same of "All GTFS Static feeds". The static GTFS has no shapes.', + }), + classify: ktmbTrain, + }), + // ------------------------------------------ United States + // The BTS National Transit Map's rail agencies (snapshot of 2025-09-02) + // that no feed covered yet: each agency's current files and terms, read + // on 2026-11-10 unless the note says otherwise. Helpers are above the + // registry. + Object.freeze({ + id: 'seattle-streetcar', + name: 'Seattle Streetcar', + operator: + 'City of Seattle (South Lake Union and First Hill lines), via King County Metro', + region: 'Seattle, WA', + center: Object.freeze({ lat: 47.61, lon: -122.33 }), + loadRadiusKm: 10, + format: 'gtfs-rt', + // King County Metro's whole-fleet files (about 500 vehicles in 50 KB; the + // trip updates about 1.1 MB): only the two streetcar routes are kept. + url: 'https://s3.amazonaws.com/kcm-alerts-realtime-prod/vehiclepositions.pb', + tripUpdatesUrl: + 'https://s3.amazonaws.com/kcm-alerts-realtime-prod/tripupdates.pb', + staticGtfsUrl: 'https://metro.kingcounty.gov/GTFS/google_transit.zip', + license: 'King County Transit Data Terms of Use', + licenseUrl: + 'https://kingcounty.gov/en/dept/metro/rider-tools/mobile-and-web-apps', + attribution: + 'Transit scheduling, geographic, and real-time data provided by permission of King County', + defaultEnabled: true, + terms: Object.freeze({ + quote: + 'King County grants you a limited, revocable license to use, reproduce, and redistribute the Data in accordance with these Terms.', + note: 'Transit Data Terms of Use on the page linked above (undated; read 2026-11-10), which links the GTFS and GTFS-RT used here and covers "schedule, geographic, real-time, or any other data posted by King County". King County also permits users "to copy, distribute, store (electronically or otherwise), create derivative works based on and perform and display (publicly or otherwise) such Data, and make such Data available to end users", and asks for the legend the credit carries; no King County service marks beyond it. Commercial use is not mentioned. The feed bundles King County Metro, Sound Transit and City of Seattle routes; only the City of Seattle streetcars (agency 23) are kept, `100340` South Lake Union and `102638` First Hill (transit, tram). Link light rail is not in this GTFS.', + }), + classify: byRoute({ 100340: TRANSIT_TRAM, 102638: TRANSIT_TRAM }), + }), + Object.freeze({ + id: 'cincinnati-connector', + name: 'Connector streetcar', + operator: 'Southwest Ohio Regional Transit Authority (SORTA/Metro)', + region: 'Cincinnati, OH', + center: Object.freeze({ lat: 39.11, lon: -84.51 }), + loadRadiusKm: 8, + format: 'gtfs-rt', + // Metro's whole-fleet files (about 150 vehicles in 12 KB; trip updates + // about 300 KB): only the streetcar is kept. + url: 'https://tmgtfsprd.sorttrpcloud.com/TMGTFSRealTimeWebService/vehicle/vehiclepositions.pb', + tripUpdatesUrl: + 'https://tmgtfsprd.sorttrpcloud.com/TMGTFSRealTimeWebService/tripupdate/tripupdates.pb', + // The developer page's "Metro GTFS data" link, replaced in place. + staticGtfsUrl: + 'https://www.go-metro.com/wp-content/uploads/2024/12/google_transit-1.zip', + license: 'SORTA/Metro developer data Terms', + licenseUrl: 'https://www.go-metro.com/about/developer-data/', + attribution: 'Southwest Ohio Regional Transit Authority (SORTA/Metro)', + defaultEnabled: true, + terms: Object.freeze({ + quote: + 'The Southwest Ohio Regional Transit Authority (SORTA/Metro) hereby grants you (Licensee) non-exclusive, limited and revocable rights to use, reproduce, and redistribute SORTA/Metro’s transit route schedules, pricing, and other associated data (“Data”) subject to the following Terms:', + note: 'The developer data page linked above (read 2026-11-10), which links the GTFS and these GTFS-RT files. SORTA/Metro trademarks "may not be used in association with Data unless approved in writing by SORTA/Metro", so the credit is text only; "Any modification, alteration, combination or arrangement of the Data is solely at your own risk." Commercial use is not mentioned. Seen 2026-11-10: 2 streetcars among 147 vehicles, 6 streetcar trips among 265 updates, trip ids those of the static file. The file of 2026-10-01 ends on 2026-12-05. One rail route, `100` Streetcar - OTR - Banks (route_type 0): transit, tram.', + }), + classify: byRoute({ 100: TRANSIT_TRAM }), + }), + Object.freeze({ + id: 'jp-manyosen', + name: 'Manyōsen', + operator: '万葉線 Manyōsen (Takaoka–Kōshinokata tram)', + region: 'Toyama, Japan', + center: Object.freeze({ lat: 36.77, lon: 137.06 }), + loadRadiusKm: 25, + format: 'gtfs-rt', + url: 'https://gtfs-rt-files.buscatch.jp/toyama/manyo_line/VehiclePositions.pb', + pollIntervalMs: 30_000, + tripUpdatesUrl: + 'https://gtfs-rt-files.buscatch.jp/toyama/manyo_line/TripUpdates.pb', + staticGtfsUrl: + 'https://api.gtfs-data.jp/v2/organizations/manyosen/feeds/manyosen/files/feed.zip?rid=current', + license: 'CC0 1.0', + licenseUrl: + 'https://api.gtfs-data.jp/v2/organizations/manyosen/feeds/manyosen', + attribution: '万葉線株式会社, via the GTFS Data Repository (gtfs-data.jp)', + defaultEnabled: true, + terms: Object.freeze({ + quote: '"feed_license": "CC0 1.0"', + note: 'GTFS Data Repository record of the feed "万葉線", read 2026-11-10: the record lists the GTFS-RT VehiclePositions and TripUpdates (buscatch.jp, keyless) with the CC0 feed. Tram: transit.', + }), + classify: always(TRANSIT_TRAM), + }), + Object.freeze({ + id: 'jp-toyama-tram', + name: 'Toyama tram', + operator: + '富山地方鉄道 Toyama Chihō Railway city tram (city lines, Toyamakō Line, loop line)', + region: 'Toyama, Japan', + center: Object.freeze({ lat: 36.72, lon: 137.21 }), + loadRadiusKm: 25, + format: 'gtfs-rt', + url: 'https://gtfs-rt-files.buscatch.jp/toyama/chitetsu_tram/VehiclePositions.pb', + pollIntervalMs: 30_000, + // No staticGtfsUrl: the tram's static GTFS codes every route as a bus + // (route_type 3), so the path build would keep none of it. Its + // TripUpdates are not used either: there are no stops to show delays at. + license: 'CC0 1.0', + licenseUrl: + 'https://api.gtfs-data.jp/v2/organizations/chitetsu/feeds/chitetsushinaidensha', + attribution: + '富山地方鉄道株式会社, via the GTFS Data Repository (gtfs-data.jp)', + defaultEnabled: true, + terms: Object.freeze({ + quote: '"feed_license": "CC0 1.0"', + note: 'GTFS Data Repository record of the feed "富山地方鉄道市内電車", read 2026-11-10: the record lists the GTFS-RT VehiclePositions and TripUpdates (buscatch.jp, keyless) with the CC0 feed. The tram-only feed (the company\'s buses are a separate feed). Tram: transit.', + }), + classify: always(TRANSIT_TRAM), + }), + Object.freeze({ + id: 'tbm-bordeaux', + name: 'Bordeaux tram', + operator: 'TBM (Bordeaux Métropole): trams A to F', + region: 'Bordeaux, France', + center: Object.freeze({ lat: 44.85, lon: -0.58 }), + loadRadiusKm: 25, + format: 'gtfs-rt', + // Every TBM vehicle (about 80 KB); the tram routes are kept. The + // `apiKey` in these links is a fixed public value the publisher prints + // on the dataset page for everyone: the same for every user, not a + // credential of the deployer's, and nothing is signed up for to get it. + url: 'https://bdx.mecatran.com/utw/ws/gtfsfeed/vehicles/bordeaux?apiKey=opendata-bordeaux-metropole-flux-gtfs-rt', + tripUpdatesUrl: + 'https://bdx.mecatran.com/utw/ws/gtfsfeed/realtime/bordeaux?apiKey=opendata-bordeaux-metropole-flux-gtfs-rt', + tripUpdatesIntervalMs: 60_000, + staticGtfsUrl: + 'https://bdx.mecatran.com/utw/ws/gtfsfeed/static/bordeaux?apiKey=opendata-bordeaux-metropole-flux-gtfs-rt', + license: 'Licence Ouverte / Open Licence 1.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)', + defaultEnabled: true, + terms: Object.freeze({ + quote: 'fr-lo', + note: 'Licence field of the transport.data.gouv.fr dataset "Réseau urbain et scolaire TBM" (publisher Bordeaux Métropole; read through its API on 2026-11-10), covering its GTFS and its GTFS-RT VehiclePositions and TripUpdates; Licence Ouverte 1.0 (Etalab) allows reuse, commercial included, with attribution. Tram: transit, tram.', + }), + classify: byRoute({ + 59: TRANSIT_TRAM, // A + 60: TRANSIT_TRAM, // B + 61: TRANSIT_TRAM, // C + 62: TRANSIT_TRAM, // D + 163: TRANSIT_TRAM, // E + 164: TRANSIT_TRAM, // F + }), + }), + Object.freeze({ + id: 'tam-montpellier', + name: 'Montpellier tram', + operator: 'TaM (Montpellier Méditerranée Métropole): trams 1 to 5', + region: 'Montpellier, France', + center: Object.freeze({ lat: 43.61, lon: 3.88 }), + loadRadiusKm: 25, + format: 'gtfs-rt', + // Every TaM vehicle (about 15 KB); the tram routes are kept. + url: 'https://gtfsproxy.e-tam.fr/COMMON/VehiclePosition.pb', + tripUpdatesUrl: 'https://gtfsproxy.e-tam.fr/COMMON/TripUpdate.pb', + tripUpdatesIntervalMs: 60_000, + staticGtfsUrl: 'https://gtfsproxy.e-tam.fr/COMMON/GTFS.zip', + license: 'ODbL 1.0', + licenseUrl: 'https://opendatacommons.org/licenses/odbl/1-0/', + attribution: 'TaM / Montpellier Méditerranée Métropole', + defaultEnabled: true, + terms: Object.freeze({ + quote: 'odc-odbl', + note: `Licence field of the transport.data.gouv.fr dataset "Réseau urbain TaM" (publisher Montpellier Méditerranée Métropole; read through its API on 2026-11-10), covering its GTFS and its GTFS-RT VehiclePositions and TripUpdates. ${panOdblNote()} Tram: transit, tram.`, + }), + classify: byRoute( + Object.fromEntries( + ['1', '2', '3', '4', '5'].map((line) => [line, TRANSIT_TRAM]), + ), + ), + }), +]); + +/** + * What ODbL means for a transport.data.gouv.fr dataset: the access point + * applies its "Conditions Particulières d'Utilisation" to every ODbL + * dataset it lists (doc.transport.data.gouv.fr, "Licence ODbL", read + * 2026-11-10). + */ +function panOdblNote() { + return 'ODbL is share-alike, with the access point\'s special conditions (doc.transport.data.gouv.fr "Licence ODbL", read 2026-11-10): the share-alike clause covers information "de même nature, de même granularité, de mêmes conditions temporelles et de même emprise géographique", and "Les bases de données ainsi repartagées doivent être publiées sur le Point d\'accès national transport.data.gouv.fr, dans le format d\'origine." The path and schedule packs built from this GTFS are such a database once shared: offered under ODbL with the attribution kept.'; +} + +/** + * Where a run goes, from its two ends as the path build reads them + * (`staticTripEnds`, scripts/build-rail-paths.mjs `readTripEnds`): `B` when + * its ends are in two countries or (unless `endsOnly`) it stops in two, `S` + * when both ends are in one state, `X` otherwise, also when an end could not + * be placed. `fix(end)` corrects an end the simplified state polygons + * misplace, which happens to stations on a border. + */ +function tripScope(ends, { fix = null, endsOnly = false } = {}) { + const place = (end) => (end && fix ? fix(end) : end); + const from = place(ends?.from); + const to = place(ends?.to); + if (!from?.country || !to?.country) return 'X'; + if (from.country !== to.country) return 'B'; + if (!endsOnly && (ends.countries?.length ?? 0) > 1) return 'B'; + return from.state && from.state === to.state ? 'S' : 'X'; +} + +/** + * `~~` (see `scopedRoute`) from a pack route id + * that is `~`, or a bare route id that is its own + * product. + */ +function withScope(routeId, scope) { + const id = String(routeId ?? ''); + const at = id.indexOf('~'); + return at > 0 + ? `${id.slice(0, at)}~${scope}~${id.slice(at + 1)}` + : `${id}~${scope}~${id}`; +} + +/** Regional inside one state, national across states, international abroad. */ +function kindForScope(scope) { + if (scope === 'B') return INTERNATIONAL_RAIL; + return scope === 'S' ? REGIONAL_RAIL : NATIONAL_RAIL; +} + +/** + * KTMB (data.gov.my GTFS of 2026-11-09). Route ids name the service: + * - local: KTM Komuter, the Klang Valley lines (`KA15_KD19` Tanjung + * Malim–Pelabuhan Klang, `KC05_KB18` Batu Caves–Pulau Sebang), Komuter + * Utara (`100_47300` Butterworth–Padang Besar, `100_9000` Butterworth– + * Ipoh) and the Shuttle Selatan (`SS`, coded as a tram in the GTFS). + * - international: Shuttle Tebrau (`ST`, JB Sentral–Woodlands, Singapore). + * - ETS, Ekspres Rakyat Timuran (`ERT`) and the Intercity shuttle (`SH`) + * by where each run ends, the states being Malaysia's 13 states and its + * federal territories: regional inside one (Tumpat–Gua Musang), national + * across them, international to Hat Yai (Thailand) or Woodlands. + * Padang Besar station lies inside the simplified Thai polygon; it is in + * Perlis, so the scope reads the run's ends only, with that one corrected. + */ +const KTMB_KOMUTER = /^(KA15_KD19|KC05_KB18|SS|\d+_\d+)$/; +const KTMB_INTERCITY = new Set(['ETS', 'ERT', 'SH']); +function ktmbPlace(end) { + return /^padang besar$/i.test(String(end.name ?? '').trim()) + ? { ...end, country: 'MY', state: 'MY-09' } + : end; +} +function ktmbTripRename(trip, routeId, { ends = null } = {}) { + return withScope( + routeId, + tripScope(ends, { fix: ktmbPlace, endsOnly: true }), + ); +} +function ktmbTrain(routeId) { + const route = scopedRoute(routeId); + if (!route) return null; + if (route.product === 'ST') return INTERNATIONAL_RAIL; + if (KTMB_KOMUTER.test(route.product)) return LOCAL_RAIL; + return KTMB_INTERCITY.has(route.product) ? kindForScope(route.scope) : null; +} + +/** + * Irish Rail in the TFI static GTFS (`GTFS_Realtime.zip`, the one the NTA + * GTFS-R feed matches): `trip_short_name` is the train code the realtime + * XML names (`E206` DART, `P600` and `D` Commuter, `A101` InterCity). The + * pack route id is `~~`, the scope by the + * provinces of the run's two ends (IRISH_RAIL_PROVINCE, parenthesised + * station names such as `Cork (Kent)` read as `Cork`): `B` when one end is + * in Northern Ireland or abroad, `S` in one province, `X` otherwise or + * when an end is not a listed terminus. A trip with no train code is left + * out. + */ +function irishRailProvince(end) { + const name = String(end?.name ?? '') + .replace(/\s*\([^)]*\)\s*$/, '') + .trim() + .toLowerCase(); + return IRISH_RAIL_PROVINCE.get(name) ?? null; +} +function irishRailTripRename(trip, routeId, { ends = null } = {}) { + const letter = /^([A-Z])\d{1,4}$/.exec( + String(trip.trip_short_name ?? '').trim(), + )?.[1]; + if (!letter) return null; + const from = irishRailProvince(ends?.from); + const to = irishRailProvince(ends?.to); + let scope = 'X'; + if ( + (from && to && (from === 'NI') !== (to === 'NI')) || + (ends?.from?.country && + ends?.to?.country && + ends.from.country !== ends.to.country) + ) + scope = 'B'; + else if (from && to && from === to) scope = 'S'; + return `${letter}~${scope}~${routeId}`; +} +/** The same rule as the realtime codes, read from a pack route id. */ +function irishRailPackTrain(route) { + if (route.product !== 'A' && route.product !== 'B') return LOCAL_RAIL; + if (route.scope === 'B') return INTERNATIONAL_RAIL; + return route.scope === 'S' ? REGIONAL_RAIL : NATIONAL_RAIL; +} + +/** + * The feeds this build polls: every registered feed that is switched on. A + * registered feed that is switched off is inert everywhere: it resolves to no + * route and is never offered to the browser. + */ +export const RAIL_ENABLED_FEEDS = Object.freeze( + RAIL_FEED_REGISTRY.filter((feed) => feed.defaultEnabled === true), +); + +const ANY_FEED_BY_ID = new Map( + RAIL_FEED_REGISTRY.map((feed) => [feed.id, feed]), +); + +/** Feed ids are path segments: lowercase letters, digits, hyphens only. */ +export const RAIL_FEED_ID_PATTERN = /^[a-z0-9][a-z0-9-]{1,63}$/; + +/** + * Whether a feed serves trip updates: it registers a `tripUpdatesUrl`. + * @param {object} feed Registry entry. + * @returns {boolean} + */ +export function railFeedHasTripUpdates(feed) { + return typeof feed?.tripUpdatesUrl === 'string'; +} + +/** + * Whether a registered feed may be polled: switched on. + * @param {object} feed + * @returns {boolean} + */ +export function isRailFeedPollable(feed) { + return feed?.defaultEnabled === true; +} + +/** + * Every pollable feed. + * @returns {object[]} + */ +export function railEnabledFeeds() { + return [...RAIL_ENABLED_FEEDS]; +} + +/** + * Look up a POLLABLE feed by id. Unknown ids, malformed ids and feeds that + * are registered but switched off return null: this is the only door from a + * request path to an upstream URL. + * @param {string} id + * @returns {object|null} + */ +export function getRailFeed(id) { + if (typeof id !== 'string' || !RAIL_FEED_ID_PATTERN.test(id)) return null; + const feed = ANY_FEED_BY_ID.get(id); + return feed && isRailFeedPollable(feed) ? feed : null; +} + +/** + * Look up any registered feed, enabled or not. For credits and documentation + * only, never for resolving a request into an upstream fetch. + * @param {string} id + * @returns {object|null} + */ +export function getRegisteredRailFeed(id) { + if (typeof id !== 'string' || !RAIL_FEED_ID_PATTERN.test(id)) return null; + return ANY_FEED_BY_ID.get(id) || null; +} + +/** + * Where a feed's positions come from (RAIL_POSITION_SOURCES). + * @param {object} feed + * @returns {'gps'|'event'} + */ +export function railFeedPositions(feed) { + return RAIL_POSITION_SOURCES.includes(feed?.positions) + ? feed.positions + : 'gps'; +} + +/** + * Whether a feed shows trains only once its pack has been built + * (scripts/build-rail-paths.mjs): a GTFS-Realtime feed whose vehicles carry + * no route (`routesFrom: 'schedule'`), so nothing classifies them without + * the pack. Irish Rail's XML names each train by its code, which classifies + * it without one. + * @param {object} feed + * @returns {boolean} + */ +export function railFeedNeedsSchedulePack(feed) { + return feed?.routesFrom === 'schedule' && feed?.format === 'gtfs-rt'; +} + +/** + * Enabled feeds whose coverage circle contains the point, nearest first. + * @param {number} lat + * @param {number} lon + * @param {number} [slackKm=0] Extra radius so a feed at the edge of coverage + * does not flap on small camera moves. + * @param {ReadonlyArray<{center: {lat: number, lon: number}, + * loadRadiusKm: number}>} [feeds=RAIL_ENABLED_FEEDS] The feeds to choose + * from; the browser passes the server's catalog, which leaves out a feed + * whose vehicles name no route until its pack is built. + * @returns {object[]} + */ +export function railFeedsInRange( + lat, + lon, + slackKm = 0, + feeds = RAIL_ENABLED_FEEDS, +) { + if (!Number.isFinite(lat) || !Number.isFinite(lon)) return []; + return (Array.isArray(feeds) ? feeds : []) + .filter( + (feed) => + Number.isFinite(feed?.center?.lat) && + Number.isFinite(feed?.center?.lon) && + Number.isFinite(feed?.loadRadiusKm), + ) + .map((feed) => ({ + feed, + km: greatCircleKm({ lat, lon }, feed.center), + })) + .filter(({ feed, km }) => km <= feed.loadRadiusKm + Math.max(0, slackKm)) + .sort((a, b) => a.km - b.km) + .map(({ feed }) => feed); +} + +/** + * The category and mode of one vehicle, or null when it is not a train the + * layer shows. A classifier that returns anything outside the known category + * and mode lists counts as null, so a registry typo drops vehicles instead of + * inventing a category. + * @param {object} feed Registry entry. + * @param {string|null} routeId GTFS route_id from the vehicle's trip. + * @param {{tripId?: string|null}} [context] More of the record, for feeds + * whose route id alone does not say what the train is (Irish Rail). + * @returns {{category: string, mode: string}|null} + */ +export function classifyTrain(feed, routeId, context = null) { + const kind = + typeof feed?.classify === 'function' + ? feed.classify(routeId, context || {}) + : null; + if (!kind) return null; + if (!RAIL_CATEGORIES.includes(kind.category)) return null; + if (!RAIL_MODES.includes(kind.mode)) return null; + return kind; +} + +/** + * Public catalog shape served by `/api/rail/feeds`: everything the browser + * needs to gate polling, label positions and credit the source, and nothing + * it could misuse. With `schedulePackBuilt`, a feed that needs its pack + * (`railFeedNeedsSchedulePack`) appears only once the host has that pack: + * packs are opt-in build output, so a feed nobody has built is absent rather + * than shown as unavailable. + * @param {{schedulePackBuilt?: ((feedId: string) => boolean)|null}} + * [options] + * @returns {object[]} + */ +export function publicRailCatalog({ schedulePackBuilt = null } = {}) { + const listed = (feed) => + typeof schedulePackBuilt !== 'function' || + !railFeedNeedsSchedulePack(feed) || + schedulePackBuilt(feed.id) === true; + return railEnabledFeeds() + .filter(listed) + .map((feed) => ({ + id: feed.id, + name: feed.name, + operator: feed.operator, + region: feed.region, + center: { ...feed.center }, + loadRadiusKm: feed.loadRadiusKm, + positions: railFeedPositions(feed), + tripUpdates: railFeedHasTripUpdates(feed), + license: feed.license, + licenseUrl: feed.licenseUrl, + attribution: feed.attribution, + })); +} diff --git a/src/data/railFeeds.test.mjs b/src/data/railFeeds.test.mjs new file mode 100644 index 000000000..5304680a9 --- /dev/null +++ b/src/data/railFeeds.test.mjs @@ -0,0 +1,474 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + RAIL_CATEGORIES, + RAIL_ENABLED_FEEDS, + RAIL_FEED_ID_PATTERN, + RAIL_FEED_REGISTRY, + RAIL_MODES, + RAIL_POSITION_SOURCES, + classifyTrain, + getRailFeed, + getRegisteredRailFeed, + isRailFeedPollable, + publicRailCatalog, + railEnabledFeeds, + railFeedPositions, + railFeedsInRange, +} from './railFeeds.js'; + +const isHttps = (url) => new URL(url).protocol === 'https:'; + +test('every registered feed is complete, https and uniquely named', () => { + const ids = new Set(); + for (const feed of RAIL_FEED_REGISTRY) { + assert.match(feed.id, RAIL_FEED_ID_PATTERN, feed.id); + assert.ok(!ids.has(feed.id), `duplicate id ${feed.id}`); + ids.add(feed.id); + const positions = railFeedPositions(feed); + assert.ok(RAIL_POSITION_SOURCES.includes(positions), feed.id); + assert.ok(isHttps(feed.url), feed.id); + for (const url of [feed.tripUpdatesUrl, feed.staticGtfsUrl]) + if (url !== undefined) assert.ok(isHttps(url), `${feed.id} ${url}`); + assert.ok( + ['gtfs-rt', 'irish-rail-xml', 'siri-vm'].includes(feed.format), + feed.id, + ); + if (feed.format === 'irish-rail-xml') + assert.equal(positions, 'event', `${feed.id} positions are events`); + for (const key of ['name', 'operator', 'region', 'license', 'attribution']) + assert.ok(feed[key], `${feed.id} has ${key}`); + assert.ok(URL.canParse(feed.licenseUrl), `${feed.id} licence url`); + assert.equal(typeof feed.defaultEnabled, 'boolean', feed.id); + assert.equal(typeof feed.terms?.quote, 'string', feed.id); + assert.equal(typeof feed.classify, 'function', feed.id); + assert.ok( + Number.isFinite(feed.center.lat) && Number.isFinite(feed.center.lon), + ); + assert.ok(feed.loadRadiusKm > 0 && feed.loadRadiusKm <= 1000, feed.id); + for (const ms of [feed.pollIntervalMs, feed.tripUpdatesIntervalMs]) + if (ms !== undefined) assert.ok(ms >= 5_000 && ms <= 300_000, feed.id); + } +}); + +// The fields the registry documents (railFeeds.js, RAIL_FEED_REGISTRY): +// an entry carries nothing else, so no feed asks for a key, a sign-in or a +// timetable to place its trains. +const REGISTRY_FIELDS = new Set([ + 'id', + 'name', + 'operator', + 'region', + 'center', + 'loadRadiusKm', + 'format', + 'url', + 'positions', + 'headers', + 'headerClock', + 'pollIntervalMs', + 'tripUpdatesUrl', + 'tripUpdatesIntervalMs', + 'maxBodyBytes', + 'limits', + 'routesFrom', + 'positionsByTrainNumber', + 'staticGtfsUrl', + 'staticGtfsEntry', + 'staticHeaders', + 'staticRouteTypes', + 'staticTripEnds', + 'staticSimplifyM', + 'staticRouteRename', + 'staticTripRename', + 'license', + 'licenseUrl', + 'attribution', + 'defaultEnabled', + 'terms', + 'classify', +]); + +test('every feed reports live positions: GPS or signalling events', () => { + for (const feed of RAIL_FEED_REGISTRY) { + assert.notEqual(feed.positions, 'schedule', feed.id); + assert.ok( + ['gps', 'event'].includes(railFeedPositions(feed)), + `${feed.id} positions`, + ); + assert.equal(typeof feed.url, 'string', `${feed.id} has a live feed`); + } + for (const entry of publicRailCatalog()) + assert.ok(['gps', 'event'].includes(entry.positions), entry.id); +}); + +/** Query parameter names that carry a key, a token or a signature. */ +const ACCESS_PARAM = + /^(api[-_]?key|appid|app[-_]?key|key|token|access[-_]?token|auth|sig|signature|subscription[-_]?key)$/i; + +/** `name=value` of each access parameter in a URL (values decoded). */ +function accessParams(url) { + if (!url) return []; + return [...new URL(url).searchParams] + .filter(([name]) => ACCESS_PARAM.test(name)) + .map(([name, value]) => `${name}=${value}`); +} + +// The only access parameter allowed, a fixed public value the publisher +// prints in the published link for everyone, the same for every user and not +// a credential of the deployer's: Bordeaux Métropole's TBM `apiKey` (on the +// dataset page). +const PUBLISHED_ACCESS_VALUES = new Set([ + 'tbm-bordeaux apiKey=opendata-bordeaux-metropole-flux-gtfs-rt', +]); + +test('an access parameter in a feed URL is found, whatever its spelling', () => { + for (const url of [ + 'https://x.test/a?apiKey=1', + 'https://x.test/a?api_key=1', + 'https://x.test/a?APIKEY=1', + 'https://x.test/a?f=pb&key=1', + 'https://x.test/a?token=1', + 'https://x.test/a?access_token=1', + 'https://x.test/a?sr=b&sig=abc%3D', + 'https://x.test/a?signature=1', + 'https://x.test/a?subscription-key=1', + ]) + assert.equal(accessParams(url).length, 1, url); + assert.deepEqual(accessParams('https://x.test/a?format=pb&route=key'), []); + assert.deepEqual(accessParams(null), []); + // Each published value is still in use, so the allow-list cannot go stale. + const used = new Set(); + for (const feed of RAIL_FEED_REGISTRY) + for (const url of [feed.url, feed.tripUpdatesUrl, feed.staticGtfsUrl]) + for (const param of accessParams(url)) used.add(`${feed.id} ${param}`); + assert.deepEqual([...used].sort(), [...PUBLISHED_ACCESS_VALUES].sort()); +}); + +test('every feed is on, credited with its licence and terms, and fetched as published', () => { + const ids = new Set(); + for (const feed of RAIL_FEED_REGISTRY) { + assert.ok(!ids.has(feed.id), `duplicate id ${feed.id}`); + ids.add(feed.id); + assert.equal(feed.defaultEnabled, true, `${feed.id} is on`); + for (const field of Object.keys(feed)) + assert.ok(REGISTRY_FIELDS.has(field), `${feed.id}: ${field}`); + assert.ok(typeof feed.license === 'string' && feed.license, feed.id); + assert.ok(isHttps(feed.licenseUrl), `${feed.id} licence url`); + assert.ok( + typeof feed.attribution === 'string' && feed.attribution, + feed.id, + ); + assert.equal(typeof feed.terms?.quote, 'string', `${feed.id} terms`); + assert.ok(feed.terms.quote || feed.terms.note, `${feed.id} terms basis`); + // No URL carries an access parameter, but for the fixed public values + // their publishers print for everyone (PUBLISHED_ACCESS_VALUES). + for (const url of [feed.url, feed.tripUpdatesUrl, feed.staticGtfsUrl]) + for (const param of accessParams(url)) + assert.ok( + PUBLISHED_ACCESS_VALUES.has(`${feed.id} ${param}`), + `${feed.id} ${url}`, + ); + } + assert.equal(ids.size, RAIL_FEED_REGISTRY.length); + assert.deepEqual( + RAIL_ENABLED_FEEDS.map((feed) => feed.id), + RAIL_FEED_REGISTRY.map((feed) => feed.id), + ); +}); + +test('a feed is switched on only when its terms were read and quoted', () => { + for (const feed of RAIL_ENABLED_FEEDS) { + assert.ok( + feed.terms.quote || feed.terms.note, + `${feed.id} is enabled without a recorded basis`, + ); + } + // Tri-Rail states no licence; it is used without one by project owner + // decision, and the terms say so. + const triRail = getRailFeed('tri-rail'); + assert.ok(triRail, 'tri-rail is pollable'); + assert.ok( + triRail.terms.note.startsWith( + 'Used without a published licence (project owner decision, 2026-11-10)', + ), + ); + assert.match(triRail.license, /^No licence stated/); +}); + +test('only enabled, well-formed ids resolve to a pollable feed', () => { + assert.equal(getRailFeed('mta-metro-north')?.id, 'mta-metro-north'); + for (const id of ['', 'Nope', '../x', 'a', null, 42, 'unknown-feed']) + assert.equal(getRailFeed(id), null, String(id)); + const mbta = getRegisteredRailFeed('mbta-rail'); + assert.equal(isRailFeedPollable(mbta), true); + assert.equal(isRailFeedPollable({ ...mbta, defaultEnabled: false }), false); + assert.deepEqual( + railEnabledFeeds().map((entry) => entry.id), + RAIL_ENABLED_FEEDS.map((entry) => entry.id), + ); +}); + +test('classifiers keep trains and drop buses in mixed feeds', () => { + const kind = (id, route) => classifyTrain(getRegisteredRailFeed(id), route); + assert.deepEqual(kind('mbta-rail', 'CR-Worcester'), { + category: 'local', + mode: 'rail', + }); + assert.equal(kind('mbta-rail', 'Red').mode, 'metro'); + assert.equal(kind('mbta-rail', 'Green-E').mode, 'tram'); + assert.equal(kind('mbta-rail', 'Mattapan').category, 'transit'); + assert.equal(kind('mbta-rail', '1'), null); + assert.equal(kind('mbta-rail', 'Boat-F1'), null); + assert.equal(kind('mbta-rail', 'Shuttle-Generic'), null); + assert.equal(kind('mbta-rail', null), null); + + assert.equal(kind('rtd-denver-rail', 'A').category, 'local'); + assert.equal(kind('rtd-denver-rail', '113G').category, 'local'); + assert.equal(kind('rtd-denver-rail', '117N').category, 'local'); + assert.equal(kind('rtd-denver-rail', '101E').category, 'transit'); + assert.equal(kind('rtd-denver-rail', '107R').category, 'transit'); + assert.equal(kind('rtd-denver-rail', '15'), null); + assert.equal(kind('rtd-denver-rail', '15L'), null); + + assert.equal(kind('capmetro-rail', '550').category, 'local'); + assert.equal(kind('capmetro-rail', '801'), null); + // Northstar's old route id is a bus now and must not come back as a train. + assert.equal(kind('metrotransit-rail', '888'), null); + assert.equal(kind('metrotransit-rail', '902').mode, 'tram'); + assert.equal(kind('septa-trolley', 'M1').mode, 'metro'); + assert.equal(kind('septa-trolley', '17'), null); + assert.equal(kind('translink-seq-rail', 'BRCA-5').category, 'local'); + assert.equal(kind('translink-seq-rail', '61-3'), null); + assert.equal(kind('adelaide-metro', 'SEAFRD').category, 'local'); + assert.equal(kind('adelaide-metro', 'GLNELG').mode, 'tram'); + assert.equal(kind('adelaide-metro', 'J1A'), null); + assert.equal(kind('mta-metro-north', null).category, 'local'); +}); + +test('Irish Rail: DART and Commuter local, InterCity by province, Enterprise international', () => { + const kind = (code, route) => + classifyTrain(getRegisteredRailFeed('irish-rail'), route, { + tripId: code, + })?.category ?? null; + assert.equal(kind('E244', 'Howth - Bray'), 'local'); + assert.equal(kind('D822', 'Grand Canal Dock - Drogheda'), 'local'); + assert.equal(kind('P571', 'Midleton - Cork'), 'local'); + assert.equal(kind('A216', 'Dublin Heuston - Cork'), 'national'); + assert.equal(kind('A720', 'Dublin Heuston - Galway'), 'national'); + assert.equal(kind('B701', 'Galway - Dublin Heuston'), 'national'); + assert.equal(kind('A308', 'Mallow - Tralee'), 'regional', 'both Munster'); + assert.equal(kind('A606', 'Dublin Connolly - Rosslare Europort'), 'regional'); + assert.equal(kind('A118', 'Dublin Connolly - Belfast'), 'international'); + assert.equal(kind('A131', 'Belfast - Dublin Connolly'), 'international'); + assert.equal(kind('A999', 'Nowhere - Elsewhere'), 'national'); + assert.equal(kind('A999', null), 'national'); + assert.equal(kind('not a code', 'Howth - Bray'), null); + assert.equal(kind(null, 'Howth - Bray'), null); +}); + +test('a classifier answer outside the known lists is treated as not a train', () => { + const feed = (answer) => ({ classify: () => answer }); + assert.equal( + classifyTrain(feed({ category: 'express', mode: 'rail' })), + null, + ); + assert.equal(classifyTrain(feed({ category: 'local', mode: 'bus' })), null); + assert.equal(classifyTrain(feed(null)), null); + assert.equal(classifyTrain({}), null); + assert.equal(classifyTrain(null), null); + for (const feed of RAIL_FEED_REGISTRY) { + for (const route of [ + 'CR-Lowell', + 'A', + '550', + '901', + 'T1', + 'BRCA-5', + 'GAW', + null, + ]) { + const kind = feed.classify(route); + if (!kind) continue; + assert.ok(RAIL_CATEGORIES.includes(kind.category), `${feed.id} ${route}`); + assert.ok(RAIL_MODES.includes(kind.mode), `${feed.id} ${route}`); + } + } +}); + +test('coverage selection returns enabled feeds near the point, nearest first', () => { + const nyc = railFeedsInRange(40.75, -73.99).map((feed) => feed.id); + assert.ok(nyc.includes('mta-metro-north') && nyc.includes('mta-lirr')); + assert.deepEqual(railFeedsInRange(0, -150), []); + assert.deepEqual(railFeedsInRange(Number.NaN, 0), []); + const brisbane = railFeedsInRange(-27.47, 153.02); + assert.equal(brisbane[0].id, 'translink-seq-rail'); + // The browser passes the server's catalog, which lists a feed that reads + // its routes from a schedule pack only once that pack is built. + const madrid = (built) => + railFeedsInRange( + 40.42, + -3.7, + 0, + publicRailCatalog({ schedulePackBuilt: () => built }), + ).map((feed) => feed.id); + assert.ok( + !madrid(false).includes('renfe-larga-distancia'), + 'no pack: not offered', + ); + assert.ok(madrid(false).includes('renfe-cercanias'), 'needs no pack'); + assert.ok(madrid(true).includes('renfe-larga-distancia')); + assert.deepEqual(railFeedsInRange(-27.47, 153.02, 0, null), []); + assert.deepEqual(railFeedsInRange(-27.47, 153.02, 0, [{ id: 'x' }]), []); +}); + +test('the public catalog exposes coverage and credit but never upstream URLs', () => { + const catalog = publicRailCatalog(); + assert.equal(catalog.length, RAIL_ENABLED_FEEDS.length); + for (const entry of catalog) { + assert.equal(entry.url, undefined); + assert.equal(entry.tripUpdatesUrl, undefined); + assert.equal(entry.headers, undefined); + assert.equal(entry.classify, undefined); + assert.ok(entry.attribution && entry.licenseUrl); + assert.ok(RAIL_POSITION_SOURCES.includes(entry.positions)); + assert.equal(typeof entry.tripUpdates, 'boolean'); + } + const byId = new Map(catalog.map((entry) => [entry.id, entry])); + assert.equal(byId.get('irish-rail').positions, 'event'); + assert.equal(byId.get('irish-rail').tripUpdates, false); + assert.equal(byId.get('mbta-rail').positions, 'gps'); + assert.equal(byId.get('mbta-rail').tripUpdates, true); + // No upstream URL reaches the browser, by any field but the licence link + // (an access point's page can be both the licence and the download). + const text = JSON.stringify( + catalog.map((entry) => ({ ...entry, licenseUrl: null })), + ); + for (const feed of RAIL_FEED_REGISTRY) + for (const url of [feed.url, feed.tripUpdatesUrl, feed.staticGtfsUrl]) + if (url) assert.ok(!text.includes(url), `${feed.id} ${url}`); +}); + +test('Finland: commuter local, H/HDM regional, long distance national, Haaparanta international', () => { + const kind = (route) => + classifyTrain(getRegisteredRailFeed('fintraffic-vr'), route)?.category ?? + null; + assert.equal(kind('EPO_HKI_E_HL_10'), 'local'); + assert.equal(kind('SK_ÄHT_480_HDM_10'), 'regional'); + assert.equal(kind('TPE_PRI_1_H_10'), 'regional'); + for (const route of [ + 'HKI_ROI_23_IC_10', + 'HKI_OL_45_S_10', + 'HKI_KOK_101_P_10', + 'HKI_KLI_265_PYO_10', + ]) + assert.equal(kind(route), 'national', route); + assert.equal(kind('OL_HPA_407_IC_10'), 'international'); + assert.equal(kind('HPA_OL_408_IC_10'), 'international'); + for (const route of ['HKI_TKU_1_T_10', 'IC 23', null]) + assert.equal(kind(route), null, String(route)); +}); + +test('Renfe: products and the autonomous-community rule', () => { + const feed = getRegisteredRailFeed('renfe-larga-distancia'); + const kind = (route) => classifyTrain(feed, route)?.category ?? null; + for (const product of ['AVE', 'AVLO', 'ALVIA', 'EUROMED', 'Intercity']) + for (const scope of ['S', 'X']) + assert.equal(kind(`${product}~${scope}~1`), 'national', product); + for (const product of ['MD', 'REGIONAL', 'REG.EXP.', 'PROXIMDAD', 'AVANT']) { + assert.equal(kind(`${product}~S~1`), 'regional', product); + assert.equal(kind(`${product}~X~1`), 'national', product); + } + assert.equal(kind('AVE INT~X~1'), 'international'); + assert.equal(kind('TRENCELTA~X~1'), 'international'); + assert.equal(kind('MD~B~1'), 'international', 'an end abroad'); + assert.equal(kind('BUS~S~1'), null); + assert.equal(kind('1700037606GL023'), null, 'an unscoped operator id'); + assert.equal(kind(null), null); + // The rename the path build applies to routes.txt rows. + const places = { + 17000: { lat: 40.47, lon: -3.68 }, + 37606: { lat: 38.89, lon: -6.98 }, + 18000: { lat: 40.41, lon: -3.7 }, + 87089: { lat: 43.3, lon: 5.38 }, + }; + const helpers = { + stop: (id) => places[id] ?? null, + stateAt: (lat) => + lat > 43 + ? { country: 'FR', state: 'FR-13' } + : lat < 39.5 + ? { country: 'ES', state: 'ES-EX' } + : { country: 'ES', state: 'ES-MD' }, + }; + const rename = (id, product) => + feed.staticRouteRename( + { route_id: id, route_short_name: product }, + helpers, + ); + assert.equal(rename('1700037606GL023', 'MD'), 'MD~X~1700037606GL023'); + assert.equal( + rename('1700018000GL001', 'REGIONAL'), + 'REGIONAL~S~1700018000GL001', + ); + assert.equal(rename('1700087089AV016', 'AVE'), 'AVE~B~1700087089AV016'); + assert.equal( + rename('1700099999GL001', 'MD'), + 'MD~X~1700099999GL001', + 'unplaced', + ); + assert.equal(rename('short', 'MD'), null); + assert.equal( + classifyTrain(getRegisteredRailFeed('renfe-cercanias'), null)?.category, + 'local', + ); +}); + +test('Norway: by line and county, replacement buses dropped, Flåmsbana a tourist line', () => { + const kind = (id, route, tripId = 'VYG:ServiceJourney:536_906310-R') => + classifyTrain(getRegisteredRailFeed(id), route, { tripId }); + assert.equal(kind('entur-vy', 'VYG:Line:F4')?.category, 'national'); + assert.equal(kind('entur-vy', 'VYG:Line:RE11')?.category, 'national'); + assert.equal(kind('entur-vy', 'VYG:Line:L1')?.category, 'local'); + assert.equal(kind('entur-vy', 'VYG:Line:RE20')?.category, 'international'); + assert.equal(kind('entur-vy', 'VYG:Line:F1')?.category, 'international'); + assert.equal(kind('entur-vy', 'VYG:Line:R55')?.category, 'regional'); + const flam = kind('entur-vy', 'VYG:Line:R45'); + assert.equal(flam.category, 'regional'); + assert.equal(flam.tourist, true); + assert.equal(kind('entur-go-ahead', 'GOA:Line:50')?.category, 'national'); + assert.equal(kind('entur-go-ahead', 'GOA:Line:59')?.category, 'local'); + assert.equal(kind('entur-flytoget', 'FLT:Line:FLY1')?.category, 'local'); + assert.equal(kind('entur-sj-nord', 'SJN:Line:21')?.category, 'national'); + assert.equal(kind('entur-sj-nord', 'SJN:Line:72')?.category, 'international'); + assert.equal(kind('entur-sj-nord', 'SJN:Line:26')?.category, 'regional'); + assert.equal( + kind('entur-vy', 'VYG:Line:RE11', 'VYG:ServiceJourney:BUS-836_761763-R'), + null, + 'a replacement bus on a train line', + ); + assert.equal(kind('entur-vy', 'VYG:Line:X99'), null); + assert.match(getRegisteredRailFeed('entur-sj-nord').terms.note, /missing/); + // Its positions come from SIRI VM, the timetable still gives its stops. + assert.equal( + railFeedPositions(getRegisteredRailFeed('entur-sj-nord')), + 'gps', + ); + assert.equal(getRegisteredRailFeed('entur-sj-nord').format, 'siri-vm'); +}); + +test('feeds that read routes from their schedule pack can build one', () => { + for (const feed of RAIL_FEED_REGISTRY.filter((entry) => entry.routesFrom)) { + assert.equal(feed.routesFrom, 'schedule', feed.id); + assert.ok(isHttps(feed.staticGtfsUrl), feed.id); + } + for (const feed of RAIL_FEED_REGISTRY.filter((entry) => entry.headerClock)) + assert.equal(feed.headerClock, 'untrusted', feed.id); + // Identify-yourself headers never carry a person. + for (const feed of RAIL_FEED_REGISTRY) + for (const value of Object.values({ + ...(feed.headers || {}), + ...(feed.staticHeaders || {}), + })) + assert.ok(!/@/.test(value), feed.id); +}); diff --git a/src/data/railFeedsAsia.test.mjs b/src/data/railFeedsAsia.test.mjs new file mode 100644 index 000000000..319f4c66c --- /dev/null +++ b/src/data/railFeedsAsia.test.mjs @@ -0,0 +1,63 @@ +// Asian feeds: Japan's live tram feeds (Manyōsen and Toyama), registered, +// switched on with their licence basis recorded, and sorted into +// categories. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + RAIL_ENABLED_FEEDS, + classifyTrain, + getRailFeed, + getRegisteredRailFeed, + railFeedPositions, + railFeedsInRange, +} from './railFeeds.js'; + +const LIVE = ['jp-manyosen', 'jp-toyama-tram']; + +const feed = (id) => getRegisteredRailFeed(id); +const kind = (id, routeId) => classifyTrain(feed(id), routeId); + +const TRAM = { category: 'transit', mode: 'tram' }; + +test('the Japanese feeds are on, with their licence basis recorded', () => { + for (const id of LIVE) { + assert.ok(getRailFeed(id), `${id} is pollable`); + assert.ok(RAIL_ENABLED_FEEDS.includes(feed(id)), id); + assert.ok(feed(id).license, id); + assert.match(feed(id).licenseUrl, /^https:\/\//, id); + assert.ok(feed(id).attribution, id); + assert.ok(feed(id).terms.quote, `${id} quotes its terms`); + assert.match(feed(id).terms.note, /read 2026-11-10/, id); + assert.doesNotMatch(feed(id).terms.note, /@/, id); + assert.equal(railFeedPositions(feed(id)), 'gps', id); + assert.match(feed(id).url, /^https:\/\/gtfs-rt-files\.buscatch\.jp\//); + } + assert.equal( + feed('jp-toyama-tram').staticGtfsUrl, + undefined, + 'its static GTFS codes the tram as buses', + ); +}); + +test('Japan: the Manyōsen and Toyama trams are transit', () => { + assert.deepEqual(kind('jp-manyosen', '1'), TRAM); + assert.deepEqual( + kind('jp-toyama-tram', '富山港線(南富山)(3004-1)'), + TRAM, + 'a route id seen live', + ); +}); + +test('each Japanese tram feed is polled around its own city only', () => { + const near = (lat, lon) => railFeedsInRange(lat, lon).map((f) => f.id); + // Takaoka for the Manyōsen, Toyama station for the city tram. + assert.ok(near(36.75, 137.02).includes('jp-manyosen')); + assert.ok(near(36.7, 137.21).includes('jp-toyama-tram')); + // Tokyo and Nagoya are hundreds of kilometres off. + for (const [lat, lon] of [ + [35.68, 139.77], + [35.17, 136.88], + ]) + for (const id of LIVE) assert.ok(!near(lat, lon).includes(id), id); + for (const id of LIVE) assert.ok(feed(id).loadRadiusKm <= 25, id); +}); diff --git a/src/data/railFeedsDocs.test.mjs b/src/data/railFeedsDocs.test.mjs new file mode 100644 index 000000000..5a79ec088 --- /dev/null +++ b/src/data/railFeedsDocs.test.mjs @@ -0,0 +1,97 @@ +// The rail counts the documents state, against the registry: how many feeds +// there are, how many publish GPS or station reports, how many work out of +// the box, register trip updates or have a static GTFS, and one +// DATA_SOURCES.md row per feed, none for a feed the registry does not hold. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { + RAIL_ENABLED_FEEDS, + RAIL_FEED_REGISTRY, + railFeedNeedsSchedulePack, + railFeedPositions, +} from './railFeeds.js'; + +const read = (file) => + readFileSync(new URL(`../../${file}`, import.meta.url), 'utf8'); + +const counts = { + registry: RAIL_FEED_REGISTRY.length, + gps: RAIL_FEED_REGISTRY.filter((feed) => railFeedPositions(feed) === 'gps') + .length, + stationReports: RAIL_FEED_REGISTRY.filter( + (feed) => railFeedPositions(feed) === 'event', + ).length, + outOfTheBox: RAIL_ENABLED_FEEDS.filter( + (feed) => !railFeedNeedsSchedulePack(feed), + ).length, + tripUpdates: RAIL_FEED_REGISTRY.filter( + (feed) => typeof feed.tripUpdatesUrl === 'string', + ).length, + staticGtfs: RAIL_FEED_REGISTRY.filter( + (feed) => typeof feed.staticGtfsUrl === 'string', + ).length, +}; + +test('the registry holds the feeds the documents count', () => { + assert.deepEqual(counts, { + registry: 30, + gps: 29, + stationReports: 1, + outOfTheBox: 28, + tripUpdates: 22, + staticGtfs: 29, + }); + assert.equal(RAIL_ENABLED_FEEDS.length, counts.registry, 'all switched on'); + assert.deepEqual( + RAIL_FEED_REGISTRY.filter( + (feed) => railFeedPositions(feed) === 'event', + ).map((feed) => feed.id), + ['irish-rail'], + 'the station reports are Irish Rail’s', + ); +}); + +test('DATA_SOURCES.md states the rail counts and has one row per feed', () => { + const sources = read('DATA_SOURCES.md'); + const { registry, gps, outOfTheBox, tripUpdates, staticGtfs } = counts; + for (const phrase of [ + `The registry lists ${registry} feeds, all switched on`, + `${gps} feeds publish live GPS positions (SJ Nord's through SIRI VM), and Irish Rail reports the station or timing point a train last passed`, + `${tripUpdates} feeds register trip updates`, + `${outOfTheBox} feeds work out of the box`, + `(${staticGtfs} of the ${registry} have one)`, + ]) + assert.ok(sources.includes(phrase), phrase); + const rows = sources + .split('\n') + .filter((line) => /^\| \*\*.+\| Rail layer trains, /.test(line)); + assert.equal(rows.length, registry, 'one row per feed'); + for (const feed of RAIL_FEED_REGISTRY) + assert.ok( + rows.some((row) => row.includes(`| Rail layer trains, ${feed.region}:`)), + `${feed.id} has a row`, + ); +}); + +test('CURRENT-STATE.md states the rail counts', () => { + const state = read('docs/CURRENT-STATE.md'); + const { registry, gps, outOfTheBox, tripUpdates, staticGtfs } = counts; + for (const phrase of [ + `(${registry} keyless feeds, all on, ${outOfTheBox} out of the box;`, + `The registry lists ${registry} feeds, all`, + `a host that has built nothing shows ${outOfTheBox} feeds`, + `By position source, ${gps} feeds publish live GPS.`, + 'Irish Rail publishes the\nstation or timing point the signalling last reported.', + `${tripUpdates} feeds register trip updates;`, + `that has a static GTFS (${staticGtfs} of the ${registry})`, + ]) + assert.ok(state.includes(phrase), phrase); +}); + +test('README.md states the rail counts', () => { + const readme = read('README.md'); + const { registry, gps, outOfTheBox } = counts; + const phrase = `from ${registry} keyless operator feeds: live GPS positions from ${gps} of them and Irish Rail’s station reports, ${outOfTheBox} live out of the box`; + assert.ok(readme.includes(phrase), phrase); +}); diff --git a/src/data/railFeedsFrance.test.mjs b/src/data/railFeedsFrance.test.mjs new file mode 100644 index 000000000..af0a0442c --- /dev/null +++ b/src/data/railFeedsFrance.test.mjs @@ -0,0 +1,86 @@ +// France (2026-11-10): the Bordeaux and Montpellier tram networks, from +// transport.data.gouv.fr. Registered, switched on with the dataset's licence +// field quoted, polled for live positions, and sorted into categories; no +// registry URL is on a download path its host's robots.txt disallows. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + RAIL_FEED_REGISTRY, + classifyTrain, + getRailFeed, + getRegisteredRailFeed, + railFeedHasTripUpdates, + railFeedPositions, +} from './railFeeds.js'; + +const kind = (id, routeId) => { + const train = classifyTrain(getRegisteredRailFeed(id), routeId); + return train ? `${train.category}/${train.mode}` : null; +}; + +const FR_FEEDS = ['tbm-bordeaux', 'tam-montpellier']; + +// ------------------------------------------------------------ registry + +test('the French feeds are on and quote the dataset licence field', () => { + for (const id of FR_FEEDS) { + const feed = getRegisteredRailFeed(id); + assert.ok(feed, `${id} is registered`); + assert.ok(getRailFeed(id), `${id} is pollable`); + assert.equal(feed.defaultEnabled, true, id); + assert.match(feed.staticGtfsUrl, /^https:\/\//, id); + assert.ok(['lov2', 'odc-odbl', 'fr-lo'].includes(feed.terms.quote), id); + assert.match(feed.terms.note, /transport\.data\.gouv\.fr dataset "/, id); + assert.match(feed.terms.note, /2026-11-10/, id); + } +}); + +test('the ODbL dataset carries the share-alike note and the access point conditions', () => { + const feed = getRegisteredRailFeed('tam-montpellier'); + assert.equal(feed.license, 'ODbL 1.0'); + assert.match(feed.terms.note, /share-alike/); + assert.match(feed.terms.note, /dans le format d'origine/); + assert.equal( + getRegisteredRailFeed('tbm-bordeaux').license, + 'Licence Ouverte / Open Licence 1.0', + ); +}); + +test('positions: Bordeaux and Montpellier trams are GPS, with trip updates', () => { + for (const id of FR_FEEDS) { + assert.equal(railFeedPositions(getRegisteredRailFeed(id)), 'gps', id); + assert.ok(railFeedHasTripUpdates(getRegisteredRailFeed(id)), id); + } +}); + +// ------------------------------------------------------------ categories + +test('tram lines are transit; buses are dropped', () => { + const cases = [ + ['tbm-bordeaux', '164', 'transit/tram'], + ['tbm-bordeaux', '901', null], + ['tam-montpellier', '5', 'transit/tram'], + ['tam-montpellier', 'A', null], + ]; + for (const [id, route, expected] of cases) + assert.equal(kind(id, route), expected, `${id} ${route}`); +}); + +// ------------------------------------------------------------ download + +// Hosts whose robots.txt disallows the download paths French publishers use. +const DISALLOWED = [ + /^https:\/\/eu\.ftp\.opendatasoft\.com\//, + /^https:\/\/thello\.axelor\.com\//, + /^https:\/\/static\.data\.gouv\.fr\/resources\//, + /^https:\/\/www\.data\.gouv\.fr\/(fr\/)?(api\/1\/)?datasets\/r\//, + /\/explore\/dataset\/.*\/download/, +]; + +test('no registry URL is on a download path its robots.txt disallows', () => { + for (const feed of RAIL_FEED_REGISTRY) + for (const url of [feed.url, feed.tripUpdatesUrl, feed.staticGtfsUrl]) + if (url) + for (const pattern of DISALLOWED) + assert.ok(!pattern.test(url), `${feed.id} ${url}`); +}); diff --git a/src/data/railFeedsNational.test.mjs b/src/data/railFeedsNational.test.mjs new file mode 100644 index 000000000..b3f3e6430 --- /dev/null +++ b/src/data/railFeedsNational.test.mjs @@ -0,0 +1,103 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + RAIL_ENABLED_FEEDS, + classifyTrain, + getRailFeed, + getRegisteredRailFeed, +} from './railFeeds.js'; +import { + railAdmissionGlobalMax, + railNaturalRequestsPerMinute, +} from './railProxy.js'; + +/** One end of a run, as the path build's `readTripEnds` writes it. */ +const at = (name, country, state) => ({ name, country, state }); +const ends = ( + from, + to, + countries = [...new Set([from.country, to.country])], +) => ({ from, to, countries }); + +/** + * The category a timetable trip gets: the feed's route rename (when it has + * one), its trip rename with the run's ends, then its classifier on the pack + * route id, as the build and the browser apply them. + */ +function tripKind(feedId, route, run = null, trip = { trip_id: 't1' }) { + const feed = getRegisteredRailFeed(feedId); + const base = + (typeof feed.staticRouteRename === 'function' + ? feed.staticRouteRename(route, { stop: () => null, stateAt: () => null }) + : null) ?? route.route_id; + const id = + typeof feed.staticTripRename === 'function' + ? feed.staticTripRename(trip, base, { ends: run }) + : base; + return classifyTrain(feed, id, { tripId: trip.trip_id }); +} +const category = (...args) => tripKind(...args)?.category ?? null; + +test('the national feeds are on and quote their terms', () => { + for (const id of ['ktmb', 'entur-sj-nord']) { + assert.ok(getRailFeed(id), `${id} is pollable`); + assert.ok(getRegisteredRailFeed(id).terms.quote, `${id} quotes its terms`); + } +}); + +test('the admission cap grows with the KTMB and SJ Nord feeds', () => { + const before = RAIL_ENABLED_FEEDS.filter( + (feed) => !['ktmb', 'entur-sj-nord'].includes(feed.id), + ); + assert.ok( + railNaturalRequestsPerMinute() > railNaturalRequestsPerMinute(before), + ); + assert.ok(railAdmissionGlobalMax() >= railAdmissionGlobalMax(before)); +}); + +test('KTMB: Komuter local, Shuttle Tebrau international, ETS and Intercity by state', () => { + const route = (id) => ({ route_id: id, route_type: '2' }); + const kl = at('KL SENTRAL', 'MY', 'MY-14'); + const jb = at('JB SENTRAL', 'MY', 'MY-01'); + const tumpat = at('TUMPAT', 'MY', 'MY-03'); + const guaMusang = at('GUA MUSANG', 'MY', 'MY-03'); + // The simplified polygons put Padang Besar station in Thailand. + const padangBesar = at('PADANG BESAR', 'TH', 'TH-90'); + const butterworth = at('BUTTERWORTH', 'MY', 'MY-07'); + assert.equal(category('ktmb', route('KA15_KD19'), ends(kl, kl)), 'local'); + assert.equal(category('ktmb', route('KC05_KB18')), 'local'); + assert.equal( + category('ktmb', route('100_47300'), ends(butterworth, padangBesar)), + 'local', + 'Komuter Utara', + ); + assert.equal(category('ktmb', { route_id: 'SS', route_type: '0' }), 'local'); + assert.equal(category('ktmb', route('ST')), 'international'); + assert.equal(category('ktmb', route('ETS'), ends(kl, jb)), 'national'); + assert.equal( + category('ktmb', route('ETS'), ends(kl, padangBesar)), + 'national', + 'Padang Besar is in Perlis', + ); + assert.equal( + category('ktmb', route('ETS'), ends(kl, at('HAT YAI', 'TH', 'TH-90'))), + 'international', + ); + assert.equal( + category( + 'ktmb', + route('ERT'), + ends(at('MERAPOH', 'MY', 'MY-06'), at('WOODLANDS CIQ', 'SG', 'SG-03')), + ), + 'international', + ); + assert.equal( + category('ktmb', route('SH'), ends(tumpat, guaMusang)), + 'regional', + ); + assert.equal(category('ktmb', route('SH')), 'national', 'ends unknown'); + assert.equal(category('ktmb', route('BUS1'), ends(kl, jb)), null); + // The realtime vehicles carry no route: it comes from the schedule pack. + assert.equal(getRegisteredRailFeed('ktmb').routesFrom, 'schedule'); + assert.equal(getRegisteredRailFeed('ktmb').pollIntervalMs, 30_000); +}); diff --git a/src/data/railFeedsUs.test.mjs b/src/data/railFeedsUs.test.mjs new file mode 100644 index 000000000..cc85a2a9f --- /dev/null +++ b/src/data/railFeedsUs.test.mjs @@ -0,0 +1,46 @@ +// Tri-Rail, South Florida's commuter line: switched on with its basis, +// polled for live positions, and sorted into categories by route or by +// train number. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + RAIL_ENABLED_FEEDS, + classifyTrain, + getRailFeed, + getRegisteredRailFeed, + railFeedHasTripUpdates, + railFeedPositions, +} from './railFeeds.js'; + +const feed = (id) => getRegisteredRailFeed(id); +const kind = (id, routeId, context) => + classifyTrain(feed(id), routeId, context); + +test('Tri-Rail is on, with its basis', () => { + const tri = feed('tri-rail'); + assert.ok(getRailFeed('tri-rail'), 'pollable'); + assert.ok(RAIL_ENABLED_FEEDS.includes(tri)); + assert.ok(tri.terms.quote, 'quotes its terms'); + assert.ok(tri.terms.note, 'explains its basis'); + // Used by project owner decision, which it says, naming no person. + assert.match(tri.terms.note, /\(project owner decision, 2026-11-10\)/); + assert.doesNotMatch(tri.terms.note, /@/); +}); + +test('Tri-Rail: live positions with trip updates, trains by route or train number', () => { + const tri = feed('tri-rail'); + assert.equal(railFeedPositions(tri), 'gps'); + assert.ok(railFeedHasTripUpdates(tri)); + assert.equal(tri.staticGtfsUrl, 'https://gtfs.tri-rail.com/gtfs.zip'); + const local = { category: 'local', mode: 'rail' }; + // Pack routes: Mainline and Downtown Miami Link. + assert.deepEqual(kind('tri-rail', '1'), local); + assert.deepEqual(kind('tri-rail', '2'), local); + // The airport shuttle bus, should a route id ever reach the classifier. + assert.equal(kind('tri-rail', '4'), null); + // Realtime vehicles carry no route, only the train number. + assert.deepEqual(kind('tri-rail', null, { tripId: 'P617' }), local); + assert.deepEqual(kind('tri-rail', '', { tripId: 'P601X' }), local); + assert.equal(kind('tri-rail', null, { tripId: 'FLA Loop_T01' }), null); + assert.equal(kind('tri-rail', null, {}), null); +}); diff --git a/src/data/railFeedsUsNoTerms.test.mjs b/src/data/railFeedsUsNoTerms.test.mjs new file mode 100644 index 000000000..6dc88ee75 --- /dev/null +++ b/src/data/railFeedsUsNoTerms.test.mjs @@ -0,0 +1,62 @@ +// A United States feed whose publisher states no terms (Charlotte LYNX): +// switched on with the reason recorded, and sorted into categories. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + RAIL_ENABLED_FEEDS, + classifyTrain, + getRailFeed, + getRegisteredRailFeed, + railFeedHasTripUpdates, + railFeedPositions, +} from './railFeeds.js'; +import { keepsRouteType } from '../../scripts/build-rail-paths.mjs'; + +// The feeds that publish no terms, used without a published licence by project +// owner decision of 2026-11-10. +const ON = ['charlotte-lynx']; +const BASIS = + 'Publishes no terms; used without a published licence (project owner decision, 2026-11-10).'; + +const TRAM = { category: 'transit', mode: 'tram' }; + +const feed = (id) => getRegisteredRailFeed(id); +const kind = (id, routeId, context) => + classifyTrain(feed(id), routeId, context); + +test('the no-terms feed is on and says why', () => { + for (const id of ON) { + const entry = feed(id); + assert.ok(getRailFeed(id), `${id} is pollable`); + assert.ok(RAIL_ENABLED_FEEDS.includes(entry), id); + assert.equal(entry.license, 'No licence published', id); + assert.ok(entry.terms.note.startsWith(BASIS), id); + // Nothing personal in the recorded basis. + assert.doesNotMatch(entry.terms.note, /@/, id); + } +}); + +test('Charlotte LYNX: live light rail and streetcar with trip updates, buses dropped', () => { + const lynx = feed('charlotte-lynx'); + assert.equal(railFeedPositions(lynx), 'gps'); + assert.ok(railFeedHasTripUpdates(lynx)); + assert.match( + lynx.staticGtfsUrl, + /^https:\/\/gtfsrealtime\.ridetransit\.org\//, + ); + // Routes seen live on 2026-11-10: 501 Blue Line, 510 Gold Line, buses. + assert.deepEqual(kind('charlotte-lynx', '501'), TRAM); + assert.deepEqual(kind('charlotte-lynx', '510'), TRAM); + for (const bus of ['9', '22', '5', null]) + assert.equal(kind('charlotte-lynx', bus), null, String(bus)); +}); + +test('a listed staticRouteTypes is the whole list, so trains coded as buses build', () => { + assert.equal(keepsRouteType({}, '3'), false, 'a bus by default'); + assert.equal(keepsRouteType({}, ' 2 '), true); + assert.equal(keepsRouteType({ staticRouteTypes: [3] }, '3'), true); + assert.equal(keepsRouteType({ staticRouteTypes: [3] }, '2'), false); + assert.equal(keepsRouteType({ staticRouteTypes: [0, 2] }, '0'), true); + assert.equal(keepsRouteType({ staticRouteTypes: [0, 2] }, '1'), false); + assert.equal(keepsRouteType({ staticRouteTypes: [3] }, ''), false); +}); diff --git a/src/data/railFeedsUsUrban.test.mjs b/src/data/railFeedsUsUrban.test.mjs new file mode 100644 index 000000000..9678477d2 --- /dev/null +++ b/src/data/railFeedsUsUrban.test.mjs @@ -0,0 +1,57 @@ +// United States streetcar feeds (Seattle, Cincinnati): switched on with +// their basis, polled for live positions, and sorted into categories with +// the buses dropped. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + RAIL_ENABLED_FEEDS, + classifyTrain, + getRailFeed, + getRegisteredRailFeed, + railFeedHasTripUpdates, + railFeedPositions, +} from './railFeeds.js'; + +// Licensed and switched on. +const ON = ['seattle-streetcar', 'cincinnati-connector']; + +const TRAM = { category: 'transit', mode: 'tram' }; + +const feed = (id) => getRegisteredRailFeed(id); +const kind = (id, routeId, context) => + classifyTrain(feed(id), routeId, context); + +test('the US streetcar feeds: licensed feeds on, each with its basis', () => { + for (const id of ON) { + assert.ok(getRailFeed(id), `${id} is pollable`); + assert.ok(RAIL_ENABLED_FEEDS.includes(feed(id)), id); + assert.ok(feed(id).terms.note, `${id} explains its basis`); + assert.ok(feed(id).staticGtfsUrl, `${id} has a timetable`); + assert.ok(feed(id).terms.quote, `${id} quotes its terms`); + // Nothing personal in the recorded basis. + assert.doesNotMatch(feed(id).terms.note, /@/, id); + } +}); + +test('the US streetcar feeds poll positions and trip updates', () => { + for (const id of ON) { + assert.equal(railFeedPositions(feed(id)), 'gps', id); + assert.ok(railFeedHasTripUpdates(feed(id)), id); + } +}); + +test('Seattle Streetcar: the two City of Seattle routes of the King County feed', () => { + assert.deepEqual(kind('seattle-streetcar', '100340'), TRAM); + assert.deepEqual(kind('seattle-streetcar', '102638'), TRAM); + assert.equal(kind('seattle-streetcar', '100001'), null, 'a Metro bus'); + assert.equal(kind('seattle-streetcar', null), null); + assert.equal( + feed('seattle-streetcar').attribution, + 'Transit scheduling, geographic, and real-time data provided by permission of King County', + ); +}); + +test('Cincinnati Connector: the streetcar route, buses dropped', () => { + assert.deepEqual(kind('cincinnati-connector', '100'), TRAM); + assert.equal(kind('cincinnati-connector', '78'), null); +}); diff --git a/src/data/railIreland.test.mjs b/src/data/railIreland.test.mjs new file mode 100644 index 000000000..cebda5819 --- /dev/null +++ b/src/data/railIreland.test.mjs @@ -0,0 +1,134 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + classifyTrain, + getRailFeed, + getRegisteredRailFeed, + publicRailCatalog, + railFeedHasTripUpdates, + railFeedPositions, +} from './railFeeds.js'; +import { + buildRailSnapshot, + railMaxBodyBytes, + railUpstreamHeaders, +} from './railProxy.js'; + +const irish = getRegisteredRailFeed('irish-rail'); + +test('Irish Rail keeps its XML positions and gains a timetable', () => { + assert.equal(irish.format, 'irish-rail-xml'); + assert.equal(irish.positions, 'event'); + assert.ok(getRailFeed('irish-rail')); + assert.match(irish.staticGtfsUrl, /\/transitData\/Data\/GTFS_Realtime\.zip$/); + assert.deepEqual(irish.staticRouteTypes, [2]); + assert.equal(irish.positionsByTrainNumber, true); + assert.equal(irish.routesFrom, 'schedule'); + // The XML's own headers and cap. + assert.match(railUpstreamHeaders(irish).Accept, /xml/); + assert.equal(railMaxBodyBytes(irish), 1024 * 1024); + // The positions publish no licence; the NTA timetable is CC BY 4.0. + assert.equal( + irish.license, + 'No licence published (positions); CC BY 4.0 (NTA timetable)', + ); + assert.equal( + irish.attribution, + 'Iarnród Éireann / Irish Rail; timetable: National Transport Authority, provided as is', + ); +}); + +test('Irish Rail is placed by its XML events and serves no trip updates', () => { + assert.equal(railFeedPositions(irish), 'event'); + assert.equal(irish.tripUpdatesUrl, undefined); + assert.equal(railFeedHasTripUpdates(irish), false); + // Listed without a timetable pack: the XML's train codes classify alone. + const entry = publicRailCatalog({ schedulePackBuilt: () => false }).find( + (feed) => feed.id === 'irish-rail', + ); + assert.equal(entry.positions, 'event'); + assert.equal(entry.tripUpdates, false); +}); + +test('Irish Rail pack routes: the code letter and the provinces of the ends', () => { + const rename = (code, from, to, countries = ['IE']) => + irish.staticTripRename({ trip_short_name: code }, 'R-1', { + ends: { + from: { name: from, country: countries[0] }, + to: { name: to, country: countries.at(-1) }, + countries, + }, + }); + assert.equal( + rename('A100', 'Dublin Connolly', 'Belfast', ['IE', 'GB']), + 'A~B~R-1', + ); + assert.equal(rename('A203', 'Cork (Kent)', 'Dublin Heuston'), 'A~X~R-1'); + assert.equal(rename('A301', 'Mallow', 'Tralee (Casement)'), 'A~S~R-1'); + assert.equal(rename('P501', 'Mallow', 'Cobh'), 'P~S~R-1'); + assert.equal(rename('E206', 'Bray (Daly)', 'Howth'), 'E~X~R-1'); + assert.equal(rename('195.MF-BH.93-GRN-y11', 'a', 'b'), null, 'no train code'); + const kind = (route) => classifyTrain(irish, route, { tripId: '6024_1' }); + assert.equal( + kind('A~B~DUB-BFT-O').category, + 'international', + 'the Enterprise', + ); + assert.equal(kind('A~X~DUB-CORK-O').category, 'national'); + assert.equal(kind('A~S~DUB-TRALEE-O').category, 'regional'); + assert.equal(kind('E~X~BRAY-HOWTH-I').category, 'local', 'DART'); + assert.equal(kind('P~S~MAL-COBH-O').category, 'local', 'commuter'); + assert.equal(kind('D~S~MAL-COBH-O').category, 'local'); + // The XML's own records still classify by their code and ends. + assert.equal( + classifyTrain(irish, 'Dublin Connolly - Belfast', { tripId: 'A100' }) + .category, + 'international', + ); + assert.equal( + classifyTrain(irish, 'Bray - Howth', { tripId: 'E206' }).category, + 'local', + ); +}); + +test('an Irish Rail train code takes the timetable run it is on now', () => { + const xml = ` + + + R53.1442 + -6.06085A610 + 09 Nov 2026 + A610\\n18:37 - Dublin Connolly to Rosslare Europort (3 mins late)\\nDeparted Greystones next stop Kilcoole + Southbound + + + R53.35 + -6.25E999 + 09 Nov 2026 + E999\\n19:00 - Bray to Howth (0 mins late)\\nDeparted Connolly next stop Clontarf Road + Northbound + +`; + const pack = { '6024_8435': 'A~X~DUB-EUROPORT-I' }; + const snapshot = buildRailSnapshot( + irish, + new TextEncoder().encode(xml), + 1_794_251_747_000, + { + routeFor: (tripId) => pack[tripId] ?? null, + tripFor: (code) => (code === 'A610' ? '6024_8435' : null), + }, + ); + const byCode = Object.fromEntries( + snapshot.trains.map((train) => [train.trainNumber ?? train.tripId, train]), + ); + assert.equal(byCode.A610.tripId, '6024_8435'); + assert.equal(byCode.A610.routeId, 'A~X~DUB-EUROPORT-I'); + assert.equal(byCode.A610.category, 'national'); + assert.equal(byCode.A610.positionSource, 'event'); + assert.equal(byCode.A610.id, 'A610', 'the vehicle keeps its code'); + // Not in the timetable: its own code and ends, as before. + assert.equal(byCode.E999.tripId, 'E999'); + assert.equal(byCode.E999.trainNumber, undefined); + assert.equal(byCode.E999.category, 'local'); +}); diff --git a/src/data/railNoPositions.js b/src/data/railNoPositions.js new file mode 100644 index 000000000..b645a7d0a --- /dev/null +++ b/src/data/railNoPositions.js @@ -0,0 +1,1092 @@ +/** + * @module railNoPositions + * @description Where the Rail layer places no train at all: Ukraine, a + * safety rule. The proxy (src/data/railProxy.js) drops every live position + * inside it before the snapshot leaves the server, counted with the other + * dropped vehicles; the path build (scripts/build-rail-paths.mjs) leaves out + * every timetable trip that stops there, so no schedule pack holds one and + * no train can take its line or stops. + * + * A stop is placed by the country its id starts with where the id carries + * one (IFOPT-style `ua:22005:1:1`, parent stations `Pua:22005`), else by the + * outline. A position is tested against a bounding box first and against + * the outline only inside it, so the check costs nothing elsewhere. + * + * The outline is Ukraine's recognised borders, Crimea included, as mapped in + * OpenStreetMap: geoBoundaries gbOpen UKR ADM0 (OpenStreetMap via Wambacher's + * OSM Boundaries), file geoBoundaries-UKR-ADM0.geojson at geoBoundaries commit + * 9469f09 (SHA-256 a24186ba62303d653efdbb2d91eddbac263619899d85a5802ac845e0a93e480a). + * It runs out to the territorial sea, where no train runs. Outer ring only, + * Douglas-Peucker at 0.0005° (longitude scaled by the cosine of latitude), + * four decimals, delta-encoded lon/lat pairs, stored open. Natural Earth's + * 10m outline is too coarse here: it puts Romanian, Polish and Slovak + * stations on the border river or line inside Ukraine (Bocicoi, Medyka, + * Čierna nad Tisou) and leaves Ukrainian halts out (Uhniv, Korchiv). + * + * Licence: the outline is a derivative of OpenStreetMap and is offered under + * the Open Database License 1.0 (https://opendatacommons.org/licenses/odbl/1-0/), + * © OpenStreetMap contributors (https://www.openstreetmap.org/copyright); + * geoBoundaries (Runfola et al. 2020, https://www.geoboundaries.org). The + * code in this module is under the repository's licence. + * + * Pure data and helpers: imported by the proxy, the path build and node:test. + */ + +/** ISO 3166-1 alpha-2 codes of the countries where no train is placed. */ +export const NO_TRAIN_POSITION_COUNTRIES = Object.freeze(['UA']); + +const DECIMALS = 4; +/** Outer rings, `[lon, lat]` deltas at DECIMALS. */ +const RINGS = Object.freeze([ + Object.freeze([ + 331962, 523736, -5, 53, -70, 2, 0, -60, -188, -3, -1, -41, 30, -21, -32, + -258, -134, -24, -120, 59, -72, 16, -30, -9, -16, -18, -33, -14, 6, -34, + -124, -96, -62, -34, -36, 13, -107, -68, -161, -122, -80, -5, 7, -28, -64, + -18, -54, 35, -10, 40, 25, 52, 36, -23, 75, 18, -29, 18, 20, 50, -15, 23, + 20, 2, -18, 23, -23, -5, -74, 20, 10, 15, -34, 2, -53, -49, 43, -64, -143, + -48, 2, -64, -210, -72, -54, -67, -120, -44, -94, -7, 30, -69, -3, -45, -36, + -1, -4, -47, -79, 1, -54, 15, -208, -3, -166, -90, -347, -142, -35, -40, + -38, -2, -97, 2, -57, -9, -85, 137, 62, 96, -9, 50, -278, 7, -236, 42, -159, + -194, -179, 65, -204, -39, -2, -23, -63, 2, -95, -39, -10, 66, -52, 47, + -306, -105, -92, 23, -162, -107, -5, 70, -105, 8, -45, 106, -230, 124, -106, + 43, -137, 36, -158, 106, -102, 36, -39, 39, -138, -11, -17, -11, -88, -19, + -64, -4, -58, 99, -214, 168, -131, -7, -164, 18, -188, -7, -22, -61, -98, + -68, -176, 99, -55, -4, -67, 29, -261, -48, -31, 8, -19, 22, -215, 110, -62, + 20, -68, -43, -67, 38, -108, -2, -216, -100, -33, -111, 117, -156, 34, -165, + 33, -91, 36, -19, 78, 52, 132, -1, -120, -85, 53, -115, 18, -9, -8, -17, 13, + -26, 18, -2, 27, -24, 55, -3, -17, -7, 11, -13, 18, 1, -10, -15, 8, -7, -61, + 6, -19, -17, -97, -44, -164, -1, -60, -7, -5, -13, -142, 57, -25, -21, 32, + -11, -31, -20, -20, 6, -11, -6, -167, -155, 2, -88, 61, -91, -2, -27, 100, + -49, 37, -28, 26, -126, -22, -7, -21, 4, -7, -19, -21, 4, 5, -12, -18, -40, + -37, 8, 21, -22, 23, 1, -8, -13, -31, 0, -2, -11, -22, -10, 7, -10, -16, -5, + 16, -15, -21, -10, -10, 13, -4, -33, -20, 0, 11, -16, -29, 0, 10, -27, -20, + -5, 15, -12, 10, 8, 18, -6, -28, -21, -4, -38, -25, -16, 24, -16, 60, 61, + 329, -7, 32, -76, -5, -12, -78, -66, -252, 5, -23, -21, -28, -58, -95, 37, + -23, -143, -15, -9, -108, -7, -54, 9, -6, -14, -19, 3, -10, -10, -10, 19, + -16, -10, -7, -20, 20, -2, -14, -12, 15, -12, -3, -21, 18, 3, -7, -14, 19, + -9, -13, -10, -23, -2, 9, -9, -25, 1, -17, -9, 6, -19, -13, -35, -41, 2, -3, + -19, -57, -20, -11, 6, -7, -22, -22, 3, 4, -8, -18, 5, -35, -10, -14, 7, + -17, -19, -23, -1, -3, -15, -23, -14, -28, 5, 17, -10, -24, -12, -11, 5, -9, + -9, -11, 8, -8, -9, -23, 8, -17, -4, 4, -18, -66, -2, -31, 8, -12, -8, -35, + 0, -2, -19, -67, -3, -24, 12, -44, -5, -1, -15, -15, 2, -11, -8, 16, -15, + -12, -30, -41, 16, -92, -36, -13, -10, 13, -10, -24, -17, -45, -4, -14, -18, + 5, -29, 15, -11, -32, -26, -1, 24, -11, -11, -53, -5, -15, 39, -59, 11, -18, + -7, -42, 4, -43, -15, -43, 21, -24, -6, -61, 3, -39, -15, -27, 12, -38, -27, + -48, 3, 11, -18, -18, -7, -1, -14, 11, -2, 9, -26, -47, -20, 18, -12, -13, + -11, 21, 3, 7, -8, -37, -30, -57, -24, -34, 2, 17, 12, -20, 5, -40, -52, 40, + -56, -83, 24, -240, -6, -52, -17, -2, -99, -289, 18, -15, -7, -182, 45, -30, + 1, -180, 95, -70, 7, -35, -12, -441, 37, -116, 1, -17, -7, 9, -11, -23, -16, + -21, -4, 1, -7, -17, 4, -1, -12, -43, -11, -1, 12, -24, -2, -3, 8, -18, -5, + 1, 9, -28, -4, -12, 16, -30, -17, -23, 3, 12, -11, -46, -17, -44, -3, -22, + -14, -57, 15, -9, -23, -23, -2, -13, 11, 18, 42, 109, -13, 7, 40, -67, 24, + -45, 3, 15, 14, -6, 6, 29, 10, -3, 7, 21, 6, 0, 9, 41, -1, 32, 21, 24, -3, + 2, -17, 99, -12, 14, 41, 135, 75, 32, 62, -71, 72, -26, -3, -10, 10, -16, + -15, -11, 11, -69, -22, 0, 24, -46, 23, -53, 55, -121, 51, -15, 15, -90, 16, + -66, 42, -83, 25, -164, 26, -91, 25, -49, -3, -69, 10, -94, -9, -49, -28, + -92, -29, -31, -27, -67, -29, -115, -3, -95, 37, -36, -1, -109, 101, -55, + -1, -47, 8, -56, -10, -156, -77, -117, -14, -151, -47, -76, -13, -28, 67, + -67, 45, -140, -2, -147, 49, -134, 19, -105, 32, -67, -27, -267, 6, -109, + -9, -80, -21, -70, 35, -67, -13, -111, 31, -190, 26, -166, -1, -111, 40, + -81, 1, -65, 10, -37, -1, -46, -26, -104, -31, -118, -22, -17, -13, -74, 10, + -49, 25, -106, 10, -17, 8, -42, 71, -42, 23, -16, 77, -20, 17, -62, -4, -63, + 10, -20, 14, -35, -12, -43, 22, -32, -25, -54, 0, -10, -14, -70, 24, -108, + 7, -14, -21, -70, -17, -54, -44, -31, -7, -56, 7, 100, -88, -2, -30, -41, + -26, -48, 1, -91, -49, -102, -28, -209, -33, -98, -21, -30, -16, -25, -41, + -7, -48, -40, -61, -95, -34, 102, -40, -238, -313, -445, -94, -201, 69, + -143, 20, -10, 61, -39, 22, -19, 40, 39, 77, -81, 20, -154, 23, -96, -3, + -10, 7, -2, -9, -24, 0, -3, 11, -26, -5, -30, 9, -7, -6, -106, 25, -43, 23, + -6, 18, -21, 14, 17, 20, -18, 3, -15, -7, -39, 17, 15, 18, -34, 15, -26, 48, + 0, 34, -50, 45, -39, 2, -74, -28, -23, -26, -69, -3, -102, -45, -94, -29, + -93, 10, -242, 0, -45, -22, -298, -36, -223, -43, -114, -6, -61, -15, -5, + -14, -134, 9, -146, 31, -5, 25, 21, 61, -66, 7, -19, -10, 6, -19, -80, 15, + 32, -85, -4, -58, -24, -14, -37, -4, -65, 23, -31, 1, -30, -14, -12, -29, + 24, -57, 24, -25, 81, -31, 34, -49, -12, -46, -43, -51, -79, -30, -17, -24, + 2, -26, 41, -61, 67, -37, 10, -15, -28, -39, -37, -27, 13, -40, -9, -27, + -44, -3, -99, 30, -32, -11, -42, -45, -77, -10, -57, -43, -74, -14, -20, + -11, -24, -41, 21, -37, 139, -19, 19, -11, 9, -29, -10, -21, -33, -16, -54, + -7, -127, 21, -32, -12, -16, -40, 64, -61, 8, -21, -12, -35, -70, -20, -108, + -4, -87, 52, -45, -1, -50, -35, -30, -55, -23, -15, -39, -3, -46, 10, -39, + -9, 1, -77, -14, -10, -35, -9, -75, 36, -46, 2, -59, -47, -69, -29, -1, -26, + 68, -99, 103, -100, 16, -29, -11, -35, -184, -119, -31, -81, -29, -15, -62, + -4, -372, 68, -65, -6, -24, -10, -37, -49, -146, -109, -76, -132, -128, -52, + -43, -42, -35, -56, -15, -53, -2, -100, -86, -179, -56, -16, -177, -20, -41, + -19, -21, -21, -6, -27, 107, -123, 11, -33, -17, -86, -55, -50, -29, -11, + -173, 5, -54, -4, -34, -14, -23, -15, -20, -55, -33, -33, -119, -32, -34, + -26, -3, -53, 21, -16, 45, -12, 184, 58, 88, -9, 31, -12, 15, -24, -7, -77, + -62, -64, -189, -145, -4, -32, 27, -119, -11, -31, -83, -46, -99, -16, -260, + 38, -61, 1, -97, -32, -49, -36, -12, -20, -1, -28, 22, -34, 156, -157, 7, + -44, -23, -80, -32, -54, 0, -26, 69, -92, 4, -29, -11, -21, -25, -22, -43, + -14, -144, 15, -97, -10, -24, -21, 2, -42, 19, -23, 95, -54, 8, -44, -15, + -17, -78, -13, -131, 9, -50, -28, -5, -37, 37, -38, 15, -45, -38, -45, -64, + -8, -114, 19, -55, -15, -20, -17, -3, -38, 41, -37, 70, -23, 152, -29, 184, + -72, 26, -21, 13, -43, -33, -23, -169, 21, -34, -11, -36, -32, 3, -51, 15, + -33, 122, -96, 18, -25, 16, -70, 19, -17, 57, -5, 145, 103, 58, 16, 54, -8, + 79, -89, 5, -21, -15, -28, -157, -162, -15, -31, -2, -54, 18, -30, 130, -82, + 55, -67, 1, -105, 19, -51, 26, -28, 60, -38, 19, 26, 122, -33, 58, -7, 35, + -25, 6, -43, -9, -38, -99, -36, -16, 12, -51, -3, -31, 7, -110, -30, -86, + -40, -29, -22, -7, -32, 33, -55, 37, -112, 49, -50, 45, -12, 44, 1, 30, 13, + 65, 54, 33, 6, 53, -6, 32, -16, 14, -29, -7, -34, 54, -29, 44, -121, 237, + -314, -39, -54, -109, -31, -43, -29, -19, -38, 3, -21, 31, -26, 110, -45, + 69, -67, 6, -26, -50, -71, -33, -21, -198, 4, -64, -11, -23, -13, -17, -33, + -57, -52, -104, -48, -84, -62, 21, -26, 73, -10, 74, 9, 35, -12, 3, -19, + -45, -25, -58, 18, -40, -10, -1, -16, 32, -19, 94, 1, 33, 16, 27, 33, 26, 2, + 15, -13, -42, -32, 7, -9, -25, -25, 24, -57, -9, -43, -45, -45, -64, -34, + -145, 4, -157, -65, -23, -18, -28, -63, 3, -27, -47, -57, -632, 219, -366, + -42, -90, -1, -32, 16, -10, 46, 58, 34, 63, 66, -1, 15, -70, 52, 0, 24, 26, + 33, 6, 27, -190, 20, -119, -24, -77, -5, -264, 71, -61, 88, -50, 20, -363, + 270, -78, 12, -182, 88, 385, 192, -95, 337, -43, 101, -124, 16, -50, 26, + -25, 28, -18, 1, -11, 18, -37, 13, -32, 58, -81, 37, -19, 37, -38, 23, -67, + 70, -43, 15, -69, 63, -37, -1, -100, 41, -272, 175, -68, 22, -274, 54, -322, + 194, -117, -35, -45, -200, -46, 5, -115, 37, -140, 77, -8, 39, -51, 13, -70, + -15, 55, -141, -65, -28, -26, -3, -65, 9, -38, 21, -364, 88, -55, 7, -59, + -11, -177, 45, -131, 9, -4, 31, -18, 3, -82, -16, -11, -9, 0, -23, 37, -34, + 4, -28, -37, -10, -25, -54, -68, -9, -41, -31, -129, -37, -60, 38, -37, 3, + -45, -9, -35, -24, -16, -85, -15, -19, -41, -14, -74, -2, -92, 31, -17, 48, + 19, 48, -50, 25, -82, 23, -43, 33, -57, 0, -36, -40, -77, -14, -23, 12, -19, + -3, -27, -7, -16, -16, -132, 0, -9, -16, 12, -35, -44, -16, -39, -2, 7, -28, + 53, -17, -21, -47, -4, -65, -103, -49, 9, -42, 32, -37, -16, -36, -48, -2, + -107, 30, -75, 11, -61, -3, -23, 55, -34, 30, -188, -61, -106, -25, -36, + 102, -191, -19, 9, -64, 24, -52, -4, -48, -111, 50, -73, 17, -84, 9, -212, + 78, -50, 3, 20, 75, -52, 50, -29, 48, -35, 66, 5, 54, -35, 57, 103, 9, 43, + 87, -18, 43, -50, 30, 0, 27, 57, 11, 11, 23, -34, 60, -25, 97, -27, 5, -80, + -23, -71, 11, -59, -5, -56, -24, -14, -44, -77, -41, -71, -18, -32, 5, -48, + -26, -102, -29, -25, -23, 5, -42, -19, -27, -50, -7, -57, 27, -75, 13, -82, + -71, -31, -2, -44, 52, -53, 21, -91, -119, -85, -16, -72, 9, -1, -70, -19, + -35, 30, -38, -7, -47, -32, -31, -107, -48, -98, -29, -61, -15, -66, 45, + -306, 158, -33, -2, -40, -81, 14, -41, -7, -76, -138, -103, -26, -87, 44, + -48, -14, -89, 12, -62, -25, -14, -35, 21, -22, -4, -32, -74, -56, -62, -39, + -84, -67, -53, -314, 71, -127, 50, -300, 62, -73, -15, -128, -45, -57, -9, + -55, -37, -75, -23, -49, -37, -87, -91, -59, -1, -41, -38, -30, -10, -118, + -4, -41, 16, -91, -60, -50, -20, -44, 85, -13, 88, 24, 41, 18, 109, -26, 74, + -55, 41, -9, 122, -78, 112, -7, 27, 19, 66, -141, 53, -61, 7, -73, -16, -62, + 2, -36, 70, -11, 62, 6, 50, -84, 39, -43, 34, -32, 62, -44, 51, 0, 78, 46, + 154, -5, 50, 114, 73, -50, 140, 30, 64, -69, 120, -2, 45, -31, 21, -39, 3, + -312, -19, -103, 51, -2, 37, -57, 32, -9, 19, 28, 19, 8, 70, -20, 89, -232, + 111, 68, 73, 16, 34, -39, 15, -77, 12, -112, 31, -40, 26, 2, 19, 55, 26, 31, + 83, 64, 37, 9, 31, -20, 12, -32, 0, -30, 26, -29, 3, -24, -3, -42, -28, -30, + 16, -61, -16, -15, 16, -88, -22, -95, 25, -89, 53, -73, -44, -162, -129, 21, + -14, -31, -39, -11, -47, -28, -10, -166, 1, -38, -22, -58, -18, -11, -43, + -52, -28, -140, -133, -48, -30, -62, -67, -17, -36, -334, -190, -192, -1, + -75, 52, -23, 99, -25, 30, -278, -42, -107, -8, -40, 1, -63, 21, -160, 3, + -135, -20, -53, -18, -17, -20, 17, -11, 0, -25, -72, -37, -54, -14, 2, -22, + -30, -24, -63, -7, -252, -89, -127, -1, -33, -12, -103, -106, -6, -50, -151, + -114, -110, -183, -9, -41, -68, -86, -123, -103, -10, -65, 99, -62, 23, -34, + 22, -109, 50, -102, -15, -20, -65, -5, -17, -19, -34, -195, -44, -55, -47, + -27, -55, -53, -30, 14, 2, 85, -67, 7, -27, 40, -72, 53, 21, 86, -23, 71, + 42, 136, -20, 18, -91, -43, -59, -15, -12, -85, -208, -103, -41, 0, -111, + 24, -169, 452, -96, 203, -33, 110, 3, 46, 19, 29, -36, 100, -36, 88, -81, + 76, 62, 29, 60, 14, 11, 14, -29, 16, -43, -4, -71, -23, -39, -22, -29, 15, + -13, 36, 144, 43, 108, 0, 17, 12, -75, 30, -41, -18, -77, -3, -63, 9, -18, + 41, -763, -9, 7, 101, -288, 0, -38, 13, -13, 27, -47, 38, -29, 8, -143, -2, + -251, 19, -73, 0, -195, -100, -262, -169, -114, -55, -171, -101, -130, -40, + -85, -4, -84, -55, -79, -17, -39, 18, -28, 36, 63, 69, 26, 62, -81, 120, + -80, 82, -131, 62, -45, 61, -412, 330, -89, 0, -152, -38, -24, 33, -24, 51, + 55, 116, 16, 105, 45, 147, 19, 42, 30, 30, -16, 47, -84, 47, -190, -67, -60, + -34, -395, -172, -116, -30, -146, -72, 10, -153, -40, -54, -84, -67, -104, + -106, -164, 29, -105, -87, -77, -170, -149, -44, -180, -38, -74, 50, -74, + -55, -186, -80, -83, -1, -47, 11, -218, -37, -255, -9, -449, -46, -17, 11, + 5, 29, 161, 99, 16, 50, 58, 27, 69, 51, 49, 75, 4, 32, -127, 7, -17, -19, + -61, -10, -93, -123, -201, -34, -40, 9, -15, 31, 51, 138, -22, 36, -10, 84, + -44, 42, -15, 36, -28, 19, -48, 10, -150, 5, -60, 45, -40, 17, -132, 12, + -186, 73, -126, -28, -214, -125, 180, -98, -68, -104, 3, -124, -15, -44, + -28, -24, 15, -22, 28, -12, -15, -24, 50, -37, 12, -39, 44, -27, 17, -26, + -9, -35, 17, -24, -11, -23, 49, -14, 49, -49, -145, -76, -8, -23, -337, -45, + -92, -88, -88, -129, -73, -86, -49, -30, -54, -51, 0, -94, -13, -55, -48, + -33, -45, -4, -171, 33, -195, 149, -123, 55, -69, 11, -57, -8, -41, 21, -95, + 92, -64, 37, -52, 17, -36, 30, 155, 30, -3, 68, -21, 23, 18, 112, 102, 55, + 119, 83, 129, 46, 55, 28, 26, 44, 78, 270, -36, 11, -34, 98, -221, 25, -61, + -10, -49, 6, -41, 27, -20, 35, -187, -6, -166, 53, -76, -57, -67, -15, -30, + -17, -68, -21, -76, 5, -334, 178, -197, 52, -127, 61, -55, -73, -67, -64, + -193, 138, -38, -9, -53, -45, -13, -33, 13, -40, -3, -46, 22, -100, 15, -22, + 55, 13, 72, -1, -9, -38, -93, -102, -40, -33, -63, -28, -45, -1, -65, 27, + -127, 32, -45, 53, 36, 48, 83, 68, -3, 26, -54, 27, -30, -12, -13, -24, -93, + -35, -61, 5, -61, 22, -26, 73, -23, 14, -309, -63, 3, -80, -345, -10, -336, + 111, -151, -97, -767, -1, 10, -39, -280, -5, 83, 76, -6, 53, -34, 65, 287, + 254, -4, 78, -227, 129, -331, -17, -40, 9, -44, 560, -7, 260, -19, 146, 12, + 108, -152, 1, -58, -7, -43, 7, -531, -18, -36, 10, -144, -19, -50, 12, -390, + -20, -454, -5, -61, -8, -218, -6, -39, -44, -108, -71, -224, -186, -61, -23, + -179, 74, -183, 53, -121, -4, -61, 21, -78, 7, -38, -18, -63, 20, -75, 37, + -103, 74, -98, 43, -91, 7, -89, -14, -94, -28, -88, -51, -48, -18, -135, 23, + -11, 19, 1, 64, -30, 13, -53, -17, -45, 9, -54, 48, -178, 54, 19, 74, -12, + 43, -19, -3, -4, 36, -49, 18, -18, 28, 51, 35, -8, 25, -264, 122, -138, -41, + -62, -2, -37, 11, -132, 110, -144, -1, -126, 7, -31, 8, -114, 0, -42, -10, + -258, 6, -52, 6, -60, 35, -103, 8, -18, -8, -35, -75, -55, -20, -201, -32, + -79, -24, -12, -13, 2, -56, -87, -77, -54, -12, -113, 12, -271, 10, -83, + -12, -224, 9, -2, 102, -25, 32, -174, 21, -72, 26, -41, 33, -50, 74, -113, + 76, -38, 17, -121, -24, -108, 0, -2, 93, -19, 52, 9, 193, -7, 68, -129, -71, + -167, -55, -51, -3, -186, 22, -178, -14, -137, -24, -61, 5, -95, 72, -39, 4, + -105, -36, -27, 42, -37, 5, -93, -2, -53, -67, -32, -17, -86, 34, -38, 74, + -170, -9, -27, 15, -27, -3, -26, -40, -209, -53, -74, -12, -110, 60, -26, 5, + -46, -22, -20, 19, -27, -18, -31, 15, -55, -26, -11, 21, 11, 51, -15, 61, + -36, 1, -106, -29, -40, 8, -80, 73, -57, 93, -113, 15, -106, 42, -82, 119, + -119, 14, -392, 10, -77, 114, -175, 44, -108, 19, -121, -1, -28, 7, -107, + -39, -30, 6, -50, -22, -50, 15, -17, -36, -56, 21, -31, -16, 11, -32, -22, + 1, -3, 14, -38, 1, -18, -10, -30, 0, -1, -34, -58, 5, 10, 15, -10, 9, -35, + -5, -21, -13, -9, -24, -69, -28, -54, -5, -129, 4, -150, 54, -32, -2, -30, + -12, -177, 22, -64, -2, -71, 38, -342, 5, -34, 27, -14, 44, -41, 52, 53, 18, + 10, 23, -57, 34, -72, -1, -50, 19, -174, 3, -78, 52, -93, -12, -26, -24, -1, + -81, 45, -82, 50, -13, 23, -36, 8, -32, -22, -18, -134, 28, -55, 1, -82, + -22, -279, -33, -83, -17, -75, -3, -302, 4, -853, 55, -30, 7, -6, 15, -187, + 32, -463, 114, -94, -142, -90, -42, -24, 23, -66, 36, -41, -1, -38, 19, -41, + 5, -35, -20, 3, -58, -157, -26, -31, 6, -8, -14, -39, 9, -29, -7, 4, 19, + -109, 7, -145, 5, -233, -6, -43, 36, -89, 13, -163, 0, -10, 8, -97, 6, -43, + 57, -19, 5, -42, 4, -91, -26, -68, 7, -68, 65, -159, 16, -7, 37, -108, 46, + -70, 49, -113, 18, -17, 36, -185, 41, -72, 50, -53, 3, -132, -19, -110, -57, + -74, -4, -59, 17, -72, 41, -222, 26, -112, -38, -151, -24, -90, -65, -140, + -34, -252, -22, -167, 22, -73, -19, -128, -41, -121, -62, -95, -20, -37, + -15, -345, -185, -257, -38, -8, -23, -154, -96, -158, -56, -284, -64, -46, + 74, -151, 183, -102, -58, -240, -42, -372, -109, -83, 172, -246, -13, -63, + -28, -17, -38, -59, -41, -35, -46, -35, -2, -26, -12, -197, -109, -173, -32, + -146, 38, -15, 16, -18, 3, -177, 97, -423, -38, -89, 69, -377, 31, -204, + -15, -305, -67, -63, -44, -141, -14, -163, -13, -196, 13, -402, -64, -903, + -5, -106, -32, -47, -4, -249, -147, -75, 6, -56, -81, 59, -65, -24, -38, + -34, -8, -93, -103, -124, -79, -128, -124, -39, -73, -15, -73, 114, -198, + 16, -109, 123, -179, -513, -349, -1478, -494, -203, -270, -242, -203, -435, + -155, -170, -77, -5, -50, -198, -108, -99, 20, -116, 43, -169, 15, -136, 74, + -153, 20, -243, 1, -213, -56, -52, 59, 31, 103, 89, 48, 236, 183, -457, 113, + -101, -3, -108, -21, -109, 37, -77, 14, -240, -52, -152, 2, 4, 41, -12, 27, + 10, 65, 21, 25, -8, 52, -30, 43, -42, 17, -121, -29, -156, -55, -61, 10, + -130, -23, -55, -27, -145, -42, -128, -24, -53, 4, -194, 42, -28, -43, -90, + -74, -63, -24, -155, 3, -89, -54, -54, -51, -67, -11, -48, -27, -89, -18, + -42, -24, -5, -40, 93, -35, 64, -52, 42, -2, 4, -29, 11, -7, -12, -17, 5, + -36, 50, -51, -3, -17, 27, -1, 40, 17, 138, -53, 63, -12, 34, -32, 35, -68, + -27, -61, -23, -7, 9, -17, -34, -15, -10, 11, -36, 1, -29, -24, -18, 2, -5, + -44, -35, -38, -47, -15, -28, -38, 1, -39, 182, -129, 19, -38, 102, -79, 49, + -52, 54, -125, -55, -72, -25, -4, -115, 23, -32, 32, -61, 19, -79, -11, -19, + 5, -45, -15, -82, -9, -22, 5, -59, 42, -10, -50, 19, -12, 61, -9, -4, -16, + -40, -12, -5, -12, 76, -27, 30, -29, 39, -2, 20, -9, 6, -35, 15, -12, 52, + -7, 30, 10, 54, 1, 19, 11, 15, 27, 43, 8, 36, -25, 8, -29, 59, -22, 17, -35, + -1, -47, 37, -14, 12, -24, -22, -11, -63, 14, -20, -11, -8, -35, -49, -19, + -14, -24, -43, -27, -29, -8, -31, 7, -17, -5, 0, -21, 42, -33, -22, -27, 24, + -13, -17, -34, 114, -22, 69, 13, 82, -42, 30, 9, 35, 29, 22, 2, 17, -12, -4, + -17, -34, -22, -12, -27, 44, -36, -20, -9, -52, 5, -15, -37, 21, -18, 34, + -3, 20, -11, 42, -74, 54, -3, 67, 21, 21, -14, -18, -17, -83, -33, 22, -23, + 72, -6, -7, -13, -52, -24, 7, -19, 41, -6, 16, -19, -58, -5, -59, -43, -34, + 11, -51, -13, -7, -10, 53, -20, -4, -17, -23, 0, -40, 17, -35, -14, 8, -24, + 23, -13, 80, -14, 12, -8, -9, -8, -35, -4, -4, -33, -41, 12, -17, -11, 26, + -24, -32, -44, 21, -10, 14, 22, 29, -1, -17, -57, 26, -13, 13, -25, -32, -9, + -52, 12, -26, -24, -75, 13, -28, -10, -2, -17, 40, -29, 1, -13, -24, -6, + -48, 23, -11, -13, 24, -15, -1, -17, -18, -2, -26, 16, -21, -4, -2, -48, + -16, -17, 34, -13, -41, -10, 5, -17, -11, -18, 23, -20, -61, -8, 2, -8, 34, + -11, 42, 1, -11, -28, -41, 8, -49, -4, 0, -28, -21, -26, -55, -24, -41, -35, + 10, -18, 36, 5, 18, -8, -48, -31, -2, -22, -16, -17, 41, -25, 5, -21, 27, + -4, 10, -10, -5, -29, -19, 10, -19, -7, 14, -40, 24, -4, 0, -21, 25, -3, 64, + -44, -9, -10, -45, -3, 3, -9, 16, -2, -4, -13, 18, -16, -42, -16, 2, -11, + 32, -15, -10, -27, 47, 17, 31, -21, 12, 2, 7, 23, 49, 29, 58, 0, -4, -17, + 23, -7, 53, 9, 18, 16, -4, -21, 34, 14, 8, -14, 35, 7, -8, -27, 18, 0, 18, + -20, 44, 2, 29, 22, -12, 22, 10, 6, 19, -27, -17, -18, 21, -39, 33, -17, 31, + 5, 22, -31, 34, -14, -9, -13, -30, -2, 1, -12, 50, 8, 9, -11, -19, -4, 0, + -7, 29, -18, 14, -23, 27, 22, 8, -18, 32, -2, -28, -13, 19, -12, -22, -28, + 24, 7, 1, -15, 28, -5, -2, -15, 41, -1, -32, -8, 27, -10, -29, -12, 20, -24, + 4, -50, -14, -44, 31, -21, 26, 5, -2, -33, -27, 14, -10, -13, 14, -21, 25, + 2, 14, -11, -47, -1, -1, -21, 63, -19, 10, -20, 51, -10, 14, -27, -11, -14, + 23, -2, 15, -29, -2, -36, -53, -2, -16, -9, 34, -45, -1, -32, 31, 2, -13, + 15, 13, 12, 37, -34, 5, 13, 38, -20, 63, 15, 13, -15, -18, -12, 39, -19, 5, + -48, 12, 3, -3, 18, 17, 6, 13, -10, -16, -21, 39, -1, 19, -24, 73, 4, 23, + -13, 50, -4, -27, -22, 29, -8, 3, -25, -13, -15, 27, -21, 121, -19, 46, -25, + 66, -2, 58, -60, -21, -38, 21, -45, 42, -17, 44, 0, 37, -20, -10, -9, -28, + 12, -12, -9, 6, -29, 21, -18, 28, 13, 3, -24, 16, -5, 3, 21, 40, -9, 24, 23, + 8, -30, 18, -12, 25, 20, 7, 28, 16, 1, 14, -18, -24, -18, 72, 1, 18, -8, + -20, -28, 9, -20, 18, -1, 54, 28, 34, -1, 27, -28, -13, -22, 5, -12, 27, + -12, 42, -1, -39, -27, 60, -25, -35, -31, 24, -16, -16, -48, 14, -21, -14, + -25, 11, -11, -82, -23, -8, 4, 8, 28, -13, 4, -50, -33, 2, 23, -13, 6, -18, + -29, 12, -15, -40, -24, 35, -17, -10, -41, 52, 0, -16, -15, -4, -24, 70, + -15, -43, -11, -11, -12, 19, -20, 38, -5, -16, -11, -34, 4, 46, -40, -53, + -33, 46, -31, 28, 24, 31, -10, 5, -49, 11, 2, 6, 14, 18, -1, -8, -45, 24, + -82, 35, -32, -6, -19, 37, 4, -3, 30, 57, 17, 14, -34, 36, 3, 23, -46, 40, + 22, 19, -26, 51, -1, 24, 44, 27, -8, 3, -20, 24, -10, -9, -25, 23, -21, -35, + -33, -2, -25, 34, -1, 7, -9, 1, -49, 30, -95, -23, -20, 55, 5, 7, -7, -18, + -25, -42, -10, -9, -13, 30, -21, 55, -7, -1, -24, 59, 19, 42, -4, -48, -28, + -33, -7, -36, -36, -31, -6, -8, -20, 53, -14, -30, -16, 91, -54, -8, -12, + -29, 1, -3, -25, 68, 9, 56, -26, -11, -18, -17, 15, -45, -6, -25, 11, -3, + -23, -36, 2, 3, -32, -36, 24, -30, 4, -9, -22, 37, -8, -34, -23, 14, -10, + 65, -6, 63, 6, 53, -25, 40, -6, 91, -82, 34, 2, 57, -42, 102, -125, 4, -108, + 33, -82, -24, -15, -1, -13, 22, -62, 42, -9, 14, -32, 20, 3, 2, 20, 15, 3, + 23, -25, 64, -15, 24, -18, 50, -90, -3, -31, 18, -26, 88, -29, 49, -56, 60, + -14, 31, -19, 41, -3, 99, -70, 11, -23, 42, -34, 78, -27, 41, -38, -14, -29, + 24, -27, 31, -8, 49, 18, 124, -13, 74, -15, 9, -18, 18, -8, 37, 21, 42, -50, + 47, -22, 41, -51, 101, -17, 30, 6, 32, -27, 6, -31, 29, 2, 31, 18, 35, -36, + 71, -1, 31, -15, 18, 5, 9, 21, 30, -5, 32, 8, 7, -6, -6, -53, -22, -33, 4, + -22, -87, -57, -25, -35, -77, -18, -5, -21, -62, -4, -20, -30, -68, -14, + -17, -16, -66, -5, -4, -23, -33, 5, -115, -10, -48, 13, 11, -20, -22, -5, + -25, 13, -24, -15, -71, 9, -28, 13, -11, -21, -27, 13, -50, -12, -33, 20, + -50, -4, -25, -23, -14, 0, -38, 30, 11, 7, 62, 3, -2, 8, -68, 12, -57, 1, + -4, -29, -17, -9, -36, 26, 18, 17, -7, 10, -32, 2, 3, -32, -25, 25, -44, 11, + -12, -7, 4, -23, -56, 6, -44, -5, -24, 24, -102, -17, -37, -21, -34, 0, -45, + -27, 2, -13, -23, -6, -20, -30, 26, -21, -4, -11, -28, 13, -7, -36, -23, 28, + -20, -11, -2, -36, -27, -19, 20, -34, -32, 20, -29, 3, 21, -34, -27, 4, -43, + -20, 4, -10, 19, 0, 31, -17, -21, -19, -16, -81, -17, -27, -60, -45, 30, + -34, 48, -14, 30, -39, -26, -36, -23, -9, 27, -33, 43, 7, -21, -24, 5, -13, + 12, -1, 10, 20, 11, 1, -2, -25, 66, -12, 1, -16, -18, -22, 14, -11, 38, -4, + -20, 27, 8, 5, 57, -13, 110, -7, 138, 29, 47, -13, 49, -38, 28, -5, 12, -77, + -32, -8, -15, 19, 12, 12, -7, 5, -40, 11, -26, -6, 21, -19, -38, -31, 14, + -6, 33, 9, 7, -7, -67, -22, 21, -10, 40, 11, 1, -26, 64, -11, -7, -10, -22, + -3, -29, 16, -25, -38, 23, -10, 22, 23, 6, -17, 26, -15, -28, 1, -41, -84, + 6, -4, 28, 14, 10, -4, -11, -50, -38, -30, 15, 1, 15, 15, 10, -5, -11, -54, + -19, -25, 27, 0, 29, -12, -6, 31, 20, 2, 46, -29, 141, -46, 18, -24, 57, 38, + 32, -12, 78, 29, 20, -22, 15, -1, 16, 33, 9, -16, 51, 5, 12, -28, -14, -5, + -6, -19, 41, -18, -38, -15, 38, -10, -18, -31, -64, -6, 29, -22, 2, -20, 14, + -7, -19, -9, 23, -12, 4, -14, -33, -39, -40, -83, 44, -29, -31, -6, -30, 17, + -4, -43, 21, -2, -28, -26, 11, -6, 46, 2, 23, 14, 28, -52, 7, 24, 19, -25, + 8, 6, -12, 17, 18, -2, 21, -29, 1, 19, 28, 1, -6, -29, 52, -27, -16, -11, 9, + -11, -6, -56, 19, 1, 24, 23, 14, -1, -28, -63, -26, -22, 34, -4, 14, -9, + -34, -10, 20, -22, -46, -11, -44, -47, 27, -9, 34, 12, 16, -5, -27, -14, 23, + -14, -5, -28, 15, -39, 1, 21, 51, -21, 46, 22, -70, -183, 6, -26, 56, -59, + 13, -111, -83, -216, 9, -159, -50, -105, -88, -265, -19, -21, -55, -195, + -321, -483, -35, -103, -287, -94, -83, -233, -567, 18, -568, -56, -41, 6, + -342, -28, -423, -13, -252, -38, -512, -130, -182, -87, -57, -40, -65, -91, + -55, -129, 1, -64, -49, -153, -475, -111, -78, -157, -194, -56, -306, -332, + -223, -90, -284, -136, -240, -131, -362, -124, -407, -337, -491, -248, -382, + -258, -281, -216, -55, -11, -100, -38, -16, -42, -130, -39, -38, 15, -9, -7, + 1, -135, -147, -74, -253, -244, -233, -70, -30, -164, -704, -599, -277, + -147, -74, -145, -190, -171, -233, -165, -319, -270, -378, -392, -249, -37, + -140, -274, 53, -64, -75, -14, -125, -67, -91, -33, -223, -189, -89, -61, + -37, -38, -11, -127, -117, -72, -117, -40, -213, -202, -43, -103, -259, + -156, -163, -11, -184, -169, -39, -193, -271, -220, -68, -39, -331, -310, + -97, -70, -118, -113, -101, -103, -65, -110, -35, -115, -115, -104, -224, + -88, 39, -62, 65, -154, 192, -109, 263, -24, 37, -67, 10, -75, 115, -433, + 126, -278, 70, -112, 117, -288, 26, -99, -36, -241, 78, -192, -69, -177, + -45, -516, -183, -60, -46, -55, -18, -93, 52, -53, 50, -6, 42, 6, 73, -28, + 50, 5, 19, -19, 39, -3, 3, -11, -33, -11, -40, -28, -64, -3, -7, -20, 15, + -23, -55, -32, -4, -33, 20, -20, -19, -16, -62, -10, -4, -23, -28, -23, 6, + -27, -21, -12, -19, -39, -80, -27, -9, -59, 22, -26, 51, -21, 16, -50, 28, + -3, 61, 36, -11, 56, 13, 9, 26, -6, 23, 10, 34, 44, -9, 24, 54, 1, 20, 13, + 29, -9, 41, 17, 29, -4, 25, -28, 37, 13, 1, -19, -66, -41, -6, -38, -21, -5, + -48, -35, -8, -42, -32, -39, 9, -27, 16, -6, 36, 7, 13, -20, 33, 3, 23, -36, + 42, 12, 50, -15, 29, 17, 38, -4, 70, 21, -12, -35, 118, 51, 45, 2, 31, -29, + 0, -14, -29, -23, -6, -17, 39, -28, -8, -15, 20, -22, -21, -22, 13, -24, 69, + -23, 13, -23, 35, 3, 28, -13, -2, -40, 57, 18, 33, -7, -5, -21, -36, -16, + -2, -11, 29, -17, 39, 29, 12, -35, 17, 3, 74, -38, 16, -15, -14, -9, -1, + -41, 22, -17, 14, -3, 36, 17, 9, -6, -10, -14, 62, -3, 35, 27, 51, 19, 30, + -36, 60, 12, -54, -63, 7, -23, 46, -12, 26, -24, 41, 6, 12, -19, 24, 7, 53, + -29, 39, 20, 11, -15, 17, 2, -4, 9, 37, 6, -14, -18, 8, -6, 51, 7, 25, -27, + -45, -42, -22, -42, -42, -10, -12, -12, 6, -11, 5, 14, 16, 2, 7, -28, -30, + -13, -52, -3, -78, -25, -14, -27, 29, -18, 15, 1, 9, -11, -26, -20, -2, 7, + -25, -7, -9, -10, 14, -8, -26, -13, 8, -17, 20, 2, -2, -23, 16, -6, -25, + -21, 14, -10, 2, -19, 14, 3, -9, 10, 27, -2, -11, -8, 15, -10, -5, -11, 8, + -5, 28, 7, -2, -36, 15, -8, -26, -29, -30, -13, 10, -14, -45, -59, 64, -14, + 6, -18, 15, 4, 24, 29, 16, -4, 3, -13, 19, 0, -56, -42, -13, -1, 5, -20, -9, + -12, 11, -14, 20, -4, 24, -51, 29, -11, 7, -13, 25, 0, 8, -21, 36, 3, 37, + -15, 2, -15, -34, -12, -12, -14, 0, -19, 20, -17, 2, -27, -28, -10, -62, 1, + -81, -30, -50, 2, -17, -12, -81, -9, -74, 12, -52, -8, -3, 11, -28, 14, 5, + 65, -19, 17, -30, 67, -38, 27, -21, 36, -53, -12, -41, -21, -47, 18, -64, 5, + -35, 60, -2, 35, -94, 20, -52, 29, -87, 70, -131, 53, -76, 17, -37, -14, + -17, -48, -51, -44, -30, -1, -33, 25, -47, 1, -86, 36, -79, -7, -32, 29, + -17, -2, -47, 18, -33, -13, -155, -18, -126, -100, -32, -8, -31, 5, -3, 10, + -52, 9, -34, 15, -21, 48, -63, 4, -30, -9, -83, 37, -7, 16, -38, 25, -11, + 29, -63, 19, -43, 66, -14, 6, -28, 89, -55, 45, -74, 9, -51, 29, -82, 4, + -52, 90, -153, 38, -51, -12, -18, -18, -75, -32, -17, -22, -27, -9, -1, -43, + -30, -27, -94, -15, -19, -165, -2, -328, -34, -50, -37, -21, -60, -18, 75, + -145, -144, -39, -103, -62, -69, -1, -84, 33, -50, -4, -55, -11, -47, -43, + -106, -13, -24, -16, 1, -71, -18, -78, -15, -12, -79, -22, -379, -412, -34, + -24, 14, -49, 49, -25, 3, -26, -67, -67, 3, -29, -15, -16, -2, -35, -27, + -11, 19, -20, -15, -169, -18, -11, -248, -63, -1, -38, -37, -26, -40, -11, + -32, -47, -22, -50, 7, -40, -28, -149, 7, -22, -38, -25, -2, -24, 58, -83, + 20, -12, 20, -137, -7, -42, 26, -66, -6, -11, -62, -24, -17, -19, -29, -44, + 10, -17, -38, -61, -91, -29, -64, -65, -76, -14, -46, -55, -1, -90, -29, + -64, 25, -13, 49, -2, 38, -60, 62, -14, 21, -18, -17, -15, 13, -83, -17, + -41, -31, -5, -63, -53, 30, -25, -81, -119, 1, -73, -61, -147, -75, -12, + -69, 3, -37, -7, -30, -7, -21, -17, -66, -10, -27, -21, -46, -4, -11, -32, + -106, -39, -17, -13, 2, -17, -70, -48, -134, -66, -37, -2, -2, -14, -96, + -50, -51, -9, -6, -79, -92, -118, -96, -55, -9, -17, -143, 8, -72, -39, -50, + -60, -51, -20, -94, 5, -9, -15, 8, -25, -14, -24, -20, -12, -17, 1, -124, + -149, -104, -202, -27, -403, -27, -120, -24, -22, 14, -30, -131, -571, -38, + -201, 25, -41, -23, -35, -8, -62, 12, -42, 45, -33, -5, -60, 17, -15, 113, + -4, 3, 47, 26, 24, 106, 30, 94, 2, 59, 11, 52, 21, 122, 74, 59, 14, 52, -1, + 28, -12, 115, -102, 102, -42, 39, -3, 181, 14, 64, -12, -8, -57, -78, -106, + -25, -19, -111, -21, -27, -12, -7, -14, 13, -16, 83, -31, 41, -34, 32, -57, + 108, -120, 268, -45, 35, -19, 199, -3, 1, -21, 20, -27, -26, -47, 30, -63, + -51, -102, -25, -12, 6, -22, 17, -15, 46, -15, -10, -23, 10, -13, -12, -8, + 27, -16, 0, -18, 22, -2, 9, -11, 47, -5, 2, -19, 82, -27, 6, -65, 24, -58, + -34, -53, 40, -77, -25, -34, 111, -79, 16, 8, 92, -67, 56, -92, 11, -46, 39, + -51, -18, -14, 157, -111, 15, 40, 83, 32, 17, 44, 43, 36, 190, 14, 2, -18, + 131, -22, -30, 44, 29, 15, 35, -22, 41, -1, 69, -66, 47, -4, 6, 8, 28, -2, + 10, -15, 56, 23, 41, -1, 36, 28, 3, 26, 12, -20, 55, 16, 39, 2, 74, 38, 19, + -1, 1, -6, 114, -36, -25, -25, 46, -15, -63, -56, 189, -16, 2, -45, -33, + -38, 6, -38, 102, -75, 2, -19, 58, -41, 13, -30, 55, 12, 73, -22, 137, -101, + 135, -22, 7, -56, -36, -61, -61, 3, -5, -31, 45, -24, -13, -20, 16, -8, 9, + -34, 47, -58, 32, -80, 33, -11, 49, 9, 28, -50, 32, -30, 28, 9, 60, -71, + -15, -13, -48, -2, -27, -14, -11, -23, 74, -46, -5, -19, -22, -9, -2, -90, + 75, -64, -75, -37, -23, -58, 48, 4, 79, -39, 65, -5, 41, 3, 61, 20, 0, -17, + 37, 4, 17, -8, 68, -59, 32, -17, 169, -8, 55, 6, 76, -32, 103, -5, 38, 15, + 81, 61, 123, 52, 71, 57, 91, 11, 49, 38, 93, -4, 47, 48, 96, -21, 79, 28, + 54, -4, 14, -13, 13, -50, 21, -21, 25, -17, 55, -10, 61, 18, 15, 18, -74, + 49, -3, 28, 20, 16, 61, -3, 77, -37, 56, -10, 34, 18, 28, 33, 49, -1, 32, + -12, 8, -19, -52, -84, 3, -30, 32, -12, 49, 13, 8, 49, 43, 56, 45, 7, 46, + -12, 37, -27, 36, -54, 2, -33, -37, -52, 8, -36, -9, -9, 25, -27, 40, -2, + -9, -43, 11, -20, 21, -17, 26, -3, -5, -11, -25, -7, 1, -30, 36, -10, 19, 7, + 24, 31, 41, 0, 23, -30, 60, -21, 15, -29, 30, -9, -16, -20, 21, -17, -30, + -20, 29, -7, -19, -24, 28, -11, -2, -17, 12, -8, -2, -13, -19, -8, 36, -6, + -36, -8, -3, -8, 85, -62, 39, 3, -16, 12, 11, 11, -7, 11, 14, 10, 12, -13, + 23, -5, 42, 6, 6, -16, -14, -170, -77, -98, -1, -17, -40, -22, -33, -7, 1, + -99, -75, -28, -72, -64, -34, -3, -30, -46, -82, -75, 23, -6, 10, -14, 29, + -4, -6, -18, 22, -15, -33, -16, -2, -20, 63, 14, 29, -9, 20, 4, 12, -18, 23, + 5, 6, -18, 43, 6, -14, -30, 15, 1, 15, 16, 15, -4, -15, -41, 16, 15, 43, -9, + -13, 20, 9, 9, 22, -5, 9, -15, 24, -3, 7, -13, -22, -15, 13, -21, -48, -17, + 17, -23, 31, 26, 13, -3, 1, -22, 18, 19, 18, 4, 13, -17, -14, -22, 21, -10, + 59, 33, 14, -13, 38, 16, 23, -1, 5, -39, -38, -16, 32, -2, -15, -29, 5, -14, + 49, -2, -17, -23, 61, 1, -5, 32, 30, -12, 21, 4, -2, 18, 25, 13, 3, -21, 23, + -9, 13, 5, -18, 9, 9, 20, 22, -6, -1, -21, 12, -6, 11, 28, 14, 4, -6, -18, + 24, -18, 12, 8, -11, 12, 26, -7, -13, 14, 24, 2, 9, 9, 19, -14, 41, 0, 75, + 46, 50, 4, 1, 15, 57, 17, -33, 19, -18, 32, 8, 27, -32, 75, 0, 56, -26, 74, + -25, 34, -65, 5, -58, 29, -27, 44, 20, 33, -4, 44, 54, 46, 172, -25, -2, + -14, 16, -10, 41, -22, 13, 8, 99, -53, 19, 15, -3, 25, 151, -16, -6, -31, + 97, -15, -1, -14, 53, -8, 6, 12, 33, -36, 10, -47, 94, -7, 44, -13, 28, -35, + 16, 12, 25, -13, 13, 8, -5, 8, 57, -3, 11, 29, 49, 56, 268, 31, 89, 44, 88, + 8, 35, -8, 54, -30, 71, 10, 16, 45, 48, 54, -68, 8, -8, 28, 32, 29, 41, 12, + 34, 20, 55, 56, 24, 43, -4, 19, -23, 22, -46, 15, -24, 33, 133, 96, -26, 39, + 1, 12, 25, 22, -56, 26, -6, 12, 145, 101, 20, 5, 7, 19, -10, 14, 12, 27, + -62, 26, -26, 24, 9, 10, 125, 52, 36, 36, 72, -18, 7, 18, 76, 36, 36, 115, + 20, 15, 16, 5, 22, -5, 11, 16, 86, 6, 11, 48, 17, 1, 34, -22, 22, 23, 51, + -12, 15, -17, -1, -34, 67, -43, -4, -31, -17, -24, 18, -30, 30, -13, -6, + -20, 67, -2, 28, -19, 98, 1, 29, -10, 178, -16, 64, -14, 62, 25, 44, -25, + 25, 27, 32, 62, 138, 1, 18, -6, 18, -143, 16, -16, 101, -24, 99, -99, -56, + -16, -33, -60, -50, -46, 112, -101, 0, -19, 68, -33, 81, -10, 77, 10, 107, + -27, 44, -47, 83, 16, 31, -12, 1, -21, -41, -12, -46, -101, 45, -29, 18, 1, + 66, -29, 55, 24, 283, -120, 0, -46, 10, -7, 58, 10, 61, -2, 65, -27, 56, -5, + 14, -9, 10, -44, 18, -23, 116, -55, 175, 19, 100, -74, 24, -1, 86, -60, 275, + -28, 127, -53, 61, 5, 31, 28, -17, 20, 4, 33, 72, 69, 39, 60, 57, 42, 20, + 28, 54, 194, 26, 57, 102, -110, 153, -21, 118, -58, 90, 17, 93, -10, 40, 33, + 45, 16, 73, -25, 133, 27, 17, -6, 4, -40, 14, -9, 52, 7, 53, -17, 72, 4, 33, + -5, 37, -22, 30, -81, 137, -41, 56, -42, 39, 4, 45, 24, 134, 10, 161, 46, + 38, 20, 23, -4, 44, -26, 42, 35, 28, 10, 107, -30, 53, 22, 66, 11, 83, 27, + 64, -67, 70, 33, 88, -3, 75, -24, 69, -50, 40, -13, 55, 6, 111, -23, 108, + -10, 37, -72, 36, -18, 42, -53, 54, -22, -17, -40, 47, -18, 14, -14, 12, + -55, -13, -39, 54, -86, 27, -18, 35, -3, 40, -43, 83, -34, 34, 8, 53, 37, + 52, 52, 49, 23, 83, -11, 113, 32, 85, 7, 99, -9, 97, 32, 87, -14, 64, 47, + 23, 37, 1, 48, 15, 20, 69, 6, 104, -8, 6, 31, 15, 12, 74, 13, 21, -4, 19, + -15, -20, -44, 15, -20, 57, 16, 112, 5, 69, 48, 33, 11, 175, -104, 76, -83, + 36, 2, 44, 20, 99, 20, 41, -9, 38, 11, 52, -2, 70, -57, 100, -31, 139, -69, + 36, -6, 14, -15, 0, -45, 16, -34, 76, -86, 4, -18, 37, -25, 178, 12, 79, + -37, 46, 2, 85, 46, 51, -3, 50, -19, 39, 53, 37, -16, 84, -18, 39, -19, 140, + 21, 218, -141, 4, -52, 107, -1, 119, 27, 146, 60, 105, 11, 78, -12, 58, 1, + 95, 36, 97, 16, 59, 58, 46, 7, 73, 51, 34, -1, 57, 31, 30, -22, 1, -17, 64, + -43, 44, 4, 64, -37, 39, 7, 93, 38, 166, 51, 67, 24, -12, 72, 26, 62, 33, + 12, 19, 32, 29, 22, 13, 19, -2, 44, 17, 14, 37, 17, 36, -1, 56, 32, 43, 12, + 47, -6, 25, 9, 41, 37, 59, 35, 77, 27, 114, -64, 168, -21, 79, 13, 149, -67, + 88, -11, 60, -31, 57, -5, 48, 8, 148, 90, 39, -10, 59, 12, 100, -14, 33, + -17, 23, -2, 35, 27, 4, 22, 39, 35, 28, 15, 66, 7, 100, -20, 17, -9, 26, + -56, 85, -41, 68, -89, 79, 17, 36, 24, 140, -4, 21, -115, 143, -174, 101, + -33, 9, -82, 60, -21, 145, -120, -66, -191, 14, -60, 188, -151, 181, -60, 9, + -113, 224, 17, 69, 24, 185, -137, 38, 58, 67, 34, 125, -12, 83, -142, 56, + 14, 146, -26, 38, 12, 192, -28, 4, -24, -50, -53, 9, -17, -21, -48, 146, + -132, 184, -222, 3, -53, 12, -12, 216, -117, -7, -43, 31, -24, -7, -24, 18, + -49, 46, -50, 3, -19, -13, -16, 8, -58, 356, 15, 256, 31, 355, 2, 67, -12, + 27, -27, 33, -5, 61, 20, 18, 36, 43, 36, 50, -9, 42, -24, 86, 6, 47, -32, + 101, -30, 18, 17, 13, 47, 103, 116, 10, 27, 84, -55, 22, 0, 117, 50, 58, -4, + 70, 40, 80, 1, 57, 37, 100, 15, 33, 23, -10, 48, 17, 76, 76, 51, 97, 82, 68, + 32, 73, 65, 44, -27, 70, 1, 54, 12, 53, 57, -5, 78, -39, 44, 109, 53, -7, + 17, 30, 19, 4, 82, 41, 32, 45, 7, 34, 30, 39, 12, 23, 35, 38, 23, -20, 67, + 49, 64, 63, 32, 101, 29, 12, 106, 23, 33, -23, 47, 1, 21, 64, 124, 28, 18, + 68, 22, 36, -7, 19, -16, 81, 14, 42, -25, 33, 2, 45, 16, 56, -19, 413, 219, + 155, -8, 141, 6, 61, 17, 1484, 164, 380, 28, 13, 9, 82, -5, 496, 30, 334, + 102, 161, 8, 17, 6, 70, -5, 24, -15, 38, 12, 57, -4, 106, -29, 127, -18, 29, + 20, 33, -20, 17, 13, 49, 3, 102, -41, 94, -5, 26, 34, 44, -6, 85, 19, 49, + -42, 365, -22, 260, 79, 174, 67, 80, 98, 114, 34, 126, 19, 108, 13, 188, + -72, 120, -20, 157, 35, 73, 128, 256, -17, 40, -28, 76, -21, 274, 49, 814, + 123, 393, -97, 175, 38, 88, 55, 40, -5, 65, -30, 65, 18, 260, 45, 148, 42, + 97, 74, 49, 56, 90, 213, 43, 219, 18, 17, 179, 89, 363, 158, 46, 44, 27, 47, + 22, 88, 47, 56, 20, 62, 4, 62, 26, 53, 107, 152, 188, 149, 116, 42, 39, 36, + 29, 53, -11, 49, -59, 96, 10, 39, 36, 20, 5, 17, 113, -18, 49, 15, 1, 29, + -26, 29, 11, 18, 47, -7, 14, -12, 6, -29, 28, -16, 23, 3, 85, 49, 35, 76, + 29, 9, 33, -3, 79, -32, -7, -23, -61, -17, -5, -11, 15, -19, 25, -8, 45, -4, + 61, 29, 53, -12, 42, 5, 152, 85, 33, 35, 22, -3, 23, -22, 47, -20, 26, -32, + 34, 2, 30, 22, 2, 14, -15, 14, -48, 9, -9, 9, 29, 16, 3, 24, 115, -7, 104, + 29, 24, 11, 11, 17, -6, 15, -33, 22, 4, 10, 109, 22, 31, 33, 64, -42, 28, + -8, 26, 0, 55, 33, 47, 3, 57, -8, 99, -30, 27, -28, 24, -7, 44, 6, 40, 20, + 26, -2, 21, -39, 14, 0, 91, 49, 59, 0, 64, 20, 1, 20, -34, 49, 27, 20, 36, + -2, 32, -21, 48, -16, 108, -20, 1, 56, -36, 52, 5, 23, 21, 18, 3, 42, 57, + 47, 52, 2, 37, 33, 29, -7, 28, -40, 56, -40, 147, 25, 64, -16, 10, 4, -28, + 67, -32, 38, 9, 11, 35, 5, 73, -50, 28, 5, 21, 46, 0, 84, -139, 146, 251, + 156, 83, 83, -8, 4, 13, 23, -19, 40, 275, 123, 1, 77, 200, 24, -51, 177, + -107, 140, 113, 45, 117, -138, 55, -38, 211, -201, 105, -123, 176, 93, 91, + -203, 165, -52, 113, -61, 188, 27, 38, 13, 90, 86, -27, 78, 77, 19, 0, 11, + -31, 9, -41, 45, -5, 56, 35, 7, -3, 110, -19, 71, -163, 73, -106, -59, -19, + -24, -26, 2, -185, 85, -98, 16, -43, 70, -74, 49, -136, 139, -307, 259, 71, + 37, 91, 27, 289, -124, 72, 54, 31, 36, 36, -11, 68, 112, 131, 9, 314, -24, + 318, -37, 217, 62, 93, -38, 89, -76, 108, -112, -32, -20, -42, -113, 371, + -202, 19, 10, -26, 73, 642, -121, 53, 54, 18, -9, -11, 134, 9, 18, 18, 7, + 109, 22, 57, -6, 33, 13, 34, -1, 187, -84, 9, 21, 7, 29, -28, 248, -46, 66, + -75, 43, -63, 30, -31, -4, -85, 31, -11, -3, -14, 26, 22, 21, 155, 20, 42, + -29, 32, -7, 141, 19, 109, -41, 19, 7, 14, 73, 39, 15, -2, 16, 156, 23, 23, + -215, 197, -166, 167, -174, 155, -47, 175, 37, 36, 24, 10, -17, 68, 19, 243, + 141, 80, -59, 74, 7, 53, 14, 226, -183, 54, 20, 89, 6, 138, 49, 227, -88, + 107, 378, 170, 134, -26, 58, 41, 50, 41, 101, 176, -26, 13, 13, -33, 17, 39, + 40, 126, -74, 17, -1, -10, 32, -22, 12, -5, 18, 35, 24, 117, -71, 82, -3, + 22, -84, -10, -82, 26, -84, 38, -65, 42, -12, 252, 32, 55, 14, 94, -30, 164, + -14, 56, 5, 152, 241, 30, 135, 82, 51, 181, 60, 121, -31, 184, 79, 17, 2, + 28, -17, -4, 28, 40, 34, 223, 98, 86, 54, 81, 25, 120, 53, 80, 10, 94, -7, + 36, -18, 27, -38, -107, -96, 13, -27, -30, -47, -34, -30, 23, -43, 38, -20, + 152, -48, 216, -90, 116, -37, 321, -19, 65, 8, 167, 65, 241, 66, 194, 37, + 61, 5, 79, -13, 168, -64, 131, -37, 112, -82, 118, -60, 64, -57, 46, -72, + 61, -23, 100, -18, 91, -6, 98, -27, 182, -75, 75, -126, -27, -42, 6, -87, + 32, -61, 60, -59, 60, -54, 102, -69, 56, -24, 60, -42, 236, -109, 149, -92, + 98, -31, 73, -6, 433, 20, 270, -4, 171, -28, 116, -39, 100, -70, 50, -50, + 79, -136, -49, -216, -75, -160, -13, -55, -4, -45, 12, -46, 19, -38, 40, + -25, 163, -43, 34, 3, 227, 99, 77, 49, 25, 26, 17, 105, 38, 35, 30, 4, 107, + -13, 195, -47, 47, -17, 44, -41, -2, -23, -34, -49, -12, -40, 2, -69, 39, + -169, 23, -32, 43, -30, 103, -45, 53, -7, 110, 14, 124, 40, 435, 187, 97, + 22, 182, 102, 57, 2, 74, -66, 37, -17, 34, 1, 61, 34, 9, 28, 127, 70, 50, 9, + 81, -24, 32, -21, 75, -108, 29, -148, -15, -100, -65, -132, -1, -22, -68, + -81, -79, -58, -322, -128, -73, -46, -17, -24, 15, -88, -22, -56, 16, -43, + 73, -40, 84, -27, 174, 10, 120, 28, 47, 22, 77, 106, 29, 43, 9, 48, 37, 65, + 116, 101, 77, 14, 195, 7, 57, -10, 123, -38, 50, -39, 21, -54, 6, -98, 36, + -91, -10, -27, -122, -73, -31, -29, -21, -63, 4, -28, 20, -28, 102, -87, 40, + -140, 81, -77, 39, -64, 39, -36, 97, -48, 177, -37, 88, -46, 94, -2, 16, 15, + -5, 20, -69, 36, -21, 46, 55, 105, -16, 88, 7, 148, 29, 36, -62, 35, 76, 29, + -10, 61, 185, 98, 32, 9, -179, 127, 41, 9, 62, -4, 127, 8, 37, 8, 1, 22, 44, + 25, 9, 30, 41, 30, 38, 15, 18, 41, 43, 16, 20, -7, 60, 11, 5, 9, 67, 29, 30, + 0, 30, 10, 8, 18, 60, 44, 11, -11, -19, -17, 13, -18, -19, -9, 2, -24, -23, + 11, -32, -15, 88, -75, 125, 67, 195, -162, 32, 25, 66, -17, 92, -38, 43, -2, + 5, 9, 138, -29, -15, -86, 346, 35, -45, -40, 27, -82, 75, -100, 143, 30, 12, + 11, 32, 9, 38, -1, 115, -34, 139, 72, 189, 29, 173, -114, 228, 15, 1, 33, + 50, 4, 67, 45, 74, -102, 196, 45, 38, -49, 38, 16, 62, -101, 80, -72, 22, + -53, -76, -191, 9, -51, -109, -2, -65, -57, 34, -15, 58, 37, 71, -45, -59, + -48, 82, -48, -49, -132, -77, -128, 165, -79, 9, -72, 35, 12, 39, 1, 46, + -20, -10, -11, 153, -77, 42, 30, 30, 7, 88, -20, 28, 6, -9, -24, -61, -6, + -48, 22, -59, -37, 31, -20, -23, -23, -73, -30, 48, -54, 433, -353, 261, + 130, 97, 61, 167, -78, 162, -101, 58, 9, 14, -41, 108, -18, 126, -101, 75, + -36, 42, 53, 154, -52, 216, 12, 59, 19, 71, 9, -22, 127, -33, 60, -27, 21, + 7, 16, -13, 12, 15, 11, 27, 2, 52, -35, 36, 2, 36, -53, 7, -32, 28, 3, -16, + 132, -40, -2, -48, 36, -24, -12, -12, 18, 57, 40, 21, -11, 10, 29, 31, 1, + 17, -10, 43, 7, 2, -12, 68, 24, 220, -17, -12, 41, -29, 18, -29, 1, 9, 53, + 504, 1, 52, -296, 30, 7, 18, -4, 73, -444, 80, -357, 126, 14, -1, 26, 20, + 17, 11, 60, 47, -20, 26, -30, -12, -20, -53, -6, -12, -25, 558, 43, 86, -8, + 11, -11, 0, -10, -35, -9, -9, -33, -30, -35, -49, -9, -19, -36, -240, -79, + -71, -48, -6, -36, -59, -12, -22, -13, -61, -80, -54, -50, -10, -28, -43, + -7, -32, -31, -89, -170, 1, -13, 208, -201, 134, -10, 144, 135, 290, -72, 3, + -16, -278, -508, -334, -126, -108, -196, 183, -215, -23, -10, 107, -128, + -33, -13, -83, -5, 1, -239, -98, -1, -4, -157, 246, 3, -108, -76, -40, -80, + -53, -12, -43, 28, 81, -163, -170, -99, 28, -44, -41, -42, -73, -19, -14, + -29, -47, -32, 27, -22, -1, -21, -37, -33, 8, -40, -10, -22, -137, -44, -43, + 36, -498, -167, 346, -479, 335, 122, 47, -48, -48, -27, 6, -61, -31, -93, 4, + -27, 78, -34, 17, -24, -22, -37, -32, -9, -33, -37, 0, -12, 39, -27, -299, + -160, 46, -65, 234, 127, 50, 17, -17, -40, 41, -36, -108, -104, 61, -102, + -49, -29, 20, -28, -2, -20, 58, -61, 513, 369, 15, -467, 75, 8, 23, 24, -2, + 41, 44, 35, 16, -1, 25, 13, 107, 74, 45, 16, 121, 6, 16, 31, 137, 28, 42, + 39, 88, 25, 201, -786, 240, 28, -3, 22, 294, 42, 73, -262, -143, -85, 147, + -446, 253, 43, 33, -127, 192, 25, -11, 52, 406, 61, -15, 151, 18, 13, -23, + 41, -6, 60, 82, 5, 21, 48, -50, 11, -11, 10, 34, 82, -20, 17, -23, 3, 13, + 59, 61, 0, 39, 26, 199, 61, 566, 58, 75, -288, 170, -842, -466, -60, 278, + -1156, -744, -74, -101, 35, -7, -4, 46, -223, 163, 34, 144, -307, 2, -65, + 195, 5, -21, 178, 643, 65, 16, -41, 152, -514, -262, -31, -67, -780, -384, + -55, 143, -176, 680, 147, 82, -358, 526, 55, 3, -10, 91, 8, -10, 36, 92, 38, + 31, -138, 98, 10, 184, -583, 997, 144, -23, 66, 373, 41, 80, -325, -85, -20, + 18, -38, 206, -274, 224, 77, -29, 67, 266, 77, 21, -30, 46, -20, 26, -27, + 72, -30, 36, -79, 73, -41, -8, -35, -158, 20, -277, -125, 75, -80, -8, -17, + 157, -139, 62, -72, 23, -36, 1, -157, 79, -18, -4, -15, 10, -3, -67, -217, + 3, -102, -21, -109, 53, -230, -53, -46, -69, -42, -11, -33, 1, -77, -29, + -72, -38, -24, -17, -33, -32, -28, -8, -23, 9, -43, 76, -97, -10, -133, 65, + -148, -23, -123, -34, -43, -74, -25, -12, -20, -56, 17, -20, -40, -25, -13, + -3, -16, 37, -117, 53, -7, 35, 15, 11, -8, -24, -29, -40, 0, -5, -41, -19, + -19, 18, -5, 57, 22, 12, -3, -33, -28, -4, -25, -24, -9, -507, -66, -60, 0, + -19, -12, -16, 10, -65, -12, 29, -45, 16, -3, 48, 49, 19, 8, 13, -5, 52, + -75, 89, -71, -6, -9, -65, -12, 5, -31, 22, -14, 154, -43, -22, -27, -61, + -20, -2, -12, 13, -14, 51, -16, 165, 2, 0, -24, -47, -57, 7, -13, 97, -27, + 86, 15, 16, -5, -7, -18, -80, -41, 21, -21, 72, 7, 74, 31, 47, 44, 15, 74, + 35, 6, 35, -23, -3, -27, -69, -71, -49, -75, 22, -6, 57, 8, 60, 38, 41, 72, + 36, 1, 72, -29, 11, 4, 7, 97, 14, 16, 19, -3, 56, -92, -9, -20, -40, -32, 2, + -19, 39, 0, 78, 28, 17, -9, -21, -23, -76, -50, -15, -21, 0, -53, 19, -32, + 23, -16, 3, -20, 35, -27, 33, -10, 160, 12, 41, -7, 20, -23, -4, -21, -17, + -14, -244, -7, -25, -7, -2, -19, 88, -63, 53, -22, 93, 17, 76, -15, 14, -18, + -44, -72, 3, -26, 109, -2, 122, 18, 188, -9, 8, -12, -14, -15, -85, -51, 18, + -77, 20, -10, 119, -4, 75, -20, 13, 10, -4, 81, 32, 19, 25, -1, 28, -15, 46, + -76, 30, 0, 21, 10, 9, 23, -11, 31, -75, 65, 7, 11, 26, 6, 26, -2, 49, -36, + 48, -88, 19, 0, 29, 36, 22, 2, 62, -23, 58, -35, 108, -83, 3, -16, -53, -17, + -3, -9, 13, -14, 31, -8, 54, 6, 63, 28, 22, 26, 11, 94, 12, 20, 53, 9, 33, + -11, 15, -15, 7, -113, 22, -21, -169, -77, -396, -142, -104, -162, -55, 79, + -211, 140, -119, 15, -362, -156, -249, 67, -90, -54, -458, 142, -36, 20, + -520, -290, 46, -194, -861, 334, 178, 103, -254, 206, -36, -22, -71, 57, 30, + 19, -135, 329, -86, -17, -185, 174, -63, 20, -5, 10, -157, -66, 155, -232, + -112, -35, 35, -51, -487, -89, -4, 59, -40, -7, -27, 103, -185, -58, 164, + -634, 10, -112, -745, -74, -28, 91, -199, -21, -142, 557, -593, 66, -91, 62, + -34, -13, -56, 94, -41, 100, 171, 143, -533, 369, -617, -520, 124, -205, + -31, -9, -196, 329, 23, 15, -274, 447, -198, -83, 63, -138, -50, -34, 39, + -48, -96, -37, -13, 6, -181, -60, 30, -24, 12, -51, 66, -97, -123, -33, 180, + -295, -211, -64, -66, 87, -326, -110, 43, -42, -56, -27, 51, -54, -270, + -178, -330, 86, -12, 19, 344, 263, 56, 33, -27, 40, -85, 44, -76, 74, 39, + 18, -20, 20, -3, 226, -100, 54, 357, 217, -58, 146, -86, -10, -50, 593, + -739, -139, 21, -292, -1009, -28, 3, -56, -246, -7, 4, -125, -58, -5, 2, + -25, -522, -17, -35, -77, 91, -47, 4, 25, 362, 9, 20, -177, -238, -21, -710, + -27, 67, -63, 42, -203, 252, -551, 202, -329, 16, 8, 1, 18, 19, 2, 63, -180, + 7, -72, 59, -29, 1, -10, -65, -9, -118, 27, -319, -532, -11, -57, 1152, + -639, -1168, -1019, 548, -443, -264, -459, -2026, -328, 32, -102, -230, -33, + 69, -226, -17, -53, -73, 0, 2, -18, 313, -950, -892, -146, 125, -380, -849, + -137, -327, 59, -62, 6, -49, -20, 0, -20, 37, -38, 64, -102, 37, -139, 3, + -45, -60, -70, -247, -32, -32, 54, -48, 50, -174, -21, -44, -14, -34, 6, + -72, 39, -33, -2, -56, -110, -41, -153, -68, -122, -139, -108, -57, -30, + -24, -30, -12, -38, 10, -58, 44, -119, 297, 48, 284, -855, -530, -87, 250, + -712, -829, -137, -119, -6, 2, 275, -1166, 339, -472, -296, -138, -143, + -323, -364, 101, -5, 387, -99, 73, -29, 129, -93, 60, -107, 2, -75, -28, + -136, 12, -127, 64, -92, 106, -114, 126, -177, 50, -97, 116, -154, 43, -55, + 62, -57, 70, -46, 208, -62, 158, -60, 608, -188, 75, -12, 129, -37, 289, + -94, 158, -65, 202, -59, 155, -73, 140, -53, 175, -29, 532, -28, 106, -14, + 268, -63, 272, -106, 94, -22, 80, -3, 161, 38, 143, 58, 55, 13, 134, 19, + 100, 1, 80, 11, 73, 21, 41, 38, -10, 105, -63, 49, -51, 16, -65, -2, -75, + 23, -26, 16, -93, 115, -24, 56, 1, 28, 26, 38, 48, 24, 55, 10, 182, -5, 103, + 23, 99, 100, 21, 36, -1, 48, -55, 35, -175, 59, -18, 21, 11, 43, 146, 74, + 94, 18, 109, -2, 64, -24, 120, -105, 110, -61, 37, -12, 43, -3, 135, 16, 68, + -13, 140, -108, 265, -179, 92, -41, 77, -23, 103, -5, 54, 17, 49, 41, 23, + 51, 2, 54, -60, 112, 4, 32, 81, 119, 87, 79, 40, 12, 116, -2, 72, 13, 77, + 34, 121, 78, 216, 80, 117, 73, 23, 7, 77, -11, 62, 5, 74, 39, 80, 27, 93, + 68, 61, 12, 157, -1, 56, 18, 107, 58, 189, 34, 277, 89, 116, 57, 83, 118, + 160, 50, 72, 7, 139, 52, 138, 100, 65, 10, 64, -2, 70, -9, 92, -39, 57, -14, + 117, -8, 60, 10, 83, 29, 45, 27, 141, 124, 59, 26, 32, 3, 47, -2, 45, -13, + 120, -79, 156, -28, 184, 33, 95, -34, 29, -2, 72, 12, 133, 44, 91, 13, 58, + -2, 41, -24, 66, -64, 216, -55, 120, -51, 65, -16, 173, -19, 472, -98, 221, + -89, 248, -125, 139, -114, 255, -237, 196, -138, 58, -66, 84, -184, 45, -71, + 12, -144, 51, -126, 27, -123, -80, -34, -71, -51, -38, -96, 25, -112, 63, + -101, 30, -90, 63, -69, 60, -33, 53, -60, 8, -26, -16, -46, 23, -47, 2615, + -220, 51, -96, 147, -205, 237, -252, 267, -169, 375, -171, 337, -118, 355, + -85, 368, -52, 375, -17, 374, 17, 369, 52, 355, 85, 337, 118, 312, 147, 282, + 174, 248, 200, 208, 220, 166, 237, 121, 251, 73, 259, 25, 264, -25, 263, + -73, 259, -121, 250, -166, 237, -208, 219, -248, 198, -282, 174, -312, 147, + -337, 116, -355, 85, -369, 51, -374, 17, -375, -17, -368, -51, -355, -85, + -205, -71, 17, 272, -11, 140, -25, 138, -48, 161, 302, 157, 286, 196, 341, + 111, 172, 72, 319, 166, 285, 194, 245, 222, 256, 117, 155, 87, 285, 194, + 136, 116, 296, 115, 164, 79, 303, 180, 137, 101, 244, 220, 106, 117, 151, + 214, 194, 119, 137, 101, 244, 219, 199, 240, 103, 167, 176, 110, 138, 100, + 244, 219, 198, 240, 82, 126, 115, 241, 344, 111, 172, 70, 164, 79, 302, 179, + 138, 100, 244, 219, 105, 117, 132, 181, 224, 103, 156, 86, 147, 94, 265, + 206, 222, 229, 175, 247, 68, 130, 99, 267, 29, 136, 16, 173, 217, 195, 105, + 117, 175, 247, 124, 261, 72, 270, 17, 186, 86, 167, 293, 49, 371, 98, 178, + 62, 275, 119, 211, -33, 197, -20, 374, -12, -37, -160, -18, -275, 36, -273, + 42, -144, -10, -204, 11, -138, 25, -136, 90, -268, 142, -258, 192, -241, + 113, -114, 258, -209, 219, -139, 220, -204, 278, -197, 153, -88, 330, -154, + 358, -121, 187, -48, 191, -39, 215, -32, 234, -90, 369, -104, 386, -69, 197, + -20, 248, -11, 260, -71, 386, -68, 197, -20, 391, -13, 226, -60, 192, -38, + 194, -30, 277, -25, 182, -68, 369, -104, 191, -39, 318, -42, 254, -99, 369, + -104, 386, -69, 197, -20, 239, -11, 219, -71, 378, -86, 194, -30, 255, -23, + 273, -108, 368, -104, 386, -69, 272, -24, 315, -106, 378, -87, 391, -50, + 199, -11, 221, 0, 321, -90, 191, -39, 391, -50, 199, -11, 245, 1, 324, -91, + 191, -39, 194, -29, 392, -32, -183, -99, -147, -94, -138, -100, -233, -209, + -196, -68, -171, -71, -320, -165, -284, -194, -164, -143, -206, -59, -349, + -134, -320, -165, -237, -160, -236, -42, -188, -45, -361, -117, -172, -71, + -319, -166, -147, -94, -265, -208, -117, -113, -199, -241, -132, -224, -133, + -127, -105, -118, -175, -251, -124, -265, -72, -275, -16, -139, 9, -280, 64, + -276, 82, -198, 29, -192, 90, -272, 142, -262, 90, -125, 215, -236, 258, + -214, 297, -186, 330, -157, 358, -123, 187, -49, 386, -69, 323, -28, 286, + -114, 182, -57, 378, -88, 391, -51, 199, -11, 398, 6, 198, 18, 195, 27, 381, + 82, 318, 103, 356, 96, 155, 55, 114, -167, 102, -121, 237, -226, 134, -104, + 297, -187, 253, -122, 200, -163, 144, -97, 315, -172, 345, -141, 181, -57, + 243, -60, 286, -135, 394, -143, 182, -58, 378, -88, 195, -30, 395, -32, 242, + 0, -98, -130, -150, -261, -56, -135, -57, -205, -80, -182, -42, -138, -45, + -280, -2, -141, 11, -142, 30, -158, -208, -85, -320, -168, -284, -198, -128, + -109, -222, -235, -93, -125, -150, -262, -56, -136, -72, -279, -16, -141, + -2, -142, 36, -283, 90, -276, 65, -134, 167, -258, 102, -122, 237, -228, + 134, -105, 144, -99, 200, -115, 97, -139, 215, -240, 258, -216, 144, -99, + 314, -174, 345, -142, 369, -108, 374, -68, 262, -191, 153, -91, 330, -159, + 176, -67, 182, -58, 346, -83, 169, -75, 358, -126, 596, -147, 385, -70, 396, + -32, 398, 6, 393, 46, 220, 43, 353, -63, 197, -21, 397, -13, 397, 26, 388, + 65, 371, 101, 257, 98, 292, -6, 199, 8, 198, 18, 387, 64, 372, 102, 178, 64, + 335, 154, 303, 185, 138, 103, 222, 204, 284, 96, 335, 154, 156, 89, 253, + 175, 197, 70, 171, 73, 164, 80, 303, 185, 265, 212, 222, 236, 175, 256, 68, + 133, 77, 203, 229, 212, 105, 121, 106, 145, 182, 76, 164, 80, 156, 89, 195, + 131, 218, 40, 188, 46, 321, 105, 240, -16, 398, 7, 197, 18, 372, 61, 342, + -119, 378, -89, 195, -30, 395, -32, 200, -2, 396, 26, 195, 28, 381, 82, 184, + 55, 349, 137, 280, 146, 265, 106, 320, 169, 147, 95, 265, 211, 222, 235, + 170, 246, 140, 70, 319, 38, 192, 37, 372, 101, 178, 63, 335, 153, 156, 88, + 284, 197, 244, 223, 126, 148, 221, -136, 161, -82, 345, -141, 182, -58, 378, + -88, 194, -31, 396, -32, 398, 7, 393, 45, 251, 51, 303, -7, 198, 8, 393, 45, + 381, 82, 184, 55, 328, 127, 345, -79, 391, -51, 199, -12, 398, 7, 197, 18, + 388, 63, 372, 101, 312, 120, 326, 7, 198, 18, 195, 27, 381, 83, 184, 55, + 349, 135, 320, 168, 284, 197, 127, 109, 103, 105, -114, 278, -303, 822, + -251, 833, -91, 412, -33, 240, 237, 224, 317, 226, 83, 83, 33, 311, 55, 258, + 605, 505, 181, 967, -132, 1700, -852, 86, -2445, 363, -2033, -238, -319, + -93, -311, -128, -268, -2, -172, -12, -170, -20, -296, -55, -283, 114, -158, + 49, -327, 77, -168, 28, -171, 19, -344, 15, -344, -16, -171, -20, -333, -63, + -162, -42, -309, -108, -287, -134, -259, -160, -227, -182, -100, -98, -172, + -210, -132, -223, -50, -116, -56, -193, -17, -9, -151, 154, -110, 94, -244, + 170, -275, 147, -146, 64, -225, 79, -42, 128, -111, 228, -154, 217, -91, + 102, -101, 98, -228, 181, -260, 159, -237, 112, -75, 187, -61, 113, -71, + 109, -173, 209, -101, 98, -228, 180, -127, 83, -203, 110, -71, 257, -111, + 228, -154, 215, -91, 102, -168, 154, -78, 196, -61, 112, -90, 134, 243, 14, + 171, 20, 168, 27, 327, 78, 157, 50, 298, 120, 141, 70, 259, 159, 227, 180, + 191, 200, 152, 215, 68, 128, 201, 142, 210, 190, 91, 102, 152, 215, 114, + 240, 139, 101, 210, 190, 172, 207, 106, 174, 242, 190, 100, 97, 170, 204, + 218, 65, 152, 57, 287, 133, 133, 76, 244, 169, 210, 189, 141, 166, 315, 91, + 152, 57, 287, 132, 259, 158, 227, 179, 124, -161, 106, -112, 244, -207, 137, + -94, 300, -168, 162, -73, 343, -123, 365, -89, 188, -31, 383, -36, 386, 0, + 192, 14, 379, 54, 184, 41, 354, 106, 330, 139, 300, 169, 137, 94, 243, 207, + 106, 112, 178, 237, 214, 82, 287, 132, 259, 157, 227, 179, 100, 97, 172, + 206, 71, 109, 73, 141, 234, 30, 235, 45, 165, -146, 119, -87, 126, -81, 274, + -144, 300, -118, 157, -49, 327, -76, 339, -46, 344, -15, 345, 16, 338, 46, + 327, 77, 309, 106, 287, 132, 133, 76, 244, 168, 210, 189, 172, 206, 71, 108, + 110, 225, 39, 115, 28, 118, 21, 212, 144, 71, 259, 157, 227, 178, 107, 104, + 355, 81, 277, 89, 76, 35, 272, -91, 327, -75, 339, -46, 172, -12, 173, -3, + 344, 16, 339, 46, 326, 77, 158, 49, 239, 93, 1825, 1141, 5208, 311, 1972, + 1198, -1083, 1385, -13, 33, 6, 46, 27, 26, 44, 98, -3, 590, -3, 43, -16, 9, + 32, 45, 38, 17, 8, 23, -9, 16, 19, 39, -25, 9, 0, 30, -16, 4, -1, 13, -21, + 9, -1, 11, -25, 4, -10, 32, 17, 19, 19, 0, 21, 17, 38, -1, 40, 15, 42, -6, + 37, 5, 60, 44, 279, 2, -2, 183, 365, 2, 71, 66, 0, 10, -25, 14, 2, 10, 42, + 22, -27, 67, -43, 12, 6, 27, -35, 6, -8, 11, 26, 30, 27, 9, 16, 26, -4, 12, + 38, 35, 4, 39, -65, 12, 0, 17, 76, 32, 14, 36, -1151, -9, 2, 217, 59, 31, + -15, 20, -30, 9, 32, 34, 47, 21, 15, 57, 74, 18, 69, 46, -6, 15, 9, 20, 28, + 26, -3, 20, -19, 13, 3, 16, -24, 20, 123, 95, 292, 2, 0, 180, 141, 1, 18, + 13, -4, 822, -65, -3, -2, 12, -69, 2, -24, 74, 25, 53, 35, 17, -20, 85, 25, + 12, 13, 20, -4, 24, -18, 12, 18, 23, -81, 3, -4, 6, -2, 353, 204, 5, 22, 17, + -12, 18, 19, 7, 33, 46, -5, 225, 399, 1, -2, 404, 1063, 5, -3, 269, 1599, 6, + 97, 89, 36, -1, -4, 160, 367, -1, -3, 232, -10, 46, 16, 8, -5, 8, 616, 1, + 75, 8, 12, -15, -6, -35, -47, -21, -9, -13, 42, -38, 36, 3, 20, -26, -4, + -13, -37, -12, -3, -19, 13, -6, 29, 10, 10, -9, 27, 5, 17, 16, 3, 29, 17, + 12, 39, -19, 27, 20, 16, -12, 175, 2, 0, 282, -11, 7, -7, 31, 22, 3, 1, 15, + -24, 7, -5, 11, 22, 22, 10, 56, 17, 0, 13, 19, -6, 28, 17, 21, 23, 15, 92, + 3, 1, 311, 42, 1, -8, 36, 40, 14, -5, 28, -31, 1, -12, 13, -27, 4, 15, 36, + 26, 25, -66, 2, -26, 34, 1, 37, 65, 47, 2, 30, -16, 48, -2, 117, 399, 0, 0, + 74, 27, 13, 9, 21, -20, 17, -15, -5, -4, 40, -32, 35, 12, 33, -18, 15, 15, + 55, 13, 25, 23, 1, 0, 16, 63, -2, 23, 17, 42, 0, 4, 139, 213, 3, 133, 15, + 21, 56, -19, 30, 12, 3, 89, -21, 27, 4, 12, -13, 20, 14, 45, -1, 9, -14, 43, + -5, 56, 5, 33, 27, 22, -13, 128, -12, 140, 2, 142, -39, 300, 5, 975, -4, 0, + -179, 264, 1, 0, -90, 401, -3, 3, 91, 124, 1, 68, -47, 28, -7, 40, 44, 51, + 31, 555, -1, -29, 38, 3, 25, 29, 10, 1, 37, 48, 25, 1097, -2, -30, 45, 70, + 13, 319, -15, 259, -415, 88, 35, 44, 37, 110, 22, 20, 11, -62, 42, 68, 104, + 60, 7, 9, -17, -14, -12, 31, -7, 56, 33, 42, 4, 39, 31, 25, 8, 73, -5, 57, + 17, 431, -350, 141, 71, 75, -77, 43, 28, 40, 8, 98, 41, 49, 36, 562, 0, 9, + 15, 31, 12, 101, -6, 93, -21, 71, -34, 863, -50, 182, 188, 65, -5, 2, 244, + 21, 6, 112, -20, 44, 6, 99, -10, 26, 4, 13, 11, -13, 490, 75, 71, -5, 50, + 15, 32, 44, 1, -4, 157, 156, 3, -6, 9, 10, 18, 27, 18, -4, 9, -20, -1, 10, + 25, -19, 15, -36, 5, 6, 9, -16, 20, 13, 18, -12, 12, 20, 10, -30, 5, 19, 67, + 36, 22, 16, 43, -8, 19, -45, 10, -16, -6, -12, 22, 2, 35, 34, 11, -22, 5, + -4, 23, 18, 16, -50, 26, 37, 40, -21, 1, -13, 13, -34, 2, 8, 17, -66, 11, + -12, 20, -24, -7, -10, 15, -82, 37, -33, 60, -16, 9, 4, 18, -31, 18, 0, 12, + 19, 13, -21, 6, 9, 24, -34, 57, -20, 11, -19, 49, 1065, 17, 1, 74, -358, + 101, -137, 79, 349, 122, 53, 298, -37, 76, 60, 23, 127, 117, 83, 35, 50, 62, + 42, 85, 11, 90, -10, 106, 30, 114, 152, 21, 120, -9, 50, 476, 129, 2, 488, + -52, 59, 15, -31, 169, 143, 119, -78, 123, 30, 7, -19, 34, -37, -1, -174, + 305, -2, 133, -25, 31, -33, 3, -43, -17, -41, -1, -64, -28, -12, -12, 1, + -44, -53, -7, -4, -18, -22, -16, -64, -18, -9, 23, -41, -2, 1, -31, -23, + -12, 10, -16, -21, -12, -1, -18, -42, -26, -28, 9, -41, -9, -19, -77, -51, + -12, 4, -11, 37, -15, 4, -12, -60, -18, -31, -36, -5, -23, -47, 1, -55, -20, + -3, 23, -28, -9, -16, 5, 18, 22, -8, 20, 21, 11, 19, 55, -9, 15, -21, 7, + -19, 81, -98, 75, -71, 20, -16, -9, -38, 10, -5, 63, -301, 7, -50, -6, -113, + 15, 9, 102, -13, 87, 43, 5, 2, 34, 162, 16, 175, -4, 396, 75, 0, 55, 135, + 20, 103, -29, 50, 85, -53, 1, 9, 13, -17, 4, 5, 6, 16, -5, 11, 11, -96, 40, + -26, 29, 45, 29, -12, 55, 65, 91, -6, 42, -14, 8, -25, -4, -58, -91, -26, + -21, -52, -3, -24, 6, -37, 29, -18, 60, -21, 22, -83, 35, 12, 21, 96, 19, + 19, 20, 1, 25, -38, 25, -78, 14, -13, 17, 2, 22, 56, 67, 3, 65, -21, 22, + -134, 67, -73, 132, 4, 10, 21, 7, 46, 3, 23, 22, -7, 11, -79, 21, -150, 77, + -159, 60, -60, 33, -64, 74, -6, 35, 22, 103, -8, 140, 14, 13, 40, 5, 40, + -20, 49, 4, 50, 30, 8, 30, -50, 122, -4, 43, -67, 127, 20, 75, -34, 49, -11, + 44, 26, 71, -49, 70, -24, 21, -66, 30, -205, 56, 3, 11, 33, 13, 14, 17, 3, + 19, -26, 19, -103, -26, -60, 4, -22, 7, -55, 50, -80, 30, -41, 4, -140, -11, + -43, -50, -168, -53, -20, 12, 4, 52, -33, 22, -34, -6, -79, -40, -107, 6, + -41, -17, -63, 3, -132, 24, -84, -13, -56, 5, -8, 22, -31, 17, -10, 19, -26, + -1, -18, -25, -25, 14, -6, 27, 26, 18, 20, -1, -5, 18, -28, 6, -50, -27, + -25, 7, -11, 51, 31, 26, -30, 12, -43, -3, -22, 9, -12, 34, 33, 41, 31, 69, + 32, 33, 236, 133, 1, 62, -26, 30, 24, 34, 90, 42, 26, 30, 12, 4, 26, -22, + 18, 4, 5, 19, -17, 51, 17, 51, -11, 8, -66, -13, -19, 28, 19, 18, 84, 34, + 23, 56, 67, 19, 22, 17, -64, 59, 15, 34, -25, 20, 43, 24, -18, 47, -19, 3, + -22, -21, -24, 4, -50, 29, -18, 22, -4, 18, 21, 18, 8, 25, 45, -1, 2, 35, + -49, 17, -3, 14, -28, 19, 19, 10, 13, -14, 17, 0, 9, 41, 56, 36, 50, 7, 11, + -15, -12, -16, 23, -1, 38, 36, -39, 25, 29, 62, -5, 29, 60, 13, 9, 24, 16, + 10, 32, -19, 26, 4, -6, 38, -18, 13, -3, 19, -25, 9, -4, 10, -16, 0, -20, + -17, 6, -26, -42, 16, -11, 25, 22, 19, 43, 8, -19, 16, 6, 25, 50, 6, 12, 28, + -5, 18, 14, 13, 14, 9, 29, -5, 32, 13, -17, 28, 28, 7, 13, 15, 32, 3, 23, + -21, -30, -14, -8, -20, 56, 9, 58, -42, 40, 0, 34, 19, 0, 40, -45, 12, -8, + 24, 26, 24, -2, 20, 13, 16, 26, 11, 28, 33, 70, 16, 46, 63, 19, 82, -56, 73, + 1, 49, 71, 57, -21, 46, 12, 24, 33, -3, 6, -14, 20, -5, 53, 18, 2, 13, -33, + 23, -28, 4, 0, 75, 45, 16, 2, 22, 38, 29, 49, 10, 33, -37, -12, -13, 208, + -75, 20, 27, 568, -130, 615, -250, 127, 2, 146, -30, 1046, 771, -76, 43, + -141, 297, 26, 48, -51, 17, -77, -3, -33, 22, 4, 19, -54, 5, -32, -18, -1, + 21, -15, 7, -28, -12, -31, 4, -19, -20, 22, -11, -39, 0, -19, -22, -39, -4, + 17, -35, -19, -8, -21, -29, -135, -9, -108, -331, -311, 39, -129, 42, -45, + 33, -27, 54, -53, 43, -80, 34, -37, 31, -488, -38, -8, 11, -41, -6, 5, -8, + -194, -12, -71, 124, -30, -19, -29, 21, -29, -4, -27, -20, 24, -57, -35, + -69, -19, -11, -38, -5, 2, 28, -23, 28, -67, 44, 7, 19, 48, 19, 7, 14, -14, + 26, -42, 12, -6, 22, -25, 2, -28, 27, -27, 7, -56, 34, -65, -7, -9, -32, + -29, 0, -11, 5, 8, 28, -11, 10, -107, 3, -57, 53, -25, 38, 49, 19, -3, 45, + 32, 38, -7, 22, -46, 15, -45, 49, 18, 63, 46, 23, -43, 13, -27, -7, -14, 6, + -6, 18, -49, 32, 15, 6, 6, 20, -37, 5, 1, 17, -24, 9, 33, 24, 1, 14, -27, 7, + -26, -14, -17, 8, -2, 23, 37, 15, -21, 14, 2, 26, -65, 7, -28, 17, -6, 41, + 43, 26, -1, 13, -23, 11, -49, -1, -102, -46, -26, -29, -26, -11, -2, -18, + -36, -8, -23, 40, -24, -3, -50, 30, -2, 43, -53, 52, -36, 8, 6, 23, -44, 42, + -48, 17, -32, -18, -28, 2, -10, -24, -138, -78, -21, 7, -125, 111, 70, 23, + 33, 24, 41, 78, -41, 10, -12, 23, 43, 54, 0, 50, 57, 29, -20, 33, 43, 6, -3, + 21, -30, 20, -31, -16, -63, 40, 208, 101, 484, -85, 248, -19, 102, 35, 258, + 153, 28, 10, 60, 5, 61, -24, 629, 63, 299, -69, 279, -10, 43, 151, -40, 115, + 60, 55, 145, 172, 127, 181, 254, 244, 52, 31, 84, 94, 101, 84, 105, 107, + 463, 72, 161, 160, 153, 87, 142, 191, 143, 90, 394, -24, 111, 10, 203, 36, + 132, 79, 63, 106, -417, 207, -24, 178, 23, 47, -17, 8, 35, 44, 52, 234, 22, + 39, 9, 81, -16, 25, -26, 17, -93, 30, -69, 49, -66, 3, -102, 102, 22, -5, + 14, 17, -40, 33, -15, -11, -11, 4, -16, 8, 17, 11, -125, 58, -289, 73, -93, + 71, -268, 171, -197, 267, -111, 85, -182, 85, 126, 450, -43, 180, 16, 41, + 1088, 410, 216, 71, -302, 271, -40, 212, -704, -181, -74, 79, -1055, -98, + -175, -237, -434, -151, -227, 14, -288, 41, -337, -58, -293, 194, -98, 83, + -27, 9, -139, 108, -360, 89, -173, -15, -101, 70, -266, 31, -24, 18, -191, + 178, -250, 562, -225, 276, 7, 15, 185, 139, -453, 31, -203, 23, -108, 18, + -554, 157, -295, 22, -55, 12, -100, -7, -194, -55, -355, -153, -210, 26, + -492, 117, -234, 26, -116, 128, -85, -3, -60, 29, -82, 57, -21, 50, -51, 55, + -59, 118, -101, 86, 28, 36, -23, 50, -15, 238, -172, 198, -74, 104, -66, 33, + -67, 117, -89, 38, -492, -272, -125, -161, -77, -76, -398, -186, -39, -37, + -183, 28, -163, 43, -102, -11, -366, -133, -143, -12, -56, -21, -164, -90, + -54, 35, -48, 13, -52, -13, 0, -18, -30, -9, -40, 40, 16, 23, -7, 12, -41, + -3, -29, 9, -14, 45, -33, 31, 24, 22, -88, 49, -28, 82, 67, 19, 5, 25, -23, + 8, -8, 16, 22, 7, 6, 12, -15, 75, -56, 11, -20, 41, 58, 27, 13, 42, 43, -2, + 25, 27, -19, 13, -55, -8, -27, 10, -15, 27, -39, 29, -17, 36, -48, 13, -38, + -12, -29, 7, -23, -10, -84, 6, -32, -10, -9, -22, -29, -12, -16, 16, -72, + -10, -66, 30, -50, -29, 0, -13, -38, -3, -7, 28, -19, 1, -35, -17, -57, 27, + -52, 72, -68, 29, -284, 50, -46, -50, -98, 39, -124, 138, -240, 13, -110, + 80, -97, 217, -301, 78, -80, 273, 247, 180, 74, 33, -36, 24, -6, 22, -46, + 31, -62, 17, -7, 38, -15, 4, 0, 10, -40, 34, -46, 12, -17, 28, -26, 5, -10, + 17, -21, 4, -48, -19, -22, -74, 24, -203, -5, -44, -50, -14, -74, -4, -199, + -164, -168, 104, -132, 72, -60, 21, -289, 19, -159, -65, -394, -124, -175, + 46, -211, 0, -60, 73, 5, 67, -46, 60, -136, -2, 43, 123, -352, 61, -178, + -196, -189, 99, -469, 152, 33, 121, -26, 168, -182, 237, -1, 79, -33, 180, + -154, 28, -124, -11, -102, -74, -158, -62, -233, -7, -743, 72, 66, -243, + -13, -94, 38, -55, 16, -171, -27, -9, 114, -162, 34, -15, 13, -29, 25, -11, + 1, -41, 14, 1, 73, -83, 29, -13, 4, -44, 49, -40, -97, -28, -165, -70, 0, + -29, 21, -43, -258, -83, -23, 3, -74, -28, 88, -94, -292, -121, -96, -52, + -76, 178, -687, -192, -101, -79, -128, -151, -106, 93, 10, 66, -23, 25, 26, + 10, -28, 62, 228, 71, -183, 176, -178, 133, 10, 7, -39, 29, -60, -38, -181, + 131, -160, 22, -135, 208, -210, 272, -36, 69, -91, 53, -249, 119, -44, 10, + -242, 22, -129, 41, -261, 102, -280, 156, -420, -57, -115, 160, -102, 86, + -68, 43, -189, 206, 26, 58, -85, 7, -108, 81, -44, 15, -64, 41, -115, 47, + -3, 18, -36, 42, -95, 68, -27, 48, -24, 6, -33, 32, -29, -2, -31, 19, 2, 16, + -19, 24, 45, 110, -167, 135, -60, 78, -58, 42, -6, 37, 129, 207, -15, 43, + 17, 175, -10, 307, 116, 95, -25, 86, -55, 24, -213, 27, -143, -3, -87, -21, + -88, -75, -213, 231, -343, 68, -132, 106, -31, 53, -204, 41, -86, 102, -41, + 25, -12, 45, -47, 67, 3, 252, -13, 96, -15, 6, -12, 27, 18, 14, -13, 28, 22, + 82, 128, 3, 68, 35, -2, 102, 65, 126, -141, 19, -38, -38, -42, -20, -252, + -63, -121, -39, -9, -16, -94, -69, -86, -16, -160, -21, -191, 14, -255, 49, + -191, -5, -57, -15, -79, -26, -101, -70, -100, -200, -68, -46, -286, -72, + -63, -30, -16, 14, -342, -132, 12, -12, -78, -32, -166, -50, -169, -68, + -199, -37, -88, -29, -54, 0, -92, 46, -358, -38, 29, -40, -2, -14, -30, -6, + -315, -46, -115, 5, -148, -22, -70, 38, -798, 6, -201, 11, -215, -38, -295, + -88, -31, 2, -6, 10, -9, -15, -33, -3, -50, 12, -34, -8, -4, -17, -176, -83, + -194, -146, -134, -2, -98, -14, -60, -24, -36, -30, -139, -14, -55, -17, + -129, -14, -38, -100, 76, -22, -5, -5, -81, 15, -24, -8, -55, 8, -67, -25, + 28, -54, -23, -24, -44, 4, 3, 15, -29, 0, -21, 11, -111, -57, -91, 0, -139, + -84, -16, -45, -34, -5, -57, 50, -20, 1, -90, -169, -41, -141, -156, -162, + -156, -42, -89, 46, -213, 51, -33, -1, -17, -33, -167, 53, 21, 14, -148, 57, + -75, 51, -71, 29, -91, 62, 88, 67, 88, 12, -26, 17, 51, 21, -63, 122, 196, + 9, -1, 25, -34, 14, -17, 19, 2, 15, -38, 31, -58, 5, -174, -14, -55, -36, + -60, 2, -110, 22, -36, 0, -120, 28, -8, 26, 32, 44, -98, 14, -5, 7, -50, -2, + -89, 33, -55, 48, -12, -7, -58, 34, -16, 50, -144, 19, -335, -19, -65, 196, + -93, -28, -173, -177, -67, -32, -263, -74, 55, -67, -163, -55, -25, -2, -54, + 14, -252, 2, -342, 27, 30, 74, -131, -3, -10, 63, -84, 114, 37, 65, -65, 3, + 45, 69, 58, 55, -283, 198, -129, 25, -91, 58, -26, 81, -96, 60, -104, 89, + -50, 74, -79, 60, -38, 17, -8, 18, -12, -2, -76, 29, -69, -46, -68, 72, + -102, 226, -228, 62, -223, 11, -227, 61, -61, 35, -107, -7, -63, -14, -18, + 45, -85, -2, -151, -17, -50, 7, -57, -24, -74, -2, -72, -19, -74, 9, -20, + -9, -60, 2, -52, -23, -147, -20, -88, -4, -24, -31, -48, -21, -64, 2, -54, + -19, -51, 13, -126, -13, -12, -26, -41, -5, -54, 41, -63, 23, -24, -15, -42, + -2, -28, -10, -28, 10, -4, 17, -23, 10, -71, 8, -74, -9, -20, 7, -174, -162, + -168, 67, -203, -44, -36, 91, -374, -199, 21, -21, 22, -7, 70, 5, 13, -7, 1, + -58, -50, -77, -92, -76, -362, -214, -184, -139, -654, 5, -148, -39, -297, + 33, -21, -24, -248, 153, 105, 73, -144, 85, -35, 99, -98, -3, -6, 374, 35, + 11, -13, 176, -66, 40, -9, 26, -86, 90, -79, -56, 1, -24, 58, 7, 1, 9, 37, + -3, 1, -15, -7, -7, -26, -1, 12, -6, -53, -27, -5, -10, -51, 33, -15, -7, + -13, 11, -7, -10, -12, 12, 11, 8, -22, 24, -23, -6, -6, 6, -11, -12, -115, + 26, 10, 13, -52, 45, -81, 28, -151, 74, -82, 59, -253, 70, -134, 161, -37, + -3, -25, 22, 26, 22, -18, 22, 4, 21, -126, 101, -8, 62, -86, 8, -12, 21, 2, + 36, 41, 47, -2, 38, -80, 114, -1, 28, -29, 36, -68, 42, -308, 137, 33, 20, + 66, 82, 210, 94, 45, 36, -69, 56, 22, 67, -50, 1, -32, -22, -105, 185, -103, + 101, 12, 29, -3, 51, 76, 64, 268, -14, 354, 113, 230, -104, 32, 27, -17, + 121, 90, 81, -157, 27, -52, 1, -33, -11, -81, 38, -46, 48, 41, 49, -25, 27, + 6, 63, -17, 17, 21, 30, 37, 5, 15, 23, 3, 61, 58, 89, -93, 95, 78, 21, -3, + 9, 22, 24, -40, 38, 1, 25, 64, 52, 3, 14, -29, 18, 152, 217, -141, 78, -59, + -15, -60, 1, -80, -12, -50, 10, -165, 112, -182, 48, -36, 36, -10, 52, 92, + 196, -5, 164, -64, 107, -50, 41, -17, 41, -41, 22, -32, -6, -36, 4, -99, 29, + -29, 24, 53, 50, 144, 45, -142, 66, -79, 95, 234, 70, -66, 40, 0, 23, 55, + 15, -16, 42, -81, 49, 3, 49, -50, -9, -142, -1, -68, 22, 58, 68, -73, 2, + -104, -15, -5, 10, -63, -3, 3, 12, -32, -1, -14, 25, -45, -11, -61, 3, -75, + 122, 36, 11, 5, 41, 46, 40, 12, 1, 4, 22, -15, 5, 18, 45, 59, 4, 10, 38, 51, + 43, 18, 1, 20, 14, -31, 33, -64, 113, -17, -1, -98, 48, 3, 11, -21, 27, 93, + 20, -72, 40, -24, 62, -60, 52, 69, -29, 276, -53, 30, 2, 77, 31, 63, 3, 7, + 25, 73, 12, 14, 72, 102, 0, -1, 32, 100, -2, 6, 15, 49, 20, -16, 25, -43, + -1, -8, 26, -27, 0, 3, 22, 32, 17, -1, 36, 63, -1, -19, 41, -31, 6, 14, 40, + -109, 72, -52, -17, -32, 2, -116, 136, -23, -12, -117, 10, -117, -46, -222, + 84, -14, 36, 22, 28, 10, 39, -5, 5, -67, 2, -12, 56, -63, -2, -18, -13, -7, + -45, -12, 8, -8, -23, -58, -14, 10, -23, -9, -19, -30, -7, -2, -16, -29, 3, + 2, -15, -12, -7, -88, -6, -17, -19, -26, 1, -9, -7, -15, 7, -47, -35, -17, + 24, -15, -15, 26, -23, -3, -35, -30, 4, 5, -14, -67, -1, -20, 17, 10, 33, + -45, -17, -36, -26, -1, 28, -15, 5, -20, -6, -46, 29, -227, -96, -8, -22, + -35, -1, -6, -19, -12, 6, -89, -49, -26, 4, -8, 12, -30, 9, -20, 26, -38, + 17, -29, -26, -3, 12, -67, 26, -2, 26, -64, 51, -1, 17, -44, 36, -14, 29, + -45, 20, -14, 22, -58, 31, 5, 31, -28, 31, -46, -28, -10, 12, -36, -13, -24, + 15, -35, 2, 82, 83, 173, -32, 32, 109, -45, 22, -24, 26, -15, -24, -38, 2, + 0, 44, -17, 40, 53, 99, 64, 22, -77, 49, -256, 74, 23, 30, -18, 47, -14, -1, + -48, 38, 15, 8, -24, 31, 46, 10, 10, 21, -149, 87, -21, 43, 107, 58, -33, + 28, -5, 207, 38, 18, 89, 69, -6, 42, -44, 20, 15, 81, 34, 20, 77, 14, -1, + 25, -305, 76, -27, -16, -118, 32, -208, -27, -89, 22, -95, -31, -48, -68, + 17, -44, -11, -22, -77, -50, -66, -22, -80, -40, -19, 134, -59, 20, -55, 0, + -35, 12, -17, 29, -58, 29, -50, 46, -52, 6, -35, -20, -71, -15, -124, 24, + -4, 26, -25, 16, -21, -12, -121, -26, -41, -20, -98, -27, -1, -76, -15, -6, + -49, 5, -56, -24, -7, -65, -38, -27, -106, -18, -129, 2, -34, -8, -34, -19, + 3, -43, -77, -56, -83, 23, -52, 3, -32, -9, -41, -38, -39, 10, -98, 72, + -227, -143, -52, -18, 14, -47, -4, -40, -183, -55, -32, 23, -52, 9, -11, 12, + 13, 22, -11, 12, -18, 1, -54, -21, -162, 100, -54, -70, -67, -31, -130, 27, + -53, 32, -177, -10, -138, 83, -164, -125, -180, 59, -205, 170, -46, 467, + -15, 26, -65, -1, -254, 43, -65, -1, -23, -11, -108, 6, -64, 35, -40, -28, + 17, -53, -28, 3, -185, -133, -394, 189, -175, 3, -103, -107, -247, 21, -50, + 9, -16, 15, -103, 36, -56, 10, -85, 30, -46, 53, -50, 15, -19, 16, -41, -4, + -18, 13, -72, 19, -15, 22, -54, 5, -71, -18, -16, 6, -26, -6, -137, 49, + -171, 29, -528, -341, -241, -17, -232, 113, -106, 24, -288, 114, -134, 21, + 11, 26, 25, 16, -33, 16, 7, 18, 31, 15, 106, 17, 63, -10, 34, 67, -119, 26, + -56, 78, 18, 12, 52, 5, 3, 20, 49, 9, -1, 21, 49, 0, 13, 24, 27, 10, 54, + -35, 25, -2, 62, 158, 97, -36, -16, 71, 12, 20, 67, 23, -76, 124, 67, 43, + 117, -44, 50, 21, 35, -8, 29, -23, -9, -15, 12, -11, 28, -6, 27, 5, -38, 48, + 56, 12, 8, 11, -34, 32, 1, 14, 176, 88, 49, 41, -4, 34, -23, 14, -38, 1, + -10, 14, 49, 15, 17, 13, -10, 10, -59, 27, -44, -3, -136, 45, -57, 10, -14, + 56, -18, 3, -26, -16, 4, -30, -34, -11, -61, -3, -38, 15, -30, -2, 71, 133, + -114, 19, -31, -38, -49, 11, -10, -45, -103, 76, -32, 40, 3, 12, -32, 22, + -48, 12, -15, 25, -36, 8, -87, -29, -90, 3, -15, 98, -58, 78, 64, 43, -48, + 73, 238, 27, -9, 51, 100, 31, -5, 42, -17, 23, -68, 46, -16, 57, 128, 42, + 13, 58, 62, 32, -12, 10, 177, 28, 145, 83, -48, 50, 66, 52, -82, 176, 34, 0, + 38, 20, 70, -3, 46, 27, 0, 52, -45, 29, -76, 15, -11, 92, -100, -1, -5, 23, + -248, -17, -8, 150, -34, -1, -54, 64, 69, 50, -9, 18, -49, 24, -13, 25, -60, + 40, 29, 23, 20, 37, -115, 85, -131, -85, -81, -20, -12, 148, -25, 8, -16, + 105, -287, -36, -26, 276, -124, 213, -145, -30, -81, -2, -28, 13, -117, -8, + 1, 56, -182, 4, -63, 80, -4, 98, -126, -53, -56, 8, -64, 34, 35, 36, 45, 5, + 3, 14, 38, 10, 39, -5, 16, 45, 28, 6, 12, 15, 30, 11, 32, -9, 27, 18, 10, + -5, 21, 14, 8, 34, 66, -5, -1, 14, 40, -13, 20, 49, 110, -3, 36, 18, 42, + -32, 38, 6, 39, 20, 23, -5, 26, -35, 22, 3, 23, 23, 24, -2, 93, 33, 98, 14, + -6, 7, 57, 20, -1, 16, 10, 1, 18, -11, 13, 8, 20, -2, 26, -27, 62, -5, 30, + 49, 51, 42, 75, 0, 51, -30, 27, 3, 55, -34, 171, 56, 110, -11, 22, 7, 9, 15, + 80, -1, 66, 23, 105, -7, 65, 26, 23, 42, 27, 15, 15, 28, 30, 27, 32, 14, 23, + -3, 54, -35, 122, -8, 52, -16, 41, -2, 102, 12, 65, -20, 38, 5, 94, -7, 202, + 40, 25, 12, 96, -28, 5, 21, 25, -1, -7, 6, 13, 7, 38, -5, 0, 20, 33, 2, 27, + 21, 72, -2, 19, 13, -5, 9, 22, 36, 30, 16, 38, -2, 40, 33, 4, 40, 30, 19, + -3, 26, 13, 12, 1, 14, -36, 31, -12, 29, -1, 41, 28, 42, -25, 39, 9, 22, + -53, 26, -2, 16, -27, 15, -7, 34, -31, 4, -56, 39, -98, 48, 3, 32, -19, 25, + -2, 22, 19, 27, -11, 10, 20, 11, 9, 28, 29, 29, -2, 18, 28, 54, -2, 55, -48, + 38, -2, 12, 32, 15, 8, 54, -36, 14, -38, 39, -49, 17, -34, 28, -86, -1, -35, + 31, -1, 17, -30, 38, -61, 0, -61, 22, -39, -2, -13, 41, 19, 18, -54, 43, 9, + 18, -13, 11, -165, 50, -159, 64, -71, 49, 4, 13, -47, 22, -23, 29, -50, 19, + -64, 6, -28, 33, -282, 3, -118, -91, -173, 32, 83, 55, 42, 15, -43, 48, 10, + 119, 16, 27, 51, 13, -114, 69, -175, -33, -135, 75, 67, 78, -111, 76, -113, + 52, 1, 21, 19, 10, -75, 63, -64, 88, -19, 97, -122, 16, -345, -25, -9, 43, + -49, 3, -78, 120, 57, 43, 47, 108, -73, 39, -31, 6, -29, 22, -2, -6, -30, + -1, -33, 6, -30, -12, -46, 11, -75, -25, -61, -1, -18, 7, 4, 38, -31, 29, + -26, 195, 22, -16, 19, 28, -25, 39, 80, 52, -23, 9, -23, 38, -22, 10, -16, + 60, -53, 12, -46, 40, -21, 130, -179, -13, -2, 37, 222, 119, 91, 2, 16, 60, + 41, 21, -115, 85, 3, 41, -13, 29, 37, 21, 66, 13, 13, -5, 36, 47, 84, 64, + -2, 43, 26, 113, 31, 27, -271, 137, -132, -1, -93, 75, -40, 15, -34, 29, + -53, 58, 111, 107, -37, 18, -82, 124, -4, 41, -50, -6, -92, 14, -148, -8, + -75, 5, -153, 70, -22, 21, 37, 129, -17, 31, -189, 71, -76, -36, -51, 32, + -52, 6, -60, 81, 4, 26, 37, 11, -10, 12, -65, -1, -23, 12, -86, 0, -69, 14, + 45, 87, -9, 76, 50, 90, -17, 60, 7, 18, -64, 58, -54, 10, -82, 52, 6, 25, + -29, 72, -117, 39, -14, -3, -15, -24, 4, -26, -25, -14, -89, 55, -151, 0, + -58, -12, -363, 139, 160, 129, -36, 38, -80, 52, -2, 27, -38, 14, -20, 38, + 10, 84, -22, 32, -132, 28, -42, -11, -167, 29, -143, 3, -107, -11, -49, -20, + -214, -23, -29, -40, -84, -6, -54, -23, -73, -6, -27, 1, -73, 90, -15, 4, + -81, -2, -78, -40, -78, -15, -96, 0, -45, -26, 7, -26, 31, -15, -13, -26, + -92, -17, -35, -19, -39, 2, -46, -31, -8, -16, -29, -8, -74, 11, -91, -4, + -50, -25, -54, 12, -22, -3, -90, -28, -120, -18, -186, -114, -31, -6, -67, + -35, 48, -71, -45, -3, -80, 50, -70, 30, -111, 42, -72, 8, 6, -31, 69, -15, + 38, -25, 86, -32, 16, -37, -49, -77, -295, 29, -56, 25, -205, -12, -68, 60, + -102, -16, -54, 31, -17, 25, 22, 29, 25, 5, 30, 23, 14, 77, 223, 43, 83, 97, + -44, 33, 33, 51, -8, 28, -90, 36, -116, -33, -90, 26, -168, 11, -82, 40, + -27, -11, -68, 34, -32, -49, 8, -29, -77, 0, -54, -44, 75, -44, 1, -65, + -180, 36, -158, 43, -18, -11, 6, 32, 46, 0, 15, 12, -10, 69, -52, 3, -23, + -11, -14, 18, -14, 1, -37, -11, -14, 35, 16, 21, 41, 13, -115, 32, -149, 15, + -73, -14, -33, -87, -62, -10, -31, 31, -51, -49, 12, -7, -29, -6, -27, -43, + -143, -15, -7, 8, -60, 1, -90, 25, -76, -1, -36, -11, -36, -37, -175, 76, + -810, 179, -17, -27, + ]), +]); + +let decoded = null; +/** The rings as [[lon, lat], ...] with their boxes, decoded once. */ +function rings() { + if (decoded) return decoded; + const scale = 10 ** DECIMALS; + decoded = RINGS.map((encoded) => { + const ring = []; + let lon = 0; + let lat = 0; + for (let i = 0; i + 1 < encoded.length; i += 2) { + lon += encoded[i]; + lat += encoded[i + 1]; + ring.push([lon / scale, lat / scale]); + } + const box = ring.reduce( + (b, [x, y]) => ({ + west: Math.min(b.west, x), + east: Math.max(b.east, x), + south: Math.min(b.south, y), + north: Math.max(b.north, y), + }), + { west: Infinity, east: -Infinity, south: Infinity, north: -Infinity }, + ); + return { ring, box }; + }); + return decoded; +} + +/** Every ring's box together: the cheap first test. */ +export const NO_TRAIN_POSITION_BOX = Object.freeze( + rings().reduce( + (b, { box }) => ({ + west: Math.min(b.west, box.west), + east: Math.max(b.east, box.east), + south: Math.min(b.south, box.south), + north: Math.max(b.north, box.north), + }), + { west: Infinity, east: -Infinity, south: Infinity, north: -Infinity }, + ), +); + +function inRing(ring, lat, lon) { + let inside = false; + for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) { + const [xi, yi] = ring[i]; + const [xj, yj] = ring[j]; + if ( + yi > lat !== yj > lat && + lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi + ) + inside = !inside; + } + return inside; +} + +/** + * Whether a position lies where no train may be placed. + * @param {number} lat + * @param {number} lon + * @returns {boolean} + */ +export function isNoTrainPositionPoint(lat, lon) { + if (!Number.isFinite(lat) || !Number.isFinite(lon)) return false; + const all = NO_TRAIN_POSITION_BOX; + if (lat < all.south || lat > all.north || lon < all.west || lon > all.east) + return false; + return rings().some( + ({ ring, box }) => + lat >= box.south && + lat <= box.north && + lon >= box.west && + lon <= box.east && + inRing(ring, lat, lon), + ); +} + +/** + * The country a stop id names, for ids that start with one (IFOPT-style + * `ua:22005:1:1`, parent stations `Pua:22005`), as an upper-case ISO code; + * else null. + * @param {string|null|undefined} stopId + * @returns {string|null} + */ +export function stopIdCountry(stopId) { + return ( + /^P?([a-z]{2}):\d/i.exec(String(stopId ?? ''))?.[1]?.toUpperCase() ?? null + ); +} + +/** + * Whether a stop lies where no train may be placed: when the country its id + * names is one of them, or when its position is inside the outline. Either + * is enough, so a stop is never kept on its id's word alone: a stop whose id + * names another country but whose coordinates are in Ukraine is left out + * too. + * @param {{id?: string, lat?: number, lon?: number}|null|undefined} stop + * @returns {boolean} + */ +export function isNoTrainPositionStop(stop) { + if (!stop) return false; + const country = stopIdCountry(stop.id); + if (country && NO_TRAIN_POSITION_COUNTRIES.includes(country)) return true; + return isNoTrainPositionPoint(Number(stop.lat), Number(stop.lon)); +} diff --git a/src/data/railNoPositions.test.mjs b/src/data/railNoPositions.test.mjs new file mode 100644 index 000000000..3c5e04b33 --- /dev/null +++ b/src/data/railNoPositions.test.mjs @@ -0,0 +1,297 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { crc32 } from 'node:zlib'; +import { PbfWriter } from 'pbf'; +import { + NO_TRAIN_POSITION_BOX, + NO_TRAIN_POSITION_COUNTRIES, + isNoTrainPositionPoint, + isNoTrainPositionStop, + stopIdCountry, +} from './railNoPositions.js'; +import { buildRailSnapshot } from './railProxy.js'; +import { createRunIndex } from './railScheduleIndex.js'; +import { + buildPack, + buildSchedule, + openZip, +} from '../../scripts/build-rail-paths.mjs'; + +const KYIV = [50.45, 30.52]; +const LVIV = [49.84, 24.03]; +const SIMFEROPOL = [44.95, 34.1]; +const AMSTERDAM = [52.37, 4.9]; +const PRZEMYSL = [49.78, 22.77]; +const CHISINAU = [47.0, 28.86]; + +/** A fake feed: every vehicle and route is a train, so only the rule drops. */ +function fakeFeed(extra = {}) { + return Object.freeze({ + id: 'test-no-positions', + name: 'Test', + format: 'gtfs-rt', + classify: () => ({ category: 'international', mode: 'rail' }), + ...extra, + }); +} + +test('the area is Ukraine, Crimea included, behind a cheap box', () => { + assert.deepEqual(NO_TRAIN_POSITION_COUNTRIES, ['UA']); + for (const [lat, lon] of [KYIV, LVIV, SIMFEROPOL, [48.0, 37.8]]) + assert.equal(isNoTrainPositionPoint(lat, lon), true, `${lat},${lon}`); + for (const [lat, lon] of [AMSTERDAM, PRZEMYSL, CHISINAU, [53.9, 27.56]]) + assert.equal(isNoTrainPositionPoint(lat, lon), false, `${lat},${lon}`); + // Outside the box nothing more is computed. + assert.ok(NO_TRAIN_POSITION_BOX.west > 22 && NO_TRAIN_POSITION_BOX.east < 41); + for (const bad of [NaN, null, undefined]) + assert.equal(isNoTrainPositionPoint(bad, 30), false); +}); + +test('stations on the border fall on their own side (OpenStreetMap positions)', () => { + // Outside Ukraine, within 1 km of the border: on the Tisa (Bocicoi), by + // the Polish crossing (Medyka) and on the Slovak line (Čierna nad Tisou). + for (const [name, lat, lon] of [ + ['Bocicoi', 47.95743, 24.02333], + ['Valea Vișeului', 47.91463, 24.15557], + ['Sighetu Marmației', 47.9358, 23.88536], + ['Medyka', 49.80248, 22.93929], + ['Čierna nad Tisou št. hr.', 48.43209, 22.13597], + ['Záhony', 48.4017, 22.18376], + ]) + assert.equal(isNoTrainPositionPoint(lat, lon), false, name); + // Inside Ukraine, as close to the border. + for (const [name, lat, lon] of [ + ['Uhniv', 50.37859, 23.73241], + ['Korchiv', 50.39422, 23.83512], + ['Teresva', 47.99573, 23.70047], + ['Solotvyno-Likarnia', 47.96591, 23.86276], + ]) + assert.equal(isNoTrainPositionPoint(lat, lon), true, name); +}); + +test('a stop is left out when its id names Ukraine or its position is inside', () => { + assert.equal(stopIdCountry('ua:22005:1:1'), 'UA'); + assert.equal(stopIdCountry('Pua:22005'), 'UA'); + assert.equal(stopIdCountry('de:09162:6'), 'DE'); + assert.equal(stopIdCountry('8000105'), null); + // A Ukrainian stop the outline would put in Hungary (Chop, on the border). + assert.equal( + isNoTrainPositionStop({ id: 'ua:22005:1:1', lat: 48.43, lon: 22.1 }), + true, + ); + // A stop whose id names Poland, at a position the outline puts in + // Ukraine (Shehyni, across the border from Medyka): its id does not keep + // it. + assert.equal( + isNoTrainPositionStop({ id: 'pl:1:1', lat: 49.7949, lon: 22.9768 }), + true, + ); + // Border stations on their own side stay, by id or by position alone. + for (const [id, lat, lon] of [ + ['pl:1:2', 49.80248, 22.93929], // Medyka + ['hu:1:1', 48.4017, 22.18376], // Záhony + ['sk:1:1', 48.43209, 22.13597], // Čierna nad Tisou + ['ro:1:1', 47.95743, 24.02333], // Bocicoi + ['5001', 49.80248, 22.93929], + ]) + assert.equal(isNoTrainPositionStop({ id, lat, lon }), false, id); + assert.equal( + isNoTrainPositionStop({ id: '9001', lat: LVIV[0], lon: LVIV[1] }), + true, + ); + assert.equal(isNoTrainPositionStop(null), false); +}); + +/** A minimal stored (uncompressed) zip. */ +function makeZip(files) { + const locals = []; + const centrals = []; + let offset = 0; + for (const [name, text] of Object.entries(files)) { + const data = Buffer.from(text, 'utf8'); + const nameBytes = Buffer.from(name, 'utf8'); + const local = Buffer.alloc(30); + local.writeUInt32LE(0x04034b50, 0); + local.writeUInt16LE(20, 4); + local.writeUInt32LE(crc32(data), 14); + local.writeUInt32LE(data.length, 18); + local.writeUInt32LE(data.length, 22); + local.writeUInt16LE(nameBytes.length, 26); + const central = Buffer.alloc(46); + central.writeUInt32LE(0x02014b50, 0); + central.writeUInt16LE(20, 4); + central.writeUInt16LE(20, 6); + central.writeUInt32LE(crc32(data), 16); + central.writeUInt32LE(data.length, 20); + central.writeUInt32LE(data.length, 24); + central.writeUInt16LE(nameBytes.length, 28); + central.writeUInt32LE(offset, 42); + locals.push(local, nameBytes, data); + centrals.push(central, nameBytes); + offset += local.length + nameBytes.length + data.length; + } + const cd = Buffer.concat(centrals); + const end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50, 0); + end.writeUInt16LE(locals.length / 3, 8); + end.writeUInt16LE(locals.length / 3, 10); + end.writeUInt32LE(cd.length, 12); + end.writeUInt32LE(offset, 16); + return Buffer.concat([...locals, cd, end]); +} + +/** + * Wien–Budapest, Budapest–Chop (a `ua:` stop the outline would miss), + * Budapest–Lviv (an id without a country, inside the outline) and + * Sighet–Bocicoi (a Romanian halt on the border river, which a coarser + * outline would place in Ukraine). + */ +const GTFS = { + 'agency.txt': + 'agency_id,agency_name,agency_url,agency_timezone\n1,Test,https://example.invalid,Europe/Vienna\n', + 'routes.txt': 'route_id,route_short_name,route_type\nR,EC,2\n', + 'trips.txt': [ + 'route_id,service_id,trip_id,trip_short_name', + 'R,daily,vienna-budapest,EC 1', + 'R,daily,budapest-chop,EC 2', + 'R,daily,budapest-lviv,EC 3', + 'R,daily,sighet-bocicoi,R 4', + ].join('\n'), + 'stops.txt': [ + 'stop_id,stop_name,stop_lat,stop_lon', + 'at:49:1,Wien Hauptbahnhof,48.185,16.376', + 'hu:55:1,Budapest-Keleti,47.500,19.083', + 'ua:22005:1:1,Chop,48.43,22.1', + '9001,Lviv,49.84,24.03', + '43820,Sighetu Marmaţiei,47.9358,23.88536', + '43753,Bocicoi Hm.,47.95743,24.02333', + ].join('\n'), + 'stop_times.txt': [ + 'trip_id,arrival_time,departure_time,stop_id,stop_sequence', + 'vienna-budapest,08:00:00,08:00:00,at:49:1,1', + 'vienna-budapest,10:40:00,10:40:00,hu:55:1,2', + 'budapest-chop,11:00:00,11:00:00,hu:55:1,1', + 'budapest-chop,16:00:00,16:00:00,ua:22005:1:1,2', + 'budapest-lviv,12:00:00,12:00:00,hu:55:1,1', + 'budapest-lviv,20:00:00,20:00:00,9001,2', + 'sighet-bocicoi,09:00:00,09:00:00,43820,1', + 'sighet-bocicoi,09:20:00,09:20:00,43753,2', + ].join('\n'), + 'calendar.txt': [ + 'service_id,monday,tuesday,wednesday,thursday,friday,saturday,sunday,start_date,end_date', + 'daily,1,1,1,1,1,1,1,20260101,20271231', + ].join('\n'), +}; + +async function buildFake(t) { + const dir = await mkdtemp(path.join(os.tmpdir(), 'gev-rail-no-pos-test-')); + t.after(() => rm(dir, { recursive: true, force: true })); + const file = path.join(dir, 'feed.zip'); + await writeFile(file, makeZip(GTFS)); + const zip = await openZip(file); + t.after(() => zip.handle.close()); + const feed = fakeFeed(); + const { pack } = await buildPack(feed, zip, { url: 'x' }); + return buildSchedule(feed, zip, { url: 'x' }, pack, { + from: '2026-11-09', + days: 2, + }); +} + +test('the build leaves out every timetable trip that stops in Ukraine', async (t) => { + const { schedule, stats } = await buildFake(t); + assert.deepEqual(Object.keys(schedule.trips).sort(), [ + 'sighet-bocicoi', + 'vienna-budapest', + ]); + assert.equal(stats.tripsNoPositions, 2); + assert.deepEqual(schedule.stops.map((stop) => stop.name).sort(), [ + 'Bocicoi Hm.', + 'Budapest-Keleti', + 'Sighetu Marmaţiei', + 'Wien Hauptbahnhof', + ]); +}); + +test('a kept stop records no country', async (t) => { + const { schedule } = await buildFake(t); + for (const stop of schedule.stops) { + assert.equal(Object.hasOwn(stop, 'cc'), false, stop.name); + assert.equal(Object.hasOwn(stop, 'country'), false, stop.name); + } +}); + +test('a left-out trip has no runs for a reported train to take', async (t) => { + const { schedule } = await buildFake(t); + const runs = createRunIndex(schedule); + for (const tripId of ['budapest-chop', 'budapest-lviv']) + assert.deepEqual(runs.runsOf(tripId), [], tripId); + assert.ok(runs.runsOf('vienna-budapest').length > 0); +}); + +/** A GTFS-RT VehiclePositions message with one vehicle per position. */ +function encodeVehicles(positions) { + const writer = new PbfWriter(); + const header = new PbfWriter(); + header.writeStringField(1, '2.0'); + header.writeVarintField(3, 1_794_000_000); + writer.writeBytesField(1, header.finish()); + positions.forEach(([lat, lon], i) => { + const entity = new PbfWriter(); + entity.writeStringField(1, `v${i}`); + const vehicle = new PbfWriter(); + const trip = new PbfWriter(); + trip.writeStringField(1, `trip-${i}`); + trip.writeStringField(5, 'R'); + vehicle.writeBytesField(1, trip.finish()); + const position = new PbfWriter(); + position.writeFloatField(1, lat); + position.writeFloatField(2, lon); + vehicle.writeBytesField(2, position.finish()); + vehicle.writeVarintField(5, 1_793_999_990); + entity.writeBytesField(4, vehicle.finish()); + writer.writeBytesField(2, entity.finish()); + }); + return writer.finish(); +} + +test('a live GPS position in Ukraine is dropped and counted, never sent', () => { + const snapshot = buildRailSnapshot( + fakeFeed({ url: 'https://example.invalid/vp' }), + encodeVehicles([AMSTERDAM, KYIV, SIMFEROPOL, PRZEMYSL]), + 1_794_000_000_000, + ); + assert.equal(snapshot.count, 2); + assert.equal(snapshot.dropped, 2); + for (const train of snapshot.trains) + assert.equal(isNoTrainPositionPoint(train.lat, train.lon), false); +}); + +test('an adapter position (Irish Rail XML) in Ukraine is dropped too', () => { + const position = (code, [lat, lon]) => ` + + R + ${lat} + ${lon} + ${code} + 06 Nov 2026 + ${code} + Northbound + `; + const body = ` +${position('A101', AMSTERDAM)}${position('A102', LVIV)} +`; + const snapshot = buildRailSnapshot( + fakeFeed({ format: 'irish-rail-xml', url: 'https://example.invalid/xml' }), + new TextEncoder().encode(body), + 1_794_000_000_000, + ); + assert.deepEqual( + snapshot.trains.map((train) => train.id), + ['A101'], + ); + assert.equal(snapshot.dropped, 1); +}); diff --git a/src/data/railPaths.js b/src/data/railPaths.js new file mode 100644 index 000000000..e876815bb --- /dev/null +++ b/src/data/railPaths.js @@ -0,0 +1,288 @@ +/** + * @module railPaths + * @description Track geometry for the Rail layer: a railway line as an indexed + * polyline measured by arc length, with projection of a position onto it and + * the point at a given arc length. + * + * Paths come from per-feed path packs (scripts/build-rail-paths.mjs), served by + * `/api/rail/paths/`. A pack holds each unique shape as a flat + * `[lat, lon, lat, lon, ...]` array with its cumulative distances in metres. + * + * Distances are measured in one local equirectangular frame per path, centred + * on the path's middle latitude. Over the extent of a commuter network (a few + * hundred kilometres) the east-west scale error stays within a few per cent, + * which moves a projection by metres, well under what a marker shows. + * + * Pure: no Cesium, no DOM, no Node built-ins. + */ + +/** Metres per degree of latitude (and of longitude at the equator). */ +const M_PER_DEG = 111_320; +/** Segments per bounding box in the whole-path search index. */ +const CHUNK = 32; + +/** Longitude difference wrapped into (-180, 180]. */ +function wrapLon(delta) { + return ((((delta + 180) % 360) + 360) % 360) - 180; +} + +/** + * Build an indexed path from a flat lat/lon array. + * @param {ArrayLike} coords `[lat, lon, lat, lon, ...]`, at least two points. + * @param {ArrayLike} [dist] Cumulative metres per point. When absent, + * wrong-sized or decreasing, the distances are measured in the local frame. + * @returns {object|null} The path, or null when the input is not a polyline. + */ +export function createRailPath(coords, dist = null) { + const n = Math.floor((coords?.length ?? 0) / 2); + if (n < 2) return null; + const lat = new Float64Array(n); + const lon = new Float64Array(n); + let minLat = Infinity; + let maxLat = -Infinity; + for (let i = 0; i < n; i++) { + const a = Number(coords[2 * i]); + const o = Number(coords[2 * i + 1]); + if (!Number.isFinite(a) || !Number.isFinite(o) || Math.abs(a) > 90) + return null; + lat[i] = a; + lon[i] = o; + if (a < minLat) minLat = a; + if (a > maxLat) maxLat = a; + } + const lat0 = (minLat + maxLat) / 2; + const lon0 = lon[0]; + const kx = M_PER_DEG * Math.cos((lat0 * Math.PI) / 180); + const ky = M_PER_DEG; + const x = new Float64Array(n); + const y = new Float64Array(n); + for (let i = 0; i < n; i++) { + x[i] = wrapLon(lon[i] - lon0) * kx; + y[i] = (lat[i] - lat0) * ky; + } + const s = new Float64Array(n); + let given = dist?.length === n && Number(dist[0]) >= 0; + for (let i = 0; given && i < n; i++) { + const d = Number(dist[i]); + if (!Number.isFinite(d) || (i > 0 && d < s[i - 1])) given = false; + else s[i] = d; + } + if (!given) { + s[0] = 0; + for (let i = 1; i < n; i++) + s[i] = s[i - 1] + Math.hypot(x[i] - x[i - 1], y[i] - y[i - 1]); + } + const chunks = Math.ceil((n - 1) / CHUNK); + const box = new Float64Array(chunks * 4); + for (let c = 0; c < chunks; c++) { + let x0 = Infinity; + let x1 = -Infinity; + let y0 = Infinity; + let y1 = -Infinity; + const end = Math.min(n - 1, (c + 1) * CHUNK); + for (let i = c * CHUNK; i <= end; i++) { + if (x[i] < x0) x0 = x[i]; + if (x[i] > x1) x1 = x[i]; + if (y[i] < y0) y0 = y[i]; + if (y[i] > y1) y1 = y[i]; + } + box[4 * c] = x0; + box[4 * c + 1] = x1; + box[4 * c + 2] = y0; + box[4 * c + 3] = y1; + } + return Object.freeze({ + n, + lat, + lon, + x, + y, + s, + box, + lat0, + lon0, + kx, + ky, + length: s[n - 1], + }); +} + +/** Index of the segment [i, i+1] whose arc range holds `s`, clamped. */ +function segmentAt(path, s) { + const arc = path.s; + let lo = 0; + let hi = path.n - 2; + if (!(s > arc[0])) return 0; + if (s >= arc[hi + 1]) return hi; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (arc[mid] <= s) lo = mid; + else hi = mid - 1; + } + return lo; +} + +/** + * Squared distance from (px, py) to segment i, writing the parameter to `hit`. + * @returns {number} + */ +function segmentDistance2(path, i, px, py, hit) { + const { x, y } = path; + const dx = x[i + 1] - x[i]; + const dy = y[i + 1] - y[i]; + const len2 = dx * dx + dy * dy; + let t = len2 > 0 ? ((px - x[i]) * dx + (py - y[i]) * dy) / len2 : 0; + if (t < 0) t = 0; + else if (t > 1) t = 1; + const ex = x[i] + dx * t - px; + const ey = y[i] + dy * t - py; + hit.t = t; + return ex * ex + ey * ey; +} + +/** + * Project a position onto a path. + * + * With a hint, only the segments whose arc range overlaps + * `[hintS - windowM, hintS + windowM]` are searched: a train known to be near + * arc `hintS` cannot jump to the far side of a loop or to a parallel branch + * that the same shape passes later. Without one, the whole path is searched. + * + * @param {object} path From createRailPath. + * @param {number} lat + * @param {number} lon + * @param {number} [hintS] Expected arc length (m). + * @param {number} [windowM] Half-width of the search window around the hint (m). + * @param {{s?: number, offsetM?: number, index?: number}} [out] + * @returns {{s: number, offsetM: number, index: number}|null} + */ +export function projectOnPath(path, lat, lon, hintS, windowM, out = {}) { + if (!path || !Number.isFinite(lat) || !Number.isFinite(lon)) return null; + const px = wrapLon(lon - path.lon0) * path.kx; + const py = (lat - path.lat0) * path.ky; + const hit = { t: 0 }; + let best = Infinity; + let bestIndex = -1; + let bestT = 0; + if (Number.isFinite(hintS) && Number.isFinite(windowM) && windowM >= 0) { + const first = segmentAt(path, hintS - windowM); + const last = segmentAt(path, hintS + windowM); + for (let i = first; i <= last; i++) { + const d2 = segmentDistance2(path, i, px, py, hit); + if (d2 < best) { + best = d2; + bestIndex = i; + bestT = hit.t; + } + } + } else { + const { box } = path; + const chunks = box.length / 4; + for (let c = 0; c < chunks; c++) { + const ox = Math.max(box[4 * c] - px, 0, px - box[4 * c + 1]); + const oy = Math.max(box[4 * c + 2] - py, 0, py - box[4 * c + 3]); + if (ox * ox + oy * oy >= best) continue; + const end = Math.min(path.n - 2, (c + 1) * CHUNK - 1); + for (let i = c * CHUNK; i <= end; i++) { + const d2 = segmentDistance2(path, i, px, py, hit); + if (d2 < best) { + best = d2; + bestIndex = i; + bestT = hit.t; + } + } + } + } + if (bestIndex < 0) return null; + const s = path.s; + out.s = s[bestIndex] + (s[bestIndex + 1] - s[bestIndex]) * bestT; + out.offsetM = Math.sqrt(best); + out.index = bestIndex; + return out; +} + +/** + * The position and direction of travel at an arc length, clamped to the path. + * @param {object} path From createRailPath. + * @param {number} s Arc length (m). + * @param {{lat?: number, lon?: number, bearingDeg?: number}} [out] + * @returns {{lat: number, lon: number, bearingDeg: number}|null} + */ +export function pointAtS(path, s, out = {}) { + if (!path || !Number.isFinite(s)) return null; + const clamped = Math.max(0, Math.min(path.length, s)); + let i = segmentAt(path, clamped); + // A zero-length segment has no direction; borrow its neighbour's. + while (i < path.n - 2 && path.s[i + 1] === path.s[i]) i++; + const span = path.s[i + 1] - path.s[i]; + const t = + span > 0 ? Math.max(0, Math.min(1, (clamped - path.s[i]) / span)) : 0; + out.lat = path.lat[i] + (path.lat[i + 1] - path.lat[i]) * t; + let lon = path.lon[i] + wrapLon(path.lon[i + 1] - path.lon[i]) * t; + if (lon > 180) lon -= 360; + else if (lon < -180) lon += 360; + out.lon = lon; + const dx = path.x[i + 1] - path.x[i]; + const dy = path.y[i + 1] - path.y[i]; + out.bearingDeg = + dx === 0 && dy === 0 + ? NaN + : ((Math.atan2(dx, dy) * 180) / Math.PI + 360) % 360; + return out; +} + +/** Path-pack format this module reads. */ +export const RAIL_PATH_PACK_VERSION = 1; + +/** + * Wrap a decoded path pack for lookups. Shapes are indexed lazily, the first + * time a train asks for one. + * @param {object} raw The JSON body of `/api/rail/paths/`. + * @returns {{feedId: string, shapeCount: number, + * pathAt: (index: number) => object|null, + * shapeForTrip: (tripId: string) => number|null, + * shapesForRoute: (routeId: string) => number[]}} + */ +export function createRailPathPack(raw) { + if ( + !raw || + raw.version !== RAIL_PATH_PACK_VERSION || + typeof raw.feedId !== 'string' || + !Array.isArray(raw.shapes) || + !Array.isArray(raw.dist) || + raw.dist.length !== raw.shapes.length + ) + throw new TypeError('Malformed rail path pack'); + const shapes = raw.shapes; + const dist = raw.dist; + const trips = raw.trips && typeof raw.trips === 'object' ? raw.trips : {}; + const routes = raw.routes && typeof raw.routes === 'object' ? raw.routes : {}; + const built = new Map(); + const validIndex = (value) => + Number.isInteger(value) && value >= 0 && value < shapes.length + ? value + : null; + return Object.freeze({ + feedId: raw.feedId, + /** When the pack was built (ISO), matched by a schedule's pathsBuiltAt. */ + builtAt: typeof raw.builtAt === 'string' ? raw.builtAt : null, + shapeCount: shapes.length, + pathAt(index) { + if (validIndex(index) === null) return null; + if (!built.has(index)) + built.set(index, createRailPath(shapes[index], dist[index])); + return built.get(index); + }, + shapeForTrip(tripId) { + const id = String(tripId ?? ''); + return id && Object.hasOwn(trips, id) ? validIndex(trips[id]) : null; + }, + shapesForRoute(routeId) { + const id = String(routeId ?? ''); + const list = id && Object.hasOwn(routes, id) ? routes[id] : null; + return Array.isArray(list) + ? list.filter((value) => validIndex(value) !== null) + : []; + }, + }); +} diff --git a/src/data/railPaths.test.mjs b/src/data/railPaths.test.mjs new file mode 100644 index 000000000..c65a1c6e7 --- /dev/null +++ b/src/data/railPaths.test.mjs @@ -0,0 +1,163 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + RAIL_PATH_PACK_VERSION, + createRailPath, + createRailPathPack, + pointAtS, + projectOnPath, +} from './railPaths.js'; + +const M_PER_DEG = 111_320; + +/** An L: 1 km east along the equator, then 1 km north. */ +function lPath() { + const dLon = 1000 / M_PER_DEG; + return createRailPath([0, 0, 0, dLon, dLon, dLon]); +} + +/** + * A hairpin: 2 km east along lat 0, a 100 m step north, then 2 km back west. + * The two legs are parallel and 100 m apart, like the two sides of a loop. + */ +function hairpin() { + const d = 1 / M_PER_DEG; + return createRailPath([0, 0, 0, 2000 * d, 100 * d, 2000 * d, 100 * d, 0]); +} + +test('a path measures its arc length in metres', () => { + const path = lPath(); + assert.equal(path.n, 3); + assert.ok(Math.abs(path.length - 2000) < 0.5, String(path.length)); + assert.equal(createRailPath([0, 0]), null, 'one point is not a path'); + assert.equal(createRailPath([0, 0, Number.NaN, 1]), null); + assert.equal(createRailPath(null), null); +}); + +test('given distances are used, and bad ones are measured instead', () => { + const coords = [0, 0, 0, 0.01, 0, 0.02]; + assert.deepEqual([...createRailPath(coords, [0, 5, 10]).s], [0, 5, 10]); + const measured = createRailPath(coords, [0, 10, 5]); + assert.ok(Math.abs(measured.s[2] - 2 * 0.01 * M_PER_DEG) < 0.5); + const wrongSize = createRailPath(coords, [0, 1]); + assert.ok(wrongSize.s[1] > 1000); +}); + +test('projection finds the arc and the offset from the line', () => { + const path = lPath(); + const d = 1 / M_PER_DEG; + const hit = projectOnPath(path, 20 * d, 500 * d); + assert.ok(Math.abs(hit.s - 500) < 0.5, String(hit.s)); + assert.ok(Math.abs(hit.offsetM - 20) < 0.5, String(hit.offsetM)); + assert.equal(hit.index, 0); + const second = projectOnPath(path, 300 * d, 1030 * d); + assert.ok(Math.abs(second.s - 1300) < 0.5, String(second.s)); + assert.ok(Math.abs(second.offsetM - 30) < 0.5); + assert.equal(second.index, 1); + const beyond = projectOnPath(path, 2000 * d, 1000 * d); + assert.ok(Math.abs(beyond.s - 2000) < 0.5, 'clamped to the end'); + assert.ok(Math.abs(beyond.offsetM - 1000) < 1); + assert.equal(projectOnPath(path, Number.NaN, 0), null); + assert.equal(projectOnPath(null, 0, 0), null); +}); + +test('a hint keeps the projection on its own side of a loop', () => { + const path = hairpin(); + const d = 1 / M_PER_DEG; + // 45 m north of the outbound leg, 55 m south of the return leg: the + // whole-path answer is the outbound leg... + const point = [45 * d, 1000 * d]; + const free = projectOnPath(path, ...point); + assert.ok(Math.abs(free.s - 1000) < 1, String(free.s)); + // ...but a train known to be on the return leg stays on it. + const hinted = projectOnPath(path, ...point, 3100, 500); + assert.ok(Math.abs(hinted.s - 3100) < 1, String(hinted.s)); + assert.ok(Math.abs(hinted.offsetM - 55) < 0.5); +}); + +test('the whole-path search agrees with a brute-force scan on a long path', () => { + const coords = []; + for (let i = 0; i < 500; i++) + coords.push(40 + Math.sin(i / 20) * 0.01, -74 + i * 0.001); + const path = createRailPath(coords); + for (const [lat, lon] of [ + [40.004, -73.9], + [40.02, -73.75], + [39.99, -73.6], + ]) { + const fast = projectOnPath(path, lat, lon); + let best = Infinity; + for (let i = 0; i < path.n - 1; i++) { + const hit = projectOnPath( + path, + lat, + lon, + (path.s[i] + path.s[i + 1]) / 2, + 0, + ); + best = Math.min(best, hit.offsetM); + } + assert.ok( + Math.abs(fast.offsetM - best) < 1e-6, + `${fast.offsetM} vs ${best}`, + ); + } +}); + +test('the point at an arc length carries the direction of travel', () => { + const path = lPath(); + const d = 1 / M_PER_DEG; + const east = pointAtS(path, 250); + assert.ok(Math.abs(east.lon - 250 * d) < 1e-9); + assert.equal(east.lat, 0); + assert.ok(Math.abs(east.bearingDeg - 90) < 1e-6, String(east.bearingDeg)); + const north = pointAtS(path, 1500); + assert.ok(Math.abs(north.lat - 500 * d) < 1e-6); + assert.ok( + Math.abs(north.bearingDeg - 0) < 1e-6 || + Math.abs(north.bearingDeg - 360) < 1e-6, + ); + const out = {}; + assert.equal(pointAtS(path, -50, out), out, 'writes into the caller scratch'); + assert.equal(out.lat, 0); + assert.equal(out.lon, 0); + assert.ok(Math.abs(pointAtS(path, 9e9).lat - 1000 * d) < 1e-9); + assert.equal(pointAtS(path, Number.NaN), null); +}); + +test('a pack resolves trips and routes to shapes and builds paths lazily', () => { + const raw = { + version: RAIL_PATH_PACK_VERSION, + feedId: 'grt-ion', + shapes: [ + [43.4, -80.5, 43.41, -80.5], + [43.4, -80.5, 43.4, -80.49], + ], + dist: [ + [0, 1113.2], + [0, 808.9], + ], + trips: { t1: 0, t2: 1, bad: 7 }, + routes: { 301: [0, 1, 9] }, + }; + const pack = createRailPathPack(raw); + assert.equal(pack.feedId, 'grt-ion'); + assert.equal(pack.shapeCount, 2); + assert.equal(pack.shapeForTrip('t1'), 0); + assert.equal(pack.shapeForTrip('bad'), null, 'out-of-range index'); + assert.equal(pack.shapeForTrip('constructor'), null, 'no prototype keys'); + assert.equal(pack.shapeForTrip(''), null); + assert.deepEqual(pack.shapesForRoute('301'), [0, 1]); + assert.deepEqual(pack.shapesForRoute('302'), []); + const path = pack.pathAt(1); + assert.equal(path.length, 808.9); + assert.equal(pack.pathAt(1), path, 'built once'); + assert.equal(pack.pathAt(5), null); + for (const broken of [ + null, + { ...raw, version: 2 }, + { ...raw, feedId: 3 }, + { ...raw, dist: [] }, + ]) + assert.throws(() => createRailPathPack(broken), TypeError); +}); diff --git a/src/data/railProxy.js b/src/data/railProxy.js new file mode 100644 index 000000000..2bea20157 --- /dev/null +++ b/src/data/railProxy.js @@ -0,0 +1,518 @@ +/** + * @module railProxy + * @description Pure server-side mechanics for the `/api/rail` proxy. + * + * The upstream mechanics are the Transit proxy's (src/data/transitProxy.js): + * the same decoder, timestamp repair, redirect rule, conditional headers, cache + * windows and backoff ladder. What differs is which feeds are reachable and + * what leaves the server: only vehicles a feed's classifier recognises as + * trains, each tagged with its category, mode and position source. On top of + * that come the Irish Rail XML and SIRI-VM adapters and trip updates. Every + * train is placed where its operator reports it. Every feed is open: no + * request carries a credential. + */ + +import { + TRANSIT_ADMISSION_MAX_GLOBAL, + TRANSIT_PROXY_MAX_BODY_BYTES, + TRANSIT_PROXY_STALE_MAX_MS, + TRANSIT_PROXY_TTL_MS, + TransitFeedShapeError, + buildTransitSnapshot, +} from './transitProxy.js'; +import { GTFS_INCREMENTALITY_FULL_DATASET } from './gtfsRealtime.js'; +import { + RAIL_FEED_REGISTRY, + classifyTrain, + getRailFeed, + railFeedHasTripUpdates, + railFeedPositions, +} from './railFeeds.js'; +import { decodeIrishRailTrains } from './irishRail.js'; +import { decodeSiriVehicleMonitoring } from './railSiriVm.js'; +import { decodeTripUpdates } from './railTripUpdates.js'; +import { isNoTrainPositionPoint } from './railNoPositions.js'; + +/** + * Text formats with their own adapter, by registry `format`. Each turns the + * document and the fetch time (epoch s) into `{entityCount, truncated, + * vehicles, feedTimestamp?}`. + */ +const TEXT_ADAPTERS = Object.freeze({ + 'irish-rail-xml': decodeIrishRailTrains, + 'siri-vm': decodeSiriVehicleMonitoring, +}); + +export { + TRANSIT_ADMISSION_MAX_PER_FEED as RAIL_ADMISSION_MAX_PER_FEED, + TRANSIT_ADMISSION_WINDOW_MS as RAIL_ADMISSION_WINDOW_MS, + TRANSIT_PROXY_TIMEOUT_MS as RAIL_PROXY_TIMEOUT_MS, + TransitFeedShapeError as RailFeedShapeError, + isAcceptableTransitUpstreamUrl as isAcceptableRailUpstreamUrl, + nextTransitBackoffMs as nextRailBackoffMs, +} from './transitProxy.js'; + +/** + * Largest path or schedule pack the proxy serves or reads (bytes). The + * biggest national packs built so far were 14 to 16 MB; anything far beyond + * that is a build fault, not a pack to ship. A pack over the cap is refused + * with its own answer, not taken for a missing one. + */ +export const RAIL_PATH_PACK_MAX_BYTES = 24 * 1024 * 1024; + +/** + * Default freshness window for trip updates. Predictions move more slowly + * than positions, and several operators refresh them every 30 to 60 s. + */ +export const RAIL_TRIP_UPDATES_TTL_MS = 30_000; +/** Shortest window a registry entry may ask for. */ +export const RAIL_MIN_TTL_MS = 5_000; +/** Trips one `/updates` answer carries, after the rail filter. */ +export const RAIL_UPDATES_MAX_TRIPS = 3_000; +/** Stop updates one `/updates` answer carries, across all its trips. */ +export const RAIL_UPDATES_MAX_STOPS = 40_000; + +/** + * Freshness window for one route of a feed: its own interval when the + * registry sets one (never below RAIL_MIN_TTL_MS), else the default. + * @param {object} feed + * @param {'trains'|'updates'} route + * @returns {number} ms + */ +export function railFeedTtlMs(feed, route = 'trains') { + const own = + route === 'updates' ? feed?.tripUpdatesIntervalMs : feed?.pollIntervalMs; + if (Number.isFinite(own) && own > 0) return Math.max(RAIL_MIN_TTL_MS, own); + return route === 'updates' ? RAIL_TRIP_UPDATES_TTL_MS : TRANSIT_PROXY_TTL_MS; +} + +/** + * Largest byte cap a feed may set for itself (`maxBodyBytes`): nothing + * larger is read into memory. + */ +export const RAIL_PROXY_MAX_BODY_CEILING_BYTES = 64 * 1024 * 1024; + +/** + * Upstream byte cap for a feed: its own cap when it sets one (never above + * RAIL_PROXY_MAX_BODY_CEILING_BYTES), else the Transit proxy's 8 MiB. + * @param {object} feed + * @returns {number} + */ +export function railMaxBodyBytes(feed) { + const own = feed?.maxBodyBytes; + return Number.isFinite(own) && own > 0 + ? Math.min(own, RAIL_PROXY_MAX_BODY_CEILING_BYTES) + : TRANSIT_PROXY_MAX_BODY_BYTES; +} + +/** + * Classify a cache entry against a freshness window: the Transit policy with + * a per-feed TTL. The serve-stale window is the same ten minutes. + * @param {{at: number}|null|undefined} entry + * @param {number} now + * @param {number} [ttlMs] + * @returns {'none'|'fresh'|'stale'|'expired'} + */ +export function railCacheStateFor(entry, now, ttlMs = TRANSIT_PROXY_TTL_MS) { + if (!entry || !Number.isFinite(entry.at)) return 'none'; + const age = now - entry.at; + if (age < ttlMs) return 'fresh'; + if (age < Math.max(TRANSIT_PROXY_STALE_MAX_MS, ttlMs)) return 'stale'; + return 'expired'; +} + +/** + * Resolve `/trains/`, `/paths/`, `/schedule/` or + * `/updates/` (the path after the `/api/rail` mount) to a pollable + * feed. Anything else (another route, an unknown or disabled id, an + * `/updates` request for a feed with no trip updates, path tricks) resolves + * to null and the caller 404s. + * @param {string} url Request URL relative to the mount point. + * @returns {{ route: 'feeds' } | + * { route: 'trains'|'paths'|'schedule'|'updates', feed: object } | null} + */ +export function resolveRailRoute(url) { + const pathname = String(url || '').split('?')[0]; + if (pathname === '/feeds' || pathname === '/feeds/') + return { route: 'feeds' }; + const match = /^\/(trains|paths|schedule|updates)\/([^/]+)\/?$/.exec( + pathname, + ); + if (!match) return null; + let id; + try { + id = decodeURIComponent(match[2]); + } catch { + return null; + } + const feed = getRailFeed(id); + if (!feed) return null; + if (match[1] === 'updates' && !railFeedHasTripUpdates(feed)) return null; + return { route: match[1], feed }; +} + +/** + * Classify decoded vehicles and keep only trains. A position where no train + * is placed (src/data/railNoPositions.js: Ukraine) is dropped first, so it + * is only counted and never leaves the server. + */ +function keepTrains(feed, vehicles, routeFor = null) { + const positionSource = railFeedPositions(feed) === 'event' ? 'event' : 'gps'; + const trains = []; + for (const vehicle of vehicles) { + if (isNoTrainPositionPoint(vehicle.lat, vehicle.lon)) continue; + const routeId = resolvedRouteId(feed, vehicle, routeFor); + const kind = classifyTrain(feed, routeId, { tripId: vehicle.tripId }); + if (!kind) continue; + trains.push({ + ...vehicle, + routeId, + category: kind.category, + mode: kind.mode, + ...(kind.tourist ? { tourist: true } : {}), + positionSource, + }); + } + return trains; +} + +/** + * The route a record is classified by. A feed with `routesFrom: 'schedule'` + * publishes trips whose route is missing or not the one its classifier reads; + * `routeFor(tripId)` answers from the feed's schedule pack, whose route ids + * the build wrote to carry the product. A trip the pack does not know keeps + * what the feed said, which its classifier then drops. + */ +function resolvedRouteId(feed, record, routeFor) { + if (feed?.routesFrom !== 'schedule' || typeof routeFor !== 'function') + return record.routeId; + return (record.tripId && routeFor(record.tripId)) || record.routeId; +} + +/** + * For a feed whose positions name each train by its number + * (`positionsByTrainNumber`, Irish Rail's train codes), the record takes + * the schedule pack's trip that number is running now (`tripFor`, given the + * number and the record, whose stated delay moves its timetable), so the + * train is drawn on its line and its card lists its stops; the number stays + * as `trainNumber`. A number the timetable does not place keeps its own id. + */ +function byTrainNumber(feed, vehicles, tripFor) { + if (feed?.positionsByTrainNumber !== true || typeof tripFor !== 'function') + return vehicles; + return vehicles.map((vehicle) => { + const tripId = vehicle.tripId ? tripFor(vehicle.tripId, vehicle) : null; + return tripId + ? { ...vehicle, tripId, trainNumber: vehicle.tripId } + : vehicle; + }); +} + +/** + * For a feed whose header clock cannot be trusted (`headerClock: + * 'untrusted'`, Renfe: local time written as UTC), a time the Transit + * ladder took from the header is replaced by the fetch time and labelled so. + */ +function distrustHeaderClock(feed, vehicles, now) { + if (feed?.headerClock !== 'untrusted') return vehicles; + const fetchedS = Math.floor(now / 1000); + return vehicles.map((vehicle) => + vehicle.timestampSource === 'header' + ? { ...vehicle, timestamp: fetchedS, timestampSource: 'fetch' } + : vehicle, + ); +} + +/** + * An upstream answer that is no document of the feed's: a failed fetch, not + * a reason to distrust the last good snapshot, which the service keeps + * serving while it is young enough (`code: 'UPSTREAM_UNREADABLE'`, a 502). + * @param {string} detail + * @returns {Error & {code: string}} + */ +function unreadableUpstream(detail) { + const failure = new Error(`upstream document unreadable: ${detail}`); + failure.code = 'UPSTREAM_UNREADABLE'; + return failure; +} + +/** + * Run a GTFS-RT decode and refuse what is not a FeedMessage: bytes protobuf + * cannot read (an HTML or JSON error page served as 200), and a message + * without a FeedHeader or its gtfs_realtime_version, which every + * FeedMessage carries. An empty body (a 200 or 204 with nothing in it) + * decodes to exactly that, and would otherwise read as a full snapshot with + * no trains. A DIFFERENTIAL feed's refusal passes through as it is. + * @template {{version: string|null}} T + * @param {() => T} decode + * @returns {T} + */ +function decodeGtfsRealtime(decode) { + let decoded; + try { + decoded = decode(); + } catch (error) { + if (error instanceof TransitFeedShapeError) throw error; + throw unreadableUpstream(error?.message || String(error)); + } + if (decoded?.version === null || decoded?.version === undefined) + throw unreadableUpstream('no FeedHeader with a gtfs_realtime_version'); + return decoded; +} + +/** + * Decode upstream bytes into the JSON snapshot the Rail layer consumes. + * + * For GTFS-RT feeds, decoding, the refusal of DIFFERENTIAL feeds and the + * report-time ladder are the Transit proxy's; a body that is no FeedMessage + * (no header, no version, not protobuf) is refused as unreadable + * (decodeGtfsRealtime). Irish Rail's XML and SIRI-VM go through their + * adapters. The classifier then runs on every vehicle: a vehicle it does + * not recognise (a bus in a mixed feed, a shuttle, an out-of-service vehicle + * with no route in a mixed feed) is dropped and only counted, so the browser + * receives trains and nothing else. Every train carries `positionSource`: + * `gps` for GTFS-RT positions, `event` for signalling events. + * + * @param {object} feed Rail registry entry. + * @param {Uint8Array|ArrayBuffer} bytes Raw upstream body. + * @param {number} [now=Date.now()] Fetch time (ms epoch). + * @param {{routeFor?: Function|null, tripFor?: Function|null}} [lookups] + * `routeFor` resolves a trip's route from the schedule pack, and + * `tripFor(number, record)` a train number's running trip + * (`positionsByTrainNumber`). + * @returns {{ feedId: string, name: string, fetchedAt: number, feedTimestamp: number|null, + * version: string|null, entityCount: number, truncated: boolean, count: number, + * dropped: number, positions: string, trains: object[] }} + */ +export function buildRailSnapshot( + feed, + bytes, + now = Date.now(), + { routeFor = null, tripFor = null } = {}, +) { + const adapter = Object.hasOwn(TEXT_ADAPTERS, feed?.format) + ? TEXT_ADAPTERS[feed.format] + : null; + if (adapter) { + const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); + const text = new TextDecoder('utf-8').decode(view); + let decoded; + try { + decoded = adapter(text, Math.floor(now / 1000)); + } catch (error) { + // An unreadable document (an error page served as 200, a truncated + // body) is a failed fetch, not a reason to distrust the last good one. + throw unreadableUpstream(error?.message || String(error)); + } + const trains = keepTrains( + feed, + byTrainNumber(feed, decoded.vehicles, tripFor), + routeFor, + ); + return { + feedId: feed.id, + name: feed.name, + fetchedAt: now, + feedTimestamp: Number.isFinite(decoded.feedTimestamp) + ? decoded.feedTimestamp + : null, + version: null, + entityCount: decoded.entityCount, + truncated: decoded.truncated, + count: trains.length, + dropped: decoded.vehicles.length - trains.length, + positions: railFeedPositions(feed), + trains, + }; + } + const decoded = decodeGtfsRealtime(() => + buildTransitSnapshot(feed, bytes, now), + ); + const trains = keepTrains( + feed, + distrustHeaderClock(feed, decoded.vehicles, now), + routeFor, + ); + return { + feedId: decoded.feedId, + name: decoded.name, + fetchedAt: decoded.fetchedAt, + feedTimestamp: + feed.headerClock === 'untrusted' ? null : decoded.feedTimestamp, + version: decoded.version, + entityCount: decoded.entityCount, + truncated: decoded.truncated, + count: trains.length, + dropped: decoded.vehicles.length - trains.length, + positions: railFeedPositions(feed), + trains, + }; +} + +/** + * Decode a TripUpdates feed into the `/api/rail/updates/` body: one + * record per rail trip, filtered with the same classifier as the positions + * (a feed's buses never leave the server), capped in trips and stops. + * + * A DIFFERENTIAL feed is refused, for the same reason positions are: an + * unmentioned trip would read as one with no prediction. A body that is no + * FeedMessage is refused as unreadable (decodeGtfsRealtime). + * + * @param {object} feed Rail registry entry. + * @param {Uint8Array|ArrayBuffer} bytes Raw GTFS-RT FeedMessage. + * @param {number} [now=Date.now()] Fetch time (ms epoch). + * @param {{routeFor?: ((tripId: string) => string|null)|null}} [lookups] + * `routeFor` is the schedule pack's trip-to-route lookup, for a feed + * whose trips do not carry the route its classifier reads (`routesFrom: + * 'schedule'`). + * @returns {{ feedId: string, name: string, fetchedAt: number, + * feedTimestamp: number|null, entityCount: number, truncated: boolean, + * count: number, dropped: number, trips: object[] }} + */ +export function buildRailTripUpdatesSnapshot( + feed, + bytes, + now = Date.now(), + { routeFor = null } = {}, +) { + const decoded = decodeGtfsRealtime(() => decodeTripUpdates(bytes)); + if (decoded.incrementality !== GTFS_INCREMENTALITY_FULL_DATASET) { + throw new TransitFeedShapeError( + `feed is differential (incrementality ${decoded.incrementality})`, + 'differential', + ); + } + const trips = []; + let truncated = decoded.truncated; + let stops = 0; + let rail = 0; + for (const trip of decoded.trips) { + const routeId = resolvedRouteId(feed, trip, routeFor); + const kind = classifyTrain(feed, routeId, { tripId: trip.tripId }); + if (!kind) continue; + rail += 1; + if ( + trips.length >= RAIL_UPDATES_MAX_TRIPS || + stops + trip.stops.length > RAIL_UPDATES_MAX_STOPS + ) { + truncated = true; + continue; + } + stops += trip.stops.length; + trips.push({ + ...trip, + routeId, + category: kind.category, + mode: kind.mode, + ...(kind.tourist ? { tourist: true } : {}), + }); + } + return { + feedId: feed.id, + name: feed.name, + fetchedAt: now, + feedTimestamp: decoded.timestamp, + entityCount: decoded.entityCount, + truncated, + count: trips.length, + dropped: decoded.trips.length - rail, + trips, + }; +} + +/** + * Upstream request headers. Same obligations as Transit's (conditional + * validators, gzip, a User-Agent that says who is asking, any identify-yourself + * header the registry names), with this proxy's own name in the User-Agent. + * A feed's own `headers` (an Accept for an XML feed) come last. + * @param {object} feed Registry entry. + * @param {{etag?: string|null, lastModified?: string|null}} [validators] + * @returns {Record} + */ +export function railUpstreamHeaders(feed, validators = null) { + return { + 'User-Agent': + 'gods-eye-view-rail-proxy/1.0 (+https://github.com/bilawalsidhu/gods-eye-view)', + Accept: 'application/x-protobuf, application/octet-stream;q=0.9, */*;q=0.1', + 'Accept-Encoding': 'gzip', + ...(validators?.etag ? { 'If-None-Match': validators.etag } : {}), + ...(validators?.lastModified + ? { 'If-Modified-Since': validators.lastModified } + : {}), + ...(feed?.headers || {}), + }; +} + +/** + * Upstream requests one minute may need when every feed in the registry is + * polled at once: one per freshness window for each feed's positions and + * trip updates. + * @param {ReadonlyArray} [feeds] + * @returns {number} + */ +export function railNaturalRequestsPerMinute(feeds = RAIL_FEED_REGISTRY) { + let total = 0; + for (const feed of feeds) { + if (feed?.defaultEnabled !== true) continue; + if (feed.url) total += Math.ceil(60_000 / railFeedTtlMs(feed, 'trains')); + if (railFeedHasTripUpdates(feed)) + total += Math.ceil(60_000 / railFeedTtlMs(feed, 'updates')); + } + return total; +} + +/** + * The process-wide admission cap (requests per admission window, all + * operators): the natural load of every enabled feed, half again on top for + * retries on the backoff ladder, never below the Transit proxy's own cap + * (TRANSIT_ADMISSION_MAX_GLOBAL, 40). It is a backstop against a bug or a + * runaway client, not the protection an operator relies on: that is each + * feed's freshness window and the per-feed admission count. Transit's fixed + * cap alone sat below the natural load once the registry passed about ten + * feeds polled every 15 s, so a viewer of several regions got 503s from a + * healthy proxy. + * @param {ReadonlyArray} [feeds] + * @returns {number} + */ +export function railAdmissionGlobalMax(feeds = RAIL_FEED_REGISTRY) { + return Math.max( + TRANSIT_ADMISSION_MAX_GLOBAL, + Math.ceil(railNaturalRequestsPerMinute(feeds) * 1.5), + ); +} + +/** + * Response headers for a snapshot. `X-GEV-Cache` mirrors the other proxies + * (HIT / MISS / INFLIGHT / STALE-ERROR); `X-Rail-Contact` is when the operator + * last ANSWERED, which a revalidated (304) feed advances without changing the + * body's own fetch time. `max-age` is what is left of the feed's own + * freshness window once the snapshot is `ageMs` old, so a HIT near the end + * of the window is not kept a whole window more downstream. + * @param {'HIT'|'MISS'|'INFLIGHT'|'STALE-ERROR'} cacheState + * @param {string} [upstreamHost] + * @param {number|null} [contactedAt] + * @param {number} [ttlMs] + * @param {number} [ageMs] How old the snapshot is (ms); 0 for a fresh one. + * @returns {Record} + */ +export function railResponseHeaders( + cacheState, + upstreamHost = '', + contactedAt = null, + ttlMs = TRANSIT_PROXY_TTL_MS, + ageMs = 0, +) { + const age = Number.isFinite(ageMs) && ageMs > 0 ? ageMs : 0; + return { + 'Content-Type': 'application/json; charset=utf-8', + 'Cache-Control': + cacheState === 'STALE-ERROR' + ? 'no-store' + : `public, max-age=${Math.max(0, Math.ceil((ttlMs - age) / 1000))}`, + 'X-GEV-Cache': cacheState, + ...(upstreamHost ? { 'X-Rail-Upstream': upstreamHost } : {}), + ...(Number.isFinite(contactedAt) + ? { 'X-Rail-Contact': String(Math.floor(contactedAt)) } + : {}), + }; +} diff --git a/src/data/railProxy.test.mjs b/src/data/railProxy.test.mjs new file mode 100644 index 000000000..d5e9ccdc7 --- /dev/null +++ b/src/data/railProxy.test.mjs @@ -0,0 +1,586 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { PbfWriter } from 'pbf'; +import { + RAIL_UPDATES_MAX_TRIPS, + RailFeedShapeError, + buildRailSnapshot, + buildRailTripUpdatesSnapshot, + railAdmissionGlobalMax, + railNaturalRequestsPerMinute, + railCacheStateFor, + railFeedTtlMs, + railMaxBodyBytes, + railResponseHeaders, + railUpstreamHeaders, + resolveRailRoute, +} from './railProxy.js'; +import { + RAIL_FEED_REGISTRY, + getRailFeed, + getRegisteredRailFeed, + railFeedPositions, +} from './railFeeds.js'; + +/** A minimal TripUpdates FeedMessage: `[tripId, routeId, delay]` rows. */ +function encodeTripUpdates(rows, { incrementality = null } = {}) { + const writer = new PbfWriter(); + const header = new PbfWriter(); + header.writeStringField(1, '2.0'); + if (incrementality !== null) header.writeVarintField(2, incrementality); + header.writeVarintField(3, 1_794_000_000); + writer.writeBytesField(1, header.finish()); + for (const [tripId, routeId, delay, stopCount = 1] of rows) { + const entity = new PbfWriter(); + entity.writeStringField(1, tripId); + const update = new PbfWriter(); + const trip = new PbfWriter(); + trip.writeStringField(1, tripId); + if (routeId) trip.writeStringField(5, routeId); + update.writeBytesField(1, trip.finish()); + for (let i = 0; i < stopCount; i += 1) { + const stop = new PbfWriter(); + stop.writeVarintField(1, i + 1); + const arrival = new PbfWriter(); + arrival.writeVarintField(1, delay); + stop.writeBytesField(2, arrival.finish()); + stop.writeStringField(4, `s${i}`); + update.writeBytesField(2, stop.finish()); + } + entity.writeBytesField(3, update.finish()); + writer.writeBytesField(2, entity.finish()); + } + return writer.finish(); +} + +const IRISH_XML = ` + + + R + 54.0 + -6.4 + A118 + 06 Nov 2026 + A118\\n14:50 - Dublin Connolly to Belfast (0 mins late)\\nDeparted Dundalk next stop Newry + Northbound + + + R + 53.29 + -6.13 + E931 + 06 Nov 2026 + E931\\n15:03 - Greystones to Howth (16 mins late)\\nDeparted Sutton next stop Howth + Northbound + +`; + +/** A minimal FeedMessage whose vehicles carry a trip route id. */ +function encodeFeed({ incrementality = null, entities = [] }) { + const writer = new PbfWriter(); + const header = new PbfWriter(); + header.writeStringField(1, '2.0'); + if (incrementality !== null) header.writeVarintField(2, incrementality); + header.writeVarintField(3, 1_700_000_000); + writer.writeBytesField(1, header.finish()); + for (const entity of entities) { + const feedEntity = new PbfWriter(); + feedEntity.writeStringField(1, entity.id); + const vehicle = new PbfWriter(); + if (entity.route) { + const trip = new PbfWriter(); + trip.writeStringField(1, `trip-${entity.id}`); + trip.writeStringField(5, entity.route); + vehicle.writeBytesField(1, trip.finish()); + } + const position = new PbfWriter(); + position.writeFloatField(1, entity.lat ?? 42.35); + position.writeFloatField(2, entity.lon ?? -71.06); + vehicle.writeBytesField(2, position.finish()); + vehicle.writeVarintField(5, 1_699_999_990); + const descriptor = new PbfWriter(); + descriptor.writeStringField(1, entity.id); + // A vendor extension (field 1999), as Adelaide Metro sends: it must be + // skipped, not fail the snapshot. + const extension = new PbfWriter(); + extension.writeVarintField(1, 1); + descriptor.writeBytesField(1999, extension.finish()); + vehicle.writeBytesField(8, descriptor.finish()); + feedEntity.writeBytesField(4, vehicle.finish()); + writer.writeBytesField(2, feedEntity.finish()); + } + return writer.finish(); +} + +test('route resolution admits only the catalog and enabled rail feeds', () => { + assert.deepEqual(resolveRailRoute('/feeds'), { route: 'feeds' }); + assert.deepEqual(resolveRailRoute('/feeds/?x=1'), { route: 'feeds' }); + assert.equal(resolveRailRoute('/trains/mbta-rail')?.feed?.id, 'mbta-rail'); + assert.equal(resolveRailRoute('/trains/mbta-rail/')?.feed?.id, 'mbta-rail'); + assert.equal(resolveRailRoute('/trains/vre'), null, 'not registered'); + assert.equal( + resolveRailRoute('/trains/mbta'), + null, + 'a transit id is not a rail id', + ); + assert.equal(resolveRailRoute('/vehicles/mbta-rail'), null); + assert.equal(resolveRailRoute('/trains/mbta-rail/extra'), null); + assert.equal(resolveRailRoute('/trains/..%2F..%2Fetc'), null); + assert.equal(resolveRailRoute('/trains/%E0%A4%A'), null); + assert.equal(resolveRailRoute(''), null); + assert.equal(resolveRailRoute(undefined), null); +}); + +test('path packs resolve for enabled rail feeds only', () => { + const route = resolveRailRoute('/paths/mta-lirr'); + assert.equal(route?.route, 'paths'); + assert.equal(route?.feed?.id, 'mta-lirr'); + assert.equal(resolveRailRoute('/paths/mta-lirr/?v=1')?.route, 'paths'); + assert.equal(resolveRailRoute('/trains/mta-lirr')?.route, 'trains'); + assert.equal(resolveRailRoute('/paths/vre'), null, 'not registered'); + assert.equal(resolveRailRoute('/paths/unknown'), null); + assert.equal(resolveRailRoute('/paths/'), null); + assert.equal(resolveRailRoute('/paths/mta-lirr/extra'), null); + assert.equal(resolveRailRoute('/paths/..%2F..%2Fetc%2Fpasswd'), null); + assert.equal(resolveRailRoute('/paths/mta-lirr.json'), null); + assert.equal(resolveRailRoute('/paths/%E0%A4%A'), null); +}); + +test('snapshots keep classified trains only and count what was dropped', () => { + const feed = getRailFeed('mbta-rail'); + const snapshot = buildRailSnapshot( + feed, + encodeFeed({ + entities: [ + { id: 'cr1', route: 'CR-Lowell' }, + { id: 'red1', route: 'Red' }, + { id: 'grn1', route: 'Green-D' }, + { id: 'bus1', route: '39' }, + { id: 'none' }, + ], + }), + 1_700_000_000_000, + ); + assert.equal(snapshot.feedId, 'mbta-rail'); + assert.equal(snapshot.entityCount, 5); + assert.equal(snapshot.count, 3); + assert.equal(snapshot.dropped, 2); + assert.deepEqual( + snapshot.trains.map(({ id, category, mode }) => [id, category, mode]), + [ + ['cr1', 'local', 'rail'], + ['red1', 'transit', 'metro'], + ['grn1', 'transit', 'tram'], + ], + ); + assert.equal(snapshot.trains[0].timestampSource, 'vehicle'); + assert.equal(snapshot.vehicles, undefined, 'buses never leave the server'); +}); + +test('a rail-only feed keeps vehicles that carry no route', () => { + const snapshot = buildRailSnapshot( + getRailFeed('mta-metro-north'), + encodeFeed({ entities: [{ id: 'mnr1' }] }), + ); + assert.equal(snapshot.count, 1); + assert.equal(snapshot.trains[0].category, 'local'); +}); + +test('a differential feed is refused, as in the Transit proxy', () => { + assert.throws( + () => + buildRailSnapshot( + getRailFeed('mbta-rail'), + encodeFeed({ incrementality: 1, entities: [] }), + ), + RailFeedShapeError, + ); +}); + +test('a body that is no FeedMessage is unreadable, not an empty snapshot', () => { + const feed = getRailFeed('mbta-rail'); + const headerless = new PbfWriter(); + const entity = new PbfWriter(); + entity.writeStringField(1, 'x'); + headerless.writeBytesField(2, entity.finish()); + const versionless = new PbfWriter(); + const header = new PbfWriter(); + header.writeVarintField(3, 1_700_000_000); + versionless.writeBytesField(1, header.finish()); + for (const [label, bytes] of [ + ['empty', new Uint8Array(0)], + ['no header', headerless.finish()], + ['no version', versionless.finish()], + ['html', new TextEncoder().encode('')], + ['json', new TextEncoder().encode('{"error":"quota"}')], + ]) { + for (const build of [buildRailSnapshot, buildRailTripUpdatesSnapshot]) + assert.throws( + () => build(feed, bytes, 1_700_000_000_000), + (error) => + error.code === 'UPSTREAM_UNREADABLE' && + !(error instanceof RailFeedShapeError), + `${build.name}: ${label}`, + ); + } + // A FeedMessage with its header and nothing else is a real empty feed. + const quiet = encodeFeed({ entities: [] }); + assert.equal(buildRailSnapshot(feed, quiet).count, 0); + assert.equal(buildRailTripUpdatesSnapshot(feed, quiet).count, 0); +}); + +test('upstream headers identify the rail proxy and let a feed override Accept', () => { + const plain = railUpstreamHeaders(getRailFeed('mbta-rail'), { + etag: '"abc"', + lastModified: 'Thu, 01 Jan 2026 00:00:00 GMT', + }); + assert.match(plain['User-Agent'], /gods-eye-view-rail-proxy/); + assert.equal(plain['If-None-Match'], '"abc"'); + assert.equal(plain['If-Modified-Since'], 'Thu, 01 Jan 2026 00:00:00 GMT'); + const adelaide = railUpstreamHeaders(getRegisteredRailFeed('adelaide-metro')); + assert.equal(adelaide.Accept, 'application/x-google-protobuf'); + assert.equal( + railUpstreamHeaders(getRailFeed('irish-rail')).Accept, + 'text/xml, application/xml;q=0.9', + ); +}); + +test('response headers report cache state and operator contact', () => { + const fresh = railResponseHeaders('MISS', 'cdn.mbta.com', 1_700_000_000_123); + assert.equal(fresh['X-GEV-Cache'], 'MISS'); + assert.equal(fresh['X-Rail-Upstream'], 'cdn.mbta.com'); + assert.equal(fresh['X-Rail-Contact'], '1700000000123'); + assert.match(fresh['Cache-Control'], /max-age=15/); + assert.equal(railResponseHeaders('STALE-ERROR')['Cache-Control'], 'no-store'); + assert.equal(railResponseHeaders('HIT')['X-Rail-Contact'], undefined); +}); + +test('GTFS trains say their positions are GPS', () => { + const snapshot = buildRailSnapshot( + getRailFeed('mbta-rail'), + encodeFeed({ entities: [{ id: 'cr1', route: 'CR-Lowell' }] }), + ); + assert.equal(snapshot.positions, 'gps'); + assert.equal(snapshot.trains[0].positionSource, 'gps'); +}); + +test('Irish Rail XML becomes classified, event-positioned trains', () => { + const now = 1_794_000_000_000; + const snapshot = buildRailSnapshot( + getRailFeed('irish-rail'), + new TextEncoder().encode(IRISH_XML), + now, + ); + assert.equal(snapshot.feedId, 'irish-rail'); + assert.equal(snapshot.positions, 'event'); + assert.equal(snapshot.entityCount, 2); + assert.equal(snapshot.count, 2); + assert.equal(snapshot.feedTimestamp, null); + const byId = new Map(snapshot.trains.map((t) => [t.id, t])); + assert.equal(byId.get('A118').category, 'international'); + assert.equal(byId.get('A118').positionSource, 'event'); + assert.equal(byId.get('A118').timestampSource, 'fetch'); + assert.equal(byId.get('A118').timestamp, now / 1000); + assert.equal(byId.get('E931').category, 'local'); + assert.equal(byId.get('E931').delaySec, 960); + assert.throws( + () => + buildRailSnapshot( + getRailFeed('irish-rail'), + new TextEncoder().encode('Service Unavailable'), + ), + (error) => + error.code === 'UPSTREAM_UNREADABLE' && + !(error instanceof RailFeedShapeError), + ); +}); + +test('no enabled feed is placed from its timetable', () => { + // Every train is where its operator reports it: no feed answers `/trains` + // with a timetable-only list. + for (const feed of RAIL_FEED_REGISTRY.filter((f) => f.defaultEnabled)) + assert.ok( + ['gps', 'event'].includes(railFeedPositions(feed)) && + typeof feed.url === 'string', + feed.id, + ); +}); + +test('trip updates keep rail trips only, with category, and refuse differential feeds', () => { + const feed = getRailFeed('mbta-rail'); + const snapshot = buildRailTripUpdatesSnapshot( + feed, + encodeTripUpdates([ + ['cr', 'CR-Worcester', 120], + ['red', 'Red', -30], + ['bus', '39', 60], + ['none', null, 0], + ]), + 5_000, + ); + assert.equal(snapshot.feedId, 'mbta-rail'); + assert.equal(snapshot.fetchedAt, 5_000); + assert.equal(snapshot.feedTimestamp, 1_794_000_000); + assert.equal(snapshot.count, 2); + assert.equal(snapshot.dropped, 2); + assert.deepEqual( + snapshot.trips.map((t) => [t.tripId, t.routeId, t.delaySec, t.category]), + [ + ['cr', 'CR-Worcester', 120, 'local'], + ['red', 'Red', -30, 'transit'], + ], + ); + assert.deepEqual(snapshot.trips[0].stops[0], { + stopId: 's0', + stopSequence: 1, + arrival: { time: null, delay: 120 }, + departure: null, + }); + assert.throws( + () => + buildRailTripUpdatesSnapshot( + feed, + encodeTripUpdates([], { incrementality: 1 }), + ), + RailFeedShapeError, + ); +}); + +test('a train named by its number takes its run, never a timetable position', () => { + const now = 1_794_000_000_000; + const bytes = new TextEncoder().encode(IRISH_XML); + const plain = buildRailSnapshot(getRailFeed('irish-rail'), bytes, now); + const named = buildRailSnapshot(getRailFeed('irish-rail'), bytes, now, { + tripFor: (code) => (code === 'A118' ? 'pack-trip' : null), + routeFor: (tripId) => + tripId === 'pack-trip' ? 'A~X~DUB-EUROPORT-I' : null, + }); + + const at = (snapshot) => + Object.fromEntries( + snapshot.trains.map((t) => [t.id, [t.lat, t.lon, t.positionSource]]), + ); + assert.deepEqual(at(named), at(plain), 'positions as reported'); + const train = named.trains.find((t) => t.id === 'A118'); + assert.equal(train.tripId, 'pack-trip'); + assert.equal(train.trainNumber, 'A118'); +}); + +test('trip updates are kept by their own route', () => { + const feed = getRailFeed('mbta-rail'); + const snapshot = buildRailTripUpdatesSnapshot( + feed, + encodeTripUpdates([ + ['cr', 'CR-Worcester', 120], + ['bus', '39', 60], + ]), + 5_000, + { routeFor: () => null }, + ); + assert.deepEqual( + snapshot.trips.map((t) => [t.tripId, t.routeId]), + [['cr', 'CR-Worcester']], + ); + assert.equal(snapshot.dropped, 1); +}); + +test('trip update answers are capped in trips', () => { + const rows = Array.from({ length: RAIL_UPDATES_MAX_TRIPS + 2 }, (_, i) => [ + `t${i}`, + 'CR-Lowell', + 0, + ]); + const snapshot = buildRailTripUpdatesSnapshot( + getRailFeed('mbta-rail'), + encodeTripUpdates(rows), + ); + assert.equal(snapshot.count, RAIL_UPDATES_MAX_TRIPS); + assert.equal(snapshot.truncated, true); + assert.equal(snapshot.dropped, 0, 'capped trips are rail, not dropped'); +}); + +test('/updates resolves only for feeds that publish trip updates', () => { + assert.equal(resolveRailRoute('/updates/mbta-rail')?.route, 'updates'); + assert.equal(resolveRailRoute('/updates/marc')?.feed?.id, 'marc'); + assert.equal(resolveRailRoute('/updates/irish-rail'), null, 'no updates'); + assert.equal(resolveRailRoute('/updates/capmetro-rail'), null); + assert.equal(resolveRailRoute('/updates/vre'), null, 'not registered'); + assert.equal(resolveRailRoute('/updates/mbta-rail/x'), null); +}); + +test('feed windows: own interval when set, a floor, and per-route defaults', () => { + assert.equal(railFeedTtlMs(getRailFeed('mbta-rail')), 15_000); + assert.equal(railFeedTtlMs(getRailFeed('mbta-rail'), 'updates'), 30_000); + assert.equal(railFeedTtlMs(getRailFeed('irish-rail')), 30_000); + assert.equal(railFeedTtlMs(getRailFeed('adelaide-metro'), 'updates'), 60_000); + assert.equal(railFeedTtlMs({ pollIntervalMs: 10 }), 5_000); + assert.equal(railMaxBodyBytes(getRailFeed('irish-rail')), 1024 * 1024); + assert.equal(railMaxBodyBytes(getRailFeed('mbta-rail')), 8 * 1024 * 1024); + assert.equal(railCacheStateFor(null, 0), 'none'); + assert.equal(railCacheStateFor({ at: 0 }, 29_000, 30_000), 'fresh'); + assert.equal(railCacheStateFor({ at: 0 }, 31_000, 30_000), 'stale'); + assert.equal(railCacheStateFor({ at: 0 }, 11 * 60_000, 30_000), 'expired'); +}); + +test('response headers follow the feed window', () => { + assert.match( + railResponseHeaders('MISS', 'h', null, 60_000)['Cache-Control'], + /max-age=60/, + ); + // An older snapshot is kept downstream only for the rest of its window. + const maxAge = (ageMs) => + railResponseHeaders('HIT', 'h', null, 60_000, ageMs)['Cache-Control']; + assert.equal(maxAge(45_000), 'public, max-age=15'); + assert.equal(maxAge(59_200), 'public, max-age=1'); + assert.equal(maxAge(60_000), 'public, max-age=0'); + assert.equal(maxAge(-5), 'public, max-age=60', 'a clock step is no age'); +}); + +test('/schedule resolves for every pollable feed', () => { + assert.equal(resolveRailRoute('/schedule/ktmb')?.route, 'schedule'); + assert.equal( + resolveRailRoute('/schedule/irish-rail')?.feed?.id, + 'irish-rail', + ); + assert.equal(resolveRailRoute('/schedule/mbta-rail')?.route, 'schedule'); + assert.equal(resolveRailRoute('/schedule/vre'), null, 'not registered'); + assert.equal(resolveRailRoute('/schedule/ktmb/x'), null); + assert.equal(resolveRailRoute('/schedule/..%2Fktmb'), null); +}); + +test('the global admission cap scales with the registry', () => { + const natural = railNaturalRequestsPerMinute(); + const cap = railAdmissionGlobalMax(); + assert.ok(natural > 40, 'the registry outgrew the old fixed cap'); + assert.ok( + cap >= natural * 1.5, + 'room for retries on top of the natural load', + ); + assert.equal(railAdmissionGlobalMax([]), 40, 'never below the Transit floor'); + const one = [{ defaultEnabled: true, url: 'https://x.test/vp' }]; + assert.equal(railNaturalRequestsPerMinute(one), 4, 'one feed at 15 s'); + const updatesOnly = [ + { defaultEnabled: true, tripUpdatesUrl: 'https://x.test/tu' }, + ]; + assert.equal(railNaturalRequestsPerMinute(updatesOnly), 2, 'no positions'); + assert.equal( + railNaturalRequestsPerMinute([{ ...one[0], defaultEnabled: false }]), + 0, + ); +}); + +/** Vehicles with a trip id and no route or time, as Renfe sends them. */ +function encodeRenfeLike(headerTime, trips) { + const writer = new PbfWriter(); + const header = new PbfWriter(); + header.writeStringField(1, '2.0'); + header.writeVarintField(3, headerTime); + writer.writeBytesField(1, header.finish()); + for (const [id, tripId, route] of trips) { + const entity = new PbfWriter(); + entity.writeStringField(1, id); + const vehicle = new PbfWriter(); + const trip = new PbfWriter(); + trip.writeStringField(1, tripId); + if (route) trip.writeStringField(5, route); + vehicle.writeBytesField(1, trip.finish()); + const position = new PbfWriter(); + position.writeFloatField(1, 40.4); + position.writeFloatField(2, -3.7); + vehicle.writeBytesField(2, position.finish()); + const descriptor = new PbfWriter(); + descriptor.writeStringField(1, id); + vehicle.writeBytesField(8, descriptor.finish()); + entity.writeBytesField(4, vehicle.finish()); + writer.writeBytesField(2, entity.finish()); + } + return writer.finish(); +} + +test('a feed with an untrusted header clock stamps its trains with the fetch time', () => { + const now = 1_794_000_000_000; + // Renfe writes local time as UTC: the header reads an hour behind. + const bytes = encodeRenfeLike(now / 1000 - 3600, [ + ['04192', '0419212026-11-06'], + ]); + const routes = new Map([['0419212026-11-06', 'ALVIA~X~1700037606GL023']]); + const snapshot = buildRailSnapshot( + getRailFeed('renfe-larga-distancia'), + bytes, + now, + { + routeFor: (tripId) => routes.get(tripId) ?? null, + }, + ); + assert.equal(snapshot.count, 1); + const [train] = snapshot.trains; + assert.equal(train.timestampSource, 'fetch'); + assert.equal(train.timestamp, now / 1000, 'not the header time'); + assert.equal( + snapshot.feedTimestamp, + null, + 'the header clock is not reported', + ); + assert.equal( + train.routeId, + 'ALVIA~X~1700037606GL023', + 'from the schedule pack', + ); + assert.equal(train.category, 'national'); + // A trusted feed keeps the header ladder. + const trusted = buildRailSnapshot( + getRailFeed('mta-metro-north'), + encodeRenfeLike(now / 1000 - 5, [['mnr1', 't1']]), + now, + ); + assert.equal(trusted.trains[0].timestampSource, 'header'); +}); + +test('a trip the schedule pack does not know is dropped, not guessed', () => { + const snapshot = buildRailSnapshot( + getRailFeed('renfe-larga-distancia'), + encodeRenfeLike(1_794_000_000, [ + ['a', 'known'], + ['b', 'unknown'], + ]), + 1_794_000_000_000, + { routeFor: (tripId) => (tripId === 'known' ? 'MD~S~1' : null) }, + ); + assert.deepEqual( + snapshot.trains.map((t) => [t.id, t.category]), + [['a', 'regional']], + ); + assert.equal(snapshot.dropped, 1); + // Without a lookup nothing in this feed can be classified. + const blind = buildRailSnapshot( + getRailFeed('renfe-larga-distancia'), + encodeRenfeLike(1_794_000_000, [['a', 'known']]), + ); + assert.equal(blind.count, 0); +}); + +test('trip updates take their route from the schedule pack too, and carry the tourist flag', () => { + const snapshot = buildRailTripUpdatesSnapshot( + getRailFeed('renfe-larga-distancia'), + encodeTripUpdates([ + ['0419212026-11-06', null, 60], + ['999', null, 0], + ]), + 5_000, + { + routeFor: (tripId) => + tripId === '0419212026-11-06' ? 'ALVIA~X~1700037606GL023' : null, + }, + ); + assert.deepEqual( + snapshot.trips.map((t) => [t.tripId, t.routeId, t.category]), + [['0419212026-11-06', 'ALVIA~X~1700037606GL023', 'national']], + ); + assert.equal(snapshot.dropped, 1, 'the trip the pack does not know'); + const flam = buildRailSnapshot( + getRailFeed('entur-vy'), + encodeFeed({ entities: [{ id: 'flam', route: 'VYG:Line:R45' }] }), + ); + assert.equal(flam.trains[0].tourist, true); + assert.equal(flam.trains[0].category, 'regional'); +}); diff --git a/src/data/railSchedule.js b/src/data/railSchedule.js new file mode 100644 index 000000000..7e15d8669 --- /dev/null +++ b/src/data/railSchedule.js @@ -0,0 +1,741 @@ +import { + dayNumberOf, + formatClock, + isTimeZone, + parseServiceDate, + serviceDayOriginMs, +} from './railTimeZones.js'; + +/** + * @module railSchedule + * @description Timetable tier of the Rail layer: which trains run now + * according to the operator's static GTFS, where along their line the + * timetable puts them (only to match a live train to its run, never drawn), + * and the stop list a train card shows. + * + * Schedule packs come from scripts/build-rail-paths.mjs (`.schedule.json`). + * Service calendars are resolved to concrete dates at build time, so this + * module never evaluates a calendar. Stop times are seconds after the service + * day's GTFS origin (noon minus 12 h, agency time zone) and may exceed 24 h. + * + * Realtime delays follow GTFS-RT TripUpdate semantics: + * + * delays = { [tripId]: { delaySec?, startDate?, relationship?, stops?: [{ + * stopId? | stopSequence?, relationship?, + * arrival?: { time?, delay? }, departure?: { time?, delay? } }] } } + * + * (the trip records of src/data/railTripUpdates.js, keyed by trip id). A + * trip `relationship` of 'CANCELED' or 'DELETED' drops the run from + * activeTrips; a stop `relationship` of 'SKIPPED' (or `skipped: true`) is + * passed without a stop, and one of 'NO_DATA' has no prediction: that stop + * and the stops after it keep their timetable times until the next update. + * + * `time` is POSIX seconds (milliseconds are accepted too), `delay` seconds. + * A stop update's delay carries on to later stops until the next update; + * stops before the first update use the trip's `delaySec` (else the + * timetable). `startDate` ('YYYYMMDD' or 'YYYY-MM-DD') pins the entry to one + * run of a trip that runs on several days. + * + * Clock strings are 24-hour 'HH:MM' in each STATION's own time zone. + * + * Pure: no Cesium, no DOM, no I/O. + */ + +/** Schedule-pack format this module reads. */ +export const RAIL_SCHEDULE_PACK_VERSION = 1; + +/** Earliest a delayed or early train is looked for around its timetable (ms). */ +const EARLY_SLACK_MS = 60 * 60_000; +const LATE_SLACK_MS = 12 * 60 * 60_000; +const MINUS = '−'; +/** TripUpdate relationships under which a scheduled run does not run. */ +const CANCELED = new Set(['CANCELED', 'DELETED']); + +/** + * A relative time chip as the Launches layer writes its countdown: `T−4` + * for an event four minutes ahead, `T+3` for one three minutes past. Minutes + * round up toward the event (Math.ceil, as the Launches countdown rounds its + * seconds), so `T−1` shows until the moment itself and `T+0` for the first + * minute after. An hour or more reads `T−1:05`. + * @param {number} deltaMs Event time minus now. + * @returns {string} + */ +export function formatRelative(deltaMs) { + if (!Number.isFinite(deltaMs)) return ''; + const minutes = Math.ceil(deltaMs / 60_000); + const sign = deltaMs > 0 ? MINUS : '+'; + const total = Math.abs(minutes); + const text = + total >= 60 + ? `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}` + : String(total); + return `T${sign}${text}`; +} + +const isIndex = (value, length) => + Number.isInteger(value) && value >= 0 && value < length; + +/** Largest delay a live report may state, either way (s): a day. */ +const LIVE_DELAY_MAX_S = 86_400; + +/** + * The delay a live train's own report states (`delaySec`: Irish Rail's + * public message, SIRI VM's `Delay`), for a feed that publishes no trip + * updates; null when it states none, or more than a day either way (a unit + * error, not a late train). + * @param {{delaySec?: unknown}|null|undefined} record + * @returns {number|null} Seconds, positive when late. + */ +export function liveDelaySec(record) { + const value = record?.delaySec; + return typeof value === 'number' && + Number.isFinite(value) && + Math.abs(value) <= LIVE_DELAY_MAX_S + ? value + : null; +} + +/** + * Validate and index a decoded schedule pack. + * @param {object} raw The JSON body of `.schedule.json`. + * @returns {object} A schedule for the functions below. + */ +export function createSchedule(raw) { + if ( + !raw || + raw.version !== RAIL_SCHEDULE_PACK_VERSION || + typeof raw.feedId !== 'string' || + !Array.isArray(raw.stops) || + !Array.isArray(raw.patterns) || + !Array.isArray(raw.profiles) || + !raw.trips || + typeof raw.trips !== 'object' || + !raw.services || + typeof raw.services !== 'object' + ) + throw new TypeError('Malformed rail schedule pack'); + const timezone = isTimeZone(raw.timezone) ? raw.timezone : 'UTC'; + const stops = raw.stops.map((stop, index) => ({ + index, + id: String(stop?.id ?? ''), + name: String(stop?.name ?? ''), + lat: Number(stop?.lat), + lon: Number(stop?.lon), + parent: isIndex(stop?.parent, raw.stops.length) ? stop.parent : null, + platform: stop?.platform ? String(stop.platform) : null, + ownZone: isTimeZone(stop?.tz) ? stop.tz : null, + })); + for (const stop of stops) { + const parent = stop.parent === null ? null : stops[stop.parent]; + stop.timezone = stop.ownZone ?? parent?.ownZone ?? timezone; + delete stop.ownZone; + Object.freeze(stop); + } + const stopIndexById = new Map(stops.map((stop) => [stop.id, stop.index])); + const patterns = raw.patterns.map((pattern, index) => { + const list = Array.isArray(pattern?.stops) ? pattern.stops : []; + const n = list.length; + if (n < 2 || !list.every((value) => isIndex(value, stops.length))) + throw new TypeError(`Malformed rail schedule pattern ${index}`); + const dist = + Array.isArray(pattern.dist) && pattern.dist.length === n + ? pattern.dist.map(Number) + : new Array(n).fill(0); + const seq = + Array.isArray(pattern.seq) && pattern.seq.length === n + ? pattern.seq.map(Number) + : list.map((_, k) => k + 1); + return Object.freeze({ + index, + shape: Number.isInteger(pattern.shape) ? pattern.shape : null, + stops: Object.freeze(list.slice()), + seq: Object.freeze(seq), + dist: Object.freeze(dist), + pass: Object.freeze( + Array.isArray(pattern.pass) && pattern.pass.length === n + ? pattern.pass.map((value) => value === 1) + : new Array(n).fill(false), + ), + }); + }); + const origins = new Map(); + const originOf = (date) => { + let origin = origins.get(date); + if (origin === undefined) { + origin = serviceDayOriginMs(date, timezone); + origins.set(date, origin); + } + return origin; + }; + const trips = new Map(); + const instances = []; + let maxDurationMs = 0; + for (const [tripId, value] of Object.entries(raw.trips)) { + const pattern = patterns[value?.pattern]; + const profile = raw.profiles[value?.profile]; + if (!pattern || !Array.isArray(profile)) continue; + if (profile.length !== pattern.stops.length * 2) continue; + const startSec = Number(value.startSec); + if (!Number.isFinite(startSec)) continue; + const trip = Object.freeze({ + id: tripId, + pattern, + offsets: profile, + startSec, + routeId: value.route == null ? null : String(value.route), + service: String(value.service ?? ''), + headsign: value.headsign ? String(value.headsign) : null, + name: value.name ? String(value.name) : null, + direction: Number.isInteger(value.direction) ? value.direction : null, + }); + trips.set(tripId, trip); + const dates = raw.services[trip.service]; + if (!Array.isArray(dates)) continue; + const durationMs = profile[profile.length - 1] * 1000; + if (durationMs > maxDurationMs) maxDurationMs = durationMs; + for (const date of dates) { + if (!parseServiceDate(date)) continue; + const startMs = originOf(date) + startSec * 1000; + if (Number.isFinite(startMs)) + instances.push({ + trip, + serviceDate: date, + startMs, + endMs: startMs + durationMs, + }); + } + } + instances.sort((a, b) => a.startMs - b.startMs); + const runsByTrip = new Map(); + for (const instance of instances) { + let list = runsByTrip.get(instance.trip.id); + if (!list) runsByTrip.set(instance.trip.id, (list = [])); + list.push(instance); + } + return Object.freeze({ + feedId: raw.feedId, + /** + * The `builtAt` of the path pack this timetable's shape indices point + * into (written by the same build), or null in packs that predate it. + */ + pathsBuiltAt: + typeof raw.pathsBuiltAt === 'string' ? raw.pathsBuiltAt : null, + timezone, + window: raw.window ?? null, + routes: raw.routes && typeof raw.routes === 'object' ? raw.routes : {}, + stops: Object.freeze(stops), + patterns: Object.freeze(patterns), + trips, + stopIndexById, + instances: Object.freeze(instances), + runsByTrip, + instanceStarts: Float64Array.from( + instances, + (instance) => instance.startMs, + ), + maxDurationMs, + }); +} + +/** + * When the last run a decoded schedule pack holds arrives (epoch ms): past + * it the pack's window has ended, and it names no train that can still be + * running. Each trip counts on the latest date of its service. Read from the + * raw pack, without indexing it (the server checks a pack it reads only for + * its routes). Null when the pack names no window (`window.to`), so its end + * is not known; -Infinity when it holds no run. + * @param {object} raw The JSON body of `.schedule.json`. + * @returns {number|null} + */ +export function schedulePackEndMs(raw) { + if (!parseServiceDate(raw?.window?.to)) return null; + const trips = raw?.trips && typeof raw.trips === 'object' ? raw.trips : {}; + const services = + raw?.services && typeof raw.services === 'object' ? raw.services : {}; + const profiles = Array.isArray(raw?.profiles) ? raw.profiles : []; + const zone = isTimeZone(raw?.timezone) ? raw.timezone : 'UTC'; + /** Service id → the origin of its latest date (-Infinity: no date). */ + const lastOrigin = new Map(); + const latestOrigin = (service) => { + let origin = lastOrigin.get(service); + if (origin !== undefined) return origin; + const dates = Object.hasOwn(services, service) ? services[service] : null; + let latest = -Infinity; + let latestDate = null; + for (const date of Array.isArray(dates) ? dates : []) { + const day = dayNumberOf(date); + if (day > latest) { + latest = day; + latestDate = date; + } + } + origin = latestDate === null ? NaN : serviceDayOriginMs(latestDate, zone); + if (!Number.isFinite(origin)) origin = -Infinity; + lastOrigin.set(service, origin); + return origin; + }; + let end = -Infinity; + for (const trip of Object.values(trips)) { + const profile = profiles[trip?.profile]; + const startSec = Number(trip?.startSec); + if (!Array.isArray(profile) || !Number.isFinite(startSec)) continue; + const origin = latestOrigin(String(trip.service ?? '')); + if (origin === -Infinity) continue; + const durationSec = Number(profile[profile.length - 1]) || 0; + end = Math.max(end, origin + (startSec + durationSec) * 1000); + } + return end; +} + +/** First index whose start is greater than `ms`. */ +function upperBound(starts, ms) { + let lo = 0; + let hi = starts.length; + while (lo < hi) { + const mid = (lo + hi) >> 1; + if (starts[mid] <= ms) lo = mid + 1; + else hi = mid; + } + return lo; +} + +/** The public view of one run of a trip. */ +function runOf(instance) { + const { trip } = instance; + return { + tripId: trip.id, + serviceDate: instance.serviceDate, + routeId: trip.routeId, + headsign: trip.headsign, + name: trip.name, + direction: trip.direction, + pattern: trip.pattern.index, + shape: trip.pattern.shape, + scheduledStartMs: instance.startMs, + scheduledEndMs: instance.endMs, + }; +} + +/** The delay entry for a run, honouring a `startDate` pin. */ +function delayFor(delays, run) { + const entry = + delays && Object.hasOwn(delays, run.tripId) ? delays[run.tripId] : null; + if (!entry || typeof entry !== 'object') return null; + if (entry.startDate != null) { + const pinned = parseServiceDate(entry.startDate); + const own = parseServiceDate(run.serviceDate); + if ( + !pinned || + !own || + pinned.year !== own.year || + pinned.month !== own.month || + pinned.day !== own.day + ) + return null; + } + return entry; +} + +const toMs = (time) => { + const value = Number(time); + if (!Number.isFinite(value) || value <= 0) return NaN; + return value > 1e11 ? value : value * 1000; +}; + +/** The pattern index each stop update applies to, in order. */ +function matchUpdates(schedule, pattern, updates) { + const byIndex = new Map(); + let from = 0; + for (const update of updates) { + if (!update || typeof update !== 'object') continue; + let found = -1; + for (let pass = 0; pass < 2 && found < 0; pass++) { + const start = pass === 0 ? from : 0; + for (let k = start; k < pattern.stops.length; k++) { + if (stopMatches(schedule, pattern, k, update)) { + found = k; + break; + } + } + } + if (found < 0) continue; + byIndex.set(found, update); + from = found + 1; + } + return byIndex; +} + +function stopMatches(schedule, pattern, k, update) { + if (update.stopSequence != null && update.stopSequence !== '') + return Number(update.stopSequence) === pattern.seq[k]; + if (update.stopId == null) return false; + const id = String(update.stopId); + const stop = schedule.stops[pattern.stops[k]]; + if (stop.id === id) return true; + if (stop.parent !== null && schedule.stops[stop.parent].id === id) + return true; + const other = schedule.stopIndexById.get(id); + if (other === undefined) return false; + const otherParent = schedule.stops[other].parent; + return otherParent !== null && otherParent === (stop.parent ?? stop.index); +} + +/** Whether a stop update carries this StopTimeUpdate relationship. */ +function stopRelationshipIs(update, relationship) { + return ( + update.relationship === relationship || + update.scheduleRelationship === relationship + ); +} + +/** + * Scheduled and expected times of every stop of a run. + * @returns {{schedArr: number[], schedDep: number[], arr: number[], + * dep: number[], skipped: boolean[], hasRealtime: boolean}} + */ +function runTimes(schedule, instance, delays) { + const { trip } = instance; + const n = trip.pattern.stops.length; + const schedArr = new Array(n); + const schedDep = new Array(n); + for (let k = 0; k < n; k++) { + schedArr[k] = instance.startMs + trip.offsets[2 * k] * 1000; + schedDep[k] = instance.startMs + trip.offsets[2 * k + 1] * 1000; + } + const arr = schedArr.slice(); + const dep = schedDep.slice(); + const skipped = new Array(n).fill(false); + const entry = delayFor(delays, { + tripId: trip.id, + serviceDate: instance.serviceDate, + }); + if (!entry) + return { schedArr, schedDep, arr, dep, skipped, hasRealtime: false }; + const updates = matchUpdates( + schedule, + trip.pattern, + Array.isArray(entry.stops) ? entry.stops : [], + ); + let carried = Number.isFinite(Number(entry.delaySec)) + ? Number(entry.delaySec) + : 0; + for (let k = 0; k < n; k++) { + const update = updates.get(k); + let arrDelay = carried; + let depDelay = carried; + if (update && stopRelationshipIs(update, 'NO_DATA')) { + // No prediction (GTFS-RT NO_DATA): the delay carried so far no longer + // holds, here or at the stops after it. + carried = 0; + arrDelay = 0; + depDelay = 0; + } else if (update) { + if (update.skipped === true || stopRelationshipIs(update, 'SKIPPED')) + skipped[k] = true; + const a = update.arrival ?? null; + const d = update.departure ?? null; + const aTime = toMs(a?.time); + const dTime = toMs(d?.time); + const aDelay = Number(a?.delay); + const dDelay = Number(d?.delay); + let arrivalGiven = true; + if (Number.isFinite(aTime)) arrDelay = (aTime - schedArr[k]) / 1000; + else if (a && a.delay != null && Number.isFinite(aDelay)) + arrDelay = aDelay; + else arrivalGiven = false; + if (Number.isFinite(dTime)) depDelay = (dTime - schedDep[k]) / 1000; + else if (d && d.delay != null && Number.isFinite(dDelay)) + depDelay = dDelay; + else if (arrivalGiven) depDelay = arrDelay; + else depDelay = carried; + if (!arrivalGiven && (Number.isFinite(dTime) || (d && d.delay != null))) + arrDelay = Math.min(carried, depDelay); + carried = depDelay; + } + arr[k] = schedArr[k] + arrDelay * 1000; + dep[k] = schedDep[k] + depDelay * 1000; + } + // Predictions are not always consistent; time never runs backwards. + for (let k = 0; k < n; k++) { + if (k > 0 && arr[k] < dep[k - 1]) arr[k] = dep[k - 1]; + if (dep[k] < arr[k]) dep[k] = arr[k]; + } + return { schedArr, schedDep, arr, dep, skipped, hasRealtime: true }; +} + +/** The instance of `trip` (a run object or a trip id) meant at `nowMs`. */ +function resolveInstance(schedule, trip, nowMs, delays) { + const pinned = trip && typeof trip === 'object'; + const runs = schedule.runsByTrip.get( + String((pinned ? trip.tripId : trip) ?? ''), + ); + if (!runs) return null; + if (pinned && trip.serviceDate != null) { + const match = runs.find( + (instance) => instance.serviceDate === trip.serviceDate, + ); + if (match) return match; + } + let best = null; + let bestScore = Infinity; + for (const instance of runs) { + const times = runTimes(schedule, instance, delays); + const start = times.dep[0]; + const end = times.arr[times.arr.length - 1]; + // Running now wins; else the nearest run, upcoming before past. + const score = + nowMs >= start && nowMs <= end + ? -1 + : nowMs < start + ? start - nowMs + : (nowMs - end) * 2; + if (score < bestScore) { + bestScore = score; + best = instance; + } + } + return best; +} + +/** + * Trains the timetable has running at `nowMs`: from the departure at the + * first stop to the arrival at the last, shifted by realtime delays. + * @param {object} schedule From createSchedule. + * @param {number} nowMs + * @param {{delays?: object, beforeMs?: number, afterMs?: number}} [options] + * `beforeMs` / `afterMs` widen the window (a train waiting at its origin, + * one just arrived). + * @returns {object[]} Runs: {tripId, serviceDate, routeId, headsign, name, + * direction, pattern, shape, scheduledStartMs, scheduledEndMs, + * expectedStartMs, expectedEndMs}. + */ +export function activeTrips( + schedule, + nowMs, + { delays = null, beforeMs = 0, afterMs = 0 } = {}, +) { + if (!schedule || !Number.isFinite(nowMs)) return []; + const lead = Math.max(0, beforeMs); + const tail = Math.max(0, afterMs); + const last = upperBound( + schedule.instanceStarts, + nowMs + lead + EARLY_SLACK_MS, + ); + const floor = nowMs - tail - LATE_SLACK_MS - schedule.maxDurationMs; + const out = []; + for (let i = last - 1; i >= 0; i--) { + const instance = schedule.instances[i]; + if (instance.startMs < floor) break; + const run = runOf(instance); + let start = instance.startMs + instance.trip.offsets[1] * 1000; + let end = instance.endMs; + const entry = delayFor(delays, run); + if (entry && CANCELED.has(entry.relationship)) continue; + if (entry) { + const times = runTimes(schedule, instance, delays); + start = times.dep[0]; + end = times.arr[times.arr.length - 1]; + } else if (instance.endMs < nowMs - tail) continue; + if (nowMs < start - lead || nowMs > end + tail) continue; + run.expectedStartMs = start; + run.expectedEndMs = end; + out.push(run); + } + return out.reverse(); +} + +/** + * Where the timetable puts a train at `nowMs`, along its pattern's shape. + * Between two stops the arc runs linearly in time from the departure to the + * next arrival; during a dwell it holds at the stop. Before the first + * departure it waits at the origin, after the last arrival at the terminus. + * + * @param {object} schedule From createSchedule. + * @param {object|string} trip A run from activeTrips, or a trip id. + * @param {number} nowMs + * @param {object} [delays] + * @returns {{tripId: string, serviceDate: string, shape: number|null, + * s: number, prevStop: number, nextStop: number, + * prevStopId: string|null, nextStopId: string|null, dwell: boolean, + * state: 'before'|'running'|'arrived', fraction: number, delaySec: number, + * lat?: number, lon?: number}|null} Stop fields are pattern indices (-1 for + * none). `lat`/`lon` interpolate between the stops and are set only when + * the pattern has no shape. + */ +export function scheduledPosition(schedule, trip, nowMs, delays = null) { + if (!schedule || !Number.isFinite(nowMs)) return null; + const instance = resolveInstance(schedule, trip, nowMs, delays); + if (!instance) return null; + const { pattern } = instance.trip; + const times = runTimes(schedule, instance, delays); + // Skipped stops are passed without a stop: their times do not pace the run. + const nodes = []; + for (let k = 0; k < pattern.stops.length; k++) + if (!times.skipped[k]) nodes.push(k); + if (nodes.length < 2) + nodes.splice(0, nodes.length, 0, pattern.stops.length - 1); + const result = { + tripId: instance.trip.id, + serviceDate: instance.serviceDate, + shape: pattern.shape, + s: 0, + prevStop: -1, + nextStop: -1, + prevStopId: null, + nextStopId: null, + dwell: false, + state: 'running', + fraction: 0, + delaySec: 0, + }; + const first = nodes[0]; + const lastNode = nodes[nodes.length - 1]; + let at = -1; + let from = -1; + let to = -1; + if (nowMs < times.dep[first]) { + result.state = 'before'; + at = first; + result.prevStop = -1; + result.nextStop = first; + } else if (nowMs >= times.arr[lastNode]) { + result.state = 'arrived'; + at = lastNode; + result.prevStop = lastNode; + result.nextStop = -1; + } else { + for (let j = 0; j < nodes.length; j++) { + const k = nodes[j]; + if (nowMs >= times.arr[k] && nowMs < times.dep[k]) { + at = k; + result.prevStop = k; + result.nextStop = j + 1 < nodes.length ? nodes[j + 1] : -1; + break; + } + if ( + j + 1 < nodes.length && + nowMs >= times.dep[k] && + nowMs < times.arr[nodes[j + 1]] + ) { + from = k; + to = nodes[j + 1]; + break; + } + } + } + if (at >= 0) { + result.dwell = true; + result.s = pattern.dist[at]; + result.fraction = 0; + result.delaySec = Math.round( + (result.state === 'arrived' + ? times.arr[at] - times.schedArr[at] + : times.dep[at] - times.schedDep[at]) / 1000, + ); + } else { + const span = times.arr[to] - times.dep[from]; + const fraction = + span > 0 ? Math.min(1, Math.max(0, (nowMs - times.dep[from]) / span)) : 1; + result.prevStop = from; + result.nextStop = to; + result.fraction = fraction; + result.s = + pattern.dist[from] + (pattern.dist[to] - pattern.dist[from]) * fraction; + result.delaySec = Math.round((times.arr[to] - times.schedArr[to]) / 1000); + } + if (result.prevStop >= 0) + result.prevStopId = schedule.stops[pattern.stops[result.prevStop]].id; + if (result.nextStop >= 0) + result.nextStopId = schedule.stops[pattern.stops[result.nextStop]].id; + if (pattern.shape === null) { + const a = + schedule.stops[ + pattern.stops[result.prevStop >= 0 ? result.prevStop : result.nextStop] + ]; + const b = result.dwell ? a : schedule.stops[pattern.stops[result.nextStop]]; + result.lat = a.lat + (b.lat - a.lat) * result.fraction; + result.lon = a.lon + (b.lon - a.lon) * result.fraction; + } + return result; +} + +/** + * A run's stop list for a train card. + * + * `state` is 'passed' once the train has left (or, at the terminus, reached) + * the stop, 'next' for the first stop not yet passed (with `atStop` while the + * train dwells there) and 'upcoming' after it. `relative` is the chip from + * formatRelative: for a passed stop the departure (terminus: arrival), for + * the stop the train stands at its departure, else the arrival. + * + * @param {object} schedule + * @param {object|string} trip A run from activeTrips, or a trip id. + * @param {number} nowMs + * @param {object} [delays] + * @returns {object[]} Per stop: {index, stopId, name, platform, parentId, + * parentName, lat, lon, timezone, scheduledArrivalMs, + * scheduledDepartureMs, expectedArrivalMs, expectedDepartureMs, + * arrivalDelaySec, departureDelaySec, scheduledArrival, + * scheduledDeparture, expectedArrival, expectedDeparture (local 'HH:MM'), + * passThrough, skipped, realtime, state, atStop, relative, relativeMs}. + */ +export function tripStops(schedule, trip, nowMs, delays = null) { + if (!schedule || !Number.isFinite(nowMs)) return []; + const instance = resolveInstance(schedule, trip, nowMs, delays); + if (!instance) return []; + const { pattern } = instance.trip; + const times = runTimes(schedule, instance, delays); + const n = pattern.stops.length; + const out = []; + let nextFound = false; + for (let k = 0; k < n; k++) { + const stop = schedule.stops[pattern.stops[k]]; + const parent = stop.parent === null ? null : schedule.stops[stop.parent]; + const zone = stop.timezone; + const terminus = k === n - 1; + const leftAt = terminus ? times.arr[k] : times.dep[k]; + let state; + let atStop = false; + if (nowMs >= leftAt) state = 'passed'; + else if (!nextFound) { + state = 'next'; + nextFound = true; + atStop = nowMs >= times.arr[k] && !times.skipped[k]; + } else state = 'upcoming'; + const eventMs = + state === 'passed' + ? leftAt + : atStop || k === 0 + ? times.dep[k] + : times.arr[k]; + out.push({ + index: k, + stopId: stop.id, + name: stop.name || parent?.name || stop.id, + platform: stop.platform, + parentId: parent?.id ?? null, + parentName: parent?.name ?? null, + lat: stop.lat, + lon: stop.lon, + timezone: zone, + scheduledArrivalMs: times.schedArr[k], + scheduledDepartureMs: times.schedDep[k], + expectedArrivalMs: times.arr[k], + expectedDepartureMs: times.dep[k], + arrivalDelaySec: Math.round((times.arr[k] - times.schedArr[k]) / 1000), + departureDelaySec: Math.round((times.dep[k] - times.schedDep[k]) / 1000), + scheduledArrival: formatClock(times.schedArr[k], zone), + scheduledDeparture: formatClock(times.schedDep[k], zone), + expectedArrival: formatClock(times.arr[k], zone), + expectedDeparture: formatClock(times.dep[k], zone), + passThrough: pattern.pass[k], + skipped: times.skipped[k], + realtime: times.hasRealtime, + state, + atStop, + relativeMs: eventMs - nowMs, + relative: formatRelative(eventMs - nowMs), + }); + } + return out; +} diff --git a/src/data/railSchedule.test.mjs b/src/data/railSchedule.test.mjs new file mode 100644 index 000000000..070b9a2fa --- /dev/null +++ b/src/data/railSchedule.test.mjs @@ -0,0 +1,444 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + activeTrips, + createSchedule, + formatRelative, + liveDelaySec, + schedulePackEndMs, + scheduledPosition, + tripStops, +} from './railSchedule.js'; +import { + formatClock, + localDate, + serviceDayOriginMs, + zonedTimeToEpochMs, +} from './railTimeZones.js'; + +const MIN = 60_000; +const H = 3_600_000; + +/** + * Four stops on one shape, New York time except the last (Chicago). Run + * times: A 0, B arrive +5 min / leave +6 min, C +15 min, D +25 min. + */ +function pack(overrides = {}) { + return { + version: 1, + feedId: 'test-feed', + timezone: 'America/New_York', + window: { from: '2026-10-31', to: '2026-11-03' }, + routes: { R: { type: 2, short: 'R' } }, + stops: [ + { id: 'A', name: 'Alpha', lat: 40, lon: -74 }, + { + id: 'B1', + name: 'Track 1', + lat: 40, + lon: -73.99, + parent: 4, + platform: '1', + }, + { id: 'C', name: 'Charlie', lat: 40, lon: -73.97 }, + { id: 'D', name: 'Delta', lat: 40, lon: -73.94, tz: 'America/Chicago' }, + { id: 'B', name: 'Bravo', lat: 40, lon: -73.99 }, + ], + patterns: [ + { + shape: 0, + stops: [0, 1, 2, 3], + seq: [1, 2, 3, 4], + dist: [0, 1000, 3000, 6000], + }, + { shape: null, stops: [0, 2], seq: [1, 2], dist: [0, 2500] }, + ], + profiles: [ + [0, 0, 300, 360, 900, 900, 1500, 1500], + [0, 0, 600, 600], + ], + services: { + SAT: ['2026-10-31'], + SUN: ['2026-11-01'], + DAILY: ['2026-10-31', '2026-11-01', '2026-11-02'], + }, + trips: { + morning: { + pattern: 0, + profile: 0, + startSec: 8 * 3600, + route: 'R', + service: 'SUN', + headsign: 'Delta', + name: '101', + direction: 0, + }, + late: { + pattern: 0, + profile: 0, + startSec: 25 * 3600, + route: 'R', + service: 'SAT', + name: '199', + }, + fallback: { + pattern: 0, + profile: 0, + startSec: 1.5 * 3600, + route: 'R', + service: 'SUN', + }, + shuttle: { + pattern: 1, + profile: 1, + startSec: 12 * 3600, + route: 'R', + service: 'DAILY', + }, + }, + ...overrides, + }; +} + +const at = (iso) => Date.parse(iso); +/** 08:00 EST on 2026-11-01, the morning trip's departure. */ +const MORNING = at('2026-11-01T13:00:00Z'); + +test('relative chips read T−n ahead and T+n past, rounded like the Launches countdown', () => { + assert.equal(formatRelative(4 * MIN), 'T−4'); + assert.equal(formatRelative(3.5 * MIN), 'T−4'); + assert.equal(formatRelative(30_000), 'T−1'); + assert.equal(formatRelative(0), 'T+0'); + assert.equal(formatRelative(-30_000), 'T+0'); + assert.equal(formatRelative(-3 * MIN), 'T+3'); + assert.equal(formatRelative(-3.5 * MIN), 'T+3'); + assert.equal(formatRelative(65 * MIN), 'T−1:05'); + assert.equal(formatRelative(-125 * MIN), 'T+2:05'); + assert.equal(formatRelative(NaN), ''); +}); + +test('service days count from noon minus 12 h, across both DST changes', () => { + // Ordinary day: local midnight. + assert.equal( + serviceDayOriginMs('2026-10-31', 'America/New_York'), + at('2026-10-31T04:00:00Z'), + ); + // Fall back: 01:00 EDT, so 08:00:00 is 08:00 EST. + const fall = serviceDayOriginMs('20261101', 'America/New_York'); + assert.equal(fall, at('2026-11-01T05:00:00Z')); + assert.equal(formatClock(fall + 8 * H, 'America/New_York'), '08:00'); + // Spring forward: 23:00 EST the evening before, so 06:00:00 is 06:00 EDT. + const spring = serviceDayOriginMs('2026-03-08', 'America/New_York'); + assert.equal(spring, at('2026-03-08T04:00:00Z')); + assert.equal(formatClock(spring + 6 * H, 'America/New_York'), '06:00'); + assert.ok(Number.isNaN(serviceDayOriginMs('2026-02-30', 'UTC'))); +}); + +test('wall times resolve to instants: overlaps take the earlier, gaps move on', () => { + assert.equal( + zonedTimeToEpochMs('America/New_York', 2026, 11, 1, 1, 30), + at('2026-11-01T05:30:00Z'), + ); + assert.equal( + zonedTimeToEpochMs('America/New_York', 2026, 3, 8, 2, 30), + at('2026-03-08T07:30:00Z'), + ); + assert.equal( + zonedTimeToEpochMs('Europe/London', 2026, 7, 1, 12), + at('2026-07-01T11:00:00Z'), + ); + assert.equal( + localDate(at('2026-11-01T03:59:00Z'), 'America/New_York'), + '2026-10-31', + ); + assert.equal(formatClock(at('2026-11-01T23:59:00Z'), 'Asia/Tokyo'), '08:59'); +}); + +test('a malformed pack is refused', () => { + assert.throws(() => createSchedule(null), TypeError); + assert.throws(() => createSchedule({ ...pack(), version: 2 }), TypeError); + assert.throws( + () => createSchedule(pack({ patterns: [{ shape: 0, stops: [0, 99] }] })), + TypeError, + ); +}); + +test('active trips come from service dates in the agency zone', () => { + const schedule = createSchedule(pack()); + const ids = (now) => + activeTrips(schedule, now).map((run) => `${run.tripId}@${run.serviceDate}`); + assert.deepEqual(ids(MORNING + 10 * MIN), ['morning@2026-11-01']); + assert.deepEqual(ids(MORNING - MIN), []); + assert.deepEqual(ids(MORNING + 26 * MIN), []); + // Saturday's 25:00:00 trip runs at 01:00 EDT on Sunday, the first 01:00. + assert.deepEqual(ids(at('2026-11-01T05:10:00Z')), ['late@2026-10-31']); + // Sunday's 01:30:00 trip runs at 01:30 EST, the second 01:30. + assert.deepEqual(ids(at('2026-11-01T06:40:00Z')), ['fallback@2026-11-01']); + // A daily trip appears once per run; widening the window catches a train + // waiting at its origin. + assert.deepEqual(ids(at('2026-11-02T17:05:00Z')), ['shuttle@2026-11-02']); + assert.deepEqual( + activeTrips(schedule, MORNING - 2 * MIN, { beforeMs: 5 * MIN }).map( + (run) => run.tripId, + ), + ['morning'], + ); + const [run] = activeTrips(schedule, MORNING + MIN); + assert.equal(run.name, '101'); + assert.equal(run.headsign, 'Delta'); + assert.equal(run.shape, 0); + assert.equal(run.scheduledStartMs, MORNING); + assert.equal(run.scheduledEndMs, MORNING + 25 * MIN); +}); + +test('the timetable position runs linearly between stops and holds through a dwell', () => { + const schedule = createSchedule(pack()); + const [run] = activeTrips(schedule, MORNING + MIN); + const moving = scheduledPosition(schedule, run, MORNING + 150_000); + assert.equal(moving.state, 'running'); + assert.equal(moving.dwell, false); + assert.equal(moving.s, 500); + assert.equal(moving.prevStop, 0); + assert.equal(moving.nextStop, 1); + assert.equal(moving.nextStopId, 'B1'); + assert.equal( + moving.lat, + undefined, + 'a shaped pattern leaves lat/lon to the path', + ); + const dwell = scheduledPosition(schedule, run, MORNING + 330_000); + assert.equal(dwell.dwell, true); + assert.equal(dwell.s, 1000); + assert.equal(dwell.prevStop, 1); + assert.equal(dwell.nextStop, 2); + const leg = scheduledPosition(schedule, 'morning', MORNING + 630_000); + assert.equal(leg.s, 2000, 'halfway from B (leaves +6) to C (arrives +15)'); + const before = scheduledPosition(schedule, run, MORNING - MIN); + assert.equal(before.state, 'before'); + assert.equal(before.s, 0); + assert.equal(before.nextStop, 0); + const after = scheduledPosition(schedule, run, MORNING + 30 * MIN); + assert.equal(after.state, 'arrived'); + assert.equal(after.s, 6000); + assert.equal(after.nextStop, -1); +}); + +test('a pattern without a shape interpolates between its stops', () => { + const schedule = createSchedule(pack()); + const noon = at('2026-11-01T17:00:00Z'); + const position = scheduledPosition( + schedule, + { tripId: 'shuttle', serviceDate: '2026-11-01' }, + noon + 5 * MIN, + ); + assert.equal(position.shape, null); + assert.equal(position.s, 1250); + assert.ok(Math.abs(position.lon - -73.985) < 1e-9); + assert.equal(position.lat, 40); +}); + +test('the stop list shows station-local 24 h clocks, states and chips', () => { + const schedule = createSchedule(pack()); + const stops = tripStops(schedule, 'morning', MORNING + 320_000); + assert.deepEqual( + stops.map((stop) => [stop.stopId, stop.state, stop.atStop, stop.relative]), + [ + ['A', 'passed', false, 'T+5'], + ['B1', 'next', true, 'T−1'], + ['C', 'upcoming', false, 'T−10'], + ['D', 'upcoming', false, 'T−20'], + ], + ); + assert.equal(stops[0].scheduledDeparture, '08:00'); + assert.equal(stops[1].scheduledArrival, '08:05'); + assert.equal(stops[1].scheduledDeparture, '08:06'); + assert.equal(stops[1].name, 'Track 1'); + assert.equal(stops[1].parentName, 'Bravo'); + assert.equal(stops[1].platform, '1'); + assert.equal(stops[1].timezone, 'America/New_York'); + // Delta keeps Chicago time: 08:25 Eastern is 07:25 Central. + assert.equal(stops[3].timezone, 'America/Chicago'); + assert.equal(stops[3].scheduledArrival, '07:25'); + assert.equal(stops[3].scheduledArrivalMs, MORNING + 25 * MIN); + assert.equal(stops[3].realtime, false); +}); + +test('times past 24:00:00 land on the next calendar day', () => { + const schedule = createSchedule(pack()); + const stops = tripStops(schedule, 'late', at('2026-11-01T05:00:00Z')); + assert.equal(stops[0].scheduledDepartureMs, at('2026-11-01T05:00:00Z')); + assert.equal(stops[0].scheduledDeparture, '01:00'); + assert.equal(stops[1].scheduledArrival, '01:05'); + // The fall-back trip at 01:30:00 runs an hour of real time later. + const fallback = tripStops(schedule, 'fallback', at('2026-11-01T06:30:00Z')); + assert.equal(fallback[0].scheduledDepartureMs, at('2026-11-01T06:30:00Z')); + assert.equal(fallback[0].scheduledDeparture, '01:30'); +}); + +test('a stop delay carries on to later stops until the next update', () => { + const schedule = createSchedule(pack()); + const delays = { + morning: { + stops: [ + { stopId: 'B1', arrival: { delay: 120 } }, + { stopSequence: 4, arrival: { time: (MORNING + 26 * MIN) / 1000 } }, + ], + }, + }; + const stops = tripStops(schedule, 'morning', MORNING, delays); + assert.deepEqual( + stops.map((stop) => [stop.arrivalDelaySec, stop.departureDelaySec]), + [ + [0, 0], + [120, 120], + [120, 120], + [60, 60], + ], + ); + assert.equal(stops[1].expectedArrival, '08:07'); + assert.equal(stops[1].scheduledArrival, '08:05'); + assert.equal(stops[1].realtime, true); + // Between A (left on time) and B (arrives +7) the run is slower. + const position = scheduledPosition( + schedule, + 'morning', + MORNING + 210_000, + delays, + ); + assert.equal(position.s, 500); + assert.equal(position.delaySec, 120); + // A late train is still active after its timetable ends. + const late = { morning: { delaySec: 600 } }; + assert.deepEqual( + activeTrips(schedule, MORNING + 30 * MIN).map((run) => run.tripId), + [], + ); + const [run] = activeTrips(schedule, MORNING + 30 * MIN, { delays: late }); + assert.equal(run.tripId, 'morning'); + assert.equal(run.expectedEndMs, MORNING + 35 * MIN); +}); + +test('a delay pinned to a start date leaves other runs alone; parent ids match platforms', () => { + const schedule = createSchedule(pack()); + const pinned = { morning: { startDate: '20261031', delaySec: 300 } }; + assert.equal( + tripStops(schedule, 'morning', MORNING, pinned)[2].arrivalDelaySec, + 0, + ); + const own = { + morning: { + startDate: '2026-11-01', + stops: [{ stopId: 'B', departure: { delay: 60 } }], + }, + }; + const stops = tripStops(schedule, 'morning', MORNING, own); + assert.equal(stops[1].departureDelaySec, 60); + assert.equal(stops[2].arrivalDelaySec, 60); +}); + +test('a skipped stop is passed without a dwell', () => { + const schedule = createSchedule(pack()); + // The record shape src/data/railTripUpdates.js serves. + const delays = { + morning: { + tripId: 'morning', + startDate: '20261101', + delaySec: null, + stops: [ + { + stopId: null, + stopSequence: 2, + arrival: null, + departure: null, + relationship: 'SKIPPED', + }, + ], + }, + }; + const position = scheduledPosition( + schedule, + 'morning', + MORNING + 450_000, + delays, + ); + assert.equal(position.dwell, false); + assert.equal(position.prevStop, 0); + assert.equal(position.nextStop, 2); + assert.equal(position.s, 1500, 'halfway from A (+0) to C (+15)'); + assert.equal( + tripStops(schedule, 'morning', MORNING, delays)[1].skipped, + true, + ); +}); + +test('a canceled run leaves the active list', () => { + const schedule = createSchedule(pack()); + const now = MORNING + 10 * MIN; + const canceled = { morning: { relationship: 'CANCELED' } }; + assert.equal(activeTrips(schedule, now).length, 1); + assert.equal(activeTrips(schedule, now, { delays: canceled }).length, 0); +}); + +test('NO_DATA at a stop ends the delay carried to it: the timetable applies again', () => { + const schedule = createSchedule(pack()); + const delays = { + morning: { + delaySec: 300, + stops: [ + { stopId: 'B1', arrival: { delay: 240 } }, + { stopId: 'C', relationship: 'NO_DATA' }, + ], + }, + }; + const stops = tripStops(schedule, 'morning', MORNING, delays); + assert.deepEqual( + stops.map((stop) => [stop.arrivalDelaySec, stop.departureDelaySec]), + [ + [300, 300], + [240, 240], + // No prediction from C on: its time and D's are the timetable's. + [0, 0], + [0, 0], + ], + ); + assert.equal(stops[2].expectedArrival, stops[2].scheduledArrival); + // A later update after NO_DATA counts again. + const later = { + morning: { + stops: [ + { stopId: 'B1', arrival: { delay: 240 } }, + { stopId: 'C', scheduleRelationship: 'NO_DATA' }, + { stopSequence: 4, arrival: { delay: 60 } }, + ], + }, + }; + assert.deepEqual( + tripStops(schedule, 'morning', MORNING, later).map( + (stop) => stop.arrivalDelaySec, + ), + [0, 240, 0, 60], + ); +}); + +test('a pack ends when the last run it holds arrives', () => { + // The latest run: the shuttle on its last date, 2 November, at noon New + // York time for 10 minutes (31 October's 25:00 train arrives earlier). + const end = schedulePackEndMs(pack()); + assert.equal( + end, + serviceDayOriginMs('2026-11-02', 'America/New_York') + 12 * H + 10 * MIN, + ); + // Without a window the end is not known; with no run there is none. + assert.equal(schedulePackEndMs(pack({ window: undefined })), null); + assert.equal(schedulePackEndMs(pack({ trips: {} })), -Infinity); + assert.equal(schedulePackEndMs(null), null); +}); + +test('a live report states its own delay; a day or more either way is no delay', () => { + assert.equal(liveDelaySec({ delaySec: 540 }), 540); + assert.equal(liveDelaySec({ delaySec: -120 }), -120); + assert.equal(liveDelaySec({ delaySec: 0 }), 0); + for (const value of [null, undefined, '540', NaN, Infinity, 86_401, -86_401]) + assert.equal(liveDelaySec({ delaySec: value }), null, String(value)); + assert.equal(liveDelaySec(null), null); +}); diff --git a/src/data/railScheduleIndex.js b/src/data/railScheduleIndex.js new file mode 100644 index 000000000..c70b419e2 --- /dev/null +++ b/src/data/railScheduleIndex.js @@ -0,0 +1,124 @@ +/** + * @module railScheduleIndex + * @description Server-side lookup over a schedule pack (the + * `.schedule.json` scripts/build-rail-paths.mjs writes) for a + * realtime source that names a train by its number (Irish Rail's train + * codes): `createRunIndex`, the dated runs of a pack's trips, so a reported + * train takes the trip it is on (its line and stops). The train's position + * is never taken from the timetable. + * + * Pure: no I/O. Times are epoch milliseconds; a run starts at the service + * day's GTFS origin (noon minus 12 h in the pack's time zone) plus the + * trip's start, and lasts its profile's last offset, as + * src/data/railSchedule.js reads them. + */ + +import { serviceDayOriginMs } from './railTimeZones.js'; + +function packTrips(raw) { + return raw?.trips && typeof raw.trips === 'object' ? raw.trips : {}; +} + +function packServices(raw) { + return raw?.services && typeof raw.services === 'object' ? raw.services : {}; +} + +function serviceDates(services, service) { + const dates = services[service]; + return Array.isArray(dates) ? dates : []; +} + +/** + * The dated runs of a pack's trips. Built lazily per trip, so a source that + * looks up a few hundred train numbers never expands the whole pack. It + * keeps the parts of the pack it reads (trips, profiles, services), not the + * pack itself. + * @param {object} raw Decoded schedule pack. + * @returns {{runsOf: (tripId: string) => Array<{tripId: string, date: string, + * startMs: number, endMs: number}>, + * tripsNamed: (name: string) => string[], + * activeTrip: (name: string, nowMs: number, + * options?: {slackMs?: number, delayMs?: number}) => + * ({tripId: string, date: string, startMs: number, endMs: number}|null)}} + */ +export function createRunIndex(raw) { + const trips = packTrips(raw); + const services = packServices(raw); + const zone = typeof raw?.timezone === 'string' ? raw.timezone : 'UTC'; + const profiles = Array.isArray(raw?.profiles) ? raw.profiles : []; + const origins = new Map(); + const originOf = (date) => { + let origin = origins.get(date); + if (origin === undefined) { + origin = serviceDayOriginMs(date, zone); + origins.set(date, origin); + } + return origin; + }; + let names = null; + const runs = new Map(); + function runsOf(tripId) { + let list = runs.get(tripId); + if (list) return list; + list = []; + const trip = Object.hasOwn(trips, tripId) ? trips[tripId] : null; + const profile = trip ? profiles[trip.profile] : null; + if (trip && Array.isArray(profile) && Number.isFinite(trip.startSec)) { + const durationMs = (Number(profile[profile.length - 1]) || 0) * 1000; + for (const date of serviceDates(services, trip.service)) { + const origin = originOf(date); + if (!Number.isFinite(origin)) continue; + const startMs = origin + trip.startSec * 1000; + list.push({ tripId, date, startMs, endMs: startMs + durationMs }); + } + } + runs.set(tripId, list); + return list; + } + return { + runsOf, + tripsNamed(name) { + if (!names) { + names = new Map(); + for (const [tripId, trip] of Object.entries(trips)) { + if (typeof trip?.name !== 'string' || !trip.name) continue; + const list = names.get(trip.name); + if (list) list.push(tripId); + else names.set(trip.name, [tripId]); + } + } + return names.get(String(name ?? '')) ?? []; + }, + /** + * The run of a train number that is under way at `nowMs` (or within + * `slackMs` of its start or end); the one whose span is nearest when + * several qualify, null when none or when two tie. A train running + * `delayMs` late (negative: early) is where its timetable was that long + * ago, so its runs are compared at `nowMs - delayMs`. + */ + activeTrip(name, nowMs, { slackMs = 10 * 60_000, delayMs = 0 } = {}) { + const at = nowMs - (Number.isFinite(delayMs) ? delayMs : 0); + let best = null; + let bestScore = Infinity; + let tie = false; + for (const tripId of this.tripsNamed(name)) { + for (const run of runsOf(tripId)) { + if (at < run.startMs - slackMs || at > run.endMs + slackMs) continue; + const score = + at < run.startMs + ? run.startMs - at + : at > run.endMs + ? at - run.endMs + : 0; + if (score < bestScore) { + best = run; + bestScore = score; + tie = false; + } else if (score === bestScore && best && run.tripId !== best.tripId) + tie = true; + } + } + return tie ? null : best; + }, + }; +} diff --git a/src/data/railSiriVm.js b/src/data/railSiriVm.js new file mode 100644 index 000000000..fba90062b --- /dev/null +++ b/src/data/railSiriVm.js @@ -0,0 +1,188 @@ +/** + * @module railSiriVm + * @description Adapter for SIRI Vehicle Monitoring (SIRI Lite `vm`, as + * Entur publishes it at `api.entur.io/realtime/v1/rest/vm`), turning each + * `` into the train records the GTFS-RT path produces. + * + * Only the handful of elements a position needs are read (RecordedAtTime, + * ValidUntilTime, LineRef, VehicleMode, VehicleLocation, Bearing, Delay, + * VehicleJourneyRef, VehicleRef), with a small scanner instead of an XML + * library: no DTDs, no entity definitions, attributes ignored, an optional + * namespace prefix on any element. A document that declares a DTD or + * entities is refused, and every size is capped (whole text, activity count, + * field length). + * + * The positions are the operator's own GPS reports, each with the time it + * was recorded, so records carry `timestampSource: 'vehicle'`. An activity + * whose `ValidUntilTime` has passed is dropped, as is one whose + * `VehicleMode` says it is not a train (Entur lists replacement buses under + * the train lines). + * + * Pure: no Node built-ins, safe for node:test and any server runtime. + */ + +import { isPlausibleVehiclePosition } from './gtfsRealtime.js'; +import { decodeXmlText } from './irishRail.js'; + +/** Longest document read (characters). SJ Nord's is about 7 KB. */ +export const SIRI_VM_MAX_TEXT_CHARS = 2 * 1024 * 1024; +/** Most activities read from one document. */ +export const SIRI_VM_MAX_ACTIVITIES = 5000; +/** Longest accepted field (characters). */ +export const SIRI_VM_MAX_FIELD_CHARS = 256; + +/** Error for a document this adapter will not read. */ +export class SiriVmFormatError extends Error { + constructor(message) { + super(message); + this.name = 'SiriVmFormatError'; + } +} + +/** `` or `` (no attributes kept), up to its close. */ +function readField(block, name) { + const match = new RegExp( + `<(?:[A-Za-z_][\\w.-]*:)?${name}(?:\\s[^>]*)?>([^<]*)`, + ).exec(block); + if (!match || match[1].length > SIRI_VM_MAX_FIELD_CHARS * 6) return null; + const value = decodeXmlText(match[1]).trim(); + return value.length <= SIRI_VM_MAX_FIELD_CHARS ? value : null; +} + +/** + * Seconds in an ISO 8601 duration as SIRI writes a delay (`PT0S`, + * `PT2M30S`, `-PT45S`, `P1DT1H`), or null. + * @param {string|null} text + * @returns {number|null} + */ +export function parseIsoDurationSec(text) { + const match = + /^(-)?P(?:(\d+(?:\.\d+)?)D)?(?:T(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?)?$/.exec( + String(text ?? '').trim(), + ); + if (!match || !(match[2] || match[3] || match[4] || match[5])) return null; + const [, sign, d = 0, h = 0, m = 0, s = 0] = match; + const total = + Number(d) * 86_400 + Number(h) * 3600 + Number(m) * 60 + Number(s); + return Math.round(sign ? -total : total); +} + +/** Epoch seconds of an ISO date-time with its offset, or null. */ +function epochSeconds(text) { + const ms = Date.parse(String(text ?? '')); + return Number.isFinite(ms) ? Math.floor(ms / 1000) : null; +} + +/** + * Split a SIRI VM document into raw activity rows. + * @param {string} text + * @returns {{rows: Array>, truncated: boolean, + * responseTimestamp: string|null}} + */ +export function parseSiriVmXml(text) { + if (typeof text !== 'string') throw new SiriVmFormatError('not text'); + if (text.length > SIRI_VM_MAX_TEXT_CHARS) + throw new SiriVmFormatError(`document is ${text.length} characters`); + if (/]/.test(text)) + throw new SiriVmFormatError('not a vehicle monitoring document'); + const open = /<(?:[A-Za-z_][\w.-]*:)?VehicleActivity(?:\s[^>]*)?>/g; + const close = /<\/(?:[A-Za-z_][\w.-]*:)?VehicleActivity>/g; + const rows = []; + let truncated = false; + let found; + while ((found = open.exec(text))) { + close.lastIndex = open.lastIndex; + const end = close.exec(text); + if (!end) break; + if (rows.length >= SIRI_VM_MAX_ACTIVITIES) { + truncated = true; + break; + } + const block = text.slice(open.lastIndex, end.index); + rows.push({ + recordedAt: readField(block, 'RecordedAtTime'), + validUntil: readField(block, 'ValidUntilTime'), + lineRef: readField(block, 'LineRef'), + mode: readField(block, 'VehicleMode'), + latitude: readField(block, 'Latitude'), + longitude: readField(block, 'Longitude'), + bearing: readField(block, 'Bearing'), + delay: readField(block, 'Delay'), + journeyRef: + readField(block, 'VehicleJourneyRef') ?? + readField(block, 'DatedVehicleJourneyRef'), + vehicleRef: readField(block, 'VehicleRef'), + lineName: readField(block, 'PublishedLineName'), + }); + open.lastIndex = end.index + end[0].length; + } + const head = text.slice(0, 4096); + return { + rows, + truncated, + responseTimestamp: readField(head, 'ResponseTimestamp'), + }; +} + +/** + * Turn a SIRI VM document into unclassified train records in the shape the + * GTFS-RT path produces (src/data/gtfsRealtime.js), plus `delaySec`. + * + * `routeId` is the `LineRef` (Entur: the same `:Line:` ids as + * the static GTFS), `tripId` the `VehicleJourneyRef` (Entur: a + * `DatedServiceJourney`, not the GTFS trip id; the timetable matches the + * train to a run by line and position instead). One record per vehicle: + * a vehicle listed twice keeps its latest report. + * + * @param {string} text The XML document. + * @param {number} fetchedAtS Fetch time (epoch seconds), used when an + * activity carries no RecordedAtTime and to drop expired activities. + * @returns {{entityCount: number, truncated: boolean, + * feedTimestamp: number|null, vehicles: object[]}} + */ +export function decodeSiriVehicleMonitoring(text, fetchedAtS) { + const { rows, truncated, responseTimestamp } = parseSiriVmXml(text); + const byId = new Map(); + for (const row of rows) { + if (row.mode && !/^rail$/i.test(row.mode)) continue; + const lat = Number.parseFloat(row.latitude ?? ''); + const lon = Number.parseFloat(row.longitude ?? ''); + if (!isPlausibleVehiclePosition(lat, lon)) continue; + const validUntil = epochSeconds(row.validUntil); + if (validUntil !== null && validUntil < fetchedAtS) continue; + const id = row.vehicleRef || row.journeyRef; + if (!id || !/^[\w:.-]{1,128}$/.test(id)) continue; + const recorded = epochSeconds(row.recordedAt); + const bearing = Number.parseFloat(row.bearing ?? ''); + const record = { + id, + lat: Number(lat.toFixed(6)), + lon: Number(lon.toFixed(6)), + bearing: + Number.isFinite(bearing) && bearing >= 0 && bearing <= 360 + ? bearing + : null, + speedMps: null, + timestamp: recorded ?? fetchedAtS, + timestampSource: recorded === null ? 'fetch' : 'vehicle', + routeId: row.lineRef || null, + tripId: row.journeyRef || null, + directionId: null, + label: row.lineName || null, + stopId: null, + status: null, + occupancy: null, + delaySec: parseIsoDurationSec(row.delay), + }; + const known = byId.get(id); + if (!known || record.timestamp >= known.timestamp) byId.set(id, record); + } + return { + entityCount: rows.length, + truncated, + feedTimestamp: epochSeconds(responseTimestamp), + vehicles: [...byId.values()], + }; +} diff --git a/src/data/railSiriVm.test.mjs b/src/data/railSiriVm.test.mjs new file mode 100644 index 000000000..2479b38be --- /dev/null +++ b/src/data/railSiriVm.test.mjs @@ -0,0 +1,182 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + SIRI_VM_MAX_ACTIVITIES, + SiriVmFormatError, + decodeSiriVehicleMonitoring, + parseIsoDurationSec, + parseSiriVmXml, +} from './railSiriVm.js'; +import { buildRailSnapshot } from './railProxy.js'; +import { getRegisteredRailFeed } from './railFeeds.js'; + +const NOW_S = Date.parse('2026-11-09T16:27:00Z') / 1000; + +/** One ``, shaped like Entur's SIRI Lite answer. */ +function activity({ + recorded = '2026-11-09T17:26:17+01:00', + validUntil = '2026-11-09T17:29:17+01:00', + line = 'SJN:Line:25', + lat = '63.42107333333333', + lon = '10.377486666666666', + delay = 'PT0S', + journey = 'SJN:DatedServiceJourney:2388_ROS-HMR_26-11-09', + vehicle = '957605930081', + extra = '', +} = {}) { + return `${recorded}${validUntil}${line}SJN${extra}${lon}${lat}${delay}${journey}${vehicle}`; +} + +function document(...activities) { + return `2026-11-09T17:26:46.11584667+01:00ENT2026-11-09T17:26:46.115848853+01:00${activities.join('')}`; +} + +test('SIRI delays are ISO 8601 durations', () => { + assert.equal(parseIsoDurationSec('PT0S'), 0); + assert.equal(parseIsoDurationSec('PT2M30S'), 150); + assert.equal(parseIsoDurationSec('-PT45S'), -45); + assert.equal(parseIsoDurationSec('PT1H'), 3600); + assert.equal(parseIsoDurationSec('P1DT1M'), 86_460); + assert.equal(parseIsoDurationSec('PT1.6S'), 2); + assert.equal(parseIsoDurationSec('P'), null); + assert.equal(parseIsoDurationSec('soon'), null); + assert.equal(parseIsoDurationSec(null), null); +}); + +test('a vehicle activity becomes a train record with its own report time', () => { + const decoded = decodeSiriVehicleMonitoring( + document(activity({ delay: 'PT3M' })), + NOW_S, + ); + assert.equal(decoded.entityCount, 1); + assert.equal(decoded.truncated, false); + assert.equal( + decoded.feedTimestamp, + Math.floor(Date.parse('2026-11-09T16:26:46.115Z') / 1000), + ); + assert.deepEqual(decoded.vehicles, [ + { + id: '957605930081', + lat: 63.421073, + lon: 10.377487, + bearing: null, + speedMps: null, + timestamp: Date.parse('2026-11-09T16:26:17Z') / 1000, + timestampSource: 'vehicle', + routeId: 'SJN:Line:25', + tripId: 'SJN:DatedServiceJourney:2388_ROS-HMR_26-11-09', + directionId: null, + label: null, + stopId: null, + status: null, + occupancy: null, + delaySec: 180, + }, + ]); +}); + +test('buses, expired reports, bad positions and duplicates are dropped', () => { + const decoded = decodeSiriVehicleMonitoring( + document( + activity({ vehicle: 'bus', extra: 'bus' }), + activity({ vehicle: 'rail', extra: 'rail' }), + activity({ vehicle: 'old', validUntil: '2026-11-09T17:20:00+01:00' }), + activity({ vehicle: 'nowhere', lat: '0', lon: '0' }), + activity({ vehicle: 'twice', recorded: '2026-11-09T17:25:00+01:00' }), + activity({ vehicle: 'twice', recorded: '2026-11-09T17:26:00+01:00' }), + activity({ vehicle: 'bad id!' }), + ), + NOW_S, + ); + assert.equal(decoded.entityCount, 7); + assert.deepEqual( + decoded.vehicles.map((v) => v.id), + ['rail', 'twice'], + ); + assert.equal( + decoded.vehicles[1].timestamp, + Date.parse('2026-11-09T16:26:00Z') / 1000, + 'the later of two reports', + ); +}); + +test('a report without a time takes the fetch time, labelled so', () => { + const xml = document(activity()).replace( + /[^<]*<\/RecordedAtTime>/, + '', + ); + const [train] = decodeSiriVehicleMonitoring(xml, NOW_S).vehicles; + assert.equal(train.timestamp, NOW_S); + assert.equal(train.timestampSource, 'fetch'); +}); + +test('namespace prefixes, bearings and published line names are read', () => { + const xml = document( + activity({ extra: '91.5' }).replace( + /<(\/?)(VehicleActivity|LineRef|Latitude|Longitude)>/g, + '<$1siri:$2>', + ), + ).replace( + '', + 'F6', + ); + const [train] = decodeSiriVehicleMonitoring(xml, NOW_S).vehicles; + assert.equal(train.routeId, 'SJN:Line:25'); + assert.equal(train.bearing, 91.5); + assert.equal(train.label, 'F6'); +}); + +test('documents that are not vehicle monitoring, or declare a DTD, are refused', () => { + assert.throws(() => parseSiriVmXml(42), SiriVmFormatError); + assert.throws( + () => parseSiriVmXml('Bad gateway'), + SiriVmFormatError, + ); + assert.throws( + () => + parseSiriVmXml(`]>${document(activity())}`), + SiriVmFormatError, + ); + assert.equal(parseSiriVmXml(document()).rows.length, 0); +}); + +test('the activity count is capped', () => { + const many = Array.from( + { length: SIRI_VM_MAX_ACTIVITIES + 2 }, + (_, i) => + `v${i}`, + ); + const parsed = parseSiriVmXml(document(...many)); + assert.equal(parsed.rows.length, SIRI_VM_MAX_ACTIVITIES); + assert.equal(parsed.truncated, true); +}); + +test('the rail proxy reads SJ Nord from SIRI VM and classifies its lines', () => { + const feed = getRegisteredRailFeed('entur-sj-nord'); + assert.equal(feed.format, 'siri-vm'); + const bytes = new TextEncoder().encode( + document( + activity({ vehicle: 'a', line: 'SJN:Line:25' }), + activity({ vehicle: 'b', line: 'SJN:Line:26' }), + activity({ vehicle: 'c', line: 'SJN:Line:72' }), + activity({ vehicle: 'd', line: 'SJN:Line:999' }), + ), + ); + const snapshot = buildRailSnapshot(feed, bytes, NOW_S * 1000); + assert.equal(snapshot.positions, 'gps'); + assert.equal(snapshot.entityCount, 4); + assert.equal(snapshot.dropped, 1, 'an unknown line'); + assert.deepEqual( + snapshot.trains.map((t) => [t.id, t.category, t.positionSource]), + [ + ['a', 'national', 'gps'], + ['b', 'regional', 'gps'], + ['c', 'international', 'gps'], + ], + ); + assert.ok(Number.isFinite(snapshot.feedTimestamp)); + assert.throws( + () => buildRailSnapshot(feed, new TextEncoder().encode(''), 0), + (error) => error.code === 'UPSTREAM_UNREADABLE', + ); +}); diff --git a/src/data/railTimeZones.js b/src/data/railTimeZones.js new file mode 100644 index 000000000..6bc4d6de3 --- /dev/null +++ b/src/data/railTimeZones.js @@ -0,0 +1,174 @@ +/** + * @module railTimeZones + * @description Wall-clock arithmetic in IANA time zones for rail timetables, + * built on Intl.DateTimeFormat only. + * + * GTFS measures stop times from "noon minus 12 hours" of the service day in + * the agency's time zone: midnight on ordinary days, and the hour that keeps + * every timetable time unambiguous on the two days a year the clocks change. + * `serviceDayOriginMs` returns that instant; a stop time of `sec` seconds is + * `origin + sec * 1000`, which also covers times past 24:00:00. + * + * Pure: no Cesium, no DOM, no Node built-ins. + */ + +const DAY_MS = 86_400_000; +const formatters = new Map(); +const clocks = new Map(); + +/** Whether `zone` is an IANA zone this runtime knows. */ +export function isTimeZone(zone) { + if (typeof zone !== 'string' || !zone) return false; + try { + partsFormatter(zone); + return true; + } catch { + return false; + } +} + +function partsFormatter(zone) { + let formatter = formatters.get(zone); + if (!formatter) { + formatter = new Intl.DateTimeFormat('en-US', { + timeZone: zone, + hourCycle: 'h23', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); + formatters.set(zone, formatter); + } + return formatter; +} + +/** + * The wall-clock fields of an instant in a zone. + * @param {number} epochMs + * @param {string} zone + * @returns {{year: number, month: number, day: number, hour: number, + * minute: number, second: number}} + */ +export function zonedParts(epochMs, zone) { + const out = {}; + for (const part of partsFormatter(zone).formatToParts(epochMs)) + if (part.type !== 'literal') out[part.type] = Number(part.value); + return { + year: out.year, + month: out.month, + day: out.day, + hour: out.hour % 24, + minute: out.minute, + second: out.second, + }; +} + +/** UTC offset of a zone at an instant, in ms (east positive). */ +export function zoneOffsetMs(epochMs, zone) { + const p = zonedParts(epochMs, zone); + const wall = Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second); + return wall - Math.floor(epochMs / 1000) * 1000; +} + +/** + * The instant a wall-clock time occurs in a zone. In a gap (spring forward) + * the result is shifted forward by the gap; in an overlap (fall back) the + * earlier instant is returned. + */ +export function zonedTimeToEpochMs( + zone, + year, + month, + day, + hour = 0, + minute = 0, + second = 0, +) { + const wall = Date.UTC(year, month - 1, day, hour, minute, second); + const first = zoneOffsetMs(wall, zone); + let guess = wall - first; + const offset2 = zoneOffsetMs(guess, zone); + if (offset2 !== first) { + const alternative = wall - offset2; + // Prefer the candidate whose wall time round-trips; else the earlier. + if (zoneOffsetMs(alternative, zone) === offset2) + guess = Math.min(guess, alternative); + } + return guess; +} + +/** Parse 'YYYY-MM-DD' or 'YYYYMMDD' into numbers, or null. */ +export function parseServiceDate(text) { + const match = /^(\d{4})-?(\d{2})-?(\d{2})$/.exec(String(text ?? '').trim()); + if (!match) return null; + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + const check = new Date(Date.UTC(year, month - 1, day)); + if (check.getUTCMonth() !== month - 1 || check.getUTCDate() !== day) + return null; + return { year, month, day }; +} + +/** 'YYYY-MM-DD' of a UTC-day number (days since 1970-01-01). */ +export function dateFromDayNumber(dayNumber) { + return new Date(dayNumber * DAY_MS).toISOString().slice(0, 10); +} + +/** UTC-day number of a service date, or NaN. */ +export function dayNumberOf(text) { + const date = parseServiceDate(text); + return date ? Date.UTC(date.year, date.month - 1, date.day) / DAY_MS : NaN; +} + +/** The local calendar date ('YYYY-MM-DD') of an instant in a zone. */ +export function localDate(epochMs, zone) { + const p = zonedParts(epochMs, zone); + return `${p.year}-${String(p.month).padStart(2, '0')}-${String(p.day).padStart(2, '0')}`; +} + +/** + * The instant GTFS times of a service date count from: noon minus 12 hours, + * local to `zone`. + * @param {string} date 'YYYY-MM-DD' or 'YYYYMMDD'. + * @param {string} zone IANA zone. + * @returns {number} Epoch ms, or NaN for a malformed date. + */ +export function serviceDayOriginMs(date, zone) { + const parsed = parseServiceDate(date); + if (!parsed) return NaN; + return ( + zonedTimeToEpochMs(zone, parsed.year, parsed.month, parsed.day, 12) - + 12 * 3_600_000 + ); +} + +/** + * A 24-hour 'HH:MM' clock string of an instant in a zone. + * @param {number} epochMs + * @param {string} zone + * @returns {string} + */ +export function formatClock(epochMs, zone) { + if (!Number.isFinite(epochMs)) return ''; + let formatter = clocks.get(zone); + if (!formatter) { + formatter = new Intl.DateTimeFormat('en-GB', { + timeZone: zone, + hourCycle: 'h23', + hour: '2-digit', + minute: '2-digit', + }); + clocks.set(zone, formatter); + } + let hour = ''; + let minute = ''; + for (const part of formatter.formatToParts(epochMs)) { + if (part.type === 'hour') hour = part.value; + else if (part.type === 'minute') minute = part.value; + } + return `${hour.padStart(2, '0')}:${minute.padStart(2, '0')}`; +} diff --git a/src/data/railTripUpdates.js b/src/data/railTripUpdates.js new file mode 100644 index 000000000..6df2a8102 --- /dev/null +++ b/src/data/railTripUpdates.js @@ -0,0 +1,278 @@ +/** + * @module railTripUpdates + * @description Minimal, bounded GTFS-Realtime decoder for TripUpdates feeds, + * for the Rail layer's live delays. + * + * Same approach as the VehiclePositions decoder (src/data/gtfsRealtime.js): + * `pbf` straight off the wire, field numbers as the contract, no generated + * code. Only what a delay readout needs is read; vehicle positions, alerts and + * every extension are skipped by pbf. + * + * Field numbers (gtfs-realtime.proto, v2.0): + * FeedEntity 3 trip_update + * TripUpdate 1 trip, 2 stop_time_update[], 3 vehicle, 4 timestamp, + * 5 delay + * StopTimeUpdate 1 stop_sequence, 2 arrival, 3 departure, 4 stop_id, + * 5 schedule_relationship + * StopTimeEvent 1 delay (int32), 2 time (int64), 3 uncertainty + * TripDescriptor 1 trip_id, 2 start_time, 3 start_date, + * 4 schedule_relationship, 5 route_id, 6 direction_id + * + * Decoding is bounded the way the vehicle decoder is: entities past the cap + * are left unread, stop updates past the per-trip cap are left unread, and an + * over-long string drops the record it belongs to instead of truncating it. + * + * Pure: safe to import from the browser layer, the Vite proxy, and node:test. + */ + +import { PbfReader } from 'pbf'; +import { + GTFS_INCREMENTALITY_FULL_DATASET, + GTFS_MAX_ENTITIES, + GTFS_MAX_STRING_CHARS, +} from './gtfsRealtime.js'; + +/** Stop updates kept per trip. The longest rail trips call at ~100 stops. */ +export const RAIL_TRIP_UPDATE_MAX_STOPS = 200; +/** Trips kept per decode, before the rail filter. */ +export const RAIL_TRIP_UPDATE_MAX_TRIPS = 20_000; +/** + * Plausible delay range (seconds). A day either way covers every honest + * report; a value outside it is a unit error, not a late train. + */ +export const RAIL_TRIP_UPDATE_MAX_DELAY_S = 86_400; + +/** TripDescriptor.ScheduleRelationship enum → label. */ +const TRIP_RELATIONSHIP = Object.freeze({ + 0: 'SCHEDULED', + 1: 'ADDED', + 2: 'UNSCHEDULED', + 3: 'CANCELED', + 5: 'REPLACEMENT', + 6: 'DUPLICATED', + 7: 'DELETED', + 8: 'NEW', +}); + +/** StopTimeUpdate.ScheduleRelationship enum → label. */ +const STOP_RELATIONSHIP = Object.freeze({ + 0: 'SCHEDULED', + 1: 'SKIPPED', + 2: 'NO_DATA', + 3: 'UNSCHEDULED', +}); + +function readBoundedString(pbf, target) { + const value = pbf.readString(); + if (value.length <= GTFS_MAX_STRING_CHARS) return value; + target.oversize = true; + return null; +} + +function readFeedHeader(tag, header, pbf) { + if (tag === 1) header.version = readBoundedString(pbf, header); + else if (tag === 2) header.incrementality = pbf.readVarint(); + else if (tag === 3) header.timestamp = pbf.readVarint(); +} + +function readTripDescriptor(tag, trip, pbf) { + if (tag === 1) trip.tripId = readBoundedString(pbf, trip); + else if (tag === 2) trip.startTime = readBoundedString(pbf, trip); + else if (tag === 3) trip.startDate = readBoundedString(pbf, trip); + else if (tag === 4) trip.scheduleRelationship = pbf.readVarint(); + else if (tag === 5) trip.routeId = readBoundedString(pbf, trip); + else if (tag === 6) trip.directionId = pbf.readVarint(); +} + +function readVehicleDescriptor(tag, descriptor, pbf) { + if (tag === 1) descriptor.id = readBoundedString(pbf, descriptor); + else if (tag === 2) descriptor.label = readBoundedString(pbf, descriptor); +} + +function readStopTimeEvent(tag, event, pbf) { + // int32 and int64 are plain (not zig-zag) varints: negatives arrive as + // ten-byte two's complement, which the signed read restores. + if (tag === 1) event.delay = pbf.readVarint(true); + else if (tag === 2) event.time = pbf.readVarint(true); +} + +function readStopTimeUpdate(tag, update, pbf) { + if (tag === 1) update.stopSequence = pbf.readVarint(); + else if (tag === 2) update.arrival = pbf.readMessage(readStopTimeEvent, {}); + else if (tag === 3) update.departure = pbf.readMessage(readStopTimeEvent, {}); + else if (tag === 4) update.stopId = readBoundedString(pbf, update); + else if (tag === 5) update.scheduleRelationship = pbf.readVarint(); +} + +function readTripUpdate(tag, tripUpdate, pbf) { + if (tag === 1) tripUpdate.trip = pbf.readMessage(readTripDescriptor, {}); + else if (tag === 2) { + // Past the cap the stop is left unread; pbf skips it whole. + if (tripUpdate.stops.length >= RAIL_TRIP_UPDATE_MAX_STOPS) { + tripUpdate.stopsTruncated = true; + return; + } + tripUpdate.stops.push(pbf.readMessage(readStopTimeUpdate, {})); + } else if (tag === 3) + tripUpdate.vehicle = pbf.readMessage(readVehicleDescriptor, {}); + else if (tag === 4) tripUpdate.timestamp = pbf.readVarint(); + else if (tag === 5) tripUpdate.delay = pbf.readVarint(true); +} + +function readFeedEntity(tag, entity, pbf) { + if (tag === 1) entity.id = readBoundedString(pbf, entity); + else if (tag === 2) entity.isDeleted = pbf.readBoolean(); + else if (tag === 3) + entity.tripUpdate = pbf.readMessage(readTripUpdate, { stops: [] }); + // 4 (vehicle) and 5 (alert) are skipped: a combined feed (the MTA's) costs + // one length varint per vehicle here. +} + +function readFeedMessage(tag, message, pbf) { + if (tag === 1) message.header = pbf.readMessage(readFeedHeader, {}); + else if (tag === 2) { + if (message.entityCount >= GTFS_MAX_ENTITIES) { + message.truncated = true; + return; + } + message.entityCount += 1; + const entity = pbf.readMessage(readFeedEntity, {}); + if (!entity.tripUpdate) return; + if (message.tripUpdates.length >= RAIL_TRIP_UPDATE_MAX_TRIPS) { + message.truncated = true; + return; + } + message.tripUpdates.push(entity); + } +} + +function nonEmptyString(value) { + const text = typeof value === 'string' ? value.trim() : ''; + return text ? text : null; +} + +function plausibleDelay(value) { + return Number.isInteger(value) && + Math.abs(value) <= RAIL_TRIP_UPDATE_MAX_DELAY_S + ? value + : null; +} + +/** Epoch seconds between 2000 and 2100, else null. */ +function plausibleTime(value) { + return Number.isInteger(value) && value > 946_684_800 && value < 4_102_444_800 + ? value + : null; +} + +function normalizeEvent(event) { + if (!event) return null; + const time = plausibleTime(event.time); + const delay = plausibleDelay(event.delay); + if (time === null && delay === null) return null; + return { time, delay }; +} + +/** + * Flatten one decoded FeedEntity into the trip record `/api/rail/updates` + * serves, or null when it carries no usable trip. + * + * `delaySec` is the trip's own delay when the operator states one, else the + * delay at the first stop update that carries one (feeds list the stops still + * ahead, so that is the delay the train is running with now). Feeds that only + * publish predicted times (MBTA) have no delay to report: null, not zero. + * @param {object} entity Decoded FeedEntity. + * @returns {object|null} + */ +export function normalizeTripUpdateEntity(entity) { + if (!entity || entity.isDeleted === true) return null; + const update = entity.tripUpdate; + if (!update || entity.oversize || update.trip?.oversize) return null; + if (update.vehicle?.oversize) return null; + const tripId = nonEmptyString(update.trip?.tripId); + const routeId = nonEmptyString(update.trip?.routeId); + if (!tripId && !routeId) return null; + const stops = []; + for (const stop of update.stops) { + if (stop.oversize) continue; + const arrival = normalizeEvent(stop.arrival); + const departure = normalizeEvent(stop.departure); + const relationship = STOP_RELATIONSHIP[stop.scheduleRelationship] || null; + const stopId = nonEmptyString(stop.stopId); + const stopSequence = Number.isInteger(stop.stopSequence) + ? stop.stopSequence + : null; + if (stopId === null && stopSequence === null) continue; + const record = { stopId, stopSequence, arrival, departure }; + if (relationship && relationship !== 'SCHEDULED') + record.relationship = relationship; + stops.push(record); + } + let delaySec = plausibleDelay(update.delay); + if (delaySec === null) { + for (const stop of stops) { + const delay = stop.arrival?.delay ?? stop.departure?.delay ?? null; + if (delay !== null) { + delaySec = delay; + break; + } + } + } + const relationship = + TRIP_RELATIONSHIP[update.trip?.scheduleRelationship] || 'SCHEDULED'; + const timestamp = + Number.isFinite(update.timestamp) && update.timestamp > 0 + ? update.timestamp + : null; + return { + tripId, + routeId, + startDate: nonEmptyString(update.trip?.startDate), + startTime: nonEmptyString(update.trip?.startTime), + directionId: Number.isInteger(update.trip?.directionId) + ? update.trip.directionId + : null, + vehicleId: nonEmptyString(update.vehicle?.id), + relationship, + timestamp, + delaySec, + stopsTruncated: update.stopsTruncated === true, + stops, + }; +} + +/** + * Decode a TripUpdates FeedMessage, at most GTFS_MAX_ENTITIES entities. + * @param {Uint8Array|ArrayBuffer} bytes Protobuf wire bytes. + * @returns {{ version: string|null, timestamp: number|null, + * incrementality: number, entityCount: number, truncated: boolean, + * trips: object[] }} + */ +export function decodeTripUpdates(bytes) { + const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); + const message = new PbfReader(view).readFields(readFeedMessage, { + header: {}, + tripUpdates: [], + entityCount: 0, + truncated: false, + }); + const trips = []; + for (const entity of message.tripUpdates) { + const trip = normalizeTripUpdateEntity(entity); + if (trip) trips.push(trip); + } + const header = message.header || {}; + return { + version: header.oversize ? null : nonEmptyString(header.version), + timestamp: + Number.isFinite(header.timestamp) && header.timestamp > 0 + ? header.timestamp + : null, + incrementality: Number.isFinite(header.incrementality) + ? header.incrementality + : GTFS_INCREMENTALITY_FULL_DATASET, + entityCount: message.entityCount, + truncated: message.truncated === true, + trips, + }; +} diff --git a/src/data/railTripUpdates.test.mjs b/src/data/railTripUpdates.test.mjs new file mode 100644 index 000000000..2fee026d8 --- /dev/null +++ b/src/data/railTripUpdates.test.mjs @@ -0,0 +1,262 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { PbfWriter } from 'pbf'; +import { GTFS_MAX_ENTITIES } from './gtfsRealtime.js'; +import { + RAIL_TRIP_UPDATE_MAX_STOPS, + RAIL_TRIP_UPDATE_MAX_TRIPS, + decodeTripUpdates, + normalizeTripUpdateEntity, +} from './railTripUpdates.js'; + +const NOW_S = 1_794_000_000; + +function event(writer, tag, { delay, time } = {}) { + const message = new PbfWriter(); + if (delay !== undefined) message.writeVarintField(1, delay); + if (time !== undefined) message.writeVarintField(2, time); + writer.writeBytesField(tag, message.finish()); +} + +/** + * A FeedMessage with trip updates. Each trip: + * `{id, tripId, routeId, startDate, delay, relationship, vehicleId, stops}`, + * each stop `{stopId, sequence, arrival, departure, relationship}`. + */ +function encodeTripUpdates({ incrementality = null, trips = [], extra = [] }) { + const writer = new PbfWriter(); + const header = new PbfWriter(); + header.writeStringField(1, '2.0'); + if (incrementality !== null) header.writeVarintField(2, incrementality); + header.writeVarintField(3, NOW_S); + writer.writeBytesField(1, header.finish()); + for (const trip of trips) { + const entity = new PbfWriter(); + entity.writeStringField(1, trip.id ?? trip.tripId ?? 'e'); + if (trip.deleted) entity.writeBooleanField(2, true); + const update = new PbfWriter(); + const descriptor = new PbfWriter(); + if (trip.tripId) descriptor.writeStringField(1, trip.tripId); + if (trip.startDate) descriptor.writeStringField(3, trip.startDate); + if (trip.relationship !== undefined) + descriptor.writeVarintField(4, trip.relationship); + if (trip.routeId) descriptor.writeStringField(5, trip.routeId); + update.writeBytesField(1, descriptor.finish()); + for (const stop of trip.stops || []) { + const stu = new PbfWriter(); + if (stop.sequence !== undefined) stu.writeVarintField(1, stop.sequence); + if (stop.arrival) event(stu, 2, stop.arrival); + if (stop.departure) event(stu, 3, stop.departure); + if (stop.stopId) stu.writeStringField(4, stop.stopId); + if (stop.relationship !== undefined) + stu.writeVarintField(5, stop.relationship); + update.writeBytesField(2, stu.finish()); + } + if (trip.vehicleId) { + const vehicle = new PbfWriter(); + vehicle.writeStringField(1, trip.vehicleId); + update.writeBytesField(3, vehicle.finish()); + } + update.writeVarintField(4, NOW_S - 5); + if (trip.delay !== undefined) update.writeVarintField(5, trip.delay); + entity.writeBytesField(3, update.finish()); + writer.writeBytesField(2, entity.finish()); + } + for (const bytes of extra) writer.writeBytesField(2, bytes); + return writer.finish(); +} + +/** An entity that carries only a vehicle position (a combined feed). */ +function vehicleOnlyEntity() { + const entity = new PbfWriter(); + entity.writeStringField(1, 'veh'); + const vehicle = new PbfWriter(); + const position = new PbfWriter(); + position.writeFloatField(1, 40.7); + position.writeFloatField(2, -73.9); + vehicle.writeBytesField(2, position.finish()); + entity.writeBytesField(4, vehicle.finish()); + return entity.finish(); +} + +test('trip updates decode with stop times, signed delays and the header', () => { + const decoded = decodeTripUpdates( + encodeTripUpdates({ + trips: [ + { + tripId: 'GO202_26_1959', + routeId: '4', + startDate: '20261106', + vehicleId: '7171', + stops: [ + { stopId: '179', sequence: 1, relationship: 2 }, + { + stopId: '33', + sequence: 2, + arrival: { time: NOW_S + 60, delay: 58 }, + departure: { time: NOW_S + 90, delay: -45 }, + }, + ], + }, + ], + extra: [vehicleOnlyEntity()], + }), + ); + assert.equal(decoded.version, '2.0'); + assert.equal(decoded.timestamp, NOW_S); + assert.equal(decoded.incrementality, 0); + assert.equal(decoded.entityCount, 2, 'the vehicle entity is counted'); + assert.equal(decoded.trips.length, 1, 'but is not a trip'); + const [trip] = decoded.trips; + assert.equal(trip.tripId, 'GO202_26_1959'); + assert.equal(trip.routeId, '4'); + assert.equal(trip.startDate, '20261106'); + assert.equal(trip.vehicleId, '7171'); + assert.equal(trip.relationship, 'SCHEDULED'); + assert.equal(trip.delaySec, 58, 'first stated stop delay'); + assert.deepEqual(trip.stops, [ + { + stopId: '179', + stopSequence: 1, + arrival: null, + departure: null, + relationship: 'NO_DATA', + }, + { + stopId: '33', + stopSequence: 2, + arrival: { time: NOW_S + 60, delay: 58 }, + departure: { time: NOW_S + 90, delay: -45 }, + }, + ]); +}); + +test('a trip-level delay wins; a times-only feed reports no delay, not zero', () => { + const decoded = decodeTripUpdates( + encodeTripUpdates({ + trips: [ + { + tripId: 'a', + routeId: 'CR-Lowell', + delay: -120, + stops: [{ stopId: 's', arrival: { delay: 30 } }], + }, + { + tripId: 'b', + routeId: 'CR-Lowell', + stops: [{ stopId: 's', arrival: { time: NOW_S + 300 } }], + }, + { tripId: 'c', routeId: 'CR-Lowell', relationship: 3 }, + ], + }), + ); + const byId = new Map(decoded.trips.map((trip) => [trip.tripId, trip])); + assert.equal(byId.get('a').delaySec, -120); + assert.equal(byId.get('b').delaySec, null); + assert.deepEqual(byId.get('b').stops[0].arrival, { + time: NOW_S + 300, + delay: null, + }); + assert.equal(byId.get('c').relationship, 'CANCELED'); + assert.deepEqual(byId.get('c').stops, []); +}); + +test('implausible values are dropped instead of carried', () => { + const decoded = decodeTripUpdates( + encodeTripUpdates({ + trips: [ + { + tripId: 'x', + routeId: 'r', + delay: 999_999, + stops: [ + { stopId: 'a', arrival: { time: 12, delay: 10 } }, + { stopId: 'b', arrival: { time: 9_999_999_999 } }, + { sequence: 4 }, + {}, + ], + }, + ], + }), + ); + const [trip] = decoded.trips; + assert.equal(trip.delaySec, 10, 'a delay of eleven days is not a delay'); + assert.deepEqual(trip.stops[0].arrival, { time: null, delay: 10 }); + assert.equal(trip.stops[1].arrival, null, 'a time past 2100 is no time'); + assert.equal( + trip.stops.length, + 3, + 'a stop with no id or sequence is dropped', + ); +}); + +test('stops past the per-trip cap are left unread and the trip says so', () => { + const stops = Array.from( + { length: RAIL_TRIP_UPDATE_MAX_STOPS + 5 }, + (_, i) => ({ stopId: `s${i}`, sequence: i, arrival: { delay: 0 } }), + ); + const [trip] = decodeTripUpdates( + encodeTripUpdates({ trips: [{ tripId: 'long', routeId: 'r', stops }] }), + ).trips; + assert.equal(trip.stops.length, RAIL_TRIP_UPDATE_MAX_STOPS); + assert.equal(trip.stopsTruncated, true); +}); + +test('over-long strings drop the record, deleted and empty entities are skipped', () => { + const long = 'x'.repeat(300); + const decoded = decodeTripUpdates( + encodeTripUpdates({ + trips: [ + { tripId: long, routeId: 'r' }, + { tripId: 'gone', routeId: 'r', deleted: true }, + { id: 'empty' }, + { + tripId: 'ok', + routeId: 'r', + stops: [{ stopId: long, sequence: 1 }, { stopId: 'fine' }], + }, + ], + }), + ); + assert.deepEqual( + decoded.trips.map((trip) => trip.tripId), + ['ok'], + ); + assert.deepEqual( + decoded.trips[0].stops.map((stop) => stop.stopId), + ['fine'], + ); + assert.equal(normalizeTripUpdateEntity(null), null); + assert.equal(normalizeTripUpdateEntity({ id: 'v' }), null); +}); + +test('every trip is decoded, up to the shared entity cap, whatever the caller asks', () => { + // The decoder takes no per-caller filter (`keep`) or entity cap + // (`maxEntities`): options like these change nothing. + const trips = Array.from({ length: GTFS_MAX_ENTITIES + 1 }, (_, i) => ({ + tripId: `t${i}`, + })); + const decoded = decodeTripUpdates(encodeTripUpdates({ trips }), { + keep: () => false, + maxEntities: GTFS_MAX_ENTITIES * 2, + }); + assert.equal(decoded.entityCount, GTFS_MAX_ENTITIES); + assert.equal(decoded.truncated, true); + assert.equal( + decoded.trips.length, + Math.min(GTFS_MAX_ENTITIES, RAIL_TRIP_UPDATE_MAX_TRIPS), + ); + assert.equal(decoded.skipped, undefined); + assert.equal(decoded.feedVersion, undefined); +}); + +test('incrementality is reported so the proxy can refuse differential feeds', () => { + assert.equal( + decodeTripUpdates(encodeTripUpdates({ incrementality: 1 })).incrementality, + 1, + ); + const empty = decodeTripUpdates(new Uint8Array(0)); + assert.equal(empty.incrementality, 0); + assert.equal(empty.timestamp, null); + assert.deepEqual(empty.trips, []); +}); diff --git a/src/data/spriteOrder.js b/src/data/spriteOrder.js index 68946ed9a..c954f4b76 100644 --- a/src/data/spriteOrder.js +++ b/src/data/spriteOrder.js @@ -7,6 +7,7 @@ export const SPRITE_LAYER_ORDER = Object.freeze([ 'bikeshare', 'transit', 'transit-motion', + 'rail', 'directions', 'ais', 'military', diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index 1f3692a60..80097ecc2 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -3776,6 +3776,21 @@ "Play speed": "سرعة التشغيل", "Scroll or pinch to zoom, drag to move along · double-click to see it all": "مرّر أو اقرص للتكبير، واسحب للتنقل · انقر نقرًا مزدوجًا لرؤية الكل", "1 min/s": "دقيقة/ث", + "Rail": "السكك الحديدية", + "Urban rail": "قطارات حضرية", + "Local rail": "قطارات الضواحي", + "Regional rail": "قطارات إقليمية", + "National rail": "قطارات وطنية", + "International rail": "قطارات دولية", + "Hide {category}": "إخفاء {category}", + "Show {category}": "إظهار {category}", + "Live GPS": "GPS مباشر", + "Estimated from station reports": "مقدَّر من بلاغات المحطات", + "To {headsign}": "إلى {headsign}", + "Next {stop} {time} {relative}": "المحطة التالية {stop} {time} {relative}", + "Prev {stop} {time} {relative}": "المحطة السابقة {stop} {time} {relative}", + "(+{minutes} min late)": "(تأخير {minutes} د)", + "Exit cockpit to fly to a train": "اخرج من القمرة للانتقال إلى قطار", "Photo © {photographer}": "صورة © {photographer}", "The shared flight isn't reporting now — showing where it was last seen": "الرحلة المشتركة: لا بيانات واردة الآن — يُعرض آخر موضع معروف", "The shared flight isn't reporting now — showing where it was at {time}": "الرحلة المشتركة: لا بيانات واردة الآن — يُعرض موضعها عند {time}", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 88ab65f22..0e8931ef1 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -3452,6 +3452,21 @@ "Play speed": "Wiedergabegeschwindigkeit", "Scroll or pinch to zoom, drag to move along · double-click to see it all": "Scrollen oder mit zwei Fingern zoomen, ziehen zum Verschieben · Doppelklick zeigt alles", "1 min/s": "1 Min./s", + "Rail": "Bahn", + "Urban rail": "Stadtverkehr", + "Local rail": "Nahverkehr", + "Regional rail": "Regionalverkehr", + "National rail": "Fernverkehr", + "International rail": "International", + "Hide {category}": "{category} ausblenden", + "Show {category}": "{category} einblenden", + "Live GPS": "Live-GPS", + "Estimated from station reports": "Geschätzt aus Bahnhofsmeldungen", + "To {headsign}": "Nach {headsign}", + "Next {stop} {time} {relative}": "Nächster Halt {stop} {time} {relative}", + "Prev {stop} {time} {relative}": "Letzter Halt {stop} {time} {relative}", + "(+{minutes} min late)": "(+{minutes} min Verspätung)", + "Exit cockpit to fly to a train": "Cockpit verlassen, um zu einem Zug zu fliegen", "Photo © {photographer}": "Foto © {photographer}", "The shared flight isn't reporting now — showing where it was last seen": "Der geteilte Flug sendet gerade nicht – letzte bekannte Position wird gezeigt", "The shared flight isn't reporting now — showing where it was at {time}": "Der geteilte Flug sendet gerade nicht – Position um {time} wird gezeigt", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index a16074c79..30c05002b 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -3533,6 +3533,21 @@ "Play speed": "Velocidad de reproducción", "Scroll or pinch to zoom, drag to move along · double-click to see it all": "Desplaza o pellizca para ampliar, arrastra para moverte · doble clic para verlo todo", "1 min/s": "1 min/s", + "Rail": "Ferrocarril", + "Urban rail": "Urbano", + "Local rail": "Cercanías", + "Regional rail": "Regional", + "National rail": "Larga distancia", + "International rail": "Internacional", + "Hide {category}": "Ocultar {category}", + "Show {category}": "Mostrar {category}", + "Live GPS": "GPS en vivo", + "Estimated from station reports": "Estimada a partir de avisos de estaciones", + "To {headsign}": "Hacia {headsign}", + "Next {stop} {time} {relative}": "Próxima parada {stop} {time} {relative}", + "Prev {stop} {time} {relative}": "Parada anterior {stop} {time} {relative}", + "(+{minutes} min late)": "(+{minutes} min de retraso)", + "Exit cockpit to fly to a train": "Sal de la cabina para volar a un tren", "Photo © {photographer}": "Foto © {photographer}", "The shared flight isn't reporting now — showing where it was last seen": "El vuelo compartido no está transmitiendo ahora; se muestra dónde se vio por última vez", "The shared flight isn't reporting now — showing where it was at {time}": "El vuelo compartido no está transmitiendo ahora; se muestra dónde estaba a las {time}", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 49e9b6387..543c5ba5e 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -3533,6 +3533,21 @@ "Play speed": "Vitesse de lecture", "Scroll or pinch to zoom, drag to move along · double-click to see it all": "Faites défiler ou pincez pour zoomer, glissez pour vous déplacer · double-clic pour tout voir", "1 min/s": "1 min/s", + "Rail": "Trains", + "Urban rail": "Urbain", + "Local rail": "Trains locaux", + "Regional rail": "Régional", + "National rail": "Grandes lignes", + "International rail": "International", + "Hide {category}": "Masquer {category}", + "Show {category}": "Afficher {category}", + "Live GPS": "GPS en direct", + "Estimated from station reports": "Estimée d’après les signalements en gare", + "To {headsign}": "Vers {headsign}", + "Next {stop} {time} {relative}": "Prochain arrêt {stop} {time} {relative}", + "Prev {stop} {time} {relative}": "Arrêt précédent {stop} {time} {relative}", + "(+{minutes} min late)": "(+{minutes} min de retard)", + "Exit cockpit to fly to a train": "Quittez le cockpit pour rejoindre un train", "Photo © {photographer}": "Photo © {photographer}", "The shared flight isn't reporting now — showing where it was last seen": "Le vol partagé n'émet pas actuellement — affichage de sa dernière position connue", "The shared flight isn't reporting now — showing where it was at {time}": "Le vol partagé n'émet pas actuellement — affichage de sa position à {time}", diff --git a/src/i18n/locales/he.json b/src/i18n/locales/he.json index b1c523a7a..86ec1d66e 100644 --- a/src/i18n/locales/he.json +++ b/src/i18n/locales/he.json @@ -3357,6 +3357,21 @@ "Reported voyage": "הפלגה שדווחה", "REPORTED VOYAGE": "הפלגה שדווחה", "1 min/s": "דקה/שנ׳", + "Rail": "רכבות", + "Urban rail": "רכבת עירונית", + "Local rail": "רכבת מקומית", + "Regional rail": "רכבת אזורית", + "National rail": "רכבת ארצית", + "International rail": "רכבת בינלאומית", + "Hide {category}": "הסתרת {category}", + "Show {category}": "הצגת {category}", + "Live GPS": "GPS חי", + "Estimated from station reports": "משוער לפי דיווחי תחנות", + "To {headsign}": "אל {headsign}", + "Next {stop} {time} {relative}": "התחנה הבאה {stop} {time} {relative}", + "Prev {stop} {time} {relative}": "התחנה הקודמת {stop} {time} {relative}", + "(+{minutes} min late)": "(+{minutes} דק׳ איחור)", + "Exit cockpit to fly to a train": "צא מתא הטייס כדי לטוס לרכבת", "Photo © {photographer}": "צילום © {photographer}", "1 h/s": "שעה/שנ׳", "1 d/s": "יום/שנ׳", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 528964948..00a5bc74d 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -3533,6 +3533,21 @@ "Play speed": "Velocità di riproduzione", "Scroll or pinch to zoom, drag to move along · double-click to see it all": "Scorri o pizzica per lo zoom, trascina per spostarti · doppio clic per vedere tutto", "1 min/s": "1 min/s", + "Rail": "Ferrovie", + "Urban rail": "Urbano", + "Local rail": "Suburbano", + "Regional rail": "Regionale", + "National rail": "Lunga percorrenza", + "International rail": "Internazionale", + "Hide {category}": "Nascondi {category}", + "Show {category}": "Mostra {category}", + "Live GPS": "GPS in tempo reale", + "Estimated from station reports": "Stimata dalle segnalazioni delle stazioni", + "To {headsign}": "Per {headsign}", + "Next {stop} {time} {relative}": "Prossima fermata {stop} {time} {relative}", + "Prev {stop} {time} {relative}": "Fermata precedente {stop} {time} {relative}", + "(+{minutes} min late)": "(+{minutes} min di ritardo)", + "Exit cockpit to fly to a train": "Esci dal cockpit per volare a un treno", "Photo © {photographer}": "Foto © {photographer}", "The shared flight isn't reporting now — showing where it was last seen": "Il volo condiviso non sta trasmettendo ora: viene mostrata l'ultima posizione nota", "The shared flight isn't reporting now — showing where it was at {time}": "Il volo condiviso non sta trasmettendo ora: viene mostrata la posizione alle {time}", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 347156b7a..cc4d529bc 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -3371,6 +3371,21 @@ "Play speed": "再生速度", "Scroll or pinch to zoom, drag to move along · double-click to see it all": "スクロールまたはピンチでズーム、ドラッグで移動 · ダブルクリックで全体表示", "1 min/s": "1分/秒", + "Rail": "鉄道", + "Urban rail": "都市鉄道", + "Local rail": "近郊列車", + "Regional rail": "地域列車", + "National rail": "長距離列車", + "International rail": "国際列車", + "Hide {category}": "{category}を非表示", + "Show {category}": "{category}を表示", + "Live GPS": "ライブGPS", + "Estimated from station reports": "駅の記録から推定", + "To {headsign}": "{headsign}行き", + "Next {stop} {time} {relative}": "次の駅 {stop} {time} {relative}", + "Prev {stop} {time} {relative}": "前の駅 {stop} {time} {relative}", + "(+{minutes} min late)": "({minutes}分遅れ)", + "Exit cockpit to fly to a train": "列車へ移動するにはコックピットを終了してください", "Photo © {photographer}": "写真 © {photographer}", "The shared flight isn't reporting now — showing where it was last seen": "共有されたフライトは現在報告していません。最後に確認された位置を表示しています", "The shared flight isn't reporting now — showing where it was at {time}": "共有されたフライトは現在報告していません。{time}時点の位置を表示しています", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 233a94f20..6657a28aa 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -3533,6 +3533,21 @@ "Play speed": "Velocidade de reprodução", "Scroll or pinch to zoom, drag to move along · double-click to see it all": "Role ou faça pinça para ampliar, arraste para se mover · clique duplo para ver tudo", "1 min/s": "1 min/s", + "Rail": "Ferroviário", + "Urban rail": "Urbano", + "Local rail": "Suburbano", + "Regional rail": "Regional", + "National rail": "Longo curso", + "International rail": "Internacional", + "Hide {category}": "Ocultar {category}", + "Show {category}": "Mostrar {category}", + "Live GPS": "GPS ao vivo", + "Estimated from station reports": "Estimada a partir dos registros das estações", + "To {headsign}": "Para {headsign}", + "Next {stop} {time} {relative}": "Próxima parada {stop} {time} {relative}", + "Prev {stop} {time} {relative}": "Parada anterior {stop} {time} {relative}", + "(+{minutes} min late)": "(+{minutes} min de atraso)", + "Exit cockpit to fly to a train": "Saia da cabine para voar até um trem", "Photo © {photographer}": "Foto © {photographer}", "The shared flight isn't reporting now — showing where it was last seen": "O voo compartilhado não está transmitindo agora — mostrando a última posição conhecida", "The shared flight isn't reporting now — showing where it was at {time}": "O voo compartilhado não está transmitindo agora — mostrando onde estava às {time}", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 3e5a78ef8..ab8a3f896 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3614,6 +3614,21 @@ "Play speed": "Скорость воспроизведения", "Scroll or pinch to zoom, drag to move along · double-click to see it all": "Прокрутите или сведите пальцы для масштаба, перетащите для перемещения · двойной щелчок — показать всё", "1 min/s": "1 мин/с", + "Rail": "Железная дорога", + "Urban rail": "Городской", + "Local rail": "Пригородный", + "Regional rail": "Региональный", + "National rail": "Дальнего следования", + "International rail": "Международный", + "Hide {category}": "Скрыть: {category}", + "Show {category}": "Показать: {category}", + "Live GPS": "GPS в реальном времени", + "Estimated from station reports": "Оценка по отметкам станций", + "To {headsign}": "До {headsign}", + "Next {stop} {time} {relative}": "Следующая {stop} {time} {relative}", + "Prev {stop} {time} {relative}": "Предыдущая {stop} {time} {relative}", + "(+{minutes} min late)": "(+{minutes} мин опоздания)", + "Exit cockpit to fly to a train": "Выйдите из кабины, чтобы перелететь к поезду", "Photo © {photographer}": "Фото © {photographer}", "The shared flight isn't reporting now — showing where it was last seen": "Общий рейс сейчас не передаёт данные — показано последнее известное место", "The shared flight isn't reporting now — showing where it was at {time}": "Общий рейс сейчас не передаёт данные — показано место в {time}", diff --git a/src/i18n/locales/sv.json b/src/i18n/locales/sv.json index 7d2828954..93c42d9bd 100644 --- a/src/i18n/locales/sv.json +++ b/src/i18n/locales/sv.json @@ -3444,6 +3444,21 @@ "Reported voyage": "Angiven resa", "REPORTED VOYAGE": "ANGIVEN RESA", "1 min/s": "1 min/s", + "Rail": "Järnväg", + "Urban rail": "Stadstrafik", + "Local rail": "Lokaltåg", + "Regional rail": "Regionaltåg", + "National rail": "Fjärrtåg", + "International rail": "Internationella tåg", + "Hide {category}": "Dölj {category}", + "Show {category}": "Visa {category}", + "Live GPS": "Live-GPS", + "Estimated from station reports": "Uppskattad från stationsrapporter", + "To {headsign}": "Mot {headsign}", + "Next {stop} {time} {relative}": "Nästa stopp {stop} {time} {relative}", + "Prev {stop} {time} {relative}": "Föregående stopp {stop} {time} {relative}", + "(+{minutes} min late)": "(+{minutes} min försenad)", + "Exit cockpit to fly to a train": "Lämna cockpiten för att flyga till ett tåg", "Photo © {photographer}": "Foto © {photographer}", "The shared flight isn't reporting now — showing where it was last seen": "Den delade flygningen rapporterar inte just nu – visar senast kända position", "The shared flight isn't reporting now — showing where it was at {time}": "Den delade flygningen rapporterar inte just nu – visar positionen från {time}", diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index 317c7da03..311b851b6 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -3614,6 +3614,21 @@ "Play speed": "Швидкість відтворення", "Scroll or pinch to zoom, drag to move along · double-click to see it all": "Прокрутіть або зведіть пальці для масштабу, перетягніть для переміщення · подвійне клацання — показати все", "1 min/s": "1 хв/с", + "Rail": "Залізниця", + "Urban rail": "Міський", + "Local rail": "Приміський", + "Regional rail": "Регіональний", + "National rail": "Далекого сполучення", + "International rail": "Міжнародний", + "Hide {category}": "Сховати: {category}", + "Show {category}": "Показати: {category}", + "Live GPS": "GPS у реальному часі", + "Estimated from station reports": "Оцінка за відмітками станцій", + "To {headsign}": "До {headsign}", + "Next {stop} {time} {relative}": "Наступна {stop} {time} {relative}", + "Prev {stop} {time} {relative}": "Попередня {stop} {time} {relative}", + "(+{minutes} min late)": "(+{minutes} хв запізнення)", + "Exit cockpit to fly to a train": "Вийдіть із кабіни, щоб перелетіти до поїзда", "Photo © {photographer}": "Фото © {photographer}", "The shared flight isn't reporting now — showing where it was last seen": "Спільний рейс зараз не передає даних — показано останнє відоме місце", "The shared flight isn't reporting now — showing where it was at {time}": "Спільний рейс зараз не передає даних — показано місце о {time}", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 54a106fe1..6956fa266 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -3371,6 +3371,21 @@ "Play speed": "播放速度", "Scroll or pinch to zoom, drag to move along · double-click to see it all": "滚动或双指缩放,拖动以移动 · 双击查看全部", "1 min/s": "1 分钟/秒", + "Rail": "铁路", + "Urban rail": "城市轨道", + "Local rail": "市郊铁路", + "Regional rail": "区域铁路", + "National rail": "长途铁路", + "International rail": "国际铁路", + "Hide {category}": "隐藏{category}", + "Show {category}": "显示{category}", + "Live GPS": "实时 GPS", + "Estimated from station reports": "根据车站报告估算", + "To {headsign}": "开往 {headsign}", + "Next {stop} {time} {relative}": "下一站 {stop} {time} {relative}", + "Prev {stop} {time} {relative}": "上一站 {stop} {time} {relative}", + "(+{minutes} min late)": "(晚点 {minutes} 分钟)", + "Exit cockpit to fly to a train": "请先退出座舱,再飞往列车", "Photo © {photographer}": "照片 © {photographer}", "The shared flight isn't reporting now — showing where it was last seen": "共享的航班目前没有发送信号——显示其最后出现的位置", "The shared flight isn't reporting now — showing where it was at {time}": "共享的航班目前没有发送信号——显示其在 {time} 的位置", diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index 93170fa7f..a8bc05998 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -3371,6 +3371,21 @@ "Play speed": "播放速度", "Scroll or pinch to zoom, drag to move along · double-click to see it all": "捲動或雙指縮放,拖曳以移動 · 按兩下查看全部", "1 min/s": "1 分鐘/秒", + "Rail": "鐵路", + "Urban rail": "城市軌道", + "Local rail": "市郊鐵路", + "Regional rail": "區域鐵路", + "National rail": "長途鐵路", + "International rail": "國際鐵路", + "Hide {category}": "隱藏{category}", + "Show {category}": "顯示{category}", + "Live GPS": "即時 GPS", + "Estimated from station reports": "根據車站報告估算", + "To {headsign}": "開往 {headsign}", + "Next {stop} {time} {relative}": "下一站 {stop} {time} {relative}", + "Prev {stop} {time} {relative}": "上一站 {stop} {time} {relative}", + "(+{minutes} min late)": "(誤點 {minutes} 分鐘)", + "Exit cockpit to fly to a train": "請先退出座艙,再飛往列車", "Photo © {photographer}": "照片 © {photographer}", "The shared flight isn't reporting now — showing where it was last seen": "共享的航班目前沒有發送信號——顯示其最後出現的位置", "The shared flight isn't reporting now — showing where it was at {time}": "共享的航班目前沒有發送信號——顯示其在 {time} 的位置", diff --git a/src/layers/rail/controls.js b/src/layers/rail/controls.js new file mode 100644 index 000000000..0899ed8e9 --- /dev/null +++ b/src/layers/rail/controls.js @@ -0,0 +1,202 @@ +import { t } from '../../i18n/index.js'; +import { RAIL_CATEGORIES } from '../../data/railFeeds.js'; +import { aggregateTransitFeedHealth } from '../transit/policy.js'; +import { + ACTIVATION_ALTITUDE_M, + FEED_STALE_AFTER_MS, + RAIL_CATEGORY_COLORS, + RAIL_CATEGORY_LABELS, + normalizeRailCategories, +} from './policy.js'; + +/** + * Category toggles, the layer row and the stats the panel reads. + * @param {object} context + * @returns {object} + */ +export function createControls({ state, parts, timetable }) { + function notify() { + state._controlsListener?.(); + } + + /** Regions with at least one pollable feed, for the empty-coverage note. */ + function regionCount() { + return new Set(parts.viewport.pollableFeeds().map((feed) => feed.region)) + .size; + } + + /** + * Trains per category for the legend, shown or not; a train two feeds + * carry is one train (the copy dedupe.js hides is not counted). + */ + function categoryCounts() { + const counts = new Map(); + for (const entry of state._trains.values()) + if (entry.duplicate !== true) + counts.set(entry.category, (counts.get(entry.category) || 0) + 1); + return counts; + } + + /** + * Trains drawn per feed: those still held (a train missing from one poll + * stays until MISSED_POLLS_TO_DROP) in a shown category, and not hidden as + * another feed's copy. The feed status's own count is what the last poll + * listed, which the loading and health rules need but the map does not + * show, so the coverage line counts drawn trains, as the layer total does. + */ + function drawnCounts() { + const counts = new Map(); + for (const entry of state._trains.values()) + if (parts.rendering.categoryShown(entry)) + counts.set(entry.feedId, (counts.get(entry.feedId) || 0) + 1); + return counts; + } + + /** The coverage line: each active feed and its train count, aged when quiet. */ + function coverageLine(active, now) { + const drawn = drawnCounts(); + return active + .map((feed) => { + const status = parts.ingestion.feedStatus(feed.id); + const quiet = + status.stale === true || + (Number.isFinite(status.upstreamAt) && + now - status.upstreamAt > FEED_STALE_AFTER_MS); + const aged = + quiet && Number.isFinite(status.upstreamAt) + ? ` (${Math.round((now - status.upstreamAt) / 1000)}s old)` + : ''; + return `${feed.name} ${drawn.get(feed.id) || 0}${aged}`; + }) + .join(' · '); + } + + const methods = { + /** Wired by the application so polls can repaint the panel row. */ + attachDataManager(manager) { + state._dataManager = manager; + }, + setRowControlsListener(listener) { + state._controlsListener = + typeof listener === 'function' ? listener : null; + }, + /** The category toggles, for share links and restoration. */ + getParams() { + return { ...state._categories }; + }, + /** Show or hide categories; unknown keys and non-booleans change nothing. */ + setParams(params = {}) { + const next = normalizeRailCategories(params, state._categories); + if (RAIL_CATEGORIES.every((key) => next[key] === state._categories[key])) + return true; + state._categories = next; + const selected = state._trains.get(state._selectedKey); + if (selected && next[selected.category] === false) + parts.selection.clearSelection(); + parts.rendering.applyCategoryVisibility(); + state._revision += 1; + notify(); + state._dataManager?.refreshLayerStats?.(); + return true; + }, + /** One chip per category, with the colour and live count as the legend. */ + getRowControls() { + const counts = categoryCounts(); + const chips = RAIL_CATEGORIES.map((category) => { + const on = state._categories[category] !== false; + const label = t(RAIL_CATEGORY_LABELS[category]); + return { + id: `rail-${category}`, + label: label.toUpperCase(), + title: on + ? t('Hide {category}', { category: label }) + : t('Show {category}', { category: label }), + active: on, + busy: false, + disabled: false, + state: on ? 'active' : 'idle', + params: { [category]: !on }, + }; + }); + const legend = RAIL_CATEGORIES.filter((category) => + counts.has(category), + ).map((category) => ({ + label: t(RAIL_CATEGORY_LABELS[category]), + color: RAIL_CATEGORY_COLORS[category], + count: counts.get(category), + })); + return { chips, legend }; + }, + /** Route-line, stop, schedule and camera-follow state, for QA probes. */ + getRouteDisplayStats() { + return { + ...parts.routes.stats(), + timetable: timetable.stats(), + follow: parts.follow.stats(), + heldFeed: state._heldFeedId, + }; + }, + getStats() { + const active = [...state._activeFeeds.values()]; + const statuses = active.map((feed) => + parts.ingestion.feedStatus(feed.id), + ); + const now = Date.now(); + const health = aggregateTransitFeedHealth(statuses, now); + let count = 0; + for (const entry of state._trains.values()) + if (parts.rendering.categoryShown(entry)) count += 1; + // New trains the layer's cap left out of the last polls + // (MAX_TRAINS_TOTAL): the map shows part of what the feeds list. + const capped = statuses.reduce( + (sum, status) => sum + (status.capped || 0), + 0, + ); + if (state._enabled && active.length === 0) { + // English: the row's prompt and the analyst's empty note, both shown + // through translateStatusMessage (ui/statusText.js), as Transit's. + const note = state._altitudeGateOpen + ? `No feed here yet · ${regionCount()} 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', + statusMessage: note, + coverage: note, + }; + } + return { + count, + lastUpdate: state._lastUpdate, + error: count === 0 ? health.error : null, + ...(count === 0 && Number.isFinite(health.retryInSec) + ? { retryInSec: health.retryInSec } + : {}), + degraded: count > 0 && health.degraded, + loading: health.loading && count === 0, + loadingLabel: + // i18n-ignore: English data, translated on display (ui/statusText.js) + health.loading && count === 0 + ? `Loading ${active.map((feed) => feed.name).join(', ')}` + : undefined, + stale: health.stale, + // The panel reads this as "PARTIAL · N of M records accepted". + ...(capped > 0 && count > 0 + ? { + partial: true, + acceptedRowCount: count, + rawRowCount: count + capped, + } + : {}), + source: 'GTFS-RT', + coverage: coverageLine(active, now), + feeds: active.map((feed) => feed.id), + }; + }, + }; + + return { methods, notify }; +} diff --git a/src/layers/rail/dedupe.js b/src/layers/rail/dedupe.js new file mode 100644 index 000000000..e1ce79f78 --- /dev/null +++ b/src/layers/rail/dedupe.js @@ -0,0 +1,394 @@ +import { greatCircleKm } from '../../geoDistance.js'; + +/** + * @module rail/dedupe + * @description One physical train drawn once when several loaded feeds carry + * it (an operator's own feed and a national feed that lists its trains too, + * a cross-border train reported on both sides). + * + * Matching, between copies from different feeds: + * - both carry a train number: the same normalised number (its last digit + * group without leading zeros: `RJX 160`, `160`), up to NUMBER_RADIUS_KM + * apart. Numbers that differ are different trains. A name's number is a + * train's when it has three digits or more, or follows a long-distance + * product (`EC 7`, `NJ 40`); a trip id that is an operator prefix and a + * number (`AM:530`) and a bare-number headsign (where a feed with no + * trip names writes the train number) are read too. + * - a shorter number after anything else names a line (`S1`, `RB 1`, + * `U6`), which many trains run: copies on different lines are different + * trains, and copies on one line within MATCH_RADIUS_KM are one train + * only in the same category, the nearest pairs first. + * A train has at most one copy per feed; the strongest matches join first. + * + * Choosing the copy drawn: + * 1. position source: live GPS, then station reports; + * 2. the line: a copy bound to its operator's shape (a built pack), then + * one without. + * The copy drawn stays drawn until another scores strictly higher, so equal + * copies never trade places. A losing copy keeps its playback and is only + * hidden; when a feed leaves the viewport its copies' partners come back. + * The comparison runs once per poll, never per frame. + */ + +/** + * Copies farther apart than this are different trains (km): two copies of + * one train at one moment differ only by where each feed reports it. + */ +export const MATCH_RADIUS_KM = 15; +/** + * Copies with the same number match up to this far apart (km): two feeds + * report one train at different moments, and one polled every few minutes + * can be tens of kilometres behind at speed. + */ +export const NUMBER_RADIUS_KM = 100; +/** + * Grid cell for finding nearby copies (degrees). A row (27.8 km) spans + * MATCH_RADIUS_KM; a column narrows away from the equator (13.9 km at + * 60°N), so as many columns either side are searched as reach it + * (columnSpan). + */ +const CELL_DEG = 0.25; + +const POSITION_RANK = Object.freeze({ + gps: 2, + event: 1, +}); +/** + * Products whose trains carry short numbers of their own (`EC 7`, `NJ 40`). + * Not `IC`: in Switzerland `IC 1` and `IC 5` are lines run by many trains a + * day, and IC train numbers elsewhere have three or more digits anyway. + */ +const LONG_DISTANCE_PRODUCTS = new Set([ + 'EC', + 'ECE', + 'EN', + 'ICE', + 'NJ', + 'RJ', + 'RJX', + 'TGV', +]); + +/** + * A train number comparable across feeds: the last group of digits, without + * leading zeros (`RJX 160` → `160`, `09725` → `9725`), or null. + * @param {string|null|undefined} label + * @returns {string|null} + */ +export function trainNumber(label) { + const digits = /(\d{1,6})\D*$/.exec(String(label ?? ''))?.[1]; + const number = digits?.replace(/^0+/, '') ?? ''; + return number || null; +} + +/** + * What a name's number says: a train's own number (three digits or more, + * or after a long-distance product: `RJX 160`, `EC 7`), or a line's + * (`S1`, `RB 1`, `U6`: each run by many trains), with its product. + * @param {string|null|undefined} label + * @returns {{number: string|null, line: {product: string, + * number: string}|null}} + */ +export function labelNumbers(label) { + const match = /([A-Za-z]*)\s*(\d{1,6})\D*$/.exec(String(label ?? '')); + const number = match?.[2].replace(/^0+/, '') ?? ''; + if (!number) return { number: null, line: null }; + const product = match[1].toUpperCase(); + return number.length >= 3 || LONG_DISTANCE_PRODUCTS.has(product) + ? { number, line: null } + : { number: null, line: { product, number } }; +} + +/** + * A record's train number and line: the number from its name, unless that + * is only the vehicle's own id (a feed that labels each train with its car + * number) or names a line; else from a trip id that is an operator + * prefix and a number (`AM:530`); else from a headsign that is a bare + * number (where a feed with no trip names writes the train number). The line is the name's, + * when it names one. + * @param {{id?: string|null, label?: string|null, tripId?: string|null, + * headsign?: string|null}} record + * @returns {{number: string|null, line: {product: string, + * number: string}|null}} + */ +export function recordNumbers(record) { + const label = String(record?.label ?? '').trim(); + const named = + label && label !== String(record?.id ?? '') + ? labelNumbers(label) + : { number: null, line: null }; + if (named.number) return named; + const trip = /^[A-Za-z]{1,6}:(\d{1,6})$/.exec( + String(record?.tripId ?? '').trim(), + ); + const headsign = String(record?.headsign ?? '').trim(); + const number = trip + ? trainNumber(trip[1]) + : /^\d{3,6}$/.test(headsign) + ? trainNumber(headsign) + : null; + return number ? { number, line: null } : named; +} + +/** + * A record's train number (recordNumbers), or null. + * @param {object} record + * @returns {string|null} + */ +export function recordTrainNumber(record) { + return recordNumbers(record).number; +} + +/** Whether two lines can be one: one number, and one product where both name it. */ +function sameLine(a, b) { + return ( + a.number === b.number && + (!a.product || !b.product || a.product === b.product) + ); +} + +/** A number match outranks any line match. */ +const NUMBER_MATCH = 2; + +/** + * How strongly two copies are the same physical train: 0 when they are not, + * NUMBER_MATCH for the same number, else, for one line in one category, + * between 0.5 and 1, higher the nearer they are. See the module notes. + * @param {object} a A copy (`copyOf` in createDuplicateFilter). + * @param {object} b + * @returns {number} + */ +export function matchStrength(a, b) { + if (a.feedId === b.feedId) return 0; + const km = greatCircleKm(a, b); + if (a.number && b.number) + return km <= NUMBER_RADIUS_KM && a.number === b.number ? NUMBER_MATCH : 0; + if (km > MATCH_RADIUS_KM) return 0; + if (a.line && b.line && !sameLine(a.line, b.line)) return 0; + // Many trains run one line: in one category, the nearest copies pair + // first. + return a.line && b.line && a.category != null && a.category === b.category + ? 1 - km / (2 * MATCH_RADIUS_KM) + : 0; +} + +/** Whether two copies are the same physical train. */ +export function sameTrain(a, b) { + return matchStrength(a, b) > 0; +} + +/** + * Grid columns either side of a copy's that reach MATCH_RADIUS_KM east and + * west of it, measured on the poleward edge of its row's neighbours. + * @param {number} lat + * @returns {number} + */ +function columnSpan(lat) { + const poleward = Math.min(89, Math.abs(lat) + CELL_DEG); + const cellKm = CELL_DEG * 111.32 * Math.cos((poleward * Math.PI) / 180); + return Math.max(1, Math.ceil(MATCH_RADIUS_KM / cellKm)); +} + +/** + * Groups of two or more copies that are one train, at most one copy per + * feed: two trains of one feed are two trains, even where a third feed's + * copy resembles both (coupled TGVs). The strongest matches are joined + * first. Numbered copies are compared wherever they are, the others only + * within MATCH_RADIUS_KM's reach on the grid. + * @param {object[]} copies + * @returns {object[][]} + */ +export function matchCopies(copies) { + const cells = new Map(); + const cellOf = (lat, lon) => + `${Math.floor(lat / CELL_DEG)}|${Math.floor(lon / CELL_DEG)}`; + copies.forEach((copy, i) => { + if (!Number.isFinite(copy.lat) || !Number.isFinite(copy.lon)) return; + const key = cellOf(copy.lat, copy.lon); + if (!cells.has(key)) cells.set(key, []); + cells.get(key).push(i); + }); + const pairs = []; + // Numbered copies pair through their number, wherever they are. + const byNumber = new Map(); + copies.forEach((copy, i) => { + if (!copy.number) return; + if (!byNumber.has(copy.number)) byNumber.set(copy.number, []); + byNumber.get(copy.number).push(i); + }); + for (const list of byNumber.values()) + for (let x = 0; x < list.length; x++) + for (let y = x + 1; y < list.length; y++) { + const strength = matchStrength(copies[list[x]], copies[list[y]]); + if (strength > 0) pairs.push([strength, list[x], list[y]]); + } + // The others through the grid: only copies near each other. + copies.forEach((copy, i) => { + if (!Number.isFinite(copy.lat) || !Number.isFinite(copy.lon)) return; + const row = Math.floor(copy.lat / CELL_DEG); + const col = Math.floor(copy.lon / CELL_DEG); + const span = columnSpan(copy.lat); + for (let dr = -1; dr <= 1; dr++) + for (let dc = -span; dc <= span; dc++) + for (const j of cells.get(`${row + dr}|${col + dc}`) ?? []) { + if (j <= i || (copy.number && copies[j].number)) continue; + const strength = matchStrength(copy, copies[j]); + if (strength > 0) pairs.push([strength, i, j]); + } + }); + pairs.sort((a, b) => b[0] - a[0]); + const parent = copies.map((_, i) => i); + const feeds = copies.map((copy) => new Set([copy.feedId])); + const members = copies.map((_, i) => [i]); + const find = (i) => { + while (parent[i] !== i) i = parent[i] = parent[parent[i]]; + return i; + }; + for (const [, i, j] of pairs) { + const a = find(i); + const b = find(j); + if (a === b || [...feeds[b]].some((feed) => feeds[a].has(feed))) continue; + // Every copy must match every other: a copy resembling two trains (an + // unnumbered train at a busy hub) never joins them into one. + const all = members[a].every((x) => + members[b].every( + (y) => (x === i && y === j) || matchStrength(copies[x], copies[y]) > 0, + ), + ); + if (!all) continue; + parent[b] = a; + for (const feed of feeds[b]) feeds[a].add(feed); + members[a].push(...members[b]); + } + const groups = new Map(); + copies.forEach((copy, i) => { + const root = find(i); + if (!groups.has(root)) groups.set(root, []); + groups.get(root).push(copy); + }); + return [...groups.values()].filter((group) => group.length > 1); +} + +/** + * A copy's score: `[position, line]`, compared in that order. + * @param {object} copy + * @returns {number[]} + */ +export function scoreCopy(copy) { + return [POSITION_RANK[copy.position] ?? 0, copy.onShape === true ? 1 : 0]; +} + +function better(a, b) { + for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return a[i] > b[i]; + return false; +} + +/** + * The copy each group draws, keeping last time's choice unless another copy + * scores strictly higher. + * @param {object[][]} groups From matchCopies. + * @param {Map} [previous] copy key → key drawn last time. + * @returns {{winners: Map, hidden: Set}} + * `winners`: every grouped copy's key → the key drawn for its train. + */ +export function chooseCopies(groups, previous = new Map()) { + const winners = new Map(); + const hidden = new Set(); + for (const group of groups) { + const scored = group.map((copy) => ({ copy, score: scoreCopy(copy) })); + const keys = new Set(group.map((copy) => copy.key)); + const incumbentKey = group + .map((copy) => previous.get(copy.key)) + .find((key) => key && keys.has(key)); + let best = + scored.find(({ copy }) => copy.key === incumbentKey) ?? + scored.reduce((a, b) => (better(b.score, a.score) ? b : a)); + for (const candidate of scored) + if (better(candidate.score, best.score)) best = candidate; + for (const { copy } of scored) { + winners.set(copy.key, best.copy.key); + if (copy.key !== best.copy.key) hidden.add(copy.key); + } + } + return { winners, hidden }; +} + +/** + * The layer's duplicate filter: after each poll it matches the trains every + * loaded feed carries, marks the copies not drawn (`entry.duplicate`), and + * moves a selection that sat on a copy now hidden to the copy drawn, so the + * card follows the same physical train (its feed line names the new + * source). + * @param {{state: object, parts: object, + * defer?: (fn: Function) => unknown}} context + */ +export function createDuplicateFilter({ + state, + parts, + defer = (fn) => setTimeout(fn, 0), +}) { + let previous = new Map(); + let pending = false; + let lastStats = { groups: 0, hidden: 0 }; + + /** The comparable view of one drawn train. */ + function copyOf(entry) { + const record = entry.record ?? {}; + const { number, line } = recordNumbers(record); + return { + key: entry.key, + feedId: entry.feedId, + lat: record.lat, + lon: record.lon, + number, + line, + category: entry.category ?? record.category ?? null, + position: record.positionSource ?? 'gps', + onShape: Number.isInteger(entry.path?.shapeIndex), + }; + } + + /** Recompute which copies are drawn. Cheap enough for once per poll. */ + function refresh() { + pending = false; + const entries = [...state._trains.values()]; + const { winners, hidden } = chooseCopies( + matchCopies(entries.map(copyOf)), + previous, + ); + previous = winners; + for (const entry of entries) { + const duplicate = hidden.has(entry.key); + if (entry.duplicate === duplicate) continue; + entry.duplicate = duplicate; + parts.rendering.paint(entry); + parts.rendering.schedulePlayback(entry); + } + // A selection stays on its train: it moves to the copy now drawn. + const selected = state._selectedKey; + if (selected && hidden.has(selected)) { + const drawn = winners.get(selected); + if (drawn && state._trains.has(drawn)) parts.selection.selectTrain(drawn); + } + lastStats = { + groups: new Set(winners.values()).size, + hidden: hidden.size, + }; + return lastStats; + } + + /** Coalesce the refreshes of several feeds' polls into one. */ + function scheduleRefresh() { + if (pending) return; + pending = true; + defer(() => { + if (pending) refresh(); + }); + } + + return { + refresh, + scheduleRefresh, + stats: () => ({ ...lastStats }), + }; +} diff --git a/src/layers/rail/dedupe.test.mjs b/src/layers/rail/dedupe.test.mjs new file mode 100644 index 000000000..ac3f61f6b --- /dev/null +++ b/src/layers/rail/dedupe.test.mjs @@ -0,0 +1,364 @@ +// One physical train in several loaded live feeds is drawn once: copies are +// matched by train number, or by line and category when near each other, +// and the copy with the better position source and line is drawn. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + NUMBER_RADIUS_KM, + chooseCopies, + createDuplicateFilter, + labelNumbers, + matchCopies, + recordNumbers, + recordTrainNumber, + sameTrain, + scoreCopy, + trainNumber, +} from './dedupe.js'; +import * as dedupe from './dedupe.js'; + +const ROSENHEIM = { lat: 47.85, lon: 12.119 }; +const SALZBURG = { lat: 47.813, lon: 13.046 }; +const LINZ = { lat: 48.29, lon: 14.291 }; + +/** A fake copy of a live train. */ +function copy(key, feedId, extra = {}) { + return { + key, + feedId, + lat: ROSENHEIM.lat, + lon: ROSENHEIM.lon, + number: null, + line: null, + category: null, + position: 'gps', + onShape: false, + ...extra, + }; +} + +test('train numbers compare across feeds', () => { + assert.equal(trainNumber('RJX 160'), '160'); + assert.equal(trainNumber('160'), '160'); + assert.equal(trainNumber('09725'), '9725'); + assert.equal(trainNumber('TGV 10465'), '10465'); + for (const none of [null, '', 'S', '0', 'RJX']) + assert.equal(trainNumber(none), null, String(none)); +}); + +test('the same number is one train; another number is not', () => { + const a = copy('a', 'feed-a', { number: '160' }); + assert.equal(sameTrain(a, copy('b', 'feed-b', { number: '160' })), true); + assert.equal(sameTrain(a, copy('b', 'feed-b', { number: '161' })), false); + // Never within one feed, never far apart. + assert.equal(sameTrain(a, copy('b', 'feed-a', { number: '160' })), false); + assert.equal( + sameTrain(a, copy('b', 'feed-b', { number: '160', ...LINZ })), + false, + ); + // Two feeds report one train at different moments: 70 km on + // (Rosenheim–Salzburg) it is still the same numbered train. + assert.ok(NUMBER_RADIUS_KM > 70); + assert.equal( + sameTrain(a, copy('b', 'feed-b', { number: '160', ...SALZBURG })), + true, + ); + assert.equal( + matchCopies([a, copy('b', 'feed-b', { number: '160', ...SALZBURG })]) + .length, + 1, + ); +}); + +test('the filter matches nothing from timetables: no stops, dates or homes', () => { + // Copies are live trains only; nothing in the filter reads a schedule. + for (const gone of [ + 'scheduleRunStops', + 'scheduleHomeCountry', + 'copyCountry', + 'shareTimedStops', + 'serviceDateKey', + ]) + assert.equal(dedupe[gone], undefined, gone); + // Unnumbered copies off any line are never joined, however close. + assert.equal(sameTrain(copy('a', 'feed-a'), copy('b', 'feed-b')), false); +}); + +test('a bare-number headsign is the number when there is no name', () => { + assert.equal( + recordTrainNumber({ label: null, headsign: '839162' }), + '839162', + ); + assert.equal( + recordTrainNumber({ label: 'RJX 160', headsign: '9999' }), + '160', + ); + assert.equal(recordTrainNumber({ label: null, headsign: 'Paris-Est' }), null); + assert.equal(recordTrainNumber({ label: null, headsign: 'S 12' }), null); +}); + +test('a live vehicle labelled with its own car number takes the number from its trip', () => { + // A live feed's vehicle 8306 runs train 530. + const live = { id: '8306', label: '8306', tripId: 'AM:530' }; + assert.equal(recordTrainNumber(live), '530'); + // A label that is not the vehicle id is the train's name. + assert.equal( + recordTrainNumber({ id: 'v1', label: '531', tripId: 'AM:530' }), + '531', + ); + // Long trip ids are not train numbers. + assert.equal( + recordTrainNumber({ id: '7', label: '7', tripId: '0519321010VRFV' }), + null, + ); + // The GPS copy matches a station-report copy of train 530, and wins over + // it even on the operator's line. + const groups = matchCopies([ + copy('gps', 'gps-feed', { number: recordTrainNumber(live) }), + copy('event', 'event-feed', { + number: '530', + position: 'event', + shape: 'operator', + }), + ]); + assert.equal(groups.length, 1); + assert.equal(chooseCopies(groups).winners.get('event'), 'gps'); +}); + +test('a group never holds two trains of one feed (coupled trains)', () => { + // Two coupled TGVs side by side in one feed; the other feed names one of + // them. Each pairs with its own number only. + const groups = matchCopies([ + copy('fr-6194', 'feed-fr', { number: '6194' }), + copy('fr-6906', 'feed-fr', { number: '6906' }), + copy('ch-6194', 'feed-ch', { number: '6194' }), + copy('ch-6906', 'feed-ch', { number: '6906' }), + ]); + assert.deepEqual( + groups.map((group) => group.map((c) => c.key).sort()).sort(), + [ + ['ch-6194', 'fr-6194'], + ['ch-6906', 'fr-6906'], + ], + ); + // On one line, one feed's two copies still never share a group. + const s1 = { ...recordNumbers({ label: 'S1' }), category: 'local' }; + const unnamed = matchCopies([ + copy('a1', 'feed-a', s1), + copy('a2', 'feed-a', { ...s1, lat: ROSENHEIM.lat + 0.01 }), + copy('b1', 'feed-b', s1), + ]); + assert.equal(unnamed.length, 1); + assert.equal(new Set(unnamed[0].map((c) => c.feedId)).size, 2); +}); + +test('a copy resembling two different trains never joins them', () => { + // An unnumbered S1 lies near two numbered trains on its line; the two + // carry different numbers, so the three are never one train. + const s1 = { line: { product: 'S', number: '1' }, category: 'local' }; + const groups = matchCopies([ + copy('n1', 'feed-a', { number: '1211', ...s1 }), + copy('n2', 'feed-s', { number: '22739', ...s1 }), + copy('s1', 'feed-c', s1), + ]); + assert.ok( + groups.every((group) => group.length < 3), + 'never all three', + ); +}); + +test('the score: position source, then the line', () => { + const base = copy('a', 'x'); + assert.deepEqual(scoreCopy(base), [2, 0]); + assert.deepEqual(scoreCopy({ ...base, position: 'event' }), [1, 0]); + assert.deepEqual(scoreCopy({ ...base, onShape: true }), [2, 1]); + // A source the layer does not know scores nothing. + assert.deepEqual(scoreCopy({ ...base, position: 'psychic' }), [0, 0]); +}); + +test('equal copies never trade places: the one drawn stays drawn', () => { + const twins = [ + copy('a', 'f1', { number: '160', position: 'event' }), + copy('b', 'f2', { number: '160', position: 'event' }), + ]; + const first = chooseCopies([twins]); + const drawn = first.winners.get('a'); + for (let i = 0; i < 3; i++) { + const again = chooseCopies([[...twins].reverse()], first.winners); + assert.equal(again.winners.get('a'), drawn); + } + // A GPS copy takes over from a station-report one at once. + const live = chooseCopies( + [[twins[0], { ...twins[1], position: 'gps' }]], + new Map([ + ['a', 'a'], + ['b', 'a'], + ]), + ); + assert.equal(live.winners.get('a'), 'b'); +}); + +/** + * A layer's state with one live train per feed, both train RJX 160, and + * spies for what the filter calls. + */ +function fakeLayer({ selected = null } = {}) { + const entry = (feedId, onShape) => ({ + key: `${feedId}:v160`, + feedId, + record: { + id: 'v160', + tripId: 't1', + label: 'RJX 160', + ...ROSENHEIM, + positionSource: 'gps', + }, + path: onShape ? { shapeIndex: 0 } : null, + }); + const trains = [entry('feed-a', false), entry('feed-b', true)]; + const state = { + _trains: new Map(trains.map((t) => [t.key, t])), + _selectedKey: selected, + }; + const calls = { paint: [], selected: [] }; + const parts = { + rendering: { + paint: (e) => calls.paint.push([e.key, e.duplicate]), + schedulePlayback: () => {}, + }, + selection: { + selectTrain: (key) => { + calls.selected.push(key); + state._selectedKey = key; + }, + }, + }; + return { state, parts, calls, trains }; +} + +test('the filter hides the weaker copy and the selection follows its train', () => { + const layer = fakeLayer({ selected: 'feed-a:v160' }); + const filter = createDuplicateFilter({ ...layer, defer: (fn) => fn() }); + assert.deepEqual(filter.refresh(), { groups: 1, hidden: 1 }); + const [unbound, operator] = layer.trains; + assert.equal(unbound.duplicate, true, 'not on the operator’s line'); + assert.equal(operator.duplicate, false); + assert.deepEqual(layer.calls.selected, [operator.key]); + // When the copy on the operator's line goes (its feed left the view), the + // other is drawn. + layer.state._trains.delete(operator.key); + filter.scheduleRefresh(); + assert.equal(unbound.duplicate, false); + assert.deepEqual(filter.stats(), { groups: 0, hidden: 0 }); +}); + +test('a short number after a local product names a line, not a train (S1, RB 1, U6)', () => { + for (const label of ['S1', 'RB 1', 'U6', 'S 12', 'RE5']) + assert.equal(recordTrainNumber({ label }), null, label); + assert.deepEqual(labelNumbers('RB 1').line, { product: 'RB', number: '1' }); + // Three digits, or a long-distance product's own short number, are a + // train's. + assert.equal(recordTrainNumber({ label: 'EC 7' }), '7'); + assert.equal(recordTrainNumber({ label: 'NJ 40' }), '40'); + assert.equal(recordTrainNumber({ label: 'S 1264' }), '1264'); + // A line name gives way to a trip id that is the train's number. + assert.equal(recordTrainNumber({ label: '5', tripId: 'AM:5' }), '5'); + + // An S1 and an RB 1 25 km apart are never joined as one train numbered 1. + const s1 = copy('s1', 'feed-a', { + ...recordNumbers({ label: 'S1' }), + lat: 48.14, + lon: 11.56, + category: 'local', + }); + const rb1 = copy('rb1', 'feed-b', { + ...recordNumbers({ label: 'RB 1' }), + lat: 48.14 + 25 / 111.32, + lon: 11.56, + category: 'regional', + }); + assert.equal(sameTrain(s1, rb1), false); + assert.deepEqual(matchCopies([s1, rb1]), []); + // Nearer, still two lines. + assert.equal(sameTrain(s1, { ...rb1, lat: 48.15, category: 'local' }), false); +}); + +test('copies on one line are one train in one category, the nearest first', () => { + const at = (key, feedId, km, extra = {}) => + copy(key, feedId, { + ...recordNumbers({ label: extra.label ?? 'S1' }), + lat: 48.14 + km / 111.32, + lon: 11.56, + category: 'local', + ...extra, + }); + const gps = at('gps', 'feed-a', 0); + // The other feed reports two S1 trains near it; the nearer is the same. + const near = at('near', 'feed-b', 1, { position: 'event' }); + const next = at('next', 'feed-b', 6, { label: 'S 1', position: 'event' }); + assert.equal(sameTrain(gps, near), true); + assert.deepEqual( + matchCopies([next, gps, near]).map((group) => + group.map((c) => c.key).sort(), + ), + [['gps', 'near']], + ); + // Another category, or beyond the match radius: not one train. + assert.equal(sameTrain(gps, { ...near, category: 'regional' }), false); + assert.equal(sameTrain(gps, at('far', 'feed-b', 20)), false); +}); + +test('copies on one line 14 km apart east-west at 60°N are compared', () => { + // A 0.25° grid column is 13.9 km wide there: the copies sit two columns + // apart. + const line = { ...recordNumbers({ label: 'R1' }), category: 'regional' }; + const west = copy('west', 'feed-a', { lat: 60, lon: 10.24, ...line }); + const east = copy('east', 'feed-b', { lat: 60, lon: 10.5, ...line }); + assert.equal(sameTrain(west, east), true); + assert.equal(matchCopies([west, east]).length, 1); +}); + +test('the filter reads lines and categories from the trains it compares', () => { + const entry = (feedId, label, category, lat) => ({ + key: `${feedId}:${label}`, + feedId, + category, + record: { + id: `v-${feedId}`, + label, + lat, + lon: 11.56, + positionSource: 'gps', + }, + }); + const trains = [ + entry('munich-live', 'S1', 'local', 48.14), + entry('other-live', 'S 1', 'local', 48.15), + entry('third-live', 'RB 1', 'regional', 48.16), + ]; + const state = { + _trains: new Map(trains.map((t) => [t.key, t])), + _pathPacks: new Map(), + _selectedKey: null, + }; + const parts = { + rendering: { paint() {}, schedulePlayback() {} }, + selection: { selectTrain() {} }, + }; + const filter = createDuplicateFilter({ + state, + parts, + defer: (fn) => fn(), + }); + assert.deepEqual(filter.refresh(), { groups: 1, hidden: 1 }); + assert.equal(trains[2].duplicate, false, 'the RB 1 is its own train'); +}); + +test('a Swiss IC line name is not a train number', () => { + assert.deepEqual(labelNumbers('IC 1'), { + number: null, + line: { product: 'IC', number: '1' }, + }); + assert.deepEqual(labelNumbers('IC 2010'), { number: '2010', line: null }); + assert.deepEqual(labelNumbers('EC 7'), { number: '7', line: null }); +}); diff --git a/src/layers/rail/follow.js b/src/layers/rail/follow.js new file mode 100644 index 000000000..7e5b0075e --- /dev/null +++ b/src/layers/rail/follow.js @@ -0,0 +1,230 @@ +import * as Cesium from 'cesium'; +import { + FOLLOW_HANDOVER_MS, + railFollowOffset, + railTrainFollowable, +} from './policy.js'; + +/** The selected train's kind in the shared world-focus framing. */ +export const RAIL_FOCUS_KIND = 'train'; + +/** + * The selected train's camera. + * + * A moving train is followed as a tracked aircraft or satellite is: the layer + * gives Cesium's `viewer.trackedEntity` an entity that stands on the train's + * marker, framed by the shared world-focus framing for a train, so the camera + * rides with the train and the user still orbits and zooms around it. Any + * other train gets one fly-to through the shared world-focus request, as a + * vessel does. Inside the Cockpit that request is refused with its toast. + * + * The follow lets go when the train is deselected (an empty click, Escape, its + * category hidden, the train leaving its feed, the layer turned off), when + * another train is selected (which takes the camera in its turn), and when + * another owner takes the camera: an aircraft or satellite tracked by another + * layer clears the selection as well, while a navigation that only releases + * the follow camera (a search, a fly-to) leaves the train selected. Letting + * go leaves the camera where it is. + * + * When the selection moves to another feed's copy of the same train + * (dedupe.js), the tracked entity stays and glides to the new copy. + * + * Services (all optional; without them a selection has no camera): + * services.camera = { + * framing: {rangeM, pitchDeg}, // WORLD_FOCUS_FRAMING.train + * requestWorldFocus(detail) → boolean, // src/worldFocus.js + * applyTrackedCameraFrame(viewer, entity, viewFrom) → stop, + * isCockpitActive() → boolean, + * } + * + * @param {object} context + * @returns {object} + */ +export function createFollow({ state, services, parts }) { + const camera = services.camera ?? null; + let removeTrackedListener = null; + + function markerPosition(entry) { + const p = entry?.marker?.position; + return p && + Number.isFinite(p.x) && + Number.isFinite(p.y) && + Number.isFinite(p.z) && + Cesium.Cartesian3.magnitudeSquared(p) > 0 + ? p + : null; + } + + /** + * Where the tracked entity stands: on the selected train's marker, or + * gliding to it after a handover. Never hands out the marker's own vector. + */ + function followedPosition(follow, result) { + const live = markerPosition(state._trains.get(state._selectedKey)); + if (live) Cesium.Cartesian3.clone(live, follow.last); + const out = result ?? new Cesium.Cartesian3(); + const blend = follow.blend; + if (blend) { + const t = (performance.now() - blend.startMs) / FOLLOW_HANDOVER_MS; + if (t < 1) { + const eased = t <= 0 ? 0 : t * t * (3 - 2 * t); + Cesium.Cartesian3.lerp(blend.from, follow.last, eased, out); + return Cesium.Cartesian3.clone(out, follow.shown); + } + follow.blend = null; + } + Cesium.Cartesian3.clone(follow.last, out); + Cesium.Cartesian3.clone(out, follow.shown); + return out; + } + + /** Whether the camera follows this train now. */ + function isFollowing(key) { + const follow = state._follow; + return Boolean( + follow && + follow.key === key && + state._viewer?.trackedEntity === follow.entity, + ); + } + + /** + * Let go of the train. The camera stays where the follow left it. + * @param {{untrack?: boolean}} [options] `untrack: false` when another + * owner already took the camera: its tracked entity is left alone. + */ + function release({ untrack = true } = {}) { + const follow = state._follow; + if (!follow) return; + // Cleared first: the tracked-entity change below is this layer's own. + state._follow = null; + follow.stopFrame?.(); + const viewer = state._viewer; + if (!viewer || viewer.isDestroyed?.()) return; + if (untrack && viewer.trackedEntity === follow.entity) + viewer.trackedEntity = undefined; + viewer.entities.remove(follow.entity); + } + + function startFollow(entry, position) { + const viewer = state._viewer; + const offset = railFollowOffset(camera.framing, viewer.camera?.heading); + const follow = { + key: entry.key, + entity: null, + last: Cesium.Cartesian3.clone(position), + shown: Cesium.Cartesian3.clone(position), + blend: null, + stopFrame: null, + }; + // Cesium's tracking camera engages only on an entity it can bound, so the + // entity has a point: a transparent one, never drawn or picked, leaving + // the marker the train's only visual. + const entity = viewer.entities.add({ + position: new Cesium.CallbackProperty( + (time, result) => followedPosition(follow, result), + false, + ), + // The frame the close-range guard (applyTrackedCameraFrame) uses too. + trackingReferenceFrame: Cesium.TrackingReferenceFrame.ENU, + viewFrom: new Cesium.Cartesian3(offset.x, offset.y, offset.z), + point: { pixelSize: 1, color: Cesium.Color.TRANSPARENT }, + }); + entity.gevSelectionOrigin = 'user'; + follow.entity = entity; + state._follow = follow; + // A flight in progress would keep Cesium from applying the frame. + viewer.camera?.cancelFlight?.(); + viewer.trackedEntity = entity; + follow.stopFrame = + camera.applyTrackedCameraFrame?.(viewer, entity, entity.viewFrom) || null; + return true; + } + + /** + * Give the camera to a train the user selected: follow it when it moves, + * else fly to it once. A train already followed keeps its follow. + * @param {object} entry + * @returns {boolean} Whether the camera was taken or kept. + */ + function focus(entry) { + if (!camera || !entry) return false; + if (isFollowing(entry.key)) return true; + release(); + const viewer = state._viewer; + const position = markerPosition(entry); + if (!viewer || !position) return false; + if ( + camera.framing && + !camera.isCockpitActive?.() && + railTrainFollowable(entry) + ) + return startFollow(entry, position); + const label = String(entry.record?.label || entry.record?.id || '').trim(); + return ( + camera.requestWorldFocus?.({ + kind: RAIL_FOCUS_KIND, + id: entry.key, + label: label || entry.key, + position: Cesium.Cartesian3.clone(position), + }) === true + ); + } + + /** + * The selection moved to another feed's copy of the followed train: the + * tracked entity stays, and glides from where it stands to the new copy. + * @param {object} entry The copy now selected. + */ + function handover(entry) { + const follow = state._follow; + if (!follow || !entry || follow.key === entry.key) return; + follow.key = entry.key; + follow.blend = { + from: Cesium.Cartesian3.clone(follow.shown), + startMs: performance.now(), + }; + } + + function onTrackedEntityChanged() { + const follow = state._follow; + const tracked = state._viewer?.trackedEntity; + if (!follow || tracked === follow.entity) return; + // An aircraft or satellite another layer tracks takes the scene, as it + // does from a selected vessel. A navigation that only releases the follow + // camera keeps the train selected. + if (tracked) parts.selection.clearSelection({ untrack: false }); + else release({ untrack: false }); + } + + /** Watch for other camera owners while the layer is on. */ + function install(viewer) { + if (removeTrackedListener || !viewer?.trackedEntityChanged) return; + removeTrackedListener = viewer.trackedEntityChanged.addEventListener( + onTrackedEntityChanged, + ); + } + + function uninstall() { + removeTrackedListener?.(); + removeTrackedListener = null; + } + + /** For QA probes: which train the camera follows, if any. */ + function stats() { + const follow = state._follow; + return follow + ? { key: follow.key, tracked: isFollowing(follow.key) } + : null; + } + + return { + focus, + release, + handover, + isFollowing, + install, + uninstall, + stats, + }; +} diff --git a/src/layers/rail/follow.test.mjs b/src/layers/rail/follow.test.mjs new file mode 100644 index 000000000..98cedad67 --- /dev/null +++ b/src/layers/rail/follow.test.mjs @@ -0,0 +1,454 @@ +// The selected train's camera and its visibility from far out: a moving train +// is followed through Cesium's tracked entity (as an aircraft is), any other +// train is flown to once, and the selected train's feed stays polled at any +// distance while the rest of the network hides as before. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { + ACTIVATION_EXIT_ALTITUDE_M, + FOLLOW_HANDOVER_MS, + RAIL_POLL_MS, + railFollowOffset, + railTrainFollowable, + railTrainKey, +} from './policy.js'; +import { WORLD_FOCUS_FRAMING } from '../../worldFocus.js'; +import { createTrack, insertFix } from '../../data/contactPlayback.js'; +import { + BOSTON, + RAIL_HARNESS_T0 as T0, + railLayerHarness as harness, + railTrain as train, +} from '../../testSupport/railLayerHarness.mjs'; + +const MADRID = { lat: 40.4168, lon: -3.7038 }; +const ORBITAL_M = 20_000_000; + +/** + * Feed a train reports that move it, poll after poll, until its playback is + * gliding along them. + */ +async function bringToMotion(app, feedId, id, start) { + let step = 0; + app.feeds.set(feedId, () => [ + train(id, start.lat + step * 0.003, start.lon), + train(`${id}-parked`, start.lat - 0.05, start.lon - 0.05), + ]); + await app.enable(); + const key = railTrainKey(feedId, id); + for (let i = 0; i < 12 && !railTrainFollowable(app.entry(key)); i += 1) { + step += 1; + app.run(RAIL_POLL_MS); + await app.poll(); + } + assert.ok(railTrainFollowable(app.entry(key)), 'the train is moving'); + return key; +} + +test('the follow offset is the shared train framing seen along the heading', () => { + const framing = WORLD_FOCUS_FRAMING.train; + const north = railFollowOffset(framing, 0); + // Behind (south of) the train and above it, looking north and down. + assert.ok(Math.abs(north.x) < 1e-9); + assert.ok(north.y < 0 && north.z > 0); + const range = Math.hypot(north.x, north.y, north.z); + assert.ok(Math.abs(range - framing.rangeM) < 1e-6); + const pitch = (Math.atan2(north.z, -north.y) * 180) / Math.PI; + assert.ok(Math.abs(pitch + framing.pitchDeg) < 1e-9); + // Facing east: the camera stands west of the train. + const east = railFollowOffset(framing, Math.PI / 2); + assert.ok(east.x < 0 && Math.abs(east.y) < 1e-6); +}); + +/** A playback track of reports 15 s apart at these [lat, lon] places. */ +function trackOf(points) { + const track = createTrack(); + points.forEach(([lat, lon], i) => + insertFix(track, { t: T0 + i * 15_000, lat, lon }, { replayed: true }), + ); + return track; +} + +test('a train is followed while it moves, and not once it is parked or unheard', () => { + const entry = (fields, points = [[BOSTON.lat, BOSTON.lon]]) => ({ + sample: { phase: 'playing', latestReportAgeMs: 20_000, ...fields }, + track: trackOf(points), + }); + const moved = [ + [BOSTON.lat, BOSTON.lon], + [BOSTON.lat + 0.003, BOSTON.lon], + ]; + const parked = [ + [BOSTON.lat, BOSTON.lon], + [BOSTON.lat + 0.00005, BOSTON.lon], + ]; + // Gliding between two reports. + assert.equal(railTrainFollowable(entry({ segmentSpeedMps: 25 })), true); + // Dwelling at a stop with its departure already reported. + assert.equal( + railTrainFollowable( + entry({ + segmentSpeedMps: 0, + motion: 'stopped', + displayT: 1_000, + nextMotionT: 31_000, + }), + ), + true, + ); + // Between reports: the playback waits at the latest one (or is still + // buffering) after the last two moved it; this is a moving train. + for (const phase of ['held', 'buffering']) + assert.equal( + railTrainFollowable( + entry({ phase, segmentSpeedMps: 0, motion: 'unknown' }, moved), + ), + true, + phase, + ); + // Parked: the last reports are one place (GPS jitter aside), and nothing + // says it will move. + assert.equal( + railTrainFollowable( + entry( + { + segmentSpeedMps: 0, + motion: 'stopped', + displayT: 1_000, + nextMotionT: Infinity, + }, + parked, + ), + ), + false, + ); + // A single report, a train not heard from lately, or no playback at all. + assert.equal( + railTrainFollowable( + entry({ phase: 'held', segmentSpeedMps: 0, motion: 'unknown' }), + ), + false, + ); + assert.equal( + railTrainFollowable( + entry({ segmentSpeedMps: 25, latestReportAgeMs: 120_000 }, moved), + ), + false, + ); + assert.equal(railTrainFollowable({}), false); + assert.equal(railTrainFollowable(null), false); +}); + +test('selecting a moving train hands Cesium a tracked entity that rides on its marker', async (t) => { + const app = harness(t); + const key = await bringToMotion(app, 'mbta-rail', 'T1', BOSTON); + app.viewer.camera.heading = Math.PI / 4; + const cancelled = app.viewer.camera.flightsCancelled; + app.parts().selection.selectAndFocusTrain(key); + + const entity = app.viewer.trackedEntity; + assert.ok(entity, 'the camera tracks an entity'); + assert.equal(app.focusRequests.length, 0, 'no one-shot fly-to'); + assert.equal(app.viewer.camera.flightsCancelled, cancelled + 1); + // The tracked-frame guard the aircraft use frames it once, from the + // train's own framing seen along the camera's heading. + assert.equal(app.frames.length, 1); + assert.equal(app.frames[0].entity, entity); + const viewFrom = entity.viewFrom.getValue(Cesium.JulianDate.now()); + const expected = railFollowOffset(WORLD_FOCUS_FRAMING.train, Math.PI / 4); + assert.ok(Math.abs(viewFrom.x - expected.x) < 1e-6); + assert.ok(Math.abs(viewFrom.z - expected.z) < 1e-6); + // Transparent and unbounded by anything but a point: the marker stays the + // only thing drawn and picked there. + assert.equal(entity.point.color.getValue().alpha, 0); + + // The entity stands where the marker is drawn, frame after frame. + const marker = app.entry(key).marker; + for (let i = 0; i < 4; i += 1) { + app.run(1_000); + const shown = app.trackedPosition(); + assert.ok( + Cesium.Cartesian3.distance(shown, marker.position) < 1e-6, + 'on the marker', + ); + } + assert.ok(app.layer.getRouteDisplayStats().follow.tracked); + + // Picking the followed train again keeps the follow as it is. + app.parts().selection.selectAndFocusTrain(key); + assert.equal(app.viewer.trackedEntity, entity); + assert.equal(app.frames.length, 1); +}); + +test('a parked train, or one known from a single report, is flown to once', async (t) => { + const app = harness(t); + await bringToMotion(app, 'mbta-rail', 'T1', BOSTON); + const parked = railTrainKey('mbta-rail', 'T1-parked'); + assert.equal(railTrainFollowable(app.entry(parked)), false); + app.parts().selection.selectAndFocusTrain(parked); + assert.equal(app.viewer.trackedEntity, undefined); + assert.equal(app.frames.length, 0); + assert.equal(app.focusRequests.length, 1); + const request = app.focusRequests[0]; + assert.equal(request.kind, 'train'); + assert.equal(request.id, parked); + assert.ok( + Cesium.Cartesian3.distance( + request.position, + app.entry(parked).marker.position, + ) < 1e-6, + ); + // A copy, never the billboard's own vector. + assert.notEqual(request.position, app.entry(parked).marker.position); + + // A second click flies again, as for a vessel. + app.parts().selection.selectAndFocusTrain(parked); + assert.equal(app.focusRequests.length, 2); + + // A train with one report has no motion to follow. + app.feeds.set('mbta-rail', () => [ + train('T1', BOSTON.lat, BOSTON.lon), + train('T1-parked', BOSTON.lat - 0.05, BOSTON.lon - 0.05), + train('NEW', BOSTON.lat + 0.02, BOSTON.lon + 0.02), + ]); + app.run(RAIL_POLL_MS); + await app.poll(); + const fresh = railTrainKey('mbta-rail', 'NEW'); + app.parts().selection.selectAndFocusTrain(fresh); + assert.equal(app.viewer.trackedEntity, undefined); + assert.equal(app.focusRequests.at(-1).id, fresh); +}); + +test('inside the Cockpit a moving train is not followed: the shared request refuses', async (t) => { + const app = harness(t); + const key = await bringToMotion(app, 'mbta-rail', 'T1', BOSTON); + app.setCockpit(true); + app.parts().selection.selectAndFocusTrain(key); + assert.equal(app.viewer.trackedEntity, undefined); + assert.equal(app.focusRequests.length, 1, 'the request carries the refusal'); + assert.equal(app.state()._selectedKey, key, 'the train is still selected'); +}); + +test('deselecting or Escape lets go of the camera where it is', async (t) => { + const app = harness(t); + const key = await bringToMotion(app, 'mbta-rail', 'T1', BOSTON); + + app.parts().selection.selectAndFocusTrain(key); + const entity = app.viewer.trackedEntity; + app.parts().selection.clearSelection(); + assert.equal(app.viewer.trackedEntity, undefined); + assert.equal(app.viewer.entities.contains(entity), false); + assert.equal(app.frames[0].stopped, true, 'the frame guard is released'); + assert.equal(app.state()._follow, null); + + app.parts().selection.selectAndFocusTrain(key); + assert.ok(app.viewer.trackedEntity); + globalThis.document.dispatchEvent( + Object.assign(new Event('keydown'), { key: 'Escape' }), + ); + assert.equal(app.state()._selectedKey, null); + assert.equal(app.viewer.trackedEntity, undefined); + assert.equal(app.viewer.entities.values.length, 0); +}); + +test('orbiting and zooming around the train keep the follow, as for an aircraft', async (t) => { + const app = harness(t); + const key = await bringToMotion(app, 'mbta-rail', 'T1', BOSTON); + app.parts().selection.selectAndFocusTrain(key); + const entity = app.viewer.trackedEntity; + // Camera input while tracked moves the camera around the target; nothing + // here changes the tracked entity, so the follow stays. + await app.moveCamera({ altitude: 60_000 }); + app.run(2_000); + await app.moveCamera({ altitude: 8_000 }); + assert.equal(app.viewer.trackedEntity, entity); + assert.equal(app.frames[0].stopped, false); + assert.ok(app.parts().follow.isFollowing(key)); +}); + +test('another camera owner ends the follow: a tracked aircraft takes the selection too', async (t) => { + const app = harness(t); + const key = await bringToMotion(app, 'mbta-rail', 'T1', BOSTON); + app.parts().selection.selectAndFocusTrain(key); + const ours = app.viewer.trackedEntity; + + // An aircraft layer tracks its contact. + const aircraft = app.viewer.entities.add({ + position: Cesium.Cartesian3.fromDegrees(-71, 42.3, 3000), + point: { pixelSize: 4 }, + }); + app.viewer.trackedEntity = aircraft; + assert.equal(app.viewer.trackedEntity, aircraft, 'its entity is untouched'); + assert.equal(app.state()._selectedKey, null); + assert.equal(app.viewer.entities.contains(ours), false); + assert.equal(app.frames[0].stopped, true); +}); + +test('a navigation that releases the follow camera keeps the train selected', async (t) => { + const app = harness(t); + const key = await bringToMotion(app, 'mbta-rail', 'T1', BOSTON); + app.parts().selection.selectAndFocusTrain(key); + const ours = app.viewer.trackedEntity; + // What the shell's release does before a search or a fly-to. + app.viewer.trackedEntity = undefined; + assert.equal(app.state()._selectedKey, key); + assert.equal(app.state()._follow, null); + assert.equal(app.viewer.entities.contains(ours), false); + // Picking the train again takes the camera back. + app.parts().selection.selectAndFocusTrain(key); + assert.ok(app.parts().follow.isFollowing(key)); +}); + +test('selecting another train hands the camera over with a fresh frame', async (t) => { + const app = harness(t); + const key = await bringToMotion(app, 'mbta-rail', 'T1', BOSTON); + app.parts().selection.selectAndFocusTrain(key); + const first = app.viewer.trackedEntity; + const parked = railTrainKey('mbta-rail', 'T1-parked'); + app.parts().selection.selectAndFocusTrain(parked); + assert.equal(app.viewer.trackedEntity, undefined, 'the follow let go'); + assert.equal(app.viewer.entities.contains(first), false); + assert.equal(app.focusRequests.at(-1).id, parked, 'and the new train flew'); +}); + +test('from orbit the selected train and its feed stay; the other trains go as before', async (t) => { + const app = harness(t); + const key = await bringToMotion(app, 'mbta-rail', 'T1', BOSTON); + const parked = railTrainKey('mbta-rail', 'T1-parked'); + app.parts().selection.selectAndFocusTrain(key); + + await app.moveCamera({ altitude: ORBITAL_M }); + assert.ok(ORBITAL_M > ACTIVATION_EXIT_ALTITUDE_M); + const state = app.state(); + assert.equal(state._heldFeedId, 'mbta-rail'); + assert.ok(state._activeFeeds.has('mbta-rail'), 'its feed is still polled'); + assert.ok(app.entry(key)?.marker?.show, 'the selected train is drawn'); + assert.equal(app.entry(parked), undefined, 'the others have gone'); + assert.ok(app.viewer.trackedEntity, 'and the follow holds'); + + // The feed keeps updating, for this train alone. + const before = app.requested.length; + const position = Cesium.Cartesian3.clone(app.entry(key).marker.position); + for (let i = 0; i < 3; i += 1) { + app.run(RAIL_POLL_MS); + await app.poll(); + } + assert.ok(app.requested.length > before); + assert.equal(app.entry(parked), undefined); + assert.ok( + Cesium.Cartesian3.distance(position, app.entry(key).marker.position) > 1, + 'it still moves', + ); + assert.equal(app.layer.getRouteDisplayStats().heldFeed, 'mbta-rail'); + + // Deselecting returns the feed to the viewport's rules: from orbit, gone. + app.parts().selection.clearSelection(); + await app.recheck(); + assert.equal(state._heldFeedId, null); + assert.equal(state._activeFeeds.has('mbta-rail'), false); + assert.equal(state._trains.size, 0); +}); + +test('a held feed the view wants again brings its other trains back at once', async (t) => { + const app = harness(t); + const key = await bringToMotion(app, 'mbta-rail', 'T1', BOSTON); + const parked = railTrainKey('mbta-rail', 'T1-parked'); + app.parts().selection.selectAndFocusTrain(key); + await app.moveCamera({ altitude: ORBITAL_M }); + assert.equal(app.entry(parked), undefined); + const before = app.requested.length; + await app.moveCamera({ altitude: 30_000 }); + assert.equal(app.state()._heldFeedId, null); + assert.ok(app.requested.length > before, 'polled at once'); + assert.ok(app.entry(parked), 'the others are back'); + assert.ok(app.entry(key), 'and the selection is kept'); + assert.equal(app.state()._selectedKey, key); +}); + +test('far side of the globe: the selected train is hidden behind the Earth', async (t) => { + const app = harness(t); + const key = await bringToMotion(app, 'mbta-rail', 'T1', BOSTON); + app.parts().selection.selectAndFocusTrain(key); + app.viewer.trackedEntity = undefined; // the camera is free to go round + await app.moveCamera({ altitude: ORBITAL_M }); + app.viewer.scene.mode = Cesium.SceneMode.SCENE3D; + const marker = app.entry(key).marker; + app.viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees( + BOSTON.lon, + BOSTON.lat, + ORBITAL_M, + ); + app.run(250); + assert.equal(marker.show, true, 'seen from above'); + app.viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees( + BOSTON.lon + 180, + -BOSTON.lat, + ORBITAL_M, + ); + app.run(250); + assert.equal(marker.show, false, 'hidden from the antipode'); +}); + +test('the follow glides to the copy another feed now draws, without letting go', async (t) => { + const app = harness(t, { at: MADRID }); + // One Alvia in two feeds: a station-event copy and, a poll later, a GPS + // copy that outranks it, so the duplicate filter moves the selection. + let step = 0; + let gpsCopy = false; + const near = (offset) => ({ + lat: MADRID.lat + offset + step * 0.003, + lon: MADRID.lon, + }); + app.feeds.set('renfe-cercanias', () => [ + train('A1', near(0).lat, near(0).lon, { + label: 'ALVIA 4073', + positionSource: 'event', + }), + ]); + app.feeds.set('renfe-larga-distancia', () => + gpsCopy + ? [ + train('B1', near(0.01).lat, near(0.01).lon, { + label: '4073', + positionSource: 'gps', + }), + ] + : [], + ); + await app.enable(); + const a = railTrainKey('renfe-cercanias', 'A1'); + const b = railTrainKey('renfe-larga-distancia', 'B1'); + for (let i = 0; i < 12 && !railTrainFollowable(app.entry(a)); i += 1) { + step += 1; + app.run(RAIL_POLL_MS); + await app.poll(); + } + assert.ok(railTrainFollowable(app.entry(a))); + app.parts().selection.selectAndFocusTrain(a); + const entity = app.viewer.trackedEntity; + assert.ok(entity); + const onA = app.trackedPosition(); + + gpsCopy = true; + step += 1; + app.run(RAIL_POLL_MS); + await app.poll(); + assert.equal(app.state()._selectedKey, b, 'the selection moved'); + assert.equal(app.viewer.trackedEntity, entity, 'the same follow'); + assert.equal(app.frames.length, 1, 'no new frame: no jump'); + assert.equal(app.focusRequests.length, 0); + assert.ok(app.parts().follow.isFollowing(b)); + // It starts where it stood, then glides onto the new copy. + const start = app.trackedPosition(); + assert.ok( + Cesium.Cartesian3.distance(start, onA) < 50, + 'no jump at the handover', + ); + app.run(FOLLOW_HANDOVER_MS + 250); + const settled = app.trackedPosition(); + assert.ok( + Cesium.Cartesian3.distance(settled, app.entry(b).marker.position) < 1e-6, + 'on the new copy', + ); +}); diff --git a/src/layers/rail/groundHeight.test.mjs b/src/layers/rail/groundHeight.test.mjs new file mode 100644 index 000000000..016e12e81 --- /dev/null +++ b/src/layers/rail/groundHeight.test.mjs @@ -0,0 +1,499 @@ +/** + * Where trains and the selected trip's stops stand while their floor cell is + * cold: a train on the floor it last stood on (or a neighbouring cell's), a + * stop on the ellipsoid only until its cell is warm, and the cell is warmed + * off the frame path. Seen on Google's photorealistic tiles, where a train + * running onto unsampled cells dropped about 150 m under its line near + * Chicago, then jumped back once a cell warmed. + */ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { + BOSTON, + railLayerHarness as harness, + railTrain as train, +} from '../../testSupport/railLayerHarness.mjs'; +import { + COLD_CELL_FLUSH_MS, + COLD_CELL_RETRY_MS, + COLD_CELLS_PER_FLUSH, + FLOOR_CELLS_BURST, + FLOOR_CELLS_PER_MINUTE, + FLOOR_WARM_MAX_DIST_KM, + STOP_REFRESH_MS, + TRACK_MAX_GRADE, + TRACK_PRIOR_BELOW_M, + TRACK_SAMPLE_MAX_AGE_MS, + TRACK_SAMPLE_MAX_CAMERA_M, + TRACK_SAMPLE_REFRESH_MS, + TRACK_SAMPLE_RETRY_MS, + railTrainKey, +} from './policy.js'; +import { RAIL_STOP_OVERLAY_SOURCE_ID } from './stopLabels.js'; + +const CHICAGO = { lat: 41.8789, lon: -87.6359 }; +const WEST = { lat: 41.8789, lon: -87.7 }; +/** A camera low enough over Chicago for the mesh to be sampled. */ +const LOW_M = 2_000; +const cellKey = ({ lat, lon }) => + `${Math.round(lat * 1000)},${Math.round(lon * 1000)}`; + +/** A floor service: warm cells by 0.001° key, and the warm requests made. */ +function floorService({ neighbour = null } = {}) { + const warm = new Map(); + const requested = []; + const near = { m: neighbour, coarseCalls: 0 }; + return { + warm, + requested, + near, + ground: { + GROUND_FLOOR_LIFT_M: 1.5, + cachedGroundFloor: (lat, lon) => warm.get(cellKey({ lat, lon })) ?? null, + warmGroundFloor(cells) { + requested.push(...cells.map(cellKey)); + }, + coarseFloorCoord(lat, lon) { + near.coarseCalls += 1; + return { + lat: Math.round(lat * 1000) / 1000, + lon: Math.round(lon * 1000) / 1000, + }; + }, + neighborFloorM: () => near.m, + }, + }; +} + +/** + * Photorealistic tiles in the scene: a shown tileset whose streaming state + * the test sets, and the mesh height every probe reads (recorded). + */ +function photoreal(app, { loaded = true, height = 158 } = {}) { + const tileset = { show: true, tilesLoaded: loaded }; + const mesh = { height, probes: [] }; + app.viewer.scene.primitives.add(tileset); + app.viewer.scene.sampleHeight = (carto) => { + mesh.probes.push({ + lat: (carto.latitude * 180) / Math.PI, + lon: (carto.longitude * 180) / Math.PI, + }); + return mesh.height; + }; + return { tileset, mesh }; +} + +test('a train on a cold cell keeps its last floor, and the cell is warmed', async (t) => { + const floors = floorService(); + floors.warm.set(cellKey(CHICAGO), 150); + const app = harness(t, { at: CHICAGO, ground: floors.ground }); + await app.enable(); + const { heightFor } = app.parts().rendering; + const train = {}; + assert.equal(heightFor(CHICAGO.lat, CHICAGO.lon, train), 151.5); + // West of the city, not sampled yet: still on the Chicago floor. + assert.equal(heightFor(WEST.lat, WEST.lon, train), 151.5); + assert.deepEqual(floors.requested, []); + t.mock.timers.tick(COLD_CELL_FLUSH_MS); + assert.deepEqual(floors.requested, [cellKey(WEST)]); + // Every frame reads it again; it is asked for once until the retry time. + heightFor(WEST.lat, WEST.lon, train); + t.mock.timers.tick(COLD_CELL_FLUSH_MS); + assert.equal(floors.requested.length, 1); + t.mock.timers.tick(COLD_CELL_RETRY_MS); + heightFor(WEST.lat, WEST.lon, train); + t.mock.timers.tick(COLD_CELL_FLUSH_MS); + assert.equal(floors.requested.length, 2); + // Once warm, its own floor. + floors.warm.set(cellKey(WEST), 160); + assert.equal(heightFor(WEST.lat, WEST.lon, train), 161.5); + // A point with no floor of its own to keep is on the ellipsoid. + assert.equal(heightFor(41.9, -87.8), 0); +}); + +test('a warm neighbouring cell comes before the last floor', async (t) => { + const floors = floorService({ neighbour: 170 }); + floors.warm.set(cellKey(CHICAGO), 150); + const app = harness(t, { at: CHICAGO, ground: floors.ground }); + await app.enable(); + const { heightFor } = app.parts().rendering; + const train = {}; + heightFor(CHICAGO.lat, CHICAGO.lon, train); + assert.equal(heightFor(WEST.lat, WEST.lon, train), 171.5); +}); + +test('a train gliding across a cold cell asks for its coarse cell once', async (t) => { + const floors = floorService({ neighbour: 170 }); + const app = harness(t, { at: CHICAGO, ground: floors.ground }); + await app.enable(); + const { heightFor } = app.parts().rendering; + const train = {}; + floors.near.coarseCalls = 0; + // Frames inside one 0.001° cell (centred on WEST), then the next cell. + for (let i = 0; i < 20; i += 1) + assert.equal(heightFor(WEST.lat, WEST.lon + i * 0.00002, train), 171.5); + assert.equal(floors.near.coarseCalls, 1); + heightFor(WEST.lat, WEST.lon + 0.001, train); + assert.equal(floors.near.coarseCalls, 2); +}); + +test('from far above, cold cells are not warmed', async (t) => { + const floors = floorService(); + const app = harness(t, { + at: CHICAGO, + altitude: 500_000, + ground: floors.ground, + }); + await app.enable(); + app.parts().rendering.heightFor(WEST.lat, WEST.lon, {}); + t.mock.timers.tick(COLD_CELL_FLUSH_MS); + assert.deepEqual(floors.requested, []); +}); + +test('a high floor next to the line is a bump, not a jump (grade limit)', async (t) => { + const floors = floorService(); + floors.warm.set(cellKey(CHICAGO), 150); + // The next cell's floor was read on a roof 30 m above the rails. + const NEXT = { lat: CHICAGO.lat, lon: CHICAGO.lon + 0.0013 }; + floors.warm.set(cellKey(NEXT), 180); + const app = harness(t, { at: CHICAGO, ground: floors.ground }); + await app.enable(); + const { displayHeightFor } = app.parts().rendering; + const train = { key: 'k1' }; + assert.equal(displayHeightFor(train, CHICAGO.lat, CHICAGO.lon), 151.5); + const drawn = train.drawnHeight; + const movedM = 0.0013 * 111_320 * Math.cos((CHICAGO.lat * Math.PI) / 180); + const next = displayHeightFor(train, NEXT.lat, NEXT.lon); + assert.ok(next > 151.5, 'climbs toward the higher floor'); + assert.ok( + next <= 151.5 + TRACK_MAX_GRADE * movedM + 0.01, + `no faster than the grade limit (${next})`, + ); + // The drawn height is kept in place, not rebuilt every frame. + assert.equal(train.drawnHeight, drawn); + assert.equal(drawn.h, next); +}); + +test('a real floor replacing the ellipsoid fallback is taken at once', async (t) => { + const floors = floorService(); + const app = harness(t, { at: CHICAGO, ground: floors.ground }); + await app.enable(); + const { displayHeightFor } = app.parts().rendering; + const train = { key: 'k2' }; + assert.equal(displayHeightFor(train, WEST.lat, WEST.lon), 0); + floors.warm.set(cellKey(WEST), 160); + assert.equal(displayHeightFor(train, WEST.lat, WEST.lon), 161.5); +}); + +test('a neighbouring floor replacing the ellipsoid is taken at once', async (t) => { + const floors = floorService(); + const app = harness(t, { at: CHICAGO, ground: floors.ground }); + await app.enable(); + const { displayHeightFor } = app.parts().rendering; + const train = { key: 'k2n' }; + assert.equal(displayHeightFor(train, WEST.lat, WEST.lon), 0); + // Its own cell is still cold, a neighbour's is warm: no 5% ramp up from + // the ellipsoid, 150 m under the mesh. + floors.near.m = 170; + const step = { lat: WEST.lat, lon: WEST.lon + 0.0002 }; + assert.equal(displayHeightFor(train, step.lat, step.lon), 171.5); + // Between two fallbacks, the grade limit still holds. + floors.near.m = 190; + const after = displayHeightFor(train, step.lat, step.lon + 0.0002); + assert.ok(after > 171.5 && after < 180, `ramps (${after})`); +}); + +test('on photorealistic tiles the mesh is sampled on the line itself', async (t) => { + const floors = floorService(); + floors.warm.set(cellKey(WEST), 150); + const app = harness(t, { + at: CHICAGO, + altitude: LOW_M, + ground: floors.ground, + }); + await app.enable(); + const { mesh } = photoreal(app); + const { displayHeightFor } = app.parts().rendering; + const train = { key: 'k3' }; + // The exact point on the track, not the floor cell's centre. + const ON_TRACK = { lat: WEST.lat + 0.00031, lon: WEST.lon - 0.00027 }; + assert.equal(displayHeightFor(train, ON_TRACK.lat, ON_TRACK.lon), 151.5); + t.mock.timers.tick(COLD_CELL_FLUSH_MS); + assert.equal(mesh.probes.length, 1); + assert.ok(Math.abs(mesh.probes[0].lat - ON_TRACK.lat) < 1e-9); + assert.ok(Math.abs(mesh.probes[0].lon - ON_TRACK.lon) < 1e-9); + // The sample is the target now; the drawn height settles toward it. + t.mock.timers.tick(20_000); + assert.equal(displayHeightFor(train, ON_TRACK.lat, ON_TRACK.lon), 159.5); + // Not asked again for the same bucket until the refresh time. + t.mock.timers.tick(COLD_CELL_FLUSH_MS); + assert.equal(mesh.probes.length, 1); +}); + +test('a sample far from the floor under it is a coarse tile and is dropped', async (t) => { + const floors = floorService(); + floors.warm.set(cellKey(WEST), 150); + const app = harness(t, { + at: CHICAGO, + altitude: LOW_M, + ground: floors.ground, + }); + await app.enable(); + const { mesh } = photoreal(app, { height: 400 }); + const { displayHeightFor } = app.parts().rendering; + const train = { key: 'k4' }; + displayHeightFor(train, WEST.lat, WEST.lon); + t.mock.timers.tick(COLD_CELL_FLUSH_MS + TRACK_SAMPLE_RETRY_MS); + assert.equal(mesh.probes.length, 1); + assert.equal(displayHeightFor(train, WEST.lat, WEST.lon), 151.5); + // A valley floor read under a viaduct, well below the floor: dropped too. + // (The next track bucket along, in the same floor cell.) + const viaduct = { key: 'k4v' }; + const NEAR = { lat: WEST.lat, lon: WEST.lon - 0.0004 }; + mesh.height = 150 - TRACK_PRIOR_BELOW_M - 5; + displayHeightFor(viaduct, NEAR.lat, NEAR.lon); + t.mock.timers.tick(COLD_CELL_FLUSH_MS); + assert.ok(mesh.probes.some((probe) => Math.abs(probe.lon - NEAR.lon) < 1e-9)); + t.mock.timers.tick(60_000); + assert.equal(displayHeightFor(viaduct, NEAR.lat, NEAR.lon), 151.5); +}); + +test('from far above, the line is not sampled', async (t) => { + const floors = floorService(); + floors.warm.set(cellKey(WEST), 150); + const app = harness(t, { + at: CHICAGO, + altitude: 500_000, + ground: floors.ground, + }); + await app.enable(); + const { mesh } = photoreal(app, { height: 151 }); + app.parts().rendering.displayHeightFor({ key: 'k5' }, WEST.lat, WEST.lon); + t.mock.timers.tick(COLD_CELL_FLUSH_MS); + assert.equal(mesh.probes.length, 0); +}); + +test('the line is sampled only from a low camera, once the tiles are loaded', async (t) => { + const floors = floorService(); + floors.warm.set(cellKey(WEST), 150); + // 20 km up the tiles near the camera are coarse, loaded or not. + const app = harness(t, { + at: CHICAGO, + altitude: 20_000, + ground: floors.ground, + }); + await app.enable(); + const { tileset, mesh } = photoreal(app); + const { displayHeightFor } = app.parts().rendering; + const train = { key: 'k6' }; + displayHeightFor(train, WEST.lat, WEST.lon); + t.mock.timers.tick(COLD_CELL_FLUSH_MS); + assert.equal(mesh.probes.length, 0, 'not from 20 km'); + // Low enough, but the tiles are still streaming. + await app.moveCamera({ altitude: 150 + TRACK_SAMPLE_MAX_CAMERA_M - 100 }); + tileset.tilesLoaded = false; + t.mock.timers.tick(TRACK_SAMPLE_RETRY_MS); + displayHeightFor(train, WEST.lat, WEST.lon); + t.mock.timers.tick(COLD_CELL_FLUSH_MS); + assert.equal(mesh.probes.length, 0, 'not while streaming'); + tileset.tilesLoaded = true; + t.mock.timers.tick(TRACK_SAMPLE_RETRY_MS); + displayHeightFor(train, WEST.lat, WEST.lon); + t.mock.timers.tick(COLD_CELL_FLUSH_MS); + assert.equal(mesh.probes.length, 1); +}); + +test('a sample read from lower down replaces one from higher up; an old one goes', async (t) => { + const floors = floorService(); + floors.warm.set(cellKey(WEST), 150); + const app = harness(t, { + at: CHICAGO, + altitude: 4_000, + ground: floors.ground, + }); + await app.enable(); + const { mesh } = photoreal(app, { height: 140 }); + const { displayHeightFor } = app.parts().rendering; + const train = { key: 'k7' }; + /** Read the bucket again, and let the drawn height settle on its target. */ + const settled = () => { + displayHeightFor(train, WEST.lat, WEST.lon); + t.mock.timers.tick(COLD_CELL_FLUSH_MS); + t.mock.timers.tick(60_000); + return displayHeightFor(train, WEST.lat, WEST.lon); + }; + // From 4 km, a coarse tile 10 m low: inside the bounds, so kept for now. + assert.equal(settled(), 141.5); + // From 1 km the tiles are finer: the new reading replaces it. + await app.moveCamera({ altitude: 1_000 }); + mesh.height = 158; + t.mock.timers.tick(TRACK_SAMPLE_REFRESH_MS); + assert.equal(settled(), 159.5); + // Back up at 4.5 km, the coarse reading does not win over it... + await app.moveCamera({ altitude: 4_500 }); + mesh.height = 140; + const reads = mesh.probes.length; + t.mock.timers.tick(TRACK_SAMPLE_REFRESH_MS); + assert.equal(settled(), 159.5); + assert.equal(mesh.probes.length, reads + 1, 'it was read again'); + // ...until the one from lower down is old. + t.mock.timers.tick(TRACK_SAMPLE_MAX_AGE_MS); + assert.equal(settled(), 141.5); +}); + +test('disabling the layer drops the cells and samples still queued', async (t) => { + const floors = floorService(); + floors.warm.set(cellKey(CHICAGO), 150); + const app = harness(t, { + at: CHICAGO, + altitude: LOW_M, + ground: floors.ground, + }); + await app.enable(); + const { mesh } = photoreal(app); + const { heightFor, displayHeightFor } = app.parts().rendering; + heightFor(WEST.lat, WEST.lon, {}); + displayHeightFor({ key: 'k8' }, CHICAGO.lat, CHICAGO.lon); + app.layer.disable(app.viewer); + t.mock.timers.tick(COLD_CELL_FLUSH_MS * 3); + assert.deepEqual(floors.requested, []); + assert.equal(mesh.probes.length, 0); + // Enabled again, the cell is asked for at once, not after a retry time. + await app.enable(); + heightFor(WEST.lat, WEST.lon, {}); + t.mock.timers.tick(COLD_CELL_FLUSH_MS); + assert.deepEqual(floors.requested, [cellKey(WEST)]); +}); + +test('trains far from the camera do not warm floor cells', async (t) => { + const floors = floorService(); + const app = harness(t, { at: CHICAGO, ground: floors.ground }); + await app.enable(); + const { heightFor } = app.parts().rendering; + // Well past FLOOR_WARM_MAX_DIST_KM from the camera over Chicago. + const FAR = { + lat: CHICAGO.lat, + lon: CHICAGO.lon + (FLOOR_WARM_MAX_DIST_KM + 20) / 83, + }; + heightFor(FAR.lat, FAR.lon, {}); + t.mock.timers.tick(COLD_CELL_FLUSH_MS); + assert.deepEqual(floors.requested, []); +}); + +/** + * Cold cells for hundreds of trains near the camera, `rounds` flushes of + * them; the cells the floor service was asked for. + */ +function crowd(t, app, floors, rounds) { + const { heightFor } = app.parts().rendering; + let asked = 0; + for (let round = 0; round < rounds; round += 1) { + for (let i = 0; i < 100; i += 1) { + const n = round * 100 + i; + heightFor( + CHICAGO.lat + (n % 60) * 0.002, + CHICAGO.lon + Math.floor(n / 60) * 0.002, + {}, + ); + } + const before = floors.requested.length; + t.mock.timers.tick(COLD_CELL_FLUSH_MS); + const flushed = floors.requested.length - before; + assert.ok(flushed <= COLD_CELLS_PER_FLUSH, `one flush ${flushed}`); + asked += flushed; + } + return asked; +} + +test('floor warming stays within the shared terrain budget', async (t) => { + const floors = floorService(); + const app = harness(t, { at: CHICAGO, ground: floors.ground }); + await app.enable(); + // The budget starts with its burst and refills at FLOOR_CELLS_PER_MINUTE: + // 40 flushes (40 s) may spend at most the burst plus 40 s of refill. + const asked = crowd(t, app, floors, 40); + const secondsElapsed = (40 * COLD_CELL_FLUSH_MS) / 1000; + assert.ok( + asked <= + FLOOR_CELLS_BURST + (FLOOR_CELLS_PER_MINUTE * secondsElapsed) / 60 + 1, + `in ${secondsElapsed} s ${asked}`, + ); + assert.ok(asked > 0); +}); + +test('a minute after an idle spell spends no more than a minute of budget plus the burst', async (t) => { + const floors = floorService(); + const app = harness(t, { at: CHICAGO, ground: floors.ground }); + await app.enable(); + // Idle long enough for any bucket to fill to the brim. + t.mock.timers.tick(10 * 60_000); + const asked = crowd(t, app, floors, 60); + assert.ok( + asked <= FLOOR_CELLS_PER_MINUTE + FLOOR_CELLS_BURST, + `in a minute ${asked}`, + ); + // About 24 of the 90 terrain requests a minute, not 38. + assert.ok(Math.ceil(asked / 64) <= 24, `${Math.ceil(asked / 64)} requests`); + // And the refill is spent: the crowd is not starved. + assert.ok(asked >= FLOOR_CELLS_PER_MINUTE, `in a minute ${asked}`); +}); + +/** A trip through Boston, its terminus 120 km away (Portland, Maine). */ +const STOPS = [ + { stopId: 'A', name: 'South Station', lat: 42.3523, lon: -71.0552 }, + { stopId: 'B', name: 'North Station', lat: 42.3656, lon: -71.0613 }, + { stopId: 'C', name: 'Woburn', lat: 42.5168, lon: -71.1442 }, + { stopId: 'D', name: 'Portland', lat: 43.6532, lon: -70.3122 }, +].map((stop, i) => ({ + ...stop, + state: i === 0 ? 'passed' : i === 1 ? 'next' : 'upcoming', + relative: i === 0 ? 'T+3' : `T−${i * 10}`, + expectedArrival: '14:32', +})); + +const heightOf = (position) => + Cesium.Cartographic.fromCartesian(position).height; + +test('stops placed before their cells were warm move up onto the floor', async (t) => { + const floors = floorService(); + const app = harness(t, { + at: BOSTON, + altitude: LOW_M, + ground: floors.ground, + stops: { stopsFor: () => ({ stops: STOPS }) }, + }); + app.feeds.set('mbta-rail', () => [train('T1', BOSTON.lat, BOSTON.lon)]); + await app.enable(); + const key = railTrainKey('mbta-rail', 'T1'); + app.parts().selection.selectTrain(key); + const dots = app.primitives.find( + (primitive) => primitive instanceof Cesium.PointPrimitiveCollection, + ); + const dotHeights = () => + Array.from({ length: dots.length }, (_, i) => + heightOf(dots.get(i).position), + ); + const labelHeights = () => + app.overlayEntries + .get(RAIL_STOP_OVERLAY_SOURCE_ID) + .map((entry) => heightOf(entry.position)); + assert.equal(dots.length, STOPS.length); + // Every stop's cell is cold: on the ellipsoid for now. + for (const h of [...dotHeights(), ...labelHeights()]) + assert.ok(Math.abs(h) < 0.01, `on the ellipsoid (${h})`); + // Their cells are warmed, the terminus too though it is 120 km off. + t.mock.timers.tick(COLD_CELL_FLUSH_MS); + for (const stop of STOPS) + assert.ok(floors.requested.includes(cellKey(stop)), stop.name); + // Once warm, the next refresh stands them on the floor. + for (const stop of STOPS) floors.warm.set(cellKey(stop), 20); + t.mock.timers.tick(STOP_REFRESH_MS); + assert.equal(dots.length, STOPS.length); + for (const h of [...dotHeights(), ...labelHeights()]) + assert.ok(Math.abs(h - 21.5) < 0.01, `on the floor (${h})`); + // Placed on a real floor, a stop is not read again. + floors.warm.clear(); + t.mock.timers.tick(STOP_REFRESH_MS * 3); + for (const h of dotHeights()) assert.ok(Math.abs(h - 21.5) < 0.01); +}); diff --git a/src/layers/rail/index.js b/src/layers/rail/index.js new file mode 100644 index 000000000..0a191c62a --- /dev/null +++ b/src/layers/rail/index.js @@ -0,0 +1,196 @@ +import { createHistoryBudget } from '../../data/contactPlayback.js'; +import { createRailSource } from './source.js'; +import { createRendering } from './rendering.js'; +import { createSelection } from './selection.js'; +import { createFollow } from './follow.js'; +import { createIngestion } from './ingestion.js'; +import { createViewport } from './viewport.js'; +import { createControls } from './controls.js'; +import { createLifecycle } from './lifecycle.js'; +import { createRouteDisplay } from './routeDisplay.js'; +import { createRailSchedules } from './schedules.js'; +import { getRegisteredRailFeed } from '../../data/railFeeds.js'; +import { DEFAULT_RAIL_CATEGORY_VISIBILITY, RAIL_POLL_MS } from './policy.js'; + +/** One Rail layer's mutable scene state; nothing is module-global. */ +function createState({ services }) { + const { setOverlayEntries, setOverlaySourceVisible, clearOverlaySource } = + services.overlays; + return { + _overlayHost: Object.freeze({ + setEntries: setOverlayEntries, + setVisible: setOverlaySourceVisible, + clearSource: clearOverlaySource, + }), + _historyBudget: createHistoryBudget(), + _viewer: null, + _markers: null, + _enabled: false, + _generation: 0, + _cameraChangedAttached: false, + _cameraDebounceTimer: null, + _altitudeGateOpen: false, + _preRenderRemove: null, + _renderHeld: false, + _clickHandler: null, + /** Trains mid-glide; the frame pass walks only these. @type {Set} */ + _moving: new Set(), + /** feedId → registry entry currently polled. */ + _activeFeeds: new Map(), + _feedStatus: new Map(), + _inFlight: new Map(), + /** train key → runtime entry. */ + _trains: new Map(), + /** + * feedId → path pack slot `{status, pack, retryAt, failures}`. Kept + * across disable/enable, and released with the feed's network and + * schedule pack once the feed is out of use FEED_RELEASE_AFTER_MS + * (routeDisplay.js releaseIdleFeeds). + */ + _pathPacks: new Map(), + /** + * The server's feed catalog (GET /api/rail/feeds), which lists a feed + * that reads its routes from a schedule pack only once that pack is + * built; null until it loads. Until then only the bundled feeds that + * need no pack are used, and a catalog that fails is asked for again + * (viewport.js). + */ + _catalog: null, + /** The catalog request in flight: `{controller}`. */ + _catalogRequest: null, + /** Failed catalog requests in a row, and the timer of the next try. */ + _catalogFailures: 0, + _catalogRetryTimer: null, + /** feedId → faint route network built from that feed's path pack. */ + _networks: new Map(), + /** Network opacity from camera altitude, 0..1. */ + _networkFade: 0, + /** The selected train's line, split where the train is. */ + _route: null, + /** The selected trip's stops and headsign from the stops provider. */ + _trip: null, + _stopsSignature: '', + _stopLabelsSignature: '', + /** + * Stop → `{lat, lon, h, q, position}` for the selected trip, kept per + * selection; one on a fallback height (q 0-1) is read again each refresh. + */ + _stopAnchors: new Map(), + _categories: { ...DEFAULT_RAIL_CATEGORY_VISIBILITY }, + _selectedKey: null, + _selectedCardAt: 0, + _selectedCardText: null, + /** The camera's follow of the selected train (follow.js), or null. */ + _follow: null, + /** A feed polled only for the selected train (viewport.js), or null. */ + _heldFeedId: null, + _revision: 0, + _controlsListener: null, + _dataManager: null, + _lastUpdate: null, + _error: null, + /** Whether the train cap (MAX_TRAINS_TOTAL) was reported once. */ + _capWarned: false, + }; +} + +/** + * Construct one Rail layer with its own scene state and supplied services. + * + * Trains come from the `/api/rail` proxy (src/sources/railService.js), already + * filtered to trains and tagged with a category. Every train drawn is where + * its operator last reported it (GPS or station reports); none is placed or + * moved from a timetable. Motion between reports is the Transit layer's + * delayed playback, so a train glides at 1x behind real time along its line + * instead of jumping between reports. + * + * Each layer owns a schedule tier (schedules.js): feeds' optional schedule + * packs and trip updates, read through the same source for the selected + * train's stops, headsign and delays. It is the default stops provider: + * + * stops.stopsFor({feedId, tripId, routeId, record}, nowMs) + * → {stops: RailTripStop[], headsign?: string} | null + * stops.onSelect?(train | null) + * + * `stopsFor` is called about once a second while a train is selected, + * synchronously; it returns null while it has nothing (yet) and loads what it + * needs on its own. RailTripStop is described in stopLabels.js. Pass + * `stops: null` to draw the selected line without stops. + * + * A selected train takes the camera through the optional `services.camera` + * (follow.js): a moving train is followed, any other flown to once. + * + * @param {{services: object, source?: object, + * stops?: {stopsFor: Function, onSelect?: Function}|null}} options + * @returns {object} The data-layer module the manager registers. + */ +export function createRailLayer({ + services, + source = createRailSource(), + stops, +}) { + if ( + !services?.overlays || + !services?.render || + !services?.sprites || + !services?.ground + ) { + throw new TypeError( + 'A rail layer needs overlay, render, sprite and ground services', + ); + } + if (typeof source?.requestSnapshot !== 'function') + throw new TypeError('A rail source needs a snapshot operation'); + if ( + stops !== undefined && + stops !== null && + typeof stops?.stopsFor !== 'function' + ) + throw new TypeError('A rail stops provider needs a stopsFor operation'); + const state = createState({ services }); + const parts = {}; + const timetable = createRailSchedules({ + source, + // The server's catalog says which feeds it can read trip updates for. + hasTripUpdates: (feedId) => { + const listed = state._catalog?.find((feed) => feed.id === feedId); + if (listed) return listed.tripUpdates === true; + return typeof getRegisteredRailFeed(feedId)?.tripUpdatesUrl === 'string'; + }, + pathPackFor: (feedId) => state._pathPacks.get(feedId)?.pack || null, + }); + const context = { + state, + services, + parts, + source, + timetable, + stops: stops === undefined ? timetable : stops, + }; + parts.rendering = createRendering(context); + parts.routes = createRouteDisplay(context); + parts.selection = createSelection(context); + parts.follow = createFollow(context); + parts.controls = createControls(context); + parts.ingestion = createIngestion(context); + parts.viewport = createViewport(context); + parts.lifecycle = createLifecycle(context); + + return Object.assign( + { updateInterval: RAIL_POLL_MS }, + parts.lifecycle.methods, + parts.controls.methods, + { + /** Test seams: this instance's own state and parts. */ + _railStateForTest: () => state, + _railPartsForTest: () => parts, + }, + ); +} + +export { + RAIL_CATEGORY_COLORS, + RAIL_POLL_MS, + buildRailSelectionCopy, + railTrainKey, +} from './policy.js'; diff --git a/src/layers/rail/ingestion.js b/src/layers/rail/ingestion.js new file mode 100644 index 000000000..54d885e37 --- /dev/null +++ b/src/layers/rail/ingestion.js @@ -0,0 +1,527 @@ +import { + FIX_FLAGS, + createLagReservoir, + destroyTrack, + readFix, +} from '../../data/contactPlayback.js'; +import { createRailPathPack } from '../../data/railPaths.js'; +import { + initializePlayback, + recordFix, + syncPlayback, + updatePlayback, +} from '../transit/movement.js'; +import { + FEED_EVICT_AFTER_MS, + FEED_STALE_AFTER_MS, + FUTURE_STAMP_TOLERANCE_MS, + MAX_TRAINS_TOTAL, + MISSED_POLLS_TO_DROP, + isLiveRailPositionSource, + isStaleTrainFix, + playbackModeFor, + railTrainKey, + trainReportTimeMs, +} from './policy.js'; +import { bindTrainPath, followFix, rebindTrainPath } from './pathBinding.js'; +import { scheduleRetryDelayMs } from './schedules.js'; +import { createDuplicateFilter } from './dedupe.js'; + +/** + * Fetching rail feeds through the proxy and turning snapshots into trains. + * The aging rules are the Transit layer's: a train is drawn from its own + * report time, a feed is judged by when its operator last answered, and both + * a quiet train and a quiet feed end in removal rather than a frozen marker. + * + * Only live positions are drawn (isLiveRailPositionSource). A feed whose + * positions fail is shown as unavailable: its trains are never placed from + * its timetable, and those already drawn age out as any quiet train does. + * @param {object} context + * @returns {object} + */ +export function createIngestion({ state, services, parts, source }) { + const { governorRequestRender } = services.render; + const { registerDynamicCredit, railFeedCredit } = services.credits; + // One physical train in several loaded feeds is drawn once (dedupe.js). + const duplicates = createDuplicateFilter({ state, parts }); + + function feedStatus(feedId) { + let status = state._feedStatus.get(feedId); + if (!status) { + status = { + count: 0, + lastUpdate: null, + upstreamAt: null, + error: null, + retryInSec: null, + stale: false, + pollSeq: 0, + loading: false, + lag: null, + /** New trains its last poll listed that MAX_TRAINS_TOTAL left out. */ + capped: 0, + }; + state._feedStatus.set(feedId, status); + } + return status; + } + + function removeTrain(key) { + const entry = state._trains.get(key); + if (!entry) return; + if (state._selectedKey === key) parts.selection.clearSelection(); + if (entry.marker) state._markers?.remove(entry.marker); + entry.marker = null; + state._moving.delete(entry); + parts.rendering.cancelWake(entry); + destroyTrack(entry.track); + state._trains.delete(key); + state._revision += 1; + // A copy it hid may now be the one to draw. + duplicates.scheduleRefresh(); + } + + /** + * Remove a feed's trains, except `keepKey` when given (the selected train, + * whose feed is held for it alone: viewport.js). + * @param {string} feedId + * @param {string|null} [keepKey] + */ + function removeFeedTrains(feedId, keepKey = null) { + for (const [key, entry] of state._trains) { + if (entry.feedId === feedId && key !== keepKey) removeTrain(key); + } + } + + /** + * Drop trains whose report aged out and feeds whose operator went quiet. + * @param {number} nowMs + * @returns {number} Trains removed. + */ + function sweepAgedTrains(nowMs) { + let removed = 0; + const deadFeeds = new Set(); + for (const [feedId, status] of state._feedStatus) { + if ( + Number.isFinite(status.upstreamAt) && + nowMs - status.upstreamAt > FEED_EVICT_AFTER_MS + ) + deadFeeds.add(feedId); + } + for (const [key, entry] of state._trains) { + if ( + deadFeeds.has(entry.feedId) || + isStaleTrainFix(entry.record, nowMs, entry.fetchedAt) + ) { + removeTrain(key); + removed += 1; + } + } + for (const feedId of deadFeeds) { + const status = state._feedStatus.get(feedId); + if (status) { + status.count = 0; + status.stale = true; + } + } + if (removed > 0) { + parts.rendering.syncRenderHold(); + governorRequestRender('rail-evict'); + state._dataManager?.refreshLayerStats?.(); + } + return removed; + } + + /** + * Fetch a feed's path pack once. A 404 means the feed has no paths and its + * trains keep straight-line motion; any other failure (a 503 from a pack + * the server cannot read now, say) is retried on the schedule packs' + * stepped delay, 30 s doubling to ten minutes (scheduleRetryDelayMs). + * @param {object} feed Registry entry. + */ + function ensurePaths(feed) { + const known = state._pathPacks.get(feed.id); + if (known && !(known.status === 'none' && Date.now() >= known.retryAt)) + return; + if (typeof source.requestPaths !== 'function') { + state._pathPacks.set(feed.id, { + status: 'none', + pack: null, + retryAt: Infinity, + }); + return; + } + const slot = { + status: 'loading', + pack: null, + retryAt: Infinity, + /** Failed loads in a row. */ + failures: known?.failures ?? 0, + }; + state._pathPacks.set(feed.id, slot); + void (async () => { + try { + const response = await source.requestPaths(feed.id); + if (!response.ok) { + const error = new Error(`rail paths HTTP ${response.status}`); + error.status = response.status; + throw error; + } + const raw = await response.json(); + const pack = createRailPathPack(raw); + if (pack.feedId !== feed.id) + throw new Error('rail path pack is for another feed'); + if (state._pathPacks.get(feed.id) !== slot) return; + slot.pack = pack; + slot.status = 'ready'; + bindFeedTrains(feed.id); + // The faint network is drawn from the same pack, built once here. + parts.routes.buildNetwork(feed.id, raw); + } catch (error) { + slot.status = 'none'; + slot.failures += 1; + slot.retryAt = + error?.status === 404 + ? Infinity + : Date.now() + scheduleRetryDelayMs(slot.failures); + if (error?.status !== 404) + console.warn( + `[Data:Rail] ${feed.id} paths unavailable:`, + error?.message || error, + ); + } + })(); + } + + /** + * Keep a train on its railway line: bind it when its trip or route changes, + * follow each new report, and move it to another of its route's shapes (or + * back to straight lines) when a report lands off the bound one. + */ + function followPath(entry, record, fix) { + const pack = state._pathPacks.get(entry.feedId)?.pack; + if (!pack) return; + const ids = { tripId: record.tripId || '', routeId: record.routeId || '' }; + const key = `${ids.tripId}\n${ids.routeId}`; + if (entry.pathKey !== key) { + entry.pathKey = key; + entry.path = bindTrainPath(pack, ids, fix); + return; + } + if (!entry.path || followFix(entry.path, fix)) return; + entry.path = rebindTrainPath(pack, ids, entry.path, fix); + } + + /** Bind the trains a feed already shows once its pack arrives. */ + function bindFeedTrains(feedId) { + const newest = {}; + for (const entry of state._trains.values()) { + if (entry.feedId !== feedId || entry.pathKey !== undefined) continue; + if (!entry.track?.count) continue; + readFix(entry.track, entry.track.count - 1, newest); + followPath(entry, entry.record, newest); + parts.rendering.schedulePlayback(entry); + } + governorRequestRender('rail-paths'); + } + + /** A playback fix for one record, with the time it was really reported. */ + function fixFor(record, fetchedAt) { + const stamped = trainReportTimeMs(record, fetchedAt); + const reportAt = + stamped > fetchedAt + FUTURE_STAMP_TOLERANCE_MS ? fetchedAt : stamped; + return { + t: reportAt, + lat: record.lat, + lon: record.lon, + bearingDeg: Number.isFinite(record.bearing) ? record.bearing : NaN, + flags: + stamped !== reportAt + ? FIX_FLAGS.RECEIPT_TIME + : record.timestampSource === 'vehicle' + ? FIX_FLAGS.VEHICLE_TIME + : record.timestampSource === 'header' + ? FIX_FLAGS.FEED_TIME + : FIX_FLAGS.RECEIPT_TIME, + }; + } + + /** Record a new report for a train already drawn. */ + function advanceTrain(entry, record, fix, observation) { + const heard = recordFix(entry, fix, observation); + if (!heard.accepted) return heard; + followPath(entry, record, fix); + if (entry.category !== record.category || entry.railMode !== record.mode) { + entry.category = record.category; + entry.railMode = record.mode; + entry.mode = playbackModeFor(record.mode); + parts.rendering.paint(entry); + } + updatePlayback(entry, observation.wallNowMs, observation.monoNowMs); + parts.rendering.schedulePlayback(entry); + return heard; + } + + /** Create and draw a train the layer has not seen before. */ + function addTrain(key, feed, record, fix, observation, status) { + const entry = { + key, + feedId: feed.id, + marker: null, + category: record.category, + railMode: record.mode, + mode: playbackModeFor(record.mode), + // A route the classifier named is a fact about the train, so its + // speed ceiling applies; the movement module reads this flag. + modeInferred: false, + courseDeg: Number.isFinite(record.bearing) ? record.bearing : null, + courseAt: observation.wallNowMs, + courseEvalAt: observation.monoNowMs, + record, + fetchedAt: observation.fetchedAt, + pollSeq: status.pollSeq, + /** Binding to a railway line (pathBinding.js), null for straight lines. */ + path: null, + /** Trip and route the binding was made for; undefined until tried. */ + pathKey: undefined, + }; + initializePlayback(entry, state._historyBudget, status.lag); + const first = recordFix(entry, fix, observation); + if (!first.accepted) { + destroyTrack(entry.track); + return null; + } + followPath(entry, record, fix); + syncPlayback(entry, observation.wallNowMs, observation.monoNowMs); + entry.marker = parts.rendering.addMarker(entry); + state._trains.set(key, entry); + parts.rendering.schedulePlayback(entry); + return entry; + } + + function applySnapshot(feed, snapshot, { stale, contactedAt = null }) { + const now = Date.now(); + const fetchedAt = Math.min( + Number.isFinite(snapshot?.fetchedAt) ? snapshot.fetchedAt : now, + now, + ); + const contacted = Math.min( + Number.isFinite(contactedAt) ? contactedAt : fetchedAt, + now, + ); + const status = feedStatus(feed.id); + status.upstreamAt = Math.max(contacted, fetchedAt); + const upstreamAgeMs = now - status.upstreamAt; + status.loading = false; + status.error = null; + status.retryInSec = null; + status.stale = stale === true || upstreamAgeMs > FEED_STALE_AFTER_MS; + if (upstreamAgeMs > FEED_EVICT_AFTER_MS) { + removeFeedTrains(feed.id); + status.count = 0; + parts.rendering.syncRenderHold(); + state._dataManager?.refreshLayerStats?.(); + return; + } + status.pollSeq += 1; + if (!status.lag) status.lag = createLagReservoir(); + const replayed = status.snapshotAt === fetchedAt || stale === true; + status.snapshotAt = fetchedAt; + const monoNow = performance.now(); + // A feed held only for the selected train (viewport.js) carries it alone, + // or a train its operator now lists on the same trip. + const only = + state._heldFeedId === feed.id + ? state._trains.get(state._selectedKey) + : null; + let seen = 0; + // The cap counts the trains drawn: a copy another feed draws better + // (dedupe.js) is hidden and does not count. + let held = 0; + for (const entry of state._trains.values()) + if (entry.duplicate !== true) held += 1; + let capped = 0; + for (const record of snapshot.trains || []) { + // Only a reported position is drawn, never one from a timetable. + if (!isLiveRailPositionSource(record?.positionSource)) continue; + if (isStaleTrainFix(record, now, fetchedAt)) continue; + const key = railTrainKey(feed.id, record.id); + if ( + only && + key !== only.key && + !(only.record?.tripId && record.tripId === only.record.tripId) + ) + continue; + const fix = fixFor(record, fetchedAt); + const observation = { + receivedAt: now, + monoNowMs: monoNow, + wallNowMs: now, + fetchedAt, + replayed, + modeInferred: false, + context: { + trip: record.tripId || '', + route: record.routeId || '', + mode: playbackModeFor(record.mode), + }, + }; + let entry = state._trains.get(key); + if (entry) { + const heard = advanceTrain(entry, record, fix, observation); + entry.pollSeq = status.pollSeq; + seen += 1; + if (!heard.accepted) continue; + } else { + if (held >= MAX_TRAINS_TOTAL) { + capped += 1; + continue; + } + entry = addTrain(key, feed, record, fix, observation, status); + if (!entry) continue; + held += 1; + seen += 1; + } + entry.record = record; + entry.fetchedAt = fetchedAt; + } + // Said in the layer's stats as a partial snapshot (controls.js). + status.capped = capped; + if (capped > 0 && !state._capWarned) { + state._capWarned = true; + console.warn( + `[Data:Rail] train cap ${MAX_TRAINS_TOTAL} reached; ${capped} trains of ${feed.id} are not drawn`, + ); + } + for (const [key, entry] of state._trains) { + if ( + entry.feedId === feed.id && + status.pollSeq - entry.pollSeq >= MISSED_POLLS_TO_DROP + ) + removeTrain(key); + } + sweepAgedTrains(now); + duplicates.scheduleRefresh(); + status.count = seen; + status.lastUpdate = fetchedAt; + state._lastUpdate = Math.max(state._lastUpdate || 0, fetchedAt); + state._error = null; + if (state._viewer) + registerDynamicCredit(state._viewer, railFeedCredit(feed)); + if (state._trains.get(state._selectedKey)?.feedId === feed.id) + parts.selection.refreshSelectedCard(true); + parts.rendering.warmFloors(feed.id); + state._revision += 1; + parts.rendering.syncRenderHold(); + governorRequestRender('rail-poll'); + state._dataManager?.refreshLayerStats?.(); + parts.controls.notify(); + } + + /** + * A feed whose live positions failed (or that its server has none for): + * its row says it is unavailable. Nothing takes the place of its trains; + * those already drawn go as their reports age (sweepAgedTrains). + * @param {object} feed + * @param {object} status + * @param {{retryInSec?: number}|null} [error] + */ + function markUnavailable(feed, status, error = null) { + // i18n-ignore: English data, translated on display (ui/statusText.js) + status.error = `${feed.name} feed unavailable`; + status.retryInSec = Number.isFinite(error?.retryInSec) + ? error.retryInSec + : null; + status.loading = false; + state._error = status.error; + sweepAgedTrains(Date.now()); + state._dataManager?.refreshLayerStats?.(); + } + + /** + * Poll one feed. A request already in flight is reused rather than aborted, + * so enable() and the manager's first update() share one request. + * @param {object} feed Registry entry. + * @param {number} generation Enable generation the poll belongs to. + * @returns {Promise} + */ + function pollFeed(feed, generation) { + if (!state._enabled || generation !== state._generation) + return Promise.resolve(); + ensurePaths(feed); + const status = feedStatus(feed.id); + const existing = state._inFlight.get(feed.id); + if (existing) return existing.promise; + const controller = new AbortController(); + status.loading = status.count === 0; + const promise = (async () => { + try { + const response = await source.requestSnapshot(feed.id, { + signal: controller.signal, + }); + if (!response.ok) { + const detail = await response.json().catch(() => null); + const error = new Error(`rail proxy HTTP ${response.status}`); + error.status = response.status; + if (Number.isFinite(detail?.retryInSec)) + error.retryInSec = detail.retryInSec; + throw error; + } + const snapshot = await response.json(); + if ( + !state._enabled || + generation !== state._generation || + !state._activeFeeds.has(feed.id) + ) + return; + // An answer whose positions are not live (anything but GPS or + // station reports) has no live data for this feed: none of its + // trains are drawn, and the feed shows unavailable. + if (!isLiveRailPositionSource(snapshot?.positions)) { + console.warn( + `[Data:Rail] ${feed.id} has no live positions on this server`, + ); + markUnavailable(feed, status); + return; + } + const contact = Number.parseInt( + response.headers.get('x-rail-contact') || '', + 10, + ); + applySnapshot(feed, snapshot, { + stale: response.headers.get('x-gev-cache') === 'STALE-ERROR', + contactedAt: Number.isFinite(contact) ? contact : null, + }); + } catch (error) { + if (error?.name === 'AbortError') return; + if (generation !== state._generation) return; + console.warn( + `[Data:Rail] ${feed.id} poll failed:`, + error?.message || error, + ); + markUnavailable(feed, status, error); + } finally { + if (state._inFlight.get(feed.id)?.controller === controller) + state._inFlight.delete(feed.id); + } + })(); + state._inFlight.set(feed.id, { controller, promise }); + return promise; + } + + function abortAllInFlight() { + for (const { controller } of state._inFlight.values()) controller.abort(); + state._inFlight.clear(); + } + + return { + feedStatus, + removeTrain, + removeFeedTrains, + sweepAgedTrains, + applySnapshot, + pollFeed, + abortAllInFlight, + duplicates, + }; +} diff --git a/src/layers/rail/layerStats.test.mjs b/src/layers/rail/layerStats.test.mjs new file mode 100644 index 000000000..7f2f0c6c1 --- /dev/null +++ b/src/layers/rail/layerStats.test.mjs @@ -0,0 +1,101 @@ +// What the layer row and stats count: trains drawn, a train two feeds carry +// once, and a partial snapshot when the train cap leaves trains out. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createTrack } from '../../data/contactPlayback.js'; +import { layerFeedState } from '../../data/feedState.js'; +import { MAX_TRAINS_TOTAL, railTrainKey } from './policy.js'; +import { + BOSTON, + railLayerHarness as harness, + railTrain as train, +} from '../../testSupport/railLayerHarness.mjs'; + +const near = (i) => ({ + lat: BOSTON.lat + 0.001 * i, + lon: BOSTON.lon + 0.001 * i, +}); + +test('a train another feed draws is not counted twice', async (t) => { + const app = harness(t, { at: BOSTON }); + app.feeds.set('mbta-rail', () => + [1, 2, 3].map((i) => train(`T${i}`, near(i).lat, near(i).lon)), + ); + await app.enable(); + assert.equal(app.layer.getStats().count, 3); + // The duplicate filter hides T3 as another feed's copy of a train. + app.entry(railTrainKey('mbta-rail', 'T3')).duplicate = true; + const stats = app.layer.getStats(); + assert.equal(stats.count, 2); + assert.match(stats.coverage, /MBTA 2\b/); + const legend = app.layer.getRowControls().legend; + assert.deepEqual( + legend.map((item) => item.count), + [2], + ); +}); + +/** Trains of another feed held by the layer, outside the view. */ +function fill(app, count, { duplicate }) { + for (let i = 0; i < count; i += 1) { + const key = railTrainKey('elsewhere', `F${i}`); + app.state()._trains.set(key, { + key, + feedId: 'elsewhere', + category: 'regional', + duplicate, + marker: null, + track: createTrack(), + fetchedAt: Date.now(), + record: { + id: `F${i}`, + lat: -40 + (i % 80), + lon: 100 + Math.floor(i / 80), + timestamp: Math.floor(Date.now() / 1000), + }, + }); + } +} + +test('copies hidden as duplicates do not count toward the train cap', async (t) => { + const app = harness(t, { at: BOSTON }); + let trains = []; + app.feeds.set('mbta-rail', () => trains); + await app.enable(); + fill(app, MAX_TRAINS_TOTAL - 1, { duplicate: true }); + trains = [1, 2, 3].map((i) => train(`T${i}`, near(i).lat, near(i).lon)); + await app.poll(); + for (const i of [1, 2, 3]) + assert.ok(app.entry(railTrainKey('mbta-rail', `T${i}`)), `T${i}`); + assert.equal(app.state()._feedStatus.get('mbta-rail').capped, 0); + assert.notEqual(app.layer.getStats().partial, true); +}); + +test('trains the cap leaves out make the snapshot partial', async (t) => { + const app = harness(t, { at: BOSTON }); + let trains = []; + app.feeds.set('mbta-rail', () => trains); + await app.enable(); + const warn = t.mock.method(console, 'warn', () => {}); + fill(app, MAX_TRAINS_TOTAL - 2, { duplicate: false }); + trains = [1, 2, 3, 4, 5].map((i) => train(`T${i}`, near(i).lat, near(i).lon)); + await app.poll(); + const kept = [1, 2, 3, 4, 5].filter((i) => + app.entry(railTrainKey('mbta-rail', `T${i}`)), + ); + assert.equal(kept.length, 2); + assert.equal(app.state()._feedStatus.get('mbta-rail').capped, 3); + const stats = app.layer.getStats(); + assert.equal(stats.partial, true); + assert.equal(stats.rawRowCount - stats.acceptedRowCount, 3); + // The panel row reads it as PARTIAL (a feed in range that the harness + // does not answer would read as degraded first). + assert.equal(layerFeedState({ ...stats, degraded: false }), 'partial'); + assert.equal(warn.mock.callCount(), 1); + // Room again: the next poll draws them all, and the note goes. + for (const [key, entry] of app.state()._trains) + if (entry.feedId === 'elsewhere') app.state()._trains.delete(key); + await app.poll(); + assert.equal(app.state()._feedStatus.get('mbta-rail').capped, 0); + assert.notEqual(app.layer.getStats().partial, true); +}); diff --git a/src/layers/rail/lifecycle.js b/src/layers/rail/lifecycle.js new file mode 100644 index 000000000..e44dc0ce3 --- /dev/null +++ b/src/layers/rail/lifecycle.js @@ -0,0 +1,170 @@ +import * as Cesium from 'cesium'; +import { destroyTrack } from '../../data/contactPlayback.js'; +import { + claimCameraSensitivity, + releaseCameraSensitivity, +} from '../../data/cameraSensitivity.js'; +import { + CAMERA_PERCENTAGE_CHANGED, + RAIL_SELECTED_OVERLAY_SOURCE_ID, +} from './policy.js'; +import { RAIL_STOP_OVERLAY_SOURCE_ID } from './stopLabels.js'; +import { + RAIL_ENABLED_FEEDS, + railFeedNeedsSchedulePack, +} from '../../data/railFeeds.js'; + +/** + * Init / enable / disable / update / destroy for one Rail layer instance. + * Everything acquired here is released here: the primitive collection, the + * sprite registration, the pick owner, listeners, the render hold and the + * camera sensitivity this layer borrowed. + * @param {object} context + * @returns {object} + */ +export function createLifecycle({ state, services, parts, timetable }) { + const { + registerSpriteCollection, + unregisterSpriteCollection, + restoreSpriteOrder, + } = services.sprites; + const { registerPickOwner, unregisterPickOwner } = services.picking; + const { releaseContinuousRender } = services.render; + + function dropAllTrains() { + for (const entry of state._trains.values()) { + parts.rendering.cancelWake(entry); + destroyTrack(entry.track); + } + state._trains.clear(); + state._moving.clear(); + state._markers?.removeAll(); + } + + const methods = { + id: 'rail', + name: 'Rail', + icon: '🚆', + source: 'GTFS-RT', + + /** Create the marker collection. Called once at bootstrap. */ + init(viewer) { + state._viewer = viewer; + state._markers = new Cesium.BillboardCollection({ + blendOption: Cesium.BlendOption.TRANSLUCENT, + }); + viewer.scene.primitives.add(state._markers); + registerSpriteCollection('rail', state._markers); + state._markers.show = false; + state._enabled = false; + state._overlayHost.setVisible(RAIL_SELECTED_OVERLAY_SOURCE_ID, false); + state._overlayHost.setVisible(RAIL_STOP_OVERLAY_SOURCE_ID, false); + restoreSpriteOrder(viewer); + // A feed that needs its schedule pack runs only once the server's + // catalog lists it (its pack is built); the catalog's own count is + // logged when it arrives (viewport.js). + const packless = RAIL_ENABLED_FEEDS.filter( + (feed) => !railFeedNeedsSchedulePack(feed), + ).length; + console.log( + `[Data:Rail] Initialized: ${RAIL_ENABLED_FEEDS.length} train feeds registered, ${packless} need no schedule pack`, + ); + }, + + /** Show trains, watch the camera, and poll every feed in range. */ + enable(viewer) { + state._enabled = true; + state._generation += 1; + state._error = null; + state._markers.show = true; + state._overlayHost.setVisible(RAIL_SELECTED_OVERLAY_SOURCE_ID, true); + state._overlayHost.setVisible(RAIL_STOP_OVERLAY_SOURCE_ID, true); + parts.selection.installClickHandler(viewer); + registerPickOwner('rail', (pickedId) => state._trains.has(pickedId)); + if (!state._cameraChangedAttached) { + viewer.camera.changed.addEventListener(parts.viewport.onCameraChanged); + claimCameraSensitivity( + viewer.camera, + 'rail', + CAMERA_PERCENTAGE_CHANGED, + ); + state._cameraChangedAttached = true; + } + if (!state._preRenderRemove) + state._preRenderRemove = viewer.scene.preRender.addEventListener( + parts.rendering.onPreRender, + ); + state._networkFade = -1; + parts.viewport.loadCatalog(); + parts.viewport.runProximityCheck(); + restoreSpriteOrder(viewer); + }, + + /** Hide everything, stop polling, drop all trains. */ + disable(viewer) { + state._enabled = false; + state._generation += 1; + clearTimeout(state._cameraDebounceTimer); + state._cameraDebounceTimer = null; + parts.selection.clearSelection(); + state._overlayHost.setVisible(RAIL_SELECTED_OVERLAY_SOURCE_ID, false); + state._overlayHost.setVisible(RAIL_STOP_OVERLAY_SOURCE_ID, false); + parts.selection.removeClickHandler(); + unregisterPickOwner('rail'); + if (state._cameraChangedAttached) { + viewer.camera.changed.removeEventListener( + parts.viewport.onCameraChanged, + ); + releaseCameraSensitivity(viewer.camera, 'rail'); + state._cameraChangedAttached = false; + } + state._preRenderRemove?.(); + state._preRenderRemove = null; + parts.viewport.cancelCatalog(); + timetable.stop(); + parts.ingestion.abortAllInFlight(); + state._activeFeeds.clear(); + state._heldFeedId = null; + state._feedStatus.clear(); + dropAllTrains(); + // Floor cells and track samples queued for trains now gone. + parts.rendering.reset(); + parts.routes.hideAll(); + if (state._markers) state._markers.show = false; + state._altitudeGateOpen = false; + if (state._renderHeld) { + releaseContinuousRender('rail'); + state._renderHeld = false; + } + }, + + /** Manager tick (every RAIL_POLL_MS): sweep, then re-poll active feeds. */ + async update() { + if (!state._enabled) return; + parts.ingestion.sweepAgedTrains(Date.now()); + parts.routes.releaseIdleFeeds(Date.now()); + const generation = state._generation; + await Promise.all( + [...state._activeFeeds.values()].map((feed) => + parts.ingestion.pollFeed(feed, generation), + ), + ); + }, + + /** Tear down the collection and release every shared registration. */ + destroy(viewer) { + this.disable(viewer); + parts.routes.destroy(); + if (state._markers) { + unregisterSpriteCollection('rail', state._markers); + viewer?.scene?.primitives?.remove(state._markers); + state._markers = null; + } + state._overlayHost.clearSource(RAIL_SELECTED_OVERLAY_SOURCE_ID); + state._dataManager = null; + state._viewer = null; + }, + }; + + return { methods }; +} diff --git a/src/layers/rail/liveOnly.test.mjs b/src/layers/rail/liveOnly.test.mjs new file mode 100644 index 000000000..86fb4b575 --- /dev/null +++ b/src/layers/rail/liveOnly.test.mjs @@ -0,0 +1,386 @@ +// Only live trains are drawn. A live feed whose positions fail is shown as +// unavailable: no train is placed or moved from its timetable, nothing is +// asked for to place one, and the trains it drew go as their reports age. +// A schedule pack is read only for a selected live train's stops. Feeds +// that read their routes from a schedule pack wait for the server's +// catalog, which is asked for again with a backoff when it fails. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + CATALOG_RETRY_FIRST_MS, + CATALOG_RETRY_MAX_MS, + FEED_EVICT_AFTER_MS, + RAIL_POLL_MS, + railCatalogRetryDelayMs, + railTrainKey, +} from './policy.js'; +import * as policy from './policy.js'; +import { + getRegisteredRailFeed, + publicRailCatalog, + railFeedNeedsSchedulePack, +} from '../../data/railFeeds.js'; +import { + RAIL_HARNESS_T0 as T0, + railLayerHarness as harness, + railTrain as train, +} from '../../testSupport/railLayerHarness.mjs'; + +const FEED = 'septa-regional-rail'; +const PHILADELPHIA = { lat: 40.0, lon: -75.25 }; +const MADRID = { lat: 40.42, lon: -3.7 }; +/** The server's catalog, narrowed to SEPTA so no other feed is polled. */ +const CATALOG = publicRailCatalog().filter((feed) => feed.id === FEED); +const LAT = 40.0; +const STEP_M = 0.1 * 111_320 * Math.cos((LAT * Math.PI) / 180); +const DAY = new Date(T0).toISOString().slice(0, 10); +/** Seconds after midnight (UTC) at the tests' start. */ +const T0_SEC = (T0 - Date.parse(`${DAY}T00:00:00Z`)) / 1000; + +/** + * A SEPTA schedule pack of one Paoli/Thorndale run, T1, west to east across + * three stops an hour long, that left its first stop ten minutes before the + * tests start. It is served, and must never place a train. + */ +const SEPTA_PACK = Object.freeze({ + version: 1, + feedId: FEED, + timezone: 'UTC', + window: { from: DAY, to: DAY }, + routes: { PAO: { type: 2 } }, + stops: [ + { id: 'A', name: 'Paoli', lat: LAT, lon: -75.45 }, + { id: 'B', name: 'Bryn Mawr', lat: LAT, lon: -75.35 }, + { id: 'C', name: '30th Street', lat: LAT, lon: -75.25 }, + ], + patterns: [ + { + shape: 0, + stops: [0, 1, 2], + seq: [1, 2, 3], + dist: [0, STEP_M, 2 * STEP_M], + }, + ], + profiles: [[0, 0, 1800, 1860, 3600, 3600]], + services: { WK: [DAY] }, + trips: { + T1: { + pattern: 0, + profile: 0, + startSec: T0_SEC - 600, + route: 'PAO', + service: 'WK', + headsign: '30th Street', + name: '9512', + }, + }, +}); +const LIVE_V1 = railTrainKey(FEED, 'V1'); + +/** SEPTA's live answer, switched by the test; its schedule pack is served. */ +function septa(app, answer = { status: 502 }) { + const live = { answer }; + app.schedules.set(FEED, SEPTA_PACK); + app.feeds.set(FEED, () => + typeof live.answer === 'function' ? live.answer() : live.answer, + ); + return live; +} + +const feedStatus = (app) => app.state()._feedStatus.get(FEED); +const feedKeys = (app) => + [...app.state()._trains.keys()].filter((key) => + key.startsWith(`rail:${FEED}:`), + ); +const asked = (app) => app.requested.filter((id) => id === FEED).length; + +/** Live V1 on trip T1, near where the timetable has the train. */ +function liveV1() { + const minutes = (Date.now() - T0) / 60_000; + return [ + train('V1', LAT, -75.4 + minutes * 0.002, { + tripId: 'T1', + routeId: 'PAO', + category: 'local', + label: '9512', + }), + ]; +} + +test('the layer has no timetable fallback to switch to', () => { + for (const gone of [ + 'LIVE_FAILURES_TO_TIMETABLE', + 'LIVE_PROBE_MIN_MS', + 'LIVE_PROBE_MAX_MS', + 'railLiveFailureKind', + 'railLiveProbeDelayMs', + ]) + assert.equal(policy[gone], undefined, gone); +}); + +test('a live feed whose operator fails shows unavailable, and no timetable is asked for', async (t) => { + t.mock.method(console, 'warn', () => {}); + const app = harness(t, { at: PHILADELPHIA, catalog: CATALOG }); + septa(app); + await app.enable(); + for (let i = 0; i < 4; i += 1) { + const status = feedStatus(app); + assert.match(status.error, /unavailable/, `poll ${i + 1}`); + assert.equal(status.loading, false); + assert.deepEqual(feedKeys(app), [], 'no train placed from the timetable'); + app.run(RAIL_POLL_MS); + await app.poll(); + } + assert.deepEqual(app.requestedSchedules, [], 'no schedule pack asked for'); + // Live positions are asked for again every poll, as for any feed. + assert.equal(asked(app), 5); + assert.match(app.layer.getStats().error, /unavailable/); +}); + +test('trains a failing feed drew are never moved on by its timetable; they age out', async (t) => { + t.mock.method(console, 'warn', () => {}); + const app = harness(t, { + at: PHILADELPHIA, + catalog: CATALOG, + scheduleStops: true, + }); + const live = septa(app, liveV1); + await app.enable(); + app.run(RAIL_POLL_MS); + await app.poll(); + assert.deepEqual(feedKeys(app), [LIVE_V1]); + // A selected live train reads its stops from the schedule pack. + app.parts().selection.selectTrain(LIVE_V1); + await app.settle(); + assert.deepEqual(app.requestedSchedules, [FEED]); + const lastReport = app.entry(LIVE_V1).record; + + // The operator fails: the train keeps its last report, and only that. + live.answer = { status: 502 }; + const failedAt = Date.now(); + while (Date.now() - failedAt <= FEED_EVICT_AFTER_MS) { + app.run(RAIL_POLL_MS); + await app.poll(); + for (const key of feedKeys(app)) + assert.equal(key, LIVE_V1, 'no timetable run is drawn'); + if (app.entry(LIVE_V1)) + assert.equal(app.entry(LIVE_V1).record, lastReport, 'never moved on'); + } + assert.deepEqual(feedKeys(app), [], 'gone as its report aged'); + assert.equal(app.state()._selectedKey, null); + assert.match(feedStatus(app).error, /unavailable/); + assert.deepEqual(app.requestedSchedules, [FEED], 'asked for once, for stops'); +}); + +test('a server that answers with timetable positions has no live data: nothing drawn', async (t) => { + t.mock.method(console, 'warn', () => {}); + // SEPTA's trains route answers 200 with no live positions + // (`positions: 'schedule'`, no trains). + const app = harness(t, { at: PHILADELPHIA, catalog: CATALOG }); + septa(app, { positions: 'schedule' }); + await app.enable(); + for (let i = 0; i < 3; i += 1) { + app.run(RAIL_POLL_MS); + await app.poll(); + } + assert.deepEqual(feedKeys(app), []); + assert.match(feedStatus(app).error, /unavailable/); + assert.deepEqual(app.requestedSchedules, []); + // It is not taken for a timetable feed: live is asked for every poll. + assert.equal(asked(app), 4); +}); + +test('a train record placed from a timetable is never drawn, whatever the feed says', async (t) => { + const app = harness(t, { at: PHILADELPHIA, catalog: CATALOG }); + septa(app, () => [ + train('V1', LAT, -75.4, { positionSource: 'schedule' }), + train('V2', LAT, -75.3, { positionSource: 'gps' }), + train('V3', LAT, -75.35, { positionSource: 'event' }), + train('V4', LAT, -75.32, { positionSource: undefined }), + ]); + await app.enable(); + assert.deepEqual(feedKeys(app).sort(), [ + railTrainKey(FEED, 'V2'), + railTrainKey(FEED, 'V3'), + railTrainKey(FEED, 'V4'), + ]); + assert.equal(feedStatus(app).error, null); +}); + +test('a catalog feed without live positions is never polled', async (t) => { + // A catalog entry whose positions are not live is left alone. + const app = harness(t, { + at: PHILADELPHIA, + catalog: CATALOG.map((feed) => ({ ...feed, positions: 'schedule' })), + }); + septa(app, liveV1); + await app.enable(); + app.run(RAIL_POLL_MS); + await app.poll(); + assert.ok(!app.state()._activeFeeds.has(FEED)); + assert.equal(asked(app), 1, 'only the first poll, before the catalog'); + assert.deepEqual(feedKeys(app), []); + assert.deepEqual(app.requestedSchedules, []); +}); + +test('a feed the catalog leaves out is never polled once it loads', async (t) => { + // Here the catalog lists SEPTA's regional rail alone, so the other + // bundled feed in range (the trolleys) is dropped as soon as it answers. + const app = harness(t, { at: PHILADELPHIA, catalog: CATALOG }); + septa(app, liveV1); + await app.enable(); + assert.deepEqual([...app.state()._activeFeeds.keys()], [FEED]); + const before = app.requested.filter((id) => id !== FEED).length; + for (let i = 0; i < 3; i += 1) { + app.run(RAIL_POLL_MS); + await app.poll(); + } + assert.equal(app.requested.filter((id) => id !== FEED).length, before); + assert.deepEqual( + [...app.state()._feedStatus.keys()], + [FEED], + 'no status, so no error, for a feed the catalog left out', + ); +}); + +/** A catalog the test answers (or fails) when it chooses. */ +function pendingCatalog() { + const pending = {}; + pending.promise = new Promise((resolve, reject) => { + pending.resolve = resolve; + pending.reject = reject; + }); + return pending; +} + +const LONG_DISTANCE = 'renfe-larga-distancia'; +const CERCANIAS = 'renfe-cercanias'; + +/** Madrid's two live feeds, both answering with no trains. */ +function liveFeedsAnswer(app) { + app.feeds.set(LONG_DISTANCE, () => []); + app.feeds.set(CERCANIAS, () => []); +} + +const needsPack = (id) => railFeedNeedsSchedulePack(getRegisteredRailFeed(id)); + +test('Renfe long distance reads its routes from a pack: it is held back until the catalog lists it', () => { + assert.equal(needsPack(LONG_DISTANCE), true); + assert.equal(needsPack(CERCANIAS), false); +}); + +test('a pack-routed live feed waits for the catalog, then runs once its pack is built', async (t) => { + // A fresh install builds no packs: until the catalog says which are + // built, no such feed's pack is asked for (it would answer 404). + const catalog = pendingCatalog(); + const app = harness(t, { at: MADRID, catalog: catalog.promise, paths: {} }); + liveFeedsAnswer(app); + await app.enable(); + const asks = () => [...app.requestedSchedules, ...app.requestedPaths]; + assert.ok(app.requestedPaths.includes(CERCANIAS), 'Cercanías starts at once'); + assert.deepEqual(asks().filter(needsPack), []); + assert.ok(!app.state()._activeFeeds.has(LONG_DISTANCE)); + assert.ok(!app.requested.includes(LONG_DISTANCE)); + + catalog.resolve( + publicRailCatalog({ schedulePackBuilt: (id) => id === LONG_DISTANCE }), + ); + await app.settle(); + assert.ok(app.state()._activeFeeds.has(LONG_DISTANCE)); + assert.ok(app.requested.includes(LONG_DISTANCE), 'its live positions'); + assert.ok(app.requestedPaths.includes(LONG_DISTANCE), 'and its line'); + // Its schedule pack is not needed to place anything. + assert.deepEqual(app.requestedSchedules, []); +}); + +test('a pack-routed live feed whose pack is not built is never polled', async (t) => { + const app = harness(t, { + at: MADRID, + catalog: publicRailCatalog({ schedulePackBuilt: () => false }), + paths: {}, + }); + liveFeedsAnswer(app); + await app.enable(); + for (let i = 0; i < 2; i += 1) { + app.run(RAIL_POLL_MS); + await app.poll(); + } + assert.deepEqual([...app.state()._activeFeeds.keys()], [CERCANIAS]); + assert.ok(!app.requested.includes(LONG_DISTANCE)); + assert.deepEqual( + [...app.requestedSchedules, ...app.requestedPaths].filter(needsPack), + [], + ); +}); + +test('a catalog that fails keeps pack-routed feeds held back and is asked for again', async (t) => { + t.mock.method(console, 'warn', () => {}); + // Two failures, then a catalog with Renfe long distance's pack built. + const answers = [ + () => Promise.reject(new Error('Rail feeds HTTP 500')), + () => Promise.reject(new Error('Rail feeds HTTP 502')), + () => + publicRailCatalog({ schedulePackBuilt: (id) => id === LONG_DISTANCE }), + ]; + const app = harness(t, { + at: MADRID, + catalog: () => answers.shift()(), + paths: {}, + }); + liveFeedsAnswer(app); + await app.enable(); + const asks = () => [...app.requestedSchedules, ...app.requestedPaths]; + assert.equal(app.catalogRequests.length, 1); + assert.ok(app.state()._activeFeeds.has(CERCANIAS), 'live feeds still run'); + assert.deepEqual(asks().filter(needsPack), [], 'no pack asked for'); + assert.ok(!app.state()._activeFeeds.has(LONG_DISTANCE)); + + // Asked for again after 30 s, then after a further minute. + t.mock.timers.tick(CATALOG_RETRY_FIRST_MS - 1); + await app.settle(); + assert.equal(app.catalogRequests.length, 1); + t.mock.timers.tick(1); + await app.settle(); + assert.equal(app.catalogRequests.length, 2); + assert.deepEqual(asks().filter(needsPack), []); + t.mock.timers.tick(2 * CATALOG_RETRY_FIRST_MS); + await app.settle(); + assert.equal(app.catalogRequests.length, 3); + assert.ok(app.state()._activeFeeds.has(LONG_DISTANCE)); + assert.ok(app.requestedPaths.includes(LONG_DISTANCE)); + + // A catalog that loaded is not asked for again. + t.mock.timers.tick(CATALOG_RETRY_MAX_MS); + await app.settle(); + assert.equal(app.catalogRequests.length, 3); +}); + +test('the catalog retry backs off to ten minutes and stops with the layer', async (t) => { + t.mock.method(console, 'warn', () => {}); + assert.equal(railCatalogRetryDelayMs(1), CATALOG_RETRY_FIRST_MS); + assert.equal(railCatalogRetryDelayMs(2), 2 * CATALOG_RETRY_FIRST_MS); + assert.equal(railCatalogRetryDelayMs(30), CATALOG_RETRY_MAX_MS); + const app = harness(t, { + at: MADRID, + catalog: () => Promise.reject(new Error('Rail feeds HTTP 500')), + paths: {}, + }); + liveFeedsAnswer(app); + await app.enable(); + app.layer.disable(app.viewer); + t.mock.timers.tick(CATALOG_RETRY_MAX_MS); + await app.settle(); + assert.equal(app.catalogRequests.length, 1, 'no retry while off'); +}); + +test('a source without a catalog polls only the feeds that need no pack', async (t) => { + const app = harness(t, { at: MADRID, catalog: false, paths: {} }); + liveFeedsAnswer(app); + await app.enable(); + const active = [...app.state()._activeFeeds.keys()]; + assert.deepEqual(active, [CERCANIAS]); + assert.deepEqual( + [...app.requestedSchedules, ...app.requestedPaths].filter(needsPack), + [], + ); +}); diff --git a/src/layers/rail/pathBinding.js b/src/layers/rail/pathBinding.js new file mode 100644 index 000000000..36814c036 --- /dev/null +++ b/src/layers/rail/pathBinding.js @@ -0,0 +1,243 @@ +import { pointAtS, projectOnPath } from '../../data/railPaths.js'; + +/** + * Binding a train to its railway line and moving it along that line. + * + * A train is bound to one shape of its feed's path pack: the shape its trip + * names, else the route's shape its report lies on. Every later report is + * projected onto that shape near the arc the train was last seen at, so a + * report can never snap to the far side of a loop or to a parallel branch. + * A report that lands well off the line means the binding was wrong (a + * diversion, a short-turn shape, a mismatched trip): the train is moved to + * another of its route's shapes if one fits, else it is unbound and drawn + * with straight-line motion again. + * + * Between two reports the marker moves by arc length: both reports are + * projected onto the shape once per playback segment, and each frame only + * interpolates the arc and looks up the point. + * + * Pure: no Cesium, no DOM. + */ + +/** A report farther than this from the bound line unbinds the train (m). */ +export const PATH_OFF_TRACK_M = 150; +/** Fastest train the search window allows for (m/s, about 250 km/h). */ +const PATH_MAX_SPEED_MPS = 70; +/** Window slack for report jitter and clock error (m). */ +const PATH_WINDOW_SLACK_M = 300; +/** Widest hinted search; beyond it the whole shape is searched (m). */ +const PATH_WINDOW_MAX_M = 50_000; +/** + * Among route shapes that fit a report about equally well, the longest wins: + * overlapping shapes are usually a full run and its short turns, and a short + * turn would unbind a train that runs past its end. + */ +const PATH_TIE_M = 10; + +/** + * Half-width of the arc window a train can have moved in `dtMs`, or NaN when + * the gap is unknown or too long for a window to be meaningful. + * @param {number} dtMs + * @returns {number} + */ +export function pathWindowM(dtMs) { + if (!Number.isFinite(dtMs)) return NaN; + const window = + PATH_WINDOW_SLACK_M + (PATH_MAX_SPEED_MPS * Math.abs(dtMs)) / 1000; + return window > PATH_WINDOW_MAX_M ? NaN : window; +} + +function createBinding(shapeIndex, path, via, s, fix) { + return { + shapeIndex, + path, + via, + /** Arc and time of the newest report. */ + fixS: s, + fixT: fix.t, + fixLat: fix.lat, + fixLon: fix.lon, + /** Arc and time last drawn. */ + drawS: s, + drawT: fix.t, + /** Projections of the current playback segment's two reports. */ + segment: { + fromSeq: -1, + toSeq: -1, + fromLat: NaN, + fromLon: NaN, + toLat: NaN, + toLon: NaN, + sFrom: NaN, + sTo: NaN, + ok: false, + }, + }; +} + +/** The route shape a report fits best, preferring the longest near-tie. */ +function bestRouteShape(pack, routeId, fix, accept) { + const candidates = []; + for (const index of pack.shapesForRoute(routeId)) { + const path = pack.pathAt(index); + const hit = projectOnPath(path, fix.lat, fix.lon); + if (!hit || hit.offsetM > PATH_OFF_TRACK_M) continue; + if (!accept(index, path)) continue; + candidates.push({ index, path, s: hit.s, offsetM: hit.offsetM }); + } + if (!candidates.length) return null; + const nearest = Math.min(...candidates.map((c) => c.offsetM)); + let best = null; + for (const candidate of candidates) { + if (candidate.offsetM > nearest + PATH_TIE_M) continue; + if (!best || candidate.path.length > best.path.length) best = candidate; + } + return best; +} + +/** + * Bind a train to a shape from its feed's pack. + * + * The trip's own shape when the trip is in the pack and the report lies on it; + * otherwise the route shape the report lies nearest (within PATH_OFF_TRACK_M), + * which for a single-shape route is that shape. + * + * @param {object} pack From createRailPathPack. + * @param {{tripId?: string, routeId?: string}} ids + * @param {{t: number, lat: number, lon: number}} fix The newest report. + * @returns {object|null} A binding, or null to keep straight-line motion. + */ +export function bindTrainPath(pack, { tripId, routeId } = {}, fix) { + if (!pack || !fix || !Number.isFinite(fix.lat) || !Number.isFinite(fix.lon)) + return null; + const trip = pack.shapeForTrip(tripId); + if (trip !== null) { + const path = pack.pathAt(trip); + const hit = projectOnPath(path, fix.lat, fix.lon); + if (hit && hit.offsetM <= PATH_OFF_TRACK_M) + return createBinding(trip, path, 'trip', hit.s, fix); + } + const best = bestRouteShape(pack, routeId, fix, (index) => index !== trip); + return best + ? createBinding(best.index, best.path, 'route', best.s, fix) + : null; +} + +/** + * Follow a new report on the bound shape. + * @param {object} binding + * @param {{t: number, lat: number, lon: number}} fix + * @returns {boolean} False when the report lies off the line: the binding is wrong. + */ +export function followFix(binding, fix) { + if (!binding || !fix) return false; + const hit = projectOnPath( + binding.path, + fix.lat, + fix.lon, + binding.fixS, + pathWindowM(fix.t - binding.fixT), + ); + if (!hit || hit.offsetM > PATH_OFF_TRACK_M) return false; + binding.fixS = hit.s; + binding.fixT = fix.t; + binding.fixLat = fix.lat; + binding.fixLon = fix.lon; + return true; +} + +/** + * After a report fell off the bound shape: another shape of the route that + * holds both the previous report and this one, or null for straight lines. + * @param {object} pack + * @param {{routeId?: string}} ids + * @param {object} binding The binding that no longer fits. + * @param {{t: number, lat: number, lon: number}} fix + * @returns {object|null} + */ +export function rebindTrainPath(pack, { routeId } = {}, binding, fix) { + if (!pack || !binding || !fix) return null; + const best = bestRouteShape(pack, routeId, fix, (index, path) => { + if (index === binding.shapeIndex) return false; + const previous = projectOnPath(path, binding.fixLat, binding.fixLon); + return !!previous && previous.offsetM <= PATH_OFF_TRACK_M; + }); + return best + ? createBinding(best.index, best.path, 'route', best.s, fix) + : null; +} + +/** Project one playback report, near the last drawn arc when that is useful. */ +function projectReport(binding, fix, hintS, hintT) { + const path = binding.path; + const window = pathWindowM(fix.t - hintT); + let hit = Number.isFinite(hintS) + ? projectOnPath(path, fix.lat, fix.lon, hintS, window) + : null; + if (!hit || hit.offsetM > PATH_OFF_TRACK_M) + hit = projectOnPath(path, fix.lat, fix.lon); + return hit && hit.offsetM <= PATH_OFF_TRACK_M ? hit.s : NaN; +} + +/** + * Where a bound train is drawn this frame. + * + * The playback segment's two reports are projected once per segment (the + * from-report reuses the previous segment's to-report when they are the same + * report); every frame interpolates the arc by the playback fraction and + * looks up that point. + * + * @param {object} binding + * @param {{fromSeq: number, toSeq: number, fraction: number}} sample Playback sample. + * @param {{from: {seq:number, t:number, lat:number, lon:number}, + * to: {seq:number, t:number, lat:number, lon:number}}} segment The sample's two reports. + * @param {{lat?: number, lon?: number, bearingDeg?: number}} out + * @returns {boolean} False when the train should stay on its straight-line point. + */ +export function placeOnPath(binding, sample, segment, out) { + if (!binding || !sample || !segment || !(sample.fromSeq >= 0)) return false; + const { from, to } = segment; + if (from.seq !== sample.fromSeq || to.seq !== sample.toSeq) return false; + const cache = binding.segment; + if ( + cache.fromSeq !== sample.fromSeq || + cache.toSeq !== sample.toSeq || + cache.fromLat !== from.lat || + cache.fromLon !== from.lon || + cache.toLat !== to.lat || + cache.toLon !== to.lon + ) { + const chained = + cache.ok && + cache.toSeq === sample.fromSeq && + cache.toLat === from.lat && + cache.toLon === from.lon; + const sFrom = chained + ? cache.sTo + : projectReport(binding, from, binding.drawS, binding.drawT); + const sTo = + sample.toSeq === sample.fromSeq + ? sFrom + : Number.isFinite(sFrom) + ? projectReport(binding, to, sFrom, from.t) + : NaN; + cache.fromSeq = sample.fromSeq; + cache.toSeq = sample.toSeq; + cache.fromLat = from.lat; + cache.fromLon = from.lon; + cache.toLat = to.lat; + cache.toLon = to.lon; + cache.sFrom = sFrom; + cache.sTo = sTo; + cache.ok = Number.isFinite(sFrom) && Number.isFinite(sTo); + } + if (!cache.ok) return false; + const f = Number.isFinite(sample.fraction) + ? Math.max(0, Math.min(1, sample.fraction)) + : 1; + const s = cache.sFrom + (cache.sTo - cache.sFrom) * f; + if (!pointAtS(binding.path, s, out)) return false; + binding.drawS = s; + binding.drawT = from.t + (to.t - from.t) * f; + return true; +} diff --git a/src/layers/rail/pathBinding.test.mjs b/src/layers/rail/pathBinding.test.mjs new file mode 100644 index 000000000..c8983a696 --- /dev/null +++ b/src/layers/rail/pathBinding.test.mjs @@ -0,0 +1,203 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createRailPathPack } from '../../data/railPaths.js'; +import { + PATH_OFF_TRACK_M, + bindTrainPath, + followFix, + pathWindowM, + placeOnPath, + rebindTrainPath, +} from './pathBinding.js'; + +const D = 1 / 111_320; + +/** + * Three shapes on the equator: + * 0: an L, 2 km east then 2 km north (the full run); + * 1: the first 1 km of the same line (a short turn); + * 2: 2 km east then 2 km south (a branch at the same junction). + */ +function pack() { + return createRailPathPack({ + version: 1, + feedId: 'test-feed', + shapes: [ + [0, 0, 0, 2000 * D, 2000 * D, 2000 * D], + [0, 0, 0, 1000 * D], + [0, 0, 0, 2000 * D, -2000 * D, 2000 * D], + ], + dist: [ + [0, 2000, 4000], + [0, 1000], + [0, 2000, 4000], + ], + trips: { full: 0, branch: 2 }, + routes: { main: [0, 1, 2], short: [1] }, + }); +} + +const fix = (t, eastM, northM) => ({ t, lat: northM * D, lon: eastM * D }); + +test('a trip binds to its own shape, else to the best route shape', () => { + const p = pack(); + const byTrip = bindTrainPath( + p, + { tripId: 'branch', routeId: 'main' }, + fix(0, 500, 10), + ); + assert.equal(byTrip.shapeIndex, 2); + assert.equal(byTrip.via, 'trip'); + assert.ok(Math.abs(byTrip.fixS - 500) < 0.5); + const single = bindTrainPath( + p, + { tripId: 'unknown', routeId: 'short' }, + fix(0, 500, 0), + ); + assert.equal(single.shapeIndex, 1); + assert.equal(single.via, 'route'); + // Near the shared start every main shape fits; the longest wins the tie. + const shared = bindTrainPath(p, { routeId: 'main' }, fix(0, 500, 0)); + assert.ok([0, 2].includes(shared.shapeIndex), String(shared.shapeIndex)); + // Up the north leg only the full run fits. + const north = bindTrainPath(p, { routeId: 'main' }, fix(0, 2010, 1500)); + assert.equal(north.shapeIndex, 0); + assert.ok(Math.abs(north.fixS - 3500) < 1); +}); + +test('a trip whose report is off its shape falls back to the route', () => { + const p = pack(); + const binding = bindTrainPath( + p, + { tripId: 'branch', routeId: 'main' }, + fix(0, 2000, 1000), + ); + assert.equal(binding.shapeIndex, 0); +}); + +test('nothing binds when no shape lies within the off-track distance', () => { + const p = pack(); + assert.equal( + bindTrainPath(p, { routeId: 'main' }, fix(0, 1000, PATH_OFF_TRACK_M + 50)), + null, + ); + assert.equal(bindTrainPath(p, { routeId: 'none' }, fix(0, 0, 0)), null); + assert.equal(bindTrainPath(null, { routeId: 'main' }, fix(0, 0, 0)), null); + assert.equal( + bindTrainPath(p, { routeId: 'main' }, { t: 0, lat: Number.NaN, lon: 0 }), + null, + ); +}); + +test('reports are followed along the line and an off-line report unbinds', () => { + const p = pack(); + const binding = bindTrainPath(p, { tripId: 'full' }, fix(0, 100, 0)); + assert.ok(followFix(binding, fix(30_000, 1200, 5))); + assert.ok(Math.abs(binding.fixS - 1200) < 0.5); + assert.equal(followFix(binding, fix(60_000, 1500, 400)), false); + assert.ok( + Math.abs(binding.fixS - 1200) < 0.5, + 'a rejected report changes nothing', + ); +}); + +test('an off-line report moves the train to a route shape that holds both reports', () => { + const p = pack(); + // Bound to the short turn, the train runs past its end and on up the L. + const short = bindTrainPath(p, { routeId: 'short' }, fix(0, 800, 0)); + assert.ok(followFix(short, fix(20_000, 950, 0))); + const next = fix(60_000, 2000, 300); + assert.equal(followFix(short, next), false); + const moved = rebindTrainPath(p, { routeId: 'main' }, short, next); + assert.equal(moved.shapeIndex, 0); + assert.ok(Math.abs(moved.fixS - 2300) < 1); + assert.equal(rebindTrainPath(p, { routeId: 'short' }, short, next), null); +}); + +test('the search window grows with the gap and gives up when it is too long', () => { + assert.equal(pathWindowM(0), 300); + assert.equal(pathWindowM(-10_000), 1000); + assert.ok(Number.isNaN(pathWindowM(Number.NaN))); + assert.ok(Number.isNaN(pathWindowM(3_600_000))); +}); + +function playback(fromSeq, toSeq, from, to, fraction) { + return { + sample: { fromSeq, toSeq, fraction }, + segment: { + from: { seq: fromSeq, ...from }, + to: { seq: toSeq, ...to }, + }, + }; +} + +test('between two reports a bound train moves along the line, not the chord', () => { + const p = pack(); + const binding = bindTrainPath(p, { tripId: 'full' }, fix(0, 1500, 0)); + // A corner cut: 1.5 km east, then 500 m up the north leg. + const a = fix(0, 1500, 0); + const b = fix(60_000, 2000, 500); + const out = {}; + const mid = playback(4, 5, a, b, 0.5); + assert.ok(placeOnPath(binding, mid.sample, mid.segment, out)); + // Halfway along the 1 km of track is the corner itself. + assert.ok(Math.abs(out.lat) < 1e-9, String(out.lat)); + assert.ok(Math.abs(out.lon - 2000 * D) < 1e-6, String(out.lon)); + assert.ok(Math.abs(binding.drawS - 2000) < 1); + const later = playback(4, 5, a, b, 0.75); + assert.ok(placeOnPath(binding, later.sample, later.segment, out)); + assert.ok(Math.abs(out.lat - 250 * D) < 1e-6); + assert.ok( + Math.abs(out.bearingDeg) < 1e-6 || Math.abs(out.bearingDeg - 360) < 1e-6, + ); +}); + +test('the two projections are computed once per segment and chained', () => { + const p = pack(); + const binding = bindTrainPath(p, { tripId: 'full' }, fix(0, 100, 0)); + const out = {}; + const first = playback(1, 2, fix(0, 100, 0), fix(30_000, 600, 0), 0.2); + assert.ok(placeOnPath(binding, first.sample, first.segment, out)); + const cache = binding.segment; + assert.equal(cache.fromSeq, 1); + assert.ok(Math.abs(cache.sTo - 600) < 0.5); + // Same segment, later frame: cached arcs, only the fraction moves. + cache.sFrom = 100.25; // a sentinel a recompute would overwrite + const frame = playback(1, 2, fix(0, 100, 0), fix(30_000, 600, 0), 0.4); + assert.ok(placeOnPath(binding, frame.sample, frame.segment, out)); + assert.equal(cache.sFrom, 100.25); + // Next segment starts at the old to-report: its arc is reused. + cache.sTo = 600.5; + const next = playback(2, 3, fix(30_000, 600, 0), fix(60_000, 1100, 0), 0); + assert.ok(placeOnPath(binding, next.sample, next.segment, out)); + assert.equal(cache.sFrom, 600.5); + assert.ok(Math.abs(cache.sTo - 1100) < 0.5); +}); + +test('a parked train sits on the line at its report', () => { + const p = pack(); + const binding = bindTrainPath(p, { tripId: 'full' }, fix(0, 700, 20)); + const out = {}; + const parked = playback(3, 3, fix(0, 700, 20), fix(0, 700, 20), 1); + assert.ok(placeOnPath(binding, parked.sample, parked.segment, out)); + assert.equal(out.lat, 0, 'pulled onto the track'); + assert.ok(Math.abs(out.lon - 700 * D) < 1e-6); +}); + +test('a segment whose reports do not fit the line keeps the straight line', () => { + const p = pack(); + const binding = bindTrainPath(p, { tripId: 'full' }, fix(0, 100, 0)); + const out = {}; + const off = playback(1, 2, fix(0, 100, 0), fix(30_000, 800, 600), 0.5); + assert.equal(placeOnPath(binding, off.sample, off.segment, out), false); + assert.equal(binding.segment.ok, false); + // No playback position yet, or stale report samples: nothing to place. + assert.equal( + placeOnPath(binding, { fromSeq: -1, toSeq: -1 }, off.segment, out), + false, + ); + const stale = playback(1, 2, fix(0, 100, 0), fix(30_000, 600, 0), 0.5); + stale.segment.to.seq = 9; + assert.equal(placeOnPath(binding, stale.sample, stale.segment, out), false); + assert.equal(placeOnPath(null, stale.sample, stale.segment, out), false); +}); diff --git a/src/layers/rail/policy.js b/src/layers/rail/policy.js new file mode 100644 index 000000000..9d2faa28a --- /dev/null +++ b/src/layers/rail/policy.js @@ -0,0 +1,577 @@ +/** + * @module layers/rail/policy + * @description Constants and pure decisions for the Rail layer. No Cesium + * scene state, no network: everything here is exercised by node:test. + * + * Motion between reports is the Transit layer's delayed playback + * (src/layers/transit/movement.js over src/data/contactPlayback.js), so the + * report-age rules here are Transit's too. + */ + +import { msg } from '../../i18n/marker.js'; +import { + RAIL_CATEGORIES, + RAIL_POSITION_SOURCES, +} from '../../data/railFeeds.js'; +import { displayMotion } from '../transit/movement.js'; +import { FIX_FLAGS, readFix } from '../../data/contactPlayback.js'; +import { roundHeadingDegrees } from '../../data/headingDegrees.js'; +import { + stopClock, + stopLateMinutes, + stopName, + stopRoleIndices, +} from './stopLabels.js'; + +export const RAIL_SELECTED_OVERLAY_SOURCE_ID = 'rail-selected'; +export const RAIL_SELECTED_OVERLAY_SOURCE_OPTIONS = Object.freeze({ + cohortLimit: 1, + collisionCapacity: 0, + moving: true, +}); + +/** Poll interval (ms), matching the proxy's 15 s cache window. */ +export const RAIL_POLL_MS = 15_000; +/** + * Feeds activate below this camera altitude, with hysteresis. Rail networks + * are regional, so the gate is Transit's: low enough that a whole-continent + * view does not poll every operator at once. + */ +export const ACTIVATION_ALTITUDE_M = 3_000_000; +export const ACTIVATION_ENTER_ALTITUDE_M = ACTIVATION_ALTITUDE_M - 150_000; +export const ACTIVATION_EXIT_ALTITUDE_M = ACTIVATION_ALTITUDE_M + 150_000; +export const CAMERA_DEBOUNCE_MS = 340; +export const CAMERA_PERCENTAGE_CHANGED = 0.05; +/** A feed already active stays active this far past its coverage edge. */ +export const RANGE_SLACK_KM = 40; +export const MAX_TRAINS_TOTAL = 5_000; +/** A train a feed stops listing for this many polls in a row is removed. */ +export const MISSED_POLLS_TO_DROP = 2; +/** A report older than this is not drawn (a frozen feed empties, it does not lie). */ +export const TRAIN_MAX_FIX_AGE_MS = 10 * 60_000; +/** A feed whose operator has not answered for this long reads as stale. */ +export const FEED_STALE_AFTER_MS = 90_000; +/** ...and for this long, its trains are removed. */ +export const FEED_EVICT_AFTER_MS = 5 * 60_000; +/** A stamp this far ahead of the fetch is a clock fault, bounded by the fetch. */ +export const FUTURE_STAMP_TOLERANCE_MS = 10_000; + +/** + * Where a drawn train's position may come from: the operator's GPS, or the + * station or timing point its signalling last reported. No train is placed + * or moved from a timetable; between reports it only glides along its line + * behind real time, as Transit's vehicles do. Every source the registry + * knows (RAIL_POSITION_SOURCES) is a live one. + */ +export const RAIL_LIVE_POSITION_SOURCES = RAIL_POSITION_SOURCES; + +/** + * Whether a feed (a registry or catalog entry) or a train record reports + * live positions. Without a stated source, GPS is meant. + * @param {string|null|undefined} source `feed.positions` or + * `record.positionSource`. + * @returns {boolean} + */ +export function isLiveRailPositionSource(source) { + return RAIL_LIVE_POSITION_SOURCES.includes(source ?? 'gps'); +} +/** + * A feed catalog that fails is asked for again after CATALOG_RETRY_FIRST_MS, + * then twice as long after each failure in a row, up to CATALOG_RETRY_MAX_MS. + */ +export const CATALOG_RETRY_FIRST_MS = 30_000; +export const CATALOG_RETRY_MAX_MS = 10 * 60_000; + +/** + * How long the catalog waits after its nth failure in a row (ms). + * @param {number} failures 1 after the first. + * @returns {number} + */ +export function railCatalogRetryDelayMs(failures) { + return Math.min( + CATALOG_RETRY_MAX_MS, + CATALOG_RETRY_FIRST_MS * 2 ** Math.max(0, failures - 1), + ); +} + +/** + * Floor cells a moving train reaches cold are warmed in batches: at most + * COLD_CELLS_PER_FLUSH per flush, COLD_CELL_FLUSH_MS after the first one is + * queued, and a cell asked for is not asked again for COLD_CELL_RETRY_MS. A + * long-distance train crosses a 0.001° cell (about 111 m) every few seconds. + */ +export const COLD_CELLS_PER_FLUSH = 64; +export const COLD_CELL_FLUSH_MS = 1_000; +/** + * Floor warming shares `/api/terrain/heights` with every other layer: 90 + * requests a minute per client, 64 points each. Rail's budget refills at + * FLOOR_CELLS_PER_MINUTE cells a minute and holds at most FLOOR_CELLS_BURST, + * so no minute spends more than the two together (1,520 cells, about 24 + * requests). It is spent only on trains within FLOOR_WARM_MAX_DIST_KM of the + * camera and on the selected trip's stops, the selected train first, then + * the nearest, so hundreds of trains in view (Paris, the Ruhr) cannot use up + * the limit and leave every layer without floors. + */ +export const FLOOR_CELLS_PER_MINUTE = 1_200; +export const FLOOR_CELLS_BURST = COLD_CELLS_PER_FLUSH * 5; +export const FLOOR_WARM_MAX_DIST_KM = 30; +export const COLD_CELL_RETRY_MS = 5_000; +/** + * Track heights on photorealistic tiles: the rendered mesh is sampled at + * points on the train's own line (where the draped line is the track bed + * or the deck), in buckets of 1/TRACK_SAMPLE_GRID degree (about 55 m), + * at most TRACK_SAMPLES_PER_FLUSH per flush, for trains within + * TRACK_SAMPLE_MAX_DIST_KM of a camera at most TRACK_SAMPLE_MAX_CAMERA_M + * above the floor there, and only while the tileset reports its tiles + * loaded: a higher camera, or tiles still streaming, reads coarse tiles, + * whose height under a viaduct is the valley's (the failure + * src/services/meshFloorSampler.js records). A sample is kept only within + * TRACK_PRIOR_BELOW_M / TRACK_PRIOR_ABOVE_M of the floor under it, and is + * read again after TRACK_SAMPLE_REFRESH_MS: the new reading replaces it + * when the camera is no higher than it was, or once the old one is + * TRACK_SAMPLE_MAX_AGE_MS old. + */ +export const TRACK_SAMPLE_GRID = 2000; +export const TRACK_SAMPLES_PER_FLUSH = 8; +export const TRACK_SAMPLE_RETRY_MS = 4_000; +export const TRACK_SAMPLE_MAX_CAMERA_M = 5_000; +export const TRACK_SAMPLE_MAX_DIST_KM = 8; +export const TRACK_PRIOR_BELOW_M = 25; +export const TRACK_PRIOR_ABOVE_M = 80; +export const TRACK_SAMPLE_REFRESH_MS = 30_000; +export const TRACK_SAMPLE_MAX_AGE_MS = 5 * 60_000; +/** + * A train's drawn height follows its target height no faster than a + * railway climbs: TRACK_MAX_GRADE of the distance it moved (lines rarely + * exceed 4%), plus TRACK_SETTLE_MPS while it stands, so a roof or overpass + * sampled under it is a bump, not a jump. It snaps to a better source (a + * real floor replacing a fallback, or any floor replacing the ellipsoid) + * and after a jump of more than TRACK_SNAP_M. + */ +export const TRACK_MAX_GRADE = 0.05; +export const TRACK_SETTLE_MPS = 0.5; +export const TRACK_SNAP_M = 2_000; +export const MARKER_PIXEL_SIZE = 18; +export const SELECTED_PIXEL_SIZE = 28; +export const SELECTED_CARD_REFRESH_MS = 250; +/** While a train is selected, every other train is drawn at this opacity. */ +export const UNSELECTED_TRAIN_ALPHA = 0.35; +/** + * The faint route network is drawn in full below the first altitude, fades + * out above it and is not drawn at all above the second (m). Above that the + * lines are clutter at the scale of a whole region, and cost frames. + */ +export const NETWORK_FULL_BELOW_M = 150_000; +export const NETWORK_HIDDEN_ABOVE_M = 200_000; +/** The network's opacity, and its share of that while a train is selected. */ +export const NETWORK_ALPHA = 0.4; +export const NETWORK_SELECTED_DIM = 0.4; +export const NETWORK_WIDTH_PX = 2; +/** + * A feed out of use this long gives back its network, path pack and parsed + * schedule pack; it loads them again when next used (ms). + */ +export const FEED_RELEASE_AFTER_MS = 10 * 60_000; +/** The selected train's line: the part ahead, and the part travelled. */ +export const ROUTE_AHEAD_WIDTH_PX = 5; +export const ROUTE_TRAVELLED_ALPHA = 0.3; +/** How often the selected line's travelled/ahead split follows the train. */ +export const ROUTE_SPLIT_INTERVAL_MS = 250; +/** How often the selected train's stops, labels and card are refreshed. */ +export const STOP_REFRESH_MS = 1_000; +/** + * How long the follow camera glides to another feed's copy of the selected + * train when the selection moves to it (ms), so it neither jumps nor lets go. + */ +export const FOLLOW_HANDOVER_MS = 1_200; + +/** + * Category palette. Warm to cool with distance served, and none of the colours + * the Transit modes, flights (white/cyan), military (amber) or vessels use, so + * a train never reads as a bus drawn by the other layer. + */ +export const RAIL_CATEGORY_COLORS = Object.freeze({ + transit: '#FF7A59', + local: '#F2D04B', + regional: '#7BE495', + national: '#5AB8FF', + international: '#C58BFF', +}); + +/** Panel and card words for each category. */ +export const RAIL_CATEGORY_LABELS = Object.freeze({ + transit: msg('Urban rail'), + local: msg('Local rail'), + regional: msg('Regional rail'), + national: msg('National rail'), + international: msg('International rail'), +}); + +/** Card words for what runs on the track, shared with the Transit layer's. */ +const RAIL_MODE_WORDS = Object.freeze({ + rail: msg('Train'), + metro: msg('Subway'), + tram: msg('Tram'), +}); + +/** Default visibility: every category on. */ +export const DEFAULT_RAIL_CATEGORY_VISIBILITY = Object.freeze( + Object.fromEntries(RAIL_CATEGORIES.map((category) => [category, true])), +); + +/** + * Normalise requested category toggles against the current ones. Unknown keys + * are ignored and non-boolean values keep the current setting, so a malformed + * share link can hide nothing it did not name. + * @param {Record} params + * @param {Record} [current] + * @returns {Record} + */ +export function normalizeRailCategories( + params, + current = DEFAULT_RAIL_CATEGORY_VISIBILITY, +) { + const next = {}; + for (const category of RAIL_CATEGORIES) { + next[category] = + typeof params?.[category] === 'boolean' + ? params[category] + : current[category] !== false; + } + return next; +} + +/** + * Stable key for a train across polls. Prefixed so it can never equal a + * Transit vehicle key for the same physical train in a shared pick registry. + * @param {string} feedId + * @param {string} trainId + * @returns {string} + */ +export function railTrainKey(feedId, trainId) { + return `rail:${feedId}:${trainId}`; +} + +/** + * When a report was made, in ms: the train's own stamp, else the fetch time. + * @param {object} record + * @param {number|null} [fetchedAtMs] + * @returns {number} + */ +export function trainReportTimeMs(record, fetchedAtMs = null) { + const seconds = Number(record?.timestamp); + if (Number.isFinite(seconds) && seconds > 0) return seconds * 1000; + if (Number.isFinite(fetchedAtMs)) return fetchedAtMs; + return Date.now(); +} + +/** + * Age of a report in ms (never negative). An unstamped record is aged from + * the snapshot's fetch time, never from now. + * @param {object} record + * @param {number} nowMs + * @param {number|null} [fetchedAtMs] + * @returns {number} + */ +export function trainFixAgeMs(record, nowMs, fetchedAtMs = null) { + const seconds = Number(record?.timestamp); + if (Number.isFinite(seconds) && seconds > 0) + return Math.max(0, nowMs - seconds * 1000); + if (Number.isFinite(fetchedAtMs)) return Math.max(0, nowMs - fetchedAtMs); + return 0; +} + +/** Whether a report is too old to draw. */ +export function isStaleTrainFix(record, nowMs, fetchedAtMs = null) { + return trainFixAgeMs(record, nowMs, fetchedAtMs) > TRAIN_MAX_FIX_AGE_MS; +} + +/** The movement module's speed ceilings are keyed by Transit mode names. */ +export function playbackModeFor(railMode) { + return railMode === 'metro' + ? 'subway' + : railMode === 'tram' + ? 'tram' + : 'rail'; +} + +/** Two reports closer than this are one place: a parked train's jitter (m). */ +export const FOLLOW_MIN_MOVE_M = 15; + +const lastFix = {}; +const priorFix = {}; + +/** How far apart a track's last two reports are (m), 0 with fewer. */ +function lastReportsApartM(track) { + if (!track || track.count < 2) return 0; + const to = readFix(track, track.count - 1, lastFix); + const from = readFix(track, track.count - 2, priorFix); + if (from.epoch !== to.epoch || to.flags & FIX_FLAGS.BREAK) return 0; + const kx = 111_320 * Math.cos((((from.lat + to.lat) / 2) * Math.PI) / 180); + const dLon = ((((to.lon - from.lon + 180) % 360) + 360) % 360) - 180; + return Math.hypot(dLon * kx, (to.lat - from.lat) * 111_320); +} + +/** + * Whether the camera follows a train once it is selected, as it follows a + * tracked aircraft: its reports are recent, and it is moving on its playback + * now, waits at a stop with its next move already reported, or is between + * reports (playback waiting for the next one) after its last two reports + * moved it. Any other train (parked, known from a single report, or not + * heard from lately) gets one fly-to. + * @param {{sample?: object, track?: object}|null} entry Runtime entry with + * playback state. + * @returns {boolean} + */ +export function railTrainFollowable(entry) { + const sample = entry?.sample; + if (!sample || displayMotion(entry, 0).word === 'NO FIX') return false; + if (sample.phase === 'playing' && sample.segmentSpeedMps > 0) return true; + if ( + sample.phase === 'playing' && + sample.motion === 'stopped' && + Number.isFinite(sample.nextMotionT) && + sample.nextMotionT > sample.displayT + ) + return true; + return lastReportsApartM(entry.track) >= FOLLOW_MIN_MOVE_M; +} + +/** + * The follow camera's offset from the train in east-north-up metres: the + * shared world-focus framing for a train (its range and pitch) seen along the + * camera's current heading, so taking the train does not swing the view round. + * @param {{rangeM: number, pitchDeg: number}} framing + * @param {number} headingRad + * @returns {{x: number, y: number, z: number}} + */ +export function railFollowOffset(framing, headingRad) { + const heading = Number.isFinite(headingRad) ? headingRad : 0; + const pitch = (framing.pitchDeg * Math.PI) / 180; + const across = framing.rangeM * Math.cos(pitch); + return { + x: -Math.sin(heading) * across, + y: -Math.cos(heading) * across, + z: -Math.sin(pitch) * framing.rangeM, + }; +} + +// Tools and the server may import this module, so no catalogue here: the +// layer passes the UI's t(). +function englishText(text, values) { + return String(text).replace(/\{(\w+)\}/g, (match, key) => + values && key in values ? String(values[key]) : match, + ); +} + +/** Card words for where a train's position comes from. */ +const RAIL_POSITION_SOURCE_WORDS = Object.freeze({ + gps: msg('Live GPS'), + event: msg('Estimated from station reports'), +}); + +/** The card never grows past this many detail lines. */ +export const RAIL_CARD_MAX_LINES = 6; + +/** + * The card's title: the route when the feed names one, else the trip's + * headsign, else the vehicle's own label, else its id. + * @param {object} record + * @param {string|null} headsign + * @param {(text: string, values?: object) => string} t + * @returns {string} + */ +function railCardTitle(record, headsign, t) { + const to = headsign ? t('To {headsign}', { headsign }) : ''; + if (record.routeId) { + const route = t('Route {route}', { route: record.routeId }); + return `🚆 ${to ? `${route} · ${to}` : route}`; + } + if (to) return `🚆 ${to}`; + const label = + typeof record.label === 'string' && record.label.trim() + ? record.label.trim() + : ''; + if (label && label !== record.id) return `🚆 ${label}`; + return `🚆 ${t('Vehicle {vehicle}', { vehicle: label || record.id })}`; +} + +/** "Next Yonkers 14:32 T−4 (+3 min late)" / "Prev Riverdale 14:24 T+3". */ +function stopLine(stop, which, t) { + const values = { + stop: stopName(stop), + time: stopClock(stop), + relative: stop.relative || '', + }; + const line = ( + which === 'next' + ? t('Next {stop} {time} {relative}', values) + : t('Prev {stop} {time} {relative}', values) + ) + .replace(/\s+/g, ' ') + .trim(); + const late = which === 'next' ? stopLateMinutes(stop) : 0; + return late > 0 + ? `${line} ${t('(+{minutes} min late)', { minutes: late })}` + : line; +} + +/** + * Card copy for the selected train. The status words, report-age wording and + * "shown behind" note are the Transit card's, so they read the same. + * + * With the trip's stops (see stopLabels.js for the stop shape) the card names + * the next and previous stops; the card stays within RAIL_CARD_MAX_LINES by + * dropping its least useful lines first. + * + * @param {object} feed Registry entry. + * @param {object} record Train record from the snapshot. + * @param {number} nowMs + * @param {number|null} [fetchedAtMs] + * @param {object|null} [entry] Runtime entry with playback state. + * @param {(text: string, values?: object) => string} [t] + * @param {{stops?: object[]|null, headsign?: string|null}|null} [trip] The + * trip's stops and headsign from the feed's schedule pack, when known. + * @returns {{title: string, details: string[]}} + */ +export function buildRailSelectionCopy( + feed, + record, + nowMs, + fetchedAtMs = null, + entry = null, + t = englishText, + trip = null, +) { + const headsign = + (typeof trip?.headsign === 'string' && trip.headsign.trim()) || + (typeof record.headsign === 'string' && record.headsign.trim()) || + null; + const title = railCardTitle(record, headsign, t); + const category = t(RAIL_CATEGORY_LABELS[record.category] || 'Train'); + const mode = t(RAIL_MODE_WORDS[record.mode] || RAIL_MODE_WORDS.rail); + /** Lines with a rank: when the card is too long, the highest ranks go. */ + const lines = []; + const add = (rank, text) => lines.push({ rank, text }); + add(0, `${category} · ${mode} · ${feed.name} · ${feed.region}`); + + const shown = entry ? displayMotion(entry, nowMs) : null; + const motion = + shown?.word === 'STOPPED' + ? t('Stopped', { context: 'vehicle motion' }) + : shown?.word === 'NO FIX' + ? t('No recent fix') + : shown?.word === 'WAITING' + ? t('Waiting for update') + : shown + ? t('Moving') + : ''; + const sourceWord = RAIL_POSITION_SOURCE_WORDS[record.positionSource]; + const source = sourceWord ? t(sourceWord) : ''; + if (motion || source) + add(1, motion && source ? `${motion} · ${source}` : motion || source); + + const stops = Array.isArray(trip?.stops) ? trip.stops : []; + const roles = stopRoleIndices(stops); + if (roles.next >= 0) add(2, stopLine(stops[roles.next], 'next', t)); + if (roles.previous >= 0) + add(3, stopLine(stops[roles.previous], 'previous', t)); + const haveStops = roles.next >= 0 || roles.previous >= 0; + + const ageS = Math.round( + (Number.isFinite(entry?.sample?.latestReportAgeMs) + ? entry.sample.latestReportAgeMs + : trainFixAgeMs(record, nowMs, fetchedAtMs)) / 1000, + ); + const reported = + ageS < 90 + ? t('Reported {seconds} s ago', { seconds: ageS }) + : t('Reported {minutes} min ago', { minutes: Math.floor(ageS / 60) }); + const ownTime = + Number.isFinite(record.timestamp) && + record.timestamp > 0 && + (!record.timestampSource || record.timestampSource === 'vehicle'); + const lagS = Number.isFinite(entry?.sample?.actualDelayMs) + ? Math.max(0, Math.round(entry.sample.actualDelayMs / 1000)) + : null; + add( + 4, + (ownTime ? reported : t('{reported} (feed time)', { reported })) + + (lagS === null + ? '' + : ` · ${t('shown {seconds} s behind', { seconds: lagS })}`), + ); + + const reportedMotion = []; + if (Number.isFinite(record.speedMps)) + reportedMotion.push( + t('reported {speed} km/h', { speed: Math.round(record.speedMps * 3.6) }), + ); + if (Number.isFinite(record.bearing)) + reportedMotion.push( + t('reported hdg {heading}°', { + heading: roundHeadingDegrees(record.bearing), + }), + ); + if (reportedMotion.length) add(7, reportedMotion.join(' · ')); + // The feed's own stop id says less than the schedule pack's stop names. + if (!haveStops) { + if (record.status === 'STOPPED_AT' && record.stopId) + add(8, t('Last report: stopped at stop {stop}', { stop: record.stopId })); + else if (record.status === 'INCOMING_AT' && record.stopId) + add( + 8, + t('Last report: arriving at stop {stop}', { stop: record.stopId }), + ); + else if (record.status === 'IN_TRANSIT_TO' && record.stopId) + add(8, t('Last report: next stop {stop}', { stop: record.stopId })); + } + if (record.mode === 'metro') + add(5, t('Shown at street level · depth not in the feed')); + add( + 6, + `${t('Vehicle {vehicle}', { vehicle: record.id })}${record.label && record.label !== record.id ? ` · ${record.label}` : ''}`, + ); + + // Past the limit, the highest ranks go; the rest keep their order. + const kept = lines + .map((line, order) => ({ ...line, order })) + .sort((a, b) => a.rank - b.rank || a.order - b.order) + .slice(0, RAIL_CARD_MAX_LINES) + .sort((a, b) => a.order - b.order); + return { title, details: kept.map((line) => line.text) }; +} + +/** + * Shared-host card for the selected train. + * @param {string} key + * @param {object|(() => object|null)} position Cartesian, or a getter the host + * calls on every paint so the card follows the marker. + * @param {{title: string, details: string[]}} copy + * @param {string} category + * @returns {object|null} + */ +export function createRailSelectedOverlayEntry(key, position, copy, category) { + if (!key || !position) return null; + return { + id: String(key), + position, + variant: 'selected', + selected: true, + protected: true, + paintLane: 'selected', + collisionGroup: 'ambient-card', + priority: Number.MAX_SAFE_INTEGER, + title: copy.title, + details: copy.details, + accent: RAIL_CATEGORY_COLORS[category] || RAIL_CATEGORY_COLORS.local, + interactive: false, + anchorRadiusPx: 9, + minAnchorGapPx: 11, + verticalOnly: true, + placement: 'above', + edgeFade: 'keyhole', + horizonCull: true, + terrainOcclusion: false, + }; +} diff --git a/src/layers/rail/policy.test.mjs b/src/layers/rail/policy.test.mjs new file mode 100644 index 000000000..38f02a5db --- /dev/null +++ b/src/layers/rail/policy.test.mjs @@ -0,0 +1,521 @@ +// Pure Rail layer decisions: category toggles from share links, train keys, +// report aging, the playback mode a train borrows from Transit, and the +// selected-train card. The copy builder stays free of i18n (tools import +// policy.js); the browser hands it t(), so a fake t() shows which words go +// through translation. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + DEFAULT_RAIL_CATEGORY_VISIBILITY, + RAIL_CARD_MAX_LINES, + RAIL_CATEGORY_COLORS, + TRAIN_MAX_FIX_AGE_MS, + RAIL_LIVE_POSITION_SOURCES, + buildRailSelectionCopy, + createRailSelectedOverlayEntry, + isLiveRailPositionSource, + isStaleTrainFix, + normalizeRailCategories, + playbackModeFor, + railTrainKey, + trainFixAgeMs, + trainReportTimeMs, +} from './policy.js'; +import { RAIL_CATEGORIES } from '../../data/railFeeds.js'; + +const NOW = 1_794_000_000_000; +const feed = { name: 'MBTA', region: 'Boston, MA' }; + +/** A t() that marks every string it translates and fills placeholders. */ +function fakeT() { + const calls = []; + const t = (text, values) => { + calls.push({ text, values }); + const filled = String(text).replace(/\{(\w+)\}/g, (match, key) => + values && key in values ? String(values[key]) : match, + ); + return `T(${filled})`; + }; + return { t, calls }; +} + +const stampedTrain = Object.freeze({ + id: '1712', + label: 'Train 1712', + routeId: 'CR-Worcester', + category: 'local', + mode: 'rail', + lat: 42.35, + lon: -71.2, + bearing: 90, + speedMps: 20, + timestamp: (NOW - 30_000) / 1000, + timestampSource: 'vehicle', + status: 'IN_TRANSIT_TO', + stopId: 'place-sstat', +}); + +const unstampedMetro = Object.freeze({ + id: '77', + label: '77', + routeId: null, + category: 'transit', + mode: 'metro', + lat: 42.36, + lon: -71.06, +}); + +test('every category is on by default', () => { + assert.deepEqual(Object.keys(DEFAULT_RAIL_CATEGORY_VISIBILITY), [ + ...RAIL_CATEGORIES, + ]); + assert.ok( + Object.values(DEFAULT_RAIL_CATEGORY_VISIBILITY).every((on) => on === true), + ); + assert.deepEqual( + normalizeRailCategories({}), + DEFAULT_RAIL_CATEGORY_VISIBILITY, + ); + assert.deepEqual( + normalizeRailCategories(null), + DEFAULT_RAIL_CATEGORY_VISIBILITY, + ); +}); + +test('category toggles apply booleans and ignore anything else', () => { + const current = { + transit: true, + local: false, + regional: true, + national: true, + international: true, + }; + assert.deepEqual( + normalizeRailCategories( + { + transit: false, + local: 'true', + regional: 0, + national: null, + international: true, + bus: false, + }, + current, + ), + { + transit: false, + local: false, + regional: true, + national: true, + international: true, + }, + ); +}); + +test('a share link can switch a hidden category back on', () => { + const allOff = Object.fromEntries(RAIL_CATEGORIES.map((key) => [key, false])); + assert.deepEqual(normalizeRailCategories({ regional: true }, allOff), { + ...allOff, + regional: true, + }); +}); + +test('train keys are namespaced so they never collide with Transit keys', () => { + assert.equal(railTrainKey('mbta-rail', '1712'), 'rail:mbta-rail:1712'); + assert.notEqual( + railTrainKey('mbta-rail', '1712'), + railTrainKey('mbta-rail', '1713'), + ); + assert.notEqual( + railTrainKey('mta-lirr', '7'), + railTrainKey('mta-metro-north', '7'), + ); +}); + +test('a stamped report is aged from its own time', () => { + assert.equal(trainFixAgeMs(stampedTrain, NOW), 30_000); + // The fetch time does not override the train's own stamp. + assert.equal(trainFixAgeMs(stampedTrain, NOW, NOW - 1_000), 30_000); + assert.equal(trainReportTimeMs(stampedTrain, NOW - 1_000), NOW - 30_000); +}); + +test('a stamp ahead of now ages to zero, never below', () => { + assert.equal(trainFixAgeMs({ timestamp: (NOW + 5_000) / 1000 }, NOW), 0); +}); + +test('an unstamped report is aged from the snapshot fetch time', () => { + assert.equal(trainFixAgeMs(unstampedMetro, NOW, NOW - 45_000), 45_000); + assert.equal(trainReportTimeMs(unstampedMetro, NOW - 45_000), NOW - 45_000); + // Zero and negative stamps count as no stamp. + assert.equal(trainFixAgeMs({ timestamp: 0 }, NOW, NOW - 2_000), 2_000); + assert.equal(trainFixAgeMs({ timestamp: -1 }, NOW, NOW - 2_000), 2_000); + // With neither a stamp nor a fetch time there is nothing to age from. + assert.equal(trainFixAgeMs(unstampedMetro, NOW), 0); +}); + +test('a report is stale only past the ten-minute limit', () => { + assert.equal(TRAIN_MAX_FIX_AGE_MS, 10 * 60_000); + const at = (ageMs) => ({ timestamp: (NOW - ageMs) / 1000 }); + assert.equal(isStaleTrainFix(at(TRAIN_MAX_FIX_AGE_MS), NOW), false); + assert.equal(isStaleTrainFix(at(TRAIN_MAX_FIX_AGE_MS + 1_000), NOW), true); +}); + +test('an unstamped report from an old snapshot is stale, not live', () => { + const oldFetch = NOW - TRAIN_MAX_FIX_AGE_MS - 1; + assert.equal(isStaleTrainFix(unstampedMetro, NOW, oldFetch), true); + assert.equal(isStaleTrainFix(unstampedMetro, NOW, NOW - 60_000), false); +}); + +test('rail modes map onto the Transit playback modes', () => { + assert.equal(playbackModeFor('metro'), 'subway'); + assert.equal(playbackModeFor('tram'), 'tram'); + assert.equal(playbackModeFor('rail'), 'rail'); + assert.equal(playbackModeFor('monorail'), 'rail'); + assert.equal(playbackModeFor(undefined), 'rail'); +}); + +test('the card for a train with its own timestamp', () => { + const { t, calls } = fakeT(); + const copy = buildRailSelectionCopy(feed, stampedTrain, NOW, NOW, null, t); + assert.equal(copy.title, '🚆 T(Route CR-Worcester)'); + assert.deepEqual(copy.details, [ + 'T(Local rail) · T(Train) · MBTA · Boston, MA', + 'T(Reported 30 s ago)', + 'T(reported 72 km/h) · T(reported hdg 90°)', + 'T(Last report: next stop place-sstat)', + 'T(Vehicle 1712) · Train 1712', + ]); + // Operator names and regions are data, never sent through t(). + assert.ok(calls.every(({ text }) => !text.includes('MBTA'))); +}); + +test('the card for an unstamped metro train says the time is the feed’s', () => { + const { t } = fakeT(); + const copy = buildRailSelectionCopy( + feed, + unstampedMetro, + NOW, + NOW - 120_000, + null, + t, + ); + assert.equal(copy.title, '🚆 T(Vehicle 77)'); + assert.deepEqual(copy.details, [ + 'T(Urban rail) · T(Subway) · MBTA · Boston, MA', + 'T(T(Reported 2 min ago) (feed time))', + 'T(Shown at street level · depth not in the feed)', + 'T(Vehicle 77)', + ]); +}); + +test('a header-stamped report is feed time even with a timestamp', () => { + const { t } = fakeT(); + const copy = buildRailSelectionCopy( + feed, + { ...stampedTrain, timestampSource: 'header' }, + NOW, + NOW, + null, + t, + ); + assert.equal(copy.details[1], 'T(T(Reported 30 s ago) (feed time))'); +}); + +test('only metro trains carry the street-level note', () => { + const { t } = fakeT(); + const note = 'T(Shown at street level · depth not in the feed)'; + for (const mode of ['rail', 'tram']) { + const copy = buildRailSelectionCopy( + feed, + { ...unstampedMetro, mode }, + NOW, + NOW, + null, + t, + ); + assert.equal(copy.details.includes(note), false, mode); + } + const metro = buildRailSelectionCopy( + feed, + { ...stampedTrain, mode: 'metro', category: 'transit' }, + NOW, + NOW, + null, + t, + ); + assert.equal(metro.details.includes(note), true); +}); + +test('playback state adds the motion word and the display delay', () => { + const { t, calls } = fakeT(); + const entry = { + sample: { + latestReportAgeMs: 5_000, + actualDelayMs: 42_400, + phase: 'playing', + motion: 'stopped', + }, + }; + const copy = buildRailSelectionCopy(feed, stampedTrain, NOW, NOW, entry, t); + assert.equal(copy.details[1], 'T(Stopped)'); + assert.deepEqual(calls.find(({ text }) => text === 'Stopped').values, { + context: 'vehicle motion', + }); + assert.equal(copy.details[2], 'T(Reported 5 s ago) · T(shown 42 s behind)'); + + const moving = buildRailSelectionCopy( + feed, + stampedTrain, + NOW, + NOW, + { sample: { ...entry.sample, motion: 'moving' } }, + t, + ); + assert.equal(moving.details[1], 'T(Moving)'); +}); + +test('without a t() the card reads in plain English', () => { + const copy = buildRailSelectionCopy(feed, stampedTrain, NOW, NOW); + assert.equal(copy.title, '🚆 Route CR-Worcester'); + assert.equal(copy.details[0], 'Local rail · Train · MBTA · Boston, MA'); + assert.equal(copy.details[1], 'Reported 30 s ago'); +}); + +test('the selected card is accented in the category colour', () => { + const copy = { title: 'Route 550', details: ['Local rail'] }; + const position = () => ({ x: 1, y: 2, z: 3 }); + for (const category of RAIL_CATEGORIES) { + const card = createRailSelectedOverlayEntry( + 'rail:capmetro-rail:101', + position, + copy, + category, + ); + assert.equal(card.accent, RAIL_CATEGORY_COLORS[category], category); + } + const card = createRailSelectedOverlayEntry( + 'rail:capmetro-rail:101', + position, + copy, + 'bus', + ); + assert.equal(card.accent, RAIL_CATEGORY_COLORS.local); + assert.equal(card.id, 'rail:capmetro-rail:101'); + assert.equal(card.position, position); + assert.equal(card.title, 'Route 550'); + assert.deepEqual(card.details, ['Local rail']); + assert.equal(card.selected, true); +}); + +test('no card without a key or a position', () => { + const copy = { title: '', details: [] }; + assert.equal( + createRailSelectedOverlayEntry('', () => null, copy), + null, + ); + assert.equal(createRailSelectedOverlayEntry('rail:a:1', null, copy), null); +}); + +/** Two stops as the schedule pack's tripStops() lists them. */ +const tripStopsNearYonkers = Object.freeze([ + Object.freeze({ + stopId: 'riv', + name: 'Riverdale', + lat: 40.9, + lon: -73.91, + state: 'passed', + expectedArrival: '14:23', + expectedDeparture: '14:24', + arrivalDelaySec: 180, + departureDelaySec: 180, + relative: 'T+3', + }), + Object.freeze({ + stopId: 'yon', + name: 'Yonkers', + lat: 40.93, + lon: -73.9, + state: 'next', + atStop: false, + expectedArrival: '14:32', + expectedDeparture: '14:33', + arrivalDelaySec: 200, + departureDelaySec: 200, + relative: 'T−4', + }), +]); + +test('with the trip’s stops the card names the next and previous stops', () => { + const { t } = fakeT(); + const copy = buildRailSelectionCopy(feed, stampedTrain, NOW, NOW, null, t, { + stops: tripStopsNearYonkers, + headsign: 'Poughkeepsie', + }); + assert.equal(copy.title, '🚆 T(Route CR-Worcester) · T(To Poughkeepsie)'); + assert.deepEqual(copy.details, [ + 'T(Local rail) · T(Train) · MBTA · Boston, MA', + 'T(Next Yonkers 14:32 T−4) T((+3 min late))', + 'T(Prev Riverdale 14:24 T+3)', + 'T(Reported 30 s ago)', + 'T(reported 72 km/h) · T(reported hdg 90°)', + 'T(Vehicle 1712) · Train 1712', + ]); + // The feed's bare stop id is not repeated beside the stop names. + assert.ok(copy.details.every((line) => !line.includes('place-sstat'))); +}); + +test('an on-time next stop has no lateness, and English reads plainly', () => { + const onTime = tripStopsNearYonkers.map((stop) => ({ + ...stop, + arrivalDelaySec: 20, + })); + const copy = buildRailSelectionCopy( + feed, + stampedTrain, + NOW, + NOW, + null, + undefined, + { + stops: onTime, + }, + ); + assert.equal(copy.details[1], 'Next Yonkers 14:32 T−4'); + assert.equal(copy.details[2], 'Prev Riverdale 14:24 T+3'); +}); + +test('the card never grows past six lines', () => { + const { t } = fakeT(); + const entry = { + sample: { + latestReportAgeMs: 5_000, + actualDelayMs: 30_000, + phase: 'playing', + motion: 'moving', + }, + }; + const busy = { ...stampedTrain, mode: 'metro', positionSource: 'gps' }; + const copy = buildRailSelectionCopy(feed, busy, NOW, NOW, entry, t, { + stops: tripStopsNearYonkers, + }); + assert.equal(copy.details.length, RAIL_CARD_MAX_LINES); + assert.equal(RAIL_CARD_MAX_LINES, 6); + // What stays: who, motion, next, previous, report age and the depth note; + // the reported speed and the vehicle line give way first. + assert.deepEqual(copy.details, [ + 'T(Local rail) · T(Subway) · MBTA · Boston, MA', + 'T(Moving) · T(Live GPS)', + 'T(Next Yonkers 14:32 T−4) T((+3 min late))', + 'T(Prev Riverdale 14:24 T+3)', + 'T(Reported 5 s ago) · T(shown 30 s behind)', + 'T(Shown at street level · depth not in the feed)', + ]); + // Without stops a full card also stays within the limit. + const noStops = buildRailSelectionCopy(feed, busy, NOW, NOW, entry, t); + assert.ok(noStops.details.length <= RAIL_CARD_MAX_LINES); +}); + +test('the card says where the position comes from', () => { + const { t } = fakeT(); + const words = { + gps: 'T(Live GPS)', + event: 'T(Estimated from station reports)', + }; + for (const [positionSource, word] of Object.entries(words)) { + const copy = buildRailSelectionCopy( + feed, + { ...stampedTrain, positionSource }, + NOW, + NOW, + null, + t, + ); + // No playback state: the source stands on its own line. + assert.equal(copy.details[1], word, positionSource); + } + // A source the layer does not draw, a timetable's included, is never + // named as one. + for (const positionSource of ['psychic', 'schedule']) { + const unknown = buildRailSelectionCopy( + feed, + { ...stampedTrain, positionSource }, + NOW, + NOW, + null, + t, + ); + assert.equal(unknown.details[1], 'T(Reported 30 s ago)', positionSource); + } +}); + +test('only live positions are drawn: GPS and station reports', () => { + assert.deepEqual([...RAIL_LIVE_POSITION_SOURCES], ['gps', 'event']); + for (const source of ['gps', 'event', undefined, null]) + assert.equal(isLiveRailPositionSource(source), true, String(source)); + for (const source of ['schedule', 'psychic', '']) + assert.equal(isLiveRailPositionSource(source), false, source); +}); + +test('without a route the title prefers the headsign, then the label', () => { + const { t } = fakeT(); + const lirr = { ...stampedTrain, routeId: null, id: '7259', label: '7259' }; + assert.equal( + buildRailSelectionCopy(feed, lirr, NOW, NOW, null, t).title, + '🚆 T(Vehicle 7259)', + ); + assert.equal( + buildRailSelectionCopy( + feed, + { ...lirr, label: 'Train 2715' }, + NOW, + NOW, + null, + t, + ).title, + '🚆 Train 2715', + ); + assert.equal( + buildRailSelectionCopy( + feed, + { ...lirr, headsign: 'Ronkonkoma' }, + NOW, + NOW, + null, + t, + ).title, + '🚆 T(To Ronkonkoma)', + ); + assert.equal( + buildRailSelectionCopy(feed, lirr, NOW, NOW, null, t, { + headsign: 'Babylon', + stops: [], + }).title, + '🚆 T(To Babylon)', + ); + assert.equal( + buildRailSelectionCopy(feed, { ...lirr, label: null }, NOW, NOW, null, t) + .title, + '🚆 T(Vehicle 7259)', + ); +}); + +test('the card names a platform stop by its station', () => { + const stops = tripStopsNearYonkers.map((stop) => ({ + ...stop, + name: `${stop.name} Platform 2`, + parentName: `${stop.name} Station`, + })); + const copy = buildRailSelectionCopy( + feed, + stampedTrain, + NOW, + NOW, + null, + undefined, + { + stops, + }, + ); + assert.equal(copy.details[1], 'Next Yonkers Station 14:32 T−4 (+3 min late)'); + assert.equal(copy.details[2], 'Prev Riverdale Station 14:24 T+3'); +}); diff --git a/src/layers/rail/rendering.js b/src/layers/rail/rendering.js new file mode 100644 index 000000000..eff1ace98 --- /dev/null +++ b/src/layers/rail/rendering.js @@ -0,0 +1,724 @@ +import * as Cesium from 'cesium'; +import { applyDisplayCourse, updatePlayback } from '../transit/movement.js'; +import { HEIGHT_SAMPLE_MAX_ALTITUDE_M } from '../transit/policy.js'; +import { + COLD_CELL_FLUSH_MS, + COLD_CELL_RETRY_MS, + TRACK_MAX_GRADE, + TRACK_PRIOR_ABOVE_M, + TRACK_PRIOR_BELOW_M, + TRACK_SAMPLE_GRID, + TRACK_SAMPLE_MAX_CAMERA_M, + TRACK_SAMPLE_MAX_AGE_MS, + TRACK_SAMPLE_MAX_DIST_KM, + TRACK_SAMPLE_REFRESH_MS, + TRACK_SAMPLE_RETRY_MS, + TRACK_SAMPLES_PER_FLUSH, + TRACK_SETTLE_MPS, + TRACK_SNAP_M, + COLD_CELLS_PER_FLUSH, + FLOOR_CELLS_BURST, + FLOOR_CELLS_PER_MINUTE, + FLOOR_WARM_MAX_DIST_KM, + MARKER_PIXEL_SIZE, + RAIL_CATEGORY_COLORS, + SELECTED_PIXEL_SIZE, + UNSELECTED_TRAIN_ALPHA, +} from './policy.js'; +import { placeOnPath } from './pathBinding.js'; + +const SPRITE_PX = 64; +/** Billboard tints; the setter copies, so sharing one instance is safe. */ +const FULL_TINT = Cesium.Color.WHITE; +const DIM_TINT = new Cesium.Color(1, 1, 1, UNSELECTED_TRAIN_ALPHA); + +/** + * Draw one category/mode sprite: a filled disc in the category colour with a + * dark rim and a white glyph for what runs on the track (a train front, a + * tram with its pantograph, or the metro "M"). Drawn once per pair and reused. + * @param {string} category + * @param {string} mode + * @returns {HTMLCanvasElement} + */ +function drawSprite(category, mode) { + const canvas = document.createElement('canvas'); + canvas.width = SPRITE_PX; + canvas.height = SPRITE_PX; + const g = canvas.getContext('2d'); + const c = SPRITE_PX / 2; + g.beginPath(); + g.arc(c, c, c - 3, 0, Math.PI * 2); + g.fillStyle = RAIL_CATEGORY_COLORS[category] || RAIL_CATEGORY_COLORS.local; + g.fill(); + g.lineWidth = 4; + g.strokeStyle = 'rgba(10, 14, 20, 0.85)'; + g.stroke(); + g.fillStyle = '#ffffff'; + g.strokeStyle = '#ffffff'; + if (mode === 'metro') { + g.font = 'bold 34px sans-serif'; + g.textAlign = 'center'; + g.textBaseline = 'middle'; + g.fillText('M', c, c + 2); + return canvas; + } + // Train front: body, windscreen cut out, two lamps. + g.beginPath(); + g.roundRect(c - 13, c - 14, 26, 28, 6); + g.fill(); + g.fillStyle = RAIL_CATEGORY_COLORS[category] || RAIL_CATEGORY_COLORS.local; + g.fillRect(c - 9, c - 9, 18, 9); + g.beginPath(); + g.arc(c - 7, c + 7, 2.5, 0, Math.PI * 2); + g.arc(c + 7, c + 7, 2.5, 0, Math.PI * 2); + g.fill(); + if (mode === 'tram') { + g.lineWidth = 3; + g.beginPath(); + g.moveTo(c - 6, c - 14); + g.lineTo(c, c - 22); + g.lineTo(c + 6, c - 14); + g.stroke(); + } + return canvas; +} + +/** + * Markers, ground height and the per-frame pass for one Rail layer. + * @param {object} context + * @returns {object} + */ +export function createRendering({ state, services, parts }) { + const { + governorRequestRender, + holdContinuousRender, + releaseContinuousRender, + } = services.render; + const { + cachedGroundFloor, + warmGroundFloor, + GROUND_FLOOR_LIFT_M, + neighborFloorM = null, + coarseFloorCoord = null, + } = services.ground; + const sprites = new Map(); + const terrainPoint = new Cesium.Cartographic(); + const scratchPosition = new Cesium.Cartesian3(); + /** Scratch for the on-line position; place() runs every frame. */ + const pathPoint = { lat: NaN, lon: NaN, bearingDeg: NaN }; + + function spriteFor(category, mode) { + const id = `${category}:${mode}`; + if (!sprites.has(id)) sprites.set(id, drawSprite(category, mode)); + return sprites.get(id); + } + + /** Whether ground matters at this camera (Transit's gate, shared). */ + function nearGround() { + const height = state._viewer?.camera?.positionCartographic?.height; + return !Number.isFinite(height) || height <= HEIGHT_SAMPLE_MAX_ALTITUDE_M; + } + + /** Kilometres from the camera's subpoint, or Infinity without a camera. */ + function cameraKm(lat, lon) { + const carto = state._viewer?.camera?.positionCartographic; + if (!carto) return Infinity; + const camLat = Cesium.Math.toDegrees(carto.latitude); + const camLon = Cesium.Math.toDegrees(carto.longitude); + const kLon = Math.cos((camLat * Math.PI) / 180); + return Math.hypot(lat - camLat, (lon - camLon) * kLon) * 111.32; + } + + /** + * A number naming the cell of 1/`perDegree` degree that holds a point. A + * string key would be built for every moving train on every frame. + */ + function cellKey(lat, lon, perDegree) { + return ( + Math.round(lat * perDegree) * (362 * perDegree) + + Math.round(lon * perDegree) + ); + } + + /** + * Cells rail may still ask the floor service for: refilled at + * FLOOR_CELLS_PER_MINUTE, never holding more than FLOOR_CELLS_BURST. + */ + const floorBudget = { cells: FLOOR_CELLS_BURST, at: Date.now() }; + function takeFloorBudget(wanted) { + const now = Date.now(); + floorBudget.cells = Math.min( + FLOOR_CELLS_BURST, + floorBudget.cells + + ((now - floorBudget.at) / 60_000) * FLOOR_CELLS_PER_MINUTE, + ); + floorBudget.at = now; + const granted = Math.max( + 0, + Math.min(wanted, Math.floor(floorBudget.cells)), + ); + floorBudget.cells -= granted; + return granted; + } + /** + * Floor cells a train (or a stop of the selected trip) reached while they + * were cold, warmed in small batches off the frame path. `asked` + * remembers when a cell was last requested, so a frame loop over a cold + * cell does not re-request it. + */ + const coldCells = new Map(); + const asked = new Map(); + let coldTimer = null; + // Track heights on photorealistic tiles, sampled from the rendered mesh at + // points on the train's own line: the mesh under a draped line is the + // track bed or the deck. A floor cell's height is taken at the cell's + // centre, which can be a roof, a cutting's rim or a hillside well above + // the rails, so it is only the fallback. Keyed by bucket of + // 1/TRACK_SAMPLE_GRID degree; each sample keeps the camera height it was + // read from (`camM`, above the floor), when (`at`), and when it was last + // read again (`readAt`). + const trackSamples = new Map(); + const trackQueue = new Map(); + const trackAsked = new Map(); + const trackProbe = new Cesium.Cartographic(); + function cameraCartographic() { + return state._viewer?.camera?.positionCartographic ?? null; + } + /** + * Whether the photorealistic tiles have finished streaming for this view: + * there is a shown tileset, and every shown one reports its tiles loaded. + */ + function tilesLoaded(scene) { + const primitives = scene.primitives; + let found = false; + for (let i = 0; i < (primitives?.length || 0); i++) { + const primitive = primitives.get(i); + if (!primitive?.show || typeof primitive.tilesLoaded !== 'boolean') + continue; + if (!primitive.tilesLoaded) return false; + found = true; + } + return found; + } + function queueTrackSample(lat, lon, key, priority) { + const now = Date.now(); + if (now - (trackAsked.get(key) ?? -Infinity) < TRACK_SAMPLE_RETRY_MS) + return; + // The floor under it is the gross check against coarse tiles; with no + // floor yet, wait (the cold-cell queue is warming it). From higher up, + // the tiles near the camera are coarse. + const prior = cachedGroundFloor(lat, lon); + const height = cameraCartographic()?.height; + if ( + !Number.isFinite(prior) || + !Number.isFinite(height) || + height - prior > TRACK_SAMPLE_MAX_CAMERA_M + ) + return; + if (trackAsked.size > 8192) trackAsked.clear(); + trackAsked.set(key, now); + trackQueue.set(key, { lat, lon, priority }); + coldTimer ??= setTimeout(flushColdCells, COLD_CELL_FLUSH_MS); + } + function flushTrackSamples() { + const scene = state._viewer?.scene; + const carto = cameraCartographic(); + if ( + !trackQueue.size || + typeof scene?.sampleHeight !== 'function' || + !Number.isFinite(carto?.height) || + !tilesLoaded(scene) + ) { + trackQueue.clear(); + return; + } + const camLat = Cesium.Math.toDegrees(carto.latitude); + const camLon = Cesium.Math.toDegrees(carto.longitude); + const kLon = Math.cos((camLat * Math.PI) / 180); + const excludeObjects = state._markers ? [state._markers] : []; + const now = performance.now(); + // The selected train first, then the rest in the order they asked. + const items = [...trackQueue].sort( + (a, b) => Number(b[1].priority) - Number(a[1].priority), + ); + trackQueue.clear(); + let sampled = 0; + for (const [key, point] of items) { + if (sampled >= TRACK_SAMPLES_PER_FLUSH) break; + const dKm = + Math.hypot(point.lat - camLat, (point.lon - camLon) * kLon) * 111.32; + if (dKm > TRACK_SAMPLE_MAX_DIST_KM) continue; + const prior = cachedGroundFloor(point.lat, point.lon); + if (!Number.isFinite(prior)) continue; + const camM = carto.height - prior; + if (camM > TRACK_SAMPLE_MAX_CAMERA_M) continue; + let height; + try { + sampled += 1; + Cesium.Cartographic.fromDegrees(point.lon, point.lat, 0, trackProbe); + height = scene.sampleHeight(trackProbe, excludeObjects); + } catch { + continue; + } + const kept = trackSamples.get(key); + if (kept) kept.readAt = now; + if ( + !Number.isFinite(height) || + height < prior - TRACK_PRIOR_BELOW_M || + height > prior + TRACK_PRIOR_ABOVE_M + ) + continue; + // A reading from higher up saw coarser tiles: it replaces one read + // from lower down only once that one is old. + if (kept) { + if (camM > kept.camM && now - kept.at < TRACK_SAMPLE_MAX_AGE_MS) + continue; + kept.h = height; + kept.camM = camM; + kept.at = now; + } else trackSamples.set(key, { h: height, camM, at: now, readAt: now }); + } + if (trackSamples.size > 50_000) trackSamples.clear(); + if (sampled) governorRequestRender('rail-track-height'); + } + function flushColdCells() { + coldTimer = null; + const queued = [...coldCells.values()].sort( + (a, b) => Number(b.priority) - Number(a.priority) || a.km - b.km, + ); + coldCells.clear(); + const granted = takeFloorBudget( + Math.min(queued.length, COLD_CELLS_PER_FLUSH), + ); + // Cells left over are asked for again after COLD_CELL_RETRY_MS. + if (granted) warmGroundFloor(queued.slice(0, granted)); + flushTrackSamples(); + } + /** + * Queue a cold floor cell for warming. Only cells within + * FLOOR_WARM_MAX_DIST_KM of the camera are, unless `anyDistance` (the + * selected trip's stops, which are drawn wherever the camera is). + */ + function queueColdCell(lat, lon, priority = false, anyDistance = false) { + if (!nearGround()) return; + const km = cameraKm(lat, lon); + if (km > FLOOR_WARM_MAX_DIST_KM && !anyDistance) return; + const key = cellKey(lat, lon, 1000); + const now = Date.now(); + if (now - (asked.get(key) ?? -Infinity) < COLD_CELL_RETRY_MS) return; + if (asked.size > 4096) asked.clear(); + asked.set(key, now); + coldCells.set(key, { lat, lon, km, priority }); + coldTimer ??= setTimeout(flushColdCells, COLD_CELL_FLUSH_MS); + } + + /** Layer disabled: drop the queued work and the samples taken so far. */ + function reset() { + clearTimeout(coldTimer); + coldTimer = null; + coldCells.clear(); + asked.clear(); + trackQueue.clear(); + trackAsked.clear(); + trackSamples.clear(); + } + + /** floorHeight's answer, reused by every call: read it before the next. */ + const floorAnswer = { h: 0, q: 0 }; + function answer(h, q) { + floorAnswer.h = h; + floorAnswer.q = q; + return floorAnswer; + } + + /** + * Height to draw a train at: the rendered terrain under it on a terrain + * globe, else the shared ground floor (which tracks the photorealistic mesh + * where one is loaded). Where that cell is not warm yet, a neighbouring + * cell's floor, else the floor this train last stood on, while the cell is + * warmed: a train running onto unsampled ground keeps its height instead of + * dropping to the ellipsoid, which lies tens to hundreds of metres under + * the photorealistic mesh (about 150 m around Chicago). Only a train with + * no floor yet, or a stop, stands on the ellipsoid, until its cell is warm. + * @param {number} lat + * @param {number} lon + * @param {{floorM?: number, key?: string}|null} [entry] the train, which + * keeps its floor + * @param {boolean} [stop] a stop of the selected trip: its cell is warmed + * first, however far from the camera + * @returns {{h: number, q: number}} The height, and how good it is: 3 the + * rendered terrain, 2 the cell's floor, 1 a neighbouring cell's or the + * train's last floor, 0 the ellipsoid. The same object every call. + */ + /** The last coarse cell asked for a stop (stops have no entry). */ + const stopCell = { kLat: NaN, kLon: NaN, cell: null }; + + /** + * The point's coarse floor cell, kept on the train (and once for stops) + * while the point stays in the same 0.001° cell: a train gliding across a + * cold cell asks the floor service for it once, not every frame. + * @param {number} lat + * @param {number} lon + * @param {object|null} entry + * @returns {{lat: number, lon: number}} + */ + function coarseCellOf(lat, lon, entry) { + const memo = entry + ? (entry.coarseCell ??= { kLat: NaN, kLon: NaN, cell: null }) + : stopCell; + const kLat = Math.round(lat * 1000); + const kLon = Math.round(lon * 1000); + if (memo.kLat !== kLat || memo.kLon !== kLon) { + memo.kLat = kLat; + memo.kLon = kLon; + memo.cell = coarseFloorCoord(lat, lon); + } + return memo.cell; + } + + function floorHeight(lat, lon, entry = null, stop = false) { + const globe = state._viewer?.scene?.globe; + if (globe?.show === true) { + Cesium.Cartographic.fromDegrees(lon, lat, 0, terrainPoint); + const rendered = globe.getHeight?.(terrainPoint); + if (Number.isFinite(rendered)) + return answer(rendered + GROUND_FLOOR_LIFT_M, 3); + } + const floor = cachedGroundFloor(lat, lon); + if (Number.isFinite(floor)) { + if (entry) entry.floorM = floor; + return answer(floor + GROUND_FLOOR_LIFT_M, 2); + } + queueColdCell( + lat, + lon, + stop || (entry !== null && entry.key === state._selectedKey), + stop, + ); + const near = + neighborFloorM && coarseFloorCoord + ? neighborFloorM(coarseCellOf(lat, lon, entry)) + : null; + if (Number.isFinite(near)) return answer(near + GROUND_FLOOR_LIFT_M, 1); + return Number.isFinite(entry?.floorM) + ? answer(entry.floorM + GROUND_FLOOR_LIFT_M, 1) + : answer(0, 0); + } + function heightFor(lat, lon, entry = null) { + return floorHeight(lat, lon, entry).h; + } + + /** + * Where a stop of the selected trip stands, and how good that height is + * (floorHeight's quality): a stop read before its cell was warm is read + * again until it stands on a real floor (routeDisplay.js). + * @param {number} lat + * @param {number} lon + * @returns {{h: number, q: number}} + */ + function stopHeight(lat, lon) { + const { h, q } = floorHeight(lat, lon, null, true); + return { h, q }; + } + + /** + * The height a train is drawn at. Its target is the track sample under it + * on photorealistic tiles (else the floor chain of heightFor), and the + * drawn height moves toward it no faster than a railway climbs: + * TRACK_MAX_GRADE of the distance moved, plus TRACK_SETTLE_MPS over time. + * A single high sample (a roof, an overpass) is a bump, cell steps are + * ramps, and a followed camera rides smoothly. The drawn height snaps when + * a better source replaces a fallback (quality 0-1 to 2-3, or any floor + * replacing the ellipsoid, 0 to 1), and after a jump of more than + * TRACK_SNAP_M. + * @param {object} entry The train. + * @param {number} lat + * @param {number} lon + * @returns {number} + */ + function displayHeightFor(entry, lat, lon) { + const globe = state._viewer?.scene?.globe; + const now = performance.now(); + let targetH = NaN; + let targetQ = 0; + if (globe?.show !== true && nearGround()) { + const key = cellKey(lat, lon, TRACK_SAMPLE_GRID); + const sampled = trackSamples.get(key); + if (sampled) { + targetH = sampled.h + GROUND_FLOOR_LIFT_M; + targetQ = 3; + } + // A sample is read again now and then: from a lower camera, it + // replaces one read from coarser tiles. + if (!sampled || now - sampled.readAt >= TRACK_SAMPLE_REFRESH_MS) + queueTrackSample(lat, lon, key, entry.key === state._selectedKey); + } + if (!Number.isFinite(targetH)) { + const floor = floorHeight(lat, lon, entry); + targetH = floor.h; + targetQ = floor.q; + } + const prev = entry.drawnHeight; + let h = targetH; + if (prev && Number.isFinite(prev.h)) { + const kLon = Math.cos((lat * Math.PI) / 180); + const moved = + Math.hypot(lat - prev.lat, (lon - prev.lon) * kLon) * 111_320; + const better = + (prev.q === 0 && targetQ > 0) || (prev.q <= 1 && targetQ >= 2); + if (moved <= TRACK_SNAP_M && !better) { + const dt = Math.max(0, (now - prev.at) / 1000); + const step = TRACK_MAX_GRADE * moved + TRACK_SETTLE_MPS * dt; + h = prev.h + Math.max(-step, Math.min(step, targetH - prev.h)); + } + } + // Written in place: this runs for every moving train on every frame. + if (prev) { + prev.h = h; + prev.q = targetQ; + prev.lat = lat; + prev.lon = lon; + prev.at = now; + } else entry.drawnHeight = { h, q: targetQ, lat, lon, at: now }; + return h; + } + + /** Ask the floor service for the cells under one feed's trains; off the frame path. */ + function warmFloors(feedId) { + for (const entry of state._trains.values()) { + if (entry.feedId !== feedId) continue; + // A parked train's height was read when it was placed; floors warmed + // since (or finer terrain tiles) land on it here, once per poll. Moving + // trains re-read their height every frame already. + if (entry.marker && !state._moving.has(entry)) place(entry); + const { lat, lon } = entry.record; + if (!Number.isFinite(cachedGroundFloor(lat, lon))) + queueColdCell(lat, lon, entry.key === state._selectedKey); + } + } + + /** + * Whether a train is drawn: its category is on, and it is not a copy of a + * train another feed draws better (dedupe.js). + */ + function categoryShown(entry) { + return ( + state._categories[entry.category] !== false && entry.duplicate !== true + ); + } + + /** + * A train bound to its railway line is drawn on the line, at the arc its + * playback has reached; any other train (or a bound train whose reports do + * not fit the line) is drawn on the time-interpolated straight-line point. + */ + function place(entry) { + const sample = entry.sample; + const onPath = + entry.path && placeOnPath(entry.path, sample, entry.segment, pathPoint); + const lat = onPath + ? pathPoint.lat + : Number.isFinite(sample?.lat) + ? sample.lat + : entry.record.lat; + const lon = onPath + ? pathPoint.lon + : Number.isFinite(sample?.lon) + ? sample.lon + : entry.record.lon; + // Write into scratch, never into `marker.position`: the getter returns the + // billboard's own vector, so filling it in place makes the setter see an + // unchanged position and skip the update that actually moves the sprite. + entry.marker.position = Cesium.Cartesian3.fromDegrees( + lon, + lat, + displayHeightFor(entry, lat, lon), + undefined, + scratchPosition, + ); + entry.marker.show = categoryShown(entry); + } + + /** While a train is selected, every other train is drawn see-through. */ + function tintFor(entry) { + return state._selectedKey !== null && entry.key !== state._selectedKey + ? DIM_TINT + : FULL_TINT; + } + + function paint(entry) { + if (!entry.marker) return; + const selected = entry.key === state._selectedKey; + const size = selected ? SELECTED_PIXEL_SIZE : MARKER_PIXEL_SIZE; + entry.marker.image = spriteFor(entry.category, entry.railMode); + entry.marker.width = size; + entry.marker.height = size; + entry.marker.color = tintFor(entry); + entry.marker.show = categoryShown(entry); + } + + /** Re-tint every train after the selection appeared, moved or cleared. */ + function paintAll() { + for (const entry of state._trains.values()) paint(entry); + } + + function addMarker(entry) { + const marker = state._markers.add({ + id: entry.key, + position: Cesium.Cartesian3.ZERO, + image: spriteFor(entry.category, entry.railMode), + width: MARKER_PIXEL_SIZE, + height: MARKER_PIXEL_SIZE, + color: tintFor(entry), + disableDepthTestDistance: Number.POSITIVE_INFINITY, + }); + entry.marker = marker; + place(entry); + return marker; + } + + function cancelWake(entry) { + if (entry?.wakeTimer) clearTimeout(entry.wakeTimer); + if (entry) entry.wakeTimer = null; + } + + /** + * Track whether a train is mid-glide, so the frame pass walks only those. A + * train parked at a fix whose next segment starts later is woken by a timer + * at that boundary, not by polling every frame. + */ + function schedulePlayback(entry) { + cancelWake(entry); + if (!entry.marker) return; + place(entry); + const sample = entry.sample; + const moving = + categoryShown(entry) && + sample?.phase === 'playing' && + sample.segmentSpeedMps > 0; + if (moving) { + state._moving.add(entry); + return; + } + state._moving.delete(entry); + if (categoryShown(entry) && Number.isFinite(sample?.nextWakeMonoMs)) { + entry.wakeTimer = setTimeout( + () => { + entry.wakeTimer = null; + if (!state._enabled || !state._trains.has(entry.key)) return; + updatePlayback(entry, Date.now(), performance.now()); + schedulePlayback(entry); + syncRenderHold(); + governorRequestRender('rail-motion-boundary'); + }, + Math.max(1, sample.nextWakeMonoMs - performance.now()), + ); + } + } + + function syncRenderHold() { + const working = state._enabled && state._moving.size > 0; + if (working && !state._renderHeld) { + holdContinuousRender('rail'); + state._renderHeld = true; + } else if (!working && state._renderHeld) { + releaseContinuousRender('rail'); + state._renderHeld = false; + governorRequestRender('rail-settled'); + } + } + + const horizon = new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84); + const surfaceScratch = new Cesium.Cartesian3(); + + /** Whether a point on the Earth is on the camera's side of it. */ + function facesCamera(position) { + const viewer = state._viewer; + const cameraPosition = viewer?.camera?.positionWC; + if ( + !position || + !cameraPosition || + viewer.scene?.mode !== Cesium.SceneMode.SCENE3D + ) + return true; + const surface = Cesium.Ellipsoid.WGS84.scaleToGeodeticSurface( + position, + surfaceScratch, + ); + if (!surface) return true; + horizon.cameraPosition = cameraPosition; + return horizon.isPointVisible(surface); + } + + /** + * Markers are drawn over the terrain, never behind it, and the selected + * train is drawn at any distance (viewport.js): from far out, the globe + * would not hide it on its far side, so this pass does, with its stops. + */ + function cullSelected() { + const entry = state._trains.get(state._selectedKey); + if (!entry?.marker) return; + const shown = categoryShown(entry) && facesCamera(entry.marker.position); + if (entry.marker.show !== shown) entry.marker.show = shown; + parts.routes.cullStops(facesCamera); + } + + function onPreRender() { + advanceMoving(); + if (state._enabled && state._selectedKey !== null) cullSelected(); + } + + function advanceMoving() { + if (!state._enabled || state._moving.size === 0) { + syncRenderHold(); + return; + } + if (globalThis.document?.visibilityState === 'hidden') return; + const now = Date.now(); + const monoNow = performance.now(); + for (const entry of state._moving) { + if (!entry.marker) { + state._moving.delete(entry); + continue; + } + updatePlayback(entry, now, monoNow); + applyDisplayCourse(entry, monoNow); + place(entry); + if ( + entry.sample.phase !== 'playing' || + entry.sample.segmentSpeedMps === 0 + ) + schedulePlayback(entry); + } + syncRenderHold(); + } + + /** Re-apply category visibility to every marker after a toggle. */ + function applyCategoryVisibility() { + for (const entry of state._trains.values()) { + if (!entry.marker) continue; + entry.marker.show = categoryShown(entry); + schedulePlayback(entry); + } + syncRenderHold(); + parts.routes.applyNetwork(); + governorRequestRender('rail-categories'); + } + + return { + addMarker, + paint, + paintAll, + heightFor, + stopHeight, + displayHeightFor, + cancelWake, + schedulePlayback, + syncRenderHold, + onPreRender, + warmFloors, + applyCategoryVisibility, + categoryShown, + reset, + }; +} diff --git a/src/layers/rail/routeDisplay.js b/src/layers/rail/routeDisplay.js new file mode 100644 index 000000000..283d19295 --- /dev/null +++ b/src/layers/rail/routeDisplay.js @@ -0,0 +1,788 @@ +import * as Cesium from 'cesium'; +import { classifyTrain, getRegisteredRailFeed } from '../../data/railFeeds.js'; +import { + FEED_RELEASE_AFTER_MS, + NETWORK_ALPHA, + NETWORK_FULL_BELOW_M, + NETWORK_HIDDEN_ABOVE_M, + NETWORK_SELECTED_DIM, + NETWORK_WIDTH_PX, + RAIL_CATEGORY_COLORS, + ROUTE_AHEAD_WIDTH_PX, + ROUTE_SPLIT_INTERVAL_MS, + ROUTE_TRAVELLED_ALPHA, + STOP_REFRESH_MS, +} from './policy.js'; +import { + networkAltitudeFade, + railNetworkLineSteps, + splitFraction, +} from './routeLines.js'; +import { + RAIL_STOP_OVERLAY_SOURCE_ID, + RAIL_STOP_OVERLAY_SOURCE_OPTIONS, + createStopOverlayEntries, + selectStopLabels, +} from './stopLabels.js'; + +/** Longest slice of network building per task, so a frame is never held (ms). */ +const NETWORK_SLICE_MS = 6; +/** The fade is applied in steps, so a slow zoom does not rewrite it per event. */ +const FADE_STEP = 0.05; +/** A split that moved less than this share of the line is not redrawn. */ +const SPLIT_EPSILON = 1e-5; +/** Arc a route-bound train must cover before its direction is trusted (m). */ +const DIRECTION_EVIDENCE_M = 60; +const STOP_DOT_PX = 5; +const STOP_LABELLED_DOT_PX = 8; +const STOP_DOT_OUTLINE = Cesium.Color.fromCssColorString( + 'rgba(10, 14, 20, 0.9)', +); + +/** + * The selected line's material: solid ahead of the train, dimmer behind. + */ +function createSplitMaterial() { + return new Cesium.Material({ + fabric: { + type: 'RailRouteSplit', + uniforms: { + aheadColor: new Cesium.Color(1, 1, 1, 1), + travelledColor: new Cesium.Color(1, 1, 1, ROUTE_TRAVELLED_ALPHA), + splitAt: -1, + direction: 1, + }, + source: `czm_material czm_getMaterial(czm_materialInput materialInput) { + czm_material m = czm_getDefaultMaterial(materialInput); + float s = materialInput.st.s; + bool travelled = direction > 0.0 ? s < splitAt : s > splitAt; + vec4 c = travelled ? travelledColor : aheadColor; + // A dark rim keeps the line legible over bright imagery. + bool rim = abs(materialInput.st.t - 0.5) > 0.36; + m.diffuse = czm_gammaCorrect(rim ? c.rgb * 0.22 : c.rgb); + m.alpha = c.a; + return m; + }`, + }, + translucent: true, + }); +} + +/** + * Route lines for one Rail layer: the faint network of every active feed + * (built once per path pack), the selected train's line split where the train + * is, and the selected trip's stops as dots and labels. + * + * Nothing here runs per frame. The network's opacity follows the camera on + * camera events; the split and the stops follow the train on a timer that + * only runs while a train is selected. + * + * @param {object} context + * @returns {object} + */ +export function createRouteDisplay({ + state, + services, + parts, + stops, + timetable, +}) { + const { governorRequestRender } = services.render; + let groundSupported = null; + /** feedId → when the feed was last seen in use (ms). */ + const feedSeenAt = new Map(); + /** Releases idle feeds while the layer is off. */ + let releaseTimer = null; + let stopPoints = null; + let timer = null; + let ticks = 0; + + function scene() { + return state._viewer?.scene || null; + } + + function useGround() { + const s = scene(); + if (!s) return false; + if (groundSupported === null) + groundSupported = + !!s.frameState?.context && + Cesium.GroundPolylinePrimitive.isSupported(s); + return groundSupported; + } + + /** Positions for a flat `[lat, lon, ...]` run, with consecutive repeats dropped. */ + function positionsOf(coords) { + const out = []; + for (let i = 0; i + 1 < coords.length; i += 2) { + const point = Cesium.Cartesian3.fromDegrees(coords[i + 1], coords[i], 0); + if ( + out.length && + Cesium.Cartesian3.equalsEpsilon( + out[out.length - 1], + point, + Cesium.Math.EPSILON7, + ) + ) + continue; + out.push(point); + } + return out; + } + + /** One batched primitive of polylines, ground-clamped where the GPU allows. */ + function linePrimitive(runs, width, material) { + const ground = useGround(); + const instances = []; + for (const positions of runs) { + if (positions.length < 2) continue; + instances.push( + new Cesium.GeometryInstance({ + geometry: ground + ? new Cesium.GroundPolylineGeometry({ positions, width }) + : new Cesium.PolylineGeometry({ + positions, + width, + arcType: Cesium.ArcType.NONE, + vertexFormat: Cesium.PolylineMaterialAppearance.VERTEX_FORMAT, + }), + }), + ); + } + if (!instances.length) return null; + const appearance = new Cesium.PolylineMaterialAppearance({ + material, + translucent: true, + }); + const primitive = ground + ? new Cesium.GroundPolylinePrimitive({ + geometryInstances: instances, + appearance, + classificationType: Cesium.ClassificationType.BOTH, + allowPicking: false, + asynchronous: true, + }) + : new Cesium.Primitive({ + geometryInstances: instances, + appearance, + // Off the terrain, a line under a hill still shows through it. + depthFailAppearance: new Cesium.PolylineMaterialAppearance({ + material, + translucent: true, + }), + allowPicking: false, + asynchronous: false, + }); + return { + primitive, + collection: ground ? scene().groundPrimitives : scene().primitives, + ground, + }; + } + + function removePrimitive(slot) { + if (!slot?.primitive) return; + slot.collection?.remove(slot.primitive); + slot.primitive = null; + } + + // --------------------------------------------------------------------------- + // Network + + /** + * Start building a feed's network from its decoded pack, in slices between + * frames. Called once per pack by ingestion. + * @param {string} feedId + * @param {object} raw The pack's JSON body. + */ + function buildNetwork(feedId, raw) { + const previous = state._networks.get(feedId); + if (previous) dropNetwork(feedId); + const feed = getRegisteredRailFeed(feedId); + const categoryOf = (routeId) => + feed ? classifyTrain(feed, routeId)?.category || null : null; + const network = { + feedId, + status: 'building', + steps: railNetworkLineSteps(raw, categoryOf), + timer: null, + layers: new Map(), + stats: null, + }; + state._networks.set(feedId, network); + const slice = () => { + network.timer = null; + if (state._networks.get(feedId) !== network) return; + const started = performance.now(); + for (;;) { + const next = network.steps.next(); + if (next.done) { + network.steps = null; + finishNetwork(network, next.value); + return; + } + if (performance.now() - started > NETWORK_SLICE_MS) break; + } + network.timer = setTimeout(slice, 0); + }; + network.timer = setTimeout(slice, 0); + } + + function finishNetwork(network, result) { + network.status = 'ready'; + network.stats = { + shapes: result.shapesIn, + pointsIn: result.pointsIn, + lines: result.lines.length, + points: result.pointsOut, + }; + if (!scene()) return; + const byCategory = new Map(); + for (const line of result.lines) { + if (!byCategory.has(line.category)) byCategory.set(line.category, []); + byCategory.get(line.category).push(positionsOf(line.coords)); + } + for (const [category, runs] of byCategory) { + const color = Cesium.Color.fromCssColorString( + RAIL_CATEGORY_COLORS[category] || RAIL_CATEGORY_COLORS.local, + ); + const material = Cesium.Material.fromType('Color', { + color: color.withAlpha(0), + }); + const slot = linePrimitive(runs, NETWORK_WIDTH_PX, material); + if (!slot) continue; + slot.primitive.show = false; + slot.collection.add(slot.primitive); + network.layers.set(category, { + ...slot, + material, + base: color, + alpha: -1, + }); + } + applyNetwork(); + } + + function dropNetwork(feedId) { + const network = state._networks.get(feedId); + if (!network) return; + clearTimeout(network.timer); + network.timer = null; + network.steps = null; + for (const layer of network.layers.values()) removePrimitive(layer); + network.layers.clear(); + state._networks.delete(feedId); + } + + /** + * Show each feed's network while the feed is active, the category is on and + * the camera is low enough, at an opacity set by altitude and selection. + */ + function applyNetwork() { + const fade = state._networkFade; + const selected = state._selectedKey !== null; + let changed = false; + for (const network of state._networks.values()) { + // A feed held only for the selected train shows that train's line alone. + const active = + state._enabled && + state._activeFeeds.has(network.feedId) && + network.feedId !== state._heldFeedId; + for (const [category, layer] of network.layers) { + const alpha = + active && state._categories[category] !== false + ? NETWORK_ALPHA * fade * (selected ? NETWORK_SELECTED_DIM : 1) + : 0; + const show = alpha > 0.001; + if (layer.primitive.show !== show) { + layer.primitive.show = show; + changed = true; + } + if (show && Math.abs(layer.alpha - alpha) > 0.001) { + layer.material.uniforms.color = layer.base.withAlpha(alpha); + layer.alpha = alpha; + changed = true; + } + } + } + if (changed) governorRequestRender('rail-network'); + } + + /** Camera moved: recompute the altitude fade, applying it only on a step. */ + function onCamera() { + const altitude = parts.viewport.cameraAltitude(); + const raw = networkAltitudeFade( + altitude, + NETWORK_FULL_BELOW_M, + NETWORK_HIDDEN_ABOVE_M, + ); + const fade = Math.round(raw / FADE_STEP) * FADE_STEP; + if (fade === state._networkFade) return; + state._networkFade = fade; + applyNetwork(); + } + + // --------------------------------------------------------------------------- + // Selected route + + /** + * The shape the selected train runs on: its binding, else its route's + * longest. + */ + function routeShapeFor(entry) { + const pack = state._pathPacks.get(entry.feedId)?.pack; + if (entry.path?.path) + return { + shapeIndex: entry.path.shapeIndex, + path: entry.path.path, + binding: entry.path, + }; + const routeId = entry.record?.routeId; + if (!pack || !routeId) return null; + let best = null; + for (const index of pack.shapesForRoute(routeId)) { + const path = pack.pathAt(index); + if (path && (!best || path.length > best.path.length)) + best = { shapeIndex: index, path, binding: null }; + } + return best; + } + + function dropRoute() { + const route = state._route; + if (!route) return; + removePrimitive(route); + state._route = null; + } + + /** + * Draw (or keep) the selected train's line. Rebuilt only when the train + * moves to another shape; the split is a material uniform. + */ + function syncRoute() { + const entry = state._trains.get(state._selectedKey); + const shape = entry && state._enabled ? routeShapeFor(entry) : null; + if (!shape || !scene()) { + if (state._route) { + dropRoute(); + governorRequestRender('rail-route'); + } + return; + } + const id = `${entry.key}\n${entry.feedId}\n${shape.shapeIndex}`; + if (state._route?.id !== id) { + dropRoute(); + const { path } = shape; + const positions = []; + // Cumulative drawn length per path vertex. A ground line's texture + // coordinate runs by length along these chords; a plain polyline's runs + // by vertex, so there it counts the vertices Cesium keeps. + const ground = useGround(); + const chordAt = new Float64Array(path.n); + for (let i = 0; i < path.n; i++) { + const point = Cesium.Cartesian3.fromDegrees( + path.lon[i], + path.lat[i], + 0, + ); + const last = positions[positions.length - 1]; + const repeat = + last && + Cesium.Cartesian3.equalsEpsilon(last, point, Cesium.Math.EPSILON7); + if (i > 0) + chordAt[i] = + chordAt[i - 1] + + (repeat ? 0 : ground ? Cesium.Cartesian3.distance(last, point) : 1); + if (!repeat) positions.push(point); + } + const material = createSplitMaterial(); + const color = Cesium.Color.fromCssColorString( + RAIL_CATEGORY_COLORS[entry.category] || RAIL_CATEGORY_COLORS.local, + ); + material.uniforms.aheadColor = color.withAlpha(1); + material.uniforms.travelledColor = color.withAlpha(ROUTE_TRAVELLED_ALPHA); + const slot = linePrimitive([positions], ROUTE_AHEAD_WIDTH_PX, material); + if (!slot) return; + slot.collection.add(slot.primitive); + state._route = { + ...slot, + id, + material, + path, + chordAt, + binding: shape.binding, + // A trip's own shape runs the way the trip does; a route's shape may + // be drawn either way, so its direction is learnt from the train. + direction: shape.binding?.via === 'trip' ? 1 : 0, + firstS: shape.binding ? shape.binding.drawS : NaN, + fraction: NaN, + }; + governorRequestRender('rail-route'); + } else if (state._route.binding !== shape.binding) { + // Rebound to the same shape (a new trip on it): follow the new binding. + state._route.binding = shape.binding; + state._route.firstS = shape.binding ? shape.binding.drawS : NaN; + if (shape.binding?.via !== 'trip') state._route.direction = 0; + } + updateSplit(); + } + + /** Move the travelled/ahead split to where the train is drawn now. */ + function updateSplit() { + const route = state._route; + if (!route?.primitive) return; + const binding = route.binding; + const s = binding ? binding.drawS : NaN; + if (route.direction === 0 && Number.isFinite(s)) { + const moved = s - route.firstS; + if (Math.abs(moved) >= DIRECTION_EVIDENCE_M) + route.direction = Math.sign(moved); + } + const fraction = + route.direction !== 0 && Number.isFinite(s) + ? splitFraction(route.path, route.chordAt, s) + : NaN; + const uniforms = route.material.uniforms; + if (!Number.isFinite(fraction)) { + // No position on the line yet: draw it all as ahead. + if (uniforms.splitAt !== -1 || uniforms.direction !== 1) { + uniforms.splitAt = -1; + uniforms.direction = 1; + governorRequestRender('rail-route-split'); + } + route.fraction = NaN; + return; + } + if ( + Number.isFinite(route.fraction) && + Math.abs(fraction - route.fraction) < SPLIT_EPSILON && + uniforms.direction === route.direction + ) + return; + route.fraction = fraction; + uniforms.splitAt = fraction; + uniforms.direction = route.direction; + governorRequestRender('rail-route-split'); + } + + // --------------------------------------------------------------------------- + // Stops + + function stopPointsCollection() { + if (!stopPoints && scene()) { + stopPoints = new Cesium.PointPrimitiveCollection({ + blendOption: Cesium.BlendOption.TRANSLUCENT, + }); + scene().primitives.add(stopPoints); + } + return stopPoints; + } + + function clearStops() { + stopPoints?.removeAll(); + state._trip = null; + state._stopsSignature = ''; + state._stopLabelsSignature = ''; + state._stopAnchors.clear(); + state._overlayHost.setEntries( + RAIL_STOP_OVERLAY_SOURCE_ID, + [], + RAIL_STOP_OVERLAY_SOURCE_OPTIONS, + ); + } + + function anchorFor(stop) { + const key = `${stop.stopId ?? ''}\n${stop.lat}\n${stop.lon}`; + let anchor = state._stopAnchors.get(key); + if (!anchor) { + const { h, q } = parts.rendering.stopHeight(stop.lat, stop.lon); + anchor = { + lat: stop.lat, + lon: stop.lon, + h, + q, + position: Cesium.Cartesian3.fromDegrees(stop.lon, stop.lat, h), + }; + state._stopAnchors.set(key, anchor); + } + return anchor.position; + } + + /** + * Stops placed before their floor cell was warm stand on a fallback (the + * ellipsoid at worst, which is drawn over the terrain far off its station + * from a low camera): each refresh reads those again, and once one stands + * better its dot and label are drawn again there. + */ + function settleAnchors() { + let moved = false; + for (const anchor of state._stopAnchors.values()) { + if (anchor.q >= 2) continue; + const { h, q } = parts.rendering.stopHeight(anchor.lat, anchor.lon); + if (q < anchor.q || (q === anchor.q && Math.abs(h - anchor.h) < 1)) + continue; + anchor.h = h; + anchor.q = q; + anchor.position = Cesium.Cartesian3.fromDegrees( + anchor.lon, + anchor.lat, + h, + ); + moved = true; + } + if (moved) { + state._stopsSignature = ''; + state._stopLabelsSignature = ''; + } + } + + /** + * Hide the stop dots on the far side of the Earth. They are drawn over the + * terrain, and the selected trip is drawn at any distance (viewport.js), so + * the rendering pass hands over its horizon test every frame. + * @param {(position: object) => boolean} facesCamera + */ + function cullStops(facesCamera) { + if (!stopPoints) return; + for (let i = 0; i < stopPoints.length; i++) { + const point = stopPoints.get(i); + const shown = facesCamera(point.position); + if (point.show !== shown) point.show = shown; + } + } + + /** Ask the provider for the selected trip's stops and redraw what changed. */ + function refreshStops() { + const entry = state._trains.get(state._selectedKey); + if (!entry || !state._enabled || typeof stops?.stopsFor !== 'function') { + if (state._trip || state._stopsSignature) clearStops(); + return; + } + let trip = null; + try { + trip = stops.stopsFor( + { + feedId: entry.feedId, + tripId: entry.record?.tripId || null, + routeId: entry.record?.routeId || null, + record: entry.record, + }, + Date.now(), + ); + } catch (error) { + console.warn('[Data:Rail] stops unavailable:', error?.message || error); + } + const list = Array.isArray(trip?.stops) ? trip.stops : []; + state._trip = list.length || trip?.headsign ? trip : null; + settleAnchors(); + const { labels, dots } = selectStopLabels(list); + const color = Cesium.Color.fromCssColorString( + RAIL_CATEGORY_COLORS[entry.category] || RAIL_CATEGORY_COLORS.local, + ); + + const signature = `${entry.key}|${labels.map((l) => l.index).join(',')}|${dots + .map((d) => `${d.index}${d.passed ? 'p' : ''}`) + .join(',')}`; + if (signature !== state._stopsSignature) { + state._stopsSignature = signature; + const points = stopPointsCollection(); + if (points) { + points.removeAll(); + const add = (stop, passed, size) => + points.add({ + position: anchorFor(stop), + pixelSize: size, + color: passed ? color.withAlpha(0.45) : color, + outlineColor: STOP_DOT_OUTLINE, + outlineWidth: 1.5, + disableDepthTestDistance: Number.POSITIVE_INFINITY, + }); + for (const dot of dots) add(dot.stop, dot.passed, STOP_DOT_PX); + for (const label of labels) + add(label.stop, label.stop.state === 'passed', STOP_LABELLED_DOT_PX); + } + governorRequestRender('rail-stops'); + } + + const entries = createStopOverlayEntries(entry.key, labels, anchorFor); + const text = JSON.stringify( + entries.map((item) => [item.id, item.title, item.details, item.accent]), + ); + if (text !== state._stopLabelsSignature) { + state._stopLabelsSignature = text; + state._overlayHost.setEntries( + RAIL_STOP_OVERLAY_SOURCE_ID, + entries, + RAIL_STOP_OVERLAY_SOURCE_OPTIONS, + ); + } + } + + // --------------------------------------------------------------------------- + // Selection ticker + + function tick() { + if (globalThis.document?.visibilityState === 'hidden') return; + if (!state._enabled || !state._selectedKey) { + stopTicker(); + return; + } + ticks += 1; + syncRoute(); + if ((ticks * ROUTE_SPLIT_INTERVAL_MS) % STOP_REFRESH_MS === 0) { + refreshStops(); + parts.selection.refreshSelectedCard(false); + } + } + + function startTicker() { + if (timer) return; + ticks = 0; + timer = setInterval(tick, ROUTE_SPLIT_INTERVAL_MS); + } + + function stopTicker() { + clearInterval(timer); + timer = null; + } + + /** A train was selected, or the selection was cleared. */ + function onSelectionChanged() { + state._stopAnchors.clear(); + state._stopsSignature = ''; + state._stopLabelsSignature = ''; + const entry = state._enabled ? state._trains.get(state._selectedKey) : null; + // The provider loads the feed's timetable and polls its delays while a + // train on it is selected. + stops?.onSelect?.( + entry + ? { + feedId: entry.feedId, + tripId: entry.record?.tripId || null, + routeId: entry.record?.routeId || null, + record: entry.record, + } + : null, + ); + if (state._selectedKey && state._enabled) { + syncRoute(); + refreshStops(); + startTicker(); + } else { + stopTicker(); + dropRoute(); + clearStops(); + } + applyNetwork(); + governorRequestRender('rail-route'); + } + + /** + * Give back what a feed out of use for FEED_RELEASE_AFTER_MS holds: its + * faint network, its path pack and its parsed timetable. A feed in use + * (polled, or held for the selected train) keeps everything; a pack still + * loading is left to finish. Called on each poll tick, and once more while + * the layer is off. + * @param {number} nowMs + */ + function releaseIdleFeeds(nowMs) { + for (const feedId of state._activeFeeds.keys()) + feedSeenAt.set(feedId, nowMs); + const held = new Set([ + ...state._networks.keys(), + ...state._pathPacks.keys(), + ...feedSeenAt.keys(), + ]); + for (const feedId of held) { + if (state._activeFeeds.has(feedId)) continue; + const seen = feedSeenAt.get(feedId); + if (seen === undefined) { + feedSeenAt.set(feedId, nowMs); + continue; + } + if (nowMs - seen < FEED_RELEASE_AFTER_MS) continue; + if (state._pathPacks.get(feedId)?.status === 'loading') continue; + dropNetwork(feedId); + state._pathPacks.delete(feedId); + timetable?.forget?.(feedId); + feedSeenAt.delete(feedId); + } + } + + /** + * Layer disabled: hide everything. Built networks stay for a quick return, + * and are released once they have been out of use FEED_RELEASE_AFTER_MS. + */ + function hideAll() { + stops?.onSelect?.(null); + stopTicker(); + dropRoute(); + clearStops(); + applyNetwork(); + releaseIdleFeeds(Date.now()); + clearTimeout(releaseTimer); + releaseTimer = setTimeout(() => { + releaseTimer = null; + releaseIdleFeeds(Date.now()); + }, FEED_RELEASE_AFTER_MS); + } + + function destroy() { + hideAll(); + clearTimeout(releaseTimer); + releaseTimer = null; + feedSeenAt.clear(); + for (const feedId of [...state._networks.keys()]) dropNetwork(feedId); + if (stopPoints) { + scene()?.primitives?.remove(stopPoints); + stopPoints = null; + } + state._overlayHost.clearSource(RAIL_STOP_OVERLAY_SOURCE_ID); + } + + /** Counts for QA and the testing surface. */ + function stats() { + const feeds = {}; + for (const [feedId, network] of state._networks) { + feeds[feedId] = { + status: network.status, + primitives: network.layers.size, + shown: [...network.layers.values()].filter((l) => l.primitive.show) + .length, + ...(network.stats || {}), + }; + } + return { + fade: state._networkFade, + feeds, + route: state._route + ? { + id: state._route.id, + vertices: state._route.path.n, + fraction: state._route.fraction, + direction: state._route.direction, + ground: state._route.ground, + } + : null, + stops: { + points: stopPoints?.length || 0, + trip: state._trip ? state._trip.stops?.length || 0 : 0, + }, + }; + } + + return { + buildNetwork, + dropNetwork, + applyNetwork, + onCamera, + syncRoute, + refreshStops, + cullStops, + onSelectionChanged, + hideAll, + releaseIdleFeeds, + destroy, + stats, + }; +} diff --git a/src/layers/rail/routeLines.js b/src/layers/rail/routeLines.js new file mode 100644 index 000000000..ad86ea03c --- /dev/null +++ b/src/layers/rail/routeLines.js @@ -0,0 +1,335 @@ +/** + * @module layers/rail/routeLines + * @description Pure geometry for the Rail layer's route lines: the faint + * network a feed's path pack draws when zoomed in, and the split of a + * selected train's line into the part travelled and the part ahead. + * + * A pack lists every distinct GTFS shape, and most of them overlap: a + * commuter network's short turns, express patterns and branches share the + * trunk. Drawing each shape would stack hundreds of translucent lines on the + * same track, so the network is a union: shapes are walked longest first, and + * only the stretches no earlier shape already covered are kept. Each kept + * stretch is then thinned to the vertices its curve needs. + * + * Pure: no Cesium, no DOM. + */ + +/** Metres per degree of latitude (and of longitude at the equator). */ +const M_PER_DEG = 111_320; +/** Coverage grid cell (m). A track within about one cell is already drawn. */ +export const NETWORK_CELL_M = 40; +/** Sample spacing when walking a shape for coverage (m). */ +const NETWORK_STEP_M = 40; +/** Uncovered stretches shorter than this are jitter beside a drawn track (m). */ +export const NETWORK_MIN_RUN_M = 200; +/** Douglas-Peucker tolerance for the drawn stretches (m). */ +export const NETWORK_SIMPLIFY_M = 10; +/** Grid key offset, so negative cell indices stay distinct. */ +const KEY_OFFSET = 2 ** 21; + +/** Longitude difference wrapped into (-180, 180]. */ +function wrapLon(delta) { + return ((((delta + 180) % 360) + 360) % 360) - 180; +} + +/** Arc length of a flat `[lat, lon, ...]` shape in a local frame (m). */ +function shapeLength(coords, kx) { + let length = 0; + for (let i = 2; i + 1 < coords.length; i += 2) + length += Math.hypot( + wrapLon(coords[i + 1] - coords[i - 1]) * kx, + (coords[i] - coords[i - 2]) * M_PER_DEG, + ); + return length; +} + +/** + * Douglas-Peucker on a run of local-frame points, returning kept indices. + * Iterative, so a long run cannot overflow the stack. + */ +function simplifyIndices(xs, ys, tolerance) { + const n = xs.length; + if (n <= 2) return Array.from({ length: n }, (_, i) => i); + const keep = new Uint8Array(n); + keep[0] = 1; + keep[n - 1] = 1; + const tol2 = tolerance * tolerance; + const stack = [0, n - 1]; + while (stack.length) { + const last = stack.pop(); + const first = stack.pop(); + const ax = xs[first]; + const ay = ys[first]; + const dx = xs[last] - ax; + const dy = ys[last] - ay; + const len2 = dx * dx + dy * dy; + let worst = -1; + let worstD2 = tol2; + for (let i = first + 1; i < last; i++) { + let t = len2 > 0 ? ((xs[i] - ax) * dx + (ys[i] - ay) * dy) / len2 : 0; + t = t < 0 ? 0 : t > 1 ? 1 : t; + const ex = ax + dx * t - xs[i]; + const ey = ay + dy * t - ys[i]; + const d2 = ex * ex + ey * ey; + if (d2 > worstD2) { + worstD2 = d2; + worst = i; + } + } + if (worst < 0) continue; + keep[worst] = 1; + stack.push(first, worst, worst, last); + } + const out = []; + for (let i = 0; i < n; i++) if (keep[i]) out.push(i); + return out; +} + +/** + * The category each shape is drawn in: that of the first route listing it + * whose category is known. Shapes no classified route lists are not drawn + * (a pack can carry shapes of routes the layer filters out). + * @param {Record} routes routeId → shape indices. + * @param {number} shapeCount + * @param {(routeId: string) => string|null} categoryOf + * @returns {(string|null)[]} + */ +export function shapeCategories(routes, shapeCount, categoryOf) { + const out = new Array(shapeCount).fill(null); + for (const [routeId, list] of Object.entries(routes || {})) { + if (!Array.isArray(list)) continue; + let category; + for (const index of list) { + if (!Number.isInteger(index) || index < 0 || index >= shapeCount) + continue; + if (out[index] !== null) continue; + if (category === undefined) category = categoryOf(routeId) || null; + if (category) out[index] = category; + } + } + return out; +} + +/** + * The union of a pack's shapes as drawable stretches, each tagged with the + * category of the route it came from. + * + * @param {{shapes: ArrayLike[], routes?: Record}} raw + * A decoded path pack (the JSON body, not the indexed wrapper). + * @param {(routeId: string) => string|null} categoryOf Route → category, or + * null for a route the layer does not draw. + * @param {{cellM?: number, minRunM?: number, simplifyM?: number}} [options] + * @returns {{lines: {category: string, coords: Float64Array}[], + * shapesIn: number, pointsIn: number, pointsOut: number}} + */ +export function buildRailNetworkLines(raw, categoryOf, options = {}) { + const steps = railNetworkLineSteps(raw, categoryOf, options); + for (;;) { + const next = steps.next(); + if (next.done) return next.value; + } +} + +/** + * The same union as buildRailNetworkLines(), one shape per step, so a large + * pack can be built in slices between frames instead of in one long task. + * @param {object} raw + * @param {(routeId: string) => string|null} categoryOf + * @param {object} [options] + * @returns {Generator>} + */ +export function* railNetworkLineSteps(raw, categoryOf, options = {}) { + const cellM = options.cellM ?? NETWORK_CELL_M; + const minRunM = options.minRunM ?? NETWORK_MIN_RUN_M; + const simplifyM = options.simplifyM ?? NETWORK_SIMPLIFY_M; + const shapes = Array.isArray(raw?.shapes) ? raw.shapes : []; + const categories = shapeCategories(raw?.routes, shapes.length, categoryOf); + let minLat = Infinity; + let maxLat = -Infinity; + let pointsIn = 0; + const order = []; + for (let index = 0; index < shapes.length; index++) { + const coords = shapes[index]; + if (!categories[index] || !(coords?.length >= 4)) continue; + for (let i = 0; i + 1 < coords.length; i += 2) { + if (coords[i] < minLat) minLat = coords[i]; + if (coords[i] > maxLat) maxLat = coords[i]; + } + pointsIn += coords.length / 2; + order.push(index); + } + if (!order.length) return { lines: [], shapesIn: 0, pointsIn, pointsOut: 0 }; + const lat0 = (minLat + maxLat) / 2; + const lon0 = shapes[order[0]][1]; + const kx = M_PER_DEG * Math.cos((lat0 * Math.PI) / 180); + const lengths = new Map( + order.map((index) => [index, shapeLength(shapes[index], kx)]), + ); + order.sort((a, b) => lengths.get(b) - lengths.get(a)); + + const covered = new Set(); + const lines = []; + let pointsOut = 0; + // Reused per shape: the densified walk in the local frame. + let xs = []; + let ys = []; + let cells = []; + const rowWidth = KEY_OFFSET * 2; + const cellKey = (x, y) => + (Math.floor(x / cellM) + KEY_OFFSET) * rowWidth + + (Math.floor(y / cellM) + KEY_OFFSET); + // Own cell first: on an already drawn track that is almost always the hit, + // so a covered sample costs one lookup and only new track pays for nine. + const around = [ + -rowWidth, + rowWidth, + -1, + 1, + -rowWidth - 1, + -rowWidth + 1, + rowWidth - 1, + rowWidth + 1, + ]; + const isCovered = (key) => { + if (covered.has(key)) return true; + for (let i = 0; i < around.length; i++) + if (covered.has(key + around[i])) return true; + return false; + }; + + function emit(category, from, to) { + const runXs = xs.slice(from, to + 1); + const runYs = ys.slice(from, to + 1); + let length = 0; + for (let i = 1; i < runXs.length; i++) + length += Math.hypot(runXs[i] - runXs[i - 1], runYs[i] - runYs[i - 1]); + if (length < minRunM) return; + const kept = simplifyIndices(runXs, runYs, simplifyM); + const coords = new Float64Array(kept.length * 2); + for (let k = 0; k < kept.length; k++) { + const i = kept[k]; + coords[2 * k] = runYs[i] / M_PER_DEG + lat0; + let lon = runXs[i] / kx + lon0; + if (lon > 180) lon -= 360; + else if (lon < -180) lon += 360; + coords[2 * k + 1] = lon; + } + pointsOut += kept.length; + lines.push({ category, coords }); + } + + for (const index of order) { + const coords = shapes[index]; + xs = []; + ys = []; + for (let i = 0; i + 1 < coords.length; i += 2) { + const x = wrapLon(coords[i + 1] - lon0) * kx; + const y = (coords[i] - lat0) * M_PER_DEG; + if (xs.length) { + const px = xs[xs.length - 1]; + const py = ys[ys.length - 1]; + const steps = Math.ceil(Math.hypot(x - px, y - py) / NETWORK_STEP_M); + for (let k = 1; k < steps; k++) { + xs.push(px + ((x - px) * k) / steps); + ys.push(py + ((y - py) * k) / steps); + } + } + xs.push(x); + ys.push(y); + } + cells = new Array(xs.length); + let runStart = -1; + for (let i = 0; i < xs.length; i++) { + cells[i] = cellKey(xs[i], ys[i]); + const free = !isCovered(cells[i]); + if (free && runStart < 0) runStart = Math.max(0, i - 1); + else if (!free && runStart >= 0) { + emit(categories[index], runStart, i); + runStart = -1; + } + } + if (runStart >= 0) emit(categories[index], runStart, xs.length - 1); + // Marked after the walk, not during it: consecutive samples of one shape + // sit in neighbouring cells, so marking as it goes would cover the shape + // against itself. + for (const key of cells) covered.add(key); + yield; + } + return { lines, shapesIn: order.length, pointsIn, pointsOut }; +} + +/** + * The fraction of a polyline, by cumulative chord length, at which arc `s` + * of a rail path falls. The renderer draws the path's own vertices, so the + * fraction is measured on those vertices: segment `i` of the path holds the + * arc range `[path.s[i], path.s[i+1]]` and the same chord in the drawn line. + * + * @param {{n: number, s: ArrayLike}} path From createRailPath. + * @param {ArrayLike} chordAt Cumulative drawn length per vertex + * (same length as the path), e.g. from cumulativeChords(). + * @param {number} s Arc length (m) on the path. + * @returns {number} 0..1, or NaN when either input is unusable. + */ +export function splitFraction(path, chordAt, s) { + if (!path || !chordAt || chordAt.length !== path.n || !Number.isFinite(s)) + return NaN; + const total = chordAt[path.n - 1]; + if (!(total > 0)) return NaN; + const arc = path.s; + if (s <= arc[0]) return 0; + if (s >= arc[path.n - 1]) return 1; + let lo = 0; + let hi = path.n - 2; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (arc[mid] <= s) lo = mid; + else hi = mid - 1; + } + const span = arc[lo + 1] - arc[lo]; + const t = span > 0 ? (s - arc[lo]) / span : 0; + const chord = chordAt[lo] + (chordAt[lo + 1] - chordAt[lo]) * t; + return Math.max(0, Math.min(1, chord / total)); +} + +/** + * Cumulative length per vertex of a path, measured on the path's own local + * frame (x, y). This is what splitFraction() needs when the drawn line's + * texture coordinate runs by length, as a ground polyline's does. + * @param {{n: number, x: ArrayLike, y: ArrayLike}} path + * @returns {Float64Array} + */ +export function cumulativeChords(path) { + const out = new Float64Array(path?.n || 0); + for (let i = 1; i < out.length; i++) + out[i] = + out[i - 1] + + Math.hypot(path.x[i] - path.x[i - 1], path.y[i] - path.y[i - 1]); + return out; +} + +/** + * Vertex-index positions: what splitFraction() needs when the drawn line's + * texture coordinate runs by vertex index, as a plain PolylineGeometry's does. + * @param {{n: number}} path + * @returns {Float64Array} + */ +export function vertexIndexChords(path) { + const out = new Float64Array(path?.n || 0); + for (let i = 0; i < out.length; i++) out[i] = i; + return out; +} + +/** + * The faint network's opacity at a camera altitude: full below `fullBelowM`, + * none above `hiddenAboveM`, linear between. + * @param {number} altitudeM + * @param {number} fullBelowM + * @param {number} hiddenAboveM + * @returns {number} 0..1 + */ +export function networkAltitudeFade(altitudeM, fullBelowM, hiddenAboveM) { + if (!Number.isFinite(altitudeM)) return 0; + if (altitudeM <= fullBelowM) return 1; + if (altitudeM >= hiddenAboveM) return 0; + return (hiddenAboveM - altitudeM) / (hiddenAboveM - fullBelowM); +} diff --git a/src/layers/rail/routeLines.test.mjs b/src/layers/rail/routeLines.test.mjs new file mode 100644 index 000000000..d16b2d06a --- /dev/null +++ b/src/layers/rail/routeLines.test.mjs @@ -0,0 +1,331 @@ +// The Rail layer's route-line geometry: the network union a path pack draws +// as faint lines, and where a selected train's line splits into the part +// travelled and the part ahead; and, on a whole layer, how the selected +// train's line is drawn and released. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { FEED_RELEASE_AFTER_MS, railTrainKey } from './policy.js'; +import { SCHEDULE_RETRY_FIRST_MS } from './schedules.js'; +import { + BOSTON, + railLayerHarness as harness, + railTrain as train, +} from '../../testSupport/railLayerHarness.mjs'; +import { + NETWORK_MIN_RUN_M, + buildRailNetworkLines, + cumulativeChords, + networkAltitudeFade, + railNetworkLineSteps, + shapeCategories, + splitFraction, + vertexIndexChords, +} from './routeLines.js'; +import { + createRailPath, + pointAtS, + projectOnPath, +} from '../../data/railPaths.js'; + +const M_PER_DEG = 111_320; +const LAT = 42.36; +const KX = M_PER_DEG * Math.cos((LAT * Math.PI) / 180); + +/** A flat [lat, lon, ...] line due east from `lon0`, one vertex per `stepM`. */ +function eastLine(lon0, lengthM, stepM = 100, lat = LAT) { + const out = []; + for (let d = 0; d <= lengthM + 1e-6; d += stepM) out.push(lat, lon0 + d / KX); + return out; +} + +/** Total length of a flat line (m), measured like the module does. */ +function lengthOf(coords) { + let length = 0; + for (let i = 2; i < coords.length; i += 2) + length += Math.hypot( + (coords[i + 1] - coords[i - 1]) * KX, + (coords[i] - coords[i - 2]) * M_PER_DEG, + ); + return length; +} + +const everyRoute = () => 'regional'; + +test('shape categories come from the first classified route listing them', () => { + const categories = shapeCategories( + { bus: [0], 'CR-A': [1, 2], 'CR-B': [2, 3], bad: [99, -1, 'x'] }, + 4, + (route) => + route === 'bus' ? null : route === 'CR-A' ? 'local' : 'national', + ); + assert.deepEqual(categories, [null, 'local', 'local', 'national']); +}); + +test('overlapping shapes are drawn once: a short turn adds nothing', () => { + const full = eastLine(-71.2, 20_000); + const shortTurn = eastLine(-71.2, 8_000, 250); // other vertex spacing too + const result = buildRailNetworkLines( + { shapes: [shortTurn, full], routes: { r: [0, 1] } }, + everyRoute, + ); + assert.equal(result.shapesIn, 2); + assert.equal(result.lines.length, 1); + assert.equal(result.lines[0].category, 'regional'); + // The longest shape is walked first, so the one line spans all of it. + assert.ok(Math.abs(lengthOf(result.lines[0].coords) - 20_000) < 60); + // A straight track keeps only its ends. + assert.equal(result.lines[0].coords.length, 4); + assert.ok(result.pointsOut < result.pointsIn); +}); + +test('a branch adds only the stretch the trunk does not already cover', () => { + const trunk = eastLine(-71.2, 20_000); + // Shares the trunk's first 10 km, then turns north for 5 km. + const branch = eastLine(-71.2, 10_000); + const turnLon = branch[branch.length - 1]; + for (let d = 100; d <= 5_000; d += 100) + branch.push(LAT + d / M_PER_DEG, turnLon); + const result = buildRailNetworkLines( + { shapes: [trunk, branch], routes: { a: [0], b: [1] } }, + (route) => (route === 'a' ? 'local' : 'regional'), + ); + assert.equal(result.lines.length, 2); + const [first, second] = result.lines; + assert.equal(first.category, 'local'); + assert.equal(second.category, 'regional'); + const branchLength = lengthOf(second.coords); + // The new stretch is the 5 km spur, joined to the trunk by a sample or so. + assert.ok(branchLength > 4_900 && branchLength < 5_200, String(branchLength)); +}); + +test('a parallel track beside a drawn one is jitter, not a new line', () => { + const a = eastLine(-71.2, 10_000); + const b = eastLine(-71.2, 9_000, 100, LAT + 15 / M_PER_DEG); // 15 m north + const result = buildRailNetworkLines( + { shapes: [a, b], routes: { r: [0, 1] } }, + everyRoute, + ); + assert.equal(result.lines.length, 1); +}); + +test('uncovered stretches shorter than the minimum run are dropped', () => { + const trunk = eastLine(-71.2, 10_000); + // Leaves the trunk for a stub shorter than the minimum, then ends. + const stub = eastLine(-71.2, 5_000); + const endLon = stub[stub.length - 1]; + const stubM = NETWORK_MIN_RUN_M / 2; + for (let d = 20; d <= stubM; d += 20) stub.push(LAT - d / M_PER_DEG, endLon); + const result = buildRailNetworkLines( + { shapes: [trunk, stub], routes: { r: [0, 1] } }, + everyRoute, + ); + assert.equal(result.lines.length, 1); +}); + +test('shapes of routes the layer does not draw are left out', () => { + const result = buildRailNetworkLines( + { shapes: [eastLine(-71.2, 5_000)], routes: { bus: [0] } }, + () => null, + ); + assert.deepEqual(result.lines, []); + assert.equal(result.shapesIn, 0); + assert.deepEqual(buildRailNetworkLines(null, everyRoute).lines, []); +}); + +test('the step builder yields once per shape and ends with the same union', () => { + const raw = { + shapes: [ + eastLine(-71.2, 20_000), + eastLine(-71.2, 8_000), + eastLine(-71.3, 3_000), + ], + routes: { r: [0, 1, 2] }, + }; + const steps = railNetworkLineSteps(raw, everyRoute); + let yields = 0; + let next = steps.next(); + while (!next.done) { + yields += 1; + next = steps.next(); + } + assert.equal(yields, 3); + assert.deepEqual( + next.value.lines.map((line) => Array.from(line.coords)), + buildRailNetworkLines(raw, everyRoute).lines.map((line) => + Array.from(line.coords), + ), + ); +}); + +test('the split falls where the train is on its line, by drawn length', () => { + // An L-shaped line: 3 km east, then 1 km north, with uneven vertices. + const coords = eastLine(-71.2, 3_000, 500); + const cornerLon = coords[coords.length - 1]; + for (let d = 100; d <= 1_000; d += 100) + coords.push(LAT + d / M_PER_DEG, cornerLon); + const path = createRailPath(coords); + const chords = cumulativeChords(path); + assert.equal(chords.length, path.n); + assert.ok(Math.abs(chords[path.n - 1] - path.length) < 1e-6); + + // A train reported 1.5 km along, a little off the track. + const hit = projectOnPath(path, LAT + 8 / M_PER_DEG, -71.2 + 1_500 / KX); + assert.ok(Math.abs(hit.s - 1_500) < 1); + const fraction = splitFraction(path, chords, hit.s); + assert.ok(Math.abs(fraction - 1_500 / 4_000) < 1e-3); + + // The drawn point at the split is the train's point on the line. + const at = pointAtS(path, fraction * path.length); + assert.ok(Math.abs(at.lon - (-71.2 + 1_500 / KX)) < 1e-6); + + // Ends and out-of-range arcs clamp; bad input is NaN, not a guess. + assert.equal(splitFraction(path, chords, -50), 0); + assert.equal(splitFraction(path, chords, path.length + 50), 1); + assert.ok(Number.isNaN(splitFraction(path, chords, NaN))); + assert.ok(Number.isNaN(splitFraction(path, new Float64Array(2), 10))); + assert.ok(Number.isNaN(splitFraction(null, chords, 10))); +}); + +test('by vertex index, the split follows a line drawn per vertex', () => { + // Vertices at 0, 500, 2500 m: by index the middle vertex is halfway. + const coords = [LAT, -71.2, LAT, -71.2 + 500 / KX, LAT, -71.2 + 2_500 / KX]; + const path = createRailPath(coords); + const byIndex = vertexIndexChords(path); + assert.deepEqual(Array.from(byIndex), [0, 1, 2]); + assert.ok(Math.abs(splitFraction(path, byIndex, 500) - 0.5) < 1e-9); + // 1500 m is halfway along the second segment: index 1.5 of 2. + assert.ok(Math.abs(splitFraction(path, byIndex, 1_500) - 0.75) < 1e-9); + // By length the same arc is 0.6 of the line. + assert.ok( + Math.abs(splitFraction(path, cumulativeChords(path), 1_500) - 0.6) < 1e-6, + ); +}); + +test('the network fades out between its two altitudes', () => { + assert.equal(networkAltitudeFade(15_000, 150_000, 200_000), 1); + assert.equal(networkAltitudeFade(150_000, 150_000, 200_000), 1); + assert.equal(networkAltitudeFade(175_000, 150_000, 200_000), 0.5); + assert.equal(networkAltitudeFade(200_000, 150_000, 200_000), 0); + assert.equal(networkAltitudeFade(3_000_000, 150_000, 200_000), 0); + assert.equal(networkAltitudeFade(Infinity, 150_000, 200_000), 0); + assert.equal(networkAltitudeFade(NaN, 150_000, 200_000), 0); +}); + +test('the selected train’s line is its trip’s shape, else its route’s longest', async (t) => { + const pack = { + version: 1, + feedId: 'mbta-rail', + shapes: [eastLine(-71.08, 3000), eastLine(-71.08, 3000, 3000, LAT + 0.01)], + dist: [[], []], + shapeSources: ['operator', 'operator'], + trips: { 'trip-T1': 0 }, + routes: { R1: [0], R2: [1] }, + }; + const app = harness(t, { paths: { 'mbta-rail': pack } }); + app.feeds.set('mbta-rail', () => [ + train('T1', LAT, -71.07, { routeId: 'R1' }), + train('T2', LAT + 0.01, -71.07, { routeId: 'R2' }), + ]); + await app.enable(); + for (const [id, shapeIndex, vertices] of [ + ['T1', 0, 31], + ['T2', 1, 2], + ]) { + const key = railTrainKey('mbta-rail', id); + app.parts().selection.selectTrain(key); + const { route } = app.layer.getRouteDisplayStats(); + assert.equal( + route?.id, + `${key} +mbta-rail +${shapeIndex}`, + id, + ); + assert.equal(route.vertices, vertices, id); + } +}); + +/** A one-shape Boston pack for mbta-rail. */ +const BOSTON_PACK = Object.freeze({ + version: 1, + feedId: 'mbta-rail', + shapes: [eastLine(-71.08, 3000)], + dist: [[]], + trips: {}, + routes: { R1: [0] }, +}); +const OCEAN = { lat: 30, lon: -40 }; +const mbtaPaths = (app) => + app.requestedPaths.filter((id) => id === 'mbta-rail').length; + +test('a path pack the server cannot read now is asked for again on a stepped delay', async (t) => { + t.mock.method(console, 'warn', () => {}); + const paths = { 'mbta-rail': 503 }; + const app = harness(t, { paths }); + app.feeds.set('mbta-rail', () => []); + await app.enable(); + assert.equal(mbtaPaths(app), 1); + await app.poll(); + assert.equal(mbtaPaths(app), 1, 'not on every poll'); + t.mock.timers.tick(SCHEDULE_RETRY_FIRST_MS); + await app.poll(); + assert.equal(mbtaPaths(app), 2, 'again after 30 s'); + // The second failure waits twice as long. + t.mock.timers.tick(SCHEDULE_RETRY_FIRST_MS); + await app.poll(); + assert.equal(mbtaPaths(app), 2); + paths['mbta-rail'] = BOSTON_PACK; + t.mock.timers.tick(SCHEDULE_RETRY_FIRST_MS); + await app.poll(); + assert.equal(mbtaPaths(app), 3); + assert.equal(app.state()._pathPacks.get('mbta-rail').status, 'ready'); +}); + +test('a feed out of use for ten minutes gives back its network and packs', async (t) => { + const app = harness(t, { paths: { 'mbta-rail': BOSTON_PACK } }); + app.feeds.set('mbta-rail', () => []); + await app.enable(); + t.mock.timers.tick(0); + assert.ok(app.state()._networks.has('mbta-rail')); + assert.equal(app.state()._pathPacks.get('mbta-rail').status, 'ready'); + // Away from Boston: kept for a quick return... + await app.moveCamera(OCEAN); + assert.ok(!app.state()._activeFeeds.has('mbta-rail')); + t.mock.timers.tick(FEED_RELEASE_AFTER_MS - 60_000); + await app.poll(); + assert.ok(app.state()._networks.has('mbta-rail')); + assert.ok(app.state()._pathPacks.has('mbta-rail')); + // ...and released after ten minutes out of use. + t.mock.timers.tick(60_000); + await app.poll(); + assert.ok(!app.state()._networks.has('mbta-rail')); + assert.ok(!app.state()._pathPacks.has('mbta-rail')); + // Back in Boston, the pack loads again. + await app.moveCamera(BOSTON); + assert.equal(mbtaPaths(app), 2); +}); + +test('a feed in use keeps its network however long it runs', async (t) => { + const app = harness(t, { paths: { 'mbta-rail': BOSTON_PACK } }); + app.feeds.set('mbta-rail', () => []); + await app.enable(); + t.mock.timers.tick(0); + for (let i = 0; i < 3; i += 1) { + t.mock.timers.tick(FEED_RELEASE_AFTER_MS); + await app.poll(); + } + assert.ok(app.state()._networks.has('mbta-rail')); + assert.equal(mbtaPaths(app), 1); +}); + +test('with the layer off, its feeds are released after ten minutes', async (t) => { + const app = harness(t, { paths: { 'mbta-rail': BOSTON_PACK } }); + app.feeds.set('mbta-rail', () => []); + await app.enable(); + t.mock.timers.tick(0); + app.layer.disable(app.viewer); + assert.ok(app.state()._networks.has('mbta-rail'), 'kept for a quick return'); + t.mock.timers.tick(FEED_RELEASE_AFTER_MS); + assert.ok(!app.state()._networks.has('mbta-rail')); + assert.ok(!app.state()._pathPacks.has('mbta-rail')); +}); diff --git a/src/layers/rail/schedules.js b/src/layers/rail/schedules.js new file mode 100644 index 000000000..8e6a5e94c --- /dev/null +++ b/src/layers/rail/schedules.js @@ -0,0 +1,518 @@ +/** + * @module layers/rail/schedules + * @description The Rail layer's schedule tier: each feed's optional schedule + * pack (src/data/railSchedule.js) and live trip-update delays, read for the + * selected live train's stops, headsign and delays (the stops provider, + * `stopsFor`). It never places or moves a train: every train drawn is where + * its operator last reported it. + * + * A schedule pack is fetched the first time a train on that feed is + * selected, and kept until the feed has been out of use for a while + * (`forget`). Trip updates are polled every RAIL_UPDATES_POLL_MS while a + * train on the feed is selected. A feed that publishes none (Irish Rail, SJ + * Nord) may state each train's delay in its report (`delaySec`): that delay + * then moves the train's timetable, for its stops and for finding its run. + * + * No Cesium, no DOM. Network access goes through the injected source and + * timers through injected functions, so node:test drives it. + */ + +import { + activeTrips, + createSchedule, + liveDelaySec, + scheduledPosition, + tripStops, +} from '../../data/railSchedule.js'; +import { pointAtS } from '../../data/railPaths.js'; + +/** Trip updates are re-read this often while needed (ms). */ +export const RAIL_UPDATES_POLL_MS = 30_000; +/** Delays older than this are not applied: a stopped poll must not lie (ms). */ +export const RAIL_UPDATES_MAX_AGE_MS = 2 * RAIL_UPDATES_POLL_MS + 15_000; +/** + * A schedule pack that failed (other than a 404) is tried again after + * SCHEDULE_RETRY_FIRST_MS, then twice as long after each failure in a row, + * up to SCHEDULE_RETRY_MS (ms): one bad answer from the server does not + * leave a selected train without its stops for ten minutes. + */ +export const SCHEDULE_RETRY_FIRST_MS = 30_000; +export const SCHEDULE_RETRY_MS = 10 * 60_000; +/** + * A live train without a known trip id may be matched to a run from this + * long before its departure (waiting at the origin)... + */ +export const MATCH_BEFORE_MS = 5 * 60_000; +/** ...to this long after its arrival, at the terminus. */ +export const MATCH_AFTER_MS = 2 * 60_000; +/** A live train without a known trip id matches a run this close (m). */ +export const MATCH_NEAREST_MAX_M = 3_000; +/** How long a nearest-run match is kept before it is looked for again (ms). */ +export const MATCH_KEEP_MS = 60_000; + +/** + * How long a schedule pack waits after its nth failure in a row (ms). + * @param {number} failures 1 after the first. + * @returns {number} + */ +export function scheduleRetryDelayMs(failures) { + return Math.min( + SCHEDULE_RETRY_MS, + SCHEDULE_RETRY_FIRST_MS * 2 ** Math.max(0, failures - 1), + ); +} + +const M_PER_DEG = 111_320; + +/** Ground distance in metres, equirectangular: fine at a few kilometres. */ +function distanceM(lat1, lon1, lat2, lon2) { + const kx = M_PER_DEG * Math.cos((((lat1 + lat2) / 2) * Math.PI) / 180); + const dLon = ((((lon2 - lon1 + 180) % 360) + 360) % 360) - 180; + return Math.hypot(dLon * kx, (lat2 - lat1) * M_PER_DEG); +} + +/** + * GTFS-RT trip records (the `/api/rail/updates` body) as the `delays` map + * railSchedule reads, keyed by trip id. + * @param {{trips?: object[]}|null} body + * @returns {Record} + */ +export function delaysFromUpdates(body) { + const delays = {}; + for (const trip of Array.isArray(body?.trips) ? body.trips : []) { + if (typeof trip?.tripId !== 'string' || !trip.tripId) continue; + delays[trip.tripId] = { + delaySec: Number.isFinite(trip.delaySec) ? trip.delaySec : null, + startDate: trip.startDate ?? null, + relationship: trip.relationship ?? null, + stops: Array.isArray(trip.stops) ? trip.stops : [], + }; + } + return delays; +} + +/** + * Whether a timetable's shape indices point into this path pack: the two + * come from one build. A path pack rebuilt on its own (or a schedule left + * from an older build) has other shapes at those indices, so its runs are + * compared between their stops instead of along some other line. Packs that + * predate the stamps are taken as a pair. + * @param {{pathsBuiltAt?: string|null}} schedule + * @param {{builtAt?: string|null}|null} pack + * @returns {boolean} + */ +export function pathsMatch(schedule, pack) { + if (!pack) return false; + if (!schedule?.pathsBuiltAt || !pack.builtAt) return true; + return schedule.pathsBuiltAt === pack.builtAt; +} + +/** + * Where the timetable has one run now: on its shape when the path pack has + * it, else between its stops. Only compared with a live train's report, to + * find the run it is on (stopsFor); never drawn. + * @param {object} schedule From createSchedule. + * @param {object|string} run A run from activeTrips, or a trip id. + * @param {number} nowMs + * @param {object|null} delays + * @param {object|null} pack A path pack (createRailPathPack), when loaded. + * @returns {object|null} scheduledPosition's result plus `lat`, `lon`, + * `bearingDeg` (NaN when unknown) and `onShape`. + */ +export function scheduledRunPoint(schedule, run, nowMs, delays, pack) { + const position = scheduledPosition(schedule, run, nowMs, delays); + if (!position) return null; + const path = + position.shape !== null && pathsMatch(schedule, pack) + ? pack.pathAt(position.shape) + : null; + const point = path ? pointAtS(path, position.s) : null; + if (point && Number.isFinite(point.lat) && Number.isFinite(point.lon)) + return { ...position, ...point, onShape: true }; + if (Number.isFinite(position.lat) && Number.isFinite(position.lon)) + return { ...position, bearingDeg: NaN, onShape: false }; + // A shaped pattern whose path pack is not here (yet): between the stops. + const trip = schedule.trips.get(position.tripId); + if (!trip) return null; + const stopAt = (k) => (k >= 0 ? schedule.stops[trip.pattern.stops[k]] : null); + const a = stopAt( + position.prevStop >= 0 ? position.prevStop : position.nextStop, + ); + const b = + position.dwell || position.nextStop < 0 ? a : stopAt(position.nextStop); + if (!a || !b) return null; + const f = position.dwell ? 0 : position.fraction; + return { + ...position, + lat: a.lat + (b.lat - a.lat) * f, + lon: a.lon + (b.lon - a.lon) * f, + bearingDeg: NaN, + onShape: false, + }; +} + +/** + * The schedule tier for one layer instance. + * @param {{source: object, + * hasTripUpdates: (feedId: string) => boolean, + * pathPackFor?: (feedId: string) => object|null, + * now?: () => number, + * setInterval?: Function, clearInterval?: Function, + * onChange?: (feedId: string) => void}} options + * @returns {object} + */ +export function createRailSchedules({ + source, + hasTripUpdates, + pathPackFor = () => null, + now = () => Date.now(), + setInterval: startInterval = (fn, ms) => globalThis.setInterval(fn, ms), + clearInterval: stopInterval = (id) => globalThis.clearInterval(id), + onChange = () => {}, +}) { + /** + * feedId → `{status: 'loading'|'ready'|'none', schedule, retryAt, promise, + * failures}`; `failures` counts failed loads in a row. + */ + const schedules = new Map(); + /** feedId → `{delays, at, timer, reasons: Set, controller}`. */ + const updates = new Map(); + /** `${feedId}\n${trainId}` → `{tripKey, run, via, at}` for live trains. */ + const matches = new Map(); + let selectedFeed = null; + + /** + * The feed's schedule, loading it on first ask. + * @param {string} feedId + * @returns {Promise} The schedule, or null when it has none. + */ + function loadSchedule(feedId) { + const known = schedules.get(feedId); + if (known?.status === 'ready') return Promise.resolve(known.schedule); + if (known?.status === 'loading') return known.promise; + if (known?.status === 'none' && now() < known.retryAt) + return Promise.resolve(null); + if (typeof source?.requestSchedule !== 'function') { + schedules.set(feedId, { + status: 'none', + schedule: null, + retryAt: Infinity, + }); + return Promise.resolve(null); + } + const slot = { + status: 'loading', + schedule: null, + retryAt: Infinity, + failures: known?.failures ?? 0, + }; + slot.promise = (async () => { + try { + const response = await source.requestSchedule(feedId); + if (!response.ok) { + const error = new Error(`rail schedule HTTP ${response.status}`); + error.status = response.status; + throw error; + } + const raw = await response.json(); + let schedule; + try { + schedule = createSchedule(raw); + if (schedule.feedId !== feedId) + throw new TypeError('rail schedule pack is for another feed'); + } catch (error) { + error.permanent = true; + throw error; + } + slot.schedule = schedule; + slot.status = 'ready'; + slot.failures = 0; + onChange(feedId); + return schedule; + } catch (error) { + slot.status = 'none'; + slot.failures += 1; + // A 404 is "no timetable for this feed", not a fault to retry; a + // malformed pack will not mend itself before the next build either. + slot.retryAt = + error?.status === 404 || error?.permanent === true + ? Infinity + : now() + scheduleRetryDelayMs(slot.failures); + if (error?.status !== 404) + console.warn( + `[Data:Rail] ${feedId} timetable unavailable:`, + error?.message || error, + ); + return null; + } finally { + slot.promise = null; + } + })(); + schedules.set(feedId, slot); + return slot.promise; + } + + /** The feed's schedule if loaded; starts the load otherwise. */ + function scheduleFor(feedId) { + const known = schedules.get(feedId); + if (known?.status === 'ready') return known.schedule; + void loadSchedule(feedId); + return null; + } + + function updatesSlot(feedId) { + let slot = updates.get(feedId); + if (!slot) { + slot = { + delays: null, + at: -Infinity, + timer: null, + reasons: new Set(), + controller: null, + }; + updates.set(feedId, slot); + } + return slot; + } + + async function fetchUpdates(feedId) { + const slot = updatesSlot(feedId); + if (slot.controller || typeof source?.requestUpdates !== 'function') return; + const controller = new AbortController(); + slot.controller = controller; + try { + const response = await source.requestUpdates(feedId, { + signal: controller.signal, + }); + if (!response.ok) throw new Error(`rail updates HTTP ${response.status}`); + const body = await response.json(); + if (slot.controller !== controller) return; + slot.delays = delaysFromUpdates(body); + slot.at = now(); + onChange(feedId); + } catch (error) { + if (error?.name !== 'AbortError') + console.warn( + `[Data:Rail] ${feedId} trip updates unavailable:`, + error?.message || error, + ); + } finally { + if (slot.controller === controller) slot.controller = null; + } + } + + /** + * Poll a feed's trip updates while `reason` needs them. Reasons are + * counted, so every reader shares one poll. + * @param {string} feedId + * @param {string} reason + */ + function watchUpdates(feedId, reason) { + if (!hasTripUpdates(feedId)) return; + const slot = updatesSlot(feedId); + slot.reasons.add(reason); + if (slot.timer !== null) return; + void fetchUpdates(feedId); + slot.timer = startInterval( + () => void fetchUpdates(feedId), + RAIL_UPDATES_POLL_MS, + ); + } + + function unwatchUpdates(feedId, reason) { + const slot = updates.get(feedId); + if (!slot) return; + slot.reasons.delete(reason); + if (slot.reasons.size || slot.timer === null) return; + stopInterval(slot.timer); + slot.timer = null; + slot.controller?.abort(); + slot.controller = null; + } + + /** Current delays for a feed, or null when none are fresh. */ + function delaysFor(feedId) { + const slot = updates.get(feedId); + return slot?.delays && now() - slot.at <= RAIL_UPDATES_MAX_AGE_MS + ? slot.delays + : null; + } + + /** + * The run a live train is on. Its trip id when the timetable has it (live + * GTFS trip ids are normally the static ones); otherwise, as a fallback, + * the running run of its route nearest the train's report, kept for a + * minute so the stop list does not hop between neighbouring runs. A train + * its own report says is `liveDelayMs` late is compared with where the + * timetable had each run that long ago. + */ + function matchRun(schedule, train, nowMs, delays, liveDelayMs = 0) { + const tripId = train.tripId || null; + if (tripId && schedule.runsByTrip.has(tripId)) + return { trip: tripId, via: 'trip' }; + const record = train.record || {}; + if (!Number.isFinite(record.lat) || !Number.isFinite(record.lon)) + return null; + const key = `${train.feedId}\n${record.id ?? ''}`; + const tripKey = `${tripId ?? ''}\n${train.routeId ?? ''}`; + const kept = matches.get(key); + if (kept && kept.tripKey === tripKey && nowMs - kept.at < MATCH_KEEP_MS) + return kept.run ? { trip: kept.run, via: 'nearest' } : null; + const pack = pathPackFor(train.feedId); + // A uniform delay is a time shift: the run as timetabled that long ago. + const matchMs = nowMs - liveDelayMs; + let best = null; + let bestM = MATCH_NEAREST_MAX_M; + for (const run of activeTrips(schedule, matchMs, { + delays, + beforeMs: MATCH_BEFORE_MS, + afterMs: MATCH_AFTER_MS, + })) { + if (train.routeId && run.routeId !== train.routeId) continue; + const point = scheduledRunPoint(schedule, run, matchMs, delays, pack); + if (!point) continue; + const m = distanceM(record.lat, record.lon, point.lat, point.lon); + if (m < bestM) { + bestM = m; + best = run; + } + } + matches.set(key, { tripKey, run: best, at: nowMs }); + return best ? { trip: best, via: 'nearest' } : null; + } + + /** + * The stops provider (see createRailLayer): the selected train's stops and + * headsign, or null until the feed's timetable has loaded (or when it has + * none, or the train matches no run). + * @param {{feedId: string, tripId: string|null, routeId: string|null, + * record: object}} train + * @param {number} nowMs + * @returns {{stops: object[], headsign: string|null, + * match: 'trip'|'nearest'}|null} + */ + function stopsFor(train, nowMs) { + if (!train?.feedId) return null; + const schedule = scheduleFor(train.feedId); + if (!schedule) return null; + // A feed without trip updates: the delay the train's report states. + const liveDelay = hasTripUpdates(train.feedId) + ? null + : liveDelaySec(train.record); + const updates = liveDelay === null ? delaysFor(train.feedId) : null; + const match = matchRun( + schedule, + train, + nowMs, + updates, + (liveDelay ?? 0) * 1000, + ); + if (!match) return null; + const tripId = + typeof match.trip === 'string' ? match.trip : match.trip.tripId; + const delays = + liveDelay === null + ? updates + : { + [tripId]: { + delaySec: liveDelay, + startDate: + typeof match.trip === 'string' ? null : match.trip.serviceDate, + }, + }; + const stops = tripStops(schedule, match.trip, nowMs, delays); + if (!stops.length) return null; + const trip = schedule.trips.get(tripId); + return { + stops, + headsign: trip?.headsign || null, + match: match.via, + }; + } + + /** + * A train was selected (or the selection cleared): poll its delays once + * its feed's timetable is loaded. Delays are shown only at the + * timetable's stops, so a feed without one is not polled. + */ + function onSelect(train) { + const feedId = train?.feedId || null; + if (feedId === selectedFeed) return; + if (selectedFeed) unwatchUpdates(selectedFeed, 'selection'); + selectedFeed = feedId; + if (!feedId) return; + void loadSchedule(feedId).then((schedule) => { + if (schedule && selectedFeed === feedId) + watchUpdates(feedId, 'selection'); + }); + } + + /** Stop every poll; loaded schedules stay for the next enable. */ + function stop() { + onSelect(null); + for (const [feedId, slot] of updates) + for (const reason of [...slot.reasons]) unwatchUpdates(feedId, reason); + matches.clear(); + } + + /** + * Release what is held for a feed no longer in use: its parsed schedule, + * its last trip updates and its live-train matches. A load or a poll + * still under way is left to finish; the feed loads again when next used. + * @param {string} feedId + */ + function forget(feedId) { + if (schedules.get(feedId)?.status !== 'loading') schedules.delete(feedId); + const slot = updates.get(feedId); + if (slot && slot.reasons.size === 0 && slot.timer === null) { + slot.controller?.abort(); + updates.delete(feedId); + } + const prefix = `${feedId} +`; + for (const key of [...matches.keys()]) + if (key.startsWith(prefix)) matches.delete(key); + } + + /** Counts for QA probes and tests. */ + function stats() { + return { + schedules: Object.fromEntries( + [...schedules].map(([feedId, slot]) => [ + feedId, + slot.status === 'ready' + ? { + status: 'ready', + trips: slot.schedule.trips.size, + runs: slot.schedule.instances.length, + } + : { status: slot.status }, + ]), + ), + updates: Object.fromEntries( + [...updates].map(([feedId, slot]) => [ + feedId, + { + polling: slot.timer !== null, + reasons: [...slot.reasons], + trips: slot.delays ? Object.keys(slot.delays).length : 0, + ageMs: Number.isFinite(slot.at) ? now() - slot.at : null, + }, + ]), + ), + }; + } + + return { + stopsFor, + onSelect, + loadSchedule, + scheduleFor, + watchUpdates, + unwatchUpdates, + delaysFor, + stop, + forget, + stats, + }; +} diff --git a/src/layers/rail/schedules.test.mjs b/src/layers/rail/schedules.test.mjs new file mode 100644 index 000000000..0db2cdca9 --- /dev/null +++ b/src/layers/rail/schedules.test.mjs @@ -0,0 +1,593 @@ +// The Rail layer's schedule tier, the stops provider of live trains: null +// before the pack loads, trip-id matching with a nearest-run fallback, delay +// polling only while a train is selected on a feed with a timetable. It +// places no train itself. Built from a small synthetic schedule pack and path +// pack. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + MATCH_NEAREST_MAX_M, + RAIL_UPDATES_MAX_AGE_MS, + RAIL_UPDATES_POLL_MS, + SCHEDULE_RETRY_FIRST_MS, + SCHEDULE_RETRY_MS, + createRailSchedules, + delaysFromUpdates, + pathsMatch, + scheduledRunPoint, + scheduleRetryDelayMs, +} from './schedules.js'; +import * as schedules from './schedules.js'; +import { createSchedule } from '../../data/railSchedule.js'; +import { createRailPathPack } from '../../data/railPaths.js'; + +const FEED = 'test-rail'; +const LAT = 50; +const STEP_M = 0.1 * 111_320 * Math.cos((LAT * Math.PI) / 180); +const at = (hhmm) => Date.parse(`2026-11-06T${hhmm}:00Z`); + +/** Three stops due east 0.1° apart; T1 runs 10:00 → 10:20, dwelling at B. */ +const rawSchedule = Object.freeze({ + version: 1, + feedId: FEED, + timezone: 'UTC', + window: { from: '2026-11-05', to: '2026-11-08' }, + routes: { R1: { type: 2 }, BUS: { type: 3 } }, + stops: [ + { id: 'A', name: 'Alpha', lat: LAT, lon: 0 }, + { id: 'B', name: 'Bravo', lat: LAT, lon: 0.1 }, + { id: 'C', name: 'Charlie', lat: LAT, lon: 0.2 }, + ], + patterns: [ + { + shape: 0, + stops: [0, 1, 2], + seq: [1, 2, 3], + dist: [0, STEP_M, 2 * STEP_M], + }, + { + shape: 1, + stops: [2, 1, 0], + seq: [1, 2, 3], + dist: [0, STEP_M, 2 * STEP_M], + }, + ], + profiles: [[0, 0, 600, 660, 1260, 1260]], + services: { WK: ['2026-11-06'] }, + trips: { + T1: { + pattern: 0, + profile: 0, + startSec: 10 * 3600, + route: 'R1', + service: 'WK', + headsign: 'Charlie', + name: 'Train 1', + }, + T2: { + pattern: 1, + profile: 0, + startSec: 10 * 3600, + route: 'R1', + service: 'WK', + headsign: 'Alpha', + name: 'Train 2', + }, + B9: { + pattern: 0, + profile: 0, + startSec: 10 * 3600, + route: 'BUS', + service: 'WK', + }, + }, +}); + +const rawPaths = Object.freeze({ + version: 1, + feedId: FEED, + shapes: [ + [LAT, 0, LAT, 0.1, LAT, 0.2], + [LAT, 0.2, LAT, 0.1, LAT, 0], + ], + dist: [ + [0, STEP_M, 2 * STEP_M], + [0, STEP_M, 2 * STEP_M], + ], + trips: { T1: 0, T2: 1 }, + routes: { R1: [0, 1] }, +}); + +const reply = (body, status = 200) => ({ + ok: status >= 200 && status < 300, + status, + async json() { + return body; + }, +}); + +const flush = async () => { + for (let i = 0; i < 4; i++) await new Promise((r) => setImmediate(r)); +}; + +/** A provider over a fake source, fake clock and fake interval timers. */ +function harness({ + schedule = () => reply(rawSchedule), + updates = () => reply({ trips: [] }), + tripUpdates = true, + clock = at('10:05'), +} = {}) { + const calls = { schedule: 0, updates: 0 }; + const timers = new Map(); + let nextTimer = 1; + const h = { + calls, + timers, + now: clock, + provider: null, + }; + h.provider = createRailSchedules({ + source: { + async requestSchedule(feedId) { + calls.schedule += 1; + assert.equal(feedId, FEED); + return schedule(); + }, + async requestUpdates(feedId) { + calls.updates += 1; + assert.equal(feedId, FEED); + return updates(); + }, + }, + hasTripUpdates: () => tripUpdates, + pathPackFor: () => createRailPathPack(rawPaths), + now: () => h.now, + setInterval: (fn, ms) => { + const id = nextTimer++; + timers.set(id, { fn, ms }); + return id; + }, + clearInterval: (id) => timers.delete(id), + }); + return h; +} + +const liveTrain = (record) => ({ + feedId: FEED, + tripId: record.tripId ?? null, + routeId: record.routeId ?? null, + record, +}); + +// --------------------------------------------------------------------------- +// Where a run is, compared with a live train's report to find its run + +test('the tier places no trains: it has no train records to offer', () => { + assert.equal(schedules.scheduledTrainRecords, undefined); + const provider = createRailSchedules({ + source: {}, + hasTripUpdates: () => false, + }); + assert.deepEqual(Object.keys(provider).sort(), [ + 'delaysFor', + 'forget', + 'loadSchedule', + 'onSelect', + 'scheduleFor', + 'stats', + 'stop', + 'stopsFor', + 'unwatchUpdates', + 'watchUpdates', + ]); +}); + +test('without the path pack a run’s point lies between its stops', () => { + const schedule = createSchedule(rawSchedule); + const point = scheduledRunPoint(schedule, 'T1', at('10:05'), null, null); + assert.equal(point.onShape, false); + assert.ok(Math.abs(point.lon - 0.05) < 1e-9); + assert.ok(Number.isNaN(point.bearingDeg)); + const onShape = scheduledRunPoint( + schedule, + 'T1', + at('10:05'), + null, + createRailPathPack(rawPaths), + ); + assert.equal(onShape.onShape, true); + assert.ok(Math.abs(onShape.bearingDeg - 90) < 1e-6); +}); + +test('a schedule from another build than its path pack stays between stops', () => { + const built = '2026-11-10T08:00:00.000Z'; + const schedule = createSchedule({ ...rawSchedule, pathsBuiltAt: built }); + const same = createRailPathPack({ ...rawPaths, builtAt: built }); + const rebuilt = createRailPathPack({ + ...rawPaths, + builtAt: '2026-11-10T09:00:00.000Z', + }); + assert.equal(schedule.pathsBuiltAt, built); + assert.equal(same.builtAt, built); + assert.equal(pathsMatch(schedule, same), true); + assert.equal(pathsMatch(schedule, rebuilt), false); + assert.equal(pathsMatch(schedule, null), false); + // Packs from before the stamps are taken as a pair. + assert.equal(pathsMatch(createSchedule(rawSchedule), rebuilt), true); + const onShape = scheduledRunPoint(schedule, 'T1', at('10:05'), null, same); + assert.equal(onShape.onShape, true); + const between = scheduledRunPoint(schedule, 'T1', at('10:05'), null, rebuilt); + assert.equal(between.onShape, false); + assert.ok(Math.abs(between.lon - 0.05) < 1e-9); +}); + +test('trip updates become the delays map the timetable reads', () => { + const delays = delaysFromUpdates({ + trips: [ + { + tripId: 'T1', + routeId: 'R1', + startDate: '20261106', + relationship: 'SCHEDULED', + delaySec: null, + stops: [{ stopId: 'B', arrival: { time: null, delay: 240 } }], + }, + { tripId: '', delaySec: 60 }, + null, + ], + }); + assert.deepEqual(Object.keys(delays), ['T1']); + assert.equal(delays.T1.delaySec, null); + assert.equal(delays.T1.stops[0].arrival.delay, 240); + assert.deepEqual(delaysFromUpdates(null), {}); +}); + +// --------------------------------------------------------------------------- +// Stops provider + +test('the provider answers null until the timetable has loaded', async () => { + const h = harness(); + const train = liveTrain({ + id: 'v1', + tripId: 'T1', + routeId: 'R1', + lat: LAT, + lon: 0.05, + }); + assert.equal(h.provider.stopsFor(train, h.now), null); + assert.equal(h.calls.schedule, 1); + // Asking again while it loads does not fetch twice. + assert.equal(h.provider.stopsFor(train, h.now), null); + assert.equal(h.calls.schedule, 1); + await flush(); + const trip = h.provider.stopsFor(train, h.now); + assert.equal(trip.match, 'trip'); + assert.equal(trip.headsign, 'Charlie'); + assert.deepEqual( + trip.stops.map((stop) => [stop.name, stop.state, stop.expectedArrival]), + [ + ['Alpha', 'passed', '10:00'], + ['Bravo', 'next', '10:10'], + ['Charlie', 'upcoming', '10:21'], + ], + ); + assert.equal(trip.stops[1].relative, 'T−5'); + assert.equal(h.calls.schedule, 1); + assert.equal(h.provider.stopsFor(null, h.now), null); +}); + +test('a live trip id the timetable lacks falls back to the nearest run', async () => { + const h = harness(); + await h.provider.loadSchedule(FEED); + const near = h.provider.stopsFor( + liveTrain({ + id: 'v7', + tripId: 'live-123', + routeId: 'R1', + lat: LAT, + lon: 0.149, + }), + h.now, + ); + // At 10:05 T2 is at 0.15 (westbound) and T1 at 0.05: T2 is nearest. + assert.equal(near.match, 'nearest'); + assert.equal(near.headsign, 'Alpha'); + // A train with no route searches every run. + const noRoute = h.provider.stopsFor( + liveTrain({ id: 'v8', tripId: null, lat: LAT, lon: 0.051 }), + h.now, + ); + assert.equal(noRoute.headsign, 'Charlie'); + // Too far from any run: no stops rather than a wrong train's. + const farLon = 0.05 + (2 * MATCH_NEAREST_MAX_M) / (STEP_M * 10); + assert.equal( + h.provider.stopsFor( + liveTrain({ + id: 'v9', + tripId: 'x', + routeId: 'R1', + lat: LAT + 0.1, + lon: farLon, + }), + h.now, + ), + null, + ); + // Another route's train does not take a run of R1. + assert.equal( + h.provider.stopsFor( + liveTrain({ id: 'v10', tripId: 'x', routeId: 'R9', lat: LAT, lon: 0.05 }), + h.now, + ), + null, + ); +}); + +test('delays are polled every 30 s only while a train on the feed is selected', async () => { + const h = harness({ + updates: () => + reply({ + trips: [ + { + tripId: 'T1', + delaySec: 240, + stops: [{ stopId: 'B', arrival: { delay: 240 }, departure: null }], + }, + ], + }), + }); + const train = liveTrain({ + id: 'v1', + tripId: 'T1', + routeId: 'R1', + lat: LAT, + lon: 0.05, + }); + assert.equal(h.calls.updates, 0); + h.provider.onSelect(train); + await flush(); + assert.equal(h.calls.updates, 1); + assert.equal(h.timers.size, 1); + assert.equal([...h.timers.values()][0].ms, RAIL_UPDATES_POLL_MS); + assert.equal(RAIL_UPDATES_POLL_MS, 30_000); + const trip = h.provider.stopsFor(train, h.now); + const bravo = trip.stops[1]; + assert.equal(bravo.arrivalDelaySec, 240); + assert.equal(bravo.expectedArrival, '10:14'); + + // The interval re-reads; reselecting on the same feed adds no second poll. + [...h.timers.values()][0].fn(); + await flush(); + assert.equal(h.calls.updates, 2); + h.provider.onSelect({ ...train, record: { ...train.record, id: 'v2' } }); + assert.equal(h.timers.size, 1); + + // Delays too old to trust are not applied. + h.now += RAIL_UPDATES_MAX_AGE_MS + 1; + assert.equal(h.provider.delaysFor(FEED), null); + assert.equal(h.provider.stopsFor(train, h.now).stops[1].arrivalDelaySec, 0); + + // Clearing the selection stops the poll. + h.provider.onSelect(null); + assert.equal(h.timers.size, 0); +}); + +test('a selected train’s delays are not polled when its feed has no timetable', async () => { + const h = harness({ + schedule: () => reply({ error: 'No rail schedule' }, 404), + }); + h.provider.onSelect(liveTrain({ id: 'v1', tripId: 'T1' })); + await flush(); + assert.equal(h.calls.schedule, 1); + assert.equal(h.calls.updates, 0); + assert.equal(h.timers.size, 0); + assert.equal(h.provider.stats().updates[FEED], undefined); + + // With the pack, the same selection polls; cleared before the pack + // arrives, it starts no poll. + const ready = harness(); + ready.provider.onSelect(liveTrain({ id: 'v1', tripId: 'T1' })); + ready.provider.onSelect(null); + await flush(); + assert.equal(ready.calls.updates, 0); + assert.equal(ready.timers.size, 0); + ready.provider.onSelect(liveTrain({ id: 'v1', tripId: 'T1' })); + await flush(); + assert.equal(ready.calls.updates, 1); + assert.equal(ready.timers.size, 1); +}); + +test('every reader of a feed’s delays shares one poll', async () => { + const h = harness(); + h.provider.watchUpdates(FEED, 'probe'); + h.provider.onSelect(liveTrain({ id: 'v1', tripId: 'T1' })); + await flush(); + assert.deepEqual(h.provider.stats().updates[FEED].reasons, [ + 'probe', + 'selection', + ]); + assert.equal(h.timers.size, 1); + h.provider.onSelect(null); + assert.equal(h.timers.size, 1); + h.provider.unwatchUpdates(FEED, 'probe'); + assert.equal(h.timers.size, 0); + h.provider.watchUpdates(FEED, 'probe'); + h.provider.stop(); + assert.equal(h.timers.size, 0); +}); + +test('a feed without trip updates is never polled for them', async () => { + const h = harness({ tripUpdates: false }); + h.provider.onSelect(liveTrain({ id: 'v1', tripId: 'T1' })); + h.provider.watchUpdates(FEED, 'probe'); + await flush(); + assert.equal(h.calls.updates, 0); + assert.equal(h.timers.size, 0); + assert.equal(h.provider.delaysFor(FEED), null); +}); + +test('without trip updates, the delay a train reports moves its stops', async () => { + // Irish Rail and SJ Nord publish no trip updates, but each report says + // how late the train is: 9 minutes here. + const h = harness({ tripUpdates: false, clock: at('10:12') }); + await h.provider.loadSchedule(FEED); + const late = (record) => + h.provider.stopsFor(liveTrain({ routeId: 'R1', ...record }), h.now); + const byTrip = late({ + id: 'v1', + tripId: 'T1', + lat: LAT, + lon: 0.06, + delaySec: 540, + }); + assert.equal(byTrip.match, 'trip'); + // On time it would have left Bravo at 10:11; 9 minutes late it is still + // to reach it. + assert.deepEqual( + byTrip.stops.map((stop) => [stop.name, stop.state, stop.expectedArrival]), + [ + ['Alpha', 'passed', '10:09'], + ['Bravo', 'next', '10:19'], + ['Charlie', 'upcoming', '10:30'], + ], + ); + assert.equal(byTrip.stops[1].arrivalDelaySec, 540); + // Without a usable trip id the run is found where its timetable was 9 + // minutes ago: T1 at 10:03, not T2, which is nearer the report at 10:12. + const byPlace = late({ + id: 'v2', + tripId: 'SJN:DatedServiceJourney:1', + lat: LAT, + lon: 0.03, + delaySec: 540, + }); + assert.equal(byPlace.match, 'nearest'); + assert.equal(byPlace.headsign, 'Charlie'); + assert.equal(byPlace.stops[1].expectedArrival, '10:19'); + // A train 25 minutes late is still on its run after the timetable ends. + h.now = at('10:40'); + const after = late({ + id: 'v3', + tripId: 'T1', + lat: LAT, + lon: 0.18, + delaySec: 1500, + }); + assert.equal(after.stops[2].state, 'next'); + assert.equal(after.stops[2].expectedArrival, '10:46'); +}); + +test('a feed with trip updates takes its delays from them, not the report', async () => { + const h = harness({ clock: at('10:12') }); + await h.provider.loadSchedule(FEED); + const trip = h.provider.stopsFor( + liveTrain({ + id: 'v1', + tripId: 'T1', + routeId: 'R1', + lat: LAT, + lon: 0.15, + delaySec: 540, + }), + h.now, + ); + assert.deepEqual( + trip.stops.map((stop) => stop.state), + ['passed', 'passed', 'next'], + ); + assert.equal(trip.stops[2].expectedArrival, '10:21'); +}); + +test('no timetable (404) is final; a failed load is retried later', async () => { + const missing = harness({ + schedule: () => reply({ error: 'No rail schedule' }, 404), + }); + const train = liveTrain({ id: 'v1', tripId: 'T1' }); + assert.equal(missing.provider.stopsFor(train, missing.now), null); + await flush(); + missing.now += SCHEDULE_RETRY_MS * 10; + assert.equal(missing.provider.stopsFor(train, missing.now), null); + await flush(); + assert.equal(missing.calls.schedule, 1); + assert.equal(await missing.provider.loadSchedule(FEED), null); + + let fail = true; + const flaky = harness({ + schedule: () => (fail ? reply({}, 503) : reply(rawSchedule)), + }); + const warn = console.warn; + console.warn = () => {}; + try { + assert.equal(await flaky.provider.loadSchedule(FEED), null); + fail = false; + assert.equal(await flaky.provider.loadSchedule(FEED), null); + assert.equal(flaky.calls.schedule, 1); + flaky.now += SCHEDULE_RETRY_MS; + assert.ok(await flaky.provider.loadSchedule(FEED)); + assert.equal(flaky.calls.schedule, 2); + // A malformed pack is not fetched again. + const broken = harness({ schedule: () => reply({ version: 99 }) }); + assert.equal(await broken.provider.loadSchedule(FEED), null); + broken.now += SCHEDULE_RETRY_MS * 10; + assert.equal(await broken.provider.loadSchedule(FEED), null); + assert.equal(broken.calls.schedule, 1); + } finally { + console.warn = warn; + } +}); + +test('a failing pack is tried again after 30 s, then twice as long each time, up to ten minutes', async () => { + assert.deepEqual( + [1, 2, 3, 4, 5, 6, 7].map(scheduleRetryDelayMs), + [30_000, 60_000, 120_000, 240_000, 480_000, 600_000, 600_000], + ); + let fail = true; + const flaky = harness({ + schedule: () => (fail ? reply({}, 502) : reply(rawSchedule)), + }); + const warn = console.warn; + console.warn = () => {}; + try { + // One transient 5xx: back within a minute, not ten. + assert.equal(await flaky.provider.loadSchedule(FEED), null); + flaky.now += SCHEDULE_RETRY_FIRST_MS - 1; + assert.equal(await flaky.provider.loadSchedule(FEED), null); + assert.equal(flaky.calls.schedule, 1, 'not before 30 s'); + flaky.now += 1; + assert.equal(await flaky.provider.loadSchedule(FEED), null); + assert.equal(flaky.calls.schedule, 2); + // Failing again, it waits twice as long. + flaky.now += 2 * SCHEDULE_RETRY_FIRST_MS - 1; + assert.equal(await flaky.provider.loadSchedule(FEED), null); + assert.equal(flaky.calls.schedule, 2, 'not before 60 s'); + fail = false; + flaky.now += 1; + assert.ok(await flaky.provider.loadSchedule(FEED)); + assert.equal(flaky.calls.schedule, 3); + // Loaded, it is never fetched again. + flaky.now += SCHEDULE_RETRY_MS * 10; + assert.ok(await flaky.provider.loadSchedule(FEED)); + assert.equal(flaky.calls.schedule, 3); + } finally { + console.warn = warn; + } +}); + +test('a forgotten feed gives back its timetable and loads it again when used', async () => { + const h = harness(); + await h.provider.loadSchedule(FEED); + assert.equal(h.provider.stats().schedules[FEED].status, 'ready'); + assert.equal(h.calls.schedule, 1); + h.provider.forget(FEED); + assert.equal(h.provider.stats().schedules[FEED], undefined); + await h.provider.loadSchedule(FEED); + assert.equal(h.calls.schedule, 2); + // Trip updates still polled for a reason are not dropped. + h.provider.watchUpdates(FEED, 'probe'); + await flush(); + h.provider.forget(FEED); + assert.ok(h.provider.stats().updates[FEED].polling); + h.provider.unwatchUpdates(FEED, 'probe'); + h.provider.forget(FEED); + assert.equal(h.provider.stats().updates[FEED], undefined); +}); diff --git a/src/layers/rail/selection.js b/src/layers/rail/selection.js new file mode 100644 index 000000000..4f4564bc6 --- /dev/null +++ b/src/layers/rail/selection.js @@ -0,0 +1,169 @@ +import * as Cesium from 'cesium'; +import { + RAIL_SELECTED_OVERLAY_SOURCE_ID, + RAIL_SELECTED_OVERLAY_SOURCE_OPTIONS, + SELECTED_CARD_REFRESH_MS, + buildRailSelectionCopy, + createRailSelectedOverlayEntry, +} from './policy.js'; +import { getRegisteredRailFeed } from '../../data/railFeeds.js'; +import { isPointerFree } from '../../data/inputOwnership.js'; +import { t } from '../../i18n/index.js'; + +/** + * Click-to-select one train, a card that follows it, and the camera handed + * to it (follow.js). Escape or a click on anything else deselects it. + * @param {object} context + * @returns {object} + */ +export function createSelection({ state, services, parts }) { + const { governorRequestRender } = services.render; + + /** The card's anchor: the marker's current position, read on every paint. */ + function anchorFor(entry) { + return () => { + const p = entry.marker?.show !== false ? entry.marker?.position : null; + return p && Number.isFinite(p.x) && Number.isFinite(p.y) ? p : null; + }; + } + + function refreshSelectedCard(force) { + const entry = state._trains.get(state._selectedKey); + if (!entry) return; + const now = Date.now(); + if (!force && now - state._selectedCardAt < SELECTED_CARD_REFRESH_MS) + return; + state._selectedCardAt = now; + const feed = getRegisteredRailFeed(entry.feedId); + if (!feed) return; + const copy = buildRailSelectionCopy( + feed, + entry.record, + now, + entry.fetchedAt, + entry, + t, + state._trip, + ); + const text = JSON.stringify(copy); + if (!force && text === state._selectedCardText) return; + state._selectedCardText = text; + const card = createRailSelectedOverlayEntry( + entry.key, + anchorFor(entry), + copy, + entry.category, + ); + state._overlayHost.setEntries( + RAIL_SELECTED_OVERLAY_SOURCE_ID, + card ? [card] : [], + RAIL_SELECTED_OVERLAY_SOURCE_OPTIONS, + ); + } + + /** + * Select a train without touching the camera. Called by a click (through + * selectAndFocusTrain) and by the duplicate filter, which moves the + * selection to another feed's copy of the same train; a follow on the old + * copy then glides to the new one. + * @param {string} key + */ + function selectTrain(key) { + if (!state._trains.has(key)) return; + const hadSelection = state._selectedKey !== null; + const previous = state._trains.get(state._selectedKey); + state._selectedKey = key; + const entry = state._trains.get(key); + if (previous && previous !== entry) parts.follow.handover(entry); + // The first selection dims every other train; a change of selection only + // swaps the two trains involved. + if (!hadSelection) parts.rendering.paintAll(); + else { + if (previous) parts.rendering.paint(previous); + parts.rendering.paint(entry); + } + // Line and stops first, so the card is built with this trip's stops. + parts.routes.onSelectionChanged(); + refreshSelectedCard(true); + // Its feed is polled for it at any distance from now on. + parts.viewport.requestProximityCheck(); + governorRequestRender('rail-select'); + } + + /** + * Select the train the user picked and give it the camera (follow.js): a + * moving train is followed, any other flown to once. Picking the train + * already followed keeps the follow as it is. + * @param {string} key + */ + function selectAndFocusTrain(key) { + if (!state._trains.has(key)) return; + const following = parts.follow.isFollowing(key); + // Another train's follow lets go first, so this is never taken for a + // move between two copies of one train. + if (!following) parts.follow.release(); + selectTrain(key); + if (!following) parts.follow.focus(state._trains.get(key)); + } + + /** + * Deselect, letting go of the camera where it is. + * @param {{untrack?: boolean}} [options] `untrack: false` when another + * camera owner took over and its tracked entity must stay. + */ + function clearSelection({ untrack = true } = {}) { + const hadSelection = state._selectedKey !== null; + parts.follow.release({ untrack }); + state._selectedKey = null; + state._selectedCardText = null; + if (hadSelection) { + parts.rendering.paintAll(); + parts.routes.onSelectionChanged(); + // A feed held for the train goes back to the viewport's rules. + parts.viewport.requestProximityCheck(); + } + state._overlayHost.setEntries( + RAIL_SELECTED_OVERLAY_SOURCE_ID, + [], + RAIL_SELECTED_OVERLAY_SOURCE_OPTIONS, + ); + governorRequestRender('rail-deselect'); + } + + function onKeyDown(event) { + if (event.key === 'Escape' && state._selectedKey) clearSelection(); + } + + function installClickHandler(viewer) { + if (state._clickHandler) return; + state._clickHandler = new Cesium.ScreenSpaceEventHandler( + viewer.scene.canvas, + ); + state._clickHandler.setInputAction((click) => { + if (!state._enabled || !isPointerFree()) return; + const picked = viewer.scene.pick(click.position); + const id = typeof picked?.id === 'string' ? picked.id : null; + if (id && state._trains.has(id)) selectAndFocusTrain(id); + else if (state._selectedKey) clearSelection(); + }, Cesium.ScreenSpaceEventType.LEFT_CLICK); + globalThis.document?.addEventListener?.('keydown', onKeyDown); + parts.follow.install(viewer); + } + + function removeClickHandler() { + state._clickHandler?.destroy(); + state._clickHandler = null; + globalThis.document?.removeEventListener?.('keydown', onKeyDown); + parts.follow.uninstall(); + } + + return { + refreshSelectedCard, + selectTrain, + selectAndFocusTrain, + clearSelection, + onKeyDown, + installClickHandler, + removeClickHandler, + }; +} diff --git a/src/layers/rail/source.js b/src/layers/rail/source.js new file mode 100644 index 000000000..111fe2fcd --- /dev/null +++ b/src/layers/rail/source.js @@ -0,0 +1,68 @@ +/** Request rail snapshots, path packs, timetables and trip updates through caller-owned transport. */ +export function createRailSource({ + fetchImpl = (...args) => fetch(...args), +} = {}) { + /** + * GET one per-feed JSON resource and hand back a response-like object whose + * body read still honours the caller's abort. + * @param {string} route `trains`, `paths`, `schedule` or `updates`. + * @param {string} feedId + * @param {{signal?: AbortSignal}} [options] + */ + function requestFeedJson(route, feedId, { signal } = {}) { + signal?.throwIfAborted(); + if (typeof feedId !== 'string' || !feedId || feedId.length > 160) + throw new TypeError('A rail feed identifier is required'); + return Promise.resolve( + fetchImpl(`/api/rail/${route}/${encodeURIComponent(feedId)}`, { + signal, + headers: { Accept: 'application/json' }, + }), + ).then((response) => { + signal?.throwIfAborted(); + return { + ok: response.ok, + status: response.status, + headers: response.headers, + async json() { + const body = await response.json(); + signal?.throwIfAborted(); + return body; + }, + }; + }); + } + + return { + /** Read the public catalog of enabled feeds. */ + async getFeeds({ signal } = {}) { + signal?.throwIfAborted(); + const response = await fetchImpl('/api/rail/feeds', { + signal, + headers: { Accept: 'application/json' }, + }); + if (!response.ok) throw new Error('Rail feeds HTTP ' + response.status); + const body = await response.json(); + signal?.throwIfAborted(); + if (!Array.isArray(body?.feeds)) + throw new Error('Malformed rail feed catalog'); + return body.feeds; + }, + /** The feed's current train-only snapshot. */ + requestSnapshot(feedId, options) { + return requestFeedJson('trains', feedId, options); + }, + /** The feed's path pack (route shapes); 404 when none is published. */ + requestPaths(feedId, options) { + return requestFeedJson('paths', feedId, options); + }, + /** The feed's timetable pack; 404 when none is published. */ + requestSchedule(feedId, options) { + return requestFeedJson('schedule', feedId, options); + }, + /** The feed's live trip updates (delays); 404 for a feed without them. */ + requestUpdates(feedId, options) { + return requestFeedJson('updates', feedId, options); + }, + }; +} diff --git a/src/layers/rail/source.test.mjs b/src/layers/rail/source.test.mjs new file mode 100644 index 000000000..2e4510062 --- /dev/null +++ b/src/layers/rail/source.test.mjs @@ -0,0 +1,205 @@ +// The Rail source names a registered feed id and nothing else: the catalog +// route, the per-feed train route with the id encoded as one path segment, +// and the caller's abort signal at every await. +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createRailSource } from './source.js'; + +const json = (body, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); + +const isAbort = (error) => error?.name === 'AbortError'; + +test('the feed catalog is read from the public catalog route', async () => { + const requested = []; + const feeds = [ + { + id: 'mbta-rail', + name: 'MBTA', + center: { lat: 42.36, lon: -71.06 }, + loadRadiusKm: 110, + }, + ]; + const source = createRailSource({ + fetchImpl: async (url, init) => { + requested.push({ url, accept: init.headers.Accept }); + return json({ feeds }); + }, + }); + assert.deepEqual(await source.getFeeds(), feeds); + assert.deepEqual(requested, [ + { url: '/api/rail/feeds', accept: 'application/json' }, + ]); +}); + +test('feed catalog failures are reported', async () => { + const failing = createRailSource({ fetchImpl: async () => json({}, 503) }); + await assert.rejects(failing.getFeeds(), /Rail feeds HTTP 503/); + const malformed = createRailSource({ + fetchImpl: async () => json({ feeds: {} }), + }); + await assert.rejects(malformed.getFeeds(), /Malformed rail feed catalog/); + const aborted = new AbortController(); + aborted.abort(); + await assert.rejects( + createRailSource({ + fetchImpl: async () => json({ feeds: [] }), + }).getFeeds({ signal: aborted.signal }), + isAbort, + ); +}); + +test('a snapshot is requested from the train route for that feed', async () => { + const requested = []; + const snapshot = { feedId: 'mta-lirr', count: 0, dropped: 3, trains: [] }; + const controller = new AbortController(); + const source = createRailSource({ + fetchImpl: async (url, init) => { + requested.push({ + url, + signal: init.signal, + accept: init.headers.Accept, + }); + return new Response(JSON.stringify(snapshot), { + status: 200, + headers: { 'X-GEV-Cache': 'HIT', 'X-Rail-Contact': '1794000000000' }, + }); + }, + }); + const response = await source.requestSnapshot('mta-lirr', { + signal: controller.signal, + }); + assert.deepEqual(requested, [ + { + url: '/api/rail/trains/mta-lirr', + signal: controller.signal, + accept: 'application/json', + }, + ]); + assert.equal(response.ok, true); + assert.equal(response.status, 200); + assert.equal(response.headers.get('x-gev-cache'), 'HIT'); + assert.equal(response.headers.get('x-rail-contact'), '1794000000000'); + assert.deepEqual(await response.json(), snapshot); +}); + +test('path packs are requested from the paths route with the same wrapper', async () => { + const requested = []; + const pack = { version: 1, feedId: 'grt-ion', shapes: [] }; + const source = createRailSource({ + fetchImpl: async (url) => { + requested.push(url); + return new Response(JSON.stringify(pack), { status: 200 }); + }, + }); + const response = await source.requestPaths('grt-ion'); + assert.deepEqual(requested, ['/api/rail/paths/grt-ion']); + assert.equal(response.ok, true); + assert.deepEqual(await response.json(), pack); + assert.throws(() => source.requestPaths(''), TypeError); +}); + +test('the feed id is encoded as a single path segment', async () => { + const requested = []; + const source = createRailSource({ + fetchImpl: async (url) => { + requested.push(url); + return json({}, 404); + }, + }); + const response = await source.requestSnapshot('../feeds?x=1 #'); + assert.equal(response.ok, false); + assert.equal(response.status, 404); + assert.deepEqual(requested, ['/api/rail/trains/..%2Ffeeds%3Fx%3D1%20%23']); +}); + +test('a missing, non-string or oversized feed id is refused', () => { + let calls = 0; + const source = createRailSource({ + fetchImpl: async () => { + calls += 1; + return json({}); + }, + }); + for (const id of ['', undefined, null, 42, {}, 'a'.repeat(161)]) { + assert.throws( + () => source.requestSnapshot(id), + { name: 'TypeError', message: 'A rail feed identifier is required' }, + String(id).slice(0, 12), + ); + } + assert.doesNotThrow(() => source.requestSnapshot('a'.repeat(160))); + assert.equal(calls, 1); +}); + +test('an already aborted request never reaches the network', () => { + let calls = 0; + const source = createRailSource({ + fetchImpl: async () => { + calls += 1; + return json({}); + }, + }); + const controller = new AbortController(); + controller.abort(); + assert.throws( + () => source.requestSnapshot('mbta-rail', { signal: controller.signal }), + isAbort, + ); + assert.equal(calls, 0); +}); + +test('an abort while the response is in flight rejects the snapshot', async () => { + const controller = new AbortController(); + const source = createRailSource({ + fetchImpl: async () => { + controller.abort(); + return json({ trains: [] }); + }, + }); + await assert.rejects( + source.requestSnapshot('mbta-rail', { signal: controller.signal }), + isAbort, + ); +}); + +test('an abort while the body is read rejects the parsed snapshot', async () => { + const controller = new AbortController(); + const source = createRailSource({ + fetchImpl: async () => json({ trains: [] }), + }); + const response = await source.requestSnapshot('mbta-rail', { + signal: controller.signal, + }); + controller.abort(); + await assert.rejects(response.json(), isAbort); +}); + +test('timetables and trip updates use the same per-feed wrapper', async () => { + const requested = []; + const source = createRailSource({ + fetchImpl: async (url, init) => { + requested.push([url, init.headers.Accept]); + return url.includes('/schedule/') + ? json({ error: 'No rail schedule' }, 404) + : json({ trips: [{ tripId: 't1', delaySec: 120 }] }); + }, + }); + const schedule = await source.requestSchedule('mbta-rail'); + assert.equal(schedule.ok, false); + assert.equal(schedule.status, 404); + const updates = await source.requestUpdates('mbta-rail'); + assert.equal(updates.ok, true); + assert.deepEqual(await updates.json(), { + trips: [{ tripId: 't1', delaySec: 120 }], + }); + assert.deepEqual(requested, [ + ['/api/rail/schedule/mbta-rail', 'application/json'], + ['/api/rail/updates/mbta-rail', 'application/json'], + ]); + assert.throws(() => source.requestSchedule(''), TypeError); + assert.throws(() => source.requestUpdates(42), TypeError); +}); diff --git a/src/layers/rail/stopLabels.js b/src/layers/rail/stopLabels.js new file mode 100644 index 000000000..70381d93b --- /dev/null +++ b/src/layers/rail/stopLabels.js @@ -0,0 +1,275 @@ +/** + * @module layers/rail/stopLabels + * @description Which stops of a selected train's trip the map labels, the + * colour of each label's time chip, and the shared-overlay entries for them. + * + * Stops come from an injected provider (see createRailLayer in index.js), + * normally the stop list railSchedule.tripStops() returns. The fields read + * here are: + * + * { + * stopId: string, + * name: string, + * parentName?: string|null, // the station a platform stop belongs to + * lat: number, lon: number, + * state: 'passed' | 'next' | 'upcoming', + * atStop?: boolean, // the train dwells at its next stop + * relative: string, // 'T−4' (due in 4 min), 'T+3' (3 min ago) + * expectedArrival?: string, // 24 h, station local time: '14:32' + * expectedDeparture?: string, + * arrivalDelaySec?: number, // expected minus timetable + * departureDelaySec?: number, + * skipped?: boolean, // cancelled at this stop + * passThrough?: boolean, // the train runs through without stopping + * } + * + * The clock and the delay shown are those of the event `relative` counts to: + * the departure from a stop passed or dwelt at, else the arrival. + * + * The map labels at most four stops: the stop just passed, the next stop, the + * stop after it and the terminus. Every other stop is a dot without a label. + * + * Pure: no Cesium, no DOM. The caller supplies anchors. + */ + +/** Overlay source for the selected train's stop labels. */ +export const RAIL_STOP_OVERLAY_SOURCE_ID = 'rail-stops'; +export const RAIL_STOP_OVERLAY_SOURCE_OPTIONS = Object.freeze({ + cohortLimit: 4, + collisionCapacity: 4, + maxVisible: 4, + moving: false, +}); + +/** A stop this late or later shows an amber time chip (ms). */ +export const STOP_LATE_MS = 3 * 60_000; +/** ...and this late or later, a red one (ms). */ +export const STOP_VERY_LATE_MS = 10 * 60_000; + +/** Time-chip colours: on time, late, very late. */ +export const STOP_CHIP_COLORS = Object.freeze({ + onTime: '#C9D1D9', + late: '#FFB020', + veryLate: '#FF4D4F', +}); + +/** Label roles, most important first for the overlay host. */ +const ROLE_PRIORITY = Object.freeze({ + next: 4, + previous: 3, + afterNext: 2, + terminus: 1, +}); + +/** Above any ambient label another layer publishes. */ +const STOP_LABEL_BASE_PRIORITY = 1e12; + +/** + * The name a stop is shown by: its station's, when the timetable lists the + * platform as a child of one ("Town Hall Station", not "Town Hall Station + * Platform 3"), else its own. + * @param {object} stop + * @returns {string} + */ +export function stopName(stop) { + const station = + typeof stop?.parentName === 'string' ? stop.parentName.trim() : ''; + if (station) return station; + return typeof stop?.name === 'string' ? stop.name.trim() : ''; +} + +/** Whether a value is a drawable stop: named and placed. */ +function placedStop(stop) { + return ( + !!stop && + stopName(stop) !== '' && + Number.isFinite(stop.lat) && + Number.isFinite(stop.lon) && + Math.abs(stop.lat) <= 90 + ); +} + +/** Whether the train calls at a stop: worth a label, a card line, a role. */ +function callingStop(stop) { + return placedStop(stop) && stop.skipped !== true && stop.passThrough !== true; +} + +/** Whether the stop's shown event is its departure rather than its arrival. */ +function showsDeparture(stop) { + return stop?.state === 'passed' || stop?.atStop === true; +} + +/** + * The 24 h station-local clock of the stop's shown event. + * @param {object} stop + * @returns {string} + */ +export function stopClock(stop) { + const departure = showsDeparture(stop); + const first = departure ? stop?.expectedDeparture : stop?.expectedArrival; + const other = departure ? stop?.expectedArrival : stop?.expectedDeparture; + if (typeof first === 'string' && first) return first; + return typeof other === 'string' ? other : ''; +} + +/** + * How late the stop's shown event is expected, in ms (never negative: an + * early train is on time for the chip), or NaN when the delay is unknown. + * @param {object} stop + * @returns {number} + */ +export function stopDelayMs(stop) { + const departure = showsDeparture(stop); + const first = departure ? stop?.departureDelaySec : stop?.arrivalDelaySec; + const other = departure ? stop?.arrivalDelaySec : stop?.departureDelaySec; + // Only a number is a delay: null or a string is "unknown", not zero. + const seconds = Number.isFinite(first) ? first : other; + return Number.isFinite(seconds) ? Math.max(0, seconds * 1000) : NaN; +} + +/** + * The time-chip colour for a delay. + * @param {number} delayMs + * @returns {string} + */ +export function stopChipColor(delayMs) { + if (Number.isFinite(delayMs) && delayMs >= STOP_VERY_LATE_MS) + return STOP_CHIP_COLORS.veryLate; + if (Number.isFinite(delayMs) && delayMs >= STOP_LATE_MS) + return STOP_CHIP_COLORS.late; + return STOP_CHIP_COLORS.onTime; +} + +/** + * Whole minutes late, for the card's "(+n min late)", or 0 when on time or + * unknown. + * @param {object} stop + * @returns {number} + */ +export function stopLateMinutes(stop) { + const delay = stopDelayMs(stop); + return Number.isFinite(delay) ? Math.round(delay / 60_000) : 0; +} + +/** + * The stop just passed and the next stop of a trip, among the stops the train + * calls at. The next stop is the one the timetable marks `next`, else the + * first not yet passed; the previous is the last passed stop before it. + * @param {object[]} stops In trip order. + * @returns {{previous: number, next: number}} Indices, -1 when absent. + */ +export function stopRoleIndices(stops) { + const list = Array.isArray(stops) ? stops : []; + let next = list.findIndex( + (stop) => callingStop(stop) && stop.state === 'next', + ); + if (next < 0) + next = list.findIndex( + (stop) => callingStop(stop) && stop.state !== 'passed', + ); + let previous = -1; + const before = next < 0 ? list.length : next; + for (let i = before - 1; i >= 0; i--) { + if (callingStop(list[i]) && list[i].state === 'passed') { + previous = i; + break; + } + } + return { previous, next }; +} + +/** + * Choose what to draw for a trip: at most four labelled stops (previous, + * next, after-next, terminus) among those the train calls at, and a dot for + * every other placed stop (skipped and run-through stops too: the line passes + * them). + * @param {object[]} stops In trip order. + * @returns {{labels: {stop: object, index: number, role: string, + * priority: number}[], dots: {stop: object, index: number, passed: boolean}[]}} + */ +export function selectStopLabels(stops) { + const list = Array.isArray(stops) ? stops : []; + const { previous, next } = stopRoleIndices(list); + const chosen = new Map(); + const take = (index, role) => { + if (index < 0 || index >= list.length || chosen.has(index)) return; + if (!callingStop(list[index])) return; + chosen.set(index, role); + }; + take(next, 'next'); + take(previous, 'previous'); + if (next >= 0) { + let after = next + 1; + while (after < list.length && !callingStop(list[after])) after++; + take(after, 'afterNext'); + } + let last = list.length - 1; + while (last >= 0 && !callingStop(list[last])) last--; + // The terminus is worth a label while the train still has it ahead. + if (last >= 0 && list[last].state !== 'passed') take(last, 'terminus'); + const labels = [...chosen] + .map(([index, role]) => ({ + stop: list[index], + index, + role, + priority: ROLE_PRIORITY[role], + })) + .sort((a, b) => a.index - b.index); + const dots = []; + for (let i = 0; i < list.length; i++) { + if (chosen.has(i) || !placedStop(list[i])) continue; + dots.push({ stop: list[i], index: i, passed: list[i].state === 'passed' }); + } + return { labels, dots }; +} + +/** + * The text a stop label carries: the name, then the clock and the relative + * time, as `Yonkers · 14:32 T−4` once the host joins title and detail. + * @param {object} stop + * @returns {{title: string, detail: string}} + */ +export function stopLabelText(stop) { + const time = [stopClock(stop), stop?.relative] + .filter((part) => typeof part === 'string' && part !== '') + .join(' '); + return { title: stopName(stop), detail: time }; +} + +/** + * Shared-host entries for the chosen labels. Their time chip (the accent the + * host paints beside the text and along the leader) is coloured by delay. + * @param {string} trainKey The selected train, so ids change with it. + * @param {ReturnType['labels']} labels + * @param {(stop: object) => object|null} anchorFor Cartesian for a stop. + * @returns {object[]} + */ +export function createStopOverlayEntries(trainKey, labels, anchorFor) { + const entries = []; + for (const label of labels || []) { + const position = anchorFor(label.stop); + if (!position) continue; + const { title, detail } = stopLabelText(label.stop); + entries.push({ + id: `${trainKey}:${label.index}:${label.stop.stopId ?? label.stop.name}`, + variant: 'track', + title, + details: detail ? [detail] : [], + accent: stopChipColor(stopDelayMs(label.stop)), + priority: STOP_LABEL_BASE_PRIORITY + label.priority, + collisionGroup: 'ambient-label', + // The selected train's card is in the card domain; labels fit around it. + yieldToCollisionGroup: 'ambient-card', + paintLane: 'ambient-track', + position, + gapPx: 10, + verticalOnly: true, + placement: 'above', + horizonCull: true, + edgeFade: 'keyhole', + interactive: false, + metadata: { role: label.role }, + }); + } + return entries; +} diff --git a/src/layers/rail/stopLabels.test.mjs b/src/layers/rail/stopLabels.test.mjs new file mode 100644 index 000000000..d2620df22 --- /dev/null +++ b/src/layers/rail/stopLabels.test.mjs @@ -0,0 +1,275 @@ +// Which of a selected train's stops the map labels, how the label's time +// chip is coloured by delay, and the overlay entries the layer publishes. +// Stops have the shape railSchedule.tripStops() returns. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + STOP_CHIP_COLORS, + STOP_LATE_MS, + STOP_VERY_LATE_MS, + createStopOverlayEntries, + selectStopLabels, + stopChipColor, + stopClock, + stopDelayMs, + stopLabelText, + stopLateMinutes, + stopName, + stopRoleIndices, +} from './stopLabels.js'; + +/** A stop as tripStops() lists it; times and delays are illustrative. */ +function stop(index, name, state, extra = {}) { + return { + index, + stopId: `s${index}`, + name, + lat: 40.9 + index * 0.01, + lon: -73.9, + state, + atStop: false, + expectedArrival: `14:${String(20 + index * 4).padStart(2, '0')}`, + expectedDeparture: `14:${String(21 + index * 4).padStart(2, '0')}`, + arrivalDelaySec: 0, + departureDelaySec: 0, + skipped: false, + passThrough: false, + relative: state === 'passed' ? 'T+3' : 'T−4', + ...extra, + }; +} + +const trip = [ + stop(0, 'Grand Central', 'passed'), + stop(1, 'Harlem–125 St', 'passed'), + stop(2, 'Riverdale', 'passed'), + stop(3, 'Yonkers', 'next'), + stop(4, 'Glenwood', 'upcoming'), + stop(5, 'Hastings', 'upcoming'), + stop(6, 'Poughkeepsie', 'upcoming'), +]; + +test('the map labels previous, next, after-next and terminus only', () => { + const { labels, dots } = selectStopLabels(trip); + assert.deepEqual( + labels.map((label) => [label.stop.name, label.role]), + [ + ['Riverdale', 'previous'], + ['Yonkers', 'next'], + ['Glenwood', 'afterNext'], + ['Poughkeepsie', 'terminus'], + ], + ); + assert.deepEqual( + dots.map((dot) => [dot.stop.name, dot.passed]), + [ + ['Grand Central', true], + ['Harlem–125 St', true], + ['Hastings', false], + ], + ); + // The next stop outranks the rest when labels compete for room. + const top = labels.reduce((a, b) => (b.priority > a.priority ? b : a)); + assert.equal(top.role, 'next'); +}); + +test('never more than four labels, and none repeated near the end', () => { + const nearEnd = [ + stop(0, 'A', 'passed'), + stop(1, 'B', 'passed'), + stop(2, 'C', 'next'), + stop(3, 'D', 'upcoming'), + ]; + const { labels, dots } = selectStopLabels(nearEnd); + // After-next is the terminus: one label, not two. + assert.deepEqual( + labels.map((label) => label.role), + ['previous', 'next', 'afterNext'], + ); + assert.deepEqual( + dots.map((dot) => dot.stop.name), + ['A'], + ); + for (let n = 0; n <= 12; n++) { + const list = Array.from({ length: n }, (_, i) => + stop( + i, + `S${i}`, + i < n / 2 ? 'passed' : i === Math.ceil(n / 2) ? 'next' : 'upcoming', + ), + ); + const chosen = selectStopLabels(list); + assert.ok(chosen.labels.length <= 4, String(n)); + assert.equal(chosen.labels.length + chosen.dots.length, n); + } +}); + +test('a finished trip labels only the stop it last called at', () => { + const done = trip.map((s) => ({ ...s, state: 'passed' })); + const { labels } = selectStopLabels(done); + assert.deepEqual( + labels.map((label) => [label.stop.name, label.role]), + [['Poughkeepsie', 'previous']], + ); +}); + +test('skipped and run-through stops get a dot, never a label or a role', () => { + const list = [ + stop(0, 'A', 'passed'), + stop(1, 'B', 'passed', { passThrough: true }), + stop(2, 'C', 'next', { skipped: true }), + stop(3, 'D', 'upcoming'), + stop(4, 'E', 'upcoming'), + stop(5, 'F', 'upcoming', { skipped: true }), + ]; + assert.deepEqual(stopRoleIndices(list), { previous: 0, next: 3 }); + const { labels, dots } = selectStopLabels(list); + assert.deepEqual( + labels.map((label) => [label.stop.name, label.role]), + [ + ['A', 'previous'], + ['D', 'next'], + ['E', 'afterNext'], + ], + ); + assert.deepEqual( + dots.map((dot) => dot.stop.name), + ['B', 'C', 'F'], + ); +}); + +test('unplaced or unnamed stops are neither labelled nor dotted', () => { + const list = [ + stop(0, 'A', 'passed'), + { ...stop(1, 'B', 'next'), lat: NaN }, + stop(2, '', 'upcoming'), + stop(3, 'D', 'upcoming'), + ]; + const { labels, dots } = selectStopLabels(list); + assert.deepEqual( + labels.map((label) => label.stop.name), + ['A', 'D'], + ); + assert.deepEqual(dots, []); + assert.deepEqual(selectStopLabels(null), { labels: [], dots: [] }); + assert.deepEqual(stopRoleIndices(undefined), { previous: -1, next: -1 }); +}); + +test('the time chip is neutral on time, amber from 3 min, red from 10 min', () => { + assert.equal(STOP_LATE_MS, 180_000); + assert.equal(STOP_VERY_LATE_MS, 600_000); + assert.equal(stopChipColor(0), STOP_CHIP_COLORS.onTime); + assert.equal(stopChipColor(STOP_LATE_MS - 1), STOP_CHIP_COLORS.onTime); + assert.equal(stopChipColor(STOP_LATE_MS), STOP_CHIP_COLORS.late); + assert.equal(stopChipColor(STOP_VERY_LATE_MS - 1), STOP_CHIP_COLORS.late); + assert.equal(stopChipColor(STOP_VERY_LATE_MS), STOP_CHIP_COLORS.veryLate); + assert.equal(stopChipColor(3 * 3_600_000), STOP_CHIP_COLORS.veryLate); + assert.equal(stopChipColor(NaN), STOP_CHIP_COLORS.onTime); + const colors = new Set(Object.values(STOP_CHIP_COLORS)); + assert.equal(colors.size, 3); +}); + +test('the delay and clock are those of the event the chip counts to', () => { + const late = stop(3, 'Yonkers', 'next', { + arrivalDelaySec: 240, + departureDelaySec: 660, + }); + // Arriving: the arrival's clock and delay. + assert.equal(stopClock(late), '14:32'); + assert.equal(stopDelayMs(late), 240_000); + assert.equal(stopChipColor(stopDelayMs(late)), STOP_CHIP_COLORS.late); + assert.equal(stopLateMinutes(late), 4); + // Standing at the stop, or past it: the departure's. + const dwelling = { ...late, atStop: true }; + assert.equal(stopClock(dwelling), '14:33'); + assert.equal(stopDelayMs(dwelling), 660_000); + assert.equal(stopChipColor(stopDelayMs(dwelling)), STOP_CHIP_COLORS.veryLate); + assert.equal(stopClock({ ...late, state: 'passed' }), '14:33'); + // An early train is on time; a missing delay or clock falls back. + assert.equal(stopDelayMs({ ...late, arrivalDelaySec: -120 }), 0); + assert.equal(stopLateMinutes({ ...late, arrivalDelaySec: -120 }), 0); + assert.equal(stopDelayMs({ ...late, arrivalDelaySec: undefined }), 660_000); + assert.ok( + Number.isNaN( + stopDelayMs({ name: 'X', arrivalDelaySec: null, departureDelaySec: 'x' }), + ), + ); + assert.equal(stopClock({ ...late, expectedArrival: '' }), '14:33'); + assert.equal(stopClock({ name: 'X' }), ''); +}); + +test('a label reads name, then clock and relative time', () => { + assert.deepEqual(stopLabelText(stop(3, 'Yonkers', 'next')), { + title: 'Yonkers', + detail: '14:32 T−4', + }); + assert.deepEqual(stopLabelText(stop(2, 'Riverdale', 'passed')), { + title: 'Riverdale', + detail: '14:29 T+3', + }); + assert.deepEqual(stopLabelText({ name: ' Glenwood ', relative: '' }), { + title: 'Glenwood', + detail: '', + }); +}); + +test('overlay entries collide as labels and yield to the selected card', () => { + const late = trip.map((s) => + s.name === 'Yonkers' ? { ...s, arrivalDelaySec: 720 } : s, + ); + const { labels } = selectStopLabels(late); + const anchors = []; + const entries = createStopOverlayEntries( + 'rail:mta-metro-north:123', + labels, + (s) => { + anchors.push(s.name); + return s.name === 'Glenwood' ? null : { x: 1, y: 2, z: 3 }; + }, + ); + // A stop without an anchor is left out, not drawn at the origin. + assert.deepEqual( + entries.map((entry) => entry.title), + ['Riverdale', 'Yonkers', 'Poughkeepsie'], + ); + assert.equal(anchors.length, 4); + const yonkers = entries[1]; + assert.equal(yonkers.variant, 'track'); + assert.deepEqual(yonkers.details, ['14:32 T−4']); + assert.equal(yonkers.accent, STOP_CHIP_COLORS.veryLate); + assert.equal(yonkers.collisionGroup, 'ambient-label'); + assert.equal(yonkers.yieldToCollisionGroup, 'ambient-card'); + assert.equal(yonkers.protected, undefined); + assert.equal(entries[0].accent, STOP_CHIP_COLORS.onTime); + assert.ok(yonkers.priority > entries[0].priority); + assert.ok(entries[0].priority > entries[2].priority); + assert.equal(new Set(entries.map((entry) => entry.id)).size, entries.length); + assert.ok( + entries.every((entry) => entry.id.startsWith('rail:mta-metro-north:123:')), + ); +}); + +test('a platform stop is shown by its station’s name', () => { + const platform = stop(3, 'Wynyard Station Platform 4', 'next', { + parentName: 'Wynyard Station', + }); + assert.equal(stopName(platform), 'Wynyard Station'); + assert.equal(stopLabelText(platform).title, 'Wynyard Station'); + assert.equal( + stopName({ ...platform, parentName: null }), + 'Wynyard Station Platform 4', + ); + assert.equal( + stopName({ ...platform, parentName: ' ' }), + 'Wynyard Station Platform 4', + ); + // A stop known only by its station is still drawable. + const { labels } = selectStopLabels([ + stop(0, 'A', 'passed'), + { ...stop(1, '', 'next'), parentName: 'Central' }, + ]); + assert.deepEqual( + labels.map((label) => stopName(label.stop)), + ['A', 'Central'], + ); +}); diff --git a/src/layers/rail/viewport.js b/src/layers/rail/viewport.js new file mode 100644 index 000000000..03771bd6c --- /dev/null +++ b/src/layers/rail/viewport.js @@ -0,0 +1,224 @@ +import * as Cesium from 'cesium'; +import { + ACTIVATION_ENTER_ALTITUDE_M, + ACTIVATION_EXIT_ALTITUDE_M, + CAMERA_DEBOUNCE_MS, + RANGE_SLACK_KM, + isLiveRailPositionSource, + railCatalogRetryDelayMs, +} from './policy.js'; +import { + RAIL_ENABLED_FEEDS, + railFeedNeedsSchedulePack, + railFeedsInRange, +} from '../../data/railFeeds.js'; + +/** + * Camera-driven feed activation: only the feeds whose coverage circle contains + * the look-at point are polled, and only below the altitude gate; the selected + * train's feed is polled for it wherever the camera is. + * @param {object} context + * @returns {object} + */ +export function createViewport({ state, services, parts, source }) { + const { governorRequestRender } = services.render; + + function cameraAltitude() { + const carto = state._viewer?.camera?.positionCartographic; + return carto && Number.isFinite(carto.height) ? carto.height : Infinity; + } + + function cameraCenter() { + const viewer = state._viewer; + const rect = viewer?.camera?.computeViewRectangle?.( + viewer.scene.globe?.ellipsoid, + ); + const point = rect + ? Cesium.Rectangle.center(rect) + : viewer?.camera?.positionCartographic; + if (!point) return null; + return { + lat: Cesium.Math.toDegrees(point.latitude), + lon: Cesium.Math.toDegrees(point.longitude), + }; + } + + /** + * Bundled feeds used until the catalog has loaded: those that need no + * schedule pack. A live feed that reads its routes from one is used only + * once the catalog lists it, which it does only once its pack is built. + */ + const PACKLESS_FEEDS = RAIL_ENABLED_FEEDS.filter( + (feed) => + isLiveRailPositionSource(feed.positions) && + !railFeedNeedsSchedulePack(feed), + ); + + /** The server's catalog once it has loaded, else the packless feeds. */ + function pollableFeeds() { + return state._catalog || PACKLESS_FEEDS; + } + + /** + * Read the server's feed catalog once per layer instance. It leaves out + * the feeds whose packs are not built, which are then never polled; until + * it answers (or with a source that has no catalog), only the feeds that + * need no pack are used. A feed it lists without live positions is never + * polled: no train is placed from a timetable. A catalog that fails is + * asked for again after railCatalogRetryDelayMs while the layer is on. + */ + function loadCatalog() { + if (state._catalog || state._catalogRequest) return; + if (typeof source.getFeeds !== 'function') return; + clearTimeout(state._catalogRetryTimer); + state._catalogRetryTimer = null; + const controller = new AbortController(); + const request = { controller }; + state._catalogRequest = request; + (async () => { + try { + const feeds = await source.getFeeds({ signal: controller.signal }); + if (state._catalogRequest !== request) return; + const usable = feeds.filter( + (feed) => + typeof feed?.id === 'string' && + Number.isFinite(feed?.center?.lat) && + Number.isFinite(feed?.center?.lon) && + Number.isFinite(feed?.loadRadiusKm) && + isLiveRailPositionSource(feed?.positions), + ); + state._catalog = Object.freeze(usable); + state._catalogFailures = 0; + state._catalogRequest = null; + logCatalog(usable); + runProximityCheck(); + } catch (error) { + if (error?.name === 'AbortError' || state._catalogRequest !== request) + return; + state._catalogRequest = null; + state._catalogFailures += 1; + const delay = railCatalogRetryDelayMs(state._catalogFailures); + console.warn( + `[Data:Rail] feed catalog unavailable, trying again in ${Math.round(delay / 1000)}s:`, + error?.message || error, + ); + if (!state._enabled) return; + state._catalogRetryTimer = setTimeout(() => { + state._catalogRetryTimer = null; + if (state._enabled) loadCatalog(); + }, delay); + } finally { + if (state._catalogRequest === request) state._catalogRequest = null; + } + })(); + } + + /** Say once how many live feeds the catalog lists. */ + function logCatalog(feeds) { + console.log(`[Data:Rail] Catalog lists ${feeds.length} live train feeds`); + } + + function cancelCatalog() { + state._catalogRequest?.controller.abort(); + state._catalogRequest = null; + clearTimeout(state._catalogRetryTimer); + state._catalogRetryTimer = null; + } + + function runProximityCheck() { + if (globalThis.document?.visibilityState === 'hidden') return; + if (!state._enabled || !state._viewer) return; + // The feeds in use until now are marked seen before any is let go. + parts.routes.releaseIdleFeeds(Date.now()); + const altitude = cameraAltitude(); + state._altitudeGateOpen = state._altitudeGateOpen + ? altitude <= ACTIVATION_EXIT_ALTITUDE_M + : altitude <= ACTIVATION_ENTER_ALTITUDE_M; + const center = state._altitudeGateOpen ? cameraCenter() : null; + const desired = new Map(); + if (center) { + const feeds = pollableFeeds(); + for (const feed of railFeedsInRange(center.lat, center.lon, 0, feeds)) + desired.set(feed.id, feed); + for (const feed of railFeedsInRange( + center.lat, + center.lon, + RANGE_SLACK_KM, + feeds, + )) + if (state._activeFeeds.has(feed.id)) desired.set(feed.id, feed); + } + // The selected train's feed stays polled however far out or away the + // camera goes, so the train and its line stay on the map; while nothing + // else would keep the feed, it carries the selected train alone and its + // other trains go as they would with the feed. + const selected = state._trains.get(state._selectedKey); + const held = + selected && + !desired.has(selected.feedId) && + state._activeFeeds.has(selected.feedId) + ? selected.feedId + : null; + if (held) desired.set(held, state._activeFeeds.get(held)); + const released = state._heldFeedId !== held ? state._heldFeedId : null; + state._heldFeedId = held; + for (const feedId of [...state._activeFeeds.keys()]) { + if (desired.has(feedId)) continue; + state._inFlight.get(feedId)?.controller.abort(); + state._inFlight.delete(feedId); + state._activeFeeds.delete(feedId); + state._feedStatus.delete(feedId); + parts.ingestion.removeFeedTrains(feedId); + } + if (held) parts.ingestion.removeFeedTrains(held, state._selectedKey); + // A feed the selection held that the view wants again brings its other + // trains back now, not at the next poll. + if (released && desired.has(released)) + void parts.ingestion.pollFeed(desired.get(released), state._generation); + for (const [feedId, feed] of desired) { + if (state._activeFeeds.has(feedId)) continue; + state._activeFeeds.set(feedId, feed); + void parts.ingestion.pollFeed(feed, state._generation); + } + parts.ingestion.sweepAgedTrains(Date.now()); + parts.routes.onCamera(); + parts.routes.applyNetwork(); + parts.rendering.syncRenderHold(); + state._dataManager?.refreshLayerStats?.(); + governorRequestRender('rail-proximity'); + } + + function onCameraChanged() { + // The network's altitude fade follows the zoom without the debounce; it + // only writes when the fade crosses a step. + parts.routes.onCamera(); + clearTimeout(state._cameraDebounceTimer); + state._cameraDebounceTimer = setTimeout(() => { + state._cameraDebounceTimer = null; + runProximityCheck(); + }, CAMERA_DEBOUNCE_MS); + } + + /** + * Re-run the check off the caller's stack: a selection changed, so the + * feed it holds may change too. + */ + function requestProximityCheck() { + if (!state._enabled) return; + clearTimeout(state._cameraDebounceTimer); + state._cameraDebounceTimer = setTimeout(() => { + state._cameraDebounceTimer = null; + runProximityCheck(); + }, 0); + } + + return { + runProximityCheck, + onCameraChanged, + requestProximityCheck, + cameraAltitude, + loadCatalog, + cancelCatalog, + pollableFeeds, + }; +} diff --git a/src/navigationPolicy.js b/src/navigationPolicy.js index 2980b21e6..a9b7a2334 100644 --- a/src/navigationPolicy.js +++ b/src/navigationPolicy.js @@ -25,6 +25,7 @@ const COCKPIT_REFUSALS = Object.freeze({ fire: msg('Exit cockpit to fly to a fire'), sighting: msg('Exit cockpit to fly to a sighting'), 'sighting-area': msg('Exit cockpit to fly to a sighting area'), + train: msg('Exit cockpit to fly to a train'), route: msg('Exit cockpit to fly to a route'), file: msg('Exit cockpit to fly to a file'), drawing: msg('Exit cockpit to fly to a drawing'), diff --git a/src/share/viewCodec.js b/src/share/viewCodec.js index f491e209e..4031c9b31 100644 --- a/src/share/viewCodec.js +++ b/src/share/viewCodec.js @@ -304,6 +304,13 @@ export const SHARE_VIEW_LAYER_OPTION_ABSENT = Object.freeze({ sinceDays: 0, }), radio: Object.freeze({ filter: 'all', volume: 0.8 }), + rail: Object.freeze({ + transit: true, + local: true, + regional: true, + national: true, + international: true, + }), 'wildlife-sightings': Object.freeze({ confirmed: false, taxa: 'all' }), }); diff --git a/src/sources/railService.js b/src/sources/railService.js new file mode 100644 index 000000000..c6d92344b --- /dev/null +++ b/src/sources/railService.js @@ -0,0 +1,968 @@ +/** + * Realtime train proxy for the Rail data layer. + * + * Only URLs registered in `src/data/railFeeds.js` are ever fetched: the browser + * names a registered id, never a URL. The request policy is the Transit + * proxy's (src/sources/transitService.js), applied to the rail registry: hop-by-hop + * redirects on the feed's own https origin, a byte cap, a memory cache with + * single-flight refresh (15 s unless the feed sets its own window), + * serve-stale-on-failure for up to 10 minutes, a fixed-window admission + * limiter and a per-feed cooldown ladder. No disk cache and no history store. + * + * Every registered feed is open: no request carries a credential. + */ + +import { coalesceProxyRequest, readResponseBytesCapped } from './httpBody.js'; +import { makeRateLimiter } from './rateLimit.js'; +import { + publicRailCatalog, + railEnabledFeeds, + railFeedNeedsSchedulePack, +} from '../data/railFeeds.js'; +import { + TRANSIT_MAX_REDIRECTS, + isTransitRedirectStatus, + transitRedirectDecision, +} from '../data/transitProxy.js'; +import { + RAIL_ADMISSION_MAX_PER_FEED, + RAIL_ADMISSION_WINDOW_MS, + RAIL_PATH_PACK_MAX_BYTES, + RAIL_PROXY_TIMEOUT_MS, + RailFeedShapeError, + buildRailSnapshot, + buildRailTripUpdatesSnapshot, + railAdmissionGlobalMax, + isAcceptableRailUpstreamUrl, + nextRailBackoffMs, + railCacheStateFor, + railFeedTtlMs, + railMaxBodyBytes, + railResponseHeaders, + railUpstreamHeaders, + resolveRailRoute, +} from '../data/railProxy.js'; +import { + RAIL_SCHEDULE_PACK_VERSION, + liveDelaySec, + schedulePackEndMs, +} from '../data/railSchedule.js'; +import { createRunIndex } from '../data/railScheduleIndex.js'; + +const JSON_NO_STORE = Object.freeze({ + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', +}); + +/** + * A request header, from a Fetch Request's Headers or a Node-style headers + * object (lowercase names), or null. + * @param {{headers?: Headers|Record}} incoming + * @param {string} name Lowercase. + * @returns {string|null} + */ +function requestHeader(incoming, name) { + const headers = incoming?.headers; + if (!headers) return null; + if (typeof headers.get === 'function') return headers.get(name); + const value = headers[name]; + if (Array.isArray(value)) return value.join(', '); + return typeof value === 'string' ? value : null; +} + +/** + * The strong entity tag of a pack version (a build stamp, or a file's time + * and size): percent-encoded, so any version is a valid tag and two versions + * never share one. + * @param {string} version + * @returns {string} + */ +function packEtag(version) { + return `"${encodeURIComponent(version)}"`; +} + +/** Whether an If-None-Match header names this tag (weak comparison). */ +function etagMatches(header, etag) { + if (!header) return false; + const own = etag.replace(/^W\//, ''); + return header + .split(',') + .map((tag) => tag.trim()) + .some((tag) => tag === '*' || tag.replace(/^W\//, '') === own); +} + +/** + * Fetch a registered feed URL, resolving each redirect hop BEFORE following + * it (the Transit rule, transitRedirectDecision), so a hop that leaves the + * URL's https origin is refused without being contacted. + * @param {object} feed Registry entry. + * @param {AbortSignal} signal Abort signal for the whole hop chain. + * @param {typeof fetch} [fetchImpl] Injected for tests. + * @param {{etag?: string|null, lastModified?: string|null}} [validators] + * @param {{url?: string}} [options] Which registered URL: the positions + * `url` by default, or `tripUpdatesUrl`. + * @returns {Promise<{response: Response, finalUrl: string}>} + */ +export async function fetchRailFeed( + feed, + signal, + fetchImpl = fetch, + validators = null, + { url = feed.url } = {}, +) { + let current = url; + for (let hop = 0; hop <= TRANSIT_MAX_REDIRECTS; hop += 1) { + const response = await fetchImpl(current, { + signal, + method: 'GET', + headers: railUpstreamHeaders(feed, validators), + redirect: 'manual', + }); + // A 304 is the answer to our own conditional request, not a redirect. + if (!isTransitRedirectStatus(response?.status)) { + return { response, finalUrl: current }; + } + const decision = transitRedirectDecision( + url, + current, + response.headers.get('location'), + ); + try { + await response.body?.cancel(); + } catch { + /* no-op */ + } + if (!decision.ok) throw new Error(`upstream ${decision.reason}`); + if (hop === TRANSIT_MAX_REDIRECTS) { + throw new Error('upstream redirected too many times'); + } + current = decision.url; + } + throw new Error('upstream redirected too many times'); +} + +/** + * A Response body for a pack as the reader returned it: text as it is, + * bytes as the one chunk of a stream. A Response made from bytes directly + * copies them; a stream hands the reader's own buffer on, so a large pack + * is never decoded or copied on its way out. + * @param {string|Uint8Array|ArrayBuffer} body + * @returns {string|ReadableStream} + */ +function packBody(body) { + if (typeof body === 'string') return body; + const bytes = body instanceof Uint8Array ? body : new Uint8Array(body); + return new ReadableStream({ + start(controller) { + controller.enqueue(bytes); + controller.close(); + }, + }); +} + +/** When a viewer asks again for a pack that could not be read (s). */ +const PACK_UNREADABLE_RETRY_S = 60; + +/** How long an answer to "is this feed's schedule pack built?" is kept. */ +export const SCHEDULE_PACK_PROBE_TTL_MS = 60_000; +const SCHEDULE_PACK_PROBE_RETRY_S = SCHEDULE_PACK_PROBE_TTL_MS / 1000; + +/** + * Runtime-agnostic request service for `/api/rail`, shared by the Vite + * plugin and any other host. + * + * GET /api/rail/feeds → public catalog (coverage + credit) + * GET /api/rail/trains/ → decoded, train-only snapshot as JSON + * GET /api/rail/updates/ → the feed's rail trip updates as JSON + * GET /api/rail/paths/ → the feed's track path pack, when built + * GET /api/rail/schedule/ → the feed's timetable pack, when built + * + * Path and schedule packs are offline build output + * (scripts/build-rail-paths.mjs). The service never touches storage itself: + * the host injects a pack reader for each, `readPathPack` and + * `readSchedulePack` (an fs reader in development). A reader receives a + * registered, enabled feed id and the byte cap, and resolves to the pack's + * JSON text or bytes, or null when the feed has no pack; a pack over the cap + * may instead be refused by throwing an error with `code: 'PACK_TOO_LARGE'`. + * Bytes are served as they are, never decoded or copied here. Null is the + * only "no pack" (404); a reader that throws is a fault here (503), which a + * viewer asks again about. + * + * Every train is placed where its operator reports it; a schedule pack only + * adds what the live feed leaves out (line names, stop names, next stops). + * Packs are opt-in build output: a live feed whose trains are named only + * through its pack (`railFeedNeedsSchedulePack`: `routesFrom: 'schedule'`) + * is listed in the catalog, and its trains and trip updates are served, only + * once the pack is built, so feeds nobody has built stay out of sight + * instead of reading as unavailable. Whether a pack + * is built is asked of `hasSchedulePack(feedId)` when given, else of + * `schedulePackVersion` (null: not built), else of `readSchedulePack` (null: + * not built); with none of them no such feed is built. Answers are kept for + * SCHEDULE_PACK_PROBE_TTL_MS; a probe that fails counts the pack as built, + * so the failure shows where its pack is read. + * + * `schedulePackVersion(feedId)` names the pack as it is now (a build stamp, + * or a file's time and size), or null when there is none. With it, what the + * realtime routes read from a pack is rebuilt within + * SCHEDULE_PACK_PROBE_TTL_MS of a new build; without it, within an hour. + * `pathPackVersion(feedId)` does the same for path packs. + * + * A path or schedule pack is served `no-cache`: a feed's two packs are read + * as a pair (the schedule's `pathsBuiltAt` names its path pack's `builtAt`), + * so a browser asks again each time instead of keeping one of them for an + * hour past a rebuild. With the pack's version, the answer carries it as its + * ETag, and a request whose If-None-Match names it is answered 304 without + * reading the pack. + * + * @param {{fetchImpl?: typeof fetch, + * readPathPack?: (feedId: string, options: {maxBytes: number}) => + * Promise, + * readSchedulePack?: (feedId: string, options: {maxBytes: number}) => + * Promise, + * hasSchedulePack?: (feedId: string) => Promise, + * schedulePackVersion?: (feedId: string) => Promise, + * pathPackVersion?: (feedId: string) => Promise, + * now?: () => number}} [options] + * @returns {{handle: (request: Request) => Promise, close: () => void}} + */ +export function createRailService({ + fetchImpl = fetch, + readPathPack = null, + readSchedulePack = null, + hasSchedulePack = null, + schedulePackVersion = null, + pathPackVersion = null, + now: clock = () => Date.now(), +} = {}) { + /** Whether a feed's timetable pack is built, as the host can tell. */ + const probeSchedulePack = + typeof hasSchedulePack === 'function' + ? hasSchedulePack + : typeof schedulePackVersion === 'function' + ? async (feedId) => (await schedulePackVersion(feedId)) != null + : typeof readSchedulePack === 'function' + ? async (feedId) => { + try { + const body = await readSchedulePack(feedId, { + maxBytes: RAIL_PATH_PACK_MAX_BYTES, + }); + return body !== null && body !== undefined; + } catch (error) { + // Built, but refused: the pack's own route says so. + if (error?.code === 'PACK_TOO_LARGE') return true; + throw error; + } + } + : null; + /** Probe answers by feed id: {at, built}. */ + const schedulePackProbes = new Map(); + async function schedulePackBuilt(feedId) { + if (!probeSchedulePack) return false; + const seen = schedulePackProbes.get(feedId); + if (seen && clock() - seen.at < SCHEDULE_PACK_PROBE_TTL_MS) + return seen.built; + let built; + try { + built = (await probeSchedulePack(feedId)) === true; + } catch (error) { + console.warn(`[rail] ${feedId}: schedule pack probe failed`, error); + built = true; + } + schedulePackProbes.set(feedId, { at: clock(), built }); + return built; + } + /** Whether the feed can be served: it needs no pack, or its pack is built. */ + async function feedServable(feed) { + return !railFeedNeedsSchedulePack(feed) || schedulePackBuilt(feed.id); + } + /** The catalog, with pack-routed feeds whose pack is unbuilt left out. */ + async function catalog() { + const built = new Set(); + await Promise.all( + railEnabledFeeds() + .filter((feed) => railFeedNeedsSchedulePack(feed)) + .map(async (feed) => { + if (await schedulePackBuilt(feed.id)) built.add(feed.id); + }), + ); + return publicRailCatalog({ + schedulePackBuilt: (feedId) => built.has(feedId), + }); + } + /** + * Snapshots by `:`, so a feed's positions and its trip + * updates never share an entry, a cooldown or an admission count. + * @type {Map} + */ + const cache = new Map(); + let closed = false; + const inFlight = new Map(); + const controllers = new Set(); + /** @type {Map} */ + const cooldown = new Map(); + const admit = makeRateLimiter({ + windowMs: RAIL_ADMISSION_WINDOW_MS, + max: RAIL_ADMISSION_MAX_PER_FEED, + // Scales with the registry (railAdmissionGlobalMax): a backstop, while + // each feed's freshness window protects its operator. + globalMax: railAdmissionGlobalMax(), + now: clock, + }); + + /** + * What the realtime path reads from a feed's schedule pack, built when the + * pack is read and kept an hour, or until the pack's version changes + * (retried after five minutes when the pack is missing or unreadable). + * The pack's text and its parsed form are released once these are built; + * `runs` keeps the pack's trips, profiles and services for the hour. + * - `routes`: trip id to pack route id, for feeds whose realtime trips do + * not carry the route their classifier reads (`routesFrom: + * 'schedule'`). Without it those trains are dropped, not misfiled. + * - `runs`: dated runs by train number, for positions that name trains + * by number (`positionsByTrainNumber`: Irish Rail's train codes), so a + * reported train takes its line and stops. Its position stays the + * reported one. + * - `endsMs`: when the last run the pack holds arrives (null when it + * names no window), past which the pack is stale. + * A pack is refused, as one that cannot be read, when it is of another + * format version or another feed, or once its window has ended + * (`endsMs`): it names no train running now. A feed that cannot be read + * without its pack answers 503 rather than an empty list when every + * record was dropped (assertPackRead). With `schedulePackVersion`, an + * entry is checked against the pack's version once per + * SCHEDULE_PACK_PROBE_TTL_MS and rebuilt when the pack changed. + * @type {Map}>} + */ + const scheduleLookups = new Map(); + const SCHEDULE_LOOKUPS_TTL_MS = 60 * 60_000; + const SCHEDULE_LOOKUPS_RETRY_MS = 5 * 60_000; + const NO_LOOKUPS = Object.freeze({ + routes: new Map(), + runs: null, + endsMs: null, + }); + + /** Positions that name trains by number (Irish Rail's train codes). */ + function namesTrains(feed) { + return feed.positionsByTrainNumber === true; + } + + /** + * Feeds that read the pack's trip-to-route lookup (`routes`): their + * realtime trips do not carry the route their classifier reads, so + * neither of the feed's routes keeps anything without the pack. + */ + function readsPackRoutes(feed) { + return feed.routesFrom === 'schedule'; + } + + function needsSchedule(feed) { + return readsPackRoutes(feed) || namesTrains(feed); + } + + /** + * Refuse a snapshot in which every record was dropped, for a feed whose + * records are classified by its schedule pack's routes: the pack is + * missing, unreadable or refused, or names none of the trips the feed runs + * now (a stale pack: Renfe's trip ids carry their date). That is + * "unavailable" (503), not a healthy feed with no trains. A snapshot that + * keeps any train still answers (Irish Rail's train codes classify its + * trains without the pack). + */ + function assertPackRead(feed, snapshot) { + if (!readsPackRoutes(feed)) return; + if (snapshot.count > 0) return; + const refused = snapshot.dropped ?? 0; + if (refused === 0) return; + const error = new Error( + `schedule pack unavailable: ${refused} records need it`, + ); + error.code = 'SCHEDULE_UNAVAILABLE'; + throw error; + } + + /** Why a decoded schedule pack cannot be read for this feed now, or null. */ + function packRefusal(feed, raw, endsMs, at) { + if (raw?.version !== RAIL_SCHEDULE_PACK_VERSION) + return `version ${JSON.stringify(raw?.version ?? null)} is unknown`; + if (raw.feedId !== feed.id) + return `it is for ${JSON.stringify(raw.feedId ?? null)}`; + if (endsMs !== null && at >= endsMs) + return Number.isFinite(endsMs) + ? `its window ended ${new Date(endsMs).toISOString()}` + : 'it holds no run'; + return null; + } + + async function readScheduleLookups(feed) { + if (typeof readSchedulePack !== 'function') return null; + const body = await readSchedulePack(feed.id, { + maxBytes: RAIL_PATH_PACK_MAX_BYTES, + }); + if (body === null || body === undefined) return null; + const text = + typeof body === 'string' ? body : new TextDecoder().decode(body); + if (text.length > RAIL_PATH_PACK_MAX_BYTES) + throw new Error('schedule pack over the cap'); + const raw = JSON.parse(text); + const endsMs = schedulePackEndMs(raw); + const refusal = packRefusal(feed, raw, endsMs, clock()); + if (refusal) throw new Error(`schedule pack refused: ${refusal}`); + const routes = new Map(); + const trips = raw.trips; + if (readsPackRoutes(feed) && trips && typeof trips === 'object') + for (const [tripId, trip] of Object.entries(trips)) + if (typeof trip?.route === 'string') routes.set(tripId, trip.route); + return { + routes, + runs: namesTrains(feed) ? createRunIndex(raw) : null, + endsMs, + }; + } + + /** + * A pack's version now, or undefined when the host names none or it + * cannot be read. + * @param {'schedule'|'paths'} kind + * @param {string} feedId + */ + async function packVersionNow(kind, feedId) { + const versionOf = + kind === 'schedule' ? schedulePackVersion : pathPackVersion; + if (typeof versionOf !== 'function') return undefined; + try { + return (await versionOf(feedId)) ?? null; + } catch (error) { + console.warn( + `[rail-proxy] ${feedId} ${kind === 'schedule' ? 'schedule' : 'path'} pack version unreadable: ${error?.message || error}`, + ); + return undefined; + } + } + + /** + * Build a feed's lookups, tied to the pack version read just before the + * pack: a build that lands in between is seen at the next check. + */ + function buildScheduleLookups(feed, at) { + const fresh = { + at, + checkedAt: at, + ttl: SCHEDULE_LOOKUPS_TTL_MS, + version: undefined, + /** The last version check: resolves true while the pack is unchanged. */ + current: true, + lookups: null, + }; + fresh.lookups = (async () => { + fresh.version = await packVersionNow('schedule', feed.id); + return readScheduleLookups(feed); + })().then( + (lookups) => { + if (!lookups) fresh.ttl = SCHEDULE_LOOKUPS_RETRY_MS; + return lookups || NO_LOOKUPS; + }, + (error) => { + fresh.ttl = SCHEDULE_LOOKUPS_RETRY_MS; + console.warn( + `[rail-proxy] ${feed.id} schedule pack unreadable: ${error?.message || error}`, + ); + return NO_LOOKUPS; + }, + ); + scheduleLookups.set(feed.id, fresh); + return fresh; + } + + /** + * Whether a kept entry still matches the pack: with a version from the + * host, asked at most once per SCHEDULE_PACK_PROBE_TTL_MS (concurrent + * askers share the one check). + */ + async function lookupsCurrent(feed, entry, at) { + if (typeof schedulePackVersion !== 'function') return true; + if (at - entry.checkedAt >= SCHEDULE_PACK_PROBE_TTL_MS) { + entry.checkedAt = at; + entry.current = (async () => { + await entry.lookups; + const version = await packVersionNow('schedule', feed.id); + // An unreadable version keeps what is built. + return version === undefined || version === entry.version; + })(); + } + return entry.current; + } + + async function scheduleLookupsFor(feed) { + if (!needsSchedule(feed)) return NO_LOOKUPS; + const at = clock(); + let entry = scheduleLookups.get(feed.id); + if (!entry || at - entry.at >= entry.ttl) { + entry = buildScheduleLookups(feed, at); + } else if (!(await lookupsCurrent(feed, entry, at))) { + // A rebuilt pack: its trips replace the old pack's at once. Another + // caller that saw the same change may have rebuilt already. + const latest = scheduleLookups.get(feed.id); + entry = latest !== entry ? latest : buildScheduleLookups(feed, clock()); + } + const lookups = await entry.lookups; + if (lookups.endsMs !== null && clock() >= lookups.endsMs) { + // The window ended while the entry was kept: refused from now on, and + // read again after five minutes, when a new build may be there. + if (!entry.ended) { + entry.ended = true; + entry.ttl = Math.min( + entry.ttl, + clock() - entry.at + SCHEDULE_LOOKUPS_RETRY_MS, + ); + console.warn( + `[rail-proxy] ${feed.id} schedule pack refused: its window ended ${new Date(lookups.endsMs).toISOString()}`, + ); + } + return NO_LOOKUPS; + } + return lookups; + } + + function reply(status, body, headers) { + return new Response(body, { status, headers }); + } + + function noteFailure(cacheKey, reason, at) { + const failures = (cooldown.get(cacheKey)?.failures || 0) + 1; + const wait = nextRailBackoffMs(failures); + cooldown.set(cacheKey, { failures, nextAttemptAt: at + wait, reason }); + return wait; + } + + /** + * Upstream bodies by URL, for a feed whose positions and trip updates are + * one document (MTA's LIRR and Metro-North): `{at, promise}`, reused for + * the asking route's own freshness window, so the operator is contacted + * once per window rather than once per route. + */ + const sharedUpstream = new Map(); + + function sharesUpstream(feed) { + return typeof feed.url === 'string' && feed.tripUpdatesUrl === feed.url; + } + + /** + * GET a registered URL and read its body, capped: `{notModified: true}` + * for a 304 to our own validators, else `{bytes, finalUrl, etag, + * lastModified, at}` with `at` the time it was asked for. + */ + async function fetchUpstream(feed, url, signal, validators, at) { + const { response, finalUrl } = await fetchRailFeed( + feed, + signal, + fetchImpl, + validators, + { url }, + ); + if (closed) throw new Error('Rail provider closed'); + if (!isAcceptableRailUpstreamUrl(finalUrl)) { + throw new Error('upstream is not https'); + } + if (response.status === 304 && validators) { + try { + await response.body?.cancel(); + } catch { + /* no-op */ + } + return { notModified: true }; + } + if (!response.ok) { + const error = new Error(`upstream HTTP ${response.status}`); + error.upstreamStatus = response.status; + throw error; + } + const bytes = await readResponseBytesCapped( + response, + railMaxBodyBytes(feed), + ); + return { + bytes, + finalUrl, + etag: response.headers.get('etag'), + lastModified: response.headers.get('last-modified'), + at, + }; + } + + /** + * The body of a URL both of a feed's routes read: the one fetched within + * this route's window when there is one (or under way), else a new fetch + * the other route may reuse. Sent without validators: a 304 would leave + * the other route, which may hold no snapshot, with nothing to read. + */ + function sharedBody(resource, signal, at) { + const { feed, url, ttlMs } = resource; + const kept = sharedUpstream.get(url); + if (kept && at - kept.at < ttlMs) return kept.promise; + const fresh = { at, promise: fetchUpstream(feed, url, signal, null, at) }; + sharedUpstream.set(url, fresh); + fresh.promise.catch(() => { + if (sharedUpstream.get(url) === fresh) sharedUpstream.delete(url); + }); + return fresh.promise; + } + + async function refresh(resource, at) { + const { feed, route, url, cacheKey } = resource; + const controller = new AbortController(); + controllers.add(controller); + const timeout = setTimeout(() => controller.abort(), RAIL_PROXY_TIMEOUT_MS); + const previous = cache.get(cacheKey); + try { + const validators = previous + ? { etag: previous.etag, lastModified: previous.lastModified } + : null; + const upstream = sharesUpstream(feed) + ? await sharedBody(resource, controller.signal, at) + : await fetchUpstream(feed, url, controller.signal, validators, at); + if (closed) throw new Error('Rail provider closed'); + // Unchanged upstream: keep the snapshot, restart its freshness window, + // and record that the operator answered. The body keeps the fetch time + // of the payload it holds. + if (upstream.notModified) { + const revalidated = { ...previous, at, contactedAt: at }; + cache.set(cacheKey, revalidated); + cooldown.delete(cacheKey); + return revalidated; + } + // A shared body is read as of when it was fetched. + const fetchedAt = upstream.at; + const lookups = await scheduleLookupsFor(feed); + // The pack's trip-to-route lookup, for a feed that reads routes from it. + const routeFor = readsPackRoutes(feed) + ? (tripId) => lookups.routes.get(tripId) ?? null + : null; + // A train named by its number takes the timetable trip it is on now: + // its line and stops, never a position. A train its report says is + // late is where its timetable was that long ago. + const tripFor = lookups.runs + ? (number, record) => + lookups.runs.activeTrip(number, fetchedAt, { + delayMs: (liveDelaySec(record) ?? 0) * 1000, + })?.tripId ?? null + : null; + const snapshot = + route === 'updates' + ? buildRailTripUpdatesSnapshot(feed, upstream.bytes, fetchedAt, { + routeFor, + }) + : buildRailSnapshot(feed, upstream.bytes, fetchedAt, { + routeFor, + tripFor, + }); + assertPackRead(feed, snapshot); + if (closed) throw new Error('Rail provider closed'); + const entry = { + at: snapshot.fetchedAt, + contactedAt: snapshot.fetchedAt, + body: JSON.stringify(snapshot), + host: new URL(upstream.finalUrl).hostname, + etag: upstream.etag, + lastModified: upstream.lastModified, + }; + cache.set(cacheKey, entry); + cooldown.delete(cacheKey); + return entry; + } finally { + clearTimeout(timeout); + controller.abort(); + controllers.delete(controller); + } + } + + /** + * The feed's path or schedule pack as stored: 404 only when the reader + * says it has none (null), 503 when it is over RAIL_PATH_PACK_MAX_BYTES (a + * build fault to fix, not a pack that was never built) or cannot be read + * now (the reader threw, or answered with something that is not a pack), + * which a viewer asks again about. Both kinds share the reader contract + * and the byte cap. Bytes from the reader go out as they are (packBody), + * `no-cache`, with the pack's version as their ETag when the host names + * one; an If-None-Match that names it is answered 304, unread. + */ + async function servePack(feed, kind, incoming) { + const read = kind === 'schedule' ? readSchedulePack : readPathPack; + const label = kind === 'schedule' ? 'schedule pack' : 'path pack'; + const missing = () => + reply( + 404, + JSON.stringify({ + error: kind === 'schedule' ? 'No rail schedule' : 'No rail paths', + feedId: feed.id, + }), + JSON_NO_STORE, + ); + const tooLarge = (detail) => { + console.warn(`[rail-proxy] ${feed.id} ${label} refused: ${detail}`); + return reply( + 503, + JSON.stringify({ + error: + kind === 'schedule' + ? 'Rail schedule too large' + : 'Rail paths too large', + feedId: feed.id, + }), + JSON_NO_STORE, + ); + }; + const unreadable = (detail) => { + console.warn(`[rail-proxy] ${feed.id} ${label} unreadable: ${detail}`); + return reply( + 503, + JSON.stringify({ + error: + kind === 'schedule' + ? 'Rail schedule unreadable' + : 'Rail paths unreadable', + feedId: feed.id, + retryInSec: PACK_UNREADABLE_RETRY_S, + }), + { ...JSON_NO_STORE, 'Retry-After': String(PACK_UNREADABLE_RETRY_S) }, + ); + }; + if (typeof read !== 'function') return missing(); + // The version is asked before the pack is read: a build landing in + // between tags newer bytes with the older version, which the next + // request corrects, and never the reverse. + const version = await packVersionNow(kind, feed.id); + const etag = typeof version === 'string' ? packEtag(version) : null; + const packHeaders = { + 'Cache-Control': 'no-cache', + ...(etag ? { ETag: etag } : {}), + }; + if (etag && etagMatches(requestHeader(incoming, 'if-none-match'), etag)) + return reply(304, null, packHeaders); + let body; + try { + body = await read(feed.id, { + maxBytes: RAIL_PATH_PACK_MAX_BYTES, + }); + } catch (error) { + if (error?.code === 'PACK_TOO_LARGE') + return tooLarge(error?.message || 'over the cap'); + return unreadable(error?.message || String(error)); + } + if (body === null || body === undefined) return missing(); + // UTF-8 is at most three bytes per UTF-16 unit: only a string near the + // cap is worth encoding to measure. + const size = + typeof body === 'string' + ? body.length * 3 <= RAIL_PATH_PACK_MAX_BYTES + ? body.length + : new TextEncoder().encode(body).byteLength + : body instanceof Uint8Array || body instanceof ArrayBuffer + ? body.byteLength + : NaN; + if (!Number.isFinite(size)) return unreadable('not a pack'); + if (size > RAIL_PATH_PACK_MAX_BYTES) return tooLarge(`${size} bytes`); + if (closed) + return reply( + 503, + JSON.stringify({ error: 'Rail provider closed' }), + JSON_NO_STORE, + ); + return reply(200, packBody(body), { + 'Content-Type': 'application/json; charset=utf-8', + ...packHeaders, + }); + } + + /** Positions or trip updates, through the cache/admission/backoff policy. */ + async function serveUpstream(resource) { + const { feed, cacheKey, ttlMs } = resource; + const at = clock(); + const cached = cache.get(cacheKey); + const state = railCacheStateFor(cached, at, ttlMs); + const ttlSeconds = Math.ceil(ttlMs / 1000); + // A downstream cache keeps the snapshot only for what is left of its + // window here. + const okHeaders = (cacheState, entry) => + railResponseHeaders( + cacheState, + entry.host, + entry.contactedAt, + ttlMs, + clock() - entry.at, + ); + if (state === 'fresh') + return reply(200, cached.body, okHeaders('HIT', cached)); + + // The cached snapshot when there is one, otherwise an honest + // "unavailable, come back in N seconds". The contact time stays as old as + // it is: a feed served from cache during an outage has not answered. + const answerWithoutUpstream = (reason, retryAfterS) => { + if (state === 'stale' && cached) { + return reply(200, cached.body, okHeaders('STALE-ERROR', cached)); + } + return reply( + 503, + JSON.stringify({ + error: 'Rail feed unavailable', + feedId: feed.id, + retryInSec: retryAfterS, + }), + { + ...JSON_NO_STORE, + 'X-GEV-Cache': 'NONE', + 'Retry-After': String(retryAfterS), + 'X-Rail-Backoff': reason, + }, + ); + }; + + const waiting = cooldown.get(cacheKey); + if (waiting && waiting.nextAttemptAt > at && !inFlight.has(cacheKey)) { + return answerWithoutUpstream( + 'cooldown', + Math.max(1, Math.ceil((waiting.nextAttemptAt - at) / 1000)), + ); + } + if (!inFlight.has(cacheKey) && !admit(cacheKey)) + return answerWithoutUpstream('rate-limited', ttlSeconds); + + const request = coalesceProxyRequest(inFlight, cacheKey, () => + refresh(resource, at), + ); + try { + const fresh = await request.promise; + return reply( + 200, + fresh.body, + okHeaders(request.shared ? 'INFLIGHT' : 'MISS', fresh), + ); + } catch (error) { + const differential = error instanceof RailFeedShapeError; + // A shape fault condemns the cached copy too: it was decoded as a full + // snapshot, which the feed has just shown it is not. + if (differential) cache.delete(cacheKey); + if (!request.shared) { + const reason = error?.message || String(error); + const wait = noteFailure(cacheKey, reason, clock()); + console.warn( + `[rail-proxy] ${cacheKey} unavailable: ${reason}; next attempt in ${Math.round(wait / 1000)}s`, + ); + } + if (state === 'stale' && cached && !differential) { + return reply(200, cached.body, okHeaders('STALE-ERROR', cached)); + } + const pending = cooldown.get(cacheKey); + const retryAfterS = pending + ? Math.max(1, Math.ceil((pending.nextAttemptAt - clock()) / 1000)) + : ttlSeconds; + // The operator answered, but this server lacks the schedule pack the + // answer is read with: unavailable here, not an upstream fault. + const noSchedule = error?.code === 'SCHEDULE_UNAVAILABLE'; + return reply( + noSchedule + ? 503 + : differential || + error?.code === 'RESPONSE_TOO_LARGE' || + error?.code === 'UPSTREAM_UNREADABLE' || + Number.isInteger(error?.upstreamStatus) + ? 502 + : 504, + JSON.stringify({ + error: differential + ? 'Rail feed is differential and is not supported' + : noSchedule + ? 'Rail schedule unavailable' + : 'Rail feed unavailable', + feedId: feed.id, + retryInSec: retryAfterS, + }), + { + ...JSON_NO_STORE, + 'X-GEV-Cache': 'NONE', + 'Retry-After': String(retryAfterS), + ...(noSchedule ? { 'X-Rail-Backoff': 'schedule' } : {}), + }, + ); + } + } + + async function handle(incoming) { + const url = new URL(incoming.url); + if (!url.pathname.startsWith('/api/rail/')) + return reply( + 404, + JSON.stringify({ error: 'Unknown rail feed' }), + JSON_NO_STORE, + ); + if (closed) + return reply( + 503, + JSON.stringify({ error: 'Rail provider closed' }), + JSON_NO_STORE, + ); + if (incoming.method !== 'GET') { + return reply(405, JSON.stringify({ error: 'Method Not Allowed' }), { + ...JSON_NO_STORE, + Allow: 'GET', + }); + } + const route = resolveRailRoute( + url.pathname.slice('/api/rail'.length) + url.search, + ); + if (!route) + return reply( + 404, + JSON.stringify({ error: 'Unknown rail feed' }), + JSON_NO_STORE, + ); + if (route.route === 'feeds') { + // Which pack-routed feeds appear follows the packs built; a short + // max-age lets a new build show up without a long wait. + return reply(200, JSON.stringify({ feeds: await catalog() }), { + 'Content-Type': 'application/json; charset=utf-8', + 'Cache-Control': 'public, max-age=300', + }); + } + if (route.route === 'paths' || route.route === 'schedule') + return servePack(route.feed, route.route, incoming); + const { feed } = route; + const updates = route.route === 'updates'; + // A pack-routed feed whose pack is not built is not served: the + // operator is not contacted for trains this server cannot name. + if (!(await feedServable(feed))) + return reply( + 503, + JSON.stringify({ + error: 'Rail schedule unavailable', + feedId: feed.id, + retryInSec: SCHEDULE_PACK_PROBE_RETRY_S, + }), + { + ...JSON_NO_STORE, + 'X-GEV-Cache': 'NONE', + 'Retry-After': String(SCHEDULE_PACK_PROBE_RETRY_S), + 'X-Rail-Backoff': 'schedule', + }, + ); + return serveUpstream({ + feed, + route: route.route, + url: updates ? feed.tripUpdatesUrl : feed.url, + cacheKey: `${route.route}:${feed.id}`, + ttlMs: railFeedTtlMs(feed, route.route), + }); + } + + function close() { + closed = true; + for (const controller of controllers) controller.abort(); + controllers.clear(); + cache.clear(); + cooldown.clear(); + scheduleLookups.clear(); + sharedUpstream.clear(); + } + return { handle, close }; +} diff --git a/src/standalone/layerSources.js b/src/standalone/layerSources.js index cef9af961..ca92deba0 100644 --- a/src/standalone/layerSources.js +++ b/src/standalone/layerSources.js @@ -9,6 +9,7 @@ import { import { createCctvSource } from '../layers/cctv/source.js'; import { createRadioSource } from '../layers/radio/source.js'; import { createTransitSource } from '../layers/transit/source.js'; +import { createRailSource } from '../layers/rail/source.js'; import { createTrafficSource } from '../layers/traffic/source.js'; import { createBikeshareSource } from '../layers/bikeshare/source.js'; import { createInstallationSource } from '../layers/installations/source.js'; @@ -55,6 +56,7 @@ export function createStandaloneLayerSources( radio: createRadioSource(), traffic: createTrafficSource({ mapTiles: trafficTiles }), transit: createTransitSource(), + rail: createRailSource(), bikeshare: createBikeshareSource(), installations: createInstallationSource({ mapTiles: installationTiles }), satellites: createSatelliteSource(), diff --git a/src/testSupport/railLayerHarness.mjs b/src/testSupport/railLayerHarness.mjs new file mode 100644 index 000000000..5f7a2ae62 --- /dev/null +++ b/src/testSupport/railLayerHarness.mjs @@ -0,0 +1,409 @@ +// A Rail layer on a stub viewer, a scripted proxy and fake camera services +// that record what the layer asks of the camera. Shared by the Rail layer's +// tests that run a whole layer (camera follow, live-only feeds, ground +// height, layer stats, selected route lines). +import * as Cesium from 'cesium'; +import { createRailLayer } from '../layers/rail/index.js'; +import { CAMERA_DEBOUNCE_MS } from '../layers/rail/policy.js'; +import { WORLD_FOCUS_FRAMING } from '../worldFocus.js'; +import { publicRailCatalog } from '../data/railFeeds.js'; + +/** The tests' wall clock at the start: 2026-09-10T00:26:40Z. */ +export const RAIL_HARNESS_T0 = 1_789_000_000_000; +export const BOSTON = Object.freeze({ lat: 42.3601, lon: -71.0589 }); + +/** A train as the rail proxy sends it. */ +export function railTrain(id, lat, lon, extra = {}) { + return { + id, + lat, + lon, + bearing: null, + speedMps: null, + timestamp: Math.floor(Date.now() / 1000), + timestampSource: 'vehicle', + routeId: 'R1', + tripId: `trip-${id}`, + label: id, + category: 'regional', + mode: 'rail', + positionSource: 'gps', + ...extra, + }; +} + +/** A canvas good enough for the sprite drawing: every 2D call is a no-op. */ +function fakeCanvas() { + const context = new Proxy( + {}, + { + get: (target, key) => (key in target ? target[key] : () => {}), + set: (target, key, value) => { + target[key] = value; + return true; + }, + }, + ); + return { width: 0, height: 0, getContext: () => context }; +} + +const headersOf = (values = {}) => ({ + get: (name) => values[String(name).toLowerCase()] ?? null, +}); + +/** + * The harness. `feeds` scripts each feed's live answer, one call per poll: + * an array of trains (200), `{positions: 'schedule'}` (a 200 answer with + * no live positions, which the layer must not draw), `{status, backoff?, retryInSec?}` (a failure as the proxy + * answers it), or nothing (502). `schedules` holds the schedule packs the + * proxy serves for stop labels (404 otherwise). + * `requested`, `requestedSchedules` and `requestedPaths` list the feed ids + * asked for, in order. + * @param {import('node:test').TestContext} t + * @param {{at?: {lat: number, lon: number}, altitude?: number, + * catalog?: object[]|Promise|Function|false, + * ground?: object, + * stops?: object|null, scheduleStops?: boolean, + * paths?: Record|null}} [options] + * `ground`: a floor service in place of the always-cold stub; `catalog`: + * the server's feed catalog (it may list fewer feeds than are bundled; a + * promise answers when it settles, a function is called per request and + * answers so); without it, a server with every pack built (all enabled + * feeds); `false` for a source with no catalog at all; + * `stops`: a stops provider (none by default); `scheduleStops`: the + * layer's own provider, its schedule tier, in its place; + * `paths`: feed id → the path pack the proxy serves, or a + * failure status it answers with (404 for any other feed; without it the + * source has no path route at all). + */ +export function railLayerHarness( + t, + { + at = BOSTON, + altitude = 20_000, + catalog = undefined, + ground = null, + stops = null, + scheduleStops = false, + paths = null, + } = {}, +) { + const T0 = RAIL_HARNESS_T0; + t.mock.timers.enable({ + apis: ['Date', 'setTimeout', 'setInterval'], + now: T0, + }); + t.mock.method(performance, 'now', () => Date.now() - T0); + for (const name of [ + 'HTMLCanvasElement', + 'HTMLImageElement', + 'ImageBitmap', + 'OffscreenCanvas', + ]) { + const prior = globalThis[name]; + globalThis[name] = class {}; + t.after(() => { + if (prior === undefined) delete globalThis[name]; + else globalThis[name] = prior; + }); + } + const originalDocument = globalThis.document; + const originalWindow = globalThis.window; + globalThis.document = Object.assign(new EventTarget(), { + visibilityState: 'visible', + documentElement: { dataset: {} }, + createElement: () => fakeCanvas(), + }); + globalThis.window = new EventTarget(); + // The layer logs its feeds and catalog. + t.mock.method(console, 'info', () => {}); + t.mock.method(console, 'log', () => {}); + + const view = { lat: at.lat, lon: at.lon, altitude }; + const cameraChanged = new Cesium.Event(); + const trackedEntityChanged = new Cesium.Event(); + const preRender = new Cesium.Event(); + const primitives = []; + const primitiveCollection = { + add(primitive) { + primitives.push(primitive); + return primitive; + }, + remove(primitive) { + const index = primitives.indexOf(primitive); + if (index >= 0) primitives.splice(index, 1); + return index >= 0; + }, + contains: (primitive) => primitives.includes(primitive), + raiseToTop() {}, + get length() { + return primitives.length; + }, + get: (index) => primitives[index], + }; + let trackedEntity; + const viewer = { + entities: new Cesium.EntityCollection(), + get trackedEntity() { + return trackedEntity; + }, + // Cesium's own setter raises the event on every change, as here. + set trackedEntity(value) { + if (trackedEntity === value) return; + trackedEntity = value; + trackedEntityChanged.raiseEvent(value); + }, + trackedEntityChanged, + isDestroyed: () => false, + camera: { + changed: cameraChanged, + percentageChanged: 0.5, + heading: 0, + flightsCancelled: 0, + positionWC: Cesium.Cartesian3.fromDegrees(at.lon, at.lat, altitude), + get positionCartographic() { + return { + height: view.altitude, + latitude: Cesium.Math.toRadians(view.lat), + longitude: Cesium.Math.toRadians(view.lon), + }; + }, + computeViewRectangle() { + return Cesium.Rectangle.fromDegrees( + view.lon - 0.1, + view.lat - 0.1, + view.lon + 0.1, + view.lat + 0.1, + ); + }, + cancelFlight() { + this.flightsCancelled += 1; + }, + }, + scene: { + canvas: { addEventListener() {}, removeEventListener() {} }, + globe: { ellipsoid: Cesium.Ellipsoid.WGS84 }, + pick: () => undefined, + requestRender() {}, + preRender, + primitives: primitiveCollection, + groundPrimitives: primitiveCollection, + }, + }; + + const feeds = new Map(); + const schedules = new Map(); + const requested = []; + const requestedSchedules = []; + const requestedPaths = []; + /** When each catalog request was made. */ + const catalogRequests = []; + /** + * A pack as the proxy serves it: 200 with its body, a number as that + * failure status, else 404. + */ + const packAnswer = (raw) => + raw && typeof raw === 'object' + ? { ok: true, status: 200, headers: headersOf(), json: async () => raw } + : { + ok: false, + status: typeof raw === 'number' ? raw : 404, + headers: headersOf(), + json: async () => ({}), + }; + const source = { + async requestSnapshot(feedId) { + requested.push(feedId); + const answer = feeds.get(feedId)?.() ?? null; + if (Array.isArray(answer)) + return { + ok: true, + status: 200, + headers: headersOf(), + json: async () => ({ fetchedAt: Date.now(), trains: answer }), + }; + if (answer?.positions === 'schedule') + return { + ok: true, + status: 200, + headers: headersOf({ 'x-gev-cache': 'SCHEDULE' }), + json: async () => ({ + fetchedAt: Date.now(), + positions: 'schedule', + trains: [], + }), + }; + const status = answer?.status ?? 502; + return { + ok: false, + status, + headers: headersOf( + answer?.backoff ? { 'x-rail-backoff': answer.backoff } : {}, + ), + json: async () => ({ + error: 'Rail feed unavailable', + ...(Number.isFinite(answer?.retryInSec) + ? { retryInSec: answer.retryInSec } + : {}), + }), + }; + }, + async requestSchedule(feedId) { + requestedSchedules.push(feedId); + return packAnswer(schedules.get(feedId)); + }, + ...(paths + ? { + async requestPaths(feedId) { + requestedPaths.push(feedId); + return packAnswer(paths[feedId]); + }, + } + : {}), + ...(catalog === false + ? {} + : { + async getFeeds() { + catalogRequests.push(Date.now()); + if (catalog === undefined) return publicRailCatalog(); + return typeof catalog === 'function' ? catalog() : catalog; + }, + }), + }; + + const focusRequests = []; + const frames = []; + /** Overlay source id → the entries last published to it. */ + const overlayEntries = new Map(); + let cockpit = false; + const layer = createRailLayer({ + source, + // Left out, the layer reads stops from its own schedule tier. + ...(scheduleStops ? {} : { stops }), + services: { + render: { + governorRequestRender() {}, + holdContinuousRender() {}, + releaseContinuousRender() {}, + }, + sprites: { + registerSpriteCollection() {}, + unregisterSpriteCollection() {}, + restoreSpriteOrder() {}, + }, + picking: { registerPickOwner() {}, unregisterPickOwner() {} }, + overlays: { + setOverlayEntries(sourceId, entries) { + overlayEntries.set(sourceId, entries); + }, + setOverlaySourceVisible() {}, + clearOverlaySource() {}, + }, + credits: { + registerDynamicCredit() {}, + railFeedCredit: (feed) => ({ key: `rail-${feed.id}`, html: feed.id }), + }, + ground: ground ?? { + GROUND_FLOOR_LIFT_M: 1.5, + cachedGroundFloor: () => null, + warmGroundFloor() {}, + }, + camera: { + framing: WORLD_FOCUS_FRAMING.train, + requestWorldFocus(detail) { + focusRequests.push(detail); + return true; + }, + applyTrackedCameraFrame(_viewer, entity, viewFrom) { + const frame = { entity, viewFrom, stopped: false }; + frames.push(frame); + return () => { + frame.stopped = true; + }; + }, + isCockpitActive: () => cockpit, + }, + }, + }); + layer.init(viewer); + + t.after(() => { + try { + layer.destroy(viewer); + } catch { + /* a test may already have destroyed it */ + } + if (originalDocument === undefined) delete globalThis.document; + else globalThis.document = originalDocument; + if (originalWindow === undefined) delete globalThis.window; + else globalThis.window = originalWindow; + }); + + const state = () => layer._railStateForTest(); + const parts = () => layer._railPartsForTest(); + /** Let pending promises (a poll's fetch, JSON and packs) settle. */ + const settle = async () => { + for (let i = 0; i < 24; i += 1) await Promise.resolve(); + }; + + return { + layer, + viewer, + state, + parts, + feeds, + schedules, + requested, + requestedSchedules, + requestedPaths, + catalogRequests, + primitives, + overlayEntries, + focusRequests, + frames, + settle, + setCockpit(value) { + cockpit = value; + }, + entry: (key) => state()._trains.get(key), + async enable() { + layer.enable(viewer); + await settle(); + t.mock.timers.tick(0); + await settle(); + }, + async poll() { + await layer.update(); + await settle(); + // The duplicate filter runs off the poll's stack. + t.mock.timers.tick(0); + await settle(); + }, + /** Wall time with frames along the way, as in the app. */ + run(ms, step = 250) { + const until = Date.now() + ms; + while (Date.now() < until) { + t.mock.timers.tick(Math.min(step, until - Date.now())); + preRender.raiseEvent(); + } + }, + /** Move the camera and let the debounced feed check run. */ + async moveCamera(next) { + Object.assign(view, next); + cameraChanged.raiseEvent(); + t.mock.timers.tick(CAMERA_DEBOUNCE_MS); + await settle(); + }, + /** Let a deferred feed check (after a selection change) run. */ + async recheck() { + t.mock.timers.tick(0); + await settle(); + }, + trackedPosition() { + return viewer.trackedEntity?.position?.getValue( + Cesium.JulianDate.now(), + new Cesium.Cartesian3(), + ); + }, + }; +} diff --git a/src/tooling/buildRailPaths.test.mjs b/src/tooling/buildRailPaths.test.mjs new file mode 100644 index 000000000..73b728353 --- /dev/null +++ b/src/tooling/buildRailPaths.test.mjs @@ -0,0 +1,682 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { crc32, deflateRawSync } from 'node:zlib'; +import { + buildPack, + buildSchedule, + entryStream, + fillTimes, + finishShape, + frequencyDepartures, + isRailRouteType, + openZip, + parseGtfsTime, + parseRecord, + projectStops, + readCsv, + resolveServiceDays, + simplifyFlat, +} from '../../scripts/build-rail-paths.mjs'; +import { createRailPath } from '../data/railPaths.js'; +import { createSchedule, tripStops } from '../data/railSchedule.js'; + +/** A minimal zip writer: stored or deflated entries, no zip64. */ +function makeZip(files) { + const locals = []; + const centrals = []; + let offset = 0; + for (const { name, text, deflate } of files) { + const data = Buffer.from(text, 'utf8'); + const body = deflate ? deflateRawSync(data) : data; + const nameBytes = Buffer.from(name, 'utf8'); + const local = Buffer.alloc(30); + local.writeUInt32LE(0x04034b50, 0); + local.writeUInt16LE(20, 4); + local.writeUInt16LE(deflate ? 8 : 0, 8); + local.writeUInt32LE(crc32(data), 14); + local.writeUInt32LE(body.length, 18); + local.writeUInt32LE(data.length, 22); + local.writeUInt16LE(nameBytes.length, 26); + const central = Buffer.alloc(46); + central.writeUInt32LE(0x02014b50, 0); + central.writeUInt16LE(20, 4); + central.writeUInt16LE(20, 6); + central.writeUInt16LE(deflate ? 8 : 0, 10); + central.writeUInt32LE(crc32(data), 16); + central.writeUInt32LE(body.length, 20); + central.writeUInt32LE(data.length, 24); + central.writeUInt16LE(nameBytes.length, 28); + central.writeUInt32LE(offset, 42); + locals.push(local, nameBytes, body); + centrals.push(central, nameBytes); + offset += local.length + nameBytes.length + body.length; + } + const cd = Buffer.concat(centrals); + const end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50, 0); + end.writeUInt16LE(files.length, 8); + end.writeUInt16LE(files.length, 10); + end.writeUInt32LE(cd.length, 12); + end.writeUInt32LE(offset, 16); + return Buffer.concat([...locals, cd, end]); +} + +const D = 1 / 111_320; + +/** Straight east along the equator in `step`-metre steps, as shapes.txt rows. */ +function shapeRows(id, steps, step) { + const rows = []; + for (let i = 0; i <= steps; i++) + rows.push(`${id},0,${(i * step * D).toFixed(6)},${i * 10}`); + // Out of order on purpose: the build sorts by sequence. + return rows.reverse(); +} + +async function withZip(t, files) { + const dir = await mkdtemp(path.join(os.tmpdir(), 'gev-build-rail-paths-')); + t.after(() => rm(dir, { recursive: true, force: true })); + const file = path.join(dir, 'feed.zip'); + await writeFile(file, makeZip(files)); + const zip = await openZip(file); + t.after(() => zip.handle.close()); + return zip; +} + +test('rail route types are the tram, metro and railway families', () => { + for (const type of [ + '0', + '1', + '2', + ' 2 ', + '5', + '7', + '12', + '100', + '109', + '401', + '900', + // Extended funicular, as basic 7 is. + '1400', + ]) + assert.ok(isRailRouteType(type), type); + for (const type of ['3', '4', '6', '11', '200', '700', '1000', '', 'x']) + assert.equal(isRailRouteType(type), false, type); +}); + +test('CSV records honour quotes and trim padded fields', () => { + assert.deepEqual(parseRecord('a, b ,c\r'), ['a', 'b', 'c']); + assert.deepEqual(parseRecord('1,"x, ""y""",z'), ['1', 'x, "y"', 'z']); +}); + +test('the zip reader streams stored and deflated entries', async (t) => { + const zip = await withZip(t, [ + { name: 'stored.txt', text: 'h\n1\n' }, + { + name: 'gtfs/routes.txt', + text: 'route_id,route_long_name\nR1,"A, B\nC"\nR2,D\n', + deflate: true, + }, + ]); + assert.deepEqual([...zip.entries.keys()].sort(), [ + 'gtfs/routes.txt', + 'stored.txt', + ]); + const rows = []; + const count = await readCsv( + await entryStream(zip, zip.entries.get('gtfs/routes.txt')), + (row) => rows.push(row), + ); + assert.equal(count, 2); + assert.deepEqual(rows, [ + { route_id: 'R1', route_long_name: 'A, B\nC' }, + { route_id: 'R2', route_long_name: 'D' }, + ]); +}); + +test('a shape is sorted, rounded, simplified and measured; a chord is dropped', () => { + const triples = [2, 0, 2000 * D, 0, 0, 0, 1, 0, 1000 * D, 1, 0, 1000 * D]; + const shape = finishShape(triples); + assert.deepEqual( + shape.flat.length, + 4, + 'collinear middle point simplified away', + ); + assert.equal(shape.rawPoints, 3, 'duplicate point removed'); + // Haversine metres, a little under the 111,320 m/degree used to place it. + assert.ok(Math.abs(shape.dist[1] - 2000) < 5, String(shape.dist[1])); + // Sparse vertices on straight track (MBTA commuter lines run 2-3.3 km + // between vertices) are track, not a chord. + assert.equal(finishShape([0, 0, 0, 1, 0, 3000 * D]).dropped, undefined); + const chord = finishShape([0, 0, 0, 1, 0, 6000 * D]); + assert.match(chord.dropped, /^vertex gap 59\d\d m$/); + assert.match(finishShape([0, 0, 0]).dropped, /fewer than two points/); +}); + +test('simplification keeps every corner beyond the tolerance', () => { + const flat = [ + 0, + 0, + 0, + 1000 * D, + 0.5 * D, + 1500 * D, + 0, + 2000 * D, + 500 * D, + 2000 * D, + ]; + const simple = simplifyFlat(flat, 1); + assert.deepEqual(simple, [0, 0, 0, 2000 * D, 500 * D, 2000 * D]); +}); + +test('a pack keeps classified rail routes, dedupes shapes and maps trips', async (t) => { + const zip = await withZip(t, [ + { + name: 'routes.txt', + text: 'route_id, route_type, route_long_name\nR1, 2, "Main, line"\nR2,2,Other\nB1,3,Bus\n', + deflate: true, + }, + { + name: 'trips.txt', + text: [ + 'route_id,service_id,trip_id,shape_id', + 'R1,wk,t1,A', + 'R1,wk,t2,A2', + 'R1,wk,t3,CHORD', + 'R1,wk,t4,', + 'R2,wk,t5,A', + 'B1,wk,t6,BUS', + ].join('\n'), + deflate: true, + }, + { + name: 'shapes.txt', + text: [ + 'shape_id,shape_pt_lat,shape_pt_lon,shape_pt_sequence', + ...shapeRows('A', 20, 100), + ...shapeRows('A2', 20, 100), + 'CHORD,0,0,1', + `CHORD,0,${(8000 * D).toFixed(6)},2`, + ...shapeRows('BUS', 5, 100), + ].join('\n'), + deflate: true, + }, + ]); + const feed = { + id: 'test-feed', + classify: (routeId) => + routeId === 'R1' || routeId === 'B1' + ? { category: 'local', mode: 'rail' } + : null, + }; + const { pack, stats } = await buildPack(feed, zip, { + url: 'https://example.test/gtfs.zip', + lastModified: null, + }); + assert.equal(pack.version, 1); + assert.equal(pack.feedId, 'test-feed'); + assert.equal(pack.shapes.length, 1, 'identical shapes stored once'); + assert.deepEqual(pack.shapes[0], [0, 0, 0, 0.01797]); + assert.equal(pack.dist[0].length, 2); + assert.deepEqual({ ...pack.trips }, { t1: 0, t2: 0 }); + assert.deepEqual({ ...pack.routes }, { R1: [0] }); + assert.equal( + stats.railRoutes, + 1, + 'the bus route and the unclassified route are out', + ); + assert.equal(stats.shapesDropped, 1); + assert.equal(stats.shapelessTrips, 1); + assert.equal(stats.tripsMapped, 2); + assert.equal(JSON.parse(JSON.stringify(pack)).trips.t1, 0); +}); + +test('GTFS times parse past midnight; blanks and junk are NaN', () => { + assert.equal(parseGtfsTime('25:30:00'), 91_800); + assert.equal(parseGtfsTime(' 7:05:09 '), 25_509); + assert.ok(Number.isNaN(parseGtfsTime(''))); + assert.ok(Number.isNaN(parseGtfsTime('7:5:00'))); +}); + +test('calendars resolve to concrete service days with exceptions', () => { + const day = (text) => Date.parse(`${text}T00:00:00Z`) / 86_400_000; + const days = resolveServiceDays( + [ + { + service_id: 'WK', + monday: '1', + tuesday: '1', + wednesday: '1', + thursday: '1', + friday: '1', + saturday: '0', + sunday: '0', + start_date: '20260101', + end_date: '20261105', + }, + ], + [ + { service_id: 'WK', date: '20261104', exception_type: '2' }, + { service_id: 'X', date: '20261108', exception_type: '1' }, + { service_id: 'Y', date: '20261108', exception_type: '2' }, + ], + day('2026-11-02'), + day('2026-11-08'), + ); + assert.deepEqual(days.get('WK'), [ + day('2026-11-02'), + day('2026-11-03'), + day('2026-11-05'), + ]); + assert.deepEqual(days.get('X'), [day('2026-11-08')]); + assert.equal(days.has('Y'), false); +}); + +test('blank stop times are filled by distance between timed stops', () => { + const arr = [0, NaN, NaN, 300, 400]; + const dep = [0, NaN, NaN, NaN, 400]; + assert.equal(fillTimes(arr, dep, [0, 100, 200, 300, 350]), true); + assert.deepEqual(arr, [0, 100, 200, 300, 400]); + assert.deepEqual(dep, [0, 100, 200, 300, 400]); + assert.equal(fillTimes([NaN, 5], [NaN, 5], [0, 1]), false); +}); + +test('stops project onto a shape in order, binding a twice-passed stop to the first pass', () => { + // Out along the equator and back 20 m north of it. + const path = createRailPath([0, 0, 0, 2000 * D, 20 * D, 2000 * D, 20 * D, 0]); + const { dist, maxOffsetM } = projectStops(path, [ + { lat: 0, lon: 0 }, + // Nearer the return line (5 m) than the outbound (15 m). + { lat: 15 * D, lon: 1000 * D }, + { lat: 20 * D, lon: 0 }, + ]); + assert.ok(Math.abs(dist[1] - 1000) < 2, String(dist[1])); + assert.ok(Math.abs(dist[2] - 4020) < 2, String(dist[2])); + assert.ok(maxOffsetM < 16); + // Never backwards, even when a stop lies behind the previous one. + const back = projectStops(path, [ + { lat: 0, lon: 1500 * D }, + { lat: 0, lon: 500 * D }, + ]); + assert.ok(back.dist[1] >= back.dist[0]); +}); + +test('a stop offset is in great-circle metres, not the path projection', () => { + // North along the meridian; the flat projection is true only at 30°N. + const path = createRailPath([0, 0, 30, 0, 60, 0]); + const eastAt60 = + (500 / (6_371_008.8 * Math.cos(Math.PI / 3))) * (180 / Math.PI); + const { maxOffsetM } = projectStops(path, [ + { lat: 0, lon: 0 }, + { lat: 60, lon: eastAt60 }, + ]); + // The projection alone reads 866 m (500 m × cos 30° / cos 60°). + assert.ok(Math.abs(maxOffsetM - 500) < 5, String(maxOffsetM)); +}); + +const east = (m) => (m * D).toFixed(6); + +function scheduleZip(t) { + return withZip(t, [ + { + name: 'agency.txt', + text: 'agency_id,agency_name,agency_url,agency_timezone\nA,Test,https://example.test,America/New_York\n', + }, + { + name: 'calendar.txt', + text: [ + 'service_id,monday,tuesday,wednesday,thursday,friday,saturday,sunday,start_date,end_date', + 'WK,1,1,1,1,1,0,0,20260101,20261231', + 'WE,0,0,0,0,0,1,1,20260101,20261231', + ].join('\n'), + }, + { + name: 'calendar_dates.txt', + text: 'service_id,date,exception_type\nWK,20261106,2\nHOL,20261107,1\n', + }, + { + name: 'routes.txt', + text: 'route_id,route_type,route_color\nR1,2,ff0000\nB1,3,\n', + }, + { + name: 'trips.txt', + text: [ + 'route_id,service_id,trip_id,shape_id,trip_headsign,trip_short_name,direction_id', + 'R1,WK,t1,S,East,101,0', + 'R1,WK,t2,S,East,103,0', + 'R1,WE,t3,S,East,105,0', + 'R1,HOL,t4,,East,107,1', + 'R1,OLD,t5,S,East,109,0', + 'B1,WK,b1,S,,,', + ].join('\n'), + deflate: true, + }, + { + name: 'stops.txt', + text: [ + 'stop_id,stop_name,stop_lat,stop_lon,parent_station,stop_timezone,platform_code', + 'P,Parent Station,0,0.00001,,,', + // A hair east of (0, 0), which the build reads as no coordinates. + 'A1,Alpha 1,0,0.000001,P,,1', + 'Bst,Bravo,0,' + east(1000) + ',,,', + 'C,Charlie,0,' + east(3000) + ',,America/Chicago,', + 'X,Unused,1,1,,,', + ].join('\n'), + }, + { + name: 'stop_times.txt', + text: [ + 'trip_id,arrival_time,departure_time,stop_id,stop_sequence,pickup_type,drop_off_type', + 't1,08:30:00,08:30:00,C,9,,', + 't1,08:00:00,08:00:00,A1,1,,', + 't1,,,Bst,5,,', + 't2,09:00:00,09:00:00,A1,1,,', + 't2,,,Bst,5,,', + 't2,09:30:00,09:30:00,C,9,,', + 't3,23:50:00,23:50:00,A1,1,,', + 't3,,,Bst,5,,', + 't3,24:20:00,24:20:00,C,9,,', + 't4,10:00:00,10:00:00,A1,1,,', + 't4,10:30:00,10:30:00,C,2,1,1', + 't5,07:00:00,07:00:00,A1,1,,', + 't5,07:30:00,07:30:00,C,9,,', + 'b1,07:00:00,07:00:00,A1,1,,', + 'b1,07:30:00,07:30:00,X,2,,', + ].join('\n'), + deflate: true, + }, + { + name: 'shapes.txt', + text: [ + 'shape_id,shape_pt_lat,shape_pt_lon,shape_pt_sequence', + ...shapeRows('S', 40, 100), + ].join('\n'), + }, + ]); +} + +const railFeed = (extra = {}) => ({ + id: 'test-feed', + classify: () => ({ category: 'local', mode: 'rail' }), + ...extra, +}); + +test('a schedule pack resolves services, shares patterns and run times, and keeps served stops', async (t) => { + const zip = await scheduleZip(t); + const feed = railFeed(); + const { pack: paths } = await buildPack(feed, zip, { url: 'x' }); + const { schedule, stats } = await buildSchedule( + feed, + zip, + { url: 'x' }, + paths, + { from: '2026-11-05', days: 4 }, + ); + assert.equal(schedule.version, 1); + assert.equal(schedule.timezone, 'America/New_York'); + assert.deepEqual(schedule.window, { from: '2026-11-05', to: '2026-11-08' }); + assert.equal(schedule.pathsBuiltAt, paths.builtAt); + assert.deepEqual(Object.keys(schedule.trips).sort(), [ + 't1', + 't2', + 't3', + 't4', + ]); + const { t1, t2, t3, t4 } = schedule.trips; + assert.equal(t1.pattern, t2.pattern); + assert.equal(t1.pattern, t3.pattern); + assert.notEqual(t1.pattern, t4.pattern); + assert.equal(t1.profile, t2.profile, 'equal run times share one array'); + assert.equal(t1.profile, t3.profile); + assert.deepEqual(schedule.profiles[t1.profile], [0, 0, 600, 600, 1800, 1800]); + assert.equal(t1.startSec, 8 * 3600); + assert.equal(t3.startSec, 23 * 3600 + 50 * 60); + assert.deepEqual( + { + name: t1.name, + headsign: t1.headsign, + direction: t1.direction, + route: t1.route, + }, + { name: '101', headsign: 'East', direction: 0, route: 'R1' }, + ); + // t3 ends past midnight, so the pack looks one day back. + assert.equal(stats.lookbackDays, 1); + assert.deepEqual( + { ...schedule.services }, + { + WK: ['2026-11-04', '2026-11-05'], + WE: ['2026-11-07', '2026-11-08'], + HOL: ['2026-11-07'], + }, + ); + assert.deepEqual( + { ...schedule.routes }, + { R1: { type: 2, color: 'FF0000' } }, + ); + const ids = schedule.stops.map((stop) => stop.id); + assert.deepEqual(ids.sort(), ['A1', 'Bst', 'C', 'P']); + const byId = Object.fromEntries( + schedule.stops.map((stop) => [stop.id, stop]), + ); + assert.equal(schedule.stops[byId.A1.parent].id, 'P'); + assert.equal(byId.A1.platform, '1'); + assert.equal(byId.C.tz, 'America/Chicago'); + assert.equal(byId.Bst.tz, undefined); + const shaped = schedule.patterns[t1.pattern]; + assert.equal(shaped.shape, 0); + assert.deepEqual(shaped.seq, [1, 5, 9]); + assert.ok(Math.abs(shaped.dist[1] - 1000) < 5, String(shaped.dist[1])); + assert.ok(Math.abs(shaped.dist[2] - 3000) < 5, String(shaped.dist[2])); + const bare = schedule.patterns[t4.pattern]; + assert.equal( + bare.shape, + null, + 'a position feed draws a shapeless trip between stops', + ); + assert.deepEqual(bare.pass, [0, 1]); + assert.ok(Math.abs(bare.dist[1] - 3000) < 5); + assert.equal(stats.patternsShapeless, 1); + + // The browser module reads it back: the 24:20:00 arrival lands in Chicago + // time on the next day. + const live = createSchedule(JSON.parse(JSON.stringify(schedule))); + const stops = tripStops( + live, + { tripId: 't3', serviceDate: '2026-11-07' }, + Date.parse('2026-11-08T04:55:00Z'), + ); + assert.deepEqual( + stops.map((stop) => [stop.stopId, stop.scheduledArrival, stop.state]), + [ + ['A1', '23:50', 'passed'], + ['Bst', '00:00', 'next'], + ['C', '23:20', 'upcoming'], + ], + ); +}); + +test('no line is invented where the GTFS has no shape', async (t) => { + const zip = await scheduleZip(t); + { + const feed = railFeed(); + const { pack: paths } = await buildPack(feed, zip, { url: 'x' }); + assert.equal(paths.shapes.length, 1); + assert.deepEqual(paths.shapeSources, ['operator']); + const { schedule, stats } = await buildSchedule( + feed, + zip, + { url: 'x' }, + paths, + { from: '2026-11-05' }, + ); + const pattern = schedule.patterns[schedule.trips.t4.pattern]; + assert.equal(pattern.shape, null, 'the shapeless trip gets no shape'); + // Great-circle metres between the two stops, a little under 3 km. + assert.equal(pattern.dist.length, 2); + assert.ok(Math.abs(pattern.dist[1] - 3000) < 5, String(pattern.dist[1])); + + assert.equal(stats.patternsShapeless, 1); + assert.equal(paths.shapes.length, 1, 'the path pack is left as built'); + } + const chord = [ + ['shape_id', 'shape_pt_lat', 'shape_pt_lon', 'shape_pt_sequence'].join(), + `S,0,0,1`, + `S,0,${(6000 * D).toFixed(6)},2`, + ].join('\n'); + const sparse = await withZip(t, [ + { name: 'routes.txt', text: 'route_id,route_type\nR1,2\n' }, + { + name: 'trips.txt', + text: 'route_id,service_id,trip_id,shape_id\nR1,WK,t1,S\n', + }, + { name: 'shapes.txt', text: chord }, + ]); + const { pack, stats } = await buildPack( + railFeed({ positions: 'schedule' }), + sparse, + { url: 'x' }, + ); + assert.equal(pack.shapes.length, 0, 'a chord is dropped on every feed'); + assert.equal(stats.shapesDropped, 1); +}); + +test('frequency windows give one departure per headway before each end', () => { + const h = (hours, minutes = 0) => hours * 3600 + minutes * 60; + // Santiago's Tren Nos: back-to-back windows of different headways. + assert.deepEqual( + frequencyDepartures([h(6), h(6, 10), 600, h(6, 10), h(6, 42), 480]), + [h(6), h(6, 10), h(6, 18), h(6, 26), h(6, 34)], + ); + // Overlapping windows do not repeat a departure; malformed rows add none. + assert.deepEqual( + frequencyDepartures([ + ...[h(7), h(7, 20), 600], + ...[h(7, 10), h(7, 11), 600], + ...[h(8), h(9), 0], + ...[Number.NaN, h(9), 600], + ...[h(9), h(8), 600], + ]), + [h(7), h(7, 10)], + ); + // One row is capped at a run a minute for a day. + assert.equal(frequencyDepartures([0, 2 * 86_400, 1]).length, 1440); +}); + +test('a frequency-based trip becomes one run per departure on its template', async (t) => { + const zip = await withZip(t, [ + { + name: 'agency.txt', + text: 'agency_id,agency_name,agency_url,agency_timezone\nA,Test,https://example.test,America/Santiago\n', + }, + { + name: 'calendar.txt', + text: [ + 'service_id,monday,tuesday,wednesday,thursday,friday,saturday,sunday,start_date,end_date', + 'L,1,1,1,1,1,0,0,20261003,20261231', + ].join('\n'), + }, + { name: 'routes.txt', text: 'route_id,route_type\nMTN,0\nMTR,0\n' }, + { + name: 'trips.txt', + text: [ + 'route_id,service_id,trip_id,shape_id,trip_headsign,direction_id', + 'MTN,L,MTN-I-L-M01,S,Nos,0', + 'MTN,L,MTN-I-L-M02,S,Nos,0', + 'MTR,L,MTR-I-L-1,S,Rancagua,0', + ].join('\n'), + }, + { + name: 'frequencies.txt', + text: [ + 'trip_id,start_time,end_time,headway_secs,exact_times', + 'MTN-I-L-M01,06:00:00,06:10:00,600,0', + 'MTN-I-L-M02,06:10:00,06:30:00,480,0', + 'MTN-I-L-M02,23:58:00,24:10:00,1200,0', + ].join('\n'), + }, + { + name: 'stops.txt', + text: [ + 'stop_id,stop_name,stop_lat,stop_lon', + 'A,Alpha,0,' + east(10), + 'B,Bravo,0,' + east(2000), + ].join('\n'), + }, + { + name: 'stop_times.txt', + text: [ + 'trip_id,arrival_time,departure_time,stop_id,stop_sequence', + // Templates timed from 00:00:00, as the DTPM file has them. + 'MTN-I-L-M01,00:00:00,00:00:00,A,1', + 'MTN-I-L-M01,00:05:00,00:05:00,B,2', + 'MTN-I-L-M02,00:00:00,00:00:00,A,1', + 'MTN-I-L-M02,00:05:00,00:05:00,B,2', + 'MTR-I-L-1,07:00:00,07:00:00,A,1', + 'MTR-I-L-1,07:04:00,07:04:00,B,2', + ].join('\n'), + }, + { + name: 'shapes.txt', + text: [ + 'shape_id,shape_pt_lat,shape_pt_lon,shape_pt_sequence', + ...shapeRows('S', 25, 100), + ].join('\n'), + }, + ]); + const feed = railFeed(); + const { pack: paths } = await buildPack(feed, zip, { url: 'x' }); + const { schedule, stats } = await buildSchedule( + feed, + zip, + { url: 'x' }, + paths, + { from: '2026-11-10', days: 1 }, + ); + + assert.deepEqual(Object.keys(schedule.trips).sort(), [ + 'MTN-I-L-M01@06:00:00', + 'MTN-I-L-M02@06:10:00', + 'MTN-I-L-M02@06:18:00', + 'MTN-I-L-M02@06:26:00', + 'MTN-I-L-M02@23:58:00', + 'MTR-I-L-1', + ]); + const runs = Object.entries(schedule.trips).filter(([id]) => + id.startsWith('MTN'), + ); + assert.deepEqual( + runs.map(([, trip]) => trip.startSec), + [21_600, 22_200, 22_680, 23_160, 86_280], + ); + // Every run shares its template's pattern and run times. + assert.equal(new Set(runs.map(([, trip]) => trip.pattern)).size, 1); + assert.equal(new Set(runs.map(([, trip]) => trip.profile)).size, 1); + assert.deepEqual(schedule.profiles[runs[0][1].profile], [0, 0, 300, 300]); + assert.equal(runs[0][1].headsign, 'Nos'); + assert.equal(schedule.trips['MTR-I-L-1'].startSec, 25_200); + assert.equal(stats.frequencyTrips, 2); + assert.equal(stats.frequencyRuns, 5); + assert.equal(stats.tripsKept, 6); + // The 23:58 run arrives after midnight: the pack looks a day back for it. + assert.equal(stats.lookbackDays, 1); + assert.deepEqual([...schedule.services.L], ['2026-11-09', '2026-11-10']); + // The browser module times a run from its own start (Santiago is UTC-3 + // in November). + const live = createSchedule(JSON.parse(JSON.stringify(schedule))); + const stops = tripStops( + live, + { tripId: 'MTN-I-L-M02@06:18:00', serviceDate: '2026-11-10' }, + Date.parse('2026-11-10T09:20:00Z'), + ); + assert.deepEqual( + stops.map((stop) => [stop.stopId, stop.scheduledArrival, stop.state]), + [ + ['A', '06:18', 'passed'], + ['B', '06:23', 'next'], + ], + ); +}); diff --git a/src/tooling/buildRailPathsCli.test.mjs b/src/tooling/buildRailPathsCli.test.mjs new file mode 100644 index 000000000..23eed6932 --- /dev/null +++ b/src/tooling/buildRailPathsCli.test.mjs @@ -0,0 +1,305 @@ +// The pack build's download and command line: https on every hop, a byte +// cap and a time limit; the redirect target recorded as the source; the +// output folder the server reads; `--no-schedule` refused for a feed named +// only through its schedule pack; and exit code 2 when a feed fails. No test +// here touches the network: downloads are a stubbed fetch, builds read a +// local zip. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + FEED_FAILED_EXIT_CODE, + buildRefusal, + downloadStatic, + packSource, + parseBuildArgs, +} from '../../scripts/build-rail-paths.mjs'; +import { getRegisteredRailFeed } from '../data/railFeeds.js'; + +const SCRIPT = fileURLToPath( + new URL('../../scripts/build-rail-paths.mjs', import.meta.url), +); + +async function tempDir(t) { + const dir = await mkdtemp(path.join(os.tmpdir(), 'gev-rail-cli-test-')); + t.after(() => rm(dir, { recursive: true, force: true })); + return dir; +} + +/** A fetch stub answering each URL from `answers`, recording each call. */ +function stubFetch(t, answers) { + const calls = []; + t.mock.method(globalThis, 'fetch', async (url, init) => { + calls.push({ url, init }); + const answer = answers(url, init); + return typeof answer === 'function' ? answer() : answer; + }); + return calls; +} + +const redirect = (location, status = 302) => + new Response(null, { status, headers: { location } }); + +test('a download follows https redirects hop by hop and records where it ended', async (t) => { + const dir = await tempDir(t); + const calls = stubFetch(t, (url) => + url === 'https://example.test/gtfs.zip' + ? redirect('https://cdn.example.test/files/gtfs.zip') + : new Response('zip bytes', { + status: 200, + headers: { 'last-modified': 'Thu, 05 Nov 2026 10:00:00 GMT' }, + }), + ); + const dest = path.join(dir, 'gtfs.zip'); + const got = await downloadStatic('https://example.test/gtfs.zip', dest, { + 'Digitraffic-User': 'GodsEyeView/RailLayer', + }); + assert.deepEqual( + calls.map((call) => call.url), + [ + 'https://example.test/gtfs.zip', + 'https://cdn.example.test/files/gtfs.zip', + ], + ); + for (const { init } of calls) { + assert.equal(init.redirect, 'manual'); + assert.match(init.headers['User-Agent'], /gods-eye-view-rail-paths/); + assert.equal(init.headers['Digitraffic-User'], 'GodsEyeView/RailLayer'); + } + assert.equal(got.finalUrl, 'https://cdn.example.test/files/gtfs.zip'); + assert.equal(got.lastModified, 'Thu, 05 Nov 2026 10:00:00 GMT'); + assert.equal(got.bytes, 9); + assert.equal(await readFile(dest, 'utf8'), 'zip bytes'); + // The packs record the URL the file came from, not the one first asked. + const feed = getRegisteredRailFeed('mbta-rail'); + assert.deepEqual(packSource(feed, got), { + url: 'https://cdn.example.test/files/gtfs.zip', + lastModified: 'Thu, 05 Nov 2026 10:00:00 GMT', + }); + assert.deepEqual( + packSource(feed, { + file: '/tmp/x.zip', + localName: 'x.zip', + lastModified: 'Wed, 04 Nov 2026 10:00:00 GMT', + }), + { + url: feed.staticGtfsUrl, + file: 'x.zip', + lastModified: 'Wed, 04 Nov 2026 10:00:00 GMT', + }, + ); +}); + +test('a download is https only: plain http is refused before it is asked', async (t) => { + const dir = await tempDir(t); + const calls = stubFetch(t, () => + redirect('http://example.test/gtfs.zip', 301), + ); + await assert.rejects( + downloadStatic('https://example.test/gtfs.zip', path.join(dir, 'a.zip')), + /refusing non-https URL http:\/\/example\.test\/gtfs\.zip/, + ); + assert.equal(calls.length, 1, 'the http hop is never contacted'); + await assert.rejects( + downloadStatic('http://example.test/gtfs.zip', path.join(dir, 'b.zip')), + /refusing non-https/, + ); + assert.equal(calls.length, 1); +}); + +test('a download stops at the redirect limit', async (t) => { + const dir = await tempDir(t); + let n = 0; + const calls = stubFetch(t, () => redirect(`https://example.test/${++n}`)); + await assert.rejects( + downloadStatic('https://example.test/0', path.join(dir, 'a.zip')), + /too many redirects/, + ); + assert.equal(calls.length, 6, 'the first ask and five redirects'); +}); + +test('a download over the byte cap is refused, declared or streamed', async (t) => { + const dir = await tempDir(t); + stubFetch( + t, + (url) => () => + url.endsWith('declared') + ? new Response('x'.repeat(10), { + status: 200, + headers: { 'content-length': '2000' }, + }) + : new Response( + new ReadableStream({ + start(controller) { + for (let i = 0; i < 4; i += 1) + controller.enqueue(new Uint8Array(400)); + controller.close(); + }, + }), + { status: 200 }, + ), + ); + await assert.rejects( + downloadStatic( + 'https://example.test/declared', + path.join(dir, 'a.zip'), + null, + { maxBytes: 1000 }, + ), + /download is 2000 bytes/, + ); + await assert.rejects( + downloadStatic( + 'https://example.test/streamed', + path.join(dir, 'b.zip'), + null, + { maxBytes: 1000 }, + ), + /exceeds the size cap/, + ); + // Under the cap it is written whole. + const got = await downloadStatic( + 'https://example.test/streamed', + path.join(dir, 'c.zip'), + null, + { maxBytes: 1600 }, + ); + assert.equal(got.bytes, 1600); +}); + +test('a download that does not finish in time is abandoned', async (t) => { + const dir = await tempDir(t); + stubFetch( + t, + (url, init) => () => + new Promise((resolve, reject) => { + init.signal.addEventListener('abort', () => reject(init.signal.reason)); + }), + ); + await assert.rejects( + downloadStatic('https://example.test/slow', path.join(dir, 'a.zip'), null, { + timeoutMs: 50, + }), + (error) => error?.name === 'TimeoutError', + ); +}); + +test('the build writes where the server reads: RAIL_PATHS_DIR unless --out names a folder', () => { + const served = path.join(os.tmpdir(), 'served-packs'); + assert.equal( + parseBuildArgs(['--feed', 'a'], { RAIL_PATHS_DIR: served }).out, + path.resolve(served), + ); + const own = path.join(os.tmpdir(), 'own-packs'); + assert.equal( + parseBuildArgs(['--feed', 'a', '--out', own], { RAIL_PATHS_DIR: served }) + .out, + path.resolve(own), + ); + assert.match( + parseBuildArgs(['--feed', 'a'], {}).out, + /[\\/]\.cache[\\/]rail-paths$/, + ); + // The served folder is held to the same rule as --out. + const src = fileURLToPath(new URL('../packs', import.meta.url)); + assert.throws( + () => parseBuildArgs(['--feed', 'a'], { RAIL_PATHS_DIR: src }), + /--out must not be inside src\//, + ); +}); + +test('--no-schedule is refused for a feed named only through its schedule pack', () => { + const ktmb = getRegisteredRailFeed('ktmb'); + const mbta = getRegisteredRailFeed('mbta-rail'); + assert.match(buildRefusal(ktmb, { schedule: false }), /ktmb.*schedule pack/); + assert.match( + buildRefusal(getRegisteredRailFeed('renfe-larga-distancia'), { + schedule: false, + }), + /renfe-larga-distancia/, + ); + assert.equal(buildRefusal(ktmb, { schedule: true }), null); + assert.equal(buildRefusal(mbta, { schedule: false }), null); +}); + +/** Run the build script with the given arguments and environment. */ +function runBuild(args, env = {}) { + const base = { ...process.env }; + delete base.RAIL_PATHS_DIR; + return spawnSync(process.execPath, [SCRIPT, ...args], { + encoding: 'utf8', + env: { ...base, ...env }, + timeout: 60_000, + }); +} + +test('a build in which a feed fails exits with code 2', async (t) => { + const dir = await tempDir(t); + const broken = path.join(dir, 'broken.zip'); + await writeFile(broken, 'this is not a zip'); + const out = path.join(dir, 'packs'); + const run = runBuild([ + '--feed', + 'mbta-rail', + '--local-gtfs', + `mbta-rail=${broken}`, + '--out', + out, + ]); + assert.equal(run.status, FEED_FAILED_EXIT_CODE, run.stderr); + assert.match(run.stderr, /mbta-rail skipped/); + assert.match(run.stdout, /done: 0 built, failed: mbta-rail/); +}); + +test('a build with no --out writes to RAIL_PATHS_DIR', async (t) => { + const dir = await tempDir(t); + const broken = path.join(dir, 'broken.zip'); + await writeFile(broken, 'this is not a zip'); + const served = path.join(dir, 'served'); + const run = runBuild( + ['--feed', 'mbta-rail', '--local-gtfs', `mbta-rail=${broken}`], + { RAIL_PATHS_DIR: served }, + ); + assert.equal(run.status, FEED_FAILED_EXIT_CODE, run.stderr); + assert.ok( + run.stdout.includes(`1 feeds → ${path.resolve(served)}`), + run.stdout, + ); +}); + +test('--no-schedule leaves a pack-routed feed and its schedule pack as they were', async (t) => { + const dir = await tempDir(t); + const zip = path.join(dir, 'ktmb.zip'); + await writeFile(zip, 'not read: the build is refused first'); + const out = path.join(dir, 'packs'); + const runRefused = async () => { + const run = runBuild([ + '--feed', + 'ktmb', + '--no-schedule', + '--local-gtfs', + `ktmb=${zip}`, + '--out', + out, + ]); + assert.equal(run.status, FEED_FAILED_EXIT_CODE, run.stderr); + assert.match( + run.stderr, + /ktmb skipped: --no-schedule: ktmb names its trains only through its schedule pack/, + ); + return run; + }; + await runRefused(); + const schedule = path.join(out, 'ktmb.schedule.json'); + await writeFile(schedule, '{"version":1,"feedId":"ktmb"}'); + await runRefused(); + assert.equal( + await readFile(schedule, 'utf8'), + '{"version":1,"feedId":"ktmb"}', + 'the schedule pack stays', + ); +}); diff --git a/src/tooling/buildRailPathsLocal.test.mjs b/src/tooling/buildRailPathsLocal.test.mjs new file mode 100644 index 000000000..420e11725 --- /dev/null +++ b/src/tooling/buildRailPathsLocal.test.mjs @@ -0,0 +1,182 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { crc32 } from 'node:zlib'; +import { + createRouteNames, + localStatic, + openZip, + parseLocalGtfs, + readTripEnds, +} from '../../scripts/build-rail-paths.mjs'; +import { classifyTrain } from '../data/railFeeds.js'; + +/** A minimal stored (uncompressed) zip. */ +function makeZip(files) { + const locals = []; + const centrals = []; + let offset = 0; + for (const [name, text] of Object.entries(files)) { + const data = Buffer.from(text, 'utf8'); + const nameBytes = Buffer.from(name, 'utf8'); + const local = Buffer.alloc(30); + local.writeUInt32LE(0x04034b50, 0); + local.writeUInt16LE(20, 4); + local.writeUInt32LE(crc32(data), 14); + local.writeUInt32LE(data.length, 18); + local.writeUInt32LE(data.length, 22); + local.writeUInt16LE(nameBytes.length, 26); + const central = Buffer.alloc(46); + central.writeUInt32LE(0x02014b50, 0); + central.writeUInt16LE(20, 4); + central.writeUInt16LE(20, 6); + central.writeUInt32LE(crc32(data), 16); + central.writeUInt32LE(data.length, 20); + central.writeUInt32LE(data.length, 24); + central.writeUInt16LE(nameBytes.length, 28); + central.writeUInt32LE(offset, 42); + locals.push(local, nameBytes, data); + centrals.push(central, nameBytes); + offset += local.length + nameBytes.length + data.length; + } + const cd = Buffer.concat(centrals); + const end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50, 0); + end.writeUInt16LE(locals.length / 3, 8); + end.writeUInt16LE(locals.length / 3, 10); + end.writeUInt32LE(cd.length, 12); + end.writeUInt32LE(offset, 16); + return Buffer.concat([...locals, cd, end]); +} + +async function tempDir(t) { + const dir = await mkdtemp(path.join(os.tmpdir(), 'gev-rail-local-test-')); + t.after(() => rm(dir, { recursive: true, force: true })); + return dir; +} + +/** + * A GTFS in a top-level folder: a train from Venezia into Austria, a + * domestic train, a train inside Italy, and a bus. + */ +const GTFS_FILES = { + 'GTFS_2026/agency.txt': + 'agency_id,agency_name,agency_url,agency_timezone\n01,Example Rail,https://example.test,Europe/Vienna\n', + 'GTFS_2026/routes.txt': [ + 'route_id,agency_id,route_short_name,route_type', + 'I3,01,I3,2', + 'A9,01,A9,2', + 'SV1,01,SV1,3', + ].join('\n'), + 'GTFS_2026/trips.txt': [ + 'route_id,service_id,trip_id,trip_short_name', + 'I3,TA,it-domestic,R 134', + 'A9,TA,cross-border,EC 134', + 'A9,TA,at-domestic,RJ 650', + 'SV1,TA,bus,', + ].join('\n'), + 'GTFS_2026/stops.txt': [ + 'stop_id,stop_name,stop_lat,stop_lon', + 'it:22021:1,Venezia Santa Lucia,45.441,12.321', + 'it:22021:2,Tarvisio Citta Boscoverde,46.507,13.583', + 'at:42:1:1,Villach Hauptbahnhof,46.618,13.848', + 'at:42:2:1,Klagenfurt Hauptbahnhof,46.616,14.312', + 'at:49:1:1,Wien Hauptbahnhof,48.185,16.376', + ].join('\n'), + 'GTFS_2026/stop_times.txt': [ + 'trip_id,arrival_time,departure_time,stop_id,stop_sequence', + 'it-domestic,09:56:00,09:56:00,it:22021:1,1', + 'it-domestic,12:41:00,12:41:00,it:22021:2,2', + 'cross-border,12:57:00,12:57:00,it:22021:2,1', + 'cross-border,13:30:00,13:32:00,at:42:1:1,2', + 'cross-border,17:07:00,17:07:00,at:49:1:1,3', + 'at-domestic,08:00:00,08:00:00,at:42:2:1,1', + 'at-domestic,12:00:00,12:00:00,at:49:1:1,2', + 'bus,08:00:00,08:00:00,at:42:1:1,1', + 'bus,08:30:00,08:30:00,at:42:2:1,2', + ].join('\n'), +}; + +/** + * A feed whose category rule needs each trip's ends (`staticTripEnds: + * true`): a trip is international when it stops in more than one country. + */ +const ENDS_FEED = Object.freeze({ + id: 'test-ends', + staticTripEnds: true, + staticTripRename: (trip, routeId, { ends } = {}) => + `${(ends?.countries ?? []).length > 1 ? 'INT' : 'DOM'}~${routeId}`, + classify: (routeId) => + String(routeId).startsWith('INT~') + ? { category: 'international', mode: 'rail' } + : String(routeId).startsWith('DOM~') + ? { category: 'national', mode: 'rail' } + : null, +}); + +test('--local-gtfs names a feed and a zip path, made absolute', () => { + const parsed = parseLocalGtfs('test-ends=downloads/GTFS_2026.zip'); + assert.equal(parsed.id, 'test-ends'); + assert.ok(path.isAbsolute(parsed.file)); + assert.equal(path.basename(parsed.file), 'GTFS_2026.zip'); + // A Windows path keeps its drive letter. + assert.equal( + parseLocalGtfs('irish-rail=C:\\Users\\me\\Downloads\\gtfs.zip').file, + path.resolve('C:\\Users\\me\\Downloads\\gtfs.zip'), + ); + for (const bad of ['', 'test-ends', 'test-ends=', '=x.zip', 'Bad_Id=x.zip']) + assert.throws(() => parseLocalGtfs(bad), /--local-gtfs/, bad); +}); + +test('a local zip stands in for a download, by name only, and is left as it was', async (t) => { + const dir = await tempDir(t); + const file = path.join(dir, 'GTFS_2026.zip'); + const bytes = makeZip(GTFS_FILES); + await writeFile(file, bytes); + const before = await stat(file); + const got = await localStatic(file); + assert.equal(got.file, file); + assert.equal(got.localName, 'GTFS_2026.zip'); + assert.equal(got.lastModified, before.mtime.toUTCString()); + // Reading it through the build leaves the file unchanged. + const zip = await openZip(got.file); + await readTripEnds(ENDS_FEED, zip); + await zip.handle.close(); + assert.deepEqual(await readFile(file), bytes); + assert.equal((await stat(file)).mtimeMs, before.mtimeMs); + await assert.rejects(localStatic(path.join(dir, 'missing.zip'))); + await assert.rejects(localStatic(dir), /not a file/); +}); + +test('staticTripEnds gives each rail trip its ends and every country it stops in', async (t) => { + const dir = await tempDir(t); + const file = path.join(dir, 'feed.zip'); + await writeFile(file, makeZip(GTFS_FILES)); + const zip = await openZip(file); + t.after(() => zip.handle.close()); + const feed = ENDS_FEED; + const ends = await readTripEnds(feed, zip); + assert.deepEqual([...ends.keys()].sort(), [ + 'at-domestic', + 'cross-border', + 'it-domestic', + ]); + assert.deepEqual(ends.get('cross-border').countries, ['AT', 'IT']); + assert.deepEqual(ends.get('at-domestic').countries, ['AT']); + assert.deepEqual(ends.get('it-domestic').countries, ['IT']); + assert.equal(ends.get('cross-border').from.name, 'Tarvisio Citta Boscoverde'); + assert.equal(ends.get('cross-border').to.name, 'Wien Hauptbahnhof'); + + const names = await createRouteNames(feed, zip); + const route = names.route({ route_id: 'A9', route_type: '2' }); + const category = (tripId, shortName) => + classifyTrain( + feed, + names.trip({ trip_id: tripId, trip_short_name: shortName }, route), + )?.category ?? null; + assert.equal(category('cross-border', 'EC 134'), 'international'); + assert.equal(category('at-domestic', 'RJ 650'), 'national'); + assert.equal(names.route({ route_id: 'SV1', route_type: '3' }), null); +}); diff --git a/src/tooling/buildRailPathsRoutes.test.mjs b/src/tooling/buildRailPathsRoutes.test.mjs new file mode 100644 index 000000000..37ed4c4c7 --- /dev/null +++ b/src/tooling/buildRailPathsRoutes.test.mjs @@ -0,0 +1,143 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { crc32 } from 'node:zlib'; +import { + buildPack, + createRouteNames, + createStateLookup, + openZip, +} from '../../scripts/build-rail-paths.mjs'; +import { getRegisteredRailFeed } from '../data/railFeeds.js'; + +/** A minimal stored (uncompressed) zip. */ +function makeZip(files) { + const locals = []; + const centrals = []; + let offset = 0; + for (const [name, text] of Object.entries(files)) { + const data = Buffer.from(text, 'utf8'); + const nameBytes = Buffer.from(name, 'utf8'); + const local = Buffer.alloc(30); + local.writeUInt32LE(0x04034b50, 0); + local.writeUInt16LE(20, 4); + local.writeUInt32LE(crc32(data), 14); + local.writeUInt32LE(data.length, 18); + local.writeUInt32LE(data.length, 22); + local.writeUInt16LE(nameBytes.length, 26); + const central = Buffer.alloc(46); + central.writeUInt32LE(0x02014b50, 0); + central.writeUInt16LE(20, 4); + central.writeUInt16LE(20, 6); + central.writeUInt32LE(crc32(data), 16); + central.writeUInt32LE(data.length, 20); + central.writeUInt32LE(data.length, 24); + central.writeUInt16LE(nameBytes.length, 28); + central.writeUInt32LE(offset, 42); + locals.push(local, nameBytes, data); + centrals.push(central, nameBytes); + offset += local.length + nameBytes.length + data.length; + } + const cd = Buffer.concat(centrals); + const end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50, 0); + end.writeUInt16LE(locals.length / 3, 8); + end.writeUInt16LE(locals.length / 3, 10); + end.writeUInt32LE(cd.length, 12); + end.writeUInt32LE(offset, 16); + return Buffer.concat([...locals, cd, end]); +} + +async function withZip(t, files) { + const dir = await mkdtemp(path.join(os.tmpdir(), 'gev-rail-routes-test-')); + t.after(() => rm(dir, { recursive: true, force: true })); + const file = path.join(dir, 'feed.zip'); + await writeFile(file, makeZip(files)); + const zip = await openZip(file); + t.after(() => zip.handle.close()); + return zip; +} + +test('states: Spanish provinces group into communities; abroad is abroad', async () => { + const at = await createStateLookup(); + assert.deepEqual(at(40.4712, -3.683), { country: 'ES', state: 'ES-MD' }); + assert.deepEqual(at(38.8907, -6.9818), { country: 'ES', state: 'ES-EX' }); + assert.equal(at(41.3799, 2.141).state, 'ES-CT', 'Barcelona'); + assert.equal(at(36.5287, -6.2896).state, 'ES-AN', 'Cádiz, on the coast'); + assert.equal(at(43.3035, 5.3822).country, 'FR'); + assert.equal(at(Number.NaN, 0), null); + assert.equal(at(0, -30), null, 'mid-ocean'); +}); + +test('Renfe routes are renamed with product and community scope', async (t) => { + const zip = await withZip(t, { + 'stops.txt': [ + 'stop_id,stop_name,stop_lat,stop_lon', + '17000,Madrid-Chamartín,40.4711789,-3.6829524', + '37606,Badajoz,38.8906960,-6.9817530', + '10200,Guadalajara,40.6440,-3.1820', + '18000,Madrid-Atocha,40.4064,-3.6906', + ].join('\n'), + }); + const names = await createRouteNames( + getRegisteredRailFeed('renfe-larga-distancia'), + zip, + ); + const route = (id, short, type = '2') => + names.route({ route_id: id, route_short_name: short, route_type: type }); + assert.equal(route('1700037606GL023', 'MD'), 'MD~X~1700037606GL023'); + assert.equal( + route('1700018000GL001', 'REGIONAL'), + 'REGIONAL~S~1700018000GL001', + ); + assert.equal( + route('1700010200GL001', 'MD'), + 'MD~X~1700010200GL001', + 'Madrid to Castilla-La Mancha', + ); + assert.equal(route('1700037606GL023', 'AUTOBUS'), null, 'an unknown product'); + assert.equal(route('1700037606GL023', 'MD', '3'), null, 'not rail'); + assert.equal(names.trip({ trip_id: 'x' }, 'MD~S~1'), 'MD~S~1'); +}); + +test('a renamed route keeps its shapes under the new id in the path pack', async (t) => { + const zip = await withZip(t, { + 'routes.txt': [ + 'route_id,agency_id,route_short_name,route_type', + '114876,IFF:NS,Intercity,2', + '9,GVB,Tram 9,0', + ].join('\n'), + 'trips.txt': [ + 'route_id,service_id,trip_id,shape_id', + '114876,s,338806760,sh1', + '9,s,t9,sh2', + ].join('\n'), + 'shapes.txt': [ + 'shape_id,shape_pt_lat,shape_pt_lon,shape_pt_sequence', + 'sh1,52.0,5.0,1', + 'sh1,52.001,5.001,2', + 'sh1,52.002,5.002,3', + 'sh2,52.3,4.9,1', + 'sh2,52.301,4.901,2', + ].join('\n'), + }); + // A rename that names the operator, as a feed that sorts its routes by + // agency writes it; the classifier reads the renamed id. + const feed = { + id: 'test-feed', + staticRouteRename: (route) => + route.route_type === '2' + ? `${route.agency_id.split(':').pop()}~${route.route_short_name}~${route.route_id}` + : null, + classify: (routeId) => + routeId?.startsWith('NS~') + ? { category: 'national', mode: 'rail' } + : null, + }; + const { pack } = await buildPack(feed, zip, { url: 'x' }); + assert.deepEqual(Object.keys(pack.routes), ['NS~Intercity~114876']); + + assert.deepEqual(Object.keys(pack.trips), ['338806760']); +}); diff --git a/src/tooling/buildRailPathsTripEnds.test.mjs b/src/tooling/buildRailPathsTripEnds.test.mjs new file mode 100644 index 000000000..d07dd518c --- /dev/null +++ b/src/tooling/buildRailPathsTripEnds.test.mjs @@ -0,0 +1,162 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { crc32 } from 'node:zlib'; +import { + buildPack, + buildSchedule, + createRouteNames, + openZip, + readTripEnds, +} from '../../scripts/build-rail-paths.mjs'; +import { classifyTrain, getRegisteredRailFeed } from '../data/railFeeds.js'; + +/** A minimal stored (uncompressed) zip. */ +function makeZip(files) { + const locals = []; + const centrals = []; + let offset = 0; + for (const [name, text] of Object.entries(files)) { + const data = Buffer.from(text, 'utf8'); + const nameBytes = Buffer.from(name, 'utf8'); + const local = Buffer.alloc(30); + local.writeUInt32LE(0x04034b50, 0); + local.writeUInt16LE(20, 4); + local.writeUInt32LE(crc32(data), 14); + local.writeUInt32LE(data.length, 18); + local.writeUInt32LE(data.length, 22); + local.writeUInt16LE(nameBytes.length, 26); + const central = Buffer.alloc(46); + central.writeUInt32LE(0x02014b50, 0); + central.writeUInt16LE(20, 4); + central.writeUInt16LE(20, 6); + central.writeUInt32LE(crc32(data), 16); + central.writeUInt32LE(data.length, 20); + central.writeUInt32LE(data.length, 24); + central.writeUInt16LE(nameBytes.length, 28); + central.writeUInt32LE(offset, 42); + locals.push(local, nameBytes, data); + centrals.push(central, nameBytes); + offset += local.length + nameBytes.length + data.length; + } + const cd = Buffer.concat(centrals); + const end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50, 0); + end.writeUInt16LE(locals.length / 3, 8); + end.writeUInt16LE(locals.length / 3, 10); + end.writeUInt32LE(cd.length, 12); + end.writeUInt32LE(offset, 16); + return Buffer.concat([...locals, cd, end]); +} + +async function withZip(t, files) { + const dir = await mkdtemp(path.join(os.tmpdir(), 'gev-rail-ends-test-')); + t.after(() => rm(dir, { recursive: true, force: true })); + const file = path.join(dir, 'feed.zip'); + await writeFile(file, makeZip(files)); + const zip = await openZip(file); + t.after(() => zip.handle.close()); + return zip; +} + +/** A small KTMB-like GTFS: an ETS run KL–JB, an ETS run KL–Hat Yai, a bus. */ +const KTMB_FILES = { + 'agency.txt': + 'agency_id,agency_name,agency_url,agency_timezone\nktmb,KTMB,https://ktmb.com.my,Asia/Kuala_Lumpur\n', + 'routes.txt': [ + 'route_id,route_short_name,route_type', + 'ETS,ETS,2', + 'KC05_KB18,Seremban,2', + 'B1,Bus,3', + ].join('\n'), + 'trips.txt': [ + 'route_id,service_id,trip_id', + 'ETS,daily,9001', + 'ETS,daily,9002', + 'KC05_KB18,daily,2001', + 'B1,daily,b1', + ].join('\n'), + 'stops.txt': [ + 'stop_id,stop_name,stop_lat,stop_lon', + '19100,KL SENTRAL,3.134167,101.686111', + '37500,JB SENTRAL,1.463147,103.764445', + '22700,SEREMBAN,2.719169,101.940792', + '99000,HAT YAI,7.0033,100.4687', + '50600,BATU CAVES,3.237796,101.681215', + ].join('\n'), + 'stop_times.txt': [ + 'trip_id,arrival_time,departure_time,stop_id,stop_sequence', + // Out of order on purpose: the ends come from stop_sequence. + '9001,08:40:00,08:42:00,22700,2', + '9001,08:00:00,08:00:00,19100,1', + '9001,12:00:00,12:00:00,37500,3', + '9002,09:00:00,09:00:00,19100,1', + '9002,17:00:00,17:00:00,99000,2', + '2001,07:00:00,07:00:00,50600,1', + '2001,07:30:00,07:30:00,19100,2', + 'b1,07:00:00,07:00:00,19100,1', + 'b1,07:30:00,07:30:00,22700,2', + ].join('\n'), + 'calendar.txt': [ + 'service_id,monday,tuesday,wednesday,thursday,friday,saturday,sunday,start_date,end_date', + 'daily,1,1,1,1,1,1,1,20260101,20271231', + ].join('\n'), + 'calendar_dates.txt': [ + 'service_id,date,exception_type', + // Far outside any build window: read and ignored. + 'daily,20271225,2', + 'daily,20261110,2', + ].join('\n'), +}; + +test('readTripEnds keeps rail trips only, ends by stop_sequence, with states', async (t) => { + const zip = await withZip(t, KTMB_FILES); + const ends = await readTripEnds(getRegisteredRailFeed('ktmb'), zip); + assert.deepEqual([...ends.keys()].sort(), ['2001', '9001', '9002']); + const ets = ends.get('9001'); + assert.equal(ets.from.name, 'KL SENTRAL'); + assert.equal(ets.to.name, 'JB SENTRAL'); + assert.deepEqual([ets.from.state, ets.to.state], ['MY-14', 'MY-01']); + assert.deepEqual(ets.countries, ['MY']); + assert.deepEqual(ends.get('9002').countries, ['MY', 'TH']); +}); + +test('a feed with staticTripEnds names each trip by where it runs', async (t) => { + const zip = await withZip(t, KTMB_FILES); + const feed = getRegisteredRailFeed('ktmb'); + const names = await createRouteNames(feed, zip); + const etsKl = names.trip( + { trip_id: '9001' }, + names.route({ route_id: 'ETS', route_type: '2' }), + ); + assert.equal(etsKl, 'ETS~X~ETS'); + assert.equal(classifyTrain(feed, etsKl).category, 'national'); + const etsHatYai = names.trip({ trip_id: '9002' }, 'ETS'); + assert.equal(classifyTrain(feed, etsHatYai).category, 'international'); + assert.equal( + classifyTrain(feed, names.trip({ trip_id: '2001' }, 'KC05_KB18')).category, + 'local', + ); + assert.equal(names.route({ route_id: 'B1', route_type: '3' }), null); +}); + +test('the packs carry the scoped route ids, and the realtime trip resolves to one', async (t) => { + const zip = await withZip(t, KTMB_FILES); + const feed = getRegisteredRailFeed('ktmb'); + const names = await createRouteNames(feed, zip); + const { pack } = await buildPack(feed, zip, { url: 'x' }, names); + const { schedule } = await buildSchedule(feed, zip, { url: 'x' }, pack, { + names, + from: '2026-11-09', + days: 3, + }); + assert.deepEqual( + Object.fromEntries( + Object.entries(schedule.trips).map(([id, trip]) => [id, trip.route]), + ), + { 9001: 'ETS~X~ETS', 9002: 'ETS~B~ETS', 2001: 'KC05_KB18~X~KC05_KB18' }, + ); + assert.deepEqual(schedule.services.daily, ['2026-11-09', '2026-11-11']); +}); diff --git a/src/tooling/buildRailPathsUnlocated.test.mjs b/src/tooling/buildRailPathsUnlocated.test.mjs new file mode 100644 index 000000000..b80c09629 --- /dev/null +++ b/src/tooling/buildRailPathsUnlocated.test.mjs @@ -0,0 +1,199 @@ +// Stops a GTFS gives no coordinates, or (0, 0) as some feeds do for foreign +// stations their international trains serve: left out of their runs, never +// placed by guesswork, so no line is ever drawn to (0, 0). +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { crc32, deflateRawSync } from 'node:zlib'; +import { + buildPack, + buildSchedule, + hasStopCoordinates, + openZip, + readTripEnds, +} from '../../scripts/build-rail-paths.mjs'; + +/** A minimal zip writer: deflated entries, no zip64. */ +function makeZip(files) { + const locals = []; + const centrals = []; + let offset = 0; + for (const { name, text } of files) { + const data = Buffer.from(text, 'utf8'); + const body = deflateRawSync(data); + const nameBytes = Buffer.from(name, 'utf8'); + const local = Buffer.alloc(30); + local.writeUInt32LE(0x04034b50, 0); + local.writeUInt16LE(20, 4); + local.writeUInt16LE(8, 8); + local.writeUInt32LE(crc32(data), 14); + local.writeUInt32LE(body.length, 18); + local.writeUInt32LE(data.length, 22); + local.writeUInt16LE(nameBytes.length, 26); + const central = Buffer.alloc(46); + central.writeUInt32LE(0x02014b50, 0); + central.writeUInt16LE(20, 4); + central.writeUInt16LE(20, 6); + central.writeUInt16LE(8, 10); + central.writeUInt32LE(crc32(data), 16); + central.writeUInt32LE(body.length, 20); + central.writeUInt32LE(data.length, 24); + central.writeUInt16LE(nameBytes.length, 28); + central.writeUInt32LE(offset, 42); + locals.push(local, nameBytes, body); + centrals.push(central, nameBytes); + offset += local.length + nameBytes.length + body.length; + } + const cd = Buffer.concat(centrals); + const end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50, 0); + end.writeUInt16LE(files.length, 8); + end.writeUInt16LE(files.length, 10); + end.writeUInt32LE(cd.length, 12); + end.writeUInt32LE(offset, 16); + return Buffer.concat([...locals, cd, end]); +} + +// A line 20 km due east from 51° N 7° E (in Germany), x in metres. +const LAT = 51; +const LON = 7; +const DLON = 1 / (111_320 * Math.cos((LAT * Math.PI) / 180)); +const lonAt = (x) => LON + x * DLON; +const at = (x) => `${LAT.toFixed(6)},${lonAt(x).toFixed(6)}`; + +async function feedZip(t) { + const dir = await mkdtemp(path.join(os.tmpdir(), 'gev-unlocated-')); + t.after(() => rm(dir, { recursive: true, force: true })); + const file = path.join(dir, 'feed.zip'); + await writeFile( + file, + makeZip([ + { + name: 'agency.txt', + text: 'agency_id,agency_name,agency_url,agency_timezone\nA,Test,https://example.test,Europe/Berlin\n', + }, + { + name: 'calendar.txt', + text: 'service_id,monday,tuesday,wednesday,thursday,friday,saturday,sunday,start_date,end_date\nWK,1,1,1,1,1,1,1,20260101,20261231\n', + }, + { name: 'routes.txt', text: 'route_id,route_type\nR1,2\n' }, + { + name: 'trips.txt', + text: [ + 'route_id,service_id,trip_id,shape_id', + 'R1,WK,t1,', + 'R1,WK,t2,', + 'R1,WK,t3,', + 'R1,WK,t4,', + ].join('\n'), + }, + { + name: 'stops.txt', + text: [ + 'stop_id,stop_name,stop_lat,stop_lon,parent_station', + `A,Alpha,${at(0)},`, + `C,Charlie,${at(20_000)},`, + // (0, 0) in the feed, parent and all. + 'PZ,Zeta (Westf.),0,0,', + 'Z,Zeta (Westf.),0,0,PZ', + 'Y,Ypsilon,0,0,', + // Blank coordinates are none either. + 'Q,Quebec,,,', + ].join('\n'), + }, + { + name: 'stop_times.txt', + text: [ + 'trip_id,arrival_time,departure_time,stop_id,stop_sequence', + 't1,08:00:00,08:00:00,A,1', + 't1,08:10:00,08:10:00,Z,2', + 't1,08:20:00,08:20:00,C,3', + 't2,09:00:00,09:00:00,A,1', + 't2,09:10:00,09:10:00,Y,2', + 't2,09:20:00,09:20:00,C,3', + 't3,10:00:00,10:00:00,A,1', + 't3,10:10:00,10:10:00,Y,2', + 't4,11:00:00,11:00:00,A,1', + 't4,11:10:00,11:10:00,Q,2', + 't4,11:20:00,11:20:00,C,3', + ].join('\n'), + }, + ]), + ); + const zip = await openZip(file); + t.after(() => zip.handle.close()); + return zip; +} + +const feed = { + id: 'test-feed', + classify: () => ({ category: 'regional', mode: 'rail' }), +}; +const options = { from: '2026-11-09', days: 2 }; + +/** Every vertex of every shape the pack holds, as [lat, lon]. */ +function vertices(pack) { + const out = []; + for (const flat of pack.shapes) + for (let i = 0; i < flat.length; i += 2) out.push([flat[i], flat[i + 1]]); + return out; +} + +const stopNames = (schedule, id) => + schedule.patterns[schedule.trips[id].pattern].stops.map( + (k) => schedule.stops[k].name, + ); + +test('(0, 0) and blank coordinates are no coordinates', () => { + assert.equal(hasStopCoordinates(0, 0), false); + assert.equal(hasStopCoordinates(-0, 0), false); + assert.equal(hasStopCoordinates(Number.NaN, 7), false); + assert.equal(hasStopCoordinates(undefined, undefined), false); + assert.equal(hasStopCoordinates(0, 0.00001), true); + assert.equal(hasStopCoordinates(51.68, 7.81), true); +}); + +test('an unlocated stop is left out of its runs, never drawn to (0, 0)', async (t) => { + const zip = await feedZip(t); + const { pack } = await buildPack(feed, zip, { url: 'x' }); + const { schedule, stats } = await buildSchedule( + feed, + zip, + { url: 'x' }, + pack, + options, + ); + // t1, t2 and t4 keep their two located stops; t3 has only one left. + assert.deepEqual(Object.keys(schedule.trips).sort(), ['t1', 't2', 't4']); + for (const id of ['t1', 't2', 't4']) + assert.deepEqual(stopNames(schedule, id), ['Alpha', 'Charlie']); + // The stop's times go with it. + const t1 = schedule.trips.t1; + assert.deepEqual(schedule.profiles[t1.profile], [0, 0, 1200, 1200]); + assert.deepEqual(schedule.patterns[t1.pattern].seq, [1, 3]); + assert.equal(stats.tripsUnknownStop, 1); + assert.equal(stats.tripsStopsLeftOut, 3); + assert.equal(stats.stopsLeftOut, 3); + // Nothing is guessed: no stop without coordinates, nor its parent, is in + // the pack. + for (const id of ['Z', 'PZ', 'Y', 'Q']) + assert.equal( + schedule.stops.find((s) => s.id === id), + undefined, + id, + ); + assert.ok(schedule.stops.every((s) => hasStopCoordinates(s.lat, s.lon))); + assert.ok(vertices(pack).every(([lat, lon]) => hasStopCoordinates(lat, lon))); +}); + +test('a run ends at its last located stop when its last has no coordinates', async (t) => { + const zip = await feedZip(t); + const ends = await readTripEnds({}, zip); + assert.equal(ends.get('t1').from.name, 'Alpha'); + assert.equal(ends.get('t1').to.name, 'Charlie'); + // t3: Alpha, then Ypsilon at (0, 0). + assert.equal(ends.get('t3').to.name, 'Alpha'); + assert.deepEqual(ends.get('t3').countries, ['DE']); +}); diff --git a/src/tooling/buildRailPathsWrite.test.mjs b/src/tooling/buildRailPathsWrite.test.mjs new file mode 100644 index 000000000..a4c405d61 --- /dev/null +++ b/src/tooling/buildRailPathsWrite.test.mjs @@ -0,0 +1,318 @@ +// How the path build writes a feed's packs: the path and schedule packs as +// a pair, never one without the other it indexes; nothing over the size the +// server serves; no collapse in trips without --force; one build per folder +// at a time; and a command line that never starts a build of every feed by +// itself. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + BUILD_LOCK, + FEED_FAILED_EXIT_CODE, + MIN_TRIP_SHARE, + acquireDirLock, + buildExitCode, + isInside, + operatorShapeTrips, + parseBuildArgs, + scheduleTripsPerDay, + tripFloorProblem, + writeFeedPacks, +} from '../../scripts/build-rail-paths.mjs'; + +async function tempDir(t) { + const dir = await mkdtemp(path.join(os.tmpdir(), 'gev-rail-write-test-')); + t.after(() => rm(dir, { recursive: true, force: true })); + return dir; +} + +/** A path pack with `trips` trips on one operator shape. */ +function pathPack(builtAt, trips, { shapes = 1, source = 'operator' } = {}) { + return { + version: 1, + feedId: 'f', + builtAt, + source: { url: 'https://example.test/gtfs.zip' }, + shapes: Array.from({ length: shapes }, () => [0, 0, 0.001, 0.001]), + dist: Array.from({ length: shapes }, () => [0, 157]), + shapeSources: Array.from({ length: shapes }, () => source), + trips: Object.fromEntries( + Array.from({ length: trips }, (_, i) => [`t${i}`, 0]), + ), + routes: { R: [0] }, + }; +} + +/** A schedule pack with `trips` trips over `days` service days. */ +function schedulePack(pathsBuiltAt, trips, days = 4) { + const from = '2026-11-09'; + const to = new Date(Date.parse(from) + (days - 1) * 86_400_000) + .toISOString() + .slice(0, 10); + return { + version: 1, + feedId: 'f', + builtAt: pathsBuiltAt, + pathsBuiltAt, + window: { from, to }, + trips: Object.fromEntries( + Array.from({ length: trips }, (_, i) => [`t${i}`, { pattern: 0 }]), + ), + }; +} + +const read = async (dir, name) => + JSON.parse(await readFile(path.join(dir, name), 'utf8')); +const tempFiles = async (dir) => + (await readdir(dir)).filter((name) => name.endsWith('.tmp')); + +async function seed(dir, { trips = 10, scheduleTrips = 100 } = {}) { + await writeFile( + path.join(dir, 'f.json'), + JSON.stringify(pathPack('old', trips)), + ); + await writeFile( + path.join(dir, 'f.schedule.json'), + JSON.stringify(schedulePack('old', scheduleTrips)), + ); +} + +test('both packs are written as a pair, through temporary names of their own', async (t) => { + const dir = await tempDir(t); + await seed(dir); + const written = await writeFeedPacks(dir, 'f', { + pack: pathPack('new', 10), + schedule: schedulePack('new', 100), + }); + assert.equal(written.removedSchedule, false); + assert.equal((await read(dir, 'f.json')).builtAt, 'new'); + assert.equal((await read(dir, 'f.schedule.json')).pathsBuiltAt, 'new'); + assert.equal(JSON.parse(written.scheduleText).pathsBuiltAt, 'new'); + assert.deepEqual(await tempFiles(dir), []); +}); + +test('a schedule that cannot be written leaves the previous path pack in place', async (t) => { + const dir = await tempDir(t); + await seed(dir); + const broken = schedulePack('new', 100); + broken.trips.t0.startSec = 1n; // JSON cannot hold it. + await assert.rejects( + writeFeedPacks(dir, 'f', { pack: pathPack('new', 10), schedule: broken }), + /BigInt/, + ); + assert.equal((await read(dir, 'f.json')).builtAt, 'old'); + assert.equal((await read(dir, 'f.schedule.json')).pathsBuiltAt, 'old'); + assert.deepEqual(await tempFiles(dir), []); +}); + +test('without a schedule the previous one, which indexes the old shapes, is removed', async (t) => { + const dir = await tempDir(t); + await seed(dir); + const written = await writeFeedPacks(dir, 'f', { pack: pathPack('new', 10) }); + assert.equal(written.removedSchedule, true); + assert.equal(written.scheduleText, null); + assert.equal((await read(dir, 'f.json')).builtAt, 'new'); + assert.deepEqual((await readdir(dir)).sort(), ['f.json']); + // Nothing to remove the next time. + const again = await writeFeedPacks(dir, 'f', { pack: pathPack('newer', 10) }); + assert.equal(again.removedSchedule, false); +}); + +test('a pack over the size the server serves is not written, and says what thins it', async (t) => { + const dir = await tempDir(t); + await seed(dir); + const pack = pathPack('new', 10); + const maxBytes = Buffer.byteLength(JSON.stringify(pack)) - 1; + await assert.rejects( + writeFeedPacks(dir, 'f', { pack, maxBytes }), + (error) => + /over the .* the server serves/.test(error.message) && + /its shapes hold 2 points/.test(error.message) && + /staticSimplifyM/.test(error.message), + ); + const schedule = schedulePack('new', 100); + await assert.rejects( + writeFeedPacks(dir, 'f', { + pack: pathPack('new', 10, { shapes: 0 }), + schedule, + maxBytes: Buffer.byteLength(JSON.stringify(schedule)) - 1, + }), + /schedule is .* --days/, + ); + assert.equal((await read(dir, 'f.json')).builtAt, 'old'); + assert.equal((await read(dir, 'f.schedule.json')).pathsBuiltAt, 'old'); +}); + +test('a collapse in trips keeps the previous packs unless --force', async (t) => { + const dir = await tempDir(t); + await seed(dir, { trips: 10, scheduleTrips: 100 }); + // The classifier kept nothing. + await assert.rejects( + writeFeedPacks(dir, 'f', { + pack: pathPack('new', 0), + schedule: schedulePack('new', 0), + }), + (error) => + /trips on operator shapes 0 against 10/.test(error.message) && + /schedule trips per day 0 against 25/.test(error.message) && + /--force/.test(error.message), + ); + // The calendar went missing: the shapes are fine, the timetable is not. + await assert.rejects( + writeFeedPacks(dir, 'f', { + pack: pathPack('new', 10), + schedule: schedulePack('new', 4), + }), + /schedule trips per day 1 against 25/, + ); + assert.equal((await read(dir, 'f.json')).builtAt, 'old'); + assert.equal((await read(dir, 'f.schedule.json')).pathsBuiltAt, 'old'); + // A shorter window is not a collapse: trips are compared per day. + await writeFeedPacks(dir, 'f', { + pack: pathPack('short', 10), + schedule: schedulePack('short', 30, 1), + }); + assert.equal((await read(dir, 'f.json')).builtAt, 'short'); + // --force writes what the floor refused. + await writeFeedPacks(dir, 'f', { + pack: pathPack('forced', 0), + schedule: schedulePack('forced', 0), + force: true, + }); + assert.equal((await read(dir, 'f.json')).builtAt, 'forced'); + // A first build has nothing to fall from. + const fresh = await tempDir(t); + await writeFeedPacks(fresh, 'f', { + pack: pathPack('first', 0), + schedule: schedulePack('first', 0), + }); + assert.equal((await read(fresh, 'f.json')).builtAt, 'first'); +}); + +test('the trip floor counts what the build makes alike each time', () => { + // Only the operator's shapes count: a shape of any other source does not. + + const pack = pathPack('x', 0, { shapes: 2 }); + pack.shapeSources = ['operator', 'straight']; + pack.trips = { a: 0, b: 1, c: 1 }; + assert.equal(operatorShapeTrips(pack), 1); + assert.equal(operatorShapeTrips(null), 0); + assert.equal(scheduleTripsPerDay(schedulePack('x', 14, 7)), 2); + assert.equal(MIN_TRIP_SHARE, 0.2); + assert.equal(tripFloorProblem('trips', 2, 10), null); + assert.match(tripFloorProblem('trips', 1, 10), /trips 1 against 10/); + assert.equal(tripFloorProblem('trips', 0, 0), null); +}); + +test('one build holds a folder’s lock; a second refuses, a dead one’s is taken over', async (t) => { + const dir = await tempDir(t); + const held = await acquireDirLock(dir, BUILD_LOCK, { what: 'test build' }); + const record = JSON.parse(await readFile(held.file, 'utf8')); + assert.equal(record.pid, process.pid); + await assert.rejects( + acquireDirLock(dir, BUILD_LOCK, { what: 'test build' }), + (error) => + /another test build holds/.test(error.message) && + error.message.includes(String(process.pid)), + ); + await held.release(); + assert.deepEqual(await readdir(dir), []); + + // A lock whose process is gone. + const lockFile = path.join(dir, BUILD_LOCK); + await writeFile( + lockFile, + JSON.stringify({ pid: 999_999, time: new Date().toISOString() }), + ); + const taken = await acquireDirLock(dir, BUILD_LOCK, { alive: () => false }); + assert.equal(JSON.parse(await readFile(lockFile, 'utf8')).pid, process.pid); + await taken.release(); + + // A live process's lock not refreshed for longer than staleMs. + await writeFile( + lockFile, + JSON.stringify({ pid: 4242, time: '2026-01-01T00:00:00.000Z' }), + ); + const old = await acquireDirLock(dir, BUILD_LOCK, { alive: () => true }); + await old.release(); + + // A live, fresh one is respected; release never removes another's lock. + await writeFile( + lockFile, + JSON.stringify({ pid: 4242, time: new Date().toISOString() }), + ); + await assert.rejects( + acquireDirLock(dir, BUILD_LOCK, { alive: () => true }), + /pid 4242/, + ); + await old.release(); + assert.equal(JSON.parse(await readFile(lockFile, 'utf8')).pid, 4242); +}); + +test('the command line names its feeds or says --all, and --help needs nothing else', () => { + assert.throws(() => parseBuildArgs([]), /--feed .*--all/); + assert.equal(parseBuildArgs(['--all']).all, true); + assert.deepEqual(parseBuildArgs(['--help']), { help: true }); + assert.deepEqual(parseBuildArgs(['--feed', 'x', '-h']), { help: true }); + assert.throws(() => parseBuildArgs(['--all', '--feed', 'x']), /not both/); + assert.throws( + () => parseBuildArgs(['--feed', 'x', '--bogus']), + /Unknown argument --bogus/, + ); + const named = parseBuildArgs(['--feed', 'a', '--feed', 'b', '--force']); + assert.deepEqual(named.feeds, ['a', 'b']); + assert.equal(named.force, true); + assert.equal(parseBuildArgs(['--feed', 'a']).force, false); + // Only registered feeds are built: there is no flag for any other. + assert.throws( + () => + parseBuildArgs([ + '--extra-feed', + 'test-extra=https://example.test/gtfs.zip', + ]), + /Unknown argument --extra-feed/, + ); +}); + +test('--out may not be src/ or public/ or inside them, but may sit beside them', () => { + const root = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..', + '..', + ); + for (const inside of [ + 'src', + 'public', + path.join('src', 'data', 'packs'), + // A name that only starts with two dots is still inside. + path.join('src', '..cache'), + path.join('public', '..x', 'y'), + ]) + assert.throws( + () => parseBuildArgs(['--feed', 'a', '--out', path.join(root, inside)]), + /--out must not be inside/, + inside, + ); + for (const outside of [ + path.join('.cache', 'rail-paths'), + 'src-packs', + path.join('src', '..', 'packs'), + ]) + assert.equal( + parseBuildArgs(['--feed', 'a', '--out', path.join(root, outside)]).out, + path.resolve(root, outside), + ); + assert.equal(isInside(root, root), true); + assert.equal(isInside(path.join(root, 'src'), root), false); +}); + +test('a build in which any feed fails exits with code 2', () => { + assert.equal(FEED_FAILED_EXIT_CODE, 2); + assert.equal(buildExitCode(0), 0); + // One failure among feeds that built is still a failed build. + assert.equal(buildExitCode(1), 2); + assert.equal(buildExitCode(12), 2); +}); diff --git a/src/tooling/railCatalogPacks.test.mjs b/src/tooling/railCatalogPacks.test.mjs new file mode 100644 index 000000000..ad364da27 --- /dev/null +++ b/src/tooling/railCatalogPacks.test.mjs @@ -0,0 +1,235 @@ +/** + * Schedule packs are opt-in build output: the catalog lists a live feed + * whose trains are named only through its pack (`routesFrom: 'schedule'`) + * once the pack is built (as `hasSchedulePack`, the pack version or the + * pack reader tells), every other feed always, and a failing probe never + * hides a feed. Without any of them no such feed is listed. + */ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, rm, writeFile, mkdir, utimes } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { + SCHEDULE_PACK_PROBE_TTL_MS, + createRailService, +} from '../sources/railService.js'; +import { + getRegisteredRailFeed, + publicRailCatalog, + railFeedNeedsSchedulePack, +} from '../data/railFeeds.js'; +import { + createFsSchedulePackProbe, + createFsSchedulePackVersion, +} from '../../server/providers/rail.js'; + +const request = (path) => ({ + url: `https://example.test${path}`, + method: 'GET', +}); + +async function catalogIds(service) { + const response = await service.handle(request('/api/rail/feeds')); + assert.equal(response.status, 200); + const { feeds } = await response.json(); + return new Set(feeds.map((feed) => feed.id)); +} + +test('which feeds need their schedule pack', () => { + // Live GTFS-RT whose vehicles carry no route. + for (const id of ['renfe-larga-distancia', 'ktmb']) + assert.equal(railFeedNeedsSchedulePack(getRegisteredRailFeed(id)), true); + // Live, with routes of its own. + assert.equal( + railFeedNeedsSchedulePack(getRegisteredRailFeed('mbta-rail')), + false, + ); + // Irish Rail's train codes classify its trains without a pack. + assert.equal( + railFeedNeedsSchedulePack(getRegisteredRailFeed('irish-rail')), + false, + ); +}); + +test('pack-routed feeds wait for their pack; other feeds stay', async (t) => { + const service = createRailService({ + fetchImpl: async () => { + throw new Error('no network in tests'); + }, + hasSchedulePack: async (feedId) => feedId === 'ktmb', + }); + t.after(() => service.close()); + const ids = await catalogIds(service); + assert.ok(ids.has('ktmb'), 'a built pack-routed feed is listed'); + assert.ok(!ids.has('renfe-larga-distancia'), 'an unbuilt one waits'); + assert.ok(ids.has('mbta-rail'), 'live feed is listed'); + assert.ok(ids.has('irish-rail'), 'Irish Rail is listed'); +}); + +test('with no way to tell, no feed that needs a pack is listed', async (t) => { + let calls = 0; + const service = createRailService({ + fetchImpl: async () => { + calls += 1; + throw new Error('no network in tests'); + }, + }); + t.after(() => service.close()); + const ids = await catalogIds(service); + assert.ok(!ids.has('ktmb'), 'a pack-routed feed waits for its pack'); + assert.ok(!ids.has('renfe-larga-distancia'), 'all of them'); + assert.ok(ids.has('mbta-rail'), 'live feed is listed'); + const packless = publicRailCatalog().filter( + (feed) => !railFeedNeedsSchedulePack(getRegisteredRailFeed(feed.id)), + ); + assert.equal(ids.size, packless.length); + // Nor are its trains or trip updates served, and the operator is not + // contacted for them. + for (const path of [ + '/api/rail/trains/renfe-larga-distancia', + '/api/rail/trains/ktmb', + '/api/rail/updates/renfe-larga-distancia', + ]) { + const response = await service.handle(request(path)); + if (response.status === 404) continue; // no trip updates route + assert.equal(response.status, 503, path); + assert.equal(response.headers.get('cache-control'), 'no-store'); + assert.equal(response.headers.get('X-Rail-Backoff'), 'schedule'); + assert.ok(Number(response.headers.get('Retry-After')) > 0); + assert.equal((await response.json()).error, 'Rail schedule unavailable'); + } + assert.equal(calls, 0); +}); + +test('a pack reader alone tells what is built, its answers kept for the TTL', async (t) => { + let now = 1_000_000; + const reads = []; + let built = false; + const service = createRailService({ + fetchImpl: async () => { + throw new Error('no network in tests'); + }, + now: () => now, + readSchedulePack: async (feedId) => { + reads.push(feedId); + if (feedId === 'ktmb') return '{}'; + if (feedId === 'renfe-larga-distancia' && built) { + const error = new Error('pack is 30000000 bytes'); + error.code = 'PACK_TOO_LARGE'; + throw error; + } + return null; + }, + }); + t.after(() => service.close()); + let ids = await catalogIds(service); + assert.ok(ids.has('ktmb'), 'a pack the reader returns is built'); + assert.ok( + !ids.has('renfe-larga-distancia'), + 'a reader answering null: not built', + ); + assert.ok(ids.has('mbta-rail')); + const firstReads = reads.length; + built = true; + ids = await catalogIds(service); + assert.ok(!ids.has('renfe-larga-distancia'), 'kept for the TTL'); + assert.equal(reads.length, firstReads); + now += SCHEDULE_PACK_PROBE_TTL_MS; + ids = await catalogIds(service); + assert.ok( + ids.has('renfe-larga-distancia'), + 'an oversized pack is built, and refused', + ); +}); + +test('a pack version alone tells what is built, without reading packs', async (t) => { + let reads = 0; + const service = createRailService({ + fetchImpl: async () => { + throw new Error('no network in tests'); + }, + readSchedulePack: async () => { + reads += 1; + return '{}'; + }, + schedulePackVersion: async (feedId) => (feedId === 'ktmb' ? 'v1' : null), + }); + t.after(() => service.close()); + const ids = await catalogIds(service); + assert.ok(ids.has('ktmb')); + assert.ok(!ids.has('renfe-larga-distancia')); + assert.equal(reads, 0); +}); + +test('a failing probe lists the feed; answers are kept for the TTL', async (t) => { + const warn = console.warn; + console.warn = () => {}; + t.after(() => { + console.warn = warn; + }); + let now = 1_000_000; + let calls = 0; + let built = false; + const service = createRailService({ + fetchImpl: async () => { + throw new Error('no network in tests'); + }, + now: () => now, + hasSchedulePack: async (feedId) => { + if (feedId === 'renfe-larga-distancia') { + calls += 1; + return built; + } + if (feedId === 'ktmb') throw new Error('EACCES'); + return false; + }, + }); + t.after(() => service.close()); + let ids = await catalogIds(service); + assert.ok(ids.has('ktmb'), 'probe failure shows the feed'); + assert.ok(!ids.has('renfe-larga-distancia')); + // Built meanwhile: still hidden within the TTL, listed after it. + built = true; + ids = await catalogIds(service); + assert.ok(!ids.has('renfe-larga-distancia')); + assert.equal(calls, 1); + now += SCHEDULE_PACK_PROBE_TTL_MS; + ids = await catalogIds(service); + assert.ok(ids.has('renfe-larga-distancia')); + assert.equal(calls, 2); +}); + +test('the fs probe sees only a schedule pack file', async (t) => { + const dir = await mkdtemp(path.join(os.tmpdir(), 'rail-probe-')); + t.after(() => rm(dir, { recursive: true, force: true })); + await writeFile(path.join(dir, 'ktmb.schedule.json'), '{}'); + await writeFile(path.join(dir, 'mbta-rail.json'), '{}'); + await mkdir(path.join(dir, 'mta-lirr.schedule.json')); + const probe = createFsSchedulePackProbe(dir); + assert.equal(await probe('ktmb'), true); + assert.equal( + await probe('mbta-rail'), + false, + 'a path pack alone is not built', + ); + assert.equal(await probe('mta-lirr'), false, 'a directory is not a pack'); + assert.equal(await probe('../ktmb'), false, 'ids never leave the dir'); +}); + +test('the fs pack version changes when the pack is rebuilt', async (t) => { + const dir = await mkdtemp(path.join(os.tmpdir(), 'rail-version-')); + t.after(() => rm(dir, { recursive: true, force: true })); + const version = createFsSchedulePackVersion(dir); + assert.equal(await version('ktmb'), null, 'no pack, no version'); + const file = path.join(dir, 'ktmb.schedule.json'); + await writeFile(file, '{}'); + await utimes(file, 1_000, 1_000); + const first = await version('ktmb'); + assert.equal(typeof first, 'string'); + assert.equal(await version('ktmb'), first, 'stable while unchanged'); + await writeFile(file, '{"trips":{}}'); + await utimes(file, 2_000, 2_000); + assert.notEqual(await version('ktmb'), first); + assert.equal(await version('../ktmb'), null, 'ids never leave the dir'); +}); diff --git a/src/tooling/railService.test.mjs b/src/tooling/railService.test.mjs new file mode 100644 index 000000000..19290a67a --- /dev/null +++ b/src/tooling/railService.test.mjs @@ -0,0 +1,877 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { PbfWriter } from 'pbf'; +import { mkdtemp, rm, utimes, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { Writable } from 'node:stream'; +import { createRailService } from '../sources/railService.js'; +import { railEnabledFeeds } from '../data/railFeeds.js'; + +import { + RAIL_PATH_PACK_MAX_BYTES, + railAdmissionGlobalMax, + railNaturalRequestsPerMinute, +} from '../data/railProxy.js'; +import { + createFsPathPackReader, + createFsSchedulePackReader, + createRailPackCache, + sendRailResponse, +} from '../../server/providers/rail.js'; + +const request = (path, method = 'GET') => ({ + url: `https://example.test${path}`, + method, +}); + +/** A full-dataset FeedMessage with one commuter train and one bus. */ +function mbtaBytes() { + const writer = new PbfWriter(); + const header = new PbfWriter(); + header.writeStringField(1, '2.0'); + header.writeVarintField(3, Math.floor(Date.now() / 1000)); + writer.writeBytesField(1, header.finish()); + for (const [id, route] of [ + ['cr', 'CR-Providence'], + ['bus', '1'], + ]) { + const entity = new PbfWriter(); + entity.writeStringField(1, id); + const vehicle = new PbfWriter(); + const trip = new PbfWriter(); + trip.writeStringField(5, route); + vehicle.writeBytesField(1, trip.finish()); + const position = new PbfWriter(); + position.writeFloatField(1, 42.3); + position.writeFloatField(2, -71.1); + vehicle.writeBytesField(2, position.finish()); + const descriptor = new PbfWriter(); + descriptor.writeStringField(1, id); + vehicle.writeBytesField(8, descriptor.finish()); + entity.writeBytesField(4, vehicle.finish()); + writer.writeBytesField(2, entity.finish()); + } + return writer.finish(); +} + +test('the service confines requests to registered feeds and GET', async (t) => { + let calls = 0; + const service = createRailService({ + fetchImpl: async () => { + calls++; + throw new Error('unexpected'); + }, + }); + t.after(service.close); + for (const path of [ + '/other', + '/api/rail/trains/unknown', + '/api/rail/trains/vre', + '/api/rail/trains/https%3A%2F%2Fexample.test', + '/api/rail/vehicles/mbta-rail', + ]) { + assert.equal((await service.handle(request(path))).status, 404, path); + } + for (const method of ['POST', 'HEAD']) { + const refused = await service.handle(request('/api/rail/feeds', method)); + assert.equal(refused.status, 405); + // A refusal is never kept by a shared cache. + assert.equal(refused.headers.get('cache-control'), 'no-store'); + assert.equal(refused.headers.get('allow'), 'GET'); + } + const response = await service.handle(request('/api/rail/feeds')); + assert.equal(response.status, 200); + const { feeds } = await response.json(); + assert.ok(feeds.some((feed) => feed.id === 'mbta-rail')); + assert.ok(feeds.every((feed) => feed.url === undefined)); + assert.equal(calls, 0); + service.close(); + assert.equal((await service.handle(request('/api/rail/feeds'))).status, 503); +}); + +test('a snapshot carries trains only and is served from cache while fresh', async (t) => { + const urls = []; + const service = createRailService({ + fetchImpl: async (url, init) => { + urls.push(url); + assert.equal(init.redirect, 'manual'); + assert.match(init.headers['User-Agent'], /rail-proxy/); + return new Response(mbtaBytes(), { status: 200 }); + }, + }); + t.after(service.close); + const first = await service.handle(request('/api/rail/trains/mbta-rail')); + assert.equal(first.status, 200); + assert.equal(first.headers.get('X-GEV-Cache'), 'MISS'); + const body = await first.json(); + assert.equal(body.count, 1); + assert.equal(body.dropped, 1); + assert.equal(body.trains[0].category, 'local'); + const second = await service.handle(request('/api/rail/trains/mbta-rail')); + assert.equal(second.headers.get('X-GEV-Cache'), 'HIT'); + assert.deepEqual(urls, ['https://cdn.mbta.com/realtime/VehiclePositions.pb']); +}); + +test('a redirect off the feed origin is refused before it is followed', async (t) => { + const urls = []; + const service = createRailService({ + fetchImpl: async (url) => { + urls.push(url); + return new Response(null, { + status: 307, + headers: { location: 'https://elsewhere.test/feed.pb' }, + }); + }, + }); + t.after(service.close); + const response = await service.handle(request('/api/rail/trains/mbta-rail')); + assert.equal(response.status, 504); + assert.equal((await response.json()).feedId, 'mbta-rail'); + assert.ok(Number(response.headers.get('Retry-After')) >= 1); + assert.deepEqual(urls, ['https://cdn.mbta.com/realtime/VehiclePositions.pb']); + // The cooldown answers the next request without contacting the operator. + const again = await service.handle(request('/api/rail/trains/mbta-rail')); + assert.equal(again.status, 503); + assert.equal(again.headers.get('X-Rail-Backoff'), 'cooldown'); + assert.equal(urls.length, 1); +}); + +test('one document for positions and trip updates is fetched once per window', async (t) => { + let now = 1_800_000_000_000; + const urls = []; + const service = createRailService({ + now: () => now, + fetchImpl: async (url) => { + urls.push(url); + return new Response(mbtaBytes(), { status: 200 }); + }, + }); + t.after(service.close); + const trains = '/api/rail/trains/mta-lirr'; + const updates = '/api/rail/updates/mta-lirr'; + // Both at once share one request; each route keeps its own snapshot. + const [a, b] = await Promise.all([ + service.handle(request(trains)), + service.handle(request(updates)), + ]); + assert.equal(a.status, 200); + assert.equal(b.status, 200); + assert.equal(urls.length, 1); + // Positions come round again (15 s); trip updates (30 s) read that body. + now += 16_000; + await service.handle(request(trains)); + assert.equal(urls.length, 2); + now += 15_000; + assert.equal((await service.handle(request(updates))).status, 200); + assert.equal(urls.length, 2, 'the body positions fetched 15 s ago'); + // With nobody reading positions, trip updates fetch on their own window. + now += 31_000; + await service.handle(request(updates)); + assert.equal(urls.length, 3); + assert.equal(new Set(urls).size, 1, 'the same URL each time'); +}); + +test('an upstream HTTP error answers 502 with a retry hint', async (t) => { + const service = createRailService({ + fetchImpl: async () => new Response('nope', { status: 500 }), + }); + t.after(service.close); + const response = await service.handle(request('/api/rail/trains/mta-lirr')); + assert.equal(response.status, 502); + const body = await response.json(); + assert.equal(body.error, 'Rail feed unavailable'); + assert.ok(body.retryInSec >= 1); +}); + +test('path packs are served from the injected reader for enabled feeds', async (t) => { + const asked = []; + const pack = JSON.stringify({ version: 1, feedId: 'mta-lirr' }); + const service = createRailService({ + fetchImpl: async () => { + throw new Error('a path request never reaches an operator'); + }, + readPathPack: async (feedId, options) => { + asked.push([feedId, options.maxBytes]); + return feedId === 'mta-lirr' ? pack : null; + }, + }); + t.after(service.close); + const response = await service.handle(request('/api/rail/paths/mta-lirr')); + assert.equal(response.status, 200); + assert.equal(response.headers.get('cache-control'), 'no-cache'); + assert.match(response.headers.get('content-type'), /application\/json/); + assert.equal(await response.text(), pack); + assert.deepEqual(asked, [['mta-lirr', RAIL_PATH_PACK_MAX_BYTES]]); + const none = await service.handle(request('/api/rail/paths/mbta-rail')); + assert.equal(none.status, 404); + assert.equal(none.headers.get('cache-control'), 'no-store'); + for (const blocked of [ + '/api/rail/paths/vre', + '/api/rail/paths/unknown', + '/api/rail/paths/..%2Fsecret', + ]) + assert.equal((await service.handle(request(blocked))).status, 404, blocked); + assert.equal(asked.length, 2, 'unregistered ids never reach the reader'); + assert.equal( + (await service.handle(request('/api/rail/paths/mta-lirr', 'POST'))).status, + 405, + ); +}); + +test('a missing reader or pack is a 404; an unreadable or oversized pack is a 503', async (t) => { + const bare = createRailService(); + t.after(bare.close); + assert.equal( + (await bare.handle(request('/api/rail/paths/mta-lirr'))).status, + 404, + ); + const warn = console.warn; + console.warn = () => {}; + t.after(() => { + console.warn = warn; + }); + const failing = createRailService({ + readPathPack: async () => { + throw new Error('disk on fire'); + }, + }); + t.after(failing.close); + // A reader that fails (EMFILE, EACCES, EIO) is not "no pack": asked again. + for (const [kind, error] of [ + ['paths', 'Rail paths unreadable'], + ['schedule', 'Rail schedule unreadable'], + ]) { + const unreadable = createRailService({ + [kind === 'paths' ? 'readPathPack' : 'readSchedulePack']: async () => { + const fault = new Error('too many open files'); + fault.code = 'EMFILE'; + throw fault; + }, + }); + t.after(unreadable.close); + const response = await unreadable.handle( + request(`/api/rail/${kind}/mta-lirr`), + ); + assert.equal(response.status, 503, kind); + assert.equal(response.headers.get('cache-control'), 'no-store'); + assert.ok(Number(response.headers.get('Retry-After')) > 0); + assert.equal((await response.json()).error, error); + } + assert.equal( + (await failing.handle(request('/api/rail/paths/mta-lirr'))).status, + 503, + ); + const none = createRailService({ readPathPack: async () => null }); + t.after(none.close); + assert.equal( + (await none.handle(request('/api/rail/paths/mta-lirr'))).status, + 404, + ); + // Over the cap is a build fault, not an unbuilt pack: its own answer, + // whether the service measures it or the reader refuses it unread. + for (const readPathPack of [ + async () => new Uint8Array(RAIL_PATH_PACK_MAX_BYTES + 1), + async () => { + const error = new Error('pack is 30000000 bytes'); + error.code = 'PACK_TOO_LARGE'; + throw error; + }, + ]) { + const huge = createRailService({ readPathPack }); + t.after(huge.close); + const response = await huge.handle(request('/api/rail/paths/mta-lirr')); + assert.equal(response.status, 503); + assert.equal(response.headers.get('cache-control'), 'no-store'); + assert.equal((await response.json()).error, 'Rail paths too large'); + } + // At the cap is served, as bytes. + const full = createRailService({ + readPathPack: async () => new Uint8Array(RAIL_PATH_PACK_MAX_BYTES), + }); + t.after(full.close); + const served = await full.handle(request('/api/rail/paths/mta-lirr')); + assert.equal(served.status, 200); + assert.equal( + (await served.arrayBuffer()).byteLength, + RAIL_PATH_PACK_MAX_BYTES, + ); +}); + +test('the fs reader reads .json from its directory and nothing else', async (t) => { + const dir = await mkdtemp(path.join(os.tmpdir(), 'gev-rail-paths-test-')); + t.after(() => rm(dir, { recursive: true, force: true })); + await writeFile(path.join(dir, 'mta-lirr.json'), '{"version":1}'); + await writeFile(path.join(dir, 'grt-ion.json'), 'x'.repeat(64)); + const read = createFsPathPackReader(dir); + const pack = await read('mta-lirr'); + assert.ok(pack instanceof Uint8Array, 'bytes, never decoded to text'); + assert.equal(Buffer.from(pack).toString('utf8'), '{"version":1}'); + assert.equal(await read('mbta-rail'), null, 'no file'); + assert.equal(await read('../mta-lirr'), null, 'not an id'); + assert.equal(await read('MTA-LIRR'), null); + await assert.rejects(read('grt-ion', { maxBytes: 10 }), (error) => { + assert.match(error.message, /64 bytes/); + assert.equal(error.code, 'PACK_TOO_LARGE'); + return true; + }); + assert.equal((await read('grt-ion', { maxBytes: 64 })).length, 64); +}); + +test('the fs pack cache reads an unchanged pack once and a rebuilt one again', async (t) => { + const dir = await mkdtemp(path.join(os.tmpdir(), 'gev-rail-cache-test-')); + t.after(() => rm(dir, { recursive: true, force: true })); + const file = path.join(dir, 'mta-lirr.json'); + await writeFile(file, '{"version":1}'); + const cache = createRailPackCache(40); + const read = createFsPathPackReader(dir, { cache }); + const [first, again] = await Promise.all([ + read('mta-lirr'), + read('mta-lirr'), + ]); + assert.equal(first, again, 'one read shared by two at once'); + assert.equal(await read('mta-lirr'), first, 'kept while unchanged'); + assert.equal(cache.bytes(), 13); + // A rebuild changes the size or the time: read again, the old copy gone. + await writeFile(file, '{"version":22}'); + await utimes(file, new Date(), new Date(Date.now() + 5_000)); + const rebuilt = await read('mta-lirr'); + assert.notEqual(rebuilt, first); + assert.equal(Buffer.from(rebuilt).toString('utf8'), '{"version":22}'); + assert.equal(cache.bytes(), 14); + // The byte budget holds: the least recently read pack makes room. + await writeFile(path.join(dir, 'mbta-rail.json'), 'x'.repeat(30)); + await read('mbta-rail'); + assert.equal(cache.bytes(), 30); + // A pack over the whole byte budget is served but never kept. + await writeFile(path.join(dir, 'grt-ion.json'), 'y'.repeat(41)); + assert.equal((await read('grt-ion')).length, 41); + assert.equal(cache.bytes(), 30); +}); + +test('a served pack is streamed to the Node response as bytes, unchanged', async () => { + const bytes = new TextEncoder().encode('{"paths":"é"}'); + const service = createRailService({ readPathPack: async () => bytes }); + try { + const response = await service.handle(request('/api/rail/paths/mta-lirr')); + const chunks = []; + const head = {}; + const res = new Writable({ + write(chunk, encoding, done) { + chunks.push(chunk); + done(); + }, + }); + res.writeHead = (status, headers) => + Object.assign(head, { status, headers }); + await sendRailResponse(response, res); + assert.equal(head.status, 200); + assert.equal(head.headers['cache-control'], 'no-cache'); + const out = Buffer.concat(chunks); + assert.deepEqual(new Uint8Array(out), bytes); + // The reader's own buffer goes out: no decode, no copy. + assert.equal(chunks.length, 1); + assert.equal(chunks[0].buffer, bytes.buffer); + } finally { + service.close(); + } +}); + +/** A TripUpdates FeedMessage with one commuter-rail trip and one bus trip. */ +function tripUpdateBytes() { + const writer = new PbfWriter(); + const header = new PbfWriter(); + header.writeStringField(1, '2.0'); + header.writeVarintField(3, Math.floor(Date.now() / 1000)); + writer.writeBytesField(1, header.finish()); + for (const [id, route] of [ + ['cr-trip', 'CR-Providence'], + ['bus-trip', '1'], + ]) { + const entity = new PbfWriter(); + entity.writeStringField(1, id); + const update = new PbfWriter(); + const trip = new PbfWriter(); + trip.writeStringField(1, id); + trip.writeStringField(5, route); + update.writeBytesField(1, trip.finish()); + const stop = new PbfWriter(); + stop.writeStringField(4, 'place-sstat'); + const arrival = new PbfWriter(); + arrival.writeVarintField(1, 240); + stop.writeBytesField(2, arrival.finish()); + update.writeBytesField(2, stop.finish()); + entity.writeBytesField(3, update.finish()); + writer.writeBytesField(2, entity.finish()); + } + return writer.finish(); +} + +test('trip updates come from their own URL, cache and filter', async (t) => { + const urls = []; + const service = createRailService({ + fetchImpl: async (url) => { + urls.push(url); + return new Response( + url.endsWith('TripUpdates.pb') ? tripUpdateBytes() : mbtaBytes(), + { status: 200 }, + ); + }, + }); + t.after(service.close); + const updates = await service.handle(request('/api/rail/updates/mbta-rail')); + assert.equal(updates.status, 200); + assert.equal(updates.headers.get('X-GEV-Cache'), 'MISS'); + assert.equal(updates.headers.get('cache-control'), 'public, max-age=30'); + const body = await updates.json(); + assert.equal(body.count, 1); + assert.equal(body.dropped, 1, 'the bus trip never leaves the server'); + assert.deepEqual( + { + tripId: body.trips[0].tripId, + routeId: body.trips[0].routeId, + delaySec: body.trips[0].delaySec, + stop: body.trips[0].stops[0], + }, + { + tripId: 'cr-trip', + routeId: 'CR-Providence', + delaySec: 240, + stop: { + stopId: 'place-sstat', + stopSequence: null, + arrival: { time: null, delay: 240 }, + departure: null, + }, + }, + ); + // Positions are a separate cache entry: this is a MISS, not the updates. + const trains = await service.handle(request('/api/rail/trains/mbta-rail')); + assert.equal(trains.headers.get('X-GEV-Cache'), 'MISS'); + assert.equal((await trains.json()).trains[0].id, 'cr'); + const again = await service.handle(request('/api/rail/updates/mbta-rail')); + assert.equal(again.headers.get('X-GEV-Cache'), 'HIT'); + assert.deepEqual(urls, [ + 'https://cdn.mbta.com/realtime/TripUpdates.pb', + 'https://cdn.mbta.com/realtime/VehiclePositions.pb', + ]); + assert.equal( + (await service.handle(request('/api/rail/updates/capmetro-rail'))).status, + 404, + 'a feed without trip updates has no /updates route', + ); +}); + +test('every train answer comes from the operator, never from a timetable', async (t) => { + // A feed once answered `/trains` with an empty list for the timetable to + // fill; every feed now reports its trains, so every answer is fetched. + const warn = console.warn; + console.warn = () => {}; + t.after(() => { + console.warn = warn; + }); + const asked = []; + const service = createRailService({ + fetchImpl: async (url) => { + asked.push(url); + return new Response('', { status: 503 }); + }, + hasSchedulePack: async () => true, + }); + t.after(service.close); + const feeds = railEnabledFeeds(); + for (const feed of feeds) { + const response = await service.handle( + request(`/api/rail/trains/${feed.id}`), + ); + assert.equal(response.status, 502, feed.id); + assert.equal(response.headers.get('X-GEV-Cache'), 'NONE', feed.id); + } + // Feeds that share one positions document are asked once per window. + assert.equal( + new Set(asked).size, + new Set(feeds.map((feed) => feed.url)).size, + ); +}); + +test('Irish Rail is fetched as XML and served as event-positioned trains', async (t) => { + const xml = ` + + + R + 53.35 + -6.25 + A216 + 06 Nov 2026 + A216\\n14:00 - Dublin Heuston to Cork (9 mins late)\\nDeparted Thurles next stop Limerick Junction + To Cork + +`; + const seen = []; + const service = createRailService({ + fetchImpl: async (url, init) => { + seen.push([url, init.headers.Accept]); + return new Response(xml, { + status: 200, + headers: { 'content-type': 'text/xml; charset=utf-8' }, + }); + }, + }); + t.after(service.close); + const response = await service.handle(request('/api/rail/trains/irish-rail')); + assert.equal(response.status, 200); + assert.equal(response.headers.get('cache-control'), 'public, max-age=30'); + const body = await response.json(); + assert.equal(body.positions, 'event'); + assert.equal(body.trains[0].id, 'A216'); + assert.equal(body.trains[0].category, 'national'); + assert.equal(body.trains[0].positionSource, 'event'); + // The message's next stop decides the status; it does not leave the server. + assert.equal(body.trains[0].status, 'IN_TRANSIT_TO'); + assert.equal(body.trains[0].nextStop, undefined); + // No schedule pack here: the train code alone classifies the train. + assert.equal(body.count, 1); + assert.deepEqual(seen, [ + [ + 'https://api.irishrail.ie/realtime/realtime.asmx/getCurrentTrainsXML', + 'text/xml, application/xml;q=0.9', + ], + ]); +}); + +test('an unreadable Irish Rail document is a 502 with backoff, not a crash', async (t) => { + const warn = console.warn; + console.warn = () => {}; + t.after(() => { + console.warn = warn; + }); + const service = createRailService({ + fetchImpl: async () => new Response('busy', { status: 200 }), + }); + t.after(service.close); + const response = await service.handle(request('/api/rail/trains/irish-rail')); + assert.equal(response.status, 502); + const again = await service.handle(request('/api/rail/trains/irish-rail')); + assert.equal(again.headers.get('X-Rail-Backoff'), 'cooldown'); +}); + +test('an answer names its upstream host, and a failed fetch is a logged 504', async (t) => { + const warnings = []; + const warn = console.warn; + console.warn = (...args) => warnings.push(args.join(' ')); + t.after(() => { + console.warn = warn; + }); + const service = createRailService({ + now: () => 1_800_000_000_000, + fetchImpl: async () => new Response(mbtaBytes(), { status: 200 }), + }); + t.after(service.close); + const response = await service.handle(request('/api/rail/trains/mbta-rail')); + assert.equal(response.status, 200); + assert.equal(response.headers.get('X-Rail-Upstream'), 'cdn.mbta.com'); + assert.equal(response.headers.get('X-Rail-Contact'), '1800000000000'); + assert.equal(response.headers.get('cache-control'), 'public, max-age=15'); + const failing = createRailService({ + fetchImpl: async (url) => { + throw new TypeError(`fetch failed for ${url}`); + }, + }); + t.after(failing.close); + const failed = await failing.handle(request('/api/rail/trains/mbta-rail')); + assert.equal(failed.status, 504); + assert.equal((await failed.json()).error, 'Rail feed unavailable'); + assert.ok( + warnings.some((line) => line.includes('trains:mbta-rail unavailable')), + ); +}); + +test('a redirect on the feed origin is followed, one hop at a time', async (t) => { + const urls = []; + const service = createRailService({ + fetchImpl: async (url, init) => { + urls.push(url); + assert.equal(init.redirect, 'manual'); + if (urls.length === 1) + return new Response(null, { + status: 302, + headers: { location: '/realtime/VehiclePositions.pb?v=2' }, + }); + return new Response(mbtaBytes(), { status: 200 }); + }, + }); + t.after(service.close); + const response = await service.handle(request('/api/rail/trains/mbta-rail')); + assert.equal(response.status, 200); + assert.deepEqual(urls, [ + 'https://cdn.mbta.com/realtime/VehiclePositions.pb', + 'https://cdn.mbta.com/realtime/VehiclePositions.pb?v=2', + ]); + assert.equal(response.headers.get('X-Rail-Upstream'), 'cdn.mbta.com'); +}); + +test('schedule packs are served from the injected reader', async (t) => { + const asked = []; + const pack = JSON.stringify({ version: 1, feedId: 'ktmb' }); + const service = createRailService({ + fetchImpl: async () => { + throw new Error('a schedule request never reaches an operator'); + }, + readSchedulePack: async (feedId, options) => { + asked.push([feedId, options.maxBytes]); + return feedId === 'mbta-rail' ? null : pack; + }, + }); + t.after(service.close); + for (const id of ['ktmb', 'irish-rail']) { + const response = await service.handle(request(`/api/rail/schedule/${id}`)); + assert.equal(response.status, 200, id); + assert.equal(response.headers.get('cache-control'), 'no-cache'); + assert.equal(await response.text(), pack); + } + const none = await service.handle(request('/api/rail/schedule/mbta-rail')); + assert.equal(none.status, 404); + assert.equal((await none.json()).error, 'No rail schedule'); + for (const blocked of [ + '/api/rail/schedule/vre', + '/api/rail/schedule/unknown', + '/api/rail/schedule/..%2Fktmb', + ]) + assert.equal((await service.handle(request(blocked))).status, 404, blocked); + assert.deepEqual(asked, [ + ['ktmb', RAIL_PATH_PACK_MAX_BYTES], + ['irish-rail', RAIL_PATH_PACK_MAX_BYTES], + ['mbta-rail', RAIL_PATH_PACK_MAX_BYTES], + ]); + // No reader: 404; an oversized pack: 503, as for path packs. + const bare = createRailService(); + t.after(bare.close); + assert.equal( + (await bare.handle(request('/api/rail/schedule/ktmb'))).status, + 404, + ); + const warn = console.warn; + console.warn = () => {}; + t.after(() => { + console.warn = warn; + }); + const huge = createRailService({ + readSchedulePack: async () => new Uint8Array(RAIL_PATH_PACK_MAX_BYTES + 1), + }); + t.after(huge.close); + const refused = await huge.handle(request('/api/rail/schedule/ktmb')); + assert.equal(refused.status, 503); + assert.equal((await refused.json()).error, 'Rail schedule too large'); + // The path reader is not asked for a schedule, nor the reverse. + const paths = createRailService({ readPathPack: async () => pack }); + t.after(paths.close); + assert.equal( + (await paths.handle(request('/api/rail/schedule/ktmb'))).status, + 404, + ); +}); + +test('the fs schedule reader reads .schedule.json beside the path packs', async (t) => { + const dir = await mkdtemp(path.join(os.tmpdir(), 'gev-rail-schedule-test-')); + t.after(() => rm(dir, { recursive: true, force: true })); + await writeFile(path.join(dir, 'ktmb.schedule.json'), '{"version":1}'); + await writeFile(path.join(dir, 'ktmb.json'), '{"paths":true}'); + const read = createFsSchedulePackReader(dir); + const text = async (pending) => Buffer.from(await pending).toString('utf8'); + assert.equal(await text(read('ktmb')), '{"version":1}'); + assert.equal(await read('irish-rail'), null); + assert.equal(await read('../ktmb'), null); + await assert.rejects(read('ktmb', { maxBytes: 3 }), /13 bytes/); + assert.equal( + await text(createFsPathPackReader(dir)('ktmb')), + '{"paths":true}', + ); +}); + +test('a viewer of every region at once is not refused by the global cap', async (t) => { + const warn = console.warn; + console.warn = () => {}; + t.after(() => { + console.warn = warn; + }); + const service = createRailService({ + now: () => 1_800_000_000_000, + fetchImpl: async () => new Response(mbtaBytes(), { status: 200 }), + }); + t.after(service.close); + const { feeds } = await ( + await service.handle(request('/api/rail/feeds')) + ).json(); + const backoffs = []; + let requests = 0; + for (const feed of feeds) { + for (const route of feed.tripUpdates ? ['trains', 'updates'] : ['trains']) { + requests += 1; + const response = await service.handle( + request(`/api/rail/${route}/${feed.id}`), + ); + backoffs.push(response.headers.get('X-Rail-Backoff')); + } + } + assert.ok(requests > 40, `${requests} resources, more than the old cap`); + assert.ok(!backoffs.includes('rate-limited'), 'the global cap held back'); + assert.ok(railAdmissionGlobalMax() >= railNaturalRequestsPerMinute()); +}); + +/** Positions with a trip id but no route, as Renfe's long-distance feed sends. */ +function routelessBytes(trips) { + const writer = new PbfWriter(); + const header = new PbfWriter(); + header.writeStringField(1, '2.0'); + header.writeVarintField(3, Math.floor(Date.now() / 1000) - 3600); + writer.writeBytesField(1, header.finish()); + for (const tripId of trips) { + const entity = new PbfWriter(); + entity.writeStringField(1, tripId); + const vehicle = new PbfWriter(); + const trip = new PbfWriter(); + trip.writeStringField(1, tripId); + vehicle.writeBytesField(1, trip.finish()); + const position = new PbfWriter(); + position.writeFloatField(1, 40.4); + position.writeFloatField(2, -3.7); + vehicle.writeBytesField(2, position.finish()); + entity.writeBytesField(4, vehicle.finish()); + writer.writeBytesField(2, entity.finish()); + } + return writer.finish(); +} + +test('routes missing from a feed are read from its schedule pack, once an hour', async (t) => { + let now = 1_800_000_000_000; + const reads = []; + const service = createRailService({ + now: () => now, + fetchImpl: async () => + new Response(routelessBytes(['0419212026-11-06', '9999912026-11-06']), { + status: 200, + }), + readSchedulePack: async (feedId, options) => { + reads.push([feedId, options.maxBytes]); + return JSON.stringify({ + version: 1, + feedId: 'renfe-larga-distancia', + trips: { + '0419212026-11-06': { route: 'ALVIA~X~1700037606GL023' }, + other: { route: 'MD~S~1' }, + }, + }); + }, + hasSchedulePack: async () => true, + }); + t.after(service.close); + const path = '/api/rail/trains/renfe-larga-distancia'; + const body = await (await service.handle(request(path))).json(); + assert.equal(body.count, 1); + assert.equal(body.dropped, 1, 'a trip the pack does not know'); + assert.equal(body.trains[0].category, 'national'); + assert.equal(body.trains[0].timestampSource, 'fetch'); + now += 20_000; + await service.handle(request(path)); + assert.deepEqual(reads, [ + ['renfe-larga-distancia', RAIL_PATH_PACK_MAX_BYTES], + ]); + now += 61 * 60_000; + await service.handle(request(path)); + assert.equal(reads.length, 2, 're-read after an hour'); +}); + +test('a rebuilt schedule pack replaces the lookups within a minute', async (t) => { + let now = 1_800_000_000_000; + let version = 'built-1'; + let route = 'ALVIA~X~1'; + let reads = 0; + let versionAsks = 0; + const service = createRailService({ + now: () => now, + fetchImpl: async () => + new Response(routelessBytes(['0419212026-11-06']), { status: 200 }), + readSchedulePack: async () => { + reads += 1; + return JSON.stringify({ + version: 1, + feedId: 'renfe-larga-distancia', + trips: { '0419212026-11-06': { route } }, + }); + }, + hasSchedulePack: async () => true, + schedulePackVersion: async () => { + versionAsks += 1; + return version; + }, + }); + t.after(service.close); + const path = '/api/rail/trains/renfe-larga-distancia'; + const routeNow = async () => + (await (await service.handle(request(path))).json()).trains[0].routeId; + assert.equal(await routeNow(), 'ALVIA~X~1'); + assert.equal(reads, 1); + // Unchanged: kept, and the version is not asked on every poll. + now += 61_000; + assert.equal(await routeNow(), 'ALVIA~X~1'); + assert.equal(reads, 1); + const asks = versionAsks; + now += 20_000; + await routeNow(); + assert.equal(versionAsks, asks); + // Rebuilt: the next check after the minute reads the new pack. + version = 'built-2'; + route = 'AVE~X~2'; + now += 61_000; + await routeNow(); + now += 16_000; + assert.equal(await routeNow(), 'AVE~X~2'); + assert.equal(reads, 2, 'read once more, well inside the hour'); +}); + +test('without a schedule pack, a feed that needs it is unavailable and the pack retried later', async (t) => { + const warn = console.warn; + console.warn = () => {}; + t.after(() => { + console.warn = warn; + }); + let now = 1_800_000_000_000; + let reads = 0; + const service = createRailService({ + now: () => now, + fetchImpl: async () => + new Response(routelessBytes(['0419212026-11-06']), { status: 200 }), + readSchedulePack: async () => { + reads += 1; + return null; + }, + // Listed (the probe says built), but the pack is gone when read. + hasSchedulePack: async () => true, + }); + t.after(service.close); + const path = '/api/rail/trains/renfe-larga-distancia'; + // Every train needs the pack for its route: not a healthy empty feed. + const response = await service.handle(request(path)); + assert.equal(response.status, 503); + assert.equal(response.headers.get('X-Rail-Backoff'), 'schedule'); + assert.equal(response.headers.get('cache-control'), 'no-store'); + assert.equal((await response.json()).error, 'Rail schedule unavailable'); + now += 20_000; + await service.handle(request(path)); + assert.equal(reads, 1, 'not re-read on every poll'); + now += 6 * 60_000; + await service.handle(request(path)); + assert.equal(reads, 2, 'retried after five minutes'); + // A feed that carries its own routes never asks for the pack. + let asked = 0; + const own = createRailService({ + fetchImpl: async () => new Response(mbtaBytes(), { status: 200 }), + readSchedulePack: async () => { + asked += 1; + return null; + }, + }); + t.after(own.close); + await own.handle(request('/api/rail/trains/mbta-rail')); + assert.equal(asked, 0); +}); diff --git a/src/tooling/railServiceFaults.test.mjs b/src/tooling/railServiceFaults.test.mjs new file mode 100644 index 000000000..b5eb46a0b --- /dev/null +++ b/src/tooling/railServiceFaults.test.mjs @@ -0,0 +1,214 @@ +/** + * The rail service under upstream faults and missing build output: an empty + * or non-protobuf 200, a pack-routed feed without its pack, and a + * downstream cache near the end of a window. + + */ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { PbfWriter } from 'pbf'; +import { createRailService } from '../sources/railService.js'; +import { RAIL_FEED_REGISTRY, getRailFeed } from '../data/railFeeds.js'; + +const request = (path) => ({ + url: `https://example.test${path}`, + method: 'GET', +}); + +function quietWarnings(t) { + const warn = console.warn; + console.warn = () => {}; + t.after(() => { + console.warn = warn; + }); +} + +/** A full-dataset VehiclePositions message: one MBTA commuter train. */ +function positionsBytes() { + const writer = new PbfWriter(); + const header = new PbfWriter(); + header.writeStringField(1, '2.0'); + header.writeVarintField(3, 1_800_000_000); + writer.writeBytesField(1, header.finish()); + const entity = new PbfWriter(); + entity.writeStringField(1, 'cr'); + const vehicle = new PbfWriter(); + const trip = new PbfWriter(); + trip.writeStringField(5, 'CR-Providence'); + vehicle.writeBytesField(1, trip.finish()); + const position = new PbfWriter(); + position.writeFloatField(1, 42.3); + position.writeFloatField(2, -71.1); + vehicle.writeBytesField(2, position.finish()); + const descriptor = new PbfWriter(); + descriptor.writeStringField(1, 'cr'); + vehicle.writeBytesField(8, descriptor.finish()); + entity.writeBytesField(4, vehicle.finish()); + writer.writeBytesField(2, entity.finish()); + return writer.finish(); +} + +/** A full-dataset TripUpdates message with the given trip ids. */ +function tripUpdatesBytes(tripIds) { + const writer = new PbfWriter(); + const header = new PbfWriter(); + header.writeStringField(1, '2.0'); + header.writeVarintField(3, 1_800_000_000); + writer.writeBytesField(1, header.finish()); + for (const tripId of tripIds) { + const descriptor = new PbfWriter(); + descriptor.writeStringField(1, tripId); + descriptor.writeStringField(3, '20270115'); + const update = new PbfWriter(); + update.writeBytesField(1, descriptor.finish()); + update.writeVarintField(5, 60); + const entity = new PbfWriter(); + entity.writeStringField(1, tripId); + entity.writeBytesField(3, update.finish()); + writer.writeBytesField(2, entity.finish()); + } + return writer.finish(); +} + +test('an empty 200 or 204 never replaces the last good snapshot', async (t) => { + quietWarnings(t); + let now = 1_800_000_000_000; + let answer = () => new Response(positionsBytes(), { status: 200 }); + let calls = 0; + const service = createRailService({ + now: () => now, + fetchImpl: async () => { + calls += 1; + return answer(); + }, + }); + t.after(service.close); + const path = '/api/rail/trains/mbta-rail'; + const good = await service.handle(request(path)); + assert.equal(good.headers.get('X-GEV-Cache'), 'MISS'); + const goodBody = await good.text(); + for (const empty of [ + () => new Response(new Uint8Array(0), { status: 200 }), + () => new Response(null, { status: 204 }), + ]) { + answer = empty; + now += 16_000; + const stale = await service.handle(request(path)); + assert.equal(stale.status, 200); + assert.equal(stale.headers.get('X-GEV-Cache'), 'STALE-ERROR'); + assert.equal(await stale.text(), goodBody, 'the good snapshot, kept'); + // The failure is on the ladder: the next ask does not reach upstream. + const before = calls; + const held = await service.handle(request(path)); + assert.equal(held.headers.get('X-GEV-Cache'), 'STALE-ERROR'); + assert.equal(calls, before); + } + // A healthy answer takes over again. + answer = () => new Response(positionsBytes(), { status: 200 }); + now += 61_000; + const back = await service.handle(request(path)); + assert.equal(back.headers.get('X-GEV-Cache'), 'MISS'); + assert.equal((await back.json()).count, 1); +}); + +test('an error page served as 200 is an upstream fault (502), not a timeout', async (t) => { + quietWarnings(t); + for (const [route, body] of [ + ['trains', 'Service Unavailable'], + ['trains', '{"error":"quota exceeded"}'], + ['updates', 'maintenance'], + ['updates', ''], + ]) { + const service = createRailService({ + fetchImpl: async () => + new Response(body, { + status: 200, + headers: { 'content-type': 'text/html' }, + }), + }); + const response = await service.handle( + request(`/api/rail/${route}/mbta-rail`), + ); + service.close(); + assert.equal(response.status, 502, `${route}: ${body}`); + assert.equal((await response.json()).error, 'Rail feed unavailable'); + } +}); + +test('a pack-routed feed names its trip updates from the pack, and is unavailable without it', async (t) => { + quietWarnings(t); + // Every pack-routed feed with trip updates (Renfe long distance): its + // updates carry no route, so they are classified by the pack's trips. + const feeds = RAIL_FEED_REGISTRY.filter( + (feed) => + feed.routesFrom === 'schedule' && typeof feed.tripUpdatesUrl === 'string', + ); + assert.ok(feeds.length >= 1, 'Renfe long distance at least'); + for (const registered of feeds) { + const id = registered.id; + if (!getRailFeed(id)) continue; + const service = createRailService({ + fetchImpl: async () => + new Response(tripUpdatesBytes(['in-pack', 'elsewhere']), { + status: 200, + }), + readSchedulePack: async (feedId) => + feedId === id + ? JSON.stringify({ + version: 1, + feedId: id, + trips: { 'in-pack': { route: 'ALVIA~X~1700037606GL023' } }, + }) + : null, + hasSchedulePack: async () => true, + }); + try { + const response = await service.handle(request(`/api/rail/updates/${id}`)); + assert.equal(response.status, 200, id); + const body = await response.json(); + assert.deepEqual( + body.trips.map((trip) => [trip.tripId, trip.routeId]), + [['in-pack', 'ALVIA~X~1700037606GL023']], + id, + ); + assert.equal(body.dropped, 1, `${id}: the trip the pack does not know`); + assert.equal(body.entityCount, 2, id); + assert.equal(body.skipped, undefined, 'every trip is decoded'); + } finally { + service.close(); + } + // Listed, but the pack is gone when read: unavailable, not empty. + const bare = createRailService({ + fetchImpl: async () => + new Response(tripUpdatesBytes(['in-pack', 'elsewhere']), { + status: 200, + }), + readSchedulePack: async () => null, + hasSchedulePack: async () => true, + }); + try { + const response = await bare.handle(request(`/api/rail/updates/${id}`)); + assert.equal(response.status, 503, id); + assert.equal((await response.json()).error, 'Rail schedule unavailable'); + } finally { + bare.close(); + } + } +}); + +test('a HIT is cached downstream only for what is left of its window', async (t) => { + let now = 1_800_000_000_000; + const service = createRailService({ + now: () => now, + fetchImpl: async () => new Response(positionsBytes(), { status: 200 }), + }); + t.after(service.close); + const path = '/api/rail/trains/mbta-rail'; + const maxAge = async () => + (await service.handle(request(path))).headers.get('cache-control'); + assert.equal(await maxAge(), 'public, max-age=15'); + now += 10_000; + assert.equal(await maxAge(), 'public, max-age=5'); + now += 4_500; + assert.equal(await maxAge(), 'public, max-age=1'); +}); diff --git a/src/tooling/railServicePacks.test.mjs b/src/tooling/railServicePacks.test.mjs new file mode 100644 index 000000000..de8d964de --- /dev/null +++ b/src/tooling/railServicePacks.test.mjs @@ -0,0 +1,337 @@ +// The schedule pack a feed's live trains are read with, and how packs are +// served: a pack of another format or feed, or whose window has ended, is +// refused; a pack-routed feed whose every record is dropped answers 503, +// not an empty list; a train named by its number keeps its run while its +// report says it is late; packs go out no-cache, with their version as +// their ETag. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { Writable } from 'node:stream'; +import { PbfWriter } from 'pbf'; +import { createRailService } from '../sources/railService.js'; +import { railProxy } from '../../server/providers/rail.js'; + +/** 2027-01-15T08:00:00Z. */ +const NOW = 1_800_000_000_000; +const MIN = 60_000; +const LIVE_TRIP = '0419212026-11-06'; + +const request = (path, headers = undefined) => ({ + url: `https://example.test${path}`, + method: 'GET', + ...(headers ? { headers } : {}), +}); + +function quietWarnings(t) { + const warn = console.warn; + console.warn = () => {}; + t.after(() => { + console.warn = warn; + }); +} + +/** Positions with a trip id but no route, as Renfe's long-distance feed sends. */ +function routelessBytes(trips) { + const writer = new PbfWriter(); + const header = new PbfWriter(); + header.writeStringField(1, '2.0'); + header.writeVarintField(3, Math.floor(NOW / 1000)); + writer.writeBytesField(1, header.finish()); + for (const tripId of trips) { + const entity = new PbfWriter(); + entity.writeStringField(1, tripId); + const vehicle = new PbfWriter(); + const trip = new PbfWriter(); + trip.writeStringField(1, tripId); + vehicle.writeBytesField(1, trip.finish()); + const position = new PbfWriter(); + position.writeFloatField(1, 40.4); + position.writeFloatField(2, -3.7); + vehicle.writeBytesField(2, position.finish()); + entity.writeBytesField(4, vehicle.finish()); + writer.writeBytesField(2, entity.finish()); + } + return writer.finish(); +} + +/** + * A Renfe long-distance schedule pack whose trips run on 15 January 2027 + * (Madrid, UTC+1 then) from `startSec` local for `durationSec`. + */ +function renfePack({ + version = 1, + feedId = 'renfe-larga-distancia', + trips = [LIVE_TRIP], + startSec = 9 * 3600, + durationSec = 3600, +} = {}) { + return JSON.stringify({ + version, + feedId, + timezone: 'Europe/Madrid', + window: { from: '2027-01-14', to: '2027-01-15' }, + profiles: [[0, 0, durationSec, durationSec]], + services: { S: ['2027-01-15'] }, + trips: Object.fromEntries( + trips.map((tripId) => [ + tripId, + { + route: 'ALVIA~X~1700037606GL023', + pattern: 0, + profile: 0, + startSec, + service: 'S', + }, + ]), + ), + }); +} + +function renfeService(t, readSchedulePack, clock = () => NOW) { + const service = createRailService({ + now: clock, + fetchImpl: async () => + new Response(routelessBytes([LIVE_TRIP]), { status: 200 }), + readSchedulePack, + hasSchedulePack: async () => true, + }); + t.after(service.close); + return service; +} + +const TRAINS = '/api/rail/trains/renfe-larga-distancia'; + +async function assertScheduleUnavailable(response, label) { + assert.equal(response.status, 503, label); + assert.equal(response.headers.get('X-Rail-Backoff'), 'schedule', label); + assert.equal(response.headers.get('cache-control'), 'no-store', label); + assert.equal( + (await response.json()).error, + 'Rail schedule unavailable', + label, + ); +} + +test('a pack that names none of the live trips is unavailable, not an empty feed', async (t) => { + quietWarnings(t); + // A pack built for other days: Renfe's trip ids carry their date. + const service = renfeService(t, async () => + renfePack({ trips: ['0419212026-11-05'] }), + ); + await assertScheduleUnavailable(await service.handle(request(TRAINS))); +}); + +test('a pack of another format, another feed or a window that has ended is refused', async (t) => { + quietWarnings(t); + // The same live train, the same trip in each pack: only the pack differs. + const good = renfeService(t, async () => renfePack()); + const served = await (await good.handle(request(TRAINS))).json(); + assert.equal(served.count, 1); + assert.equal(served.trains[0].routeId, 'ALVIA~X~1700037606GL023'); + for (const [label, pack] of [ + ['format version 2', renfePack({ version: 2 })], + ['another feed', renfePack({ feedId: 'ktmb' })], + // 04:00 to 05:00 UTC: over three hours before now. + ['window ended', renfePack({ startSec: 5 * 3600 })], + ]) { + const service = renfeService(t, async () => pack); + await assertScheduleUnavailable( + await service.handle(request(TRAINS)), + label, + ); + } +}); + +test('a kept pack is refused once its window ends, and read again later', async (t) => { + quietWarnings(t); + let now = NOW; + let reads = 0; + // Its one run: 07:30 to 08:20 UTC. + const service = renfeService( + t, + async () => { + reads += 1; + return renfePack({ startSec: 8.5 * 3600, durationSec: 50 * 60 }); + }, + () => now, + ); + assert.equal((await service.handle(request(TRAINS))).status, 200); + now += 21 * MIN; + await assertScheduleUnavailable(await service.handle(request(TRAINS))); + assert.equal(reads, 1, 'refused as kept, not read again'); + now += 6 * MIN; + await service.handle(request(TRAINS)); + assert.equal(reads, 2, 'read again after five minutes'); +}); + +/** Irish Rail's running trains, one train with its stated delay. */ +function irishXml(minutesLate) { + return ` + + + R52.6 + -6.4A610 + 15 Jan 2027 + A610\\n07:00 - Dublin Connolly to Rosslare Europort (${minutesLate} mins late)\\nDeparted Enniscorthy next stop Wexford + Southbound + +`; +} + +/** A610's run ends 07:45 UTC (Dublin is on UTC in January); A700 runs later. */ +const IRISH_PACK = JSON.stringify({ + version: 1, + feedId: 'irish-rail', + timezone: 'Europe/Dublin', + window: { from: '2027-01-14', to: '2027-01-15' }, + profiles: [[0, 0, 2700, 2700]], + services: { S: ['2027-01-15'] }, + trips: { + '6024_8435': { + name: 'A610', + route: 'A~X~DUB-EUROPORT-I', + pattern: 0, + profile: 0, + startSec: 7 * 3600, + service: 'S', + }, + '6024_9000': { + name: 'A700', + route: 'A~X~DUB-EUROPORT-I', + pattern: 0, + profile: 0, + startSec: 10 * 3600, + service: 'S', + }, + }, +}); + +test('a late Irish Rail train keeps its run past its timetabled end', async (t) => { + // Now is 15 minutes after the run's timetabled end, past the 10 minutes + // a run is looked for around its times. + const tripOf = async (minutesLate) => { + const service = createRailService({ + now: () => NOW, + fetchImpl: async () => + new Response(irishXml(minutesLate), { status: 200 }), + readSchedulePack: async () => IRISH_PACK, + }); + try { + const body = await ( + await service.handle(request('/api/rail/trains/irish-rail')) + ).json(); + return body.trains[0]; + } finally { + service.close(); + } + }; + const late = await tripOf(16); + assert.equal(late.tripId, '6024_8435', '16 minutes late: still on its run'); + assert.equal(late.trainNumber, 'A610'); + assert.equal(late.delaySec, 960); + const onTime = await tripOf(0); + assert.equal(onTime.tripId, 'A610', 'on time: that run is over'); + assert.equal(onTime.trainNumber, undefined); +}); + +test('packs are served no-cache with their version as ETag, and 304 when unchanged', async (t) => { + let pathVersion = '1731340000000.5:120'; + let reads = 0; + const service = createRailService({ + readPathPack: async () => { + reads += 1; + return '{"paths":1}'; + }, + pathPackVersion: async () => pathVersion, + readSchedulePack: async () => '{"schedule":1}', + schedulePackVersion: async () => 'built-7', + }); + t.after(service.close); + const path = '/api/rail/paths/mta-lirr'; + const first = await service.handle(request(path)); + assert.equal(first.status, 200); + assert.equal(first.headers.get('cache-control'), 'no-cache'); + const etag = first.headers.get('etag'); + assert.equal(etag, `"${encodeURIComponent(pathVersion)}"`); + assert.equal(await first.text(), '{"paths":1}'); + // Asked again with its tag: 304, and the pack is not read. + for (const headers of [ + { 'if-none-match': etag }, + new Headers({ 'If-None-Match': `W/${etag}, "other"` }), + ]) { + const again = await service.handle(request(path, headers)); + assert.equal(again.status, 304); + assert.equal(again.headers.get('etag'), etag); + assert.equal(again.headers.get('cache-control'), 'no-cache'); + assert.equal(await again.text(), ''); + } + assert.equal(reads, 1); + // Rebuilt: the old tag gets the new pack. + pathVersion = '1731340099000:140'; + const rebuilt = await service.handle( + request(path, { 'if-none-match': etag }), + ); + assert.equal(rebuilt.status, 200); + assert.notEqual(rebuilt.headers.get('etag'), etag); + assert.equal(reads, 2); + // The schedule pack carries its own version. + const schedule = await service.handle(request('/api/rail/schedule/mta-lirr')); + assert.equal(schedule.headers.get('etag'), '"built-7"'); + assert.equal( + ( + await service.handle( + request('/api/rail/schedule/mta-lirr', { + 'if-none-match': '"built-7"', + }), + ) + ).status, + 304, + ); + // Without a version there is no tag: no-cache, read each time. + const bare = createRailService({ readPathPack: async () => '{}' }); + t.after(bare.close); + const plain = await bare.handle(request(path, { 'if-none-match': '*' })); + assert.equal(plain.status, 200); + assert.equal(plain.headers.get('etag'), null); + assert.equal(plain.headers.get('cache-control'), 'no-cache'); +}); + +test('the development server passes If-None-Match to the pack routes', async (t) => { + const plugin = railProxy({ + readPathPack: async () => '{"paths":1}', + pathPackVersion: async () => 'v1', + }); + let handler = null; + const server = { + middlewares: { + use(prefix, fn) { + assert.equal(prefix, '/api/rail'); + handler = fn; + }, + }, + }; + plugin.configureServer(server); + t.after(() => plugin.closeBundle()); + const answer = async (headers) => { + const head = {}; + const chunks = []; + const res = new Writable({ + write(chunk, encoding, done) { + chunks.push(chunk); + done(); + }, + }); + res.writeHead = (status, sent) => Object.assign(head, { status, sent }); + const ended = new Promise((resolve) => res.on('finish', resolve)); + await handler({ url: '/paths/mta-lirr', method: 'GET', headers }, res); + await ended; + return { ...head, body: Buffer.concat(chunks).toString('utf8') }; + }; + const first = await answer({}); + assert.equal(first.status, 200); + assert.equal(first.sent.etag, '"v1"'); + assert.equal(first.body, '{"paths":1}'); + const again = await answer({ 'if-none-match': '"v1"' }); + assert.equal(again.status, 304); + assert.equal(again.body, ''); +}); diff --git a/src/tooling/transitQa.test.mjs b/src/tooling/transitQa.test.mjs index 202e21870..cd01e9810 100644 --- a/src/tooling/transitQa.test.mjs +++ b/src/tooling/transitQa.test.mjs @@ -673,12 +673,15 @@ test('public transit documentation uses courtesy attribution and the approved gr new URL('../../DATA_SOURCES.md', import.meta.url), 'utf8', ); + // The Transit layer's own rows; the Rail layer's rows for the same + // operators quote their licences. const rows = data .split('\n') - .filter((line) => - /^\| \*\*(MBTA|CapMetro|Metro Transit|OVapi|Entur|TransLink|HSL)/.test( - line, - ), + .filter( + (line) => + /^\| \*\*(MBTA|CapMetro|Metro Transit|OVapi|Entur|TransLink|HSL)/.test( + line, + ) && /^\| [^|]+\| Transit layer /.test(line), ); assert.equal(rows.length, 7); for (const row of rows) { diff --git a/src/ui/layerNames.js b/src/ui/layerNames.js index 20573cc91..640d5ad74 100644 --- a/src/ui/layerNames.js +++ b/src/ui/layerNames.js @@ -32,6 +32,7 @@ export const LAYER_NAME_KEYS = Object.freeze({ 'local-adsb': msg('Local ADS-B'), 'osm-places': msg('OSM Places'), transit: msg('Transit'), + rail: msg('Rail'), 'recent-imagery': msg('Recent Imagery'), 'fire-perimeters': msg('Fire Perimeters'), 'gnss-integrity': msg('GNSS Integrity'), diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js index 896627bdc..c78a17645 100644 --- a/src/ui/layerPanel.js +++ b/src/ui/layerPanel.js @@ -53,6 +53,7 @@ const PANEL_GROUPS = [ 'ais-live-vessels', 'traffic', 'transit', + 'rail', 'bikeshare', ], }, diff --git a/src/ui/layerPanel.test.mjs b/src/ui/layerPanel.test.mjs index 7ac40db2d..6288d32aa 100644 --- a/src/ui/layerPanel.test.mjs +++ b/src/ui/layerPanel.test.mjs @@ -4,7 +4,7 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { runInNewContext } from 'node:vm'; -test('panel presentation places Transit between Street Traffic and Bike Share in Movement', () => { +test('panel presentation places Transit and Rail between Street Traffic and Bike Share in Movement', () => { const source = readFileSync( new URL('./layerPanel.js', import.meta.url), 'utf8', @@ -28,10 +28,12 @@ test('panel presentation places Transit between Street Traffic and Bike Share in 'ais-live-vessels', 'traffic', 'transit', + 'rail', 'bikeshare', ], ); assert.equal(order.filter(({ id }) => id === 'transit').length, 1); + assert.equal(order.filter(({ id }) => id === 'rail').length, 1); }); test('Wildlife follows Events and precedes Weather', () => { diff --git a/src/voice/layerManifest.js b/src/voice/layerManifest.js index da277293f..23fd127db 100644 --- a/src/voice/layerManifest.js +++ b/src/voice/layerManifest.js @@ -462,6 +462,7 @@ export const VOICE_OFF_LAYERS = Object.freeze({ news: 'browsed in the News panel; voice tools are a later step', 'wildlife-sightings': 'community observation layer; no room in the prompt budget', + rail: 'train layer; no room in the prompt budget, and "trains" still names transit', }); const TYPE_NAMES = Object.freeze({ diff --git a/src/worldFocus.js b/src/worldFocus.js index 6ed3a3b32..30d39369e 100644 --- a/src/worldFocus.js +++ b/src/worldFocus.js @@ -10,6 +10,9 @@ export const WORLD_FOCUS_FRAMING = Object.freeze({ vessel: Object.freeze({ radiusM: 150, rangeM: 1200, pitchDeg: -30 }), fire: Object.freeze({ radiusM: 400, rangeM: 3000, pitchDeg: -35 }), sighting: Object.freeze({ radiusM: 250, rangeM: 2500, pitchDeg: -40 }), + // A stopped train's one fly-to, and the frame a moving train is followed + // in (src/layers/rail/follow.js): the train and the track either side. + train: Object.freeze({ radiusM: 200, rangeM: 2500, pitchDeg: -35 }), 'sighting-area': Object.freeze({ radiusM: 11000, rangeM: 60000, diff --git a/src/worldFocus.test.mjs b/src/worldFocus.test.mjs index 3a10b75eb..8e2438cb1 100644 --- a/src/worldFocus.test.mjs +++ b/src/worldFocus.test.mjs @@ -209,6 +209,7 @@ test('every framing is a real oblique standoff, not a nadir or an inside-out sph 'fire', 'sighting', 'sighting-area', + 'train', 'vessel', ]); for (const kind of kinds) { @@ -244,6 +245,10 @@ test('every framing is a real oblique standoff, not a nadir or an inside-out sph { ...WORLD_FOCUS_FRAMING.fire }, { radiusM: 400, rangeM: 3000, pitchDeg: -35 }, ); + assert.deepEqual( + { ...WORLD_FOCUS_FRAMING.train }, + { radiusM: 200, rangeM: 2500, pitchDeg: -35 }, + ); assert.deepEqual( { ...WORLD_FOCUS_FRAMING.sighting }, { radiusM: 250, rangeM: 2500, pitchDeg: -40 }, From d793406aefced2c622bba37970fb0a1556f98b1e Mon Sep 17 00:00:00 2001 From: Denis Groshev Date: Wed, 11 Nov 2026 16:08:24 +0100 Subject: [PATCH 10/55] docs(changelog): add the #1675 fragment --- changelog.d/{0-rail-layer.md => 1675-rail-layer.md} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename changelog.d/{0-rail-layer.md => 1675-rail-layer.md} (97%) diff --git a/changelog.d/0-rail-layer.md b/changelog.d/1675-rail-layer.md similarity index 97% rename from changelog.d/0-rail-layer.md rename to changelog.d/1675-rail-layer.md index 14eac616f..044dd41c5 100644 --- a/changelog.d/0-rail-layer.md +++ b/changelog.d/1675-rail-layer.md @@ -21,4 +21,4 @@ flown to once; the selected train and its line stay on the map from orbit. No train is placed in Ukraine. Feeds that publish no licence are used on the basis each one's entry records, and their credit never presents that - as a licence (Denis Groshev, @ZloyS, #0). + as a licence (Denis Groshev, @ZloyS, #1675). From 44d2c0695c9f13a7b23b7f4fff12f5ea7882e060 Mon Sep 17 00:00:00 2001 From: Denis Groshev Date: Wed, 11 Nov 2026 18:00:24 +0100 Subject: [PATCH 11/55] feat(news): the News layer reads its time from the world clock Core's catalog passes worldClock.timeFor('news') as the layer's now, so a host that follows the clock for News can show a past time's reports with their ages, window and counts as they were then. Core does not follow the clock for the layer, so it keeps reading the wall clock. --- changelog.d/0-news-world-clock.md | 4 ++++ docs/CURRENT-STATE.md | 1 + src/app/constructCatalog.js | 8 +++++++- 3 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 changelog.d/0-news-world-clock.md diff --git a/changelog.d/0-news-world-clock.md b/changelog.d/0-news-world-clock.md new file mode 100644 index 000000000..632af9043 --- /dev/null +++ b/changelog.d/0-news-world-clock.md @@ -0,0 +1,4 @@ +- The News layer reads its time from the world clock, so a host that follows + the clock for News can show a past time's reports with their ages, window and + counts as they were then. Nothing changes while the timeline is live + (Denis Groshev, #0). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 7e976acbb..cbb939dd8 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -6692,6 +6692,7 @@ request viewbox and continues searching without that geographic preference. ### News (November 2026) - **What it shows.** Events ▸ **News**: the last 24 hours only, or the fewer hours a server names. Zoomed out, one beacon per hotspot with its count for the window and its trend, and a breathing beacon per developing situation (still under reduced motion). Below 2,500 km (7,500 km with a hotspot chosen), the hotspot outlines with disputed areas dotted and named, and every placed report as a ground-clamped marker: fill is severity (0 grey no physical event, 1 yellow minor, 2 orange significant, 3 red major), the icon is the category, the rim is attestation (solid confirmed, dashed claim, thin single), size grows with sources and the marker fades over the day. Colour never marks a side. Up to ten picture cards (physical events with two or more sources, major or pressing) stand over their markers without overlapping. A selected report's card reads "Source: host ↗" and opens the article. +- **Time.** The layer reads its time from the world clock (`worldClock.timeFor('news')`, passed as `now` in `src/app/constructCatalog.js`): report ages, the 24-hour window, "today", fading and the pressing and card rules all count from it. Core does not follow the clock for the layer, so this is the wall clock and nothing changes. A host whose news source can answer a past time may follow it for `news`; the layer then shows that time's news as it was then, with ages counted from the timeline. - **Panel.** The right-rail NEWS panel (`src/ui/newsPanel.js`, `src/ui/news/`) has a time window at the top (the server's 24 h, or 12, 6, 3 or 1 h; the hotspot counts are read for the chosen window) and two sections. **Conflicts**: hotspot chips with counts, a severity floor (significant and above by default), a category filter, the chosen event, and a continuous feed, newest first. **Local**, while the camera is below 3,000 km: every report in the area at the view centre, newest first, named at the top ("News from …") and outlined on the globe, which then shows those reports instead of the hotspots' news; at sea, reports within 500 km. A disputed area of the bundled list (11, see `src/data/local_data/natural_earth/README.md`), or a territory Natural Earth gives no country code (Somaliland, Northern Cyprus and others), is named as itself and read from no country's outlets; elsewhere Natural Earth's default country lines apply. Each card shows the publisher's picture when the event has one (hotlinked, no referrer, publisher credited), the headline (a model's English headline marked as such), a mono meta line (severity marker, category, place, sources, age), who reported it, attestation and media chips, and the service it came through ("via GDELT Project"). Open, a card plays the post's clip on request. A click flies the camera straight down from 150 km and selects the report. - **Notices.** A section whose key the server lacks says "{provider} key required" and names the variable; a refused key says "{provider} refused its key"; a spent daily allowance says "{provider} limit reached for today". The other section keeps working. - **Source.** `sources.news` (`createNewsSource`, export `./layers/news/source`) reads same-origin `/api/news/*` with a 15 s timeout, a 4 MiB cap and record-by-record validation; `newsQueryAllowed` lets a host admit exactly the client's requests. Core's local server (`server/providers/news.js`) forwards only those GET requests to `GEV_NEWS_ORIGIN` when it is set. diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index 42b0d8008..aecf87b9a 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -1,6 +1,7 @@ import { resolveCatalogSources } from './sourceComposition.js'; import { createWeatherClock } from '../layers/weather/clock.js'; import { worldClock } from '../time/worldClock.js'; +import { NEWS_LAYER_ID } from '../layers/news/model.js'; import { createWeatherLayer } from '../layers/weather/index.js'; import { createApplicationCyclones } from './layers/cyclones.js'; import { createWindLayer } from '../layers/wind/index.js'; @@ -142,7 +143,12 @@ export function createApplicationCatalog({ createApplicationDroneAirspace({ source: sources['drone-airspace'], }), - createApplicationNews({ source: sources.news }), + // Ages, the window and the counts read the world clock: a host that + // follows it for the layer shows the news at the time on the timeline. + createApplicationNews({ + source: sources.news, + now: worldClock.timeFor(NEWS_LAYER_ID), + }), createApplicationWildlifeSightings({ source: sources['wildlife-sightings'], groundFloor: surface.groundFloor, From 72c24a86d1c1a7f568adb5636a9e16f81972694d Mon Sep 17 00:00:00 2001 From: Denis Groshev Date: Wed, 11 Nov 2026 18:06:15 +0100 Subject: [PATCH 12/55] chore(news): name the changelog fragment for #1677 --- changelog.d/{0-news-world-clock.md => 1677-news-world-clock.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{0-news-world-clock.md => 1677-news-world-clock.md} (100%) diff --git a/changelog.d/0-news-world-clock.md b/changelog.d/1677-news-world-clock.md similarity index 100% rename from changelog.d/0-news-world-clock.md rename to changelog.d/1677-news-world-clock.md From 82e2b716bc31f41cb9f16bd00cbf2e214c4efa13 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 11 Nov 2026 21:21:07 +0400 Subject: [PATCH 13/55] docs(routes): tighten the outage comments and notes Shorter comments, changelog and CURRENT-STATE text for the 502 outage path; the CURRENT-STATE paragraph now also names the 429 exception and which budget expiry answers busy and which answers 502. Text only. --- changelog.d/1509-route-outage-unavailable.md | 7 +++---- docs/CURRENT-STATE.md | 19 ++++++++----------- server/providers/places/routes.js | 19 +++++-------------- src/layers/directions/source.js | 5 ++--- src/tooling/placeProviders.test.mjs | 1 - src/tools/places.js | 3 +-- src/tools/queries/places.test.mjs | 2 +- 7 files changed, 20 insertions(+), 36 deletions(-) diff --git a/changelog.d/1509-route-outage-unavailable.md b/changelog.d/1509-route-outage-unavailable.md index 89b573c4d..653463c51 100644 --- a/changelog.d/1509-route-outage-unavailable.md +++ b/changelog.d/1509-route-outage-unavailable.md @@ -1,4 +1,3 @@ -- When the routing service is down (an error status, no answer or a non-JSON - body), voice route planning hears "Routing service unavailable" and the - Directions layer shows "Routing unavailable" instead of "no route found", - so neither blames the request (quanticsoul4772, #1509). +- Routing outages report "Routing service unavailable" in voice route planning + and "Routing unavailable" in Directions instead of "no route found" + (quanticsoul4772, #1509). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index ce4cc52ef..28ea6a217 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5447,16 +5447,13 @@ service's usage policy states; past a bounded queue the answer is a 429 with a `Retry-After` rather than a queue that grows until everything times out. The upstream host is pinned and a redirect is refused rather than followed, and a rate limit from the routing service is reported as one instead of as "no route -found". So is an outage: any failure status but 400 (a 5xx, a 403 block, a -404), no answer (timeout, network error) or a body that is not JSON answers -`502 routing service unavailable`. `plan_route` throws `Routing service -unavailable (HTTP 502)`, which is what voice hears, and the layer shows -`Routing unavailable (HTTP 502)`; only OSRM's own refusal (a 400, or a 200 -without a route) reads "no route found". The 12 s timeout runs from the -request's arrival, queue wait included; a budget spent before the request -leaves the queue answers 429 "routing busy". A request that waited in the -queue gives OSRM the rest of the 12 s, so a slow answer then still reads as -unavailable. +found". Upstream failure statuses except 400 and 429, network failures, and +unreadable, oversized or non-JSON bodies return `502 routing service unavailable`. +Voice hears `Routing service unavailable (HTTP 502)`; Directions shows +`Routing unavailable (HTTP 502)`. OSRM 400 responses or successful responses +without a usable route return "no route found". The 12 s upstream budget includes +queue time: expiry before dispatch returns 429 "routing busy" when the queue slot +opens; expiry during the upstream request returns 502. `src/data/routeSteps.js` turns OSRM maneuver type / modifier / exit / road name into one plain-English sentence per decision, folding `exit roundabout` steps into the roundabout they leave, and caps one route at @@ -6867,7 +6864,7 @@ apply to `public/` assets or built-preview serving. - `/api/google/text-search` keeps the Google key server-side for view-biased Places recovery used by annotation resolution. - `/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; a routing-service outage answers 502 instead. +- `/api/route` proxies bounded OSRM route requests for annotation routes, with profile allowlisting, distance caps, response caps, caching, and sanitized "no route found" errors; routing outages return 502. - `/api/flights`, `/api/military`, and `/api/route` answer `405 Method Not Allowed` with `Allow: GET` to anything but `GET`, before rate limiting and any upstream call. - `/api/flights` negotiates gzip. The cached global snapshot and the regional adsb.lol fallback are each gzipped once per refresh (about 860 KB becomes about 250 KB). Clients that offer gzip get that frame; other clients, and bodies under 1 KiB or that gzip cannot shrink, get identity bytes. Both answers carry `Vary: Accept-Encoding`. Uncached bodies, such as auth failures, are never sent under a cached frame's encoding. - Track endpoints: `/api/vessels/track?mmsi=` (server-accumulated ring buffers; sub-route handled before the rows snapshot), `/api/flights/track?icao24=` (OAuth, 60s cache, sanitized errors, independent OpenSky credit bucket), `/api/military/track?hex=` (400 for an invalid hex, otherwise `503 history_unavailable` with `Retry-After: 1800` and `Cache-Control: no-store`, no upstream request; military tracking uses its locally accumulated trail). diff --git a/server/providers/places/routes.js b/server/providers/places/routes.js index 7d9ca718e..2dbc1aafa 100644 --- a/server/providers/places/routes.js +++ b/server/providers/places/routes.js @@ -72,10 +72,7 @@ class UpstreamRateLimitError extends Error { } } -/** - * Thrown when the routing service failed, did not answer (timeout, network - * error, refused redirect) or did not answer with JSON. - */ +/** Thrown when the routing service failed or sent no JSON; answered as 502. */ class UpstreamUnavailableError extends Error { constructor() { super('routing service unavailable'); @@ -178,14 +175,12 @@ async function fetchRoute({ `${base.replace(/\/$/, '')}/route/v1/${osrmProfile}/${coords}` + `?overview=full&geometries=geojson&alternatives=false&steps=${withSteps ? 'true' : 'false'}`; const controller = new AbortController(); - // One 12 s budget from arrival, queue wait included, so callers sized to it - // (the Directions layer gives up at 15 s) hear back in time. + // The 12 s budget includes queue wait, so Directions (15 s) hears back. const timer = setTimeout(() => controller.abort(), 12000); let osrm; try { const upstreamRes = await throughUpstreamGate(async () => { - // A budget spent waiting in our own queue is our backlog, not the - // routing service failing. + // Expiry before dispatch reflects our backlog, not an upstream outage. if (controller.signal.aborted) throw new RouteBusyError(_upstreamIntervalMs); try { @@ -207,8 +202,7 @@ async function fetchRoute({ Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : 5, ); } - // OSRM answers 400 for a request it cannot route (NoRoute, NoSegment); - // any other failure status (a 5xx, a 403 block, a 404) is the service's. + // OSRM answers 400 for a route it cannot find; other failures are outages. if (upstreamRes.status === 400) { await cancelBody(upstreamRes); return { payload: null, error: 'no route found' }; @@ -222,9 +216,7 @@ async function fetchRoute({ await cancelBody(upstreamRes); throw new UpstreamUnavailableError(); } - // A body cut off by the timeout or the network, an oversized one and one - // that is not JSON are all the service's failure; an exception after this - // point is ours and stays a "route proxy error". + // Read and parse failures are outages; later ones stay "route proxy error". try { osrm = JSON.parse( await readResponseTextCapped(upstreamRes, ROUTE_MAX_RESPONSE_BYTES), @@ -392,7 +384,6 @@ export function installRouteMiddleware( ); } if (upstreamError instanceof UpstreamUnavailableError) { - // Same reason: an outage says nothing about whether a route exists. return unavailable(upstreamError.message); } throw upstreamError; diff --git a/src/layers/directions/source.js b/src/layers/directions/source.js index 52ffc0888..e89bd4e83 100644 --- a/src/layers/directions/source.js +++ b/src/layers/directions/source.js @@ -21,7 +21,7 @@ export function createDirectionsSource({ signal, headers: { Accept: 'application/json' }, }); - // A failure page in front of the proxy need not be JSON. + // Proxy error pages may not be JSON. let payload = null; try { payload = await response.json(); @@ -45,8 +45,7 @@ export function createDirectionsSource({ { status: 429, refusal }, ); } - // A 5xx is a routing outage whatever its body says; only the proxy's - // refusals carry an error text worth showing. + // A 5xx body may mislabel an outage as a route refusal. if (response.status >= 500 || (!response.ok && !payload?.error)) throw Object.assign( new Error(`Routing unavailable (HTTP ${response.status})`), diff --git a/src/tooling/placeProviders.test.mjs b/src/tooling/placeProviders.test.mjs index 96c4ef61f..06553f7bc 100644 --- a/src/tooling/placeProviders.test.mjs +++ b/src/tooling/placeProviders.test.mjs @@ -236,7 +236,6 @@ test('OSRM routing preserves aliases, cache, span guards and upstream failure be assert.equal((await request(route, url)).body.error, error); assert.equal(calls, 1); now += 600001; - // OSRM's own "no route" is a 400 with a JSON code, and stays one. t.mock.method(globalThis, 'fetch', async () => Response.json( { code: 'NoRoute', message: 'Impossible route between points' }, diff --git a/src/tools/places.js b/src/tools/places.js index 3ecef4855..e708a763f 100644 --- a/src/tools/places.js +++ b/src/tools/places.js @@ -189,8 +189,7 @@ export function createRouteService({ } catch { /* the status below is authoritative */ } - // A rate limit or an outage (5xx) is the routing service's failure, not - // a missing route, whatever the body says. Voice hears this message. + // Handle 429 and 5xx before route refusals, regardless of the body. if (response.status === 429) throw httpFailure(response, 'Routing'); if (response.status >= 500) throw new ToolError( diff --git a/src/tools/queries/places.test.mjs b/src/tools/queries/places.test.mjs index 4e795e3f1..d818f081b 100644 --- a/src/tools/queries/places.test.mjs +++ b/src/tools/queries/places.test.mjs @@ -283,7 +283,7 @@ test('a routing outage is reported as unavailable, not as invalid arguments', as assert.equal(result.structuredContent.error, 'unavailable'); assert.doesNotMatch(result.content[0].text, /No walk route/); } - // Voice passes only the message on, so it names the outage itself. + // Voice receives only the message, so it must name the outage. const outage = createRouteService({ fetchImpl: async () => Response.json( From 94ff78be3b7a05ea51d7f74438e0a20b85319a9c Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 11 Nov 2026 21:23:35 +0400 Subject: [PATCH 14/55] refactor(routes): inline the single-use 502 responder The outage branch is the only caller, so it writes its 502 directly. Same status, headers and body. --- server/providers/places/routes.js | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/server/providers/places/routes.js b/server/providers/places/routes.js index 2dbc1aafa..4c38a21f9 100644 --- a/server/providers/places/routes.js +++ b/server/providers/places/routes.js @@ -267,10 +267,6 @@ export function installRouteMiddleware( }); res.end(JSON.stringify({ ok: false, error: msg })); }; - const unavailable = (msg) => { - res.writeHead(502, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ ok: false, error: msg })); - }; try { if (!_routeRateLimiter(clientKey(req))) { rateLimited('rate limited', 5); @@ -384,7 +380,9 @@ export function installRouteMiddleware( ); } if (upstreamError instanceof UpstreamUnavailableError) { - return unavailable(upstreamError.message); + res.writeHead(502, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: false, error: upstreamError.message })); + return; } throw upstreamError; } From ca58e113c08aed469df373b6625c17fe01e753cc Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Tue, 10 Nov 2026 15:02:34 -0600 Subject: [PATCH 15/55] test(routes): cover HTML 502 outage in MCP plan_route (#1509) Preserve quanticsoul4772's routing-outage fix and add a focused voice-facing regression for a non-JSON gateway response. --- src/tools/queries/places.test.mjs | 32 +++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/tools/queries/places.test.mjs b/src/tools/queries/places.test.mjs index d818f081b..1cc6e6c8a 100644 --- a/src/tools/queries/places.test.mjs +++ b/src/tools/queries/places.test.mjs @@ -303,6 +303,38 @@ test('a routing outage is reported as unavailable, not as invalid arguments', as ); }); +test('an HTML 502 gateway response tells voice the route service is unavailable', async () => { + const catalog = composeCatalog({ + tools: coreTools, + services: { + routing: createRouteService({ + fetchImpl: async () => + new Response('Bad Gateway', { + status: 502, + headers: { 'content-type': 'text/html' }, + }), + }), + places, + }, + }); + const server = createMcpServer({ catalog, name: 'test', version: '1.0.0' }); + const { result } = await server.handle({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: 'plan_route', + arguments: { from: { place: 'Austin' }, to: { place: 'Dallas' } }, + }, + }); + assert.equal(result.isError, true); + assert.equal(result.structuredContent.error, 'unavailable'); + assert.equal( + result.content[0].text, + 'Routing service unavailable (HTTP 502)', + ); +}); + test('place answers say when the search limits may have left places out', async () => { const five = Array.from({ length: 5 }, (_, index) => place(`p${index}`, 30.27, -97.74), From 99a1e06b61fae27ea2691b265f205172e8d47d25 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Wed, 11 Nov 2026 13:53:10 -0800 Subject: [PATCH 16/55] feat(news): an application may draw the News picture cards itself createNewsLayer takes a pictureCards(cards, { selectedId }) function; createApplicationCatalog passes it through newsOptions. With it, the layer paints no picture cards of its own and hands the function one card per pictured event on the map, most severe then newest first, with the selected report's id; it hands no cards when the layer turns off. --- docs/CURRENT-STATE.md | 2 +- src/app/constructCatalog.js | 3 +- src/layers/news/index.js | 56 ++++++++++++++++++++++- src/layers/news/layer.test.mjs | 81 ++++++++++++++++++++++++++++++++++ 4 files changed, 139 insertions(+), 3 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 28ea6a217..909433e92 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -6698,7 +6698,7 @@ request viewbox and continues searching without that geographic preference. ### News (November 2026) -- **What it shows.** Events ▸ **News**: the last 24 hours only, or the fewer hours a server names. Zoomed out, one beacon per hotspot with its count for the window and its trend, and a breathing beacon per developing situation (still under reduced motion). Below 2,500 km (7,500 km with a hotspot chosen), the hotspot outlines with disputed areas dotted and named, and every placed report as a ground-clamped marker: fill is severity (0 grey no physical event, 1 yellow minor, 2 orange significant, 3 red major), the icon is the category, the rim is attestation (solid confirmed, dashed claim, thin single), size grows with sources and the marker fades over the day. Colour never marks a side. Up to ten picture cards (physical events with two or more sources, major or pressing) stand over their markers without overlapping. A selected report's card reads "Source: host ↗" and opens the article. +- **What it shows.** Events ▸ **News**: the last 24 hours only, or the fewer hours a server names. Zoomed out, one beacon per hotspot with its count for the window and its trend, and a breathing beacon per developing situation (still under reduced motion). Below 2,500 km (7,500 km with a hotspot chosen), the hotspot outlines with disputed areas dotted and named, and every placed report as a ground-clamped marker: fill is severity (0 grey no physical event, 1 yellow minor, 2 orange significant, 3 red major), the icon is the category, the rim is attestation (solid confirmed, dashed claim, thin single), size grows with sources and the marker fades over the day. Colour never marks a side. Up to ten picture cards (physical events with two or more sources, major or pressing) stand over their markers without overlapping. An app built on Core may draw the picture cards itself: `createApplicationCatalog({ newsOptions: { pictureCards } })` calls `pictureCards(cards, { selectedId })` on each redraw with one card per event on the map that has a picture, single source or not, most severe then newest first (`{ id, title, lat, lon, publishedAt, severity, color, image, video, credit }`, at the marker), and the selected report's id or null (its own card still stands at its marker); with no cards when the layer turns off; the layer then paints none of its own, and a click on one is `setParams({ reportId, focus: true })`. A selected report's card reads "Source: host ↗" and opens the article. - **Panel.** The right-rail NEWS panel (`src/ui/newsPanel.js`, `src/ui/news/`) has a time window at the top (the server's 24 h, or 12, 6, 3 or 1 h; the hotspot counts are read for the chosen window) and two sections. **Conflicts**: hotspot chips with counts, a severity floor (significant and above by default), a category filter, the chosen event, and a continuous feed, newest first. **Local**, while the camera is below 3,000 km: every report in the area at the view centre, newest first, named at the top ("News from …") and outlined on the globe, which then shows those reports instead of the hotspots' news; at sea, reports within 500 km. A disputed area of the bundled list (11, see `src/data/local_data/natural_earth/README.md`), or a territory Natural Earth gives no country code (Somaliland, Northern Cyprus and others), is named as itself and read from no country's outlets; elsewhere Natural Earth's default country lines apply. Each card shows the publisher's picture when the event has one (hotlinked, no referrer, publisher credited), the headline (a model's English headline marked as such), a mono meta line (severity marker, category, place, sources, age), who reported it, attestation and media chips, and the service it came through ("via GDELT Project"). Open, a card plays the post's clip on request. A click flies the camera straight down from 150 km and selects the report. - **Notices.** A section whose key the server lacks says "{provider} key required" and names the variable; a refused key says "{provider} refused its key"; a spent daily allowance says "{provider} limit reached for today". The other section keeps working. - **Source.** `sources.news` (`createNewsSource`, export `./layers/news/source`) reads same-origin `/api/news/*` with a 15 s timeout, a 4 MiB cap and record-by-record validation; `newsQueryAllowed` lets a host admit exactly the client's requests. Core's local server (`server/providers/news.js`) forwards only those GET requests to `GEV_NEWS_ORIGIN` when it is set. diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index 42b0d8008..74f67760c 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -68,6 +68,7 @@ export function createApplicationCatalog({ signal, metadata = APPLICATION_LAYER_METADATA, vesselOptions, + newsOptions, resolveAsset, nepalBoundaryResolver, streetLevelProviders, @@ -142,7 +143,7 @@ export function createApplicationCatalog({ createApplicationDroneAirspace({ source: sources['drone-airspace'], }), - createApplicationNews({ source: sources.news }), + createApplicationNews({ ...newsOptions, source: sources.news }), createApplicationWildlifeSightings({ source: sources['wildlife-sightings'], groundFloor: surface.groundFloor, diff --git a/src/layers/news/index.js b/src/layers/news/index.js index 6749b3501..bd7024ab9 100644 --- a/src/layers/news/index.js +++ b/src/layers/news/index.js @@ -48,6 +48,7 @@ import { countryGroups, countryOnly, disputedLabel, + eventPicture, feedCardView, feedQueryFor, hotspotLabel, @@ -63,6 +64,7 @@ import { normalizeFilters, passesFilters, reportMarkerPosition, + reportSeverity, reportsInView, severityStyle, shortTitle, @@ -167,6 +169,13 @@ const emptyInView = () => ({ * chosen: outlines (disputed areas dotted) and the geocoded reports as * markers, the big events as picture cards. The News panel renders the feed * this layer publishes through getRowControls().feed. + * + * `pictureCards(cards, { selectedId })`, when given, draws the picture cards + * instead of the layer: it is called on every render with one card per + * pictured event on the map, in rank order (`{ id, title, lat, lon, + * publishedAt, severity, color, image, video, credit }`), and the selected + * report's id or null, whose own card the layer still draws at its marker; it + * is called with no cards when the layer is turned off. */ export function createNewsLayer({ source, @@ -178,6 +187,7 @@ export function createNewsLayer({ openUrl = (url) => globalThis.open?.(url, '_blank', 'noopener,noreferrer'), getViewCentre = viewCentre, registerCredit = registerDynamicCredit, + pictureCards: drawPictureCards = null, } = {}) { if ( typeof source?.getHotspots !== 'function' || @@ -185,6 +195,8 @@ export function createNewsLayer({ ) throw new TypeError('News requires a news source'); if (!overlayHost) throw new TypeError('News requires an overlay host'); + if (drawPictureCards !== null && typeof drawPictureCards !== 'function') + throw new TypeError('News picture cards must be a function'); let viewer = null, dataSource = null, @@ -679,6 +691,43 @@ export function createNewsLayer({ } } + // With a `pictureCards` function the host draws the picture cards: one per + // event on the map with a picture (its most severe, then newest report), + // most severe then newest first, at its marker. The host chooses which + // stand; a click on one calls setParams({ reportId, focus: true }). + function hostCards(drawn) { + const events = new Set(); + return drawn + .filter((r) => r.pins.length && onMap(r) && eventPicture(r)) + .sort( + (a, b) => + reportSeverity(b) - reportSeverity(a) || + (b.event?.lastAt || b.publishedAt || '').localeCompare( + a.event?.lastAt || a.publishedAt || '', + ), + ) + .filter((report) => { + const event = report.event?.id || report.id; + if (events.has(event)) return false; + events.add(event); + return true; + }) + .map((report) => { + const picture = eventPicture(report); + return { + id: report.id, + title: report.titleEn || report.title, + ...reportMarkerPosition(report, 0), + publishedAt: report.event?.lastAt || report.publishedAt || null, + severity: reportSeverity(report), + color: severityStyle(report).color, + image: picture.url, + video: picture.video || null, + credit: picture.credit, + }; + }); + } + // The selected report's card: what, where, how attested, the link to the // original article ("Source: host ↗"), which a click on it opens, and the // service it came through. @@ -775,7 +824,11 @@ export function createNewsLayer({ .filter((target) => target.position); horizon.update(); updatePulse(); - pictureCards(drawn, current, labels); + if (drawPictureCards) + drawPictureCards(enabled ? hostCards(drawn) : [], { + selectedId: enabled ? selectedId : null, + }); + else pictureCards(drawn, current, labels); selectedCard(current, labels); if (enabled) overlayHost.setEntries(NEWS_OVERLAY_SOURCE_ID, labels, { moving: false }); @@ -1442,6 +1495,7 @@ export function createNewsLayer({ } overlayHost.clearSource(NEWS_OVERLAY_SOURCE_ID); overlayHost.setVisible(NEWS_OVERLAY_SOURCE_ID, false); + drawPictureCards?.([], { selectedId: null }); requestRender(); notify(); }, diff --git a/src/layers/news/layer.test.mjs b/src/layers/news/layer.test.mjs index 3a677d4db..4b260790d 100644 --- a/src/layers/news/layer.test.mjs +++ b/src/layers/news/layer.test.mjs @@ -46,6 +46,7 @@ function fixture( centre = () => ({ lat: 49, lon: 36 }), credits = [], now = () => NOW, + pictureCards, } = {}, ) { const calls = { feed: [], hotspots: 0, outline: 0 }; @@ -118,6 +119,7 @@ function fixture( credits.push(credit.key); return true; }, + ...(pictureCards ? { pictureCards } : {}), }); // The context store announces selections on `window`, which Node lacks. const originalWindow = globalThis.window; @@ -227,6 +229,85 @@ test('the severity floor changes what is shown, not what is read', async (t) => ); }); +test('a host that draws the picture cards is handed one card per pictured event on the map, most severe first', async (t) => { + const picture = (url, extra = {}) => ({ + size: 1, + lastAt: '2026-10-28T09:00:00.000Z', + sides: [], + image: { url, publisher: 'Example', ...extra }, + }); + const reports = [ + report('single', { + severity: 1, + event: picture('https://example.com/single.jpg', { + video: 'https://example.com/single.mp4', + }), + }), + report('major', { + severity: 3, + event: { + ...picture('https://example.com/major.jpg'), + id: 'event-major', + size: 3, + }, + }), + // Another report of the same event: its picture stands once. + report('repeat', { + severity: 2, + event: { + ...picture('https://example.com/major.jpg'), + id: 'event-major', + size: 3, + }, + }), + report('bare', { severity: 2 }), + ]; + const handed = []; + const { layer, entries } = fixture(t, { + height: 500_000, + source: { getFeed: async () => ({ reports, nextBefore: null }) }, + pictureCards: (cards, state) => handed.push({ cards, ...state }), + }); + layer.enable(); + await layer.update(); + layer.setParams({ filters: { minSeverity: 0 } }); + const { cards, selectedId } = handed.at(-1); + assert.equal(selectedId, null); + // A single source is enough; a report without a picture has none. + assert.deepEqual( + cards.map((card) => card.id), + ['major', 'single'], + ); + assert.equal(cards[1].image, 'https://example.com/single.jpg'); + assert.equal(cards[1].video, 'https://example.com/single.mp4'); + assert.equal(cards[0].video, null); + assert.equal(cards[0].credit, 'Example'); + assert.ok( + Math.abs(cards[0].lat - 50) < 0.5 && Math.abs(cards[0].lon - 36) < 0.5, + ); + // The layer paints none of its own. + assert.ok( + entries.flat().every((entry) => !entry.id.startsWith('event-card:')), + ); + // The selected report is named, pictured or not. + layer.setParams({ reportId: 'bare' }); + assert.equal(handed.at(-1).selectedId, 'bare'); + layer.disable(); + assert.deepEqual(handed.at(-1), { cards: [], selectedId: null }); +}); + +test('picture cards must be drawn by a function', () => { + assert.throws( + () => + createNewsLayer({ + source: { getHotspots() {}, getFeed() {} }, + overlayHost: {}, + pictureCards: 'yes', + }), + TypeError, + ); +}); + test('Local reads every report once, lists those near the view and takes the map over', async (t) => { t.mock.timers.enable({ apis: ['setTimeout'] }); let centre = { lat: 49, lon: 36 }; From 0f18c030414f7efd1434fdb107f1869df631f73c Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Wed, 11 Nov 2026 14:48:05 -0800 Subject: [PATCH 17/55] chore(news): credit the #1677 changelog fragment --- changelog.d/1677-news-world-clock.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/1677-news-world-clock.md b/changelog.d/1677-news-world-clock.md index 632af9043..1afb8bfe7 100644 --- a/changelog.d/1677-news-world-clock.md +++ b/changelog.d/1677-news-world-clock.md @@ -1,4 +1,4 @@ - The News layer reads its time from the world clock, so a host that follows the clock for News can show a past time's reports with their ages, window and counts as they were then. Nothing changes while the timeline is live - (Denis Groshev, #0). + (Denis Groshev, @ZloyS, #1677). From ace21600023665673101e087675eae7a62b6a670 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Wed, 11 Nov 2026 14:54:01 -0800 Subject: [PATCH 18/55] fix(rail): Montpellier tram speeds are converted from km/h TaM publishes km/h where GTFS-Realtime asks m/s, and the Transit layer's entry for the same feed already sets speedUnit: 'km/h'. The Rail entry did not, so the proxy passed the readings through and the card multiplied them by 3.6 again: a tram at 30 km/h read about 108 km/h. A registry test now requires a Rail feed that Transit also reads to declare the same unit. --- src/data/railFeeds.js | 9 ++++++++- src/data/railFeeds.test.mjs | 17 +++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/data/railFeeds.js b/src/data/railFeeds.js index f1c1eec1d..6b7215c3c 100644 --- a/src/data/railFeeds.js +++ b/src/data/railFeeds.js @@ -476,7 +476,9 @@ const OWNER_AUTHORIZED_NO_TERMS = * 64 MiB: railMaxBodyBytes; the feeds that set one publish small documents * and set a lower cap), and `headerClock: 'untrusted'` marks a feed whose * header time is wrong and whose vehicles carry none: every train is then - * stamped with the fetch time. + * stamped with the fetch time. `speedUnit: 'km/h'` marks a feed that + * publishes km/h where the spec requires m/s; the proxy converts it, as for + * Transit (src/data/transitFeeds.js). * `tripUpdatesUrl` is the operator's GTFS-RT TripUpdates feed, served as * `/api/rail/updates/`. * @@ -509,6 +511,7 @@ const OWNER_AUTHORIZED_NO_TERMS = * url: string, * positions?: 'gps'|'event', * headers?: Record, headerClock?: 'untrusted', + * speedUnit?: 'km/h', * pollIntervalMs?: number, tripUpdatesUrl?: string, * tripUpdatesIntervalMs?: number, maxBodyBytes?: number, * limits?: {quote: string, note?: string}, @@ -1307,6 +1310,10 @@ export const RAIL_FEED_REGISTRY = Object.freeze([ format: 'gtfs-rt', // Every TaM vehicle (about 15 KB); the tram routes are kept. url: 'https://gtfsproxy.e-tam.fr/COMMON/VehiclePosition.pb', + // Speeds are km/h where the spec asks m/s (the Transit layer's entry for + // this feed measured readings at 3.3 times the movement); the proxy + // converts them. + speedUnit: 'km/h', tripUpdatesUrl: 'https://gtfsproxy.e-tam.fr/COMMON/TripUpdate.pb', tripUpdatesIntervalMs: 60_000, staticGtfsUrl: 'https://gtfsproxy.e-tam.fr/COMMON/GTFS.zip', diff --git a/src/data/railFeeds.test.mjs b/src/data/railFeeds.test.mjs index 5304680a9..f6fb8db1e 100644 --- a/src/data/railFeeds.test.mjs +++ b/src/data/railFeeds.test.mjs @@ -66,6 +66,7 @@ const REGISTRY_FIELDS = new Set([ 'positions', 'headers', 'headerClock', + 'speedUnit', 'pollIntervalMs', 'tripUpdatesUrl', 'tripUpdatesIntervalMs', @@ -472,3 +473,19 @@ test('feeds that read routes from their schedule pack can build one', () => { })) assert.ok(!/@/.test(value), feed.id); }); + +test('a feed Transit also reads declares the same speed unit', async () => { + const { TRANSIT_FEED_REGISTRY } = await import('./transitFeeds.js'); + const transitByUrl = new Map( + TRANSIT_FEED_REGISTRY.map((feed) => [feed.url, feed]), + ); + let shared = 0; + for (const feed of RAIL_FEED_REGISTRY) { + const transit = transitByUrl.get(feed.url); + if (!transit) continue; + shared += 1; + assert.equal(feed.speedUnit, transit.speedUnit, feed.id); + } + assert.ok(shared > 0); + assert.equal(getRegisteredRailFeed('tam-montpellier').speedUnit, 'km/h'); +}); From 283890cee0239b2e7408ce8ec124ec129c89b762 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Wed, 11 Nov 2026 14:54:01 -0800 Subject: [PATCH 19/55] fix(rail): trip-update delays are as old as the server's read The browser stamped every trip-updates answer as just read. While an operator's feed fails, the rail proxy replays its last good copy for up to ten minutes, and a cached copy can be a TTL old, so those delays never reached RAIL_UPDATES_MAX_AGE_MS and a selected train's card showed them as current. Delays now take their age from the snapshot's fetchedAt (capped at now), as positions do in ingestion.js, so they stop applying 75 s after the server last read the operator. --- src/layers/rail/schedules.js | 10 +++++- src/layers/rail/schedules.test.mjs | 49 ++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/src/layers/rail/schedules.js b/src/layers/rail/schedules.js index 8e6a5e94c..90892f752 100644 --- a/src/layers/rail/schedules.js +++ b/src/layers/rail/schedules.js @@ -287,8 +287,16 @@ export function createRailSchedules({ if (!response.ok) throw new Error(`rail updates HTTP ${response.status}`); const body = await response.json(); if (slot.controller !== controller) return; + // Delays are as old as the server's read of the operator (`fetchedAt`), + // not this answer: a cached copy, or the last good one replayed while + // the operator fails, stops applying RAIL_UPDATES_MAX_AGE_MS after it, + // as positions do (ingestion.js applySnapshot). + const at = now(); slot.delays = delaysFromUpdates(body); - slot.at = now(); + slot.at = Math.min( + Number.isFinite(body?.fetchedAt) ? body.fetchedAt : at, + at, + ); onChange(feedId); } catch (error) { if (error?.name !== 'AbortError') diff --git a/src/layers/rail/schedules.test.mjs b/src/layers/rail/schedules.test.mjs index 0db2cdca9..6d618bc06 100644 --- a/src/layers/rail/schedules.test.mjs +++ b/src/layers/rail/schedules.test.mjs @@ -372,6 +372,55 @@ test('delays are polled every 30 s only while a train on the feed is selected', assert.equal(h.timers.size, 0); }); +test('delays are as old as the server’s read, even when it replays them', async () => { + let readAt = null; + const h = harness({ + updates: () => + reply({ + fetchedAt: readAt ?? h.now, + trips: [ + { + tripId: 'T1', + delaySec: 240, + stops: [{ stopId: 'B', arrival: { delay: 240 }, departure: null }], + }, + ], + }), + }); + const train = liveTrain({ + id: 'v1', + tripId: 'T1', + routeId: 'R1', + lat: LAT, + lon: 0.05, + }); + h.provider.onSelect(train); + await flush(); + assert.equal(h.provider.stopsFor(train, h.now).stops[1].arrivalDelaySec, 240); + + // The operator fails: each poll is answered with the server's last copy, + // still stamped with the time it was read. + readAt = h.now; + const poll = [...h.timers.values()][0].fn; + for (let i = 0; i < 3; i++) { + h.now += RAIL_UPDATES_POLL_MS; + poll(); + await flush(); + } + assert.equal(h.calls.updates, 4); + assert.ok(3 * RAIL_UPDATES_POLL_MS > RAIL_UPDATES_MAX_AGE_MS); + assert.equal(h.provider.delaysFor(FEED), null); + assert.equal(h.provider.stopsFor(train, h.now).stops[1].arrivalDelaySec, 0); + + // A fresh read applies again; a server clock ahead counts as now. + readAt = h.now + 5_000; + poll(); + await flush(); + assert.equal(h.provider.stopsFor(train, h.now).stops[1].arrivalDelaySec, 240); + h.now += RAIL_UPDATES_MAX_AGE_MS; + assert.notEqual(h.provider.delaysFor(FEED), null); +}); + test('a selected train’s delays are not polled when its feed has no timetable', async () => { const h = harness({ schedule: () => reply({ error: 'No rail schedule' }, 404), From a105e819e0cd54bb70c8ffa4d44c01a422a29ac1 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Wed, 11 Nov 2026 19:43:06 -0800 Subject: [PATCH 20/55] fix(news): an application's picture cards stay at every height Zoomed out, a hotspot's reports give way to its beacon, and the cards an application draws through pictureCards went with them; they came back only once the camera had stopped after zooming in. The cards now come from every report the close-up view would mark, at any height; the layer's own markers and beacons are unchanged. --- docs/CURRENT-STATE.md | 2 +- src/layers/news/index.js | 17 +++++++++++++++-- src/layers/news/layer.test.mjs | 28 ++++++++++++++++++++++++++++ 3 files changed, 44 insertions(+), 3 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index db0dc8118..bdfefe043 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -6972,7 +6972,7 @@ request viewbox and continues searching without that geographic preference. ### News (November 2026) -- **What it shows.** Events ▸ **News**: the last 24 hours only, or the fewer hours a server names. Zoomed out, one beacon per hotspot with its count for the window and its trend, and a breathing beacon per developing situation (still under reduced motion). Below 2,500 km (7,500 km with a hotspot chosen), the hotspot outlines with disputed areas dotted and named, and every placed report as a ground-clamped marker: fill is severity (0 grey no physical event, 1 yellow minor, 2 orange significant, 3 red major), the icon is the category, the rim is attestation (solid confirmed, dashed claim, thin single), size grows with sources and the marker fades over the day. Colour never marks a side. Up to ten picture cards (physical events with two or more sources, major or pressing) stand over their markers without overlapping. An app built on Core may draw the picture cards itself: `createApplicationCatalog({ newsOptions: { pictureCards } })` calls `pictureCards(cards, { selectedId })` on each redraw with one card per event on the map that has a picture, single source or not, most severe then newest first (`{ id, title, lat, lon, publishedAt, severity, color, image, video, credit }`, at the marker), and the selected report's id or null (its own card still stands at its marker); with no cards when the layer turns off; the layer then paints none of its own, and a click on one is `setParams({ reportId, focus: true })`. A selected report's card reads "Source: host ↗" and opens the article. +- **What it shows.** Events ▸ **News**: the last 24 hours only, or the fewer hours a server names. Zoomed out, one beacon per hotspot with its count for the window and its trend, and a breathing beacon per developing situation (still under reduced motion). Below 2,500 km (7,500 km with a hotspot chosen), the hotspot outlines with disputed areas dotted and named, and every placed report as a ground-clamped marker: fill is severity (0 grey no physical event, 1 yellow minor, 2 orange significant, 3 red major), the icon is the category, the rim is attestation (solid confirmed, dashed claim, thin single), size grows with sources and the marker fades over the day. Colour never marks a side. Up to ten picture cards (physical events with two or more sources, major or pressing) stand over their markers without overlapping. An app built on Core may draw the picture cards itself: `createApplicationCatalog({ newsOptions: { pictureCards } })` calls `pictureCards(cards, { selectedId })` on each redraw with one card per event on the map that has a picture, single source or not, at every height (zoomed out, where a hotspot stands as one beacon, its reports' pictures still come), most severe then newest first (`{ id, title, lat, lon, publishedAt, severity, color, image, video, credit }`, at the marker), and the selected report's id or null (its own card still stands at its marker); with no cards when the layer turns off; the layer then paints none of its own, and a click on one is `setParams({ reportId, focus: true })`. A selected report's card reads "Source: host ↗" and opens the article. - **Time.** The layer reads its time from the world clock (`worldClock.timeFor('news')`, passed as `now` in `src/app/constructCatalog.js`): report ages, the 24-hour window, "today", fading and the pressing and card rules all count from it. Core does not follow the clock for the layer, so this is the wall clock and nothing changes. A host whose news source can answer a past time may follow it for `news`; the layer then shows that time's news as it was then, with ages counted from the timeline. - **Panel.** The right-rail NEWS panel (`src/ui/newsPanel.js`, `src/ui/news/`) has a time window at the top (the server's 24 h, or 12, 6, 3 or 1 h; the hotspot counts are read for the chosen window) and two sections. **Conflicts**: hotspot chips with counts, a severity floor (significant and above by default), a category filter, the chosen event, and a continuous feed, newest first. **Local**, while the camera is below 3,000 km: every report in the area at the view centre, newest first, named at the top ("News from …") and outlined on the globe, which then shows those reports instead of the hotspots' news; at sea, reports within 500 km. A disputed area of the bundled list (11, see `src/data/local_data/natural_earth/README.md`), or a territory Natural Earth gives no country code (Somaliland, Northern Cyprus and others), is named as itself and read from no country's outlets; elsewhere Natural Earth's default country lines apply. Each card shows the publisher's picture when the event has one (hotlinked, no referrer, publisher credited), the headline (a model's English headline marked as such), a mono meta line (severity marker, category, place, sources, age), who reported it, attestation and media chips, and the service it came through ("via GDELT Project"). Open, a card plays the post's clip on request. A click flies the camera straight down from 150 km and selects the report. - **Notices.** A section whose key the server lacks says "{provider} key required" and names the variable; a refused key says "{provider} refused its key"; a spent daily allowance says "{provider} limit reached for today". The other section keeps working. diff --git a/src/layers/news/index.js b/src/layers/news/index.js index bd7024ab9..099f7f4ef 100644 --- a/src/layers/news/index.js +++ b/src/layers/news/index.js @@ -172,7 +172,8 @@ const emptyInView = () => ({ * * `pictureCards(cards, { selectedId })`, when given, draws the picture cards * instead of the layer: it is called on every render with one card per - * pictured event on the map, in rank order (`{ id, title, lat, lon, + * pictured event on the map, at every height (zoomed out too, where the + * hotspots stand as beacons), in rank order (`{ id, title, lat, lon, * publishedAt, severity, color, image, video, credit }`), and the selected * report's id or null, whose own card the layer still draws at its marker; it * is called with no cards when the layer is turned off. @@ -691,6 +692,16 @@ export function createNewsLayer({ } } + // What a host's picture cards are chosen from when the hotspots stand as + // beacons: every report the close-up view would mark, and the selection. + function cardable() { + const current = selected(); + const all = localMode() ? localReports(NEWS_MAP_LIMIT) : mapReports; + return current && !all.some((r) => r.id === current.id) + ? [...all, current] + : all; + } + // With a `pictureCards` function the host draws the picture cards: one per // event on the map with a picture (its most severe, then newest report), // most severe then newest first, at its marker. The host chooses which @@ -825,7 +836,9 @@ export function createNewsLayer({ horizon.update(); updatePulse(); if (drawPictureCards) - drawPictureCards(enabled ? hostCards(drawn) : [], { + // At every height: zoomed out, a hotspot's reports give way to its + // beacon, but their pictures still stand (the host keeps them apart). + drawPictureCards(enabled ? hostCards(detail ? drawn : cardable()) : [], { selectedId: enabled ? selectedId : null, }); else pictureCards(drawn, current, labels); diff --git a/src/layers/news/layer.test.mjs b/src/layers/news/layer.test.mjs index 4b260790d..7935a908d 100644 --- a/src/layers/news/layer.test.mjs +++ b/src/layers/news/layer.test.mjs @@ -296,6 +296,34 @@ test('a host that draws the picture cards is handed one card per pictured event assert.deepEqual(handed.at(-1), { cards: [], selectedId: null }); }); +test("a host's picture cards stay at every height, beacons or markers", async (t) => { + const reports = [ + report('major', { + severity: 3, + event: { + size: 1, + lastAt: '2026-10-28T09:00:00.000Z', + sides: [], + image: { url: 'https://example.com/major.jpg', publisher: 'Example' }, + }, + }), + ]; + const handed = []; + const { layer } = fixture(t, { + // The whole globe: the hotspot stands as one beacon, its reports unmarked. + height: 20_000_000, + source: { getFeed: async () => ({ reports, nextBefore: null }) }, + pictureCards: (cards) => handed.push(cards), + }); + layer.enable(); + await layer.update(); + layer.setParams({ filters: { minSeverity: 0 } }); + assert.deepEqual( + handed.at(-1).map((card) => card.id), + ['major'], + ); +}); + test('picture cards must be drawn by a function', () => { assert.throws( () => From db80d82b8d67a828847fae0aa530f7a0cd1b97ca Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Thu, 12 Nov 2026 12:26:45 +0000 Subject: [PATCH 21/55] Add the Data Library: live regional datasets, tiles and packs DATA LAYERS keeps its curated core layers; regional and niche sources live in the Data Library, opened from Browse data library at the foot of the panel. Each entry is a JSON manifest in src/library/entries/ pointing at a live upstream (query API, provider-hosted file or tile endpoint), so nothing is bundled and a self-hosted instance hosts no data. Sources without CORS go through one allowlisted /api/library route. Kinds: dataset (GeoJSON, CSV, JSON records, ArcGIS, partitioned GeoParquet), tiles (XYZ, WMS/ArcGIS bbox export, terrarium relief) and pack. Selections persist and travel in share links as lib=, separate from the core layer tokens. Library rows get Zoom and Remove icon chips that work while the layer is off, rendered through RailChips. Strings are translated in every locale. --- .env.example | 5 + .github/workflows/ci.yml | 4 + CONTRIBUTING.md | 3 +- DATA_SOURCES.md | 117 +++ SECURITY.md | 1 + THIRD_PARTY_NOTICES.md | 19 +- changelog.d/1604-data-library.md | 27 + docs/CURRENT-STATE.md | 200 +++++ docs/DATA-LIBRARY.md | 183 ++++ index.html | 2 +- package-lock.json | 14 + package.json | 7 + scripts/library.mjs | 321 +++++++ scripts/package-boundaries.json | 156 +++- scripts/qa-data-library.mjs | 636 ++++++++++++++ server/providers/library.js | 363 ++++++++ server/providers/library.test.mjs | 332 ++++++++ server/providers/local.js | 2 + src/app/layerPresentation.js | 1 + src/app/tools.js | 39 + src/data/libraryRegistration.test.mjs | 54 ++ src/data/lifecycle.js | 42 + src/data/localGeojsonCore.js | 53 +- src/i18n/locales/ar.json | 123 ++- src/i18n/locales/de.json | 99 ++- src/i18n/locales/es.json | 105 ++- src/i18n/locales/fr.json | 105 ++- src/i18n/locales/he.json | 105 ++- src/i18n/locales/it.json | 105 ++- src/i18n/locales/ja.json | 93 +- src/i18n/locales/pt.json | 105 ++- src/i18n/locales/ru.json | 111 ++- src/i18n/locales/sv.json | 99 ++- src/i18n/locales/uk.json | 111 ++- src/i18n/locales/zh-CN.json | 93 +- src/i18n/locales/zh-Hant.json | 93 +- src/i18n/same-as-english.json | 27 +- src/library/camera.js | 74 ++ src/library/catalog.js | 248 ++++++ src/library/catalog.test.mjs | 47 + src/library/controller.js | 394 +++++++++ src/library/controller.test.mjs | 365 ++++++++ src/library/entries/ak-statewide-imagery.json | 47 + src/library/entries/ar-statewide-imagery.json | 47 + src/library/entries/az-statewide-imagery.json | 48 ++ src/library/entries/bangor-me.json | 48 ++ src/library/entries/basemap-at-orthofoto.json | 48 ++ src/library/entries/bayern-dop40.json | 48 ++ src/library/entries/bismarck-mandan-nd.json | 48 ++ src/library/entries/bkg-topplus-open.json | 34 + src/library/entries/cctv-florida-fl511.json | 24 + src/library/entries/cctv-lithuania.json | 24 + src/library/entries/cctv-nyc-dot.json | 24 + src/library/entries/ct-statewide-imagery.json | 48 ++ src/library/entries/dakota-county-mn.json | 48 ++ src/library/entries/dc-statewide-imagery.json | 47 + src/library/entries/de-statewide-imagery.json | 48 ++ src/library/entries/ee-maa-amet-ortofoto.json | 47 + src/library/entries/eia-power-plants.json | 68 ++ src/library/entries/emodnet-bathymetry.json | 34 + src/library/entries/epa-superfund-sites.json | 40 + .../entries/epa-toxic-release-facilities.json | 45 + src/library/entries/epc-england-wales.json | 66 ++ src/library/entries/fema-usa-structures.json | 65 ++ .../entries/gbif-occurrence-density.json | 60 ++ .../entries/gdacs-disaster-alerts.json | 56 ++ src/library/entries/geneva-sitg-ortho.json | 48 ++ .../entries/gpw-population-density.json | 60 ++ src/library/entries/grand-forks-nd.json | 48 ++ src/library/entries/gsi-japan-map.json | 35 + src/library/entries/gsi-japan-photo.json | 48 ++ .../entries/gvp-holocene-volcanoes.json | 47 + src/library/entries/ia-statewide-imagery.json | 48 ++ src/library/entries/icgc-orto-catalunya.json | 47 + src/library/entries/id-statewide-imagery.json | 47 + .../entries/ign-orthophotos-france.json | 48 ++ src/library/entries/il-statewide-imagery.json | 48 ++ .../entries/imf-portwatch-chokepoints.json | 39 + .../entries/imf-portwatch-disruptions.json | 57 ++ src/library/entries/in-statewide-imagery.json | 47 + src/library/entries/jrc-surface-water.json | 48 ++ .../entries/kartverket-admin-units.json | 46 + src/library/entries/kartverket-cadastre.json | 36 + src/library/entries/kartverket-depth.json | 102 +++ src/library/entries/kartverket-hillshade.json | 36 + .../entries/kartverket-nautical-charts.json | 36 + .../entries/kartverket-terrain-relief.json | 36 + src/library/entries/kartverket-topo-grey.json | 36 + src/library/entries/kartverket-topo.json | 36 + .../entries/kartverket-toporaster.json | 36 + src/library/entries/ks-statewide-imagery.json | 48 ++ src/library/entries/ky-statewide-imagery.json | 47 + src/library/entries/lake-county-in.json | 48 ++ src/library/entries/lawrence-ks.json | 48 ++ src/library/entries/lee-county-fl.json | 48 ++ src/library/entries/loudoun-county-va.json | 48 ++ src/library/entries/louisville-lojic-ky.json | 49 ++ src/library/entries/lu-ortho-latest.json | 48 ++ src/library/entries/ma-statewide-imagery.json | 48 ++ src/library/entries/macrostrat-geology.json | 88 ++ src/library/entries/mapterhorn-terrain.json | 32 + src/library/entries/maris-ms.json | 48 ++ src/library/entries/md-statewide-imagery.json | 48 ++ src/library/entries/me-statewide-imagery.json | 48 ++ src/library/entries/mi-statewide-imagery.json | 48 ++ src/library/entries/mn-statewide-imagery.json | 48 ++ src/library/entries/mountain-village-co.json | 48 ++ src/library/entries/ms-statewide-imagery.json | 48 ++ src/library/entries/mt-statewide-imagery.json | 47 + src/library/entries/naip.json | 47 + src/library/entries/nasa-black-marble.json | 30 + src/library/entries/nasa-eonet-events.json | 58 ++ src/library/entries/nc-statewide-imagery.json | 47 + src/library/entries/nctcog-tx.json | 48 ++ src/library/entries/nd-statewide-imagery.json | 48 ++ src/library/entries/new-orleans-rpc-la.json | 48 ++ src/library/entries/nh-statewide-imagery.json | 48 ++ src/library/entries/nj-statewide-imagery.json | 48 ++ src/library/entries/noaa-tide-stations.json | 37 + src/library/entries/nrw-dop.json | 48 ++ src/library/entries/nsw-imagery.json | 47 + .../entries/ntad-national-bridges.json | 57 ++ .../entries/nwa-benton-washington-ar.json | 48 ++ src/library/entries/nws-active-alerts.json | 56 ++ src/library/entries/ny-statewide-imagery.json | 47 + src/library/entries/oakland-county-mi.json | 48 ++ src/library/entries/oh-statewide-imagery.json | 47 + src/library/entries/openseamap-seamarks.json | 30 + src/library/entries/or-statewide-imagery.json | 47 + src/library/entries/pa-statewide-imagery.json | 48 ++ .../entries/pdok-aerial-netherlands.json | 48 ++ src/library/entries/pl-ortofotomapa.json | 48 ++ src/library/entries/pnoa-ma.json | 48 ++ src/library/entries/portland-metro-or.json | 48 ++ src/library/entries/pt-dgt-ortos.json | 48 ++ src/library/entries/ri-statewide-imagery.json | 48 ++ src/library/entries/swisstopo-swissimage.json | 49 ++ src/library/entries/tx-statewide-imagery.json | 47 + src/library/entries/union-county-oh.json | 49 ++ src/library/entries/usgs-hydrography.json | 35 + src/library/entries/usgs-topo.json | 35 + src/library/entries/va-statewide-imagery.json | 48 ++ src/library/entries/vlaanderen-omw.json | 48 ++ src/library/entries/vt-statewide-imagery.json | 47 + src/library/entries/wallonie-ortho.json | 48 ++ src/library/entries/wi-statewide-imagery.json | 48 ++ .../entries/wri-global-power-plants.json | 65 ++ src/library/entries/wv-statewide-imagery.json | 49 ++ src/library/extent.js | 104 +++ src/library/extent.test.mjs | 75 ++ src/library/formats.js | 453 ++++++++++ src/library/formats.test.mjs | 282 ++++++ src/library/layers.js | 800 ++++++++++++++++++ src/library/ledger.json | 105 +++ src/library/manifest.js | 771 +++++++++++++++++ src/library/manifest.test.mjs | 242 ++++++ src/library/outlines.js | 117 +++ src/library/outlines.test.mjs | 66 ++ src/library/parquet.js | 246 ++++++ src/library/parquet.test.mjs | 121 +++ src/library/relief.js | 109 +++ src/library/relief.test.mjs | 95 +++ src/library/selection.js | 134 +++ src/library/selection.test.mjs | 96 +++ src/library/source.js | 341 ++++++++ src/library/tileLayer.test.mjs | 135 +++ src/library/viewLayer.test.mjs | 192 +++++ src/navigationPolicy.js | 1 + src/sharelink.js | 17 + src/tooling/previewServing.test.mjs | 3 + src/ui/layerPanel.js | 36 +- src/ui/layerPanel/LayerRows.vue | 1 + src/ui/layerPanel/layerRows.test.mjs | 54 ++ src/ui/libraryBrowser.js | 604 +++++++++++++ src/ui/libraryBrowser.test.mjs | 146 ++++ src/ui/rail/RailChips.vue | 17 +- src/ui/railTestFixture.mjs | 4 + src/ui/styles/library.css | 368 ++++++++ src/ui/styles/radio.css | 24 + src/ui/styles/scenes.css | 1 + src/ui/views/LayerPanelsView.vue | 5 + style.css | 1 + 182 files changed, 15805 insertions(+), 44 deletions(-) create mode 100644 changelog.d/1604-data-library.md create mode 100644 docs/DATA-LIBRARY.md create mode 100644 scripts/library.mjs create mode 100644 scripts/qa-data-library.mjs create mode 100644 server/providers/library.js create mode 100644 server/providers/library.test.mjs create mode 100644 src/data/libraryRegistration.test.mjs create mode 100644 src/library/camera.js create mode 100644 src/library/catalog.js create mode 100644 src/library/catalog.test.mjs create mode 100644 src/library/controller.js create mode 100644 src/library/controller.test.mjs create mode 100644 src/library/entries/ak-statewide-imagery.json create mode 100644 src/library/entries/ar-statewide-imagery.json create mode 100644 src/library/entries/az-statewide-imagery.json create mode 100644 src/library/entries/bangor-me.json create mode 100644 src/library/entries/basemap-at-orthofoto.json create mode 100644 src/library/entries/bayern-dop40.json create mode 100644 src/library/entries/bismarck-mandan-nd.json create mode 100644 src/library/entries/bkg-topplus-open.json create mode 100644 src/library/entries/cctv-florida-fl511.json create mode 100644 src/library/entries/cctv-lithuania.json create mode 100644 src/library/entries/cctv-nyc-dot.json create mode 100644 src/library/entries/ct-statewide-imagery.json create mode 100644 src/library/entries/dakota-county-mn.json create mode 100644 src/library/entries/dc-statewide-imagery.json create mode 100644 src/library/entries/de-statewide-imagery.json create mode 100644 src/library/entries/ee-maa-amet-ortofoto.json create mode 100644 src/library/entries/eia-power-plants.json create mode 100644 src/library/entries/emodnet-bathymetry.json create mode 100644 src/library/entries/epa-superfund-sites.json create mode 100644 src/library/entries/epa-toxic-release-facilities.json create mode 100644 src/library/entries/epc-england-wales.json create mode 100644 src/library/entries/fema-usa-structures.json create mode 100644 src/library/entries/gbif-occurrence-density.json create mode 100644 src/library/entries/gdacs-disaster-alerts.json create mode 100644 src/library/entries/geneva-sitg-ortho.json create mode 100644 src/library/entries/gpw-population-density.json create mode 100644 src/library/entries/grand-forks-nd.json create mode 100644 src/library/entries/gsi-japan-map.json create mode 100644 src/library/entries/gsi-japan-photo.json create mode 100644 src/library/entries/gvp-holocene-volcanoes.json create mode 100644 src/library/entries/ia-statewide-imagery.json create mode 100644 src/library/entries/icgc-orto-catalunya.json create mode 100644 src/library/entries/id-statewide-imagery.json create mode 100644 src/library/entries/ign-orthophotos-france.json create mode 100644 src/library/entries/il-statewide-imagery.json create mode 100644 src/library/entries/imf-portwatch-chokepoints.json create mode 100644 src/library/entries/imf-portwatch-disruptions.json create mode 100644 src/library/entries/in-statewide-imagery.json create mode 100644 src/library/entries/jrc-surface-water.json create mode 100644 src/library/entries/kartverket-admin-units.json create mode 100644 src/library/entries/kartverket-cadastre.json create mode 100644 src/library/entries/kartverket-depth.json create mode 100644 src/library/entries/kartverket-hillshade.json create mode 100644 src/library/entries/kartverket-nautical-charts.json create mode 100644 src/library/entries/kartverket-terrain-relief.json create mode 100644 src/library/entries/kartverket-topo-grey.json create mode 100644 src/library/entries/kartverket-topo.json create mode 100644 src/library/entries/kartverket-toporaster.json create mode 100644 src/library/entries/ks-statewide-imagery.json create mode 100644 src/library/entries/ky-statewide-imagery.json create mode 100644 src/library/entries/lake-county-in.json create mode 100644 src/library/entries/lawrence-ks.json create mode 100644 src/library/entries/lee-county-fl.json create mode 100644 src/library/entries/loudoun-county-va.json create mode 100644 src/library/entries/louisville-lojic-ky.json create mode 100644 src/library/entries/lu-ortho-latest.json create mode 100644 src/library/entries/ma-statewide-imagery.json create mode 100644 src/library/entries/macrostrat-geology.json create mode 100644 src/library/entries/mapterhorn-terrain.json create mode 100644 src/library/entries/maris-ms.json create mode 100644 src/library/entries/md-statewide-imagery.json create mode 100644 src/library/entries/me-statewide-imagery.json create mode 100644 src/library/entries/mi-statewide-imagery.json create mode 100644 src/library/entries/mn-statewide-imagery.json create mode 100644 src/library/entries/mountain-village-co.json create mode 100644 src/library/entries/ms-statewide-imagery.json create mode 100644 src/library/entries/mt-statewide-imagery.json create mode 100644 src/library/entries/naip.json create mode 100644 src/library/entries/nasa-black-marble.json create mode 100644 src/library/entries/nasa-eonet-events.json create mode 100644 src/library/entries/nc-statewide-imagery.json create mode 100644 src/library/entries/nctcog-tx.json create mode 100644 src/library/entries/nd-statewide-imagery.json create mode 100644 src/library/entries/new-orleans-rpc-la.json create mode 100644 src/library/entries/nh-statewide-imagery.json create mode 100644 src/library/entries/nj-statewide-imagery.json create mode 100644 src/library/entries/noaa-tide-stations.json create mode 100644 src/library/entries/nrw-dop.json create mode 100644 src/library/entries/nsw-imagery.json create mode 100644 src/library/entries/ntad-national-bridges.json create mode 100644 src/library/entries/nwa-benton-washington-ar.json create mode 100644 src/library/entries/nws-active-alerts.json create mode 100644 src/library/entries/ny-statewide-imagery.json create mode 100644 src/library/entries/oakland-county-mi.json create mode 100644 src/library/entries/oh-statewide-imagery.json create mode 100644 src/library/entries/openseamap-seamarks.json create mode 100644 src/library/entries/or-statewide-imagery.json create mode 100644 src/library/entries/pa-statewide-imagery.json create mode 100644 src/library/entries/pdok-aerial-netherlands.json create mode 100644 src/library/entries/pl-ortofotomapa.json create mode 100644 src/library/entries/pnoa-ma.json create mode 100644 src/library/entries/portland-metro-or.json create mode 100644 src/library/entries/pt-dgt-ortos.json create mode 100644 src/library/entries/ri-statewide-imagery.json create mode 100644 src/library/entries/swisstopo-swissimage.json create mode 100644 src/library/entries/tx-statewide-imagery.json create mode 100644 src/library/entries/union-county-oh.json create mode 100644 src/library/entries/usgs-hydrography.json create mode 100644 src/library/entries/usgs-topo.json create mode 100644 src/library/entries/va-statewide-imagery.json create mode 100644 src/library/entries/vlaanderen-omw.json create mode 100644 src/library/entries/vt-statewide-imagery.json create mode 100644 src/library/entries/wallonie-ortho.json create mode 100644 src/library/entries/wi-statewide-imagery.json create mode 100644 src/library/entries/wri-global-power-plants.json create mode 100644 src/library/entries/wv-statewide-imagery.json create mode 100644 src/library/extent.js create mode 100644 src/library/extent.test.mjs create mode 100644 src/library/formats.js create mode 100644 src/library/formats.test.mjs create mode 100644 src/library/layers.js create mode 100644 src/library/ledger.json create mode 100644 src/library/manifest.js create mode 100644 src/library/manifest.test.mjs create mode 100644 src/library/outlines.js create mode 100644 src/library/outlines.test.mjs create mode 100644 src/library/parquet.js create mode 100644 src/library/parquet.test.mjs create mode 100644 src/library/relief.js create mode 100644 src/library/relief.test.mjs create mode 100644 src/library/selection.js create mode 100644 src/library/selection.test.mjs create mode 100644 src/library/source.js create mode 100644 src/library/tileLayer.test.mjs create mode 100644 src/library/viewLayer.test.mjs create mode 100644 src/ui/libraryBrowser.js create mode 100644 src/ui/libraryBrowser.test.mjs create mode 100644 src/ui/styles/library.css diff --git a/.env.example b/.env.example index 18b7c759e..2caf21d29 100644 --- a/.env.example +++ b/.env.example @@ -280,6 +280,11 @@ AISSTREAM_API_KEY= # CCTV_PREFER_AUSTIN=1 # prioritize Austin cameras in the registry # CCTV_FORCE_AUSTIN= # force live default sources when a source-pack JSON is configured +# Optional: Data Library (DATA LAYERS ▸ Browse data library). Every entry is +# fetched live from its provider; nothing is hosted here. See docs/DATA-LIBRARY.md. +# GEV_LIBRARY_ENABLE= # comma-separated ids of operator opt-in entries to serve (packs use their host's own switch, e.g. CCTV_NYC_ENABLED) +# GEV_LIBRARY_DISABLE= # comma-separated ids to remove from this instance entirely + # Optional: comma-separated Overpass URLs; use only instances you run or pay for. # Also enables voice place search (osm_query, "hospitals in Kathmandu"). OVERPASS_UPSTREAMS= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 63d1eb29b..541af1136 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,6 +53,10 @@ jobs: if: github.event_name == 'pull_request' run: npm run layer-token:check -- --base-ref origin/main + - name: Check Data Library manifests and share tokens + if: github.event_name == 'pull_request' + run: npm run library:check -- --base-ref origin/main + - name: Run unit tests run: npm test diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 201763748..5ef1d7f8e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -129,6 +129,7 @@ prints anything; never fix that by renaming or deleting PR A's published token. The highest-leverage places to jump in: - **🌆 Add a CCTV source pack.** Austin is the reference camera source. Follow the [CCTV integration workflow](docs/CCTV_INTEGRATION.md) for source review, provider registration, proxy security, tests, and attribution. The proxy only fetches server-registered URLs — never client-supplied ones; see [SECURITY.md](SECURITY.md). +- **📚 Add a Data Library entry.** A regional or niche source that is not a core layer belongs in the Data Library: one JSON manifest pointing at a live API, provider-hosted file or tile endpoint, with its licence and attribution, and no JavaScript. See [docs/DATA-LIBRARY.md](docs/DATA-LIBRARY.md). Library entries use their own share-token ledger, not the layer tokens below. - **🛰️ Add or improve a data layer.** Layer factories live in `src/layers//`, with source, record, controller and renderer owners implementing the layer interface (`init/enable/disable/update/destroy/getStats`, optional `getDetectableObjects`/`getStats`). Use an existing layer as a template, and see [Adding a new layer](#adding-a-new-layer) for what the PR needs. - **🎙️ Extend voice control.** Voice arguments are defined in `src/voice/actionSchemas.js`, with server-side descriptions in `server/providers/openai/tools.js` and client-side execution in `src/voice/gevActions.js`. Keep the tool surface tight and the responses honest (confirm only what actually happened). - **🎨 Add a visual style.** Styles are GLSL post-process shaders in `src/styles/`. @@ -228,7 +229,7 @@ ownership and adoption process. 1. Branch off `main`. 2. Keep `npm run build`, `npm test`, and `npm run test:track` green and avoid new console errors, plus the [feature gate](#feature-regression-gates) for the area you touched. 3. If you change runtime behavior, update `docs/CURRENT-STATE.md` in the same PR, and add your changelog entry as `changelog.d/-.md` instead of editing `CHANGELOG.md` — see [changelog.d/README.md](changelog.d/README.md). Distinct filenames don't conflict; a release assembles the directory into `CHANGELOG.md`. A documentation claim that needs a regression test should assert against `docs/CURRENT-STATE.md` or `README.md`, which a PR still edits directly. -4. If you add or change a data source, update [DATA_SOURCES.md](DATA_SOURCES.md) with its license and attribution. **Don't add data you don't have the right to redistribute** — fetch it at runtime instead. +4. If you add or change a data source, update [DATA_SOURCES.md](DATA_SOURCES.md) with its license and attribution (for a Data Library entry, `npm run library:docs` writes its row). **Don't add data you don't have the right to redistribute** — fetch it at runtime instead. 5. Describe what you changed and how you verified it (screenshots welcome for anything visual). 6. If you add a new layer, fill in the new-layer section of the PR template. See [Adding a new layer](#adding-a-new-layer). diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 1398e52f4..80a03cc71 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -288,6 +288,123 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei --- + + +## Data Library entries (fetched at runtime — not stored in this repo) + +Each Data Library entry is a manifest in `src/library/entries/` pointing at a live upstream its provider hosts; see [docs/DATA-LIBRARY.md](docs/DATA-LIBRARY.md). An entry appears on the map only after a viewer adds it from **DATA LAYERS ▸ Browse data library**, and its attribution is registered in the Data attribution popover while it is in use. This table is generated from the manifests: edit the manifest, then run `npm run library:docs`. + +| Entry | How it is served | Licence / terms | Attribution | +| --- | --- | --- | --- | +| **Alaska Statewide Imagery (2023)** (`ak-statewide-imagery`, `apps.geo.fpac.usda.gov`) | tiles, bbox, browser-direct | [Public Domain (U.S. Government work)](https://www.usgs.gov/information-policies-and-instructions/copyrights-and-credits) | "USDA / Alaska Geospatial Council" | +| **Arkansas Statewide Imagery (2023)** (`ar-statewide-imagery`, `gis.arkansas.gov`) | tiles, bbox, browser-direct | [Public domain — Arkansas GIS Office open data](https://gis.arkansas.gov/) | "Arkansas GIS Office" | +| **Arizona Statewide Imagery (2017)** (`az-statewide-imagery`, `azgeo.az.gov`) | tiles, xyz, browser-direct; operator adds `az-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open state geodata — attribution requested](https://azgeo.az.gov/) | "AZGEO" | +| **Bangor, Maine Orthoimagery** (`bangor-me`, `tiles.arcgis.com`) | tiles, xyz, browser-direct; operator adds `bangor-me` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://www.bangormaine.gov/) | "City of Bangor GIS" | +| **basemap.at Orthofoto** (`basemap-at-orthofoto`, `mapsneu.wien.gv.at`) | tiles, xyz, browser-direct | [CC BY 4.0](https://basemap.at/nutzungsbedingungen/) | "basemap.at — Datenquelle: basemap.at" | +| **Digitales Orthophoto Bayern (DOP 40 cm, open)** (`bayern-dop40`, `geoservices.bayern.de`) | tiles, bbox, browser-direct | [CC BY 4.0](https://geodaten.bayern.de/opengeodata/) | "© Bayerische Vermessungsverwaltung" | +| **Bismarck–Mandan, North Dakota Aerial Photography** (`bismarck-mandan-nd`, `ndgishub.nd.gov`) | tiles, bbox, browser-direct; operator adds `bismarck-mandan-nd` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://www.gis.nd.gov/) | "95West Aerial Mapping" | +| **TopPlusOpen basemap (BKG)** (`bkg-topplus-open`, `sgx.geodatenzentrum.de`) | tiles, xyz, browser-direct | [Datenlizenz Deutschland – Namensnennung 2.0](https://www.govdata.de/dl-de/by-2-0) | "© BKG, dl-de/by-2-0" | +| **Florida cameras (FL511)** (`cctv-florida-fl511`, `fl511.com`) | CCTV pack; operator sets `CCTV_FLORIDA_ENABLED=1` | [FL511 content: individual personal use only](https://fl511.com/privacy#disclaimer) | "Florida DOT — FL511.com" | +| **Lithuania road cameras (Via Lietuva)** (`cctv-lithuania`, `eismoinfo.lt`) | CCTV pack; operator sets `CCTV_LITHUANIA_ENABLED=1` | [Via Lietuva open-data statement; camera photo coverage unconfirmed](https://eismoinfo.lt/eismoinfo-backend/content?id=44) | "Via Lietuva / eismoinfo.lt" | +| **New York City cameras (NYC DOT TMC)** (`cctv-nyc-dot`, `webcams.nyctmc.org`) | CCTV pack; operator sets `CCTV_NYC_ENABLED=1` | [NYC DOT camera feed: data-sharing agreement required](https://www.nyc.gov/html/dot/html/about/datafeeds.shtml) | "NYC DOT Traffic Management Center" | +| **Connecticut Statewide Imagery (2019)** (`ct-statewide-imagery`, `cteco.uconn.edu`) | tiles, bbox, browser-direct; operator adds `ct-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open state geodata — attribution requested](https://ct-deep-gis-open-data-website-ctdeep.hub.arcgis.com/) | "CT ECO — UConn CLEAR / CT DEEP" | +| **Dakota County, Minnesota Air Photo** (`dakota-county-mn`, `gisimg.co.dakota.mn.us`) | tiles, bbox, browser-direct; operator adds `dakota-county-mn` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://gis.co.dakota.mn.us/) | "Dakota County GIS" | +| **District of Columbia Statewide Imagery (2025)** (`dc-statewide-imagery`, `imagery.dcgis.dc.gov`) | tiles, bbox, browser-direct | [Open data — DC public domain terms](https://opendata.dc.gov/pages/terms-of-use) | "Office of the Chief Technology Officer" | +| **Delaware Statewide Imagery (2024)** (`de-statewide-imagery`, `imagery.firstmap.delaware.gov`) | tiles, bbox, browser-direct; operator adds `de-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open state geodata — attribution requested](https://firstmap.delaware.gov/) | "This data is meant for use by requested parties. This data cannot be used without express consent.This data is meant for use by requested parties. This data cannot be used without express consent." | +| **Eesti ortofoto (Maa-amet)** (`ee-maa-amet-ortofoto`, `tiles.maaamet.ee`) | tiles, xyz, browser-direct | [Maa-amet open data licence (attribution)](https://geoportaal.maaamet.ee/eng/Spatial-Data/Orthophotos-p309.html) | "© Maa-amet (Estonian Land Board)" | +| **US power plants, 100 MW and up (EIA)** (`eia-power-plants`, `services2.arcgis.com`) | dataset, arcgis, browser-direct | [U.S. Energy Information Administration data: public domain](https://www.eia.gov/about/copyrights_reuse.php) | "U.S. Energy Information Administration (EIA-860, EIA-860M, EIA-923)" | +| **European seabed depth (EMODnet)** (`emodnet-bathymetry`, `tiles.emodnet-bathymetry.eu`) | tiles, xyz, browser-direct | [EMODnet data: free use with attribution](https://emodnet.ec.europa.eu/en/emodnet-data-policy) | "EMODnet Bathymetry Consortium — EMODnet Digital Bathymetry (DTM)" | +| **Superfund sites (US EPA)** (`epa-superfund-sites`, `geopub.epa.gov`) | dataset, arcgis, browser-direct | [U.S. EPA data: public domain](https://www.epa.gov/web-policies-and-procedures/epa-disclaimers) | "U.S. Environmental Protection Agency — Facility Registry Service (EnviroMapper)" | +| **Toxic release facilities (US EPA TRI)** (`epa-toxic-release-facilities`, `geopub.epa.gov`) | dataset, arcgis, browser-direct | [U.S. EPA data: public domain](https://www.epa.gov/web-policies-and-procedures/epa-disclaimers) | "U.S. Environmental Protection Agency — Toxics Release Inventory via the Facility Registry Service" | +| **Home energy ratings, England and Wales (EPC)** (`epc-england-wales`, `s3.us-west-2.amazonaws.com`) | dataset, geoparquet, browser-direct | [Open Government Licence v3.0 (EPC data); OS Open UPRN under OGL](https://epc.opendatacommunities.org/docs/copyright) | "Energy Performance of Buildings Data, DLUHC; geocoded by Addresscloud with OS Open UPRN — contains OS data © Crown copyright and database right; via Source Cooperative" | +| **US building footprints (FEMA USA Structures)** (`fema-usa-structures`, `services2.arcgis.com`) | dataset, arcgis, browser-direct | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | "FEMA Response Geospatial Office and Oak Ridge National Laboratory — USA Structures" | +| **Biodiversity records (GBIF)** (`gbif-occurrence-density`, `api.gbif.org`) | tiles, xyz, browser-direct | [GBIF data use agreement (records under CC0, CC BY or CC BY-NC)](https://www.gbif.org/terms) | "GBIF.org — Global Biodiversity Information Facility" | +| **Disaster alerts (GDACS)** (`gdacs-disaster-alerts`, `www.gdacs.org`) | dataset, geojson, browser-direct | [GDACS terms of use (free, attribution required)](https://www.gdacs.org/About/termofuse.aspx) | "GDACS — Global Disaster Alert and Coordination System (UN, European Commission)" | +| **Orthophotos Canton de Genève (SITG)** (`geneva-sitg-ortho`, `ge.ch`) | tiles, bbox, browser-direct | [SITG open data — free use with attribution](https://ge.ch/sitg/media/sitg/documents/CGU_donnees_libres.pdf) | "© SITG — Etat de Genève" | +| **Population density 2020 (SEDAC GPW)** (`gpw-population-density`, `gibs.earthdata.nasa.gov`) | tiles, xyz, browser-direct | [CC BY 4.0](https://sedac.ciesin.columbia.edu/data/collection/gpw-v4/terms-of-use) | "CIESIN, Columbia University — Gridded Population of the World v4.11, via NASA GIBS" | +| **Grand Forks, North Dakota Aerial Photography** (`grand-forks-nd`, `ndgishub.nd.gov`) | tiles, bbox, browser-direct; operator adds `grand-forks-nd` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://www.gis.nd.gov/) | "Grand Forks/East Grand Forks Metropolitan Planning Organization (MPO)" | +| **Japan standard map (GSI)** (`gsi-japan-map`, `cyberjapandata.gsi.go.jp`) | tiles, xyz, browser-direct | [GSI Tiles terms (free use with attribution)](https://maps.gsi.go.jp/development/ichiran.html) | "Geospatial Information Authority of Japan (GSI Tiles)" | +| **Japan aerial photos (GSI)** (`gsi-japan-photo`, `cyberjapandata.gsi.go.jp`) | tiles, xyz, browser-direct | [GSI Tiles terms (free use with attribution)](https://maps.gsi.go.jp/development/ichiran.html) | "Geospatial Information Authority of Japan (GSI Tiles)" | +| **Holocene volcanoes (Smithsonian GVP)** (`gvp-holocene-volcanoes`, `webservices.volcano.si.edu`) | dataset, geojson, proxied | [Smithsonian Terms of Use (personal, educational, non-commercial use; cite the Global Volcanism Program)](https://www.si.edu/termsofuse) | "Global Volcanism Program, Smithsonian Institution — Volcanoes of the World" | +| **Iowa Statewide Imagery (2023)** (`ia-statewide-imagery`, `ortho.gis.iastate.edu`) | tiles, bbox, browser-direct; operator adds `ia-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open state geodata — attribution requested](https://geodata.iowa.gov/) | "USDA NAIP, Iowa State University GIS Facility" | +| **Ortofoto de Catalunya 25 cm** (`icgc-orto-catalunya`, `geoserveis.icgc.cat`) | tiles, xyz, browser-direct | [CC BY 4.0](https://www.icgc.cat/en/Ambits-tematics/Ortofoto) | "© Institut Cartogràfic i Geològic de Catalunya" | +| **Idaho Statewide Imagery (2023)** (`id-statewide-imagery`, `insideidaho.org`) | tiles, bbox, browser-direct | [Public domain / open — INSIDE Idaho terms](https://insideidaho.org/) | "United States Department of Agriculture, Farm Production and Conservation Business Center, Geospatial Enterprise Operations (FPAC-BC-GEO)" | +| **France aerial photos (IGN)** (`ign-orthophotos-france`, `data.geopf.fr`) | tiles, xyz, browser-direct | [Licence Ouverte / Etalab 2.0](https://www.etalab.gouv.fr/licence-ouverte-open-licence/) | "© IGN — Géoplateforme" | +| **Illinois Statewide Imagery** (`il-statewide-imagery`, `data.isgs.illinois.edu`) | tiles, bbox, browser-direct; operator adds `il-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open state geodata — attribution requested](https://clearinghouse.isgs.illinois.edu/) | "Illinois State Geological Survey / IDOT" | +| **Maritime chokepoints (IMF PortWatch)** (`imf-portwatch-chokepoints`, `services9.arcgis.com`) | dataset, arcgis, browser-direct; operator adds `imf-portwatch-chokepoints` to `GEV_LIBRARY_ENABLE` | [IMF PortWatch terms; attribution required (reuse terms under review)](https://www.imf.org/external/terms.htm) | "IMF PortWatch (portwatch.imf.org)" | +| **Port disruptions (IMF PortWatch)** (`imf-portwatch-disruptions`, `services9.arcgis.com`) | dataset, arcgis, browser-direct; operator adds `imf-portwatch-disruptions` to `GEV_LIBRARY_ENABLE` | [IMF PortWatch terms; attribution required (reuse terms under review)](https://www.imf.org/external/terms.htm) | "IMF PortWatch (portwatch.imf.org), with GDACS event data" | +| **Indiana Statewide Imagery (2016)** (`in-statewide-imagery`, `di-ingov.img.arcgis.com`) | tiles, bbox, browser-direct | [CC0 1.0 Universal (public domain dedication)](https://creativecommons.org/publicdomain/zero/1.0/legalcode) | "Indiana Geographic Information Office (CC0)" | +| **Surface water occurrence (JRC)** (`jrc-surface-water`, `storage.googleapis.com`) | tiles, xyz, browser-direct | [Copernicus programme: free, without restriction of use](https://global-surface-water.appspot.com/download) | "Source: EC JRC/Google — Pekel et al. (2016), Global Surface Water" | +| **Norway counties and municipalities (Kartverket)** (`kartverket-admin-units`, `wms.geonorge.no`) | tiles, bbox, browser-direct | [CC BY 4.0 (Kartverket free products)](https://www.kartverket.no/api-og-data/vilkar-for-bruk) | "© Kartverket" | +| **Norway cadastral map (Kartverket)** (`kartverket-cadastre`, `wms.geonorge.no`) | tiles, bbox, browser-direct | [CC BY 4.0 (Kartverket free products)](https://www.kartverket.no/api-og-data/vilkar-for-bruk) | "© Kartverket" | +| **Norway sea depths (Kartverket)** (`kartverket-depth`, `wms.geonorge.no`) | tiles, bbox, browser-direct | [CC BY 4.0 (Kartverket free products)](https://www.kartverket.no/api-og-data/vilkar-for-bruk) | "© Kartverket" | +| **Norway hillshade (Kartverket)** (`kartverket-hillshade`, `wms.geonorge.no`) | tiles, bbox, browser-direct | [CC BY 4.0 (Kartverket free products)](https://www.kartverket.no/api-og-data/vilkar-for-bruk) | "© Kartverket" | +| **Norway nautical charts (Kartverket)** (`kartverket-nautical-charts`, `cache.kartverket.no`) | tiles, xyz, browser-direct | [CC BY 4.0 (Kartverket free products)](https://www.kartverket.no/api-og-data/vilkar-for-bruk) | "© Kartverket" | +| **Norway terrain relief (Kartverket DTM)** (`kartverket-terrain-relief`, `wms.geonorge.no`) | tiles, bbox, browser-direct | [CC BY 4.0 (Kartverket free products)](https://www.kartverket.no/api-og-data/vilkar-for-bruk) | "© Kartverket" | +| **Norway topographic map (Kartverket)** (`kartverket-topo`, `cache.kartverket.no`) | tiles, xyz, browser-direct | [CC BY 4.0 (Kartverket free products)](https://www.kartverket.no/api-og-data/vilkar-for-bruk) | "© Kartverket" | +| **Norway topographic map, greyscale (Kartverket)** (`kartverket-topo-grey`, `cache.kartverket.no`) | tiles, xyz, browser-direct | [CC BY 4.0 (Kartverket free products)](https://www.kartverket.no/api-og-data/vilkar-for-bruk) | "© Kartverket" | +| **Norway topographic raster map (Kartverket)** (`kartverket-toporaster`, `cache.kartverket.no`) | tiles, xyz, browser-direct | [CC BY 4.0 (Kartverket free products)](https://www.kartverket.no/api-og-data/vilkar-for-bruk) | "© Kartverket" | +| **Kansas Statewide Imagery (2021)** (`ks-statewide-imagery`, `services.kgs.ku.edu`) | tiles, bbox, browser-direct; operator adds `ks-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open state geodata — attribution requested](https://www.kansasgis.org/) | "FSA, USDA," | +| **Kentucky Statewide Imagery** (`ky-statewide-imagery`, `kyraster.ky.gov`) | tiles, bbox, browser-direct | [Public domain with attribution](https://kygeonet.ky.gov/) | "KyFromAbove" | +| **Lake County, Indiana Aerial Imagery** (`lake-county-in`, `tiles.arcgis.com`) | tiles, xyz, browser-direct; operator adds `lake-county-in` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://www.lakecountyin.org/) | "Lake County Surveyor's Office" | +| **Lawrence, Kansas Aerial Photos** (`lawrence-ks`, `tiles.arcgis.com`) | tiles, xyz, browser-direct; operator adds `lawrence-ks` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://gis-lawrenceks.opendata.arcgis.com/) | "City of Lawrence GIS" | +| **Lee County, Florida Aerial Imagery** (`lee-county-fl`, `gisimageserver.leegov.com`) | tiles, bbox, browser-direct; operator adds `lee-county-fl` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://www.leegov.com/gis) | "Eagle View, Lee County Property Appraiser, Lee County GIS" | +| **Loudoun County, Virginia Imagery** (`loudoun-county-va`, `logis.loudoun.gov`) | tiles, bbox, browser-direct; operator adds `loudoun-county-va` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://geohub-loudoungis.opendata.arcgis.com/) | "Loudoun County GIS" | +| **Louisville / LOJIC, Kentucky Imagery** (`louisville-lojic-ky`, `services.lojic.org`) | tiles, bbox, browser-direct; operator adds `louisville-lojic-ky` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://www.lojic.org/) | "LOJIC, USDA-NAIP" | +| **Orthophoto officielle du Luxembourg** (`lu-ortho-latest`, `wmts1.geoportail.lu`) | tiles, xyz, browser-direct | [CC0 1.0 (Licence ouverte Luxembourg)](https://data.public.lu/en/datasets/orthophoto-officelle-du-grand-duche-de-luxembourg-edition-2023/) | "Administration du cadastre et de la topographie, Luxembourg (CC0)" | +| **Massachusetts Statewide Imagery** (`ma-statewide-imagery`, `tiles.arcgis.com`) | tiles, xyz, browser-direct; operator adds `ma-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open state geodata — attribution requested](https://gis.data.mass.gov/) | "MassGIS, Commonwealth of Massachusetts" | +| **Geologic map (Macrostrat)** (`macrostrat-geology`, `tiles.macrostrat.org`) | tiles, xyz, browser-direct | [CC BY 4.0 (Macrostrat; source maps credited by their publishers)](https://macrostrat.org/about) | "Macrostrat — geologic maps from their original publishers" | +| **Global terrain shaded relief (Mapterhorn)** (`mapterhorn-terrain`, `tiles.mapterhorn.com`) | tiles, xyz, browser-direct | [Open data, per source (mostly CC BY 4.0 and public domain)](https://mapterhorn.com/attribution) | "© Mapterhorn and its open elevation sources" | +| **MARIS Imagery, Mississippi** (`maris-ms`, `tiles.arcgis.com`) | tiles, xyz, browser-direct; operator adds `maris-ms` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://www.gis.ms.gov/) | "MARIS" | +| **Maryland Statewide Imagery** (`md-statewide-imagery`, `mdgeodata.md.gov`) | tiles, bbox, browser-direct; operator adds `md-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open state geodata — attribution requested](https://imap.maryland.gov/) | "Maryland iMAP" | +| **Maine Statewide Imagery** (`me-statewide-imagery`, `gis.maine.gov`) | tiles, bbox, browser-direct; operator adds `me-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open state geodata — attribution requested](https://www.maine.gov/geolib/) | "USDA Aerial Photography Field Office" | +| **Michigan Statewide Imagery (2024)** (`mi-statewide-imagery`, `imagery.michigan.gov`) | tiles, bbox, browser-direct; operator adds `mi-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open state geodata — attribution requested](https://gis-michigan.opendata.arcgis.com/) | "State of Michigan" | +| **Minnesota Statewide Imagery** (`mn-statewide-imagery`, `imageserver.gisdata.mn.gov`) | tiles, bbox, browser-direct | [Public domain / open — MnGeo terms](https://www.mngeo.state.mn.us/) | "MnGeo / State of Minnesota" | +| **Mountain Village, Colorado Orthoimagery** (`mountain-village-co`, `tiles.arcgis.com`) | tiles, xyz, browser-direct; operator adds `mountain-village-co` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://www.townofmountainvillage.com/) | "Town of Mountain Village" | +| **Mississippi Statewide Imagery** (`ms-statewide-imagery`, `tiles.arcgis.com`) | tiles, xyz, browser-direct; operator adds `ms-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open state geodata — attribution requested](https://www.gis.ms.gov/) | "USDA-FSA-APFO" | +| **Montana Statewide Imagery (2023)** (`mt-statewide-imagery`, `gisservice.mt.gov`) | tiles, bbox, browser-direct | [Public domain — Montana State Library open data](https://msl.mt.gov/geoinfo/) | "Montana State Library" | +| **NAIP — National Agriculture Imagery Program** (`naip`, `imagery.nationalmap.gov`) | tiles, bbox, browser-direct | [Public Domain (U.S. Government work)](https://naip-usdaonline.hub.arcgis.com/) | "USDA NAIP" | +| **Earth at night (NASA Black Marble 2016)** (`nasa-black-marble`, `gibs.earthdata.nasa.gov`) | tiles, xyz, browser-direct | [NASA data: U.S. public domain; no endorsement implied](https://www.earthdata.nasa.gov/engage/open-data-services-software-policies) | "NASA Earth Observatory / GIBS — VIIRS Black Marble" | +| **NASA EONET open natural events** (`nasa-eonet-events`, `eonet.gsfc.nasa.gov`) | dataset, geojson, browser-direct | [NASA data: U.S. public domain; no endorsement implied](https://www.nasa.gov/nasa-brand-center/images-and-media/) | "NASA Earth Observatory Natural Event Tracker (EONET)" | +| **North Carolina Statewide Imagery (2023)** (`nc-statewide-imagery`, `services.nconemap.gov`) | tiles, bbox, browser-direct | [Public domain — NC OneMap terms of use](https://www.nconemap.gov/pages/terms) | "NC911, NCCGIA" | +| **North Central Texas (NCTCOG) Ortho** (`nctcog-tx`, `mapitimage.tarrantcounty.com`) | tiles, bbox, browser-direct; operator adds `nctcog-tx` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://www.nctcog.org/) | "North Central Texas Council of Governments" | +| **North Dakota Statewide Imagery (2013)** (`nd-statewide-imagery`, `ndgishub.nd.gov`) | tiles, xyz, browser-direct; operator adds `nd-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open state geodata — attribution requested](https://www.gis.nd.gov/) | "ND Dept. of Transportation, ND Dept. of Emergency Services, and the ND GIS Hub" | +| **New Orleans Regional Planning Commission Imagery** (`new-orleans-rpc-la`, `tiles.arcgis.com`) | tiles, xyz, browser-direct; operator adds `new-orleans-rpc-la` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://www.norpc.org/) | "Regional Planning Commission for Jefferson, Orleans, Plaquemines, St. Bernard, St. Charles, St. John the Baptist, St. Tammany, and Tangipahoa Parishes (RPC)" | +| **New Hampshire Statewide Imagery (2022)** (`nh-statewide-imagery`, `nhgeodata.unh.edu`) | tiles, bbox, browser-direct; operator adds `nh-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open — NH GRANIT terms](https://www.granit.unh.edu/) | "NH GRANIT, University of New Hampshire" | +| **New Jersey Statewide Imagery (2015)** (`nj-statewide-imagery`, `maps.nj.gov`) | tiles, xyz, browser-direct; operator adds `nj-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open — State of New Jersey provides data 'as is'](https://njgin.nj.gov/) | "Project definition, funding requests, and data distribution by NJ Office of Information Technology, Office of Geographic Information Systems (NJOIT - OGIS). Funding for the imagery project provided by" | +| **US tide stations (NOAA CO-OPS)** (`noaa-tide-stations`, `api.tidesandcurrents.noaa.gov`) | dataset, json, browser-direct | [NOAA data: U.S. public domain](https://tidesandcurrents.noaa.gov/disclaimers.html) | "NOAA Center for Operational Oceanographic Products and Services (CO-OPS)" | +| **Digitale Orthophotos NRW (DOP 10 cm)** (`nrw-dop`, `www.wms.nrw.de`) | tiles, bbox, browser-direct | [Datenlizenz Deutschland – Zero (public-domain-like)](https://www.govdata.de/dl-de/zero-2-0) | "Geobasis NRW (dl-de/zero-2-0)" | +| **NSW Imagery (Six Maps)** (`nsw-imagery`, `maps.six.nsw.gov.au`) | tiles, xyz, browser-direct | [CC BY 4.0](https://www.spatial.nsw.gov.au/products_and_services/web_services) | "© Spatial Services NSW" | +| **US bridges and their condition (NBI)** (`ntad-national-bridges`, `services.arcgis.com`) | dataset, arcgis, browser-direct | [U.S. government work: unrestricted public use](https://www.bts.gov/ntad) | "USDOT Bureau of Transportation Statistics — National Transportation Atlas Database, National Bridge Inventory" | +| **Northwest Arkansas (Benton & Washington County) Imagery** (`nwa-benton-washington-ar`, `tiles.arcgis.com`) | tiles, xyz, browser-direct; operator adds `nwa-benton-washington-ar` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://www.nwarpc.org/) | "NWARPC; Washington County; Benton County" | +| **US weather alerts with drawn areas (NWS)** (`nws-active-alerts`, `api.weather.gov`) | dataset, geojson, browser-direct | [NWS data: U.S. public domain](https://www.weather.gov/disclaimer) | "NOAA National Weather Service" | +| **New York Statewide Imagery (2019)** (`ny-statewide-imagery`, `orthos.its.ny.gov`) | tiles, xyz, browser-direct | [Public domain — NYS open data](https://data.gis.ny.gov/) | "USDA; NYS ITS Geospatial Services" | +| **Oakland County, Michigan Ortho Imagery** (`oakland-county-mi`, `gisservices.oakgov.com`) | tiles, bbox, browser-direct; operator adds `oakland-county-mi` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://oakgov.maps.arcgis.com/) | "Oakland County GIS" | +| **Ohio Statewide Imagery** (`oh-statewide-imagery`, `maps.ohio.gov`) | tiles, xyz, browser-direct | [Public domain — Ohio open data](https://maps.ohio.gov/) | "Ohio Geographically Referenced Information Program (OGRIP)" | +| **Seamarks (OpenSeaMap)** (`openseamap-seamarks`, `tiles.openseamap.org`) | tiles, xyz, browser-direct | [CC BY-SA 2.0; data © OpenStreetMap contributors (ODbL)](https://openseamap.org/index.php?id=imprint&L=1) | "© OpenSeaMap contributors, CC BY-SA; data © OpenStreetMap contributors" | +| **Oregon Statewide Imagery (2000)** (`or-statewide-imagery`, `imagery.oregonexplorer.info`) | tiles, bbox, browser-direct | [Public domain — Oregon open data](https://www.oregon.gov/geo/) | "Oregon, GEO, DAS" | +| **Pennsylvania Statewide Imagery** (`pa-statewide-imagery`, `imagery.pasda.psu.edu`) | tiles, bbox, browser-direct | [Public domain — PASDA open data](https://www.pasda.psu.edu/) | "PASDA / PAMAP, Commonwealth of Pennsylvania" | +| **Netherlands aerial photos (PDOK)** (`pdok-aerial-netherlands`, `service.pdok.nl`) | tiles, xyz, browser-direct | [CC BY 4.0](https://www.pdok.nl/introductie/-/article/luchtfoto-pdok) | "Beeldmateriaal Nederland via PDOK" | +| **Ortofotomapa Polski (GUGiK)** (`pl-ortofotomapa`, `mapy.geoportal.gov.pl`) | tiles, bbox, browser-direct | [Polish open geodata (free reuse, Prawo geodezyjne art. 40a)](https://www.geoportal.gov.pl/) | "© GUGiK / geoportal.gov.pl" | +| **PNOA Máxima Actualidad** (`pnoa-ma`, `www.ign.es`) | tiles, bbox, browser-direct | [CC BY 4.0 (compatible) — Orden FOM/2807/2015](https://www.scne.es/productos.html) | "PNOA © Instituto Geográfico Nacional de España" | +| **Portland Metro (RLIS), Oregon Aerial Photos** (`portland-metro-or`, `utility.arcgis.com`) | tiles, xyz, browser-direct; operator adds `portland-metro-or` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://rlisdiscovery.oregonmetro.gov/) | "Oregon Metro RLIS" | +| **Ortofotos de Portugal Continental (DGT)** (`pt-dgt-ortos`, `ortos.dgterritorio.gov.pt`) | tiles, bbox, browser-direct | [CC BY 4.0](https://www.dgterritorio.gov.pt/) | "© Direção-Geral do Território" | +| **Rhode Island Statewide Imagery (2019)** (`ri-statewide-imagery`, `tiles.arcgis.com`) | tiles, xyz, browser-direct; operator adds `ri-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open — RIGIS terms](https://www.rigis.org/) | "URI EDC, RIGIS" | +| **Switzerland aerial photos (SWISSIMAGE 10 cm)** (`swisstopo-swissimage`, `wmts.geo.admin.ch`) | tiles, xyz, browser-direct | [swisstopo free geodata (OGD): free use, including commercial, with source credit](https://www.swisstopo.admin.ch/en/terms-of-use-free-geodata-and-geoservices) | "©swisstopo — SWISSIMAGE 10 cm" | +| **Texas Statewide Imagery (2022)** (`tx-statewide-imagery`, `imagery.geographic.texas.gov`) | tiles, bbox, browser-direct | [Public domain — TxGIO open data](https://geographic.texas.gov/) | "Texas Geographic Information Office (TxGIO)" | +| **Union County, Ohio Aerial Imagery** (`union-county-oh`, `www7.co.union.oh.us`) | tiles, bbox, browser-direct; operator adds `union-county-oh` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://union-county-auditor-unionco.hub.arcgis.com/) | "Union County / City of Marysville GIS" | +| **US rivers and lakes (USGS)** (`usgs-hydrography`, `basemap.nationalmap.gov`) | tiles, xyz, browser-direct | [USGS The National Map: U.S. public domain](https://www.usgs.gov/information-policies-and-instructions/copyrights-and-credits) | "USGS The National Map — National Hydrography Dataset" | +| **US topographic map (USGS)** (`usgs-topo`, `basemap.nationalmap.gov`) | tiles, xyz, browser-direct | [USGS The National Map: U.S. public domain](https://www.usgs.gov/information-policies-and-instructions/copyrights-and-credits) | "USGS The National Map" | +| **Virginia Statewide Imagery** (`va-statewide-imagery`, `vginmaps.vdem.virginia.gov`) | tiles, xyz, browser-direct; operator adds `va-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open — VGIN VBMP terms](https://vgin.vdem.virginia.gov/) | "Virginia Geographic Information Network (VGIN)" | +| **Orthofotomozaïek Vlaanderen (winter)** (`vlaanderen-omw`, `geo.api.vlaanderen.be`) | tiles, bbox, browser-direct | [Vlaanderen model licence for free reuse](https://www.vlaanderen.be/digitaal-vlaanderen/onze-oplossingen/orthofotomozaieken) | "© Digitaal Vlaanderen" | +| **Vermont Statewide Imagery** (`vt-statewide-imagery`, `maps.vcgi.vermont.gov`) | tiles, bbox, browser-direct | [Public domain — Vermont open data](https://vcgi.vermont.gov/) | "Vermont Center for Geographic Information (VCGI)" | +| **Orthophotos de Wallonie** (`wallonie-ortho`, `geoservices.wallonie.be`) | tiles, bbox, browser-direct | [Conditions ouvertes SPW (attribution)](https://geoportail.wallonie.be/catalogue-donnees) | "© Service public de Wallonie" | +| **Wisconsin Statewide Imagery (2020)** (`wi-statewide-imagery`, `di-wisdot.img.arcgis.com`) | tiles, bbox, browser-direct; operator adds `wi-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open — WisconsinView / state open data](https://www.wisconsinview.org/) | "WisDOT - NAIP 2020" | +| **Global power plants (WRI)** (`wri-global-power-plants`, `raw.githubusercontent.com`) | dataset, csv, browser-direct | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | "World Resources Institute, Global Power Plant Database (2021)" | +| **West Virginia Statewide Imagery** (`wv-statewide-imagery`, `services.wvgis.wvu.edu`) | tiles, bbox, browser-direct; operator adds `wv-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open — WV GIS Technical Center terms](https://wvgis.wvu.edu/) | "WV GIS Technical Center, West Virginia University" | + + + ## Bundled snapshots Static datasets shipped in the repo for an out-of-the-box experience. **None are MIT** — each keeps its own license (see the carve-out in [LICENSE](LICENSE)). Each folder also has its own provenance README. diff --git a/SECURITY.md b/SECURITY.md index 7b57e5a6d..e67186d64 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -73,6 +73,7 @@ The data proxies under `server/providers/` are written so the browser cannot tur - **Radio is not an audio relay.** `/api/radio/stations` contacts only allowlisted Radio Browser HTTPS hosts and paths, rejects redirects, rejects any hostname with a loopback/private/link-local/metadata/non-public A or AAAA result, and pins each TLS connection to a validated address. It returns normalized public HTTPS stream URLs; `/api/radio/click/:uuid` applies the same destination policy and accepts only station IDs from the current bounded catalog. The browser then connects directly to the broadcaster after an explicit playback action, so the broadcaster sees the listener's IP address. GEV never proxies, caches, records, or redistributes audio. - **Québec 511 video plays in the browser, opt-in and after Play.** With `CCTV_QUEBEC_VIDEO_ENABLED=1`, `/api/cctv/sources` lists, for each MTQ camera, one URL built server-side from its numeric id on `https://www.quebec511.info/Carte/Fenetres/camera.ashx`; the overlay accepts no other host, path or format. The browser loads that clip only after the viewer presses Play for the selected camera, so Québec 511 sees the viewer's IP address, and the panel says so before Play. Selecting a camera by click, auto-hop, a scene or voice never loads it, and switching cameras stops playback. GEV never proxies, caches or reads the video's pixels. With the flag unset (the default) the browser makes no request to Québec 511. - **No verbatim client headers upstream.** The CCTV media route is the one route that relays a request header (`Range`, for video seeking). It is parsed and canonicalized before it is forwarded: one `bytes=` range only, with every accepted form — explicit span, open-ended and suffix — bounded to the same 64 MiB ceiling the relay applies to a declared response body. Multi-range, malformed, inverted and non-`bytes` values are dropped and the request proceeds without a `Range`, as RFC 7233 §3.1 prescribes. Bounding the request bounds what is asked for: a response that declares no length — live streamed media, or a chunked body from an upstream that ignores the `Range` — has no ceiling. An upstream request the browser has stopped waiting for is cancelled rather than left running, whether the viewer leaves before the headers arrive or during the body. +- **The Data Library proxy fetches manifest upstreams only.** `/api/library/data/`, `/api/library/tile////` and `/api/library/index/` (a partitioned GeoParquet entry's file index, built from an S3 ListObjectsV2 listing and one ranged footer read per file, cached in memory for the entry's `cacheSeconds`) resolve the id against the validated manifests in `src/library/entries/`; the browser never supplies a URL, and every derived request must stay on the manifest's https host. Redirects are refused, dataset bodies are capped while streaming at the manifest's `maxBytes` (at most 32 MiB) and tiles at 2 MiB of `image/png`, `image/jpeg` or `image/webp`, responses are cached in memory only (at most 512 tiles), requests are rate-limited per client, and upstream error text is never returned. A keyed entry must be proxied; its key is read from this server's environment through the key registry and its routes refuse cross-site and forwarded requests like the other cost-bearing routes. Browser-direct entries are fetched by the browser with no cookies or referrer. - **Transit fetches registered feeds only.** `/api/transit/vehicles/` resolves the id against `src/data/transitFeeds.js`; the browser never supplies a URL, and a feed that is registered but disabled does not resolve at all. Redirects are followed manually and each hop is validated against the feed's own https origin before it is requested, so an off-origin or downgraded hop is refused rather than contacted. Bodies are capped at 8 MB, the protobuf is decoded server-side under entity-count and string-length ceilings, a differential feed is refused, and snapshots live 15 s in memory with no disk cache. A per-feed admission limiter and a failure cooldown ladder bound what this process can ask of any operator. - **Rail fetches registered feeds only, and none needs a credential.** `/api/rail/feeds` lists the offered feeds; `/api/rail/trains/`, `/api/rail/updates/`, `/api/rail/paths/` and `/api/rail/schedule/` resolve the id against `src/data/railFeeds.js` (`RAIL_FEED_ID_PATTERN`: lowercase letters, digits and hyphens). The browser never supplies a URL, and an id that is not registered, or a trip-updates route for a feed that registers none, does not resolve at all; any method but GET answers 405 (`Allow: GET`, never cached). Every registered feed and every static timetable is open: no rail request carries a credential of the deployer's; one feed's published URL includes a fixed public value its publisher prints for everyone (Bordeaux's `apiKey`), and the only rail setting the server reads is `RAIL_PATHS_DIR`. Upstream requests follow the Transit proxy's policy: https only, each redirect hop validated against the feed's own https origin before it is requested (three hops at most), a request timeout, bodies capped at 8 MiB unless the registry sets a feed's own cap (never above 64 MiB), and a per-feed admission limiter and the failure cooldown ladder bound what the process asks of any operator. Responses are decoded server-side: GTFS-Realtime under entity-count and string-length ceilings, a differential feed is refused, and an empty or header-less GTFS-Realtime reply (an error page served as 200, say) is refused as unreadable, so the last good snapshot is served stale or the request fails with 502. The Irish Rail and SIRI VM XML is read by small scanners, not an XML library: a document that declares a DTD or an entity is refused, and the whole text, the number of trains and every field are capped. Only vehicles the feed's classifier recognises as trains leave the server, and no train position inside Ukraine is ever sent (`src/data/railNoPositions.js`). Snapshots and trip updates live in memory for the feed's own freshness window, with serve-stale after a failure; there is no disk cache and no history. Path and schedule packs are optional build output, read from disk (`.cache/rail-paths/` or `RAIL_PATHS_DIR`) by a reader that re-checks the id against the same pattern, so a name never leaves that directory. A pack over 24 MiB is refused before it is read and answers 503, never a partial file, and so does a pack that cannot be read: only a missing pack is 404; the rest pass through as bytes, through a 64 MiB in-memory read cache keyed by each file's modification time and size, and are served as JSON holding timetables and track shapes only, `no-cache` with an ETag from that time and size (304 when unchanged). A schedule pack of another format version or another feed, or whose window has ended, is not used, and a feed that cannot be read without it answers 503 rather than an empty list. The pack build (`scripts/build-rail-paths.mjs`) runs only when a person runs it, never on app start: it downloads over https only (a redirect to plain http is refused), caps each download in size and time, sends no credential, and refuses an `--out` folder (or a `RAIL_PATHS_DIR` it writes to) inside `src/` or `public/`. - **Local receiver feeds are operator-configured and local-only.** `/api/local-receivers/aircraft` reads only the `aircraft.json` URLs in the server's `LOCAL_RECEIVER_FEEDS`; the browser never supplies an address. Each host must pass the shared tap address rule (`src/data/tapAddress.js`: loopback, RFC1918, `localhost`, `*.local`; no link-local, IPv6 or other names), the scheme must be http(s), the path must end in `aircraft.json`, and credentials, queries and fragments are refused. Invalid entries are logged at startup and never fetched. Reads use a 2 s timeout, refuse redirects, cap bodies at 2 MB and share one read per second; responses name each feed by band only and never carry upstream error text. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index ac186c25b..4ed2ea418 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -1,8 +1,8 @@ # Third-party software notices The project depends on third-party npm packages whose own licenses apply. This -file records the packages added for the browser-local SDR feature and the -Street Level viewer; the complete resolved dependency inventory remains in +file records the packages added for the browser-local SDR feature, the +Street Level viewer and the Data Library's GeoParquet reader; the complete resolved dependency inventory remains in `package-lock.json`. ## Web RTL-SDR @@ -45,3 +45,18 @@ Street Level viewer; the complete resolved dependency inventory remains in - Source: - License: MIT - License text: + +## hyparquet + +- Package: `hyparquet` 1.31.2 (loaded on demand when a Data Library + GeoParquet entry is switched on) +- Author: Hyperparam +- Source: +- License: MIT + +## fzstd (hyparquet ZSTD decompressor) + +- Package: `fzstd` 0.1.1 +- Author: Copyright (c) 2020 Arjun Barrett +- Source: +- License: MIT diff --git a/changelog.d/1604-data-library.md b/changelog.d/1604-data-library.md new file mode 100644 index 000000000..7b5b211fa --- /dev/null +++ b/changelog.d/1604-data-library.md @@ -0,0 +1,27 @@ +- Add the Data Library. New regional and niche sources no longer join the + Data Layers panel: **Browse data library** at its foot lists live entries + (a manifest each in `src/library/entries/`, fetched from the provider, never + bundled) with their licence, coverage and what this instance needs to serve + them, and **Add** puts one under a Library heading. Datasets (GeoJSON, CSV, + ArcGIS), raster tiles and host-layer packs are supported; selections persist + and travel in share links as `lib=`, separate from the layer tokens; one + generic `/api/library` proxy serves entries without CORS. Seeded with NASA + EONET events, EIA and WRI power plants, NWS alerts, NOAA tide stations, + Smithsonian volcanoes, NASA Black Marble, OpenSeaMap seamarks, and the + opt-in IMF PortWatch and CCTV packs. FEMA USA Structures and the + National Bridge Inventory load what is in view once you zoom in, and + Addresscloud's EPC ratings stream from Source Cooperative's GeoParquet on + S3, coloured A–G with live legend counts. Open tile overlays (geology, + biodiversity, population, surface water, national aerial photos and maps) + join GDACS alerts and EPA Superfund and TRI facilities. 64 aerial photo + layers from the Open Imagery Register (national programmes across Europe, + NSW, NAIP and US state, county and city services, WMS and ArcGIS export + included) show their resolution, true-ortho flag, years flown and refresh + cycle in Browse; aerial photos stay above every other drape, are clipped to + their coverage, and the providers' blank fill no longer whitens the globe. + Kartverket's open Norwegian maps (topographic, nautical charts, hillshade, + terrain relief, sea depths, boundaries and the cadastre) and Mapterhorn's + global terrain, drawn in the browser as shaded relief with an elevation + legend, are in the library too. Stat tiles head the dialog. A library layer's row has + Zoom to layer and Remove icons, and the dialog has Zoom and a confirmed + Remove all (asturksever, #1604). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index bdfefe043..c453e4f3a 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -384,6 +384,206 @@ provider registration rejects compositions that cannot fit rather than truncatin identities. A malformed or duplicate entry invalidates the complete custom-switch field. Existing whole-link size limits still apply. +## Data Library — October 21, 2026 + +DATA LAYERS keeps its curated core layers; everything else lives in the Data +Library, opened from **Browse data library** at the foot of the panel. An entry +is a manifest in `src/library/entries/` that points at a live upstream its +provider hosts — a query API, a provider-hosted static file or a tile endpoint. +Nothing an entry describes is bundled, so a self-hosted instance serves every +entry without hosting data. Manifests are validated strictly when +`src/library/catalog.js` loads (unknown fields are errors), so an invalid entry +fails the unit suite. Authoring guide: [DATA-LIBRARY.md](DATA-LIBRARY.md). + +Three kinds ship. A `dataset` (GeoJSON, GeoJSON Lines, CSV with lat/lon +columns, a JSON record list read from `source.records` with named or guessed +lat/lon fields, or a paged ArcGIS FeatureServer query) renders through the same +GeoJSON layer as Datacenters and Dams, with the manifest's label, up to two +card fields and an optional `rank` field that orders a capped set and feeds the +layer's level-of-detail priority (0–600, below the named/unnamed gap). A capped +dataset reads PARTIAL. A dataset with `source.view` is view-bounded, +for layers too large to load whole (FEMA USA Structures: ~135 million +footprints): below `view.maxAltitudeM` it queries ArcGIS for what intersects a +window around the point the camera looks at (half the camera height each way, +330 m – 5.5 km, clipped to the visible rectangle), one page per view after the +camera settles, replacing the previous set only when the new one is ready and +aborting a superseded request; above it the row reads "zoom in to load this +layer" and nothing is fetched. Footprints are ground-clamped polygons (draped on +Google 3D tiles too), without outlines. Zoom to layer lands straight above the +manifest's `view.sample` area at 60 % of the altitude limit. View-bounded +entries are ArcGIS or GeoParquet, and browser-direct, in this build. + +`geoparquet` datasets are read in the browser with `hyparquet` (with `fzstd` +for ZSTD), imported lazily on first use; only the columns an entry uses are +decoded, and WKB geometry arrives as GeoJSON. A partitioned set +(`source.partitions`: an S3 ListObjectsV2 `listUrl` and a `keyBase`, both on the +source host) must be view-bounded. Its index — each file's GeoParquet footer +bbox, read with one range request per file — comes from `/api/library/index/` +(built once per `cacheSeconds` and shared), or is built in the browser and kept +in `gev:library:index:` on a static build. Footer bboxes can be stretched +by stray geocodes, so candidates are ranked by how much of each file's bbox +the view covers, read in parallel batches of four (up to eight per view) +until a batch has features in view, and each file read once teaches its real +extent (the box holding 98 % of its points, `gev:library:extent:`), so +later views skip files that do not cover them. The last four decoded files +stay in memory. `display.colorBy` colours each feature by a field's value (a +dotted path such as `categories.0.title` is flattened into the feature's +`__gevColorKey`): view-bounded layers draw 6 px dots or filled polygons, other +datasets keep the GeoJSON layer's stems, cards and level of detail with a +per-feature colour (`featureColor` in `localGeojsonCore.js`). Values sharing a +`colorBy.labels` label form one legend group (coal, gas and oil are "Fossil"); +values the manifest does not colour draw in `display.color` and count as +Other. The row legend lists each group with its count (in view, or in the +loaded set), one item per line as Fire Perimeters' does. Status-like fields +(GDACS and PortWatch alert levels, NWS severity, bridge condition) use the +fixed status palette (good #0ca30c, warning #fab219, serious #ec835a, +critical #d03b3b); categorical ones (fuel, EONET category, building +occupancy) use at most four hues that pass all-pairs colour-vision checks on +the dark globe, plus a neutral Other. Tile layers carry a static +`display.legend` copied from the provider's own scale (GBIF classic.poly, +JRC occurrence, GIBS GPW, Macrostrat's ICS period colours); EMODnet publishes +no machine-readable scale and has none. Aerial photo layers (PDOK, IGN, GSI +photos, swisstopo SWISSIMAGE) set `display.outline`: their covered countries' +borders draw as a thin white ground-clamped line, so the imagery's edge is +plain, with a "Coverage outline" legend item. Borders come from Natural +Earth's 1:50m admin-0 countries (public domain, fetched live once per page and +credited); `countries` draws every part (Japan's islands, Liechtenstein beside +Switzerland), `coverage` only the parts inside `coverage.bbox` (the European +Netherlands, metropolitan France). A failed border fetch costs only the +outline. The same layers set `display.aerial`: they drape above every other +imagery layer and raise themselves again whenever another drape is added, so a +basemap or weather overlay switched on later goes underneath. Each is +`source.bounded` (tiles requested inside `coverage.bbox` only) and names its +provider's no-imagery fill in `display.noDataColor`, drawn transparent through +Cesium's colour-to-alpha (max channel distance 0.02): measured fills are PDOK +and swisstopo white, IGN's flat ocean blue (#0a3b59) and GSI's ocean grey +(#343434). Without both, PDOK's white fill covered the whole globe. A 4xx tile +answer counts as "no imagery here", never as a tile error. IGN's overseas +departments are outside its single bbox and are not shown. The aerial set now holds 68 +entries: 64 imported from the Open Imagery Register (national programmes in +Austria, Belgium, Germany, Estonia, Spain, Luxembourg, Poland, Portugal, +Geneva, Australia's NSW, and 51 US services: NAIP plus state, county and +city programmes) plus PDOK, IGN, GSI and SWISSIMAGE, each with an `imagery` +block (type, published resolution, true ortho, years flown, refresh) that +Browse shows as a spec list. Tiles take `xyz` (with `{reverseY}` for TMS) or +`bbox` templates (`{bbox-epsg-3857}`: WMS GetMap and ArcGIS export, filled +by Cesium in the browser and by the proxy from z/x/y). Sub-national entries +draw a `bbox` rectangle outline without fetching the country borders. 35 +entries whose licence the register gives only as "see publisher terms" are +operator opt-in. Norway comes from Kartverket's open +services (CC BY 4.0, "© Kartverket"; its zoom 12–20 Geovekst data may be shown +in live services but not copied): the topographic, greyscale, raster-topo and +nautical-chart caches (`xyz`), and WMS hillshade, DTM colour relief, sea depths +(from zoom 11, Kartverket's 16-class depth legend), county and municipality +boundaries, and the cadastre (from zoom 14). Norge i bilder needs +authentication and is not included. `mapterhorn-terrain` is the first +`source.encoding: terrarium` entry: Mapterhorn's global 512 px terrain-RGB +tiles are decoded per tile in the browser (`src/library/relief.js`) into +shaded relief with a six-class elevation legend in metres. A `tiles` entry (`{z}/{x}/{y}` template) drapes on the +active imagery host — the globe, or the Google 3D tileset — and follows map +stack switches; with no host it shows the existing "choose a globe map" +guidance, and its row reads DRAPED rather than a count. A `pack` describes an +existing host-layer source pack (today the opt-in CCTV packs): it is +informational in Browse, names the host's own switch (`CCTV_NYC_ENABLED=1` and +so on) and offers no Add. The `provider` kind from the RFC is refused by the +validator until it ships. + +Entries register their layer only when a viewer adds them, after the core +catalog is sealed: `LayerLifecycle.registerLibraryLayer` accepts only ids in +the `lib-` namespace and only after `finalizeRegistrations`, and +`unregisterLibraryLayer` destroys them. Library layers never enter +`LAYER_STATE_REGISTRY` or the core token ledger. The panel groups them under a +**Library** heading after every core group and rebuilds its rows on a +`registrations` activity. Add switches the layer on; Remove destroys it. +A library layer's row carries two icon chips, on or off (the panel shows +row controls for an off layer only for Data Library rows, whose chips are +marked `whileOff`): **Zoom to layer** +(`zoom_in_map`) flies to the extent of the loaded features, or a tiles entry's +declared coverage, taking the shorter way round the antimeridian and showing the +whole globe for world-scale sets; **Remove from Data Layers** (`delete`) +removes the entry. Zoom on a layer that is off switches it on first (a +dataset only knows its extent once loaded). Zoom runs as an immediate +navigation (`layer`), so Cockpit +refuses it with its own message and the current follow owner is released first; +reduced motion flies instantly. Icon chips (`chipGroup.js` `icon`/`action`) use +their label as accessible name and tooltip and carry no `aria-pressed`. + +This browser's selection persists in `gev:library:v1` (`{ added, on }`), written +only for explicit (user, voice, tool) changes, like core layer state, so a +Context session's temporary changes are not remembered. Share links carry the +switched-on entries as `lib=.` with the permanent two-character tokens in +`src/library/ledger.json` (append-only; packs take none). A link's entries join +the viewer's library and, as for core layers, the link alone decides what is +on. Unknown tokens are skipped. The live hash follows the existing +initial-restore suppression. + +Browser-direct entries are fetched without cookies or a referrer, redirects +refused, bodies capped while streaming. Proxied entries go through +`/api/library` (`server/providers/library.js`): `/catalog` reports each entry's +availability and reason (`operator-opt-in`, `operator-disabled`, `key-missing`) +without secrets; `/data/` and `/tile////` reach only the +manifest's fixed upstream (the browser names an id, never a URL), refuse +redirects and off-manifest hosts, cap dataset bodies at the manifest's +`maxBytes` (≤ 32 MiB) and tiles at 2 MiB of `image/png|jpeg|webp`, cache per the +manifest's `cacheSeconds` (60 s – 24 h; at most 512 tiles), serve the last good +dataset as stale on failure, rate-limit per client, and never return upstream +text. A keyed entry must be proxied; its key comes from the server environment +through the key registry and its routes use the strict cross-site gate, while +keyless routes use the keyless gate so they work behind a reverse proxy. +Operators opt entries in with `GEV_LIBRARY_ENABLE` and remove them with +`GEV_LIBRARY_DISABLE`. A static build with no server still serves every +browser-direct, non-opt-in entry; proxied ones say they need the app's server. + +The Browse dialog is a `dialog[data-panel-surface]`: modal, labelled, search +focused on open, Escape and backdrop dismiss and return focus to Browse, and it +closes when clean view, recording or Cockpit hides the panels. Its surface is +frosted glass (a lighter `--glass-bg` than the rails, a 22 px backdrop blur +clipped to the theme's frame, a barely dimmed backdrop), so the globe shows +through without the HUD text behind it competing. An added entry that is on +offers ZOOM, which closes the dialog and flies to it. Above the intro, four +stat tiles summarise the library: Datasets (every entry, broken down by kind), +Themes (how many distinct themes the entries cover), Added (and how many are on +the map), and Map features (features loaded by dataset layers that are on; +tiles have no count). +They refresh with the dialog and once a second while it is open, so counts +land as datasets finish loading. The line beside **REMOVE ALL** answers the +filter ("Showing 2 of 8"). **REMOVE ALL** removes every added entry; its first +press arms a confirm for four seconds. It lists +addable entries first, then entries this instance would need to switch on, then +packs, with kind, status, opt-in or key badges, provider, coverage and a licence +link, and filters by text, theme and "only what this instance serves". +Archived entries are hidden unless added. An entry's attribution and licence are +registered in the Data attribution popover while it is in use. + +Seeds: NASA EONET open events (GeoJSON, browser-direct), EIA US power plants +≥ 100 MW (ArcGIS, browser-direct, ranked by capacity), Smithsonian GVP Holocene +volcanoes (WFS GeoJSON, proxied — the upstream sends no CORS headers), NASA +Black Marble 2016 night lights and OpenSeaMap seamarks (tiles), and the NYC, +Lithuania and FL511 CCTV packs. Added from the Ask dataset registry (its live, +mappable sources that are not core layers): the WRI Global Power Plant Database +(provider-hosted CSV, the 10,000 largest of ~35,000 by capacity, so it reads +PARTIAL), NWS active alerts that carry their own polygon (zone-wide alerts have +no geometry and are dropped), NOAA CO-OPS tide stations (JSON records), and IMF +PortWatch chokepoints and port disruptions (ArcGIS, operator opt-in until the +reuse terms are confirmed, as Ask holds them in hosted mode). FEMA/ORNL USA +Structures (CC BY 4.0) is the first view-bounded entry; its service refuses a +query page smaller than its own 2,000 records, so the probe asks for a full +page. Addresscloud's EPC ratings for England and Wales (Source Cooperative, +read from `s3.us-west-2.amazonaws.com/us-west-2.opendata.source.coop`, 344 +local-authority GeoParquet files, OGL v3.0) are the first partitioned +GeoParquet entry, coloured by band A–G with the official EPC colours. From an +open/freemium registry review (commercial sources excluded): GDACS disaster +alerts; the National Bridge Inventory (view-bounded, coloured Good/Fair/Poor); +EPA Superfund sites and Toxics Release Inventory facilities (view-bounded); and +tile overlays for Macrostrat geology, GBIF biodiversity density, USGS +topographic and hydrography maps, PDOK (Netherlands) and IGN (France) aerial +photos, BKG TopPlusOpen, GSI Japan map and photos, EMODnet bathymetry, SEDAC +GPW 2020 population density (legend from the GIBS colour map) and JRC surface +water occurrence. `npm run library:check` validates manifests, +the ledger (append-only with `--base-ref`, run in CI on pull requests) and the +generated DATA_SOURCES.md section; `npm run library:probe` hits every upstream +once; `npm run qa:data-library` is the browser gate (fixture-backed). + ## Utilities panel — October 29, 2026 UTILITIES is its own left-side panel, between Data Layers and Scenes, with diff --git a/docs/DATA-LIBRARY.md b/docs/DATA-LIBRARY.md new file mode 100644 index 000000000..c549e7b4d --- /dev/null +++ b/docs/DATA-LIBRARY.md @@ -0,0 +1,183 @@ +# Data Library + +DATA LAYERS is the curated set of core layers. The Data Library holds +everything else: regional sources, niche themes and community contributions. +A viewer opens it from **DATA LAYERS ▸ Browse data library**, adds what they +want, and the entry appears in the panel under **Library**. Nothing in the +library is on until someone adds it. + +Runtime behaviour is described in [CURRENT-STATE.md](CURRENT-STATE.md#data-library--october-21-2026). +This page is the authoring guide. + +## The one rule: live sources only + +A library entry points at data its provider hosts. That can be a query API, a +static file at a stable URL the provider controls, or a tile endpoint. The +data is never copied into this repository. If the upstream goes away, the +entry goes stale; this project never becomes the host of record. That is what +lets every self-hosted instance serve the whole library with no data of its +own. + +It also keeps the existing rule in [CONTRIBUTING.md](../CONTRIBUTING.md): do +not add data you have no right to redistribute. A live source still needs a +licence that allows this use, and the manifest must say what it is. + +## Kinds + +| Kind | What it is | Renders through | +| --- | --- | --- | +| `dataset` | Vector features: `geojson`, `geojsonl`, `csv` (lat/lon columns), `json` (a record list with lat/lon fields), `arcgis` (a FeatureServer or MapServer layer, paged) or `geoparquet` (one file, or a partitioned set on S3) | The GeoJSON layer Datacenters and Dams use; view-bounded and GeoParquet sets draw as ground-clamped dots and polygons | +| `tiles` | Raster tiles: `xyz` with a `{z}/{x}/{y}` template (`{reverseY}` for TMS rows), or `bbox` with a `{bbox-epsg-3857}` template (WMS GetMap, ArcGIS `export`/`exportImage`) | The active map's imagery host (globe or Google 3D tiles) | +| `pack` | A source pack an existing host layer already loads (today: CCTV) | The host layer, switched on by its own environment variable | + +A `dataset` or `tiles` entry needs no JavaScript. A `pack` documents a host +layer's opt-in source so viewers can see it and operators know the switch; the +host layer owns its code. Provider entries (code implementing a family +contract, such as a Street Level provider) are not supported yet and are +refused by the validator. + +## Add an entry + +1. Create `src/library/entries/.json`. The id is 3–48 lowercase letters, + digits or dashes and is permanent. +2. Add one import line for it in `src/library/catalog.js` (keep the list in + file-name order). +3. For a `dataset` or `tiles` entry, run `npm run library:next -- ` and + append `["", ""]` to `src/library/ledger.json`. Tokens are + permanent: never change or reuse a row, even when an entry is removed. + Packs take no token. +4. Run `npm run library:docs` to regenerate the DATA_SOURCES.md table. +5. Run `npm run library:check -- --base-ref origin/main`, + `npm run library:probe`, `npm test` and `npm run qa:data-library`. +6. Open a pull request with a `changelog.d/` fragment. + +Start new entries at `"status": "community"`. Maintainers move an entry to +`verified` after reviewing its licence and seeing it stay healthy; `stale` and +`archived` mark entries whose upstream has stopped answering. Archived entries +are hidden from Browse unless a viewer already added them. + +## Manifest reference + +```json +{ + "id": "eia-power-plants", + "kind": "dataset", + "family": "infrastructure", + "title": "US power plants, 100 MW and up (EIA)", + "provider": "EIA", + "description": "Operating US power plants with at least 100 MW of capacity.", + "icon": "ϟ", + "maintainer": "github:your-handle", + "coverage": { "countries": ["US"], "bbox": [-180, 13, -64, 72] }, + "source": { + "format": "arcgis", + "url": "https://services2.arcgis.com/…/Power_Plants_in_the_US/FeatureServer/0", + "query": { "where": "Total_MW >= 100", "outFields": "Plant_Name,PrimSource,Total_MW" }, + "fetch": "browser-direct", + "cacheSeconds": 86400, + "maxFeatures": 4000, + "maxPages": 3 + }, + "display": { + "color": "#ffc23d", + "label": "Plant_Name", + "fields": ["PrimSource", "Total_MW"], + "rank": { "field": "Total_MW", "order": "desc" } + }, + "licence": { "name": "EIA data: public domain", "url": "https://www.eia.gov/about/copyrights_reuse.php" }, + "attribution": { "text": "U.S. Energy Information Administration", "url": "https://atlas.eia.gov/" }, + "status": "community", + "reviewed": "2026-10-21" +} +``` + +Unknown fields are errors, so a typo cannot silently drop a cap or a licence. + +| Field | Required | Notes | +| --- | --- | --- | +| `id`, `kind`, `family`, `title` (≤ 60), `provider` (≤ 32), `description` (≤ 240), `maintainer`, `status` | yes | `family` is one of movement, cameras, infrastructure, events, weather, environment, imagery. `provider` is the short name the panel row shows. | +| `coverage` | yes | `global: true`, ISO 3166-1 alpha-2 `countries`, and/or a `bbox` `[west, south, east, north]`. | +| `source.url` | yes | https, a real hostname, no credentials or fragment. | +| `source.format` | yes | Datasets: `geojson`, `geojsonl`, `csv`, `json`, `arcgis`, `geoparquet`. Tiles: `xyz` (`{z}`, `{x}` and `{y}` or `{reverseY}`) or `bbox` (one `{bbox-epsg-3857}`, filled with the tile's Web Mercator bounds in metres, as MapLibre does). Packs: `host`. | +| `source.records` | no | JSON only: the property path to the record list, such as `stations` for `{ "stations": [ … ] }`. Omit when the response is the list itself. | +| `source.fetch` | datasets, tiles | `browser-direct` when the upstream sends CORS headers and needs no key; otherwise `proxied`. | +| `source.query` | no | Query parameters added to the URL. For `arcgis`, `where` and `outFields`; geometry is always WGS84 GeoJSON. | +| `source.columns` | no | CSV or JSON: `{ "lat": "…", "lon": "…" }` when the fields are not lat/latitude/y and lon/lng/longitude/x. | +| `source.cacheSeconds` | no | 60–86,400, default 3,600. How long the proxy keeps a response. | +| `source.maxBytes` | no | Body cap, default 16 MiB, at most 32 MiB. | +| `source.maxFeatures` | no | Feature cap, default 10,000, at most 20,000. A capped dataset reads PARTIAL. | +| `source.maxPages` | no | ArcGIS pages to follow, default 5, at most 10. | +| `source.view` | no | `{ "maxAltitudeM": 300–50000, "sample": [w, s, e, n] }`. Makes a huge ArcGIS layer view-bounded: below that camera height it loads what is in view, one page at a time; above it the row asks the viewer to zoom in. Needs `arcgis` or `geoparquet`, and `browser-direct`. `sample` is where Zoom to layer lands and what `library:probe` queries. | +| `source.partitions` | no | GeoParquet only: `{ "listUrl": "", "keyBase": "" }`, both on the source host. Needs `source.view`. The library indexes each file by its footer bbox and reads only the files in view. | +| `source.minZoom`, `source.maxZoom` | no | Tiles only, 0–22. | +| `source.encoding` | no | Tiles (`xyz`): `terrarium` when the pixels are elevation as terrain-RGB (height = R × 256 + G + B / 256 − 32768 m). The browser draws them as shaded relief: one orange ramp in six elevation classes (legend in metres), lit from the north-west; sea (exactly 0 m) is transparent. | +| `source.tileSize` | no | Tiles: 256 (default) or 512 px. | +| `source.bounded` | no | Tiles with `coverage.bbox`: `true` requests tiles inside the bbox only. Required for aerial imagery, whose servers paint a fill (white, a flat ocean, a coarse world mosaic) everywhere they have no photos. A 4xx tile answer is treated as "no imagery here", never retried or counted as an error. | +| `source.key` | no | `{ "id": "", "in": "query" \| "header", "name": "…" }`. The id must exist in `src/keySetupCore.mjs`. A keyed entry must be `proxied`, so the key never reaches the browser. | +| `display.color`, `display.label`, `display.fields` (≤ 4, two shown) | no | Datasets only, except `opacity`. | +| `display.rank` | no | `{ "field": "…", "order": "desc" \| "asc" }`. Keeps the most important features when capped and lets the map prefer them at global zoom. Numbers or ISO dates. Without it, a zoomed-out view shows whichever features come first. | +| `display.colorBy` | no | View-bounded datasets: `{ "field": "…", "colors": { "": "#rrggbb" }, "labels": { "": "…" } }`, up to 12 values. Colours each feature by value; the row legend lists each label with the count in view. | +| `display.legend` | no | `[{ "label": "…", "color": "#rrggbb" }]`, up to 12: a static legend, such as a tile layer's colour scale. Take the colours from the provider's own legend or colour map. | +| `display.outline` | no | Tiles: `countries` outlines every part of each covered country, `coverage` only the parts inside `coverage.bbox`, `bbox` the bbox rectangle itself (a state, county or city inside a country). Use it for aerial imagery so its edge shows. | +| `display.aerial` | no | Tiles: `true` for aerial or satellite photography. It drapes above every other imagery layer, including core overlays, and stays there when another layer is switched on. Pair it with `source.bounded`. | +| `display.noDataColor` | no | Tiles: `#rrggbb` of the provider's no-imagery fill, drawn transparent (within a small tolerance for JPEG noise along its edge). Measure it from a tile outside the coverage; do not guess. Globe maps only: Google 3D tiles ignore it. | +| `display.opacity` | no | Tiles: (0, 1]. | +| `imagery` | no | Tiles: what a photo layer is. `type` (`ortho`, `satellite-mosaic`, `oblique`, `historic`), `resolution` as the provider publishes it ("10 cm (plains) / 25 cm (Alps)"), `resolutionM` (the finest, in metres), `trueOrtho`, and optional `vintage` ("2021–present"), `updateCycle`, `orthoQuality` and `registry` (an https link to where the metadata came from). Browse shows it as a spec list on the card. | +| `pack` | packs | `{ "host": "cctv", "env": "CCTV_EXAMPLE_ENABLED" }`. Packs are always `licence.operatorOptIn: true`. | +| `licence` | yes | `name`, an https `url`, and `operatorOptIn: true` when the terms need the operator's own agreement or consent. | +| `attribution` | yes | `text` and an optional https `url`; shown in the Data attribution popover while the entry is in use. | +| `regionsServed` | no | ISO codes, when the upstream only answers from some countries. | +| `reviewed` | no | `YYYY-MM-DD` of the last licence and endpoint review. | + +## Aerial imagery from the Open Imagery Register + +The aerial photo entries came from the [Open Imagery Register](https://imagery-registry.netlify.app/) +on 2026-10-23: every register source with a map preview, less the ones that +fail the library's rules. Each entry keeps the register's resolution, product +type, true-ortho flag, years flown and refresh cycle in `imagery`. Before an +entry was written, a tile inside its coverage was fetched in a browser from +the app's origin: it had to be a real photo (not a blank fill), and the +provider had to send CORS headers (or the entry would be `proxied`). The +fill a provider paints at its coverage corners became `display.noDataColor`. + +Left out, with the reason: + +| Register source | Why | +| --- | --- | +| NASA GIBS true colour | Already in core (Recent imagery). | +| Sentinel-2 cloudless (EOX) | CC BY-NC-SA: non-commercial. | +| SWISSIMAGE Bern and Marion County PMTiles | Extracts hosted by the register, not the provider. | +| Cabarrus County NC, Hawaii, Nashville, Sioux Falls | Credit a commercial imagery vendor (EagleView, Maxar WorldView, Pictometry, Nearmap). | +| DVRPC, Missouri, Washington | The preview serves a blank white tile across the coverage centre. | +| Sarasota County FL | The service now requires a token. | + +Entries whose register licence reads "see publisher terms" or "verify portal +terms" are `licence.operatorOptIn: true`: an operator reads the publisher's +terms and lists the id in `GEV_LIBRARY_ENABLE`. Re-check an entry's terms and +drop the opt-in when its publisher states an open licence. + +## Choosing a fetch mode + +Use `browser-direct` when the upstream answers a browser on another origin +(`Access-Control-Allow-Origin`) and needs no key. Tile servers must send CORS +headers for WebGL to use them; without them, use `proxied`. + +Use `proxied` otherwise. The proxy is generic: there is no per-entry server +code. It reaches only the manifest's host, refuses redirects, caps bodies, +caches per `cacheSeconds` and never returns upstream error text. The browser +names an entry id, never a URL, so the proxy cannot be pointed anywhere else. + +## Operator switches + +| Variable | Effect | +| --- | --- | +| `GEV_LIBRARY_ENABLE` | Comma-separated ids of `operatorOptIn` datasets and tiles to serve. | +| `GEV_LIBRARY_DISABLE` | Comma-separated ids to remove from this instance. | +| A pack's own variable | For example `CCTV_NYC_ENABLED=1`; Browse then shows the pack as switched on. | + +## When an entry belongs in core instead + +A library entry can be proposed for DATA LAYERS when it has global or +near-global coverage, is keyless or uses a key the registry already has, has +stayed `verified` with no stale period for about six months, and a core +maintainer will own it. Promotion and demotion are maintainer decisions. diff --git a/index.html b/index.html index 5cfee67ea..f3057f742 100644 --- a/index.html +++ b/index.html @@ -26,7 +26,7 @@ with the sources: src/materialSymbolsSubset.test.mjs fails when a source names a glyph this list is missing, because a missing glyph does not draw a box — the ligature never forms and the element renders the literal word. --> - +
diff --git a/package-lock.json b/package-lock.json index 9ab7b43f7..df28aba4b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,9 @@ "@meri-imperiumi/eccodes-wasm": "^2.48.2", "cesium": "^1.124.0", "egm96-universal": "^1.1.1", + "fzstd": "0.1.1", "hls.js": "^1.7.3", + "hyparquet": "1.31.2", "mapillary-js": "^4.1.2", "mgrs": "^2.1.0", "pbf": "^5.1.2", @@ -2460,6 +2462,12 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/fzstd": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/fzstd/-/fzstd-0.1.1.tgz", + "integrity": "sha512-dkuVSOKKwh3eas5VkJy1AW1vFpet8TA/fGmVA5krThl8YcOVE/8ZIoEA1+U1vEn5ckxxhLirSdY837azmbaNHA==", + "license": "MIT" + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -2570,6 +2578,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/hyparquet": { + "version": "1.31.2", + "resolved": "https://registry.npmjs.org/hyparquet/-/hyparquet-1.31.2.tgz", + "integrity": "sha512-dpVTCGkPPIdfDExa7YZGaJpuBTmLFDKGeZ6meHUj1mz/Q3qmaQHGLLqCyPBkQjfxrYNb08YTax682qeGO+zkVA==", + "license": "MIT" + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", diff --git a/package.json b/package.json index b8f5c581a..6c11697bc 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,10 @@ "i18n": "node scripts/i18n.mjs", "layer-token:next": "node scripts/next-layer-state-token.mjs", "layer-token:check": "node scripts/check-layer-state-tokens.mjs", + "library:check": "node scripts/library.mjs check", + "library:next": "node scripts/library.mjs next", + "library:docs": "node scripts/library.mjs docs", + "library:probe": "node scripts/library.mjs probe", "changelog": "node scripts/assemble-changelog.mjs --write", "changelog:check": "node scripts/assemble-changelog.mjs --check", "dev": "vite", @@ -54,6 +58,7 @@ "qa:street-level:fixtures": "node scripts/qa-street-level.mjs --fixtures", "check:boundaries": "node scripts/check-import-directions.mjs && node scripts/check-package-boundaries.mjs", "qa:transit": "node scripts/qa-transit.mjs", + "qa:data-library": "node scripts/qa-data-library.mjs", "mcp": "node server/mcp/stdio.js" }, "dependencies": { @@ -63,7 +68,9 @@ "@meri-imperiumi/eccodes-wasm": "^2.48.2", "cesium": "^1.124.0", "egm96-universal": "^1.1.1", + "fzstd": "0.1.1", "hls.js": "^1.7.3", + "hyparquet": "1.31.2", "mapillary-js": "^4.1.2", "mgrs": "^2.1.0", "pbf": "^5.1.2", diff --git a/scripts/library.mjs b/scripts/library.mjs new file mode 100644 index 000000000..62366302e --- /dev/null +++ b/scripts/library.mjs @@ -0,0 +1,321 @@ +#!/usr/bin/env node +/** + * Data Library maintenance. See docs/DATA-LIBRARY.md. + * + * npm run library:check [-- --base-ref origin/main] + * Validate every manifest, the share-token ledger and the generated + * DATA_SOURCES.md section. With --base-ref, also prove the ledger only + * appended rows since that ref (published tokens never change). + * npm run library:next -- + * Print the next free share token for a new switchable entry. + * npm run library:docs + * Rewrite the generated Data Library section of DATA_SOURCES.md. + * npm run library:probe [-- --json] [-- --strict] + * Hit every dataset and tile upstream once (bounded) and report + * status, latency and feature count. --strict exits 1 on a failure. + */ +import { execFileSync } from 'node:child_process'; +import { readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { LIBRARY_ENTRIES, LIBRARY_LEDGER } from '../src/library/catalog.js'; +import { libraryTileUrl } from '../src/library/manifest.js'; +import { + arcgisPageUrl, + datasetRequestUrl, + parseLibraryBody, +} from '../src/library/formats.js'; +import { + nextLibraryToken, + readLibraryLedger, +} from '../src/library/selection.js'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const DATA_SOURCES = path.join(root, 'DATA_SOURCES.md'); +const START = ''; +const END = ''; +const LEDGER_PATH = 'src/library/ledger.json'; + +const cell = (text) => String(text).replace(/\|/g, '\\|').replace(/\s+/g, ' '); + +/** The generated DATA_SOURCES.md section, one row per entry. */ +export function libraryDocsSection(entries = LIBRARY_ENTRIES) { + const rows = [...entries] + .sort((a, b) => a.id.localeCompare(b.id)) + .map((entry) => { + const host = (() => { + try { + return new URL(entry.source.url.replace(/\{[xyz]\}/g, '0')).host; + } catch { + return ''; + } + })(); + const how = + entry.kind === 'pack' + ? `CCTV pack; operator sets \`${entry.pack.env}=1\`` + : `${entry.kind}, ${entry.source.format}, ${entry.source.fetch}` + + (entry.licence.operatorOptIn + ? `; operator adds \`${entry.id}\` to \`GEV_LIBRARY_ENABLE\`` + : '') + + (entry.source.key + ? `; needs the \`${entry.source.key.id}\` key` + : ''); + return `| **${cell(entry.title)}** (\`${entry.id}\`, \`${host}\`) | ${cell(how)} | [${cell(entry.licence.name)}](${entry.licence.url}) | "${cell(entry.attribution.text)}" |`; + }); + return [ + START, + '', + '## Data Library entries (fetched at runtime — not stored in this repo)', + '', + 'Each Data Library entry is a manifest in `src/library/entries/` pointing at a live upstream its provider hosts; see [docs/DATA-LIBRARY.md](docs/DATA-LIBRARY.md). An entry appears on the map only after a viewer adds it from **DATA LAYERS ▸ Browse data library**, and its attribution is registered in the Data attribution popover while it is in use. This table is generated from the manifests: edit the manifest, then run `npm run library:docs`.', + '', + '| Entry | How it is served | Licence / terms | Attribution |', + '| --- | --- | --- | --- |', + ...rows, + '', + END, + ].join('\n'); +} + +function replaceSection(text, section) { + const start = text.indexOf(START); + const end = text.indexOf(END); + if (start < 0 || end < start) { + const anchor = text.indexOf('\n## Bundled snapshots'); + if (anchor < 0) + throw new Error('DATA_SOURCES.md has no Bundled snapshots heading'); + return `${text.slice(0, anchor)}\n${section}\n${text.slice(anchor)}`; + } + return text.slice(0, start) + section + text.slice(end + END.length); +} + +function check(baseRef) { + const errors = []; + const ledger = readLibraryLedger(LIBRARY_LEDGER); + errors.push(...ledger.errors.map((error) => `ledger ${error}`)); + for (const entry of LIBRARY_ENTRIES) { + const switchable = entry.kind !== 'pack'; + if (switchable && !ledger.tokenById.has(entry.id)) + errors.push( + `${entry.id}: no share token (npm run library:next -- ${entry.id})`, + ); + if (!switchable && ledger.tokenById.has(entry.id)) + errors.push( + `${entry.id}: packs are switched by their host and take no token`, + ); + } + if (baseRef) { + let published = []; + try { + published = JSON.parse( + execFileSync('git', ['show', `${baseRef}:${LEDGER_PATH}`], { + cwd: root, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }), + ); + } catch { + published = []; + } + published.forEach(([id, token], index) => { + const row = LIBRARY_LEDGER[index]; + if (!row || row[0] !== id || row[1] !== token) + errors.push( + `ledger row ${index} (${id} → ${token}) was published on ${baseRef} and must not change`, + ); + }); + LIBRARY_LEDGER.slice(published.length).forEach(([id, token], offset) => { + const expected = nextLibraryToken( + LIBRARY_LEDGER.slice(0, published.length + offset), + ); + if (token !== expected) + errors.push( + `ledger row for ${id} should take ${expected}, not ${token}`, + ); + }); + } + const text = readFileSync(DATA_SOURCES, 'utf8'); + if (!text.includes(libraryDocsSection())) + errors.push( + 'DATA_SOURCES.md Data Library section is out of date (npm run library:docs)', + ); + if (errors.length) { + for (const error of errors) console.error(`✗ ${error}`); + process.exit(1); + } + console.log( + `Data Library valid: ${LIBRARY_ENTRIES.length} entries, ${LIBRARY_LEDGER.length} share tokens${baseRef ? `, ledger append-only against ${baseRef}` : ''}.`, + ); +} + +/** Points inside a coverage bbox to try, centre first, as [fx, fy] shares. */ +const PROBE_POINTS = [ + [0.5, 0.5], + [0.4, 0.45], + [0.6, 0.55], + [0.25, 0.25], + [0.75, 0.75], +]; + +/** + * A Web Mercator tile to probe. Regional entries try points inside their + * coverage bbox (a regional server answers 404 or a blank fill outside its + * photos, and a bbox centre can be sea); global entries probe a mid-world + * tile. + */ +function probeTile(entry, z, [fx, fy] = PROBE_POINTS[0]) { + const n = 2 ** z; + const bbox = entry.coverage?.bbox; + if (!bbox) return { x: Math.floor(n / 2), y: Math.floor(n / 3) }; + const lon = bbox[0] + (bbox[2] - bbox[0]) * fx; + const lat = Math.max(-85, Math.min(85, bbox[1] + (bbox[3] - bbox[1]) * fy)); + const rad = (lat * Math.PI) / 180; + const clamp = (value) => Math.min(n - 1, Math.max(0, Math.floor(value))); + return { + x: clamp(((lon + 180) / 360) * n), + y: clamp( + ((1 - Math.log(Math.tan(rad) + 1 / Math.cos(rad)) / Math.PI) / 2) * n, + ), + }; +} + +async function probeEntry(entry) { + const started = Date.now(); + const signal = AbortSignal.timeout(30_000); + try { + if (entry.kind === 'tiles') { + // Regional imagery is probed at z13, the Open Imagery Register's + // validation zoom (some caches start there); global layers at their + // coarsest level. + const minZoom = entry.source.minZoom ?? 0; + const z = entry.coverage.bbox + ? Math.min(entry.source.maxZoom ?? 18, Math.max(minZoom, 13)) + : minZoom; + const points = entry.coverage.bbox ? PROBE_POINTS : [PROBE_POINTS[0]]; + let status = null; + let lastError = null; + for (const point of points) { + const { x, y } = probeTile(entry, z, point); + const url = libraryTileUrl(entry.source.url, z, x, y); + try { + const response = await fetch(url, { + signal: AbortSignal.timeout(20_000), + redirect: 'error', + }); + await response.arrayBuffer(); + status = response.status; + if ( + response.ok && + /^image\//.test(response.headers.get('content-type') || '') + ) + return { id: entry.id, ok: true, status, ms: Date.now() - started }; + } catch (error) { + lastError = error; + } + } + if (status === null && lastError) throw lastError; + return { id: entry.id, ok: false, status, ms: Date.now() - started }; + } + if (entry.source.format === 'geoparquet') { + // List the partitions and read one footer by range request: proof the + // bucket answers and the files are GeoParquet, without reading data. + const { listPartitions, rangeBuffer, geoParquetInfo } = + await import('../src/library/parquet.js'); + const { parquetMetadataAsync } = await import('hyparquet'); + const fetchImpl = (target, options = {}) => + fetch(target, { ...options, signal }); + const objects = entry.source.partitions + ? await listPartitions(entry, { fetchImpl, signal }) + : [{ key: entry.source.url, size: null }]; + const first = objects[0]; + const url = entry.source.partitions + ? new URL(first.key, entry.source.partitions.keyBase).href + : entry.source.url; + const size = + first.size ?? + Number( + (await fetch(url, { method: 'HEAD', signal })).headers.get( + 'content-length', + ), + ); + const metadata = await parquetMetadataAsync( + rangeBuffer(url, size, { fetchImpl, signal }), + { initialFetchSize: 16 * 1024 }, + ); + return { + id: entry.id, + ok: Boolean(geoParquetInfo(metadata).column), + status: 200, + ms: Date.now() - started, + features: Number(metadata.num_rows), + partitions: objects.length, + }; + } + const url = + entry.source.format === 'arcgis' + ? arcgisPageUrl( + entry.source.url, + entry.source.query, + 0, + // Some huge view services refuse a page smaller than their own. + entry.source.view ? 2000 : 100, + // A view-bounded layer is probed over its sample area. + entry.source.view?.sample ?? null, + ) + : datasetRequestUrl(entry.source); + const response = await fetch(url, { signal, redirect: 'error' }); + const text = await response.text(); + const parsed = response.ok + ? parseLibraryBody(entry.source.format, text, entry.source) + : null; + return { + id: entry.id, + ok: Boolean(parsed?.candidates.length), + status: response.status, + ms: Date.now() - started, + features: parsed?.candidates.length ?? 0, + }; + } catch (error) { + return { + id: entry.id, + ok: false, + status: null, + ms: Date.now() - started, + error: error.message, + }; + } +} + +const [command = 'check', ...rest] = process.argv.slice(2); +const flag = (name) => rest.includes(name); +const option = (name) => { + const index = rest.indexOf(name); + return index >= 0 ? rest[index + 1] : null; +}; + +if (command === 'check') check(option('--base-ref')); +else if (command === 'next') { + const id = rest.find((arg) => !arg.startsWith('--')); + if (!id) throw new Error('usage: npm run library:next -- '); + if (LIBRARY_LEDGER.some(([existing]) => existing === id)) + throw new Error(`${id} already owns a token`); + console.log(nextLibraryToken(LIBRARY_LEDGER)); +} else if (command === 'docs') { + const text = readFileSync(DATA_SOURCES, 'utf8'); + writeFileSync(DATA_SOURCES, replaceSection(text, libraryDocsSection())); + console.log('DATA_SOURCES.md Data Library section updated.'); +} else if (command === 'probe') { + const results = []; + for (const entry of LIBRARY_ENTRIES.filter(({ kind }) => kind !== 'pack')) + results.push(await probeEntry(entry)); + if (flag('--json')) console.log(JSON.stringify(results, null, 2)); + else + for (const result of results) + console.log( + `${result.ok ? 'ok ' : 'FAIL'} ${result.id} · ${result.status ?? '—'} · ${result.ms} ms${result.features !== undefined ? ` · ${result.features} features` : ''}${result.partitions !== undefined ? ` in the first of ${result.partitions} partitions` : ''}${result.error ? ` · ${result.error}` : ''}`, + ); + if (flag('--strict') && results.some(({ ok }) => !ok)) process.exit(1); +} else { + console.error(`unknown command: ${command}`); + process.exit(2); +} diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index a771cc26f..cc317f90b 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -3493,6 +3493,154 @@ "src/voice/geminiSession.js", "src/voice/geminiAudio.js", "src/voice/geminiCaptureWorklet.js", + "src/ui/libraryBrowser.js", + "src/library/manifest.js", + "src/library/selection.js", + "src/library/ledger.json", + "src/library/entries/ak-statewide-imagery.json", + "src/library/entries/ar-statewide-imagery.json", + "src/library/entries/az-statewide-imagery.json", + "src/library/entries/bangor-me.json", + "src/library/entries/basemap-at-orthofoto.json", + "src/library/entries/bayern-dop40.json", + "src/library/entries/bismarck-mandan-nd.json", + "src/library/entries/bkg-topplus-open.json", + "src/library/entries/cctv-florida-fl511.json", + "src/library/entries/cctv-lithuania.json", + "src/library/entries/cctv-nyc-dot.json", + "src/library/entries/ct-statewide-imagery.json", + "src/library/entries/dakota-county-mn.json", + "src/library/entries/dc-statewide-imagery.json", + "src/library/entries/de-statewide-imagery.json", + "src/library/entries/ee-maa-amet-ortofoto.json", + "src/library/entries/eia-power-plants.json", + "src/library/entries/emodnet-bathymetry.json", + "src/library/entries/epa-superfund-sites.json", + "src/library/entries/epa-toxic-release-facilities.json", + "src/library/entries/epc-england-wales.json", + "src/library/entries/fema-usa-structures.json", + "src/library/entries/gbif-occurrence-density.json", + "src/library/entries/gdacs-disaster-alerts.json", + "src/library/entries/geneva-sitg-ortho.json", + "src/library/entries/gpw-population-density.json", + "src/library/entries/grand-forks-nd.json", + "src/library/entries/gsi-japan-map.json", + "src/library/entries/gsi-japan-photo.json", + "src/library/entries/gvp-holocene-volcanoes.json", + "src/library/entries/ia-statewide-imagery.json", + "src/library/entries/icgc-orto-catalunya.json", + "src/library/entries/id-statewide-imagery.json", + "src/library/entries/ign-orthophotos-france.json", + "src/library/entries/il-statewide-imagery.json", + "src/library/entries/imf-portwatch-chokepoints.json", + "src/library/entries/imf-portwatch-disruptions.json", + "src/library/entries/in-statewide-imagery.json", + "src/library/entries/jrc-surface-water.json", + "src/library/entries/kartverket-admin-units.json", + "src/library/entries/kartverket-cadastre.json", + "src/library/entries/kartverket-depth.json", + "src/library/entries/kartverket-hillshade.json", + "src/library/entries/kartverket-nautical-charts.json", + "src/library/entries/kartverket-terrain-relief.json", + "src/library/entries/kartverket-topo-grey.json", + "src/library/entries/kartverket-topo.json", + "src/library/entries/kartverket-toporaster.json", + "src/library/entries/ks-statewide-imagery.json", + "src/library/entries/ky-statewide-imagery.json", + "src/library/entries/lake-county-in.json", + "src/library/entries/lawrence-ks.json", + "src/library/entries/lee-county-fl.json", + "src/library/entries/loudoun-county-va.json", + "src/library/entries/louisville-lojic-ky.json", + "src/library/entries/lu-ortho-latest.json", + "src/library/entries/ma-statewide-imagery.json", + "src/library/entries/macrostrat-geology.json", + "src/library/entries/mapterhorn-terrain.json", + "src/library/entries/maris-ms.json", + "src/library/entries/md-statewide-imagery.json", + "src/library/entries/me-statewide-imagery.json", + "src/library/entries/mi-statewide-imagery.json", + "src/library/entries/mn-statewide-imagery.json", + "src/library/entries/mountain-village-co.json", + "src/library/entries/ms-statewide-imagery.json", + "src/library/entries/mt-statewide-imagery.json", + "src/library/entries/naip.json", + "src/library/entries/nasa-black-marble.json", + "src/library/entries/nasa-eonet-events.json", + "src/library/entries/nc-statewide-imagery.json", + "src/library/entries/nctcog-tx.json", + "src/library/entries/nd-statewide-imagery.json", + "src/library/entries/new-orleans-rpc-la.json", + "src/library/entries/nh-statewide-imagery.json", + "src/library/entries/nj-statewide-imagery.json", + "src/library/entries/noaa-tide-stations.json", + "src/library/entries/nrw-dop.json", + "src/library/entries/nsw-imagery.json", + "src/library/entries/ntad-national-bridges.json", + "src/library/entries/nwa-benton-washington-ar.json", + "src/library/entries/nws-active-alerts.json", + "src/library/entries/ny-statewide-imagery.json", + "src/library/entries/oakland-county-mi.json", + "src/library/entries/oh-statewide-imagery.json", + "src/library/entries/openseamap-seamarks.json", + "src/library/entries/or-statewide-imagery.json", + "src/library/entries/pa-statewide-imagery.json", + "src/library/entries/pdok-aerial-netherlands.json", + "src/library/entries/pl-ortofotomapa.json", + "src/library/entries/pnoa-ma.json", + "src/library/entries/portland-metro-or.json", + "src/library/entries/pt-dgt-ortos.json", + "src/library/entries/ri-statewide-imagery.json", + "src/library/entries/swisstopo-swissimage.json", + "src/library/entries/tx-statewide-imagery.json", + "src/library/entries/union-county-oh.json", + "src/library/entries/usgs-hydrography.json", + "src/library/entries/usgs-topo.json", + "src/library/entries/va-statewide-imagery.json", + "src/library/entries/vlaanderen-omw.json", + "src/library/entries/vt-statewide-imagery.json", + "src/library/entries/wallonie-ortho.json", + "src/library/entries/wi-statewide-imagery.json", + "src/library/entries/wri-global-power-plants.json", + "src/library/entries/wv-statewide-imagery.json", + "src/library/formats.js", + "src/library/entries/gvp-holocene-volcanoes.json", + "src/library/entries/nasa-black-marble.json", + "src/library/entries/nasa-eonet-events.json", + "src/library/entries/openseamap-seamarks.json", + "src/library/catalog.js", + "src/library/source.js", + "src/library/layers.js", + "src/library/controller.js", + "src/library/extent.js", + "src/library/camera.js", + "src/library/entries/imf-portwatch-disruptions.json", + "src/library/entries/imf-portwatch-chokepoints.json", + "src/library/entries/noaa-tide-stations.json", + "src/library/entries/nws-active-alerts.json", + "src/library/entries/wri-global-power-plants.json", + "src/library/entries/fema-usa-structures.json", + "src/library/entries/epc-england-wales.json", + "src/library/parquet.js", + "src/library/entries/bkg-topplus-open.json", + "src/library/entries/emodnet-bathymetry.json", + "src/library/entries/epa-superfund-sites.json", + "src/library/entries/epa-toxic-release-facilities.json", + "src/library/entries/gbif-occurrence-density.json", + "src/library/entries/gpw-population-density.json", + "src/library/entries/gdacs-disaster-alerts.json", + "src/library/entries/gsi-japan-map.json", + "src/library/entries/ign-orthophotos-france.json", + "src/library/entries/gsi-japan-photo.json", + "src/library/entries/jrc-surface-water.json", + "src/library/entries/ntad-national-bridges.json", + "src/library/entries/usgs-hydrography.json", + "src/library/entries/macrostrat-geology.json", + "src/library/entries/pdok-aerial-netherlands.json", + "src/library/entries/swisstopo-swissimage.json", + "src/library/entries/usgs-topo.json", + "src/library/outlines.js", + "src/library/relief.js", "src/sceneCommandRelease.js", "src/scopeMaskPolicy.js", "src/visualStylePolicy.js", @@ -3628,6 +3776,8 @@ "satellite.js", "mapillary-js", "@jtarrio/webrtlsdr", + "hyparquet", + "fzstd", "vue", "pinia", "vue-i18n" @@ -4426,7 +4576,11 @@ }, "layer-lifecycle": { "exports": ["./data/lifecycle"], - "modules": ["src/data/lifecycle.js", "src/data/cameraArrival.js"], + "modules": [ + "src/data/lifecycle.js", + "src/data/cameraArrival.js", + "src/library/manifest.js" + ], "external": [] }, "ui-navigation": { diff --git a/scripts/qa-data-library.mjs b/scripts/qa-data-library.mjs new file mode 100644 index 000000000..e8adc49c7 --- /dev/null +++ b/scripts/qa-data-library.mjs @@ -0,0 +1,636 @@ +#!/usr/bin/env node +/** + * Data Library live gate. Run against this candidate's dev or preview server: + * + * npm run qa:data-library -- --url http://localhost:4173 + * + * Every upstream a library entry names is answered from fixtures in the + * browser (EONET, the EIA ArcGIS pages, the GIBS tiles and the proxied + * volcano route), so the gate is deterministic and offline apart from the + * app itself. It drives the real panel and dialog: Browse opens a modal, + * Add registers a layer under the Library heading and switches it on, the + * share link carries `lib=`, a reload restores the selection, Escape and + * clean view close the dialog, and Remove destroys the layer. + */ +import assert from 'node:assert/strict'; + +const { default: puppeteer } = await import('puppeteer'); +const args = process.argv.slice(2); +const urlIndex = args.indexOf('--url'); +const base = urlIndex >= 0 ? args[urlIndex + 1] : 'http://localhost:4173'; + +const point = (lon, lat, properties) => ({ + type: 'Feature', + geometry: { type: 'Point', coordinates: [lon, lat] }, + properties, +}); +const collection = (features, extra = {}) => + JSON.stringify({ type: 'FeatureCollection', features, ...extra }); +// 1×1 transparent PNG. +const PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', + 'base64', +); + +const seen = { + eonet: 0, + arcgisPages: [], + tiles: 0, + proxied: 0, + structureBoxes: [], +}; +function fixtureFor(url) { + const cors = { 'Access-Control-Allow-Origin': '*' }; + if (url.hostname === 'eonet.gsfc.nasa.gov') { + seen.eonet += 1; + return { + status: 200, + headers: cors, + contentType: 'application/json', + body: collection([ + point(-120, 38, { + title: 'Fixture Fire', + date: '2026-10-20T00:00:00Z', + }), + point(140, 35, { + title: 'Fixture Storm', + date: '2026-10-19T00:00:00Z', + }), + ]), + }; + } + if ( + url.hostname === 'services2.arcgis.com' && + url.pathname.includes('USA_Structures') + ) { + seen.structureBoxes.push(url.searchParams.get('geometry')); + const square = (lon, lat) => ({ + type: 'Feature', + geometry: { + type: 'Polygon', + coordinates: [ + [ + [lon, lat], + [lon + 0.0004, lat], + [lon + 0.0004, lat + 0.0003], + [lon, lat + 0.0003], + [lon, lat], + ], + ], + }, + properties: { OCC_CLS: 'Government', HEIGHT: 30 }, + }); + return { + status: 200, + headers: cors, + contentType: 'application/json', + body: collection([ + square(-77.035, 38.892), + square(-77.03, 38.893), + square(-77.025, 38.894), + ]), + }; + } + if (url.hostname === 'services2.arcgis.com') { + const offset = Number(url.searchParams.get('resultOffset')); + seen.arcgisPages.push(offset); + const features = + offset === 0 + ? [ + point(-118.9, 47.9, { Plant_Name: 'Fixture Dam', Total_MW: 6809 }), + point(-112.9, 33.4, { + Plant_Name: 'Fixture Nuclear', + Total_MW: 3937, + }), + ] + : [point(-97.6, 28.8, { Plant_Name: 'Fixture Coast', Total_MW: 2560 })]; + return { + status: 200, + headers: cors, + contentType: 'application/json', + body: collection(features, { + properties: { exceededTransferLimit: offset === 0 }, + }), + }; + } + if (url.hostname === 'gibs.earthdata.nasa.gov') { + seen.tiles += 1; + return { status: 200, headers: cors, contentType: 'image/png', body: PNG }; + } + if (url.pathname === '/api/library/data/gvp-holocene-volcanoes') { + seen.proxied += 1; + return { + status: 200, + contentType: 'application/json', + body: collection([ + point(-121.76, 46.85, { name: 'Fixture Volcano', __gevDetails: [] }), + ]), + }; + } + return null; +} + +const browser = await puppeteer.launch({ + headless: true, + executablePath: + process.env.PUPPETEER_EXECUTABLE_PATH || (await puppeteer.executablePath()), + defaultViewport: { width: 1440, height: 900 }, + protocolTimeout: 300_000, + args: ['--no-sandbox', '--use-gl=angle', '--use-angle=swiftshader'], +}); +const errors = []; +let count = 0; +const step = async (label, fn) => { + await fn(); + console.log(`ok ${++count} ${label}`); +}; + +async function open(page, hash = '') { + await page.goto(`${base}/${hash}`, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction( + () => + window.__gevDataLibrary?.library.ready && + window.__godsEyeView?.dataManager && + document.getElementById('loading-screen')?.classList.contains('hidden'), + { timeout: 180_000 }, + ); +} + +const libraryRows = (page) => + page.evaluate(() => + [...document.querySelectorAll('#data-toggles [data-library-entry]')].map( + (row) => ({ + id: row.dataset.libraryEntry, + on: row.querySelector('.data-toggle-btn')?.classList.contains('active'), + count: row.querySelector('.data-count')?.textContent, + }), + ), + ); + +try { + const page = await browser.newPage(); + page.on('console', (message) => { + if (message.type() === 'error' && /Library|library/.test(message.text())) + errors.push(message.text()); + }); + page.on('pageerror', (error) => errors.push(String(error))); + await page.setRequestInterception(true); + page.on('request', (request) => { + const fixture = fixtureFor(new URL(request.url())); + if (fixture) request.respond(fixture); + else request.continue(); + }); + await page.evaluateOnNewDocument(() => { + try { + // Start empty once; the reload step needs what this run stored. + if (!sessionStorage.getItem('qa-data-library')) { + localStorage.removeItem('gev:library:v1'); + sessionStorage.setItem('qa-data-library', '1'); + } + // Skip the first-run chooser (src/firstRunExperience.js). + localStorage.setItem('gev:first-run-mission:v1', 'suppressed'); + } catch { + /* storage may be blocked; the gate still runs */ + } + }); + await open(page); + + await step( + 'the panel offers Browse and lists no library layer by default', + async () => { + assert.deepEqual(await libraryRows(page), []); + const hidden = await page.$eval( + '#data-library-browse', + (node) => node.hidden, + ); + assert.equal(hidden, false); + }, + ); + + await step( + 'Browse opens a labelled modal with addable entries first and packs last', + async () => { + await page.evaluate(() => { + document.getElementById('data-panel')?.classList.remove('collapsed'); + document.getElementById('data-library-browse').click(); + }); + const dialog = await page.$eval('#data-library', (node) => ({ + open: node.open, + modal: node.matches(':modal'), + title: document.getElementById(node.getAttribute('aria-labelledby')) + ?.textContent, + focus: document.activeElement?.type, + order: [...node.querySelectorAll('.data-library-item')].map( + (item) => item.dataset.libraryId, + ), + })); + assert.equal(dialog.open, true); + assert.equal(dialog.modal, true); + assert.equal(dialog.title, 'DATA LIBRARY'); + assert.equal(dialog.focus, 'search'); + assert.ok(dialog.order.at(-1).startsWith('cctv-'), dialog.order.join()); + assert.ok(!dialog.order[0].startsWith('cctv-')); + }, + ); + + await step( + 'a pack explains the operator switch instead of offering Add', + async () => { + const pack = await page.$eval( + '#data-library .data-library-item[data-library-id="cctv-nyc-dot"]', + (item) => ({ + action: Boolean(item.querySelector('.data-library-action')), + reason: item.querySelector('.data-library-reason')?.textContent, + }), + ); + assert.equal(pack.action, false); + assert.match(pack.reason, /CCTV_NYC_ENABLED=1/); + }, + ); + + for (const [id, expected] of [ + ['nasa-eonet-events', '2'], + ['eia-power-plants', '3'], + ['gvp-holocene-volcanoes', '1'], + ['nasa-black-marble', 'DRAPED'], + ]) + await step( + `Add ${id} registers it under Library and switches it on`, + async () => { + await page.click( + `#data-library button[data-library-id="${id}"][data-library-action="add"]`, + ); + await page.waitForFunction( + (layerId, text) => { + const row = document.querySelector( + `#data-toggles [data-layer-id="${layerId}"]`, + ); + return ( + row + ?.querySelector('.data-toggle-btn') + ?.classList.contains('active') && + row.querySelector('.data-count')?.textContent === text + ); + }, + { timeout: 60_000 }, + `lib-${id}`, + expected, + ); + }, + ); + + await step( + 'the fixtures were reached the way each manifest says', + async () => { + assert.ok(seen.eonet >= 1); + assert.deepEqual(seen.arcgisPages.slice(0, 2), [0, 2]); + assert.ok(seen.proxied >= 1); + const draped = await page.evaluate( + () => + window.__godsEyeView.dataManager.layers + .get('lib-nasa-black-marble') + .module.getDiagnostics().draped, + ); + assert.equal(draped, true); + // Imagery requests tiles on the following frames, not on enable. + for (let waited = 0; seen.tiles === 0 && waited < 30_000; waited += 250) + await new Promise((resolve) => setTimeout(resolve, 250)); + assert.ok(seen.tiles >= 1, 'the drape requested its tiles'); + const headings = await page.$$eval( + '#data-toggles .data-layer-group-heading', + (nodes) => nodes.map((node) => node.textContent), + ); + assert.equal(headings.at(-1), 'Library'); + }, + ); + + await step( + 'the stats strip sits above the intro and counts what is on the map', + async () => { + await page.waitForFunction( + () => + document.querySelector( + '[data-stat="features"] .data-library-stat-value', + )?.textContent === '6', + { timeout: 15_000 }, + ); + const strip = await page.evaluate(() => { + const stats = document.querySelector( + '#data-library .data-library-stats', + ); + const read = (id) => + [...document.querySelectorAll(`[data-stat="${id}"] dd`)].map( + (node) => node.textContent, + ); + return { + beforeIntro: + stats?.nextElementSibling?.classList.contains('data-library-intro'), + label: stats?.getAttribute('aria-label'), + entries: read('entries'), + added: read('added'), + features: read('features'), + }; + }); + assert.equal(strip.beforeIntro, true); + assert.equal(strip.label, 'Data Library at a glance'); + // Counted from the shipped catalog, so a new entry does not break this. + const kinds = await page.evaluate(() => { + const counts = { dataset: 0, tiles: 0, pack: 0 }; + for (const { entry } of window.__gevDataLibrary.library.list()) + counts[entry.kind] += 1; + return counts; + }); + assert.deepEqual(strip.entries, [ + String(kinds.dataset + kinds.tiles + kinds.pack), + `${kinds.dataset} datasets · ${kinds.tiles} tile layers · ${kinds.pack} packs`, + ]); + assert.deepEqual(strip.added, ['4', '4 on the map']); + // Fixtures: 2 EONET events, 3 power plants, 1 volcano. + assert.deepEqual(strip.features, ['6', 'from 3 datasets']); + }, + ); + + await step('the share link carries lib= with the ledger tokens', async () => { + await page.waitForFunction( + () => /(?:^|&)lib=00\.02\.01\.03(?:&|$)/.test(location.hash.slice(1)), + { timeout: 15_000 }, + ); + }); + + await step( + 'Escape closes the dialog and returns focus to Browse', + async () => { + await page.focus('#data-library input[type="search"]'); + await page.keyboard.press('Escape'); + const state = await page.evaluate(() => ({ + open: document.getElementById('data-library').open, + focus: document.activeElement?.id, + })); + assert.deepEqual(state, { open: false, focus: 'data-library-browse' }); + }, + ); + + await step('clean view closes an open dialog', async () => { + const open = await page.evaluate(async () => { + window.__gevDataLibrary.browser.open(); + document.body.classList.add('ui-clean-view'); + await new Promise((resolve) => setTimeout(resolve, 50)); + const result = document.getElementById('data-library').open; + document.body.classList.remove('ui-clean-view'); + return result; + }); + assert.equal(open, false); + }); + + await step( + 'a reload restores the added entries and what was on', + async () => { + await page.evaluate(() => { + document + .querySelector( + '#data-toggles [data-layer-id="lib-gvp-holocene-volcanoes"] .data-toggle-btn', + ) + .click(); + }); + await page.waitForFunction( + () => + JSON.parse(localStorage.getItem('gev:library:v1')).on.length === 3, + ); + await open(page, ''); + await page.waitForFunction( + () => + document.querySelectorAll('#data-toggles [data-library-entry]') + .length === 4, + { timeout: 60_000 }, + ); + const rows = await libraryRows(page); + assert.deepEqual(rows.map(({ id, on }) => [id, on]).sort(), [ + ['eia-power-plants', true], + ['gvp-holocene-volcanoes', false], + ['nasa-black-marble', true], + ['nasa-eonet-events', true], + ]); + }, + ); + + const cameraCenter = () => + page.evaluate(() => { + const { camera } = window.__godsEyeView.viewer; + const carto = camera.positionCartographic; + return { + lon: (carto.longitude * 180) / Math.PI, + lat: (carto.latitude * 180) / Math.PI, + height: carto.height, + }; + }); + const waitForFlight = () => + page.waitForFunction( + () => !window.__godsEyeView.viewer.camera._currentFlight, + { timeout: 30_000, polling: 200 }, + ); + + await step( + 'Zoom to layer on the row works while off: it switches on and flies', + async () => { + const chip = + '#data-toggles [data-layer-id="lib-eia-power-plants"] [data-chip-id="zoom"]'; + await page.evaluate(() => + document.getElementById('data-panel')?.classList.remove('collapsed'), + ); + // The icons work while the layer is off; Zoom switches it back on. + await page.evaluate(() => + window.__godsEyeView.dataManager.setEnabled( + 'lib-eia-power-plants', + false, + { origin: 'user' }, + ), + ); + await page.waitForSelector(chip, { visible: true, timeout: 30_000 }); + await page.evaluate( + (selector) => document.querySelector(selector).click(), + chip, + ); + await page.waitForFunction( + () => + window.__godsEyeView.dataManager.isEnabled('lib-eia-power-plants'), + { timeout: 30_000 }, + ); + await new Promise((resolve) => setTimeout(resolve, 300)); + await waitForFlight(); + const center = await cameraCenter(); + // Fixture plants span -118.9…-97.6 °E and 28.8…47.9 °N. + assert.ok(center.lon > -125 && center.lon < -90, JSON.stringify(center)); + assert.ok(center.lat > 22 && center.lat < 55, JSON.stringify(center)); + }, + ); + + await step( + 'ZOOM in the dialog closes it and flies to that entry', + async () => { + await page.evaluate(() => window.__gevDataLibrary.browser.open()); + const zoom = + '#data-library button[data-library-id="gvp-holocene-volcanoes"][data-library-action="zoom"]'; + assert.ok(await page.$(zoom), 'Zoom is offered for an entry that is off'); + await page.click( + '#data-library button[data-library-id="nasa-eonet-events"][data-library-action="zoom"]', + ); + const open = await page.$eval('#data-library', (node) => node.open); + assert.equal(open, false); + await new Promise((resolve) => setTimeout(resolve, 300)); + await waitForFlight(); + const center = await cameraCenter(); + // Fixture events at (-120, 38) and (140, 35): the frame sits between them. + assert.ok(center.lat > 20 && center.lat < 55, JSON.stringify(center)); + }, + ); + + await step('Remove destroys the layer and forgets it', async () => { + await page.evaluate(() => window.__gevDataLibrary.browser.open()); + await page.click( + '#data-library button[data-library-id="nasa-black-marble"][data-library-action="remove"]', + ); + await page.waitForFunction( + () => + !document.querySelector( + '#data-toggles [data-layer-id="lib-nasa-black-marble"]', + ) && + !window.__godsEyeView.dataManager.layers.has('lib-nasa-black-marble'), + { timeout: 30_000 }, + ); + const stored = await page.evaluate(() => + JSON.parse(localStorage.getItem('gev:library:v1')), + ); + assert.equal(stored.added.includes('nasa-black-marble'), false); + }); + + await step( + 'the row delete icon removes that entry, even while it is off', + async () => { + await page.evaluate(() => { + window.__gevDataLibrary.browser.close(); + document.getElementById('data-panel')?.classList.remove('collapsed'); + }); + const chip = + '#data-toggles [data-layer-id="lib-eia-power-plants"] [data-chip-id="remove"]'; + await page.evaluate(() => + window.__godsEyeView.dataManager.setEnabled( + 'lib-eia-power-plants', + false, + { origin: 'user' }, + ), + ); + await page.waitForSelector(chip, { visible: true, timeout: 30_000 }); + const label = await page.$eval(chip, (node) => ({ + name: node.getAttribute('aria-label'), + glyph: node.textContent, + pressed: node.getAttribute('aria-pressed'), + })); + assert.deepEqual(label, { + name: 'Remove from Data Layers', + glyph: 'delete', + pressed: null, + }); + await page.evaluate( + (selector) => document.querySelector(selector).click(), + chip, + ); + await page.waitForFunction( + () => + !window.__godsEyeView.dataManager.layers.has( + 'lib-eia-power-plants', + ) && + !document.querySelector( + '#data-toggles [data-layer-id="lib-eia-power-plants"]', + ), + { timeout: 30_000 }, + ); + }, + ); + + await step( + 'a view-bounded layer asks to zoom in, then loads only what is in view', + async () => { + await page.evaluate(() => + window.__gevDataLibrary.library.add('fema-usa-structures'), + ); + const row = '#data-toggles [data-layer-id="lib-fema-usa-structures"]'; + await page.waitForFunction( + (selector) => + /zoom in to load this layer/.test( + document.querySelector(`${selector} .data-toggle-meta`) + ?.textContent || '', + ), + { timeout: 30_000 }, + row, + ); + assert.equal(seen.structureBoxes.length, 0, 'nothing loads from orbit'); + await page.evaluate(() => + window.__gevDataLibrary.library.zoom('fema-usa-structures'), + ); + await page.waitForFunction( + () => + window.__godsEyeView.dataManager.layers + .get('lib-fema-usa-structures') + .module.getDiagnostics().count === 3, + { timeout: 60_000, polling: 250 }, + ); + const altitude = (await cameraCenter()).height; + assert.ok(altitude <= 4000, `landed at ${Math.round(altitude)} m`); + const [west, south, east, north] = seen.structureBoxes + .at(-1) + .split(',') + .map(Number); + assert.ok(east - west < 0.2 && north - south < 0.2, 'a view-sized box'); + assert.ok( + west < -77.03 && east > -77.03 && south < 38.893 && north > 38.893, + ); + // Climbing back out clears the buildings and shows the guidance again. + await page.evaluate(() => { + const { viewer, requestRender } = window.__godsEyeView; + viewer.camera.zoomOut(60_000); + requestRender?.('qa-data-library'); + }); + await page.waitForFunction( + () => + window.__godsEyeView.dataManager.layers + .get('lib-fema-usa-structures') + .module.getDiagnostics().tooHigh, + { timeout: 30_000, polling: 250 }, + ); + }, + ); + + await step('Remove all asks twice, then clears the library', async () => { + await page.evaluate(() => window.__gevDataLibrary.browser.open()); + await page.click('#data-library .data-library-reset'); + const armed = await page.$eval( + '#data-library .data-library-reset', + (node) => ({ + text: node.textContent, + armed: node.classList.contains('is-armed'), + }), + ); + assert.equal(armed.armed, true); + assert.match(armed.text, /CONFIRM/); + assert.ok( + (await page.$$('#data-toggles [data-library-entry]')).length > 0, + 'one press removes nothing', + ); + await page.click('#data-library .data-library-reset'); + await page.waitForFunction( + () => + !document.querySelector('#data-toggles [data-library-entry]') && + document.querySelector('#data-library .data-library-reset').hidden, + { timeout: 30_000 }, + ); + const stored = await page.evaluate(() => + JSON.parse(localStorage.getItem('gev:library:v1')), + ); + assert.deepEqual(stored, { added: [], on: [] }); + }); + + assert.deepEqual(errors, [], 'no library errors on the page'); + console.log(`# ${count} passed`); +} finally { + await browser.close(); +} diff --git a/server/providers/library.js b/server/providers/library.js new file mode 100644 index 000000000..c01b45aa3 --- /dev/null +++ b/server/providers/library.js @@ -0,0 +1,363 @@ +import { LIBRARY_ENTRIES } from '../../src/library/catalog.js'; +import { loadLibraryDataset } from '../../src/library/formats.js'; +import { buildPartitionIndex } from '../../src/library/parquet.js'; +import { + libraryUpstreamUrl, + parseLibraryIdList, + libraryTileUrl, +} from '../../src/library/manifest.js'; +import { KEY_SETUP_KEYS } from '../../src/keySetupCore.mjs'; +import { + readResponseBytesCapped, + readResponseTextCapped, + coalesceProxyRequest, +} from './common/http.js'; +import { makeRateLimiter, clientKey } from './common/rate-limit.js'; +import { admitSameSite, admitSameSiteKeyless } from './common/same-site.js'; + +const DATASET_TIMEOUT_MS = 30_000; +const TILE_TIMEOUT_MS = 10_000; +/** Each listing page or footer read while building a partition index. */ +const INDEX_TIMEOUT_MS = 20_000; +const TILE_MAX_BYTES = 2 * 1024 * 1024; +const TILE_CACHE_LIMIT = 512; +const IN_FLIGHT_LIMIT = 64; + +/** + * Availability of every entry on this instance, decided by the operator's + * environment alone: `GEV_LIBRARY_DISABLE` removes entries, opt-in entries + * need `GEV_LIBRARY_ENABLE` (or, for a pack, its host's own switch), and a + * keyed entry needs its key. The answer names a reason, never a secret. + * @param {object[]} entries + * @param {Record} env + * @returns {Map} + */ +export function libraryAvailability(entries, env) { + const enabled = parseLibraryIdList(env.GEV_LIBRARY_ENABLE); + const disabled = parseLibraryIdList(env.GEV_LIBRARY_DISABLE); + const result = new Map(); + for (const entry of entries) { + let reason = null; + if (disabled.has(entry.id)) reason = 'operator-disabled'; + else if (entry.kind === 'pack') { + if (String(env[entry.pack.env] || '').trim() !== '1') + reason = 'operator-opt-in'; + } else if (entry.licence.operatorOptIn && !enabled.has(entry.id)) + reason = 'operator-opt-in'; + else if (entry.source.key && !libraryKeyValue(entry.source.key, env)) + reason = 'key-missing'; + result.set(entry.id, { available: reason === null, reason }); + } + return result; +} + +/** The configured value for a manifest key, through the key registry. */ +export function libraryKeyValue(key, env) { + const registered = KEY_SETUP_KEYS.find(({ id }) => id === key?.id); + for (const name of registered?.envVars || []) { + const value = String(env[name] || '').trim(); + if (value) return value; + } + return null; +} + +function withKey(url, key, env, headers) { + if (!key) return url; + const value = libraryKeyValue(key, env); + if (!value) throw Object.assign(new Error('key_missing'), { status: 503 }); + if (key.in === 'header') { + headers[key.name] = value; + return url; + } + const keyed = new URL(url); + keyed.searchParams.set(key.name, value); + return keyed.href; +} + +/** + * Parse `////` against a tiles entry's zoom range. + * @returns {{ z: number, x: number, y: number } | null} + */ +export function parseLibraryTilePath(parts, source) { + if (parts.length !== 3 || !parts.every((part) => /^\d{1,8}$/.test(part))) + return null; + const [z, x, y] = parts.map(Number); + const minZoom = source.minZoom ?? 0; + const maxZoom = source.maxZoom ?? 22; + if (z < minZoom || z > maxZoom) return null; + const span = 2 ** z; + if (x >= span || y >= span) return null; + return { z, x, y }; +} + +/** + * The Data Library's one server route. Proxied datasets and tiles reach their + * manifest's fixed upstream only: the browser names an entry id, never a URL, + * so the allowlist is exactly the catalog. Redirects are refused, bodies are + * capped while streaming, keys come from this server's environment and + * upstream error text is never returned. + */ +export function libraryProxy({ + entries = LIBRARY_ENTRIES, + env = process.env, + fetchImpl = (...args) => globalThis.fetch(...args), + now = () => Date.now(), +} = {}) { + const byId = new Map(entries.map((entry) => [entry.id, entry])); + const datasets = new Map(); + const tiles = new Map(); + const indexes = new Map(); + const inFlight = new Map(); + const allowData = makeRateLimiter({ + windowMs: 60_000, + max: 30, + globalMax: 600, + }); + const allowTiles = makeRateLimiter({ + windowMs: 60_000, + max: 600, + globalMax: 6000, + }); + + async function upstream(url, entry, headers, timeoutMs) { + // Defense in depth: every derived request stays on the manifest's host. + const target = libraryUpstreamUrl(url); + const declared = libraryUpstreamUrl(entry.source.url); + if (!target || !declared || target.host !== declared.host) + throw Object.assign(new Error('off_manifest_host'), { status: 502 }); + const signal = AbortSignal.timeout(timeoutMs); + const response = await fetchImpl(url, { + signal, + redirect: 'error', + headers: { Accept: '*/*', ...headers }, + }); + if (!response.ok) { + await response.body?.cancel?.().catch?.(() => {}); + throw Object.assign(new Error('upstream_unavailable'), { status: 502 }); + } + return { response, signal }; + } + + async function fetchDataset(entry) { + const headers = {}; + const collection = await loadLibraryDataset(entry, async (url, budget) => { + const keyed = withKey(url, entry.source.key, env, headers); + const { response, signal } = await upstream( + keyed, + entry, + headers, + DATASET_TIMEOUT_MS, + ); + const text = await readResponseTextCapped( + response, + budget.remainingBytes, + signal, + ); + budget.remainingBytes -= new TextEncoder().encode(text).byteLength; + return text; + }); + return { ...collection, fetchedAt: now() }; + } + + async function cached(store, key, ttlMs, load, limit = Infinity) { + const previous = store.get(key); + if (previous && now() - previous.savedAt < ttlMs) + return { value: previous.value, stale: false }; + if (!inFlight.has(key) && inFlight.size >= IN_FLIGHT_LIMIT) + throw Object.assign(new Error('busy'), { status: 429 }); + try { + const { promise } = coalesceProxyRequest(inFlight, key, async () => { + const value = await load(); + store.delete(key); + store.set(key, { value, savedAt: now() }); + while (store.size > limit) store.delete(store.keys().next().value); + return value; + }); + return { value: await promise, stale: false }; + } catch (error) { + if (previous) return { value: previous.value, stale: true }; + throw error; + } + } + + function json(res, status, value, extra = {}) { + if (res.destroyed) return; + res.writeHead(status, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + ...(status === 405 ? { Allow: 'GET' } : {}), + ...(status === 429 ? { 'Retry-After': '60' } : {}), + ...extra, + }); + res.end(JSON.stringify(value)); + } + + function gate(req, res, entry) { + // A keyed entry spends the operator's credential: refuse forwarded + // requests like the other cost-bearing routes. Keyless entries keep + // working behind a reverse proxy, like terrain heights. + return entry?.source?.key + ? admitSameSite(req, res) + : admitSameSiteKeyless(req, res); + } + + async function handler(req, res) { + if (req.method !== 'GET') + return json(res, 405, { error: 'method_not_allowed' }); + const path = (req.url || '/').split('?')[0]; + const parts = path.split('/').filter(Boolean); + const availability = libraryAvailability(entries, env); + + if (parts.length === 1 && parts[0] === 'catalog') { + if (admitSameSiteKeyless(req, res)) return undefined; + return json(res, 200, { + entries: entries.map(({ id }) => ({ id, ...availability.get(id) })), + }); + } + + const [route, id, ...rest] = parts; + const entry = byId.get(id); + if (!entry || !['data', 'tile', 'index'].includes(route)) + return json(res, 404, { error: 'unknown_entry' }); + + // A partitioned GeoParquet entry's index: which file covers where, read + // from each file's footer. Built once per cacheSeconds and shared, so + // visitors do not each list the bucket and read every footer. + if (route === 'index') { + if (rest.length || !entry.source?.partitions) + return json(res, 404, { error: 'not_partitioned' }); + if (admitSameSiteKeyless(req, res)) return undefined; + const { available, reason } = availability.get(id); + if (!available) return json(res, 403, { error: reason }); + if (!allowData(clientKey(req))) + return json(res, 429, { error: 'rate_limited' }); + try { + const { value } = await cached( + indexes, + `index:${id}`, + entry.source.cacheSeconds * 1000, + async () => ({ + partitions: await buildPartitionIndex(entry, { + fetchImpl: (url, options = {}) => + fetchImpl(url, { + ...options, + signal: + options.signal ?? AbortSignal.timeout(INDEX_TIMEOUT_MS), + }), + }), + builtAt: now(), + }), + ); + return json(res, 200, value); + } catch (error) { + const status = error?.status === 429 ? 429 : 502; + return json(res, status, { error: 'library_upstream_unavailable' }); + } + } + const isDataset = route === 'data' && rest.length === 0; + const isTile = route === 'tile'; + if ( + entry.source.fetch !== 'proxied' || + (isDataset && entry.kind !== 'dataset') || + (isTile && entry.kind !== 'tiles') || + (!isDataset && !isTile) + ) + return json(res, 404, { error: 'not_proxied' }); + if (gate(req, res, entry)) return undefined; + const { available, reason } = availability.get(id); + if (!available) return json(res, 403, { error: reason }); + + const ttlMs = entry.source.cacheSeconds * 1000; + if (isDataset) { + if (!allowData(clientKey(req))) + return json(res, 429, { error: 'rate_limited' }); + try { + const { value, stale } = await cached( + datasets, + `data:${id}`, + ttlMs, + () => fetchDataset(entry), + ); + return json( + res, + 200, + stale ? { ...value, stale: true } : value, + stale ? { 'X-Data-Stale': 'true' } : {}, + ); + } catch (error) { + const status = [429, 503].includes(error?.status) ? error.status : 502; + return json(res, status, { + error: + status === 503 ? 'key_missing' : 'library_upstream_unavailable', + }); + } + } + + const tile = parseLibraryTilePath(rest, entry.source); + if (!tile) return json(res, 400, { error: 'invalid_tile' }); + if (!allowTiles(clientKey(req))) + return json(res, 429, { error: 'rate_limited' }); + try { + const key = `tile:${id}:${tile.z}/${tile.x}/${tile.y}`; + const { value } = await cached( + tiles, + key, + ttlMs, + async () => { + const headers = {}; + const url = withKey( + libraryTileUrl(entry.source.url, tile.z, tile.x, tile.y), + entry.source.key, + env, + headers, + ); + const { response, signal } = await upstream( + url, + entry, + headers, + TILE_TIMEOUT_MS, + ); + const type = String(response.headers.get('content-type') || '') + .split(';')[0] + .trim() + .toLowerCase(); + if (!/^image\/(png|jpeg|webp)$/.test(type)) { + await response.body?.cancel?.().catch?.(() => {}); + throw Object.assign(new Error('not_an_image'), { status: 502 }); + } + const bytes = await readResponseBytesCapped( + response, + TILE_MAX_BYTES, + signal, + ); + return { type, bytes }; + }, + TILE_CACHE_LIMIT, + ); + if (res.destroyed) return undefined; + res.writeHead(200, { + 'Content-Type': value.type, + 'Cache-Control': `private, max-age=${entry.source.cacheSeconds}`, + 'X-Content-Type-Options': 'nosniff', + }); + res.end(Buffer.from(value.bytes)); + return undefined; + } catch (error) { + const status = [429, 503].includes(error?.status) ? error.status : 502; + return json(res, status, { + error: status === 503 ? 'key_missing' : 'library_upstream_unavailable', + }); + } + } + + return { + name: 'data-library', + handler, + configureServer({ middlewares }) { + middlewares.use('/api/library', handler); + }, + configurePreviewServer({ middlewares }) { + middlewares.use('/api/library', handler); + }, + }; +} diff --git a/server/providers/library.test.mjs b/server/providers/library.test.mjs new file mode 100644 index 000000000..eca9f7060 --- /dev/null +++ b/server/providers/library.test.mjs @@ -0,0 +1,332 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + libraryAvailability, + libraryProxy, + parseLibraryTilePath, +} from './library.js'; +import { validateLibraryCatalog } from '../../src/library/manifest.js'; + +const base = { + family: 'events', + provider: 'Fixture', + description: 'Fixture entry.', + maintainer: 'test', + coverage: { global: true }, + licence: { name: 'Public domain', url: 'https://example.org/licence' }, + attribution: { text: 'Fixture' }, + status: 'community', +}; +const { entries, errors } = validateLibraryCatalog([ + { + ...base, + id: 'proxied-points', + kind: 'dataset', + title: 'Proxied points', + source: { + format: 'geojson', + url: 'https://data.example.org/points.json', + fetch: 'proxied', + maxBytes: 4096, + }, + display: { label: 'title' }, + }, + { + ...base, + id: 'direct-points', + kind: 'dataset', + title: 'Direct points', + source: { + format: 'geojson', + url: 'https://data.example.org/direct.json', + fetch: 'browser-direct', + }, + }, + { + ...base, + id: 'keyed-points', + kind: 'dataset', + title: 'Keyed points', + source: { + format: 'geojson', + url: 'https://keyed.example.org/points.json', + fetch: 'proxied', + key: { id: 'firms', in: 'query', name: 'MAP_KEY' }, + }, + }, + { + ...base, + id: 'opt-in-points', + kind: 'dataset', + title: 'Opt-in points', + source: { + format: 'geojson', + url: 'https://data.example.org/opt.json', + fetch: 'proxied', + }, + licence: { ...base.licence, operatorOptIn: true }, + }, + { + ...base, + id: 'proxied-tiles', + kind: 'tiles', + title: 'Proxied tiles', + source: { + format: 'xyz', + url: 'https://tiles.example.org/{z}/{x}/{y}.png', + fetch: 'proxied', + minZoom: 2, + maxZoom: 5, + }, + }, + { + ...base, + id: 'fixture-pack', + kind: 'pack', + family: 'cameras', + title: 'Fixture cameras', + source: { format: 'host', url: 'https://cams.example.org/' }, + pack: { host: 'cctv', env: 'CCTV_FIXTURE_ENABLED' }, + licence: { ...base.licence, operatorOptIn: true }, + }, +]); +assert.deepEqual(errors, []); + +const collection = (features) => + JSON.stringify({ type: 'FeatureCollection', features }); +const point = (title) => ({ + type: 'Feature', + geometry: { type: 'Point', coordinates: [1, 2] }, + properties: { title }, +}); + +function install({ env = {}, fetchImpl, now } = {}) { + const plugin = libraryProxy({ entries, env, fetchImpl, now }); + let handler; + plugin.configurePreviewServer({ + middlewares: { + use(path, callback) { + assert.equal(path, '/api/library'); + handler = callback; + }, + }, + }); + return async (url, headers = {}) => { + const res = { + headers: {}, + writeHead(status, values) { + this.status = status; + this.headers = values; + }, + setHeader(name, value) { + this.headers[name] = value; + }, + end(body) { + this.raw = body; + try { + this.body = JSON.parse(body); + } catch { + this.body = null; + } + }, + set statusCode(value) { + this.status = value; + }, + }; + await handler( + { url, method: 'GET', headers, socket: { remoteAddress: '127.0.0.1' } }, + res, + ); + return res; + }; +} + +test('availability is decided by the operator environment and names no secret', () => { + const none = libraryAvailability(entries, {}); + assert.deepEqual(none.get('proxied-points'), { + available: true, + reason: null, + }); + assert.equal(none.get('keyed-points').reason, 'key-missing'); + assert.equal(none.get('opt-in-points').reason, 'operator-opt-in'); + assert.equal(none.get('fixture-pack').reason, 'operator-opt-in'); + const configured = libraryAvailability(entries, { + FIRMS_MAP_KEY: 'secret', + GEV_LIBRARY_ENABLE: 'opt-in-points', + GEV_LIBRARY_DISABLE: 'proxied-points', + CCTV_FIXTURE_ENABLED: '1', + }); + assert.equal(configured.get('keyed-points').available, true); + assert.equal(configured.get('opt-in-points').available, true); + assert.equal(configured.get('proxied-points').reason, 'operator-disabled'); + assert.equal(configured.get('fixture-pack').available, true); + assert.doesNotMatch(JSON.stringify([...configured]), /secret/); +}); + +test('the catalog route lists every entry with its availability', async () => { + const request = install(); + const res = await request('/catalog'); + assert.equal(res.status, 200); + assert.equal(res.body.entries.length, entries.length); + assert.deepEqual( + res.body.entries.find(({ id }) => id === 'opt-in-points'), + { id: 'opt-in-points', available: false, reason: 'operator-opt-in' }, + ); +}); + +test('a proxied dataset reaches only its manifest URL, refuses redirects and is normalized', async () => { + const calls = []; + const request = install({ + now: () => 42, + fetchImpl: async (url, options) => { + calls.push({ url, options }); + return new Response(collection([point('Storm'), { geometry: null }])); + }, + }); + const res = await request('/data/proxied-points?url=https://evil.example'); + assert.equal(res.status, 200); + assert.equal(calls.length, 1); + assert.equal(calls[0].url, 'https://data.example.org/points.json'); + assert.equal(calls[0].options.redirect, 'error'); + assert.ok(calls[0].options.signal instanceof AbortSignal); + assert.equal(res.body.features.length, 1); + assert.equal(res.body.features[0].properties.name, 'Storm'); + assert.equal(res.body.dropped, 1); + assert.equal(res.body.fetchedAt, 42); + // A fresh cache answers without another upstream request. + await request('/data/proxied-points'); + assert.equal(calls.length, 1); +}); + +test('browser-direct, unknown, opt-in and wrong-kind entries are not proxied', async () => { + let calls = 0; + const request = install({ + fetchImpl: async () => { + calls++; + return new Response(collection([])); + }, + }); + assert.equal((await request('/data/direct-points')).status, 404); + assert.equal((await request('/data/nope-nope')).status, 404); + assert.equal((await request('/data/proxied-tiles')).status, 404); + assert.equal((await request('/tile/proxied-points/2/0/0')).status, 404); + const optIn = await request('/data/opt-in-points'); + assert.equal(optIn.status, 403); + assert.equal(optIn.body.error, 'operator-opt-in'); + assert.equal(calls, 0); +}); + +test('a keyed entry gets the operator key server-side and refuses forwarded requests', async () => { + const calls = []; + const request = install({ + env: { FIRMS_MAP_KEY: 'k3y' }, + fetchImpl: async (url) => { + calls.push(new URL(url)); + return new Response(collection([point('Fire')])); + }, + }); + const forwarded = await request('/data/keyed-points', { + 'x-forwarded-for': '203.0.113.9', + }); + assert.equal(forwarded.status, 403); + assert.equal(calls.length, 0); + const res = await request('/data/keyed-points'); + assert.equal(res.status, 200); + assert.equal(calls[0].searchParams.get('MAP_KEY'), 'k3y'); + assert.doesNotMatch(res.raw, /k3y/); +}); + +test('an oversized or failing upstream answers 502 without its text, then serves stale', async () => { + let clock = 0; + let mode = 'ok'; + const request = install({ + now: () => clock, + fetchImpl: async () => { + if (mode === 'big') return new Response('x'.repeat(8192)); + if (mode === 'fail') + return new Response('secret upstream text', { status: 500 }); + return new Response(collection([point('A')])); + }, + }); + mode = 'big'; + const big = await request('/data/proxied-points'); + assert.equal(big.status, 502); + assert.doesNotMatch(big.raw, /x{20}/); + mode = 'ok'; + assert.equal((await request('/data/proxied-points')).status, 200); + clock = 10 * 3600 * 1000; + mode = 'fail'; + const stale = await request('/data/proxied-points'); + assert.equal(stale.status, 200); + assert.equal(stale.body.stale, true); + assert.equal(stale.headers['X-Data-Stale'], 'true'); + assert.doesNotMatch(stale.raw, /secret upstream text/); +}); + +test('cross-site browser requests are refused before any upstream request', async () => { + let calls = 0; + const request = install({ + fetchImpl: async () => { + calls++; + return new Response(collection([])); + }, + }); + const res = await request('/data/proxied-points', { + host: 'localhost:5173', + origin: 'https://evil.example', + 'sec-fetch-site': 'cross-site', + }); + assert.equal(res.status, 403); + assert.equal(calls, 0); +}); + +test('tiles: bounded z/x/y, image types only, cached', async () => { + const calls = []; + const request = install({ + fetchImpl: async (url) => { + calls.push(url); + if (url.includes('/3/')) + return new Response('', { + headers: { 'content-type': 'text/html' }, + }); + return new Response(new Uint8Array([137, 80, 78, 71]), { + headers: { 'content-type': 'image/png' }, + }); + }, + }); + assert.equal((await request('/tile/proxied-tiles/1/0/0')).status, 400); + assert.equal((await request('/tile/proxied-tiles/2/4/0')).status, 400); + assert.equal((await request('/tile/proxied-tiles/2/a/0')).status, 400); + const ok = await request('/tile/proxied-tiles/2/1/3'); + assert.equal(ok.status, 200); + assert.equal(ok.headers['Content-Type'], 'image/png'); + assert.equal(calls[0], 'https://tiles.example.org/2/1/3.png'); + await request('/tile/proxied-tiles/2/1/3'); + assert.equal(calls.length, 1); + assert.equal((await request('/tile/proxied-tiles/3/1/1')).status, 502); +}); + +test('tile paths are parsed against the zoom range', () => { + const source = { minZoom: 0, maxZoom: 3 }; + assert.deepEqual(parseLibraryTilePath(['3', '7', '7'], source), { + z: 3, + x: 7, + y: 7, + }); + assert.equal(parseLibraryTilePath(['3', '8', '0'], source), null); + assert.equal(parseLibraryTilePath(['4', '0', '0'], source), null); + assert.equal(parseLibraryTilePath(['1', '0'], source), null); +}); + +test('only GET is served', async () => { + const plugin = libraryProxy({ entries, env: {} }); + const res = { + writeHead(status, headers) { + this.status = status; + this.headers = headers; + }, + end() {}, + }; + await plugin.handler({ url: '/catalog', method: 'POST', headers: {} }, res); + assert.equal(res.status, 405); +}); diff --git a/server/providers/local.js b/server/providers/local.js index f7c5b36ec..76c347db4 100644 --- a/server/providers/local.js +++ b/server/providers/local.js @@ -36,6 +36,7 @@ import { inaturalistProxy } from './inaturalist/observations.js'; import { acartiaProxy } from './acartia.js'; import { cycloneProxy } from './cyclones.js'; import { windProxy } from './wind.js'; +import { libraryProxy } from './library.js'; /** * Construct the local provider plugins in their established order. @@ -85,6 +86,7 @@ function localProviderPlugins({ mapillaryProxy(), firePerimetersProxy(), gnssIntegrityProxy(), + libraryProxy(), ghostMapsProxy(), shipwrecksProxy(), droneAirspaceProxy(), diff --git a/src/app/layerPresentation.js b/src/app/layerPresentation.js index eaa0e0b57..d3f1fb448 100644 --- a/src/app/layerPresentation.js +++ b/src/app/layerPresentation.js @@ -18,6 +18,7 @@ export class LayerPresentation { this.pendingVisible = false; this._unsubscribe = manager.subscribeActivity((change) => { if (change.type === 'status') this.refresh(); + else if (change.type === 'registrations') this._panel?.rerender(); else if (change.type === 'destroy-all') this.destroy(); else { const reason = diff --git a/src/app/tools.js b/src/app/tools.js index cffae32b2..927e343bb 100644 --- a/src/app/tools.js +++ b/src/app/tools.js @@ -11,6 +11,10 @@ import { useDisplayStore } from '../ui/stores/display.js'; import { applicationPinia } from '../ui/vueRuntime.js'; import { initImageryBoxTool } from '../ui/imageryBoxTool.js'; import { createRecentImageryPanel } from '../ui/recentImagery.js'; +import { createDataLibrary } from '../library/controller.js'; +import { flyToLibraryExtent } from '../library/camera.js'; +import { mountLibraryBrowser } from '../ui/libraryBrowser.js'; +import { localGeoJsonServices } from './localGeojsonServices.js'; import { initGevVoiceCommands } from '../voice/gevRealtime.js'; import { installViews, isEmbeddedInline } from './embed.js'; import { @@ -109,6 +113,41 @@ export function createApplicationTools({ connect: connectDisplay, }); defer(() => geoFileImport?.destroy()); + // DATA ▸ Library: entries register their layers only when a viewer adds + // them, after the core catalog is sealed. The browser dialog and the + // controller share the application lifetime. + const dataLibrary = createDataLibrary({ + dataManager, + services: localGeoJsonServices, + getTileset: () => tileset, + mapStackController, + shareLink: styleManager.shareLinkManager, + requestRender: governorRequestRender, + // Zoom to layer: an immediate navigation, so Cockpit can refuse it and + // the current follow owner is released first (as for imported files). + zoomTo: (extent) => + styleManager.runImmediateNavigation('layer', () => + flyToLibraryExtent(viewer, extent), + ) !== false, + }); + defer(() => dataLibrary.destroy()); + const libraryTrigger = document.getElementById('data-library-browse'); + const libraryBrowser = mountLibraryBrowser({ + library: dataLibrary, + trigger: libraryTrigger, + }); + defer(() => libraryBrowser.destroy()); + if (libraryTrigger) libraryTrigger.hidden = false; + dataLibrary.start({ signal }).catch((error) => { + if (!signal?.aborted) console.warn('[Library] start failed:', error); + }); + // The live gate's handle (scripts/qa-data-library.mjs). + const libraryHandle = { library: dataLibrary, browser: libraryBrowser }; + window.__gevDataLibrary = libraryHandle; + defer(() => { + if (window.__gevDataLibrary === libraryHandle) + delete window.__gevDataLibrary; + }); // DATA ▸ Recent Imagery: the box tool claims the pointer like Draw and the // panel lives on the right rail, so both belong to the application // lifetime. The tileset lets the layer drape while the globe is hidden. diff --git a/src/data/libraryRegistration.test.mjs b/src/data/libraryRegistration.test.mjs new file mode 100644 index 000000000..e9a09d489 --- /dev/null +++ b/src/data/libraryRegistration.test.mjs @@ -0,0 +1,54 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { LayerLifecycle } from './lifecycle.js'; + +const layer = (id) => ({ + id, + name: id, + icon: '', + source: 'test', + async init() {}, + async enable() {}, + async update() {}, + async disable() {}, + async destroy() {}, + getStats: () => ({ count: 0, lastUpdate: null }), +}); + +test('library layers register only after sealing and only in the lib- namespace', async () => { + const manager = new LayerLifecycle({}); + const activity = []; + manager.subscribeActivity((change) => activity.push(change.type)); + manager.register(layer('earthquakes')); + assert.throws( + () => manager.registerLibraryLayer(layer('lib-night-lights')), + /after the catalog is sealed/, + ); + manager.finalizeRegistrations([ + { id: 'earthquakes', disposition: 'enabled-only' }, + ]); + assert.throws( + () => manager.registerLibraryLayer(layer('radio')), + /lib- namespace/, + ); + assert.throws(() => manager.register(layer('lib-x-y-z')), /finalized/); + manager.registerLibraryLayer(layer('lib-night-lights')); + assert.equal(manager.isLibraryLayer('lib-night-lights'), true); + assert.equal(manager.isLibraryLayer('earthquakes'), false); + const rows = manager.getAll(); + assert.equal( + rows.find(({ id }) => id === 'earthquakes').libraryEntryId, + null, + ); + assert.equal( + rows.find(({ id }) => id === 'lib-night-lights').libraryEntryId, + 'night-lights', + ); + assert.equal(await manager.unregisterLibraryLayer('earthquakes'), false); + assert.equal(manager.layers.has('earthquakes'), true); + await manager.setEnabled('lib-night-lights', true, { origin: 'user' }); + assert.equal(await manager.unregisterLibraryLayer('lib-night-lights'), true); + assert.equal(manager.layers.has('lib-night-lights'), false); + assert.equal(manager.isLibraryLayer('lib-night-lights'), false); + assert.equal(activity.filter((type) => type === 'registrations').length, 2); +}); diff --git a/src/data/lifecycle.js b/src/data/lifecycle.js index 3ea02eca9..c67c4287f 100644 --- a/src/data/lifecycle.js +++ b/src/data/lifecycle.js @@ -1,4 +1,5 @@ import { cancelCameraArrival } from './cameraArrival.js'; +import { libraryEntryIdOf } from '../library/manifest.js'; function cloneLayerParams(value) { if (Array.isArray(value)) return value.map(cloneLayerParams); @@ -97,6 +98,7 @@ export class LayerLifecycle { this._registrationDispositions = null; this._allowQaRegistration = allowQaRegistration === true; this._qaLayerIds = new Set(); + this._libraryLayerIds = new Set(); } register(layerModule) { @@ -125,6 +127,41 @@ export class LayerLifecycle { return destroyed; } + /** + * Register a Data Library layer after the catalog is sealed. Only ids in + * the library namespace (`lib-`) are accepted, so this seam can + * never stand in for a core layer, and library layers never join the core + * share-token registry: their selection travels in `lib=` instead. + * @param {object} layerModule + * @returns {string} The registered id. + */ + registerLibraryLayer(layerModule) { + if (!this._registrationsFinalized) + throw new Error('Library layers register after the catalog is sealed'); + if (!libraryEntryIdOf(layerModule?.id)) + throw new Error('Library layer ids must use the lib- namespace'); + this._registerLayer(layerModule); + this._libraryLayerIds.add(layerModule.id); + this._publishActivity({ type: 'registrations', layerId: layerModule.id }); + return layerModule.id; + } + + /** Destroy a layer registered through `registerLibraryLayer`. */ + async unregisterLibraryLayer(layerId) { + if (!this._libraryLayerIds.has(layerId)) return false; + const destroyed = await this.destroyLayer(layerId); + if (destroyed) { + this._libraryLayerIds.delete(layerId); + this._publishActivity({ type: 'registrations', layerId }); + } + return destroyed; + } + + /** Whether a registered layer came from the Data Library. */ + isLibraryLayer(layerId) { + return this._libraryLayerIds.has(layerId); + } + _registerLayer(layerModule) { if (!layerModule || typeof layerModule.id !== 'string' || !layerModule.id) { throw new Error('Data layer must provide a stable id'); @@ -2231,6 +2268,7 @@ export class LayerLifecycle { } this.layers.delete(layerId); this._qaLayerIds.delete(layerId); + this._libraryLayerIds.delete(layerId); return true; } @@ -2274,6 +2312,10 @@ export class LayerLifecycle { // showInTogglePanel). The layer reports stats.keyRequired while that // key is absent; the panel builds the guidance text from the pair. requiresKeyId: entry.module.requiresKeyId || null, + // Data Library entry id; the panel groups these under Library. + libraryEntryId: this._libraryLayerIds.has(id) + ? libraryEntryIdOf(id) + : null, enabled: entry.enabled, lifecycleState: entry.lifecycleState, lifecycleUncertain: entry.lifecycleUncertain, diff --git a/src/data/localGeojsonCore.js b/src/data/localGeojsonCore.js index 811dfab57..e079b5e8a 100644 --- a/src/data/localGeojsonCore.js +++ b/src/data/localGeojsonCore.js @@ -86,6 +86,13 @@ export function localInfrastructureOverlayCopy(properties, layerId) { if (river && river.toLocaleLowerCase() !== title.toLocaleLowerCase()) { details.push(clampCardLine(river)); } + } else if (Array.isArray(props.__gevDetails)) { + // Data Library features carry their card lines from the manifest + // (src/library/formats.js presentLibraryProperties). + for (const line of props.__gevDetails.slice(0, 2)) { + const clean = cleanLabel(line); + if (clean) details.push(clampCardLine(clean)); + } } return { title, details }; @@ -292,7 +299,10 @@ export function localDatasetError(error) { * A minimal, rock-solid native implementation for loading local GeoJSON Data. * Draws 3D stems (polylines) attached to Point entities and ensures * standard scene.pick natively clicks them. - * @param {object} options Dataset URL, identity, appearance and optional Cesium adapters. + * @param {object} options Dataset URL (or a `loadFeatures({signal})` loader + * resolving to GeoJSON features), identity, appearance (an optional + * `featureColor(properties)` returns a CSS colour per feature) and optional + * Cesium adapters. * @param {object} services Caller-owned operations; see docs/INFRASTRUCTURE-LAYERS.md. * @returns {object} A fresh layer implementing init/enable/disable/update/destroy/getStats/getAnalystRecords. * One live instance per layer id is allowed in a given viewer/context/overlay host. @@ -310,6 +320,8 @@ export function createLocalGeoJsonLayer( labels = true, labelMax = DEFAULT_LABEL_MAX, labelGridPx = DEFAULT_LABEL_GRID_PX, + loadFeatures = null, + featureColor = null, screenSpaceEventHandlerFactory = (canvas) => new Cesium.ScreenSpaceEventHandler(canvas), projectToWindow = (scene, position) => @@ -585,7 +597,17 @@ export function createLocalGeoJsonLayer( let addedToScene = false; try { let features = _cachedFeatures; - if (!features) { + if (!features && loadFeatures) { + // A caller-owned loader (the Data Library) fetches and bounds + // its own live dataset; this layer only renders it. + features = await loadFeatures({ + signal: _loadController.signal, + }); + if (_destroyed) return; + if (!Array.isArray(features)) + throw new Error('dataset unavailable'); + _cachedFeatures = features; + } else if (!features) { const response = await fetch(url, { signal: _loadController.signal, }); @@ -639,9 +661,21 @@ export function createLocalGeoJsonLayer( _stemRecords = []; _stemGeometryDirty = true; + // An optional per-feature colour (Data Library colorBy); one + // Cesium colour per distinct CSS value. + const colorCache = new Map([[color, baseColor]]); + const colorFor = (entity) => { + if (typeof featureColor !== 'function') return baseColor; + const css = featureColor(propertyObject(entity)) || color; + if (!colorCache.has(css)) + colorCache.set(css, Cesium.Color.fromCssColorString(css)); + return colorCache.get(css); + }; + for (let i = 0; i < entities.length; i++) { const feature = entities[i]; feature.__localLayerId = id; // Tag it so our click handler knows it belongs to this layer + const featureBaseColor = colorFor(feature); let pos = feature.position?.getValue(Cesium.JulianDate.now()); @@ -649,7 +683,10 @@ export function createLocalGeoJsonLayer( // It's a polygon or line if (feature.polygon) { feature.polygon.outline = true; - feature.polygon.outlineColor = baseColor; + feature.polygon.outlineColor = featureBaseColor; + if (featureBaseColor !== baseColor) + feature.polygon.material = + featureBaseColor.withAlpha(0.3); // Calculate center point for the stem const hierarchy = feature.polygon.hierarchy?.getValue( @@ -716,11 +753,11 @@ export function createLocalGeoJsonLayer( feature.polyline = new Cesium.PolylineGraphics({ positions: stemPositionBuffers[0], width: 3.5, - material: new Cesium.ColorMaterialProperty(baseColor), + material: new Cesium.ColorMaterialProperty(featureBaseColor), }); feature.point = new Cesium.PointGraphics({ pixelSize: 10, - color: baseColor, + color: featureBaseColor, outlineColor: Cesium.Color.BLACK, outlineWidth: 2, // Never depth-cull the anchor against the photoreal mesh — @@ -749,7 +786,7 @@ export function createLocalGeoJsonLayer( position: tip, properties, priority, - accent: color, + accent: featureBaseColor.toCssHexString(), }) : null, }); @@ -1323,6 +1360,10 @@ function labelPriorityFromProperties(props, layerId) { if (props.output || tags['plant:output:electricity']) score += 120; if (layerId === 'local-dams') score += 80; if (layerId === 'local-datacenters') score += 60; + // Data Library features carry their manifest importance (0–600), below the + // named/unnamed gap so a rank never promotes an unnamed feature. + if (Number.isFinite(props.__gevRank)) + score += Math.max(0, Math.min(600, props.__gevRank)); return score; } diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index 76516c4e3..5b0da6060 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -3830,5 +3830,126 @@ "{source} imagery could not be opened": "تعذّر فتح صور {source}", "{place} has no position to look for imagery at": "لا يوجد لـ{place} موضع للبحث عن الصور فيه", "No {source} imagery near {place}": "لا توجد صور {source} قرب {place}", - "{reason} (filters on: {filters})": "{reason} (المرشحات المفعّلة: {filters})" + "{reason} (filters on: {filters})": "{reason} (المرشحات المفعّلة: {filters})", + "tiles unavailable": "البلاطات غير متاحة", + "zoom in to load this layer": "كبّر لتحميل هذه الطبقة", + "Zoom to layer": "تكبير إلى الطبقة", + "Zoom to layer · fly to where this layer has data": "تكبير إلى الطبقة · الطيران إلى حيث توجد بيانات هذه الطبقة", + "Remove from Data Layers": "إزالة من طبقات البيانات", + "Remove from Data Layers · add it again from the library": "إزالة من طبقات البيانات · أضِفها مجددًا من المكتبة", + "Coverage outline": "حدود التغطية", + "DRAPED": "على التضاريس", + "Below 250 m": "أقل من 250 م", + "250–500 m": "250–500 م", + "500–1,000 m": "500–1,000 م", + "1,000–2,000 m": "1,000–2,000 م", + "2,000–3,000 m": "2,000–3,000 م", + "3,000 m and above": "3,000 م فأكثر", + "Exit cockpit to fly to a layer": "اخرج من قمرة القيادة للطيران إلى طبقة", + "Library": "المكتبة", + "Environment": "البيئة", + "Imagery": "الصور", + "Demographics": "التركيبة السكانية", + "DATASET": "مجموعة بيانات", + "TILES": "بلاطات", + "PACK": "حزمة", + "INCUBATING": "قيد الاحتضان", + "COMMUNITY": "مجتمعي", + "VERIFIED": "موثّق", + "ARCHIVED": "مؤرشف", + "Datasets": "مجموعات البيانات", + "{datasets} · {tiles} · {packs}": "{datasets} · {tiles} · {packs}", + "{count} datasets": { + "zero": "{count} مجموعة بيانات", + "one": "{count} مجموعة بيانات", + "two": "{count} مجموعتا بيانات", + "few": "{count} مجموعات بيانات", + "many": "{count} مجموعة بيانات", + "other": "{count} مجموعة بيانات" + }, + "{count} tile layers": { + "zero": "{count} طبقة بلاطات", + "one": "{count} طبقة بلاطات", + "two": "{count} طبقتا بلاطات", + "few": "{count} طبقات بلاطات", + "many": "{count} طبقة بلاطات", + "other": "{count} طبقة بلاطات" + }, + "{count} packs": { + "zero": "{count} حزمة", + "one": "{count} حزمة", + "two": "{count} حزمتان", + "few": "{count} حزم", + "many": "{count} حزمة", + "other": "{count} حزمة" + }, + "Themes": "المواضيع", + "Added": "المُضافة", + "{count} on the map": { + "zero": "{count} على الخريطة", + "one": "{count} على الخريطة", + "two": "{count} على الخريطة", + "few": "{count} على الخريطة", + "many": "{count} على الخريطة", + "other": "{count} على الخريطة" + }, + "none yet": "لا شيء بعد", + "Map features": "معالم الخريطة", + "from {count} datasets": { + "zero": "من {count} مجموعة بيانات", + "one": "من {count} مجموعة بيانات", + "two": "من {count} مجموعتَي بيانات", + "few": "من {count} مجموعات بيانات", + "many": "من {count} مجموعة بيانات", + "other": "من {count} مجموعة بيانات" + }, + "switch on a dataset to load features": "شغّل مجموعة بيانات لتحميل المعالم", + "Switched on by this instance · part of the CCTV layer": "مُفعّل من هذا المثيل · جزء من طبقة CCTV", + "Operator opt-in: set {env}=1 on the server": "تفعيل من المشغّل: اضبط {env}=1 على الخادم", + "Operator opt-in: add {id} to GEV_LIBRARY_ENABLE on the server": "تفعيل من المشغّل: أضِف {id} إلى GEV_LIBRARY_ENABLE على الخادم", + "Turned off by this instance's operator": "أوقفه مشغّل هذا المثيل", + "Needs a provider key on the server": "يتطلب مفتاح مزوّد على الخادم", + "Needs this app's server · not available in a static build": "يتطلب خادم هذا التطبيق · غير متاح في إصدار ثابت", + "Checking what this instance serves…": "جارٍ التحقق مما يقدّمه هذا المثيل…", + "Regional": "إقليمي", + "Orthophoto": "صورة متعامدة", + "Satellite mosaic": "فسيفساء أقمار صناعية", + "Oblique": "مائلة", + "Historic imagery": "صور تاريخية", + "True orthophoto": "صورة متعامدة حقيقية", + "Type": "النوع", + "Resolution": "الدقة", + "Flown": "تاريخ التصوير", + "Updates": "التحديث", + "Ortho quality": "جودة التعامد", + "DATA LIBRARY": "مكتبة البيانات", + "Close data library": "إغلاق مكتبة البيانات", + "Data Library at a glance": "مكتبة البيانات في لمحة", + "Live sources beyond the core layers. Each one is fetched from its provider while it is on; nothing is stored in this app. Add one and it appears in Data Layers under Library.": "مصادر مباشرة تتجاوز الطبقات الأساسية. يُجلب كل منها من مزوّده ما دام مفعّلًا؛ ولا يُخزَّن شيء في هذا التطبيق. أضِف واحدًا وسيظهر في طبقات البيانات ضمن المكتبة.", + "Name, provider or country": "الاسم أو المزوّد أو البلد", + "Theme": "الموضوع", + "All themes": "كل المواضيع", + "Only what this instance serves": "فقط ما يقدّمه هذا المثيل", + "OPT-IN": "اختياري", + "KEY": "مفتاح", + "KEYLESS": "بلا مفتاح", + "ZOOM": "تكبير", + "Zoom to {name}": "تكبير إلى {name}", + "WORKING…": "جارٍ العمل…", + "ADD": "إضافة", + "Remove {name} from Data Layers": "إزالة {name} من طبقات البيانات", + "Add {name} to Data Layers": "إضافة {name} إلى طبقات البيانات", + "CONFIRM · REMOVE ALL {count}": { + "zero": "تأكيد · إزالة الكل ({count})", + "one": "تأكيد · إزالة {count}", + "two": "تأكيد · إزالة الكل ({count})", + "few": "تأكيد · إزالة الكل ({count})", + "many": "تأكيد · إزالة الكل ({count})", + "other": "تأكيد · إزالة الكل ({count})" + }, + "REMOVE ALL": "إزالة الكل", + "Confirm: remove every added entry from Data Layers": "تأكيد: إزالة كل الإدخالات المُضافة من طبقات البيانات", + "Remove every added entry from Data Layers": "إزالة كل الإدخالات المُضافة من طبقات البيانات", + "Showing {shown} of {total}": "عرض {shown} من {total}", + "BROWSE DATA LIBRARY": "تصفّح مكتبة البيانات" } diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index b00084ebc..55b9301f9 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -3506,5 +3506,102 @@ "{source} imagery could not be opened": "Bilder von {source} konnten nicht geöffnet werden", "{place} has no position to look for imagery at": "{place} hat keine Position, an der nach Bildern gesucht werden kann", "No {source} imagery near {place}": "Keine Bilder von {source} in der Nähe von {place}", - "{reason} (filters on: {filters})": "{reason} (aktive Filter: {filters})" + "{reason} (filters on: {filters})": "{reason} (aktive Filter: {filters})", + "tiles unavailable": "Kacheln nicht verfügbar", + "zoom in to load this layer": "hineinzoomen, um diese Ebene zu laden", + "Zoom to layer": "Zur Ebene zoomen", + "Zoom to layer · fly to where this layer has data": "Zur Ebene zoomen · dorthin fliegen, wo diese Ebene Daten hat", + "Remove from Data Layers": "Aus Datenebenen entfernen", + "Remove from Data Layers · add it again from the library": "Aus Datenebenen entfernen · über die Bibliothek erneut hinzufügen", + "Coverage outline": "Abdeckungsumriss", + "DRAPED": "DRAPIERT", + "Below 250 m": "Unter 250 m", + "250–500 m": "250–500 m", + "500–1,000 m": "500–1.000 m", + "1,000–2,000 m": "1.000–2.000 m", + "2,000–3,000 m": "2.000–3.000 m", + "3,000 m and above": "3.000 m und mehr", + "Exit cockpit to fly to a layer": "Cockpit verlassen, um zu einer Ebene zu fliegen", + "Library": "Bibliothek", + "Environment": "Umwelt", + "Imagery": "Bildmaterial", + "Demographics": "Demografie", + "DATASET": "DATENSATZ", + "TILES": "KACHELN", + "PACK": "PAKET", + "INCUBATING": "IN ERPROBUNG", + "COMMUNITY": "COMMUNITY", + "VERIFIED": "GEPRÜFT", + "ARCHIVED": "ARCHIVIERT", + "Datasets": "Datensätze", + "{datasets} · {tiles} · {packs}": "{datasets} · {tiles} · {packs}", + "{count} datasets": { + "one": "{count} Datensatz", + "other": "{count} Datensätze" + }, + "{count} tile layers": { + "one": "{count} Kachelebene", + "other": "{count} Kachelebenen" + }, + "{count} packs": { + "one": "{count} Paket", + "other": "{count} Pakete" + }, + "Themes": "Themen", + "Added": "Hinzugefügt", + "{count} on the map": { + "one": "{count} auf der Karte", + "other": "{count} auf der Karte" + }, + "none yet": "noch keine", + "Map features": "Kartenobjekte", + "from {count} datasets": { + "one": "aus {count} Datensatz", + "other": "aus {count} Datensätzen" + }, + "switch on a dataset to load features": "einen Datensatz einschalten, um Objekte zu laden", + "Switched on by this instance · part of the CCTV layer": "Von dieser Instanz eingeschaltet · Teil der CCTV-Ebene", + "Operator opt-in: set {env}=1 on the server": "Opt-in durch Betreiber: {env}=1 auf dem Server setzen", + "Operator opt-in: add {id} to GEV_LIBRARY_ENABLE on the server": "Opt-in durch Betreiber: {id} zu GEV_LIBRARY_ENABLE auf dem Server hinzufügen", + "Turned off by this instance's operator": "Vom Betreiber dieser Instanz ausgeschaltet", + "Needs a provider key on the server": "Benötigt einen Anbieterschlüssel auf dem Server", + "Needs this app's server · not available in a static build": "Benötigt den Server dieser App · in einem statischen Build nicht verfügbar", + "Checking what this instance serves…": "Prüfe, was diese Instanz bereitstellt…", + "Regional": "Regional", + "Orthophoto": "Orthofoto", + "Satellite mosaic": "Satellitenmosaik", + "Oblique": "Schrägluftbild", + "Historic imagery": "Historische Luftbilder", + "True orthophoto": "True Orthofoto", + "Type": "Typ", + "Resolution": "Auflösung", + "Flown": "Befliegung", + "Updates": "Aktualisierung", + "Ortho quality": "Orthoqualität", + "DATA LIBRARY": "DATENBIBLIOTHEK", + "Close data library": "Datenbibliothek schließen", + "Data Library at a glance": "Datenbibliothek auf einen Blick", + "Live sources beyond the core layers. Each one is fetched from its provider while it is on; nothing is stored in this app. Add one and it appears in Data Layers under Library.": "Live-Quellen über die Kernebenen hinaus. Jede wird von ihrem Anbieter abgerufen, solange sie eingeschaltet ist; in dieser App wird nichts gespeichert. Fügen Sie eine hinzu, und sie erscheint in den Datenebenen unter Bibliothek.", + "Name, provider or country": "Name, Anbieter oder Land", + "Theme": "Thema", + "All themes": "Alle Themen", + "Only what this instance serves": "Nur was diese Instanz bereitstellt", + "OPT-IN": "OPT-IN", + "KEY": "SCHLÜSSEL", + "KEYLESS": "OHNE SCHLÜSSEL", + "ZOOM": "ZOOMEN", + "Zoom to {name}": "Zu {name} zoomen", + "WORKING…": "ARBEITET…", + "ADD": "HINZUFÜGEN", + "Remove {name} from Data Layers": "{name} aus Datenebenen entfernen", + "Add {name} to Data Layers": "{name} zu Datenebenen hinzufügen", + "CONFIRM · REMOVE ALL {count}": { + "one": "BESTÄTIGEN · {count} ENTFERNEN", + "other": "BESTÄTIGEN · ALLE {count} ENTFERNEN" + }, + "REMOVE ALL": "ALLE ENTFERNEN", + "Confirm: remove every added entry from Data Layers": "Bestätigen: alle hinzugefügten Einträge aus Datenebenen entfernen", + "Remove every added entry from Data Layers": "Alle hinzugefügten Einträge aus Datenebenen entfernen", + "Showing {shown} of {total}": "{shown} von {total} angezeigt", + "BROWSE DATA LIBRARY": "DATENBIBLIOTHEK DURCHSUCHEN" } diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 525306c35..3f7a5e7b0 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -3587,5 +3587,108 @@ "{source} imagery could not be opened": "No se pudieron abrir las imágenes de {source}", "{place} has no position to look for imagery at": "{place} no tiene una posición donde buscar imágenes", "No {source} imagery near {place}": "No hay imágenes de {source} cerca de {place}", - "{reason} (filters on: {filters})": "{reason} (filtros activos: {filters})" + "{reason} (filters on: {filters})": "{reason} (filtros activos: {filters})", + "tiles unavailable": "teselas no disponibles", + "Environment": "Medio ambiente", + "Imagery": "Imágenes", + "DATASET": "DATOS", + "TILES": "TESELAS", + "PACK": "PAQUETE", + "INCUBATING": "EN INCUBACIÓN", + "COMMUNITY": "COMUNIDAD", + "VERIFIED": "VERIFICADO", + "ARCHIVED": "ARCHIVADO", + "Switched on by this instance · part of the CCTV layer": "Activado en esta instancia · parte de la capa CCTV", + "Operator opt-in: set {env}=1 on the server": "Activación del operador: define {env}=1 en el servidor", + "Operator opt-in: add {id} to GEV_LIBRARY_ENABLE on the server": "Activación del operador: añade {id} a GEV_LIBRARY_ENABLE en el servidor", + "Turned off by this instance's operator": "Desactivado por el operador de esta instancia", + "Needs a provider key on the server": "Necesita una clave del proveedor en el servidor", + "Needs this app's server · not available in a static build": "Necesita el servidor de esta app · no disponible en una compilación estática", + "Checking what this instance serves…": "Comprobando qué ofrece esta instancia…", + "Regional": "Regional", + "DATA LIBRARY": "BIBLIOTECA DE DATOS", + "Close data library": "Cerrar la biblioteca de datos", + "Live sources beyond the core layers. Each one is fetched from its provider while it is on; nothing is stored in this app. Add one and it appears in Data Layers under Library.": "Fuentes en vivo más allá de las capas principales. Cada una se obtiene de su proveedor mientras está activa; esta app no almacena nada. Añade una y aparecerá en Capas de datos, en Biblioteca.", + "Name, provider or country": "Nombre, proveedor o país", + "Theme": "Tema", + "All themes": "Todos los temas", + "Only what this instance serves": "Solo lo que ofrece esta instancia", + "OPT-IN": "OPCIONAL", + "KEY": "CLAVE", + "KEYLESS": "SIN CLAVE", + "WORKING…": "PROCESANDO…", + "ADD": "AÑADIR", + "Remove {name} from Data Layers": "Quitar {name} de Capas de datos", + "Add {name} to Data Layers": "Añadir {name} a Capas de datos", + "Library": "Biblioteca", + "BROWSE DATA LIBRARY": "EXPLORAR BIBLIOTECA DE DATOS", + "DRAPED": "SUPERPUESTO", + "Zoom to layer": "Acercar a la capa", + "ZOOM": "ACERCAR", + "Zoom to {name}": "Acercar a {name}", + "CONFIRM · REMOVE ALL {count}": { + "one": "CONFIRMAR · QUITAR {count}", + "many": "CONFIRMAR · QUITAR LAS {count}", + "other": "CONFIRMAR · QUITAR LAS {count}" + }, + "REMOVE ALL": "QUITAR TODO", + "Confirm: remove every added entry from Data Layers": "Confirmar: quitar de Capas de datos todas las entradas añadidas", + "Remove every added entry from Data Layers": "Quitar de Capas de datos todas las entradas añadidas", + "Exit cockpit to fly to a layer": "Sal de la cabina para volar a una capa", + "Zoom to layer · fly to where this layer has data": "Acercar a la capa · vuela a donde esta capa tiene datos", + "Remove from Data Layers": "Quitar de Capas de datos", + "Remove from Data Layers · add it again from the library": "Quitar de Capas de datos · vuelve a añadirla desde la biblioteca", + "{datasets} · {tiles} · {packs}": "{datasets} · {tiles} · {packs}", + "{count} datasets": { + "one": "{count} conjunto de datos", + "many": "{count} de conjuntos de datos", + "other": "{count} conjuntos de datos" + }, + "{count} tile layers": { + "one": "{count} capa de teselas", + "many": "{count} de capas de teselas", + "other": "{count} capas de teselas" + }, + "{count} packs": { + "one": "{count} paquete", + "many": "{count} de paquetes", + "other": "{count} paquetes" + }, + "Added": "Añadidas", + "{count} on the map": { + "one": "{count} en el mapa", + "many": "{count} en el mapa", + "other": "{count} en el mapa" + }, + "none yet": "ninguna todavía", + "from {count} datasets": { + "one": "de {count} conjunto de datos", + "many": "de {count} de conjuntos de datos", + "other": "de {count} conjuntos de datos" + }, + "switch on a dataset to load features": "activa un conjunto de datos para cargar elementos", + "Data Library at a glance": "La biblioteca de datos de un vistazo", + "Map features": "Elementos del mapa", + "Showing {shown} of {total}": "Mostrando {shown} de {total}", + "Datasets": "Conjuntos de datos", + "Themes": "Temas", + "zoom in to load this layer": "acércate para cargar esta capa", + "Demographics": "Demografía", + "Coverage outline": "Contorno de cobertura", + "Orthophoto": "Ortofoto", + "Satellite mosaic": "Mosaico satelital", + "Oblique": "Oblicua", + "Historic imagery": "Imágenes históricas", + "True orthophoto": "Ortofoto verdadera", + "Type": "Tipo", + "Resolution": "Resolución", + "Flown": "Vuelo", + "Updates": "Actualización", + "Ortho quality": "Calidad del orto", + "Below 250 m": "Menos de 250 m", + "250–500 m": "250–500 m", + "500–1,000 m": "500–1000 m", + "1,000–2,000 m": "1000–2000 m", + "2,000–3,000 m": "2000–3000 m", + "3,000 m and above": "3000 m o más" } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 7c4a54cf2..870d75390 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -3587,5 +3587,108 @@ "{source} imagery could not be opened": "Impossible d'ouvrir les images de {source}", "{place} has no position to look for imagery at": "{place} n'a pas de position où chercher des images", "No {source} imagery near {place}": "Aucune image {source} près de {place}", - "{reason} (filters on: {filters})": "{reason} (filtres actifs : {filters})" + "{reason} (filters on: {filters})": "{reason} (filtres actifs : {filters})", + "tiles unavailable": "tuiles indisponibles", + "zoom in to load this layer": "zoomez pour charger cette couche", + "Zoom to layer": "Zoomer sur la couche", + "Zoom to layer · fly to where this layer has data": "Zoomer sur la couche · voler là où cette couche a des données", + "Remove from Data Layers": "Retirer des Couches de données", + "Remove from Data Layers · add it again from the library": "Retirer des Couches de données · ajoutez-la à nouveau depuis la bibliothèque", + "Coverage outline": "Contour de couverture", + "DRAPED": "DRAPÉ", + "Below 250 m": "Moins de 250 m", + "250–500 m": "250–500 m", + "500–1,000 m": "500–1 000 m", + "1,000–2,000 m": "1 000–2 000 m", + "2,000–3,000 m": "2 000–3 000 m", + "3,000 m and above": "3 000 m et plus", + "Exit cockpit to fly to a layer": "Quittez le cockpit pour voler vers une couche", + "Library": "Bibliothèque", + "Environment": "Environnement", + "Imagery": "Imagerie", + "Demographics": "Démographie", + "DATASET": "JEU DE DONNÉES", + "TILES": "TUILES", + "PACK": "PACK", + "INCUBATING": "EN INCUBATION", + "COMMUNITY": "COMMUNAUTÉ", + "VERIFIED": "VÉRIFIÉ", + "ARCHIVED": "ARCHIVÉ", + "Datasets": "Jeux de données", + "{datasets} · {tiles} · {packs}": "{datasets} · {tiles} · {packs}", + "{count} datasets": { + "one": "{count} jeu de données", + "many": "{count} de jeux de données", + "other": "{count} jeux de données" + }, + "{count} tile layers": { + "one": "{count} couche de tuiles", + "many": "{count} de couches de tuiles", + "other": "{count} couches de tuiles" + }, + "{count} packs": { + "one": "{count} pack", + "many": "{count} de packs", + "other": "{count} packs" + }, + "Themes": "Thèmes", + "Added": "Ajoutées", + "{count} on the map": { + "one": "{count} sur la carte", + "many": "{count} sur la carte", + "other": "{count} sur la carte" + }, + "none yet": "aucune pour l'instant", + "Map features": "Entités cartographiques", + "from {count} datasets": { + "one": "issues de {count} jeu de données", + "many": "issues de {count} de jeux de données", + "other": "issues de {count} jeux de données" + }, + "switch on a dataset to load features": "activez un jeu de données pour charger des entités", + "Switched on by this instance · part of the CCTV layer": "Activé par cette instance · fait partie de la couche CCTV", + "Operator opt-in: set {env}=1 on the server": "Activation par l'opérateur : définir {env}=1 sur le serveur", + "Operator opt-in: add {id} to GEV_LIBRARY_ENABLE on the server": "Activation par l'opérateur : ajouter {id} à GEV_LIBRARY_ENABLE sur le serveur", + "Turned off by this instance's operator": "Désactivé par l'opérateur de cette instance", + "Needs a provider key on the server": "Nécessite une clé du fournisseur sur le serveur", + "Needs this app's server · not available in a static build": "Nécessite le serveur de cette app · indisponible dans une version statique", + "Checking what this instance serves…": "Vérification de ce que propose cette instance…", + "Regional": "Régional", + "Orthophoto": "Orthophoto", + "Satellite mosaic": "Mosaïque satellite", + "Oblique": "Oblique", + "Historic imagery": "Imagerie historique", + "True orthophoto": "Orthophoto vraie", + "Type": "Type", + "Resolution": "Résolution", + "Flown": "Vol", + "Updates": "Mise à jour", + "Ortho quality": "Qualité ortho", + "DATA LIBRARY": "BIBLIOTHÈQUE DE DONNÉES", + "Close data library": "Fermer la bibliothèque de données", + "Data Library at a glance": "La bibliothèque de données en un coup d'œil", + "Live sources beyond the core layers. Each one is fetched from its provider while it is on; nothing is stored in this app. Add one and it appears in Data Layers under Library.": "Sources en direct au-delà des couches principales. Chacune est récupérée auprès de son fournisseur tant qu'elle est active ; rien n'est stocké dans cette app. Ajoutez-en une et elle apparaît dans Couches de données, sous Bibliothèque.", + "Name, provider or country": "Nom, fournisseur ou pays", + "Theme": "Thème", + "All themes": "Tous les thèmes", + "Only what this instance serves": "Seulement ce que propose cette instance", + "OPT-IN": "SUR ACTIVATION", + "KEY": "CLÉ", + "KEYLESS": "SANS CLÉ", + "ZOOM": "ZOOMER", + "Zoom to {name}": "Zoomer sur {name}", + "WORKING…": "EN COURS…", + "ADD": "AJOUTER", + "Remove {name} from Data Layers": "Retirer {name} des Couches de données", + "Add {name} to Data Layers": "Ajouter {name} aux Couches de données", + "CONFIRM · REMOVE ALL {count}": { + "one": "CONFIRMER · RETIRER {count}", + "many": "CONFIRMER · RETIRER LES {count}", + "other": "CONFIRMER · RETIRER LES {count}" + }, + "REMOVE ALL": "TOUT RETIRER", + "Confirm: remove every added entry from Data Layers": "Confirmer : retirer des Couches de données toutes les entrées ajoutées", + "Remove every added entry from Data Layers": "Retirer des Couches de données toutes les entrées ajoutées", + "Showing {shown} of {total}": "{shown} sur {total} affichés", + "BROWSE DATA LIBRARY": "PARCOURIR LA BIBLIOTHÈQUE DE DONNÉES" } diff --git a/src/i18n/locales/he.json b/src/i18n/locales/he.json index c28e10ba2..b0bd1eac2 100644 --- a/src/i18n/locales/he.json +++ b/src/i18n/locales/he.json @@ -3587,5 +3587,108 @@ "{source} imagery could not be opened": "לא ניתן היה לפתוח את הצילומים של {source}", "{place} has no position to look for imagery at": "ל-{place} אין מיקום לחיפוש צילומים", "No {source} imagery near {place}": "אין צילומים של {source} ליד {place}", - "{reason} (filters on: {filters})": "{reason} (מסננים פעילים: {filters})" + "{reason} (filters on: {filters})": "{reason} (מסננים פעילים: {filters})", + "tiles unavailable": "האריחים אינם זמינים", + "zoom in to load this layer": "התקרב כדי לטעון את השכבה הזאת", + "Zoom to layer": "התקרבות לשכבה", + "Zoom to layer · fly to where this layer has data": "התקרבות לשכבה · טיסה למקום שבו יש לשכבה נתונים", + "Remove from Data Layers": "הסרה משכבות נתונים", + "Remove from Data Layers · add it again from the library": "הסרה משכבות נתונים · אפשר להוסיף אותה שוב מהספרייה", + "Coverage outline": "קו מתאר של הכיסוי", + "DRAPED": "פרוש על הקרקע", + "Below 250 m": "מתחת ל-250 מ׳", + "250–500 m": "250–500 מ׳", + "500–1,000 m": "500–1,000 מ׳", + "1,000–2,000 m": "1,000–2,000 מ׳", + "2,000–3,000 m": "2,000–3,000 מ׳", + "3,000 m and above": "3,000 מ׳ ומעלה", + "Exit cockpit to fly to a layer": "צא מתא הטייס כדי לטוס אל שכבה", + "Library": "ספרייה", + "Environment": "סביבה", + "Imagery": "צילומים", + "Demographics": "דמוגרפיה", + "DATASET": "מערך נתונים", + "TILES": "אריחים", + "PACK": "חבילה", + "INCUBATING": "בהבשלה", + "COMMUNITY": "קהילה", + "VERIFIED": "מאומת", + "ARCHIVED": "בארכיון", + "Datasets": "מערכי נתונים", + "{datasets} · {tiles} · {packs}": "{datasets} · {tiles} · {packs}", + "{count} datasets": { + "one": "{count} מערך נתונים", + "two": "{count} מערכי נתונים", + "other": "{count} מערכי נתונים" + }, + "{count} tile layers": { + "one": "{count} שכבת אריחים", + "two": "{count} שכבות אריחים", + "other": "{count} שכבות אריחים" + }, + "{count} packs": { + "one": "{count} חבילה", + "two": "{count} חבילות", + "other": "{count} חבילות" + }, + "Themes": "נושאים", + "Added": "נוספו", + "{count} on the map": { + "one": "{count} על המפה", + "two": "{count} על המפה", + "other": "{count} על המפה" + }, + "none yet": "אין עדיין", + "Map features": "ישויות במפה", + "from {count} datasets": { + "one": "מ-{count} מערך נתונים", + "two": "מ-{count} מערכי נתונים", + "other": "מ-{count} מערכי נתונים" + }, + "switch on a dataset to load features": "הפעל מערך נתונים כדי לטעון ישויות", + "Switched on by this instance · part of the CCTV layer": "מופעל במופע הזה · חלק משכבת CCTV", + "Operator opt-in: set {env}=1 on the server": "הפעלה ביוזמת המפעיל: הגדר {env}=1 בשרת", + "Operator opt-in: add {id} to GEV_LIBRARY_ENABLE on the server": "הפעלה ביוזמת המפעיל: הוסף את {id} ל-GEV_LIBRARY_ENABLE בשרת", + "Turned off by this instance's operator": "כובה על ידי המפעיל של המופע הזה", + "Needs a provider key on the server": "נדרש מפתח ספק בשרת", + "Needs this app's server · not available in a static build": "נדרש השרת של האפליקציה · לא זמין בבנייה סטטית", + "Checking what this instance serves…": "בודק מה המופע הזה מספק…", + "Regional": "אזורי", + "Orthophoto": "אורתופוטו", + "Satellite mosaic": "פסיפס לוויני", + "Oblique": "צילום אלכסוני", + "Historic imagery": "צילומים היסטוריים", + "True orthophoto": "אורתופוטו אמיתי", + "Type": "סוג", + "Resolution": "רזולוציה", + "Flown": "צולם מהאוויר", + "Updates": "עדכונים", + "Ortho quality": "איכות האורתו", + "DATA LIBRARY": "ספריית נתונים", + "Close data library": "סגירת ספריית הנתונים", + "Data Library at a glance": "ספריית הנתונים במבט חטוף", + "Live sources beyond the core layers. Each one is fetched from its provider while it is on; nothing is stored in this app. Add one and it appears in Data Layers under Library.": "מקורות חיים מעבר לשכבות הליבה. כל מקור נטען מהספק שלו כל עוד הוא מופעל; דבר אינו נשמר באפליקציה. הוסף מקור והוא יופיע בשכבות נתונים תחת ספרייה.", + "Name, provider or country": "שם, ספק או מדינה", + "Theme": "נושא", + "All themes": "כל הנושאים", + "Only what this instance serves": "רק מה שהמופע הזה מספק", + "OPT-IN": "בהפעלה ידנית", + "KEY": "מפתח", + "KEYLESS": "ללא מפתח", + "ZOOM": "התקרבות", + "Zoom to {name}": "התקרבות אל {name}", + "WORKING…": "מבצע…", + "ADD": "הוספה", + "Remove {name} from Data Layers": "הסרת {name} משכבות נתונים", + "Add {name} to Data Layers": "הוספת {name} לשכבות נתונים", + "CONFIRM · REMOVE ALL {count}": { + "one": "אישור · הסרת {count}", + "two": "אישור · הסרת כל {count}", + "other": "אישור · הסרת כל {count}" + }, + "REMOVE ALL": "הסרת הכול", + "Confirm: remove every added entry from Data Layers": "אישור: הסרת כל הפריטים שנוספו משכבות נתונים", + "Remove every added entry from Data Layers": "הסרת כל הפריטים שנוספו משכבות נתונים", + "Showing {shown} of {total}": "מוצגים {shown} מתוך {total}", + "BROWSE DATA LIBRARY": "עיון בספריית הנתונים" } diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 200fb17f4..9a840d7c1 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -3587,5 +3587,108 @@ "{source} imagery could not be opened": "Impossibile aprire le immagini di {source}", "{place} has no position to look for imagery at": "{place} non ha una posizione in cui cercare immagini", "No {source} imagery near {place}": "Nessuna immagine di {source} vicino a {place}", - "{reason} (filters on: {filters})": "{reason} (filtri attivi: {filters})" + "{reason} (filters on: {filters})": "{reason} (filtri attivi: {filters})", + "tiles unavailable": "tile non disponibili", + "zoom in to load this layer": "avvicinati per caricare questo livello", + "Zoom to layer": "Zoom sul livello", + "Zoom to layer · fly to where this layer has data": "Zoom sul livello · vola dove questo livello ha dati", + "Remove from Data Layers": "Rimuovi da Livelli dati", + "Remove from Data Layers · add it again from the library": "Rimuovi da Livelli dati · aggiungilo di nuovo dalla libreria", + "Coverage outline": "Contorno di copertura", + "DRAPED": "DRAPPEGGIATO", + "Below 250 m": "Sotto i 250 m", + "250–500 m": "250–500 m", + "500–1,000 m": "500–1.000 m", + "1,000–2,000 m": "1.000–2.000 m", + "2,000–3,000 m": "2.000–3.000 m", + "3,000 m and above": "3.000 m e oltre", + "Exit cockpit to fly to a layer": "Esci dalla cabina di pilotaggio per volare a un livello", + "Library": "Libreria", + "Environment": "Ambiente", + "Imagery": "Immagini", + "Demographics": "Demografia", + "DATASET": "DATASET", + "TILES": "TILE", + "PACK": "PACCHETTO", + "INCUBATING": "IN INCUBAZIONE", + "COMMUNITY": "COMUNITÀ", + "VERIFIED": "VERIFICATO", + "ARCHIVED": "ARCHIVIATO", + "Datasets": "Dataset", + "{datasets} · {tiles} · {packs}": "{datasets} · {tiles} · {packs}", + "{count} datasets": { + "one": "{count} dataset", + "many": "{count} di dataset", + "other": "{count} dataset" + }, + "{count} tile layers": { + "one": "{count} livello di tile", + "many": "{count} di livelli di tile", + "other": "{count} livelli di tile" + }, + "{count} packs": { + "one": "{count} pacchetto", + "many": "{count} di pacchetti", + "other": "{count} pacchetti" + }, + "Themes": "Temi", + "Added": "Aggiunti", + "{count} on the map": { + "one": "{count} sulla mappa", + "many": "{count} sulla mappa", + "other": "{count} sulla mappa" + }, + "none yet": "ancora nessuno", + "Map features": "Elementi della mappa", + "from {count} datasets": { + "one": "da {count} dataset", + "many": "da {count} di dataset", + "other": "da {count} dataset" + }, + "switch on a dataset to load features": "attiva un dataset per caricare gli elementi", + "Switched on by this instance · part of the CCTV layer": "Attivato da questa istanza · parte del livello CCTV", + "Operator opt-in: set {env}=1 on the server": "Attivazione dell'operatore: imposta {env}=1 sul server", + "Operator opt-in: add {id} to GEV_LIBRARY_ENABLE on the server": "Attivazione dell'operatore: aggiungi {id} a GEV_LIBRARY_ENABLE sul server", + "Turned off by this instance's operator": "Disattivato dall'operatore di questa istanza", + "Needs a provider key on the server": "Richiede una chiave del fornitore sul server", + "Needs this app's server · not available in a static build": "Richiede il server di questa app · non disponibile in una build statica", + "Checking what this instance serves…": "Verifica di ciò che offre questa istanza…", + "Regional": "Regionale", + "Orthophoto": "Ortofoto", + "Satellite mosaic": "Mosaico satellitare", + "Oblique": "Obliqua", + "Historic imagery": "Immagini storiche", + "True orthophoto": "Ortofoto vera", + "Type": "Tipo", + "Resolution": "Risoluzione", + "Flown": "Volo", + "Updates": "Aggiornamenti", + "Ortho quality": "Qualità ortofoto", + "DATA LIBRARY": "LIBRERIA DATI", + "Close data library": "Chiudi la libreria dati", + "Data Library at a glance": "La libreria dati in sintesi", + "Live sources beyond the core layers. Each one is fetched from its provider while it is on; nothing is stored in this app. Add one and it appears in Data Layers under Library.": "Fonti in tempo reale oltre ai livelli principali. Ognuna viene caricata dal suo fornitore finché è attiva; questa app non memorizza nulla. Aggiungine una e comparirà in Livelli dati, sotto Libreria.", + "Name, provider or country": "Nome, fornitore o paese", + "Theme": "Tema", + "All themes": "Tutti i temi", + "Only what this instance serves": "Solo ciò che offre questa istanza", + "OPT-IN": "SU ATTIVAZIONE", + "KEY": "CHIAVE", + "KEYLESS": "SENZA CHIAVE", + "ZOOM": "ZOOM", + "Zoom to {name}": "Zoom su {name}", + "WORKING…": "IN CORSO…", + "ADD": "AGGIUNGI", + "Remove {name} from Data Layers": "Rimuovi {name} da Livelli dati", + "Add {name} to Data Layers": "Aggiungi {name} a Livelli dati", + "CONFIRM · REMOVE ALL {count}": { + "one": "CONFERMA · RIMUOVI {count}", + "many": "CONFERMA · RIMUOVI TUTTI I {count}", + "other": "CONFERMA · RIMUOVI TUTTI I {count}" + }, + "REMOVE ALL": "RIMUOVI TUTTO", + "Confirm: remove every added entry from Data Layers": "Conferma: rimuovi da Livelli dati tutte le voci aggiunte", + "Remove every added entry from Data Layers": "Rimuovi da Livelli dati tutte le voci aggiunte", + "Showing {shown} of {total}": "{shown} di {total} mostrati", + "BROWSE DATA LIBRARY": "ESPLORA LIBRERIA DATI" } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 0df76fc4e..5dc71c358 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -3425,5 +3425,96 @@ "{source} imagery could not be opened": "{source}の画像を開けませんでした", "{place} has no position to look for imagery at": "{place}には画像を探す位置がありません", "No {source} imagery near {place}": "{place}の近くに{source}の画像はありません", - "{reason} (filters on: {filters})": "{reason}(有効なフィルター: {filters})" + "{reason} (filters on: {filters})": "{reason}(有効なフィルター: {filters})", + "tiles unavailable": "タイルを利用できません", + "zoom in to load this layer": "ズームインしてこのレイヤーを読み込む", + "Zoom to layer": "レイヤーにズーム", + "Zoom to layer · fly to where this layer has data": "レイヤーにズーム · このレイヤーのデータがある場所へ移動", + "Remove from Data Layers": "データレイヤーから削除", + "Remove from Data Layers · add it again from the library": "データレイヤーから削除 · ライブラリから再度追加できます", + "Coverage outline": "カバー範囲の輪郭", + "DRAPED": "地形に投影", + "Below 250 m": "250 m 未満", + "250–500 m": "250–500 m", + "500–1,000 m": "500–1,000 m", + "1,000–2,000 m": "1,000–2,000 m", + "2,000–3,000 m": "2,000–3,000 m", + "3,000 m and above": "3,000 m 以上", + "Exit cockpit to fly to a layer": "レイヤーへ移動するにはコックピットを終了してください", + "Library": "ライブラリ", + "Environment": "環境", + "Imagery": "画像", + "Demographics": "人口統計", + "DATASET": "データセット", + "TILES": "タイル", + "PACK": "パック", + "INCUBATING": "試験中", + "COMMUNITY": "コミュニティ", + "VERIFIED": "検証済み", + "ARCHIVED": "アーカイブ済み", + "Datasets": "データセット", + "{datasets} · {tiles} · {packs}": "{datasets} · {tiles} · {packs}", + "{count} datasets": { + "other": "{count} 件のデータセット" + }, + "{count} tile layers": { + "other": "{count} 件のタイルレイヤー" + }, + "{count} packs": { + "other": "{count} 件のパック" + }, + "Themes": "テーマ", + "Added": "追加済み", + "{count} on the map": { + "other": "地図上に {count} 件" + }, + "none yet": "まだありません", + "Map features": "地図上の地物", + "from {count} datasets": { + "other": "{count} 件のデータセットから" + }, + "switch on a dataset to load features": "データセットをオンにすると地物を読み込みます", + "Switched on by this instance · part of the CCTV layer": "このインスタンスで有効 · CCTV レイヤーの一部", + "Operator opt-in: set {env}=1 on the server": "運用者による有効化:サーバーで {env}=1 を設定", + "Operator opt-in: add {id} to GEV_LIBRARY_ENABLE on the server": "運用者による有効化:サーバーの GEV_LIBRARY_ENABLE に {id} を追加", + "Turned off by this instance's operator": "このインスタンスの運用者により無効化されています", + "Needs a provider key on the server": "サーバーにプロバイダーのキーが必要です", + "Needs this app's server · not available in a static build": "このアプリのサーバーが必要 · 静的ビルドでは利用できません", + "Checking what this instance serves…": "このインスタンスの提供内容を確認中…", + "Regional": "地域", + "Orthophoto": "オルソ画像", + "Satellite mosaic": "衛星モザイク", + "Oblique": "斜め写真", + "Historic imagery": "過去の画像", + "True orthophoto": "トゥルーオルソ画像", + "Type": "種類", + "Resolution": "解像度", + "Flown": "撮影", + "Updates": "更新", + "Ortho quality": "オルソ品質", + "DATA LIBRARY": "データライブラリ", + "Close data library": "データライブラリを閉じる", + "Data Library at a glance": "データライブラリの概要", + "Live sources beyond the core layers. Each one is fetched from its provider while it is on; nothing is stored in this app. Add one and it appears in Data Layers under Library.": "コアレイヤー以外のライブソースです。オンの間は各プロバイダーから取得され、このアプリには何も保存されません。追加すると、データレイヤーの「ライブラリ」に表示されます。", + "Name, provider or country": "名前、プロバイダー、国", + "Theme": "テーマ", + "All themes": "すべてのテーマ", + "Only what this instance serves": "このインスタンスが提供するもののみ", + "OPT-IN": "要有効化", + "KEY": "キー必須", + "KEYLESS": "キー不要", + "ZOOM": "ズーム", + "Zoom to {name}": "{name} にズーム", + "WORKING…": "処理中…", + "ADD": "追加", + "Remove {name} from Data Layers": "{name} をデータレイヤーから削除", + "Add {name} to Data Layers": "{name} をデータレイヤーに追加", + "CONFIRM · REMOVE ALL {count}": { + "other": "確認 · {count} 件をすべて削除" + }, + "REMOVE ALL": "すべて削除", + "Confirm: remove every added entry from Data Layers": "確認:追加したすべての項目をデータレイヤーから削除", + "Remove every added entry from Data Layers": "追加したすべての項目をデータレイヤーから削除", + "Showing {shown} of {total}": "{total} 件中 {shown} 件を表示", + "BROWSE DATA LIBRARY": "データライブラリを見る" } diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 926a02ce6..cb34c1594 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -3587,5 +3587,108 @@ "{source} imagery could not be opened": "Não foi possível abrir as imagens de {source}", "{place} has no position to look for imagery at": "{place} não tem uma posição para buscar imagens", "No {source} imagery near {place}": "Nenhuma imagem de {source} perto de {place}", - "{reason} (filters on: {filters})": "{reason} (filtros ativos: {filters})" + "{reason} (filters on: {filters})": "{reason} (filtros ativos: {filters})", + "tiles unavailable": "blocos indisponíveis", + "zoom in to load this layer": "aproxime para carregar esta camada", + "Zoom to layer": "Ir para a camada", + "Zoom to layer · fly to where this layer has data": "Ir para a camada · voe até onde esta camada tem dados", + "Remove from Data Layers": "Remover de Camadas de dados", + "Remove from Data Layers · add it again from the library": "Remover de Camadas de dados · adicione de novo pela biblioteca", + "Coverage outline": "Contorno de cobertura", + "DRAPED": "SOBREPOSTO", + "Below 250 m": "Abaixo de 250 m", + "250–500 m": "250–500 m", + "500–1,000 m": "500–1.000 m", + "1,000–2,000 m": "1.000–2.000 m", + "2,000–3,000 m": "2.000–3.000 m", + "3,000 m and above": "3.000 m ou mais", + "Exit cockpit to fly to a layer": "Saia da cabine para voar até uma camada", + "Library": "Biblioteca", + "Environment": "Meio ambiente", + "Imagery": "Imagens", + "Demographics": "Demografia", + "DATASET": "DADOS", + "TILES": "BLOCOS", + "PACK": "PACOTE", + "INCUBATING": "EM INCUBAÇÃO", + "COMMUNITY": "COMUNIDADE", + "VERIFIED": "VERIFICADO", + "ARCHIVED": "ARQUIVADO", + "Datasets": "Conjuntos de dados", + "{datasets} · {tiles} · {packs}": "{datasets} · {tiles} · {packs}", + "{count} datasets": { + "one": "{count} conjunto de dados", + "many": "{count} de conjuntos de dados", + "other": "{count} conjuntos de dados" + }, + "{count} tile layers": { + "one": "{count} camada de blocos", + "many": "{count} de camadas de blocos", + "other": "{count} camadas de blocos" + }, + "{count} packs": { + "one": "{count} pacote", + "many": "{count} de pacotes", + "other": "{count} pacotes" + }, + "Themes": "Temas", + "Added": "Adicionadas", + "{count} on the map": { + "one": "{count} no mapa", + "many": "{count} no mapa", + "other": "{count} no mapa" + }, + "none yet": "nenhuma ainda", + "Map features": "Elementos do mapa", + "from {count} datasets": { + "one": "de {count} conjunto de dados", + "many": "de {count} de conjuntos de dados", + "other": "de {count} conjuntos de dados" + }, + "switch on a dataset to load features": "ative um conjunto de dados para carregar elementos", + "Switched on by this instance · part of the CCTV layer": "Ativado nesta instância · parte da camada CCTV", + "Operator opt-in: set {env}=1 on the server": "Ativação pelo operador: defina {env}=1 no servidor", + "Operator opt-in: add {id} to GEV_LIBRARY_ENABLE on the server": "Ativação pelo operador: adicione {id} a GEV_LIBRARY_ENABLE no servidor", + "Turned off by this instance's operator": "Desativado pelo operador desta instância", + "Needs a provider key on the server": "Requer uma chave do provedor no servidor", + "Needs this app's server · not available in a static build": "Requer o servidor deste app · indisponível em uma build estática", + "Checking what this instance serves…": "Verificando o que esta instância oferece…", + "Regional": "Regional", + "Orthophoto": "Ortofoto", + "Satellite mosaic": "Mosaico de satélite", + "Oblique": "Oblíqua", + "Historic imagery": "Imagens históricas", + "True orthophoto": "Ortofoto verdadeira", + "Type": "Tipo", + "Resolution": "Resolução", + "Flown": "Voo", + "Updates": "Atualização", + "Ortho quality": "Qualidade da ortofoto", + "DATA LIBRARY": "BIBLIOTECA DE DADOS", + "Close data library": "Fechar a biblioteca de dados", + "Data Library at a glance": "A biblioteca de dados em resumo", + "Live sources beyond the core layers. Each one is fetched from its provider while it is on; nothing is stored in this app. Add one and it appears in Data Layers under Library.": "Fontes ao vivo além das camadas principais. Cada uma é obtida do seu provedor enquanto está ativa; nada é armazenado neste app. Adicione uma e ela aparecerá em Camadas de dados, em Biblioteca.", + "Name, provider or country": "Nome, provedor ou país", + "Theme": "Tema", + "All themes": "Todos os temas", + "Only what this instance serves": "Só o que esta instância oferece", + "OPT-IN": "OPCIONAL", + "KEY": "CHAVE", + "KEYLESS": "SEM CHAVE", + "ZOOM": "APROXIMAR", + "Zoom to {name}": "Ir para {name}", + "WORKING…": "PROCESSANDO…", + "ADD": "ADICIONAR", + "Remove {name} from Data Layers": "Remover {name} de Camadas de dados", + "Add {name} to Data Layers": "Adicionar {name} a Camadas de dados", + "CONFIRM · REMOVE ALL {count}": { + "one": "CONFIRMAR · REMOVER {count}", + "many": "CONFIRMAR · REMOVER TODAS AS {count}", + "other": "CONFIRMAR · REMOVER TODAS AS {count}" + }, + "REMOVE ALL": "REMOVER TUDO", + "Confirm: remove every added entry from Data Layers": "Confirmar: remover de Camadas de dados todas as entradas adicionadas", + "Remove every added entry from Data Layers": "Remover de Camadas de dados todas as entradas adicionadas", + "Showing {shown} of {total}": "Mostrando {shown} de {total}", + "BROWSE DATA LIBRARY": "EXPLORAR BIBLIOTECA DE DADOS" } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index a1ec7ad85..de517a550 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3668,5 +3668,114 @@ "{source} imagery could not be opened": "Не удалось открыть снимки {source}", "{place} has no position to look for imagery at": "У места «{place}» нет координат для поиска снимков", "No {source} imagery near {place}": "Нет снимков {source} рядом с {place}", - "{reason} (filters on: {filters})": "{reason} (включены фильтры: {filters})" + "{reason} (filters on: {filters})": "{reason} (включены фильтры: {filters})", + "tiles unavailable": "тайлы недоступны", + "Environment": "Окружающая среда", + "Imagery": "Снимки", + "DATASET": "НАБОР ДАННЫХ", + "TILES": "ТАЙЛЫ", + "PACK": "ПАКЕТ", + "INCUBATING": "В ИНКУБАЦИИ", + "COMMUNITY": "СООБЩЕСТВО", + "VERIFIED": "ПРОВЕРЕНО", + "ARCHIVED": "В АРХИВЕ", + "Switched on by this instance · part of the CCTV layer": "Включено на этом экземпляре · часть слоя CCTV", + "Operator opt-in: set {env}=1 on the server": "Включает оператор: задайте {env}=1 на сервере", + "Operator opt-in: add {id} to GEV_LIBRARY_ENABLE on the server": "Включает оператор: добавьте {id} в GEV_LIBRARY_ENABLE на сервере", + "Turned off by this instance's operator": "Отключено оператором этого экземпляра", + "Needs a provider key on the server": "Нужен ключ провайдера на сервере", + "Needs this app's server · not available in a static build": "Нужен сервер приложения · недоступно в статической сборке", + "Checking what this instance serves…": "Проверяем, что доступно на этом экземпляре…", + "Regional": "Региональный", + "DATA LIBRARY": "БИБЛИОТЕКА ДАННЫХ", + "Close data library": "Закрыть библиотеку данных", + "Live sources beyond the core layers. Each one is fetched from its provider while it is on; nothing is stored in this app. Add one and it appears in Data Layers under Library.": "Живые источники помимо основных слоёв. Каждый загружается у своего провайдера, пока он включён; приложение ничего не хранит. Добавьте источник, и он появится в «Слоях данных» в разделе «Библиотека».", + "Name, provider or country": "Название, провайдер или страна", + "Theme": "Тема", + "All themes": "Все темы", + "Only what this instance serves": "Только то, что доступно на этом экземпляре", + "OPT-IN": "ПО ВЫБОРУ", + "KEY": "КЛЮЧ", + "KEYLESS": "БЕЗ КЛЮЧА", + "WORKING…": "ВЫПОЛНЯЕТСЯ…", + "ADD": "ДОБАВИТЬ", + "Remove {name} from Data Layers": "Убрать {name} из «Слоёв данных»", + "Add {name} to Data Layers": "Добавить {name} в «Слои данных»", + "Library": "Библиотека", + "BROWSE DATA LIBRARY": "ОТКРЫТЬ БИБЛИОТЕКУ ДАННЫХ", + "DRAPED": "НАЛОЖЕНО", + "Zoom to layer": "Приблизить к слою", + "ZOOM": "ПРИБЛИЗИТЬ", + "Zoom to {name}": "Приблизить к «{name}»", + "CONFIRM · REMOVE ALL {count}": { + "one": "ПОДТВЕРДИТЬ · УБРАТЬ {count}", + "few": "ПОДТВЕРДИТЬ · УБРАТЬ ВСЕ {count}", + "many": "ПОДТВЕРДИТЬ · УБРАТЬ ВСЕ {count}", + "other": "ПОДТВЕРДИТЬ · УБРАТЬ ВСЕ {count}" + }, + "REMOVE ALL": "УБРАТЬ ВСЁ", + "Confirm: remove every added entry from Data Layers": "Подтвердите: убрать все добавленные записи из «Слоёв данных»", + "Remove every added entry from Data Layers": "Убрать все добавленные записи из «Слоёв данных»", + "Exit cockpit to fly to a layer": "Выйдите из кабины, чтобы перелететь к слою", + "Zoom to layer · fly to where this layer has data": "Приблизить к слою · перелететь туда, где у слоя есть данные", + "Remove from Data Layers": "Убрать из «Слоёв данных»", + "Remove from Data Layers · add it again from the library": "Убрать из «Слоёв данных» · снова добавить можно из библиотеки", + "{datasets} · {tiles} · {packs}": "{datasets} · {tiles} · {packs}", + "{count} datasets": { + "one": "{count} набор данных", + "few": "{count} набора данных", + "many": "{count} наборов данных", + "other": "{count} набора данных" + }, + "{count} tile layers": { + "one": "{count} слой тайлов", + "few": "{count} слоя тайлов", + "many": "{count} слоёв тайлов", + "other": "{count} слоя тайлов" + }, + "{count} packs": { + "one": "{count} пакет", + "few": "{count} пакета", + "many": "{count} пакетов", + "other": "{count} пакета" + }, + "Added": "Добавлено", + "{count} on the map": { + "one": "{count} на карте", + "few": "{count} на карте", + "many": "{count} на карте", + "other": "{count} на карте" + }, + "none yet": "пока ничего", + "from {count} datasets": { + "one": "из {count} набора данных", + "few": "из {count} наборов данных", + "many": "из {count} наборов данных", + "other": "из {count} набора данных" + }, + "switch on a dataset to load features": "включите набор данных, чтобы загрузить объекты", + "Data Library at a glance": "Библиотека данных вкратце", + "Map features": "Объекты на карте", + "Showing {shown} of {total}": "Показано {shown} из {total}", + "Datasets": "Наборы данных", + "Themes": "Темы", + "zoom in to load this layer": "приблизьте, чтобы загрузить этот слой", + "Demographics": "Демография", + "Coverage outline": "Граница покрытия", + "Orthophoto": "Ортофото", + "Satellite mosaic": "Спутниковая мозаика", + "Oblique": "Перспективная съёмка", + "Historic imagery": "Исторические снимки", + "True orthophoto": "Истинное ортофото", + "Type": "Тип", + "Resolution": "Разрешение", + "Flown": "Съёмка", + "Updates": "Обновление", + "Ortho quality": "Качество ортофото", + "Below 250 m": "Ниже 250 м", + "250–500 m": "250–500 м", + "500–1,000 m": "500–1000 м", + "1,000–2,000 m": "1000–2000 м", + "2,000–3,000 m": "2000–3000 м", + "3,000 m and above": "3000 м и выше" } diff --git a/src/i18n/locales/sv.json b/src/i18n/locales/sv.json index 73719086a..3d4c93d9b 100644 --- a/src/i18n/locales/sv.json +++ b/src/i18n/locales/sv.json @@ -3506,5 +3506,102 @@ "Play speed": "Uppspelningshastighet", "Scroll or pinch to zoom, drag to move along · double-click to see it all": "Scrolla eller nyp för att zooma, dra för att flytta · dubbelklicka för att se allt", "Live": "Live", - "Back to live": "Tillbaka till live" + "Back to live": "Tillbaka till live", + "tiles unavailable": "kartrutor otillgängliga", + "zoom in to load this layer": "zooma in för att ladda lagret", + "Zoom to layer": "Zooma till lagret", + "Zoom to layer · fly to where this layer has data": "Zooma till lagret · flyg dit lagret har data", + "Remove from Data Layers": "Ta bort från Datalager", + "Remove from Data Layers · add it again from the library": "Ta bort från Datalager · lägg till det igen från biblioteket", + "Coverage outline": "Täckningens kontur", + "DRAPED": "DRAPERAT", + "Below 250 m": "Under 250 m", + "250–500 m": "250–500 m", + "500–1,000 m": "500–1 000 m", + "1,000–2,000 m": "1 000–2 000 m", + "2,000–3,000 m": "2 000–3 000 m", + "3,000 m and above": "3 000 m och högre", + "Exit cockpit to fly to a layer": "Lämna cockpiten för att flyga till ett lager", + "Library": "Bibliotek", + "Environment": "Miljö", + "Imagery": "Bilder", + "Demographics": "Demografi", + "DATASET": "DATAMÄNGD", + "TILES": "KARTRUTOR", + "PACK": "PAKET", + "INCUBATING": "INKUBERAS", + "COMMUNITY": "GEMENSKAP", + "VERIFIED": "VERIFIERAD", + "ARCHIVED": "ARKIVERAD", + "Datasets": "Datamängder", + "{datasets} · {tiles} · {packs}": "{datasets} · {tiles} · {packs}", + "{count} datasets": { + "one": "{count} datamängd", + "other": "{count} datamängder" + }, + "{count} tile layers": { + "one": "{count} kartrutelager", + "other": "{count} kartrutelager" + }, + "{count} packs": { + "one": "{count} paket", + "other": "{count} paket" + }, + "Themes": "Teman", + "Added": "Tillagda", + "{count} on the map": { + "one": "{count} på kartan", + "other": "{count} på kartan" + }, + "none yet": "inga ännu", + "Map features": "Kartobjekt", + "from {count} datasets": { + "one": "från {count} datamängd", + "other": "från {count} datamängder" + }, + "switch on a dataset to load features": "slå på en datamängd för att ladda objekt", + "Switched on by this instance · part of the CCTV layer": "Påslaget av den här instansen · del av CCTV-lagret", + "Operator opt-in: set {env}=1 on the server": "Aktiveras av operatören: sätt {env}=1 på servern", + "Operator opt-in: add {id} to GEV_LIBRARY_ENABLE on the server": "Aktiveras av operatören: lägg till {id} i GEV_LIBRARY_ENABLE på servern", + "Turned off by this instance's operator": "Avstängt av instansens operatör", + "Needs a provider key on the server": "Kräver en leverantörsnyckel på servern", + "Needs this app's server · not available in a static build": "Kräver appens server · inte tillgängligt i en statisk build", + "Checking what this instance serves…": "Kontrollerar vad instansen tillhandahåller …", + "Regional": "Regionalt", + "Orthophoto": "Ortofoto", + "Satellite mosaic": "Satellitmosaik", + "Oblique": "Snedbild", + "Historic imagery": "Historiska bilder", + "True orthophoto": "Sant ortofoto", + "Type": "Typ", + "Resolution": "Upplösning", + "Flown": "Flugen", + "Updates": "Uppdateras", + "Ortho quality": "Ortokvalitet", + "DATA LIBRARY": "DATABIBLIOTEK", + "Close data library": "Stäng databiblioteket", + "Data Library at a glance": "Databiblioteket i korthet", + "Live sources beyond the core layers. Each one is fetched from its provider while it is on; nothing is stored in this app. Add one and it appears in Data Layers under Library.": "Realtidskällor utöver baslagren. Var och en hämtas från sin leverantör medan den är påslagen; inget lagras i appen. Lägg till en så visas den i Datalager under Bibliotek.", + "Name, provider or country": "Namn, leverantör eller land", + "Theme": "Tema", + "All themes": "Alla teman", + "Only what this instance serves": "Bara det instansen tillhandahåller", + "OPT-IN": "VALFRITT", + "KEY": "NYCKEL", + "KEYLESS": "UTAN NYCKEL", + "ZOOM": "ZOOMA", + "Zoom to {name}": "Zooma till {name}", + "WORKING…": "ARBETAR …", + "ADD": "LÄGG TILL", + "Remove {name} from Data Layers": "Ta bort {name} från Datalager", + "Add {name} to Data Layers": "Lägg till {name} i Datalager", + "CONFIRM · REMOVE ALL {count}": { + "one": "BEKRÄFTA · TA BORT {count}", + "other": "BEKRÄFTA · TA BORT ALLA {count}" + }, + "REMOVE ALL": "TA BORT ALLA", + "Confirm: remove every added entry from Data Layers": "Bekräfta: ta bort alla tillagda poster från Datalager", + "Remove every added entry from Data Layers": "Ta bort alla tillagda poster från Datalager", + "Showing {shown} of {total}": "Visar {shown} av {total}", + "BROWSE DATA LIBRARY": "BLÄDDRA I DATABIBLIOTEKET" } diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index f7dc9c35b..f64f6e8e0 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -3668,5 +3668,114 @@ "{source} imagery could not be opened": "Не вдалося відкрити знімки {source}", "{place} has no position to look for imagery at": "Для місця «{place}» немає координат для пошуку знімків", "No {source} imagery near {place}": "Немає знімків {source} поблизу {place}", - "{reason} (filters on: {filters})": "{reason} (увімкнено фільтри: {filters})" + "{reason} (filters on: {filters})": "{reason} (увімкнено фільтри: {filters})", + "tiles unavailable": "тайли недоступні", + "zoom in to load this layer": "наблизьте, щоб завантажити цей шар", + "Zoom to layer": "Наблизити до шару", + "Zoom to layer · fly to where this layer has data": "Наблизити до шару · перелетіти туди, де цей шар має дані", + "Remove from Data Layers": "Вилучити з «Шарів даних»", + "Remove from Data Layers · add it again from the library": "Вилучити з «Шарів даних» · можна знову додати з бібліотеки", + "Coverage outline": "Контур покриття", + "DRAPED": "НАКЛАДЕНО", + "Below 250 m": "Нижче 250 м", + "250–500 m": "250–500 м", + "500–1,000 m": "500–1000 м", + "1,000–2,000 m": "1000–2000 м", + "2,000–3,000 m": "2000–3000 м", + "3,000 m and above": "3000 м і вище", + "Exit cockpit to fly to a layer": "Вийдіть із кабіни, щоб перелетіти до шару", + "Library": "Бібліотека", + "Environment": "Довкілля", + "Imagery": "Знімки", + "Demographics": "Демографія", + "DATASET": "НАБІР ДАНИХ", + "TILES": "ТАЙЛИ", + "PACK": "ПАКЕТ", + "INCUBATING": "НА ІНКУБАЦІЇ", + "COMMUNITY": "СПІЛЬНОТА", + "VERIFIED": "ПЕРЕВІРЕНО", + "ARCHIVED": "В АРХІВІ", + "Datasets": "Набори даних", + "{datasets} · {tiles} · {packs}": "{datasets} · {tiles} · {packs}", + "{count} datasets": { + "one": "{count} набір даних", + "few": "{count} набори даних", + "many": "{count} наборів даних", + "other": "{count} набору даних" + }, + "{count} tile layers": { + "one": "{count} шар тайлів", + "few": "{count} шари тайлів", + "many": "{count} шарів тайлів", + "other": "{count} шару тайлів" + }, + "{count} packs": { + "one": "{count} пакет", + "few": "{count} пакети", + "many": "{count} пакетів", + "other": "{count} пакета" + }, + "Themes": "Теми", + "Added": "Додані", + "{count} on the map": { + "one": "{count} на карті", + "few": "{count} на карті", + "many": "{count} на карті", + "other": "{count} на карті" + }, + "none yet": "поки жодного", + "Map features": "Об’єкти карти", + "from {count} datasets": { + "one": "з {count} набору даних", + "few": "з {count} наборів даних", + "many": "з {count} наборів даних", + "other": "з {count} набору даних" + }, + "switch on a dataset to load features": "увімкніть набір даних, щоб завантажити об’єкти", + "Switched on by this instance · part of the CCTV layer": "Увімкнено цим екземпляром · частина шару CCTV", + "Operator opt-in: set {env}=1 on the server": "Увімкнення оператором: задайте {env}=1 на сервері", + "Operator opt-in: add {id} to GEV_LIBRARY_ENABLE on the server": "Увімкнення оператором: додайте {id} до GEV_LIBRARY_ENABLE на сервері", + "Turned off by this instance's operator": "Вимкнено оператором цього екземпляра", + "Needs a provider key on the server": "Потрібен ключ постачальника на сервері", + "Needs this app's server · not available in a static build": "Потрібен сервер цього застосунку · недоступно в статичній збірці", + "Checking what this instance serves…": "Перевірка, що надає цей екземпляр…", + "Regional": "Регіональні", + "Orthophoto": "Ортофото", + "Satellite mosaic": "Супутникова мозаїка", + "Oblique": "Перспективна зйомка", + "Historic imagery": "Історичні знімки", + "True orthophoto": "Справжнє ортофото", + "Type": "Тип", + "Resolution": "Роздільність", + "Flown": "Зйомка", + "Updates": "Оновлення", + "Ortho quality": "Якість орто", + "DATA LIBRARY": "БІБЛІОТЕКА ДАНИХ", + "Close data library": "Закрити бібліотеку даних", + "Data Library at a glance": "Бібліотека даних коротко", + "Live sources beyond the core layers. Each one is fetched from its provider while it is on; nothing is stored in this app. Add one and it appears in Data Layers under Library.": "Живі джерела поза основними шарами. Кожне завантажується від свого постачальника, поки увімкнене; цей застосунок нічого не зберігає. Додайте джерело, і воно з’явиться в «Шарах даних» у розділі «Бібліотека».", + "Name, provider or country": "Назва, постачальник або країна", + "Theme": "Тема", + "All themes": "Усі теми", + "Only what this instance serves": "Лише те, що надає цей екземпляр", + "OPT-IN": "ЗА ЗГОДОЮ", + "KEY": "КЛЮЧ", + "KEYLESS": "БЕЗ КЛЮЧА", + "ZOOM": "НАБЛИЗИТИ", + "Zoom to {name}": "Наблизити до {name}", + "WORKING…": "ОБРОБКА…", + "ADD": "ДОДАТИ", + "Remove {name} from Data Layers": "Вилучити {name} з «Шарів даних»", + "Add {name} to Data Layers": "Додати {name} до «Шарів даних»", + "CONFIRM · REMOVE ALL {count}": { + "one": "ПІДТВЕРДИТИ · ВИЛУЧИТИ {count}", + "few": "ПІДТВЕРДИТИ · ВИЛУЧИТИ ВСІ {count}", + "many": "ПІДТВЕРДИТИ · ВИЛУЧИТИ ВСІ {count}", + "other": "ПІДТВЕРДИТИ · ВИЛУЧИТИ ВСІ {count}" + }, + "REMOVE ALL": "ВИЛУЧИТИ ВСЕ", + "Confirm: remove every added entry from Data Layers": "Підтвердити: вилучити всі додані записи з «Шарів даних»", + "Remove every added entry from Data Layers": "Вилучити всі додані записи з «Шарів даних»", + "Showing {shown} of {total}": "Показано {shown} з {total}", + "BROWSE DATA LIBRARY": "ВІДКРИТИ БІБЛІОТЕКУ ДАНИХ" } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 6b3020392..da34f1c26 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -3425,5 +3425,96 @@ "{source} imagery could not be opened": "无法打开 {source} 影像", "{place} has no position to look for imagery at": "{place} 没有可查找影像的位置", "No {source} imagery near {place}": "{place} 附近没有 {source} 影像", - "{reason} (filters on: {filters})": "{reason}(已开启筛选:{filters})" + "{reason} (filters on: {filters})": "{reason}(已开启筛选:{filters})", + "tiles unavailable": "瓦片不可用", + "zoom in to load this layer": "放大以加载此图层", + "Zoom to layer": "缩放至图层", + "Zoom to layer · fly to where this layer has data": "缩放至图层 · 飞往此图层有数据的区域", + "Remove from Data Layers": "从数据图层中移除", + "Remove from Data Layers · add it again from the library": "从数据图层中移除 · 可从数据资源库重新添加", + "Coverage outline": "覆盖范围轮廓", + "DRAPED": "贴地", + "Below 250 m": "低于 250 m", + "250–500 m": "250–500 m", + "500–1,000 m": "500–1,000 m", + "1,000–2,000 m": "1,000–2,000 m", + "2,000–3,000 m": "2,000–3,000 m", + "3,000 m and above": "3,000 m 及以上", + "Exit cockpit to fly to a layer": "退出驾驶舱后才能飞往图层", + "Library": "资源库", + "Environment": "环境", + "Imagery": "影像", + "Demographics": "人口统计", + "DATASET": "数据集", + "TILES": "瓦片", + "PACK": "数据包", + "INCUBATING": "孵化中", + "COMMUNITY": "社区", + "VERIFIED": "已验证", + "ARCHIVED": "已归档", + "Datasets": "数据集", + "{datasets} · {tiles} · {packs}": "{datasets} · {tiles} · {packs}", + "{count} datasets": { + "other": "{count} 个数据集" + }, + "{count} tile layers": { + "other": "{count} 个瓦片图层" + }, + "{count} packs": { + "other": "{count} 个数据包" + }, + "Themes": "主题", + "Added": "已添加", + "{count} on the map": { + "other": "地图上 {count} 个" + }, + "none yet": "暂无", + "Map features": "地图要素", + "from {count} datasets": { + "other": "来自 {count} 个数据集" + }, + "switch on a dataset to load features": "开启一个数据集以加载要素", + "Switched on by this instance · part of the CCTV layer": "已由此实例开启 · 属于 CCTV 图层", + "Operator opt-in: set {env}=1 on the server": "运营者启用:在服务器上设置 {env}=1", + "Operator opt-in: add {id} to GEV_LIBRARY_ENABLE on the server": "运营者启用:在服务器上将 {id} 添加到 GEV_LIBRARY_ENABLE", + "Turned off by this instance's operator": "已被此实例的运营者关闭", + "Needs a provider key on the server": "需要在服务器上配置提供方密钥", + "Needs this app's server · not available in a static build": "需要此应用的服务器 · 静态构建中不可用", + "Checking what this instance serves…": "正在检查此实例提供的内容…", + "Regional": "区域", + "Orthophoto": "正射影像", + "Satellite mosaic": "卫星镶嵌图", + "Oblique": "倾斜影像", + "Historic imagery": "历史影像", + "True orthophoto": "真正射影像", + "Type": "类型", + "Resolution": "分辨率", + "Flown": "航摄时间", + "Updates": "更新频率", + "Ortho quality": "正射质量", + "DATA LIBRARY": "数据资源库", + "Close data library": "关闭数据资源库", + "Data Library at a glance": "数据资源库概览", + "Live sources beyond the core layers. Each one is fetched from its provider while it is on; nothing is stored in this app. Add one and it appears in Data Layers under Library.": "核心图层之外的实时数据源。每个数据源开启时直接从其提供方获取;本应用不存储任何数据。添加后,它会出现在数据图层的“资源库”分组中。", + "Name, provider or country": "名称、提供方或国家", + "Theme": "主题", + "All themes": "全部主题", + "Only what this instance serves": "仅显示此实例提供的内容", + "OPT-IN": "需启用", + "KEY": "需密钥", + "KEYLESS": "免密钥", + "ZOOM": "缩放", + "Zoom to {name}": "缩放至 {name}", + "WORKING…": "处理中…", + "ADD": "添加", + "Remove {name} from Data Layers": "从数据图层中移除 {name}", + "Add {name} to Data Layers": "将 {name} 添加到数据图层", + "CONFIRM · REMOVE ALL {count}": { + "other": "确认 · 全部移除 {count} 项" + }, + "REMOVE ALL": "全部移除", + "Confirm: remove every added entry from Data Layers": "确认:从数据图层中移除所有已添加条目", + "Remove every added entry from Data Layers": "从数据图层中移除所有已添加条目", + "Showing {shown} of {total}": "显示 {shown} / {total}", + "BROWSE DATA LIBRARY": "浏览数据资源库" } diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index 3bf6ada54..52e6b2033 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -3425,5 +3425,96 @@ "{source} imagery could not be opened": "無法打開 {source} 影像", "{place} has no position to look for imagery at": "{place} 沒有可查找影像的位置", "No {source} imagery near {place}": "{place} 附近沒有 {source} 影像", - "{reason} (filters on: {filters})": "{reason}(已開啟篩選:{filters})" + "{reason} (filters on: {filters})": "{reason}(已開啟篩選:{filters})", + "tiles unavailable": "圖磚無法使用", + "zoom in to load this layer": "放大以載入此圖層", + "Zoom to layer": "縮放至圖層", + "Zoom to layer · fly to where this layer has data": "縮放至圖層 · 飛往此圖層有資料的區域", + "Remove from Data Layers": "從數據圖層中移除", + "Remove from Data Layers · add it again from the library": "從數據圖層中移除 · 可從數據資源庫重新加入", + "Coverage outline": "涵蓋範圍輪廓", + "DRAPED": "貼地", + "Below 250 m": "低於 250 m", + "250–500 m": "250–500 m", + "500–1,000 m": "500–1,000 m", + "1,000–2,000 m": "1,000–2,000 m", + "2,000–3,000 m": "2,000–3,000 m", + "3,000 m and above": "3,000 m 以上", + "Exit cockpit to fly to a layer": "離開駕駛艙後才能飛往圖層", + "Library": "資源庫", + "Environment": "環境", + "Imagery": "影像", + "Demographics": "人口統計", + "DATASET": "資料集", + "TILES": "圖磚", + "PACK": "資料包", + "INCUBATING": "孵化中", + "COMMUNITY": "社群", + "VERIFIED": "已驗證", + "ARCHIVED": "已封存", + "Datasets": "資料集", + "{datasets} · {tiles} · {packs}": "{datasets} · {tiles} · {packs}", + "{count} datasets": { + "other": "{count} 個資料集" + }, + "{count} tile layers": { + "other": "{count} 個圖磚圖層" + }, + "{count} packs": { + "other": "{count} 個資料包" + }, + "Themes": "主題", + "Added": "已加入", + "{count} on the map": { + "other": "地圖上 {count} 個" + }, + "none yet": "尚無", + "Map features": "地圖圖徵", + "from {count} datasets": { + "other": "來自 {count} 個資料集" + }, + "switch on a dataset to load features": "開啟一個資料集以載入圖徵", + "Switched on by this instance · part of the CCTV layer": "已由此執行個體開啟 · 屬於 CCTV 圖層", + "Operator opt-in: set {env}=1 on the server": "營運者啟用:在伺服器上設定 {env}=1", + "Operator opt-in: add {id} to GEV_LIBRARY_ENABLE on the server": "營運者啟用:在伺服器上將 {id} 加入 GEV_LIBRARY_ENABLE", + "Turned off by this instance's operator": "已被此執行個體的營運者關閉", + "Needs a provider key on the server": "需要在伺服器上設定提供者金鑰", + "Needs this app's server · not available in a static build": "需要此應用程式的伺服器 · 靜態建置中無法使用", + "Checking what this instance serves…": "正在檢查此執行個體提供的內容…", + "Regional": "區域", + "Orthophoto": "正射影像", + "Satellite mosaic": "衛星鑲嵌圖", + "Oblique": "傾斜影像", + "Historic imagery": "歷史影像", + "True orthophoto": "真正射影像", + "Type": "類型", + "Resolution": "解析度", + "Flown": "航拍時間", + "Updates": "更新頻率", + "Ortho quality": "正射品質", + "DATA LIBRARY": "數據資源庫", + "Close data library": "關閉數據資源庫", + "Data Library at a glance": "數據資源庫概覽", + "Live sources beyond the core layers. Each one is fetched from its provider while it is on; nothing is stored in this app. Add one and it appears in Data Layers under Library.": "核心圖層以外的即時資料來源。每個來源開啟時直接從其提供者取得;本應用程式不儲存任何資料。加入後,它會出現在數據圖層的「資源庫」分組中。", + "Name, provider or country": "名稱、提供者或國家", + "Theme": "主題", + "All themes": "所有主題", + "Only what this instance serves": "僅顯示此執行個體提供的內容", + "OPT-IN": "需啟用", + "KEY": "需金鑰", + "KEYLESS": "免金鑰", + "ZOOM": "縮放", + "Zoom to {name}": "縮放至 {name}", + "WORKING…": "處理中…", + "ADD": "加入", + "Remove {name} from Data Layers": "從數據圖層中移除 {name}", + "Add {name} to Data Layers": "將 {name} 加入數據圖層", + "CONFIRM · REMOVE ALL {count}": { + "other": "確認 · 全部移除 {count} 項" + }, + "REMOVE ALL": "全部移除", + "Confirm: remove every added entry from Data Layers": "確認:從數據圖層中移除所有已加入項目", + "Remove every added entry from Data Layers": "從數據圖層中移除所有已加入項目", + "Showing {shown} of {total}": "顯示 {shown} / {total}", + "BROWSE DATA LIBRARY": "瀏覽數據資源庫" } diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index ed788cebc..b90685e68 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -244,7 +244,10 @@ "▲ {frp} MW", "ICAO: {id}", "MMSI: {id}", - "NORAD: {id}" + "NORAD: {id}", + "COMMUNITY", + "Regional", + "OPT-IN" ], "es": [ "1 min/s", @@ -404,7 +407,9 @@ "iNaturalist", "ICAO: {id}", "MMSI: {id}", - "NORAD: {id}" + "NORAD: {id}", + "Regional", + "{datasets} · {tiles} · {packs}" ], "fr": [ "1 min/s", @@ -619,7 +624,12 @@ "≥ {value} MW", "▲ {frp} MW", "SATELLITE", - "Photo © {photographer}" + "Photo © {photographer}", + "PACK", + "{count} packs", + "Orthophoto", + "Oblique", + "Type" ], "it": [ "1 min/s", @@ -822,7 +832,10 @@ "SATELLITE", "ICAO: {id}", "MMSI: {id}", - "NORAD: {id}" + "NORAD: {id}", + "DATASET", + "{count} datasets", + "ZOOM" ], "ja": [ "1090 MHz", @@ -1049,7 +1062,8 @@ "iNaturalist", "ICAO: {id}", "MMSI: {id}", - "NORAD: {id}" + "NORAD: {id}", + "Regional" ], "ru": [ "1900–1945", @@ -1085,7 +1099,8 @@ "{division}, {country}", "ICAO: {id}", "MMSI: {id}", - "NORAD: {id}" + "NORAD: {id}", + "{datasets} · {tiles} · {packs}" ], "sv": [ "STATION", diff --git a/src/library/camera.js b/src/library/camera.js new file mode 100644 index 000000000..e673e1c54 --- /dev/null +++ b/src/library/camera.js @@ -0,0 +1,74 @@ +/** + * "Zoom to layer" for Data Library layers. Callers run this inside the + * shell's immediate navigation (`runImmediateNavigation('layer', …)`), so + * Cockpit can refuse it and the current follow owner is released first. + */ +import * as Cesium from 'cesium'; +import { cancelCameraArrival } from '../data/cameraArrival.js'; +import { isWorldScale, padExtent } from './extent.js'; + +/** Camera height for a world-scale layer: the whole globe in view. */ +const WORLD_HEIGHT_M = 20_000_000; +/** Where a view-bounded zoom lands, as a share of the layer's altitude. */ +const VIEW_LANDING_FRACTION = 0.6; + +/** + * Fly the camera to frame an extent. + * @param {object} viewer + * @param {{ west: number, south: number, east: number, north: number, maxAltitudeM?: number }} extent + * `maxAltitudeM` marks a view-bounded layer's limit. + * @param {{ cesium?: object, reducedMotion?: boolean }} [options] + * @returns {boolean} Whether a flight started. + */ +export function flyToLibraryExtent( + viewer, + extent, + { + cesium = Cesium, + reducedMotion = globalThis.matchMedia?.('(prefers-reduced-motion: reduce)') + ?.matches === true, + } = {}, +) { + if (!viewer?.camera?.flyTo || !extent) return false; + cancelCameraArrival(viewer); + const box = padExtent(extent); + const duration = reducedMotion ? 0 : 1.6; + // A view-bounded layer loads only below its altitude: land straight above + // its sample area, low enough for the layer to load. + if (Number.isFinite(extent.maxAltitudeM)) { + viewer.camera.flyTo({ + destination: cesium.Cartesian3.fromDegrees( + (extent.west + extent.east) / 2, + (extent.south + extent.north) / 2, + extent.maxAltitudeM * VIEW_LANDING_FRACTION, + ), + duration, + }); + return true; + } + if (isWorldScale(box)) { + const lon = (box.west + box.east) / 2; + const lat = (box.south + box.north) / 2; + viewer.camera.flyTo({ + destination: cesium.Cartesian3.fromDegrees( + lon > 180 ? lon - 360 : lon, + lat, + WORLD_HEIGHT_M, + ), + duration, + }); + return true; + } + // Cesium reads west > east as a box crossing the antimeridian. + const wrap = (lon) => (lon > 180 ? lon - 360 : lon < -180 ? lon + 360 : lon); + viewer.camera.flyTo({ + destination: cesium.Rectangle.fromDegrees( + wrap(box.west), + box.south, + wrap(box.east), + box.north, + ), + duration, + }); + return true; +} diff --git a/src/library/catalog.js b/src/library/catalog.js new file mode 100644 index 000000000..c04656e13 --- /dev/null +++ b/src/library/catalog.js @@ -0,0 +1,248 @@ +/** + * The Data Library catalog: every manifest in `src/library/entries/`. + * + * Adding an entry is one JSON file plus one import line here, and a share + * token in `ledger.json` when the entry can be switched on (datasets and + * tiles). `npm run library:check` fails when a file, import or token is + * missing. Manifests are validated when this module loads, so an invalid + * entry fails the unit suite rather than reaching a page. + */ +import { validateLibraryCatalog } from './manifest.js'; +import ledgerRows from './ledger.json' with { type: 'json' }; +import akStatewideImagery from './entries/ak-statewide-imagery.json' with { type: 'json' }; +import arStatewideImagery from './entries/ar-statewide-imagery.json' with { type: 'json' }; +import azStatewideImagery from './entries/az-statewide-imagery.json' with { type: 'json' }; +import bangorMe from './entries/bangor-me.json' with { type: 'json' }; +import basemapAtOrthofoto from './entries/basemap-at-orthofoto.json' with { type: 'json' }; +import bayernDop40 from './entries/bayern-dop40.json' with { type: 'json' }; +import bismarckMandanNd from './entries/bismarck-mandan-nd.json' with { type: 'json' }; +import bkgTopplusOpen from './entries/bkg-topplus-open.json' with { type: 'json' }; +import cctvFloridaFl511 from './entries/cctv-florida-fl511.json' with { type: 'json' }; +import cctvLithuania from './entries/cctv-lithuania.json' with { type: 'json' }; +import cctvNycDot from './entries/cctv-nyc-dot.json' with { type: 'json' }; +import ctStatewideImagery from './entries/ct-statewide-imagery.json' with { type: 'json' }; +import dakotaCountyMn from './entries/dakota-county-mn.json' with { type: 'json' }; +import dcStatewideImagery from './entries/dc-statewide-imagery.json' with { type: 'json' }; +import deStatewideImagery from './entries/de-statewide-imagery.json' with { type: 'json' }; +import eeMaaAmetOrtofoto from './entries/ee-maa-amet-ortofoto.json' with { type: 'json' }; +import eiaPowerPlants from './entries/eia-power-plants.json' with { type: 'json' }; +import emodnetBathymetry from './entries/emodnet-bathymetry.json' with { type: 'json' }; +import epaSuperfundSites from './entries/epa-superfund-sites.json' with { type: 'json' }; +import epaToxicReleaseFacilities from './entries/epa-toxic-release-facilities.json' with { type: 'json' }; +import epcEnglandWales from './entries/epc-england-wales.json' with { type: 'json' }; +import femaUsaStructures from './entries/fema-usa-structures.json' with { type: 'json' }; +import gbifOccurrenceDensity from './entries/gbif-occurrence-density.json' with { type: 'json' }; +import gdacsDisasterAlerts from './entries/gdacs-disaster-alerts.json' with { type: 'json' }; +import genevaSitgOrtho from './entries/geneva-sitg-ortho.json' with { type: 'json' }; +import gpwPopulationDensity from './entries/gpw-population-density.json' with { type: 'json' }; +import grandForksNd from './entries/grand-forks-nd.json' with { type: 'json' }; +import gsiJapanMap from './entries/gsi-japan-map.json' with { type: 'json' }; +import gsiJapanPhoto from './entries/gsi-japan-photo.json' with { type: 'json' }; +import gvpHoloceneVolcanoes from './entries/gvp-holocene-volcanoes.json' with { type: 'json' }; +import iaStatewideImagery from './entries/ia-statewide-imagery.json' with { type: 'json' }; +import icgcOrtoCatalunya from './entries/icgc-orto-catalunya.json' with { type: 'json' }; +import idStatewideImagery from './entries/id-statewide-imagery.json' with { type: 'json' }; +import ignOrthophotosFrance from './entries/ign-orthophotos-france.json' with { type: 'json' }; +import ilStatewideImagery from './entries/il-statewide-imagery.json' with { type: 'json' }; +import imfPortwatchChokepoints from './entries/imf-portwatch-chokepoints.json' with { type: 'json' }; +import imfPortwatchDisruptions from './entries/imf-portwatch-disruptions.json' with { type: 'json' }; +import inStatewideImagery from './entries/in-statewide-imagery.json' with { type: 'json' }; +import jrcSurfaceWater from './entries/jrc-surface-water.json' with { type: 'json' }; +import kartverketAdminUnits from './entries/kartverket-admin-units.json' with { type: 'json' }; +import kartverketCadastre from './entries/kartverket-cadastre.json' with { type: 'json' }; +import kartverketDepth from './entries/kartverket-depth.json' with { type: 'json' }; +import kartverketHillshade from './entries/kartverket-hillshade.json' with { type: 'json' }; +import kartverketNauticalCharts from './entries/kartverket-nautical-charts.json' with { type: 'json' }; +import kartverketTerrainRelief from './entries/kartverket-terrain-relief.json' with { type: 'json' }; +import kartverketTopoGrey from './entries/kartverket-topo-grey.json' with { type: 'json' }; +import kartverketTopo from './entries/kartverket-topo.json' with { type: 'json' }; +import kartverketToporaster from './entries/kartverket-toporaster.json' with { type: 'json' }; +import ksStatewideImagery from './entries/ks-statewide-imagery.json' with { type: 'json' }; +import kyStatewideImagery from './entries/ky-statewide-imagery.json' with { type: 'json' }; +import lakeCountyIn from './entries/lake-county-in.json' with { type: 'json' }; +import lawrenceKs from './entries/lawrence-ks.json' with { type: 'json' }; +import leeCountyFl from './entries/lee-county-fl.json' with { type: 'json' }; +import loudounCountyVa from './entries/loudoun-county-va.json' with { type: 'json' }; +import louisvilleLojicKy from './entries/louisville-lojic-ky.json' with { type: 'json' }; +import luOrthoLatest from './entries/lu-ortho-latest.json' with { type: 'json' }; +import maStatewideImagery from './entries/ma-statewide-imagery.json' with { type: 'json' }; +import macrostratGeology from './entries/macrostrat-geology.json' with { type: 'json' }; +import mapterhornTerrain from './entries/mapterhorn-terrain.json' with { type: 'json' }; +import marisMs from './entries/maris-ms.json' with { type: 'json' }; +import mdStatewideImagery from './entries/md-statewide-imagery.json' with { type: 'json' }; +import meStatewideImagery from './entries/me-statewide-imagery.json' with { type: 'json' }; +import miStatewideImagery from './entries/mi-statewide-imagery.json' with { type: 'json' }; +import mnStatewideImagery from './entries/mn-statewide-imagery.json' with { type: 'json' }; +import mountainVillageCo from './entries/mountain-village-co.json' with { type: 'json' }; +import msStatewideImagery from './entries/ms-statewide-imagery.json' with { type: 'json' }; +import mtStatewideImagery from './entries/mt-statewide-imagery.json' with { type: 'json' }; +import naip from './entries/naip.json' with { type: 'json' }; +import nasaBlackMarble from './entries/nasa-black-marble.json' with { type: 'json' }; +import nasaEonetEvents from './entries/nasa-eonet-events.json' with { type: 'json' }; +import ncStatewideImagery from './entries/nc-statewide-imagery.json' with { type: 'json' }; +import nctcogTx from './entries/nctcog-tx.json' with { type: 'json' }; +import ndStatewideImagery from './entries/nd-statewide-imagery.json' with { type: 'json' }; +import newOrleansRpcLa from './entries/new-orleans-rpc-la.json' with { type: 'json' }; +import nhStatewideImagery from './entries/nh-statewide-imagery.json' with { type: 'json' }; +import njStatewideImagery from './entries/nj-statewide-imagery.json' with { type: 'json' }; +import noaaTideStations from './entries/noaa-tide-stations.json' with { type: 'json' }; +import nrwDop from './entries/nrw-dop.json' with { type: 'json' }; +import nswImagery from './entries/nsw-imagery.json' with { type: 'json' }; +import ntadNationalBridges from './entries/ntad-national-bridges.json' with { type: 'json' }; +import nwaBentonWashingtonAr from './entries/nwa-benton-washington-ar.json' with { type: 'json' }; +import nwsActiveAlerts from './entries/nws-active-alerts.json' with { type: 'json' }; +import nyStatewideImagery from './entries/ny-statewide-imagery.json' with { type: 'json' }; +import oaklandCountyMi from './entries/oakland-county-mi.json' with { type: 'json' }; +import ohStatewideImagery from './entries/oh-statewide-imagery.json' with { type: 'json' }; +import openseamapSeamarks from './entries/openseamap-seamarks.json' with { type: 'json' }; +import orStatewideImagery from './entries/or-statewide-imagery.json' with { type: 'json' }; +import paStatewideImagery from './entries/pa-statewide-imagery.json' with { type: 'json' }; +import pdokAerialNetherlands from './entries/pdok-aerial-netherlands.json' with { type: 'json' }; +import plOrtofotomapa from './entries/pl-ortofotomapa.json' with { type: 'json' }; +import pnoaMa from './entries/pnoa-ma.json' with { type: 'json' }; +import portlandMetroOr from './entries/portland-metro-or.json' with { type: 'json' }; +import ptDgtOrtos from './entries/pt-dgt-ortos.json' with { type: 'json' }; +import riStatewideImagery from './entries/ri-statewide-imagery.json' with { type: 'json' }; +import swisstopoSwissimage from './entries/swisstopo-swissimage.json' with { type: 'json' }; +import txStatewideImagery from './entries/tx-statewide-imagery.json' with { type: 'json' }; +import unionCountyOh from './entries/union-county-oh.json' with { type: 'json' }; +import usgsHydrography from './entries/usgs-hydrography.json' with { type: 'json' }; +import usgsTopo from './entries/usgs-topo.json' with { type: 'json' }; +import vaStatewideImagery from './entries/va-statewide-imagery.json' with { type: 'json' }; +import vlaanderenOmw from './entries/vlaanderen-omw.json' with { type: 'json' }; +import vtStatewideImagery from './entries/vt-statewide-imagery.json' with { type: 'json' }; +import wallonieOrtho from './entries/wallonie-ortho.json' with { type: 'json' }; +import wiStatewideImagery from './entries/wi-statewide-imagery.json' with { type: 'json' }; +import wriGlobalPowerPlants from './entries/wri-global-power-plants.json' with { type: 'json' }; +import wvStatewideImagery from './entries/wv-statewide-imagery.json' with { type: 'json' }; + +/** Raw manifests in file-name order; the validated form is below. */ +export const LIBRARY_MANIFESTS = Object.freeze([ + akStatewideImagery, + arStatewideImagery, + azStatewideImagery, + bangorMe, + basemapAtOrthofoto, + bayernDop40, + bismarckMandanNd, + bkgTopplusOpen, + cctvFloridaFl511, + cctvLithuania, + cctvNycDot, + ctStatewideImagery, + dakotaCountyMn, + dcStatewideImagery, + deStatewideImagery, + eeMaaAmetOrtofoto, + eiaPowerPlants, + emodnetBathymetry, + epaSuperfundSites, + epaToxicReleaseFacilities, + epcEnglandWales, + femaUsaStructures, + gbifOccurrenceDensity, + gdacsDisasterAlerts, + genevaSitgOrtho, + gpwPopulationDensity, + grandForksNd, + gsiJapanMap, + gsiJapanPhoto, + gvpHoloceneVolcanoes, + iaStatewideImagery, + icgcOrtoCatalunya, + idStatewideImagery, + ignOrthophotosFrance, + ilStatewideImagery, + imfPortwatchChokepoints, + imfPortwatchDisruptions, + inStatewideImagery, + jrcSurfaceWater, + kartverketAdminUnits, + kartverketCadastre, + kartverketDepth, + kartverketHillshade, + kartverketNauticalCharts, + kartverketTerrainRelief, + kartverketTopoGrey, + kartverketTopo, + kartverketToporaster, + ksStatewideImagery, + kyStatewideImagery, + lakeCountyIn, + lawrenceKs, + leeCountyFl, + loudounCountyVa, + louisvilleLojicKy, + luOrthoLatest, + maStatewideImagery, + macrostratGeology, + mapterhornTerrain, + marisMs, + mdStatewideImagery, + meStatewideImagery, + miStatewideImagery, + mnStatewideImagery, + mountainVillageCo, + msStatewideImagery, + mtStatewideImagery, + naip, + nasaBlackMarble, + nasaEonetEvents, + ncStatewideImagery, + nctcogTx, + ndStatewideImagery, + newOrleansRpcLa, + nhStatewideImagery, + njStatewideImagery, + noaaTideStations, + nrwDop, + nswImagery, + ntadNationalBridges, + nwaBentonWashingtonAr, + nwsActiveAlerts, + nyStatewideImagery, + oaklandCountyMi, + ohStatewideImagery, + openseamapSeamarks, + orStatewideImagery, + paStatewideImagery, + pdokAerialNetherlands, + plOrtofotomapa, + pnoaMa, + portlandMetroOr, + ptDgtOrtos, + riStatewideImagery, + swisstopoSwissimage, + txStatewideImagery, + unionCountyOh, + usgsHydrography, + usgsTopo, + vaStatewideImagery, + vlaanderenOmw, + vtStatewideImagery, + wallonieOrtho, + wiStatewideImagery, + wriGlobalPowerPlants, + wvStatewideImagery, +]); + +const validated = validateLibraryCatalog(LIBRARY_MANIFESTS); +if (!validated.ok) + throw new TypeError( + `Invalid Data Library manifests:\n${validated.errors.join('\n')}`, + ); + +/** Validated, frozen entries with defaults filled. */ +export const LIBRARY_ENTRIES = Object.freeze(validated.entries); + +const BY_ID = new Map(LIBRARY_ENTRIES.map((entry) => [entry.id, entry])); + +/** One entry by id, or undefined. */ +export function libraryEntry(id) { + return BY_ID.get(id); +} + +/** The share-link ledger rows, `[entryId, token]`, append-only. */ +export const LIBRARY_LEDGER = Object.freeze( + ledgerRows.map((row) => Object.freeze([...row])), +); diff --git a/src/library/catalog.test.mjs b/src/library/catalog.test.mjs new file mode 100644 index 000000000..4de0c44d4 --- /dev/null +++ b/src/library/catalog.test.mjs @@ -0,0 +1,47 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readdirSync, readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; +import { + LIBRARY_ENTRIES, + LIBRARY_LEDGER, + LIBRARY_MANIFESTS, +} from './catalog.js'; +import { readLibraryLedger } from './selection.js'; + +const here = path.dirname(fileURLToPath(import.meta.url)); + +test('every manifest file is imported, named after its id', () => { + const files = readdirSync(path.join(here, 'entries')) + .filter((name) => name.endsWith('.json')) + .sort(); + assert.deepEqual( + files, + LIBRARY_MANIFESTS.map(({ id }) => `${id}.json`).sort(), + ); + for (const file of files) { + const raw = JSON.parse( + readFileSync(path.join(here, 'entries', file), 'utf8'), + ); + assert.equal(`${raw.id}.json`, file); + } +}); + +test('every switchable entry owns a permanent share token', () => { + const ledger = readLibraryLedger(LIBRARY_LEDGER); + assert.deepEqual(ledger.errors, []); + for (const entry of LIBRARY_ENTRIES.filter(({ kind }) => kind !== 'pack')) + assert.ok(ledger.tokenById.has(entry.id), `${entry.id} has no token`); + // Packs are switched by their host, never by a share link. + for (const entry of LIBRARY_ENTRIES.filter(({ kind }) => kind === 'pack')) + assert.equal(ledger.tokenById.has(entry.id), false, entry.id); +}); + +test('nothing in the library is bundled: every source is a live https upstream', () => { + for (const entry of LIBRARY_ENTRIES) { + assert.match(entry.source.url, /^https:\/\//, entry.id); + assert.ok(entry.licence.name && entry.licence.url, entry.id); + assert.ok(entry.attribution.text, entry.id); + } +}); diff --git a/src/library/controller.js b/src/library/controller.js new file mode 100644 index 000000000..49c02bbae --- /dev/null +++ b/src/library/controller.js @@ -0,0 +1,394 @@ +/** + * The Data Library controller: which entries this instance offers, which the + * viewer has added, and the lifetime of their layers. + * + * Core layers are registered once and sealed; library layers register only + * when a viewer adds them (`LayerLifecycle.registerLibraryLayer`), so the + * default panel stays the curated set and an unused entry costs nothing. + */ +import { LIBRARY_ENTRIES, LIBRARY_LEDGER } from './catalog.js'; +import { libraryEntryIdOf, libraryLayerId } from './manifest.js'; +import { + decodeLibraryParam, + encodeLibraryParam, + normalizeLibrarySelection, + readLibraryLedger, + readStoredLibrarySelection, + writeStoredLibrarySelection, +} from './selection.js'; +import { createLibrarySource } from './source.js'; +import { + createLibraryDatasetLayer, + createLibraryTileLayer, + createLibraryViewLayer, +} from './layers.js'; +import { isExplicitLayerStateOrigin } from '../data/layerState.js'; + +function safeLocalStorage() { + try { + return globalThis.localStorage ?? null; + } catch { + return null; + } +} + +/** + * @param {object} options + * @param {object} options.dataManager The sealed LayerLifecycle. + * @param {object} options.services Local GeoJSON layer services. + * @param {() => object|null} [options.getTileset] Photoreal tileset reader. + * @param {object} [options.mapStackController] + * @param {object} [options.shareLink] ShareLinkManager. + * @param {string} [options.initialHash] The page hash at startup. + * @param {(reason: string) => void} [options.requestRender] + * @param {(extent: object) => boolean} [options.zoomTo] Fly to an extent + * through the shell's navigation authority; false when refused. + */ +export function createDataLibrary({ + entries = LIBRARY_ENTRIES, + ledger = LIBRARY_LEDGER, + dataManager, + services, + getTileset = () => null, + mapStackController = null, + shareLink = null, + initialHash = globalThis.location?.hash ?? '', + requestRender = () => {}, + zoomTo = null, + storage = safeLocalStorage(), + source = createLibrarySource({ entries }), +}) { + if (!dataManager?.registerLibraryLayer) + throw new TypeError('A sealed layer manager is required'); + const byId = new Map(entries.map((entry) => [entry.id, entry])); + const switchable = new Set( + entries.filter(({ kind }) => kind !== 'pack').map(({ id }) => id), + ); + const tokens = readLibraryLedger(ledger); + let availability = new Map(); + let serverPresent = false; + let selection = { added: [], on: [] }; + let started = false; + let destroyed = false; + const listeners = new Set(); + const pending = new Map(); + + const notify = () => { + for (const listener of [...listeners]) { + try { + listener(); + } catch (error) { + console.warn('[Library] listener error:', error); + } + } + }; + + const persist = () => { + selection = normalizeLibrarySelection(selection, switchable); + writeStoredLibrarySelection(storage, selection); + shareLink?.onLibraryStateChange?.(); + notify(); + }; + + function createLayer(entry) { + // No navigation owner, no Zoom chip: never a button that does nothing. + const onZoom = + typeof zoomTo === 'function' + ? () => { + zoom(entry.id).catch((error) => + console.warn(`[Library] ${entry.id} zoom failed:`, error), + ); + } + : null; + const onRemove = () => { + remove(entry.id).catch((error) => + console.warn(`[Library] ${entry.id} remove failed:`, error), + ); + }; + if (entry.kind === 'dataset' && entry.source.view) + return createLibraryViewLayer(entry, { + source, + onZoom, + onRemove, + requestRender, + }); + if (entry.kind === 'dataset') + return createLibraryDatasetLayer(entry, { + services, + source, + onZoom, + onRemove, + }); + return createLibraryTileLayer(entry, { + source, + getTileset, + mapStackController, + requestRender, + onZoom, + onRemove, + }); + } + + function isRegistered(id) { + return dataManager.layers.has(libraryLayerId(id)); + } + + function register(entry) { + if (isRegistered(entry.id)) return true; + try { + dataManager.registerLibraryLayer(createLayer(entry)); + return true; + } catch (error) { + console.warn(`[Library] ${entry.id} could not register:`, error); + return false; + } + } + + function addable(id) { + return switchable.has(id) && availability.get(id)?.available === true; + } + + const unsubscribeManager = dataManager.subscribe?.((change) => { + if (change?.type !== 'visibility') return; + const id = libraryEntryIdOf(change.layerId); + if (!id || !selection.added.includes(id)) return; + // Like core layer state: only explicit choices are remembered, never a + // Context session's temporary changes or a restore. + if (!isExplicitLayerStateOrigin(change.origin)) { + notify(); + return; + } + const on = new Set(selection.on); + if (change.enabled) on.add(id); + else on.delete(id); + selection = { ...selection, on: [...on] }; + persist(); + }); + + shareLink?.setLibraryParamsEncoder?.((params) => + encodeLibraryParam( + params, + selection.on.filter((id) => isRegistered(id)), + tokens.tokenById, + ), + ); + + /** + * Read what this instance serves, then restore this browser's selection and + * any `lib=` entries in the opening link. + * @param {{ signal?: AbortSignal }} [options] + */ + async function start({ signal } = {}) { + if (started || destroyed) return; + started = true; + const answer = await source.getAvailability({ signal }); + if (destroyed) return; + availability = answer.availability; + serverPresent = answer.server; + const stored = readStoredLibrarySelection(storage, switchable); + const hash = String(initialHash || '').replace(/^#/, ''); + const linked = hash + ? decodeLibraryParam(new URLSearchParams(hash), tokens.idByToken) + : null; + // A link's entries join the viewer's library and open switched on; with a + // link, it alone decides what is on, as it does for core layers. + const added = [...stored.added, ...(linked || [])]; + const on = linked ?? stored.on; + selection = normalizeLibrarySelection({ added, on }, switchable); + const restoreOrigin = linked ? 'share-restore' : 'local-restore'; + for (const id of selection.added) { + const entry = byId.get(id); + if (!entry || !addable(id) || !register(entry)) continue; + if (selection.on.includes(id)) + dataManager + .setEnabled(libraryLayerId(id), true, { origin: restoreOrigin }) + .catch((error) => + console.warn(`[Library] ${id} restore failed:`, error), + ); + } + writeStoredLibrarySelection(storage, selection); + shareLink?.onLibraryStateChange?.(); + notify(); + } + + /** + * Add an entry to the panel and switch it on. + * @param {string} id + * @returns {Promise} + */ + async function add(id) { + if (destroyed || !addable(id) || pending.has(id)) return false; + const entry = byId.get(id); + const work = (async () => { + if (!register(entry)) return false; + selection = { + added: [...selection.added.filter((other) => other !== id), id], + on: selection.on, + }; + persist(); + await dataManager.setEnabled(libraryLayerId(id), true, { + origin: 'user', + }); + return true; + })(); + pending.set(id, work); + notify(); + try { + return await work; + } finally { + pending.delete(id); + notify(); + } + } + + /** + * Remove an entry from the panel and destroy its layer. + * @param {string} id + * @returns {Promise} + */ + async function remove(id) { + if (destroyed || pending.has(id)) return false; + const work = (async () => { + if (isRegistered(id)) + await dataManager.unregisterLibraryLayer(libraryLayerId(id)); + selection = { + added: selection.added.filter((other) => other !== id), + on: selection.on.filter((other) => other !== id), + }; + persist(); + return true; + })(); + pending.set(id, work); + notify(); + try { + return await work; + } finally { + pending.delete(id); + notify(); + } + } + + /** + * Remove every added entry at once (the Browse dialog's reset). + * @returns {Promise} How many entries were removed. + */ + async function reset() { + if (destroyed) return 0; + const ids = [...selection.added]; + let removed = 0; + for (const id of ids) if (await remove(id)) removed += 1; + return removed; + } + + /** The area an added entry's layer covers, or null. */ + function extentOf(id) { + return ( + dataManager.layers.get(libraryLayerId(id))?.module?.getExtent?.() ?? null + ); + } + + /** + * Fly to an added entry's layer, switching it on first when it is off: a + * dataset only knows where its features are once loaded, and an off layer + * has nothing to show at the destination. + * @param {string} id + * @returns {Promise} Whether the flight started. + */ + async function zoom(id) { + if (destroyed || typeof zoomTo !== 'function' || !isRegistered(id)) + return false; + const layerId = libraryLayerId(id); + if (!dataManager.isEnabled?.(layerId)) + await dataManager.setEnabled(layerId, true, { origin: 'user' }); + const extent = extentOf(id); + if (destroyed || !extent) return false; + return zoomTo(extent) !== false; + } + + /** + * Everything the Browse view shows, in catalog order. + * @returns {Array<{ entry: object, available: boolean, reason: string|null, added: boolean, on: boolean, busy: boolean }>} + */ + function list() { + return entries.map((entry) => { + const state = availability.get(entry.id) || { + available: false, + reason: started ? 'server-unavailable' : null, + }; + return { + entry, + available: state.available, + reason: state.reason, + added: selection.added.includes(entry.id) && isRegistered(entry.id), + on: dataManager.isEnabled?.(libraryLayerId(entry.id)) === true, + busy: pending.has(entry.id), + canZoom: typeof zoomTo === 'function' && isRegistered(entry.id), + }; + }); + } + + /** + * The library at a glance, for the Browse dialog's stats strip. + * `features` sums the features currently loaded by dataset layers that are + * on; tiles are drapes and have no count. + * @returns {{ entries: number, kinds: { dataset: number, tiles: number, pack: number }, themes: string[], available: number, unavailable: number, added: number, on: number, features: number, featureLayers: number }} + */ + function summary() { + const rows = list(); + const kinds = { dataset: 0, tiles: 0, pack: 0 }; + let features = 0; + let featureLayers = 0; + for (const row of rows) { + kinds[row.entry.kind] += 1; + if (!row.on || row.entry.kind !== 'dataset') continue; + const count = dataManager.layers + .get(libraryLayerId(row.entry.id)) + ?.module?.getStats?.()?.count; + if (Number.isFinite(count) && count > 0) { + features += count; + featureLayers += 1; + } + } + const available = rows.filter((row) => row.available).length; + return { + entries: rows.length, + kinds, + // Distinct themes (manifest families) the entries cover, sorted. + themes: [...new Set(rows.map((row) => row.entry.family))].sort(), + available, + unavailable: rows.length - available, + added: rows.filter((row) => row.added).length, + on: rows.filter((row) => row.on).length, + features, + featureLayers, + }; + } + + return { + start, + add, + remove, + summary, + reset, + zoom, + list, + get ready() { + return started && availability.size > 0; + }, + get serverPresent() { + return serverPresent; + }, + subscribe(listener) { + if (typeof listener !== 'function') return () => {}; + listeners.add(listener); + return () => listeners.delete(listener); + }, + destroy() { + if (destroyed) return; + destroyed = true; + unsubscribeManager?.(); + shareLink?.setLibraryParamsEncoder?.(null); + listeners.clear(); + }, + }; +} diff --git a/src/library/controller.test.mjs b/src/library/controller.test.mjs new file mode 100644 index 000000000..2e32a120b --- /dev/null +++ b/src/library/controller.test.mjs @@ -0,0 +1,365 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { LayerLifecycle } from '../data/lifecycle.js'; +import { createDataLibrary } from './controller.js'; +import { LIBRARY_ENTRIES, LIBRARY_LEDGER } from './catalog.js'; +import { validateLibraryCatalog } from './manifest.js'; +import { LIBRARY_STORAGE_KEY } from './selection.js'; + +const base = { + family: 'imagery', + provider: 'Fixture', + description: 'Fixture entry.', + maintainer: 'test', + coverage: { global: true }, + licence: { name: 'Public domain', url: 'https://example.org/licence' }, + attribution: { text: 'Fixture' }, + status: 'community', +}; +const tiles = (id) => ({ + ...base, + id, + kind: 'tiles', + title: id, + source: { + format: 'xyz', + url: 'https://tiles.example.org/{z}/{x}/{y}.png', + fetch: 'browser-direct', + }, +}); +const { entries } = validateLibraryCatalog([ + tiles('night-lights'), + tiles('sea-marks'), + { + ...base, + id: 'fixture-pack', + kind: 'pack', + family: 'cameras', + title: 'Pack', + source: { format: 'host', url: 'https://cams.example.org/' }, + pack: { host: 'cctv', env: 'CCTV_FIXTURE_ENABLED' }, + licence: { ...base.licence, operatorOptIn: true }, + }, +]); +const ledger = [ + ['night-lights', '00'], + ['sea-marks', '01'], +]; + +function sealedManager(t) { + const manager = new LayerLifecycle({}); + manager.finalizeRegistrations([]); + // Enabled layers arm refresh timers; release them with the test. + t.after(() => manager.destroyAll()); + return manager; +} + +function memoryStorage(initial) { + const store = new Map(initial ? [[LIBRARY_STORAGE_KEY, initial]] : []); + return { + store, + getItem: (key) => store.get(key) ?? null, + setItem: (key, value) => store.set(key, value), + }; +} + +const source = (availability) => ({ + async getAvailability() { + return { + server: true, + availability: new Map( + entries.map(({ id }) => [ + id, + availability[id] ?? { available: true, reason: null }, + ]), + ), + }; + }, + async loadDataset() { + return { features: [], truncated: false }; + }, + tileTemplate: (entry) => entry.source.url, +}); + +function shareLinkDouble() { + return { + encoder: null, + changes: 0, + setLibraryParamsEncoder(encoder) { + this.encoder = encoder; + }, + onLibraryStateChange() { + this.changes += 1; + }, + encode() { + const params = new URLSearchParams(); + this.encoder?.(params); + return params.get('lib'); + }, + }; +} + +test('nothing registers until a viewer adds an entry; add turns it on and persists', async (t) => { + const manager = sealedManager(t); + const storage = memoryStorage(); + const shareLink = shareLinkDouble(); + const library = createDataLibrary({ + entries, + ledger, + dataManager: manager, + services: {}, + storage, + shareLink, + initialHash: '', + source: source({}), + }); + await library.start(); + assert.equal(manager.layers.size, 0); + assert.equal(await library.add('night-lights'), true); + assert.equal(manager.isEnabled('lib-night-lights'), true); + assert.equal(manager.getAll()[0].libraryEntryId, 'night-lights'); + assert.deepEqual(JSON.parse(storage.store.get(LIBRARY_STORAGE_KEY)), { + added: ['night-lights'], + on: ['night-lights'], + }); + assert.equal(shareLink.encode(), '00'); + await manager.setEnabled('lib-night-lights', false, { origin: 'user' }); + assert.deepEqual(JSON.parse(storage.store.get(LIBRARY_STORAGE_KEY)).on, []); + assert.equal(shareLink.encode(), null); + assert.equal(await library.remove('night-lights'), true); + assert.equal(manager.layers.has('lib-night-lights'), false); + assert.deepEqual(JSON.parse(storage.store.get(LIBRARY_STORAGE_KEY)), { + added: [], + on: [], + }); +}); + +test('packs and unavailable entries cannot be added', async (t) => { + const manager = sealedManager(t); + const library = createDataLibrary({ + entries, + ledger, + dataManager: manager, + services: {}, + storage: memoryStorage(), + initialHash: '', + source: source({ + 'sea-marks': { available: false, reason: 'operator-opt-in' }, + }), + }); + await library.start(); + assert.equal(await library.add('fixture-pack'), false); + assert.equal(await library.add('sea-marks'), false); + assert.equal(manager.layers.size, 0); + const row = library.list().find(({ entry }) => entry.id === 'sea-marks'); + assert.deepEqual( + { available: row.available, reason: row.reason, added: row.added }, + { available: false, reason: 'operator-opt-in', added: false }, + ); +}); + +test('a stored selection restores; a non-explicit change is not remembered', async (t) => { + const manager = sealedManager(t); + const storage = memoryStorage( + JSON.stringify({ added: ['night-lights', 'sea-marks'], on: ['sea-marks'] }), + ); + const library = createDataLibrary({ + entries, + ledger, + dataManager: manager, + services: {}, + storage, + initialHash: '', + source: source({}), + }); + await library.start(); + await manager.waitForLayerSettled?.('lib-sea-marks'); + assert.equal(manager.layers.has('lib-night-lights'), true); + assert.equal(manager.isEnabled('lib-night-lights'), false); + assert.equal(manager.isEnabled('lib-sea-marks'), true); + await manager.setEnabled('lib-sea-marks', false, { origin: 'context' }); + assert.deepEqual(JSON.parse(storage.store.get(LIBRARY_STORAGE_KEY)).on, [ + 'sea-marks', + ]); +}); + +test('a link decides what is on and adds its entries to the library', async (t) => { + const manager = sealedManager(t); + const storage = memoryStorage( + JSON.stringify({ added: ['night-lights'], on: ['night-lights'] }), + ); + const library = createDataLibrary({ + entries, + ledger, + dataManager: manager, + services: {}, + storage, + initialHash: '#lat=1&lon=2&lib=01.zz', + source: source({}), + }); + await library.start(); + await manager.waitForLayerSettled?.('lib-sea-marks'); + assert.equal(manager.isEnabled('lib-sea-marks'), true); + assert.equal(manager.isEnabled('lib-night-lights'), false); + assert.deepEqual(JSON.parse(storage.store.get(LIBRARY_STORAGE_KEY)), { + added: ['night-lights', 'sea-marks'], + on: ['sea-marks'], + }); +}); + +test('zoom flies to an added layer through the caller, reset removes everything', async (t) => { + const manager = sealedManager(t); + const storage = memoryStorage(); + const flights = []; + const library = createDataLibrary({ + entries, + ledger, + dataManager: manager, + services: {}, + storage, + initialHash: '', + source: source({}), + zoomTo: (extent) => { + flights.push(extent); + return true; + }, + }); + await library.start(); + assert.equal(await library.zoom('night-lights'), false, 'not added yet'); + await library.add('night-lights'); + await library.add('sea-marks'); + const row = library.list().find(({ entry }) => entry.id === 'night-lights'); + assert.equal(row.canZoom, true); + // Off: zoom switches the layer on, then flies. + await manager.setEnabled('lib-night-lights', false, { origin: 'user' }); + assert.equal(await library.zoom('night-lights'), true); + assert.equal(manager.isEnabled('lib-night-lights'), true); + assert.deepEqual(flights[0], { + west: -180, + south: -85, + east: 180, + north: 85, + }); + const chips = manager.layers + .get('lib-night-lights') + .module.getRowControls().chips; + assert.equal(chips[0].id, 'zoom'); + chips[0].onClick(); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(flights.length, 2); + assert.deepEqual( + chips.map(({ id, icon, action, whileOff }) => [id, icon, action, whileOff]), + [ + ['zoom', 'zoom_in_map', true, true], + ['remove', 'delete', true, true], + ], + ); + // The row's delete icon removes that entry from the library. + chips[1].onClick(); + await manager.waitForLayerSettled?.('lib-night-lights'); + await new Promise((resolve) => setTimeout(resolve, 0)); + for ( + let tries = 0; + manager.layers.has('lib-night-lights') && tries < 50; + tries++ + ) + await new Promise((resolve) => setTimeout(resolve, 10)); + assert.equal(manager.layers.has('lib-night-lights'), false); + assert.deepEqual(JSON.parse(storage.store.get(LIBRARY_STORAGE_KEY)).added, [ + 'sea-marks', + ]); + assert.equal(await library.reset(), 1); + assert.equal(manager.layers.size, 0); + assert.deepEqual(JSON.parse(storage.store.get(LIBRARY_STORAGE_KEY)), { + added: [], + on: [], + }); +}); + +test('without a navigation owner the zoom control is disabled', async (t) => { + const manager = sealedManager(t); + const library = createDataLibrary({ + entries, + ledger, + dataManager: manager, + services: {}, + storage: memoryStorage(), + initialHash: '', + source: source({}), + }); + await library.start(); + await library.add('night-lights'); + assert.equal(await library.zoom('night-lights'), false); + assert.deepEqual( + manager.layers + .get('lib-night-lights') + .module.getRowControls() + .chips.map(({ id }) => id), + ['remove'], + ); +}); + +test('the summary counts entries by kind, availability and what is added and on', async (t) => { + const manager = sealedManager(t); + const library = createDataLibrary({ + entries, + ledger, + dataManager: manager, + services: {}, + storage: memoryStorage(), + initialHash: '', + source: source({ + 'sea-marks': { available: false, reason: 'operator-opt-in' }, + }), + }); + await library.start(); + await library.add('night-lights'); + assert.deepEqual(library.summary(), { + entries: 3, + kinds: { dataset: 0, tiles: 2, pack: 1 }, + themes: ['cameras', 'imagery'], + // The pack is available because the fixture source says so. + available: 2, + unavailable: 1, + added: 1, + on: 1, + // Tiles are drapes: no feature count. + features: 0, + featureLayers: 0, + }); +}); + +test('every shipped entry kind registers, view-bounded datasets included', async (t) => { + const manager = sealedManager(t); + const library = createDataLibrary({ + entries: LIBRARY_ENTRIES, + ledger: LIBRARY_LEDGER, + dataManager: manager, + services: {}, + storage: memoryStorage(), + initialHash: '', + source: { + async getAvailability() { + return { + server: true, + availability: new Map( + LIBRARY_ENTRIES.map(({ id }) => [ + id, + { available: true, reason: null }, + ]), + ), + }; + }, + async loadDataset() { + return { features: [], truncated: false }; + }, + tileTemplate: (entry) => entry.source.url, + }, + }); + await library.start(); + assert.equal(await library.add('fema-usa-structures'), true); + const module = manager.layers.get('lib-fema-usa-structures').module; + // No camera to read: the layer waits for one rather than loading. + assert.equal(module.getDiagnostics().tooHigh, true); + assert.equal(await library.add('nasa-black-marble'), true); +}); diff --git a/src/library/entries/ak-statewide-imagery.json b/src/library/entries/ak-statewide-imagery.json new file mode 100644 index 000000000..685e7f2ad --- /dev/null +++ b/src/library/entries/ak-statewide-imagery.json @@ -0,0 +1,47 @@ +{ + "id": "ak-statewide-imagery", + "kind": "tiles", + "family": "imagery", + "title": "Alaska Statewide Imagery (2023)", + "provider": "Alaska Geospatial Council", + "description": "Aerial orthophotos: Statewide coverage of Alaska.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-170, 51.2, -129, 71.4551] + }, + "source": { + "format": "bbox", + "url": "https://apps.geo.fpac.usda.gov/nrcs-imagery/rest/services/ortho_imagery/alaska_vivid_2023_30cm/ImageServer/exportImage?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=jpgpng&f=image", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 19, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "30 cm", + "resolutionM": 0.3, + "trueOrtho": false, + "vintage": "2023–present", + "updateCycle": "Periodic statewide refresh", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Public Domain (U.S. Government work)", + "url": "https://www.usgs.gov/information-policies-and-instructions/copyrights-and-credits" + }, + "attribution": { + "text": "USDA / Alaska Geospatial Council", + "url": "https://gis.data.alaska.gov/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/ar-statewide-imagery.json b/src/library/entries/ar-statewide-imagery.json new file mode 100644 index 000000000..055eb01bf --- /dev/null +++ b/src/library/entries/ar-statewide-imagery.json @@ -0,0 +1,47 @@ +{ + "id": "ar-statewide-imagery", + "kind": "tiles", + "family": "imagery", + "title": "Arkansas Statewide Imagery (2023)", + "provider": "Arkansas GIS Office (GeoStor)", + "description": "Aerial orthophotos: Statewide coverage of Arkansas.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-94.642, 32.9545, -89.584, 36.5414] + }, + "source": { + "format": "bbox", + "url": "https://gis.arkansas.gov/arcgis/rest/services/ImageServices/IMAGERY_9IN_2023/ImageServer/exportImage?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=jpgpng&f=image", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 19, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "9 in / 6 in urban", + "resolutionM": 0.2286, + "trueOrtho": false, + "vintage": "2023–present", + "updateCycle": "Multi-year statewide cycles", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Public domain — Arkansas GIS Office open data", + "url": "https://gis.arkansas.gov/" + }, + "attribution": { + "text": "Arkansas GIS Office", + "url": "https://gis.arkansas.gov/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/az-statewide-imagery.json b/src/library/entries/az-statewide-imagery.json new file mode 100644 index 000000000..d1c23909f --- /dev/null +++ b/src/library/entries/az-statewide-imagery.json @@ -0,0 +1,48 @@ +{ + "id": "az-statewide-imagery", + "kind": "tiles", + "family": "imagery", + "title": "Arizona Statewide Imagery (2017)", + "provider": "AZGeo", + "description": "Aerial orthophotos: Statewide coverage of Arizona.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-115.1014, 31.2527, -108.8553, 37.1065] + }, + "source": { + "format": "xyz", + "url": "https://azgeo.az.gov/arcgis/rest/services/imagery/NAIP2017/MapServer/tile/{z}/{y}/{x}", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 19, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "1 m (NAIP)", + "resolutionM": 1, + "trueOrtho": false, + "vintage": "2017–present", + "updateCycle": "NAIP 2-year cycle", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Open state geodata — attribution requested", + "url": "https://azgeo.az.gov/", + "operatorOptIn": true + }, + "attribution": { + "text": "AZGEO", + "url": "https://azgeo.az.gov/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/bangor-me.json b/src/library/entries/bangor-me.json new file mode 100644 index 000000000..ee3848d0e --- /dev/null +++ b/src/library/entries/bangor-me.json @@ -0,0 +1,48 @@ +{ + "id": "bangor-me", + "kind": "tiles", + "family": "imagery", + "title": "Bangor, Maine Orthoimagery", + "provider": "City of Bangor GIS", + "description": "Aerial orthophotos: Bangor, Maine jurisdiction.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-68.9126, 44.7674, -68.6451, 44.8951] + }, + "source": { + "format": "xyz", + "url": "https://tiles.arcgis.com/tiles/yIxdYDwtUG29jfAN/arcgis/rest/services/2024_Orthoimagery_Tiles/MapServer/tile/{z}/{y}/{x}", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 11, + "maxZoom": 22, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "sub-foot to 1 ft (local program)", + "resolutionM": 0.15, + "trueOrtho": false, + "vintage": "2024–present", + "updateCycle": "Local flight cycle (typically annual to 3-yearly)", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Local government open data — see publisher terms", + "url": "https://www.bangormaine.gov/", + "operatorOptIn": true + }, + "attribution": { + "text": "City of Bangor GIS", + "url": "https://www.bangormaine.gov/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/basemap-at-orthofoto.json b/src/library/entries/basemap-at-orthofoto.json new file mode 100644 index 000000000..48e9eca2a --- /dev/null +++ b/src/library/entries/basemap-at-orthofoto.json @@ -0,0 +1,48 @@ +{ + "id": "basemap-at-orthofoto", + "kind": "tiles", + "family": "imagery", + "title": "basemap.at Orthofoto", + "provider": "basemap.at / geoland.at", + "description": "Aerial orthophotos: 100% of Austria (mosaic of state orthophotos).", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["AT"], + "bbox": [9.53, 46.37, 17.17, 49.02] + }, + "source": { + "format": "xyz", + "url": "https://mapsneu.wien.gv.at/basemap/bmaporthofoto30cm/normal/google3857/{z}/{y}/{x}.jpeg", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 19, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "coverage", + "aerial": true, + "noDataColor": "#fdfdfd" + }, + "imagery": { + "type": "ortho", + "resolution": "20–30 cm", + "resolutionM": 0.2, + "trueOrtho": false, + "vintage": "2021–present", + "updateCycle": "Rolling (per state flight cycles)", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "CC BY 4.0", + "url": "https://basemap.at/nutzungsbedingungen/" + }, + "attribution": { + "text": "basemap.at — Datenquelle: basemap.at", + "url": "https://basemap.at/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/bayern-dop40.json b/src/library/entries/bayern-dop40.json new file mode 100644 index 000000000..265c4d159 --- /dev/null +++ b/src/library/entries/bayern-dop40.json @@ -0,0 +1,48 @@ +{ + "id": "bayern-dop40", + "kind": "tiles", + "family": "imagery", + "title": "Digitales Orthophoto Bayern (DOP 40 cm, open)", + "provider": "LDBV Bayern", + "description": "Aerial orthophotos: 100% of Bavaria (~70,550 km²).", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["DE"], + "bbox": [8.97, 47.27, 13.84, 50.56] + }, + "source": { + "format": "bbox", + "url": "https://geoservices.bayern.de/od/wms/dop/v1/dop40?service=WMS&request=GetMap&version=1.3.0&layers=by_dop40c&styles=&crs=EPSG:3857&bbox={bbox-epsg-3857}&width=256&height=256&format=image/jpeg", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 8, + "maxZoom": 20, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true, + "noDataColor": "#ffffff" + }, + "imagery": { + "type": "ortho", + "resolution": "40 cm (20 cm is paid)", + "resolutionM": 0.4, + "trueOrtho": false, + "vintage": "2021–present", + "updateCycle": "Biennial (rolling)", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "CC BY 4.0", + "url": "https://geodaten.bayern.de/opengeodata/" + }, + "attribution": { + "text": "© Bayerische Vermessungsverwaltung", + "url": "https://geodaten.bayern.de/opengeodata/OpenDataDetail.html?pn=dop40" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/bismarck-mandan-nd.json b/src/library/entries/bismarck-mandan-nd.json new file mode 100644 index 000000000..a6c09c4ba --- /dev/null +++ b/src/library/entries/bismarck-mandan-nd.json @@ -0,0 +1,48 @@ +{ + "id": "bismarck-mandan-nd", + "kind": "tiles", + "family": "imagery", + "title": "Bismarck–Mandan, North Dakota Aerial Photography", + "provider": "North Dakota GIS Hub", + "description": "Aerial orthophotos: Bismarck–Mandan, North Dakota jurisdiction.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-101.0948, 46.6527, -100.5815, 46.9844] + }, + "source": { + "format": "bbox", + "url": "https://ndgishub.nd.gov/arcgis/rest/services/Imagery/AerialImage_BisMan_2023/ImageServer/exportImage?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=jpgpng&f=image", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 11, + "maxZoom": 20, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "sub-foot to 1 ft (local program)", + "resolutionM": 0.15, + "trueOrtho": false, + "vintage": "2023–present", + "updateCycle": "Local flight cycle (typically annual to 3-yearly)", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Local government open data — see publisher terms", + "url": "https://www.gis.nd.gov/", + "operatorOptIn": true + }, + "attribution": { + "text": "95West Aerial Mapping", + "url": "https://www.gis.nd.gov/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/bkg-topplus-open.json b/src/library/entries/bkg-topplus-open.json new file mode 100644 index 000000000..efa989dcf --- /dev/null +++ b/src/library/entries/bkg-topplus-open.json @@ -0,0 +1,34 @@ +{ + "id": "bkg-topplus-open", + "kind": "tiles", + "family": "imagery", + "title": "TopPlusOpen basemap (BKG)", + "provider": "BKG", + "description": "Germany's federal topographic basemap, detailed for Germany and Europe and generalised worldwide.", + "icon": "⛰", + "maintainer": "github:asturksever", + "coverage": { + "global": true + }, + "source": { + "format": "xyz", + "url": "https://sgx.geodatenzentrum.de/wmts_topplus_open/tile/1.0.0/web/default/WEBMERCATOR/{z}/{y}/{x}.png", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 0, + "maxZoom": 18 + }, + "display": { + "opacity": 1 + }, + "licence": { + "name": "Datenlizenz Deutschland – Namensnennung 2.0", + "url": "https://www.govdata.de/dl-de/by-2-0" + }, + "attribution": { + "text": "© BKG, dl-de/by-2-0", + "url": "https://gdz.bkg.bund.de/" + }, + "status": "community", + "reviewed": "2026-10-22" +} diff --git a/src/library/entries/cctv-florida-fl511.json b/src/library/entries/cctv-florida-fl511.json new file mode 100644 index 000000000..5af3ed4ec --- /dev/null +++ b/src/library/entries/cctv-florida-fl511.json @@ -0,0 +1,24 @@ +{ + "id": "cctv-florida-fl511", + "kind": "pack", + "family": "cameras", + "title": "Florida cameras (FL511)", + "provider": "FL511", + "description": "Statewide FL511 traffic camera stills, added to the CCTV layer. Individual personal use only; other use needs FDOT's written consent.", + "icon": "◉", + "maintainer": "core", + "coverage": { "countries": ["US"], "bbox": [-87.64, 24.4, -79.97, 31.0] }, + "source": { "format": "host", "url": "https://fl511.com/" }, + "pack": { "host": "cctv", "env": "CCTV_FLORIDA_ENABLED" }, + "licence": { + "name": "FL511 content: individual personal use only", + "url": "https://fl511.com/privacy#disclaimer", + "operatorOptIn": true + }, + "attribution": { + "text": "Florida DOT — FL511.com", + "url": "https://fl511.com/" + }, + "status": "verified", + "reviewed": "2026-10-21" +} diff --git a/src/library/entries/cctv-lithuania.json b/src/library/entries/cctv-lithuania.json new file mode 100644 index 000000000..7b036c6ce --- /dev/null +++ b/src/library/entries/cctv-lithuania.json @@ -0,0 +1,24 @@ +{ + "id": "cctv-lithuania", + "kind": "pack", + "family": "cameras", + "title": "Lithuania road cameras (Via Lietuva)", + "provider": "Via Lietuva", + "description": "Eismoinfo road-camera stills across Lithuania, added to the CCTV layer. Off until Via Lietuva confirms the photo licence.", + "icon": "◉", + "maintainer": "core", + "coverage": { "countries": ["LT"], "bbox": [20.9, 53.89, 26.84, 56.45] }, + "source": { "format": "host", "url": "https://eismoinfo.lt/" }, + "pack": { "host": "cctv", "env": "CCTV_LITHUANIA_ENABLED" }, + "licence": { + "name": "Via Lietuva open-data statement; camera photo coverage unconfirmed", + "url": "https://eismoinfo.lt/eismoinfo-backend/content?id=44", + "operatorOptIn": true + }, + "attribution": { + "text": "Via Lietuva / eismoinfo.lt", + "url": "https://eismoinfo.lt/" + }, + "status": "verified", + "reviewed": "2026-10-21" +} diff --git a/src/library/entries/cctv-nyc-dot.json b/src/library/entries/cctv-nyc-dot.json new file mode 100644 index 000000000..e6f936fd3 --- /dev/null +++ b/src/library/entries/cctv-nyc-dot.json @@ -0,0 +1,24 @@ +{ + "id": "cctv-nyc-dot", + "kind": "pack", + "family": "cameras", + "title": "New York City cameras (NYC DOT TMC)", + "provider": "NYC DOT", + "description": "About 980 NYC DOT traffic cameras across the five boroughs, added to the CCTV layer. Needs your own NYC DOT data-sharing agreement.", + "icon": "◉", + "maintainer": "core", + "coverage": { "countries": ["US"], "bbox": [-74.26, 40.49, -73.69, 40.92] }, + "source": { + "format": "host", + "url": "https://webcams.nyctmc.org/api/cameras" + }, + "pack": { "host": "cctv", "env": "CCTV_NYC_ENABLED" }, + "licence": { + "name": "NYC DOT camera feed: data-sharing agreement required", + "url": "https://www.nyc.gov/html/dot/html/about/datafeeds.shtml", + "operatorOptIn": true + }, + "attribution": { "text": "NYC DOT Traffic Management Center" }, + "status": "verified", + "reviewed": "2026-10-21" +} diff --git a/src/library/entries/ct-statewide-imagery.json b/src/library/entries/ct-statewide-imagery.json new file mode 100644 index 000000000..e93283574 --- /dev/null +++ b/src/library/entries/ct-statewide-imagery.json @@ -0,0 +1,48 @@ +{ + "id": "ct-statewide-imagery", + "kind": "tiles", + "family": "imagery", + "title": "Connecticut Statewide Imagery (2019)", + "provider": "CT ECO (UConn / CT DEEP)", + "description": "Aerial orthophotos: Statewide coverage of Connecticut.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-73.7389, 40.9738, -71.7784, 42.0598] + }, + "source": { + "format": "bbox", + "url": "https://cteco.uconn.edu/ctraster/rest/services/images/Ortho_2019_tiled/ImageServer/exportImage?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=jpgpng&f=image", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 19, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "6 in", + "resolutionM": 0.1524, + "trueOrtho": false, + "vintage": "2019–present", + "updateCycle": "Roughly every 3 years", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Open state geodata — attribution requested", + "url": "https://ct-deep-gis-open-data-website-ctdeep.hub.arcgis.com/", + "operatorOptIn": true + }, + "attribution": { + "text": "CT ECO — UConn CLEAR / CT DEEP", + "url": "https://ct-deep-gis-open-data-website-ctdeep.hub.arcgis.com/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/dakota-county-mn.json b/src/library/entries/dakota-county-mn.json new file mode 100644 index 000000000..6b0560e63 --- /dev/null +++ b/src/library/entries/dakota-county-mn.json @@ -0,0 +1,48 @@ +{ + "id": "dakota-county-mn", + "kind": "tiles", + "family": "imagery", + "title": "Dakota County, Minnesota Air Photo", + "provider": "Dakota County GIS", + "description": "Aerial orthophotos: Dakota County, Minnesota jurisdiction.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-93.3479, 44.4582, -92.7129, 44.9358] + }, + "source": { + "format": "bbox", + "url": "https://gisimg.co.dakota.mn.us/arcgis/rest/services/AerialPhotography/2025AirPhotoLeafOff6Inch_Spring/ImageServer/exportImage?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=jpgpng&f=image", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 11, + "maxZoom": 20, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "6 in", + "resolutionM": 0.1524, + "trueOrtho": false, + "vintage": "2025–present", + "updateCycle": "Local flight cycle (typically annual to 3-yearly)", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Local government open data — see publisher terms", + "url": "https://gis.co.dakota.mn.us/", + "operatorOptIn": true + }, + "attribution": { + "text": "Dakota County GIS", + "url": "https://gis.co.dakota.mn.us/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/dc-statewide-imagery.json b/src/library/entries/dc-statewide-imagery.json new file mode 100644 index 000000000..7660aaf74 --- /dev/null +++ b/src/library/entries/dc-statewide-imagery.json @@ -0,0 +1,47 @@ +{ + "id": "dc-statewide-imagery", + "kind": "tiles", + "family": "imagery", + "title": "District of Columbia Statewide Imagery (2025)", + "provider": "DC GIS (OCTO)", + "description": "Aerial orthophotos: Statewide coverage of District of Columbia.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-77.1224, 38.7855, -76.9007, 39.0018] + }, + "source": { + "format": "bbox", + "url": "https://imagery.dcgis.dc.gov/dcgis/rest/services/Ortho/Ortho_2025/ImageServer/exportImage?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=jpgpng&f=image", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 19, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "3 in", + "resolutionM": 0.0762, + "trueOrtho": false, + "vintage": "2025–present", + "updateCycle": "Annual", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Open data — DC public domain terms", + "url": "https://opendata.dc.gov/pages/terms-of-use" + }, + "attribution": { + "text": "Office of the Chief Technology Officer", + "url": "https://opendata.dc.gov/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/de-statewide-imagery.json b/src/library/entries/de-statewide-imagery.json new file mode 100644 index 000000000..83d3b2cd0 --- /dev/null +++ b/src/library/entries/de-statewide-imagery.json @@ -0,0 +1,48 @@ +{ + "id": "de-statewide-imagery", + "kind": "tiles", + "family": "imagery", + "title": "Delaware Statewide Imagery (2024)", + "provider": "Delaware FirstMap", + "description": "Aerial orthophotos: Statewide coverage of Delaware.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-75.8081, 38.4498, -75.0334, 39.8439] + }, + "source": { + "format": "bbox", + "url": "https://imagery.firstmap.delaware.gov/imagery/rest/services/DE_Imagery_SP/DE_Imagery_2024/ImageServer/exportImage?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=jpgpng&f=image", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 19, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "3–6 in", + "resolutionM": 0.1524, + "trueOrtho": false, + "vintage": "2024–present", + "updateCycle": "Roughly every 2 years", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Open state geodata — attribution requested", + "url": "https://firstmap.delaware.gov/", + "operatorOptIn": true + }, + "attribution": { + "text": "This data is meant for use by requested parties. This data cannot be used without express consent.This data is meant for use by requested parties. This data cannot be used without express consent.", + "url": "https://firstmap.delaware.gov/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/ee-maa-amet-ortofoto.json b/src/library/entries/ee-maa-amet-ortofoto.json new file mode 100644 index 000000000..d4aeb6122 --- /dev/null +++ b/src/library/entries/ee-maa-amet-ortofoto.json @@ -0,0 +1,47 @@ +{ + "id": "ee-maa-amet-ortofoto", + "kind": "tiles", + "family": "imagery", + "title": "Eesti ortofoto (Maa-amet)", + "provider": "Estonian Land Board (Maa-amet)", + "description": "Aerial orthophotos: 100% of Estonia (2-year cycle; cities yearly).", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["EE"], + "bbox": [21.76, 57.51, 28.21, 59.68] + }, + "source": { + "format": "xyz", + "url": "https://tiles.maaamet.ee/tm/tms/1.0.0/foto@GMC/{z}/{x}/{reverseY}.png", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 18, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "coverage", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "10–25 cm", + "resolutionM": 0.1, + "trueOrtho": false, + "vintage": "2021–present", + "updateCycle": "Biennial (annual in cities)", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Maa-amet open data licence (attribution)", + "url": "https://geoportaal.maaamet.ee/eng/Spatial-Data/Orthophotos-p309.html" + }, + "attribution": { + "text": "© Maa-amet (Estonian Land Board)", + "url": "https://geoportaal.maaamet.ee/eng/Download-p609.html" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/eia-power-plants.json b/src/library/entries/eia-power-plants.json new file mode 100644 index 000000000..13372ccfd --- /dev/null +++ b/src/library/entries/eia-power-plants.json @@ -0,0 +1,68 @@ +{ + "id": "eia-power-plants", + "kind": "dataset", + "family": "infrastructure", + "title": "US power plants, 100 MW and up (EIA)", + "provider": "EIA", + "description": "Operating US power plants with at least 100 MW of capacity, with their primary energy source, from the EIA's US Energy Atlas.", + "icon": "ϟ", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-180, 13, -64, 72] + }, + "source": { + "format": "arcgis", + "url": "https://services2.arcgis.com/FiaPA4ga0iQKduv3/arcgis/rest/services/Power_Plants_in_the_US/FeatureServer/0", + "query": { + "where": "Total_MW >= 100", + "outFields": "Plant_Name,Utility_Na,PrimSource,Total_MW,State" + }, + "fetch": "browser-direct", + "cacheSeconds": 86400, + "maxFeatures": 4000, + "maxPages": 3 + }, + "display": { + "color": "#a0aec0", + "label": "Plant_Name", + "fields": ["PrimSource", "Total_MW"], + "rank": { + "field": "Total_MW", + "order": "desc" + }, + "colorBy": { + "field": "PrimSource", + "colors": { + "coal": "#d55181", + "natural gas": "#d55181", + "petroleum": "#d55181", + "nuclear": "#c98500", + "hydroelectric": "#3987e5", + "pumped storage": "#3987e5", + "wind": "#008300", + "solar": "#008300" + }, + "labels": { + "coal": "Fossil (coal, gas, oil)", + "natural gas": "Fossil (coal, gas, oil)", + "petroleum": "Fossil (coal, gas, oil)", + "nuclear": "Nuclear", + "hydroelectric": "Hydro", + "pumped storage": "Hydro", + "wind": "Wind and solar", + "solar": "Wind and solar" + } + } + }, + "licence": { + "name": "U.S. Energy Information Administration data: public domain", + "url": "https://www.eia.gov/about/copyrights_reuse.php" + }, + "attribution": { + "text": "U.S. Energy Information Administration (EIA-860, EIA-860M, EIA-923)", + "url": "https://atlas.eia.gov/" + }, + "status": "community", + "reviewed": "2026-10-21" +} diff --git a/src/library/entries/emodnet-bathymetry.json b/src/library/entries/emodnet-bathymetry.json new file mode 100644 index 000000000..58b99fea3 --- /dev/null +++ b/src/library/entries/emodnet-bathymetry.json @@ -0,0 +1,34 @@ +{ + "id": "emodnet-bathymetry", + "kind": "tiles", + "family": "environment", + "title": "European seabed depth (EMODnet)", + "provider": "EMODnet", + "description": "Shaded relief of the seabed across European seas from the EMODnet Digital Bathymetry model.", + "icon": "≈", + "maintainer": "github:asturksever", + "coverage": { + "bbox": [-36, 15, 43, 90] + }, + "source": { + "format": "xyz", + "url": "https://tiles.emodnet-bathymetry.eu/2020/baselayer/web_mercator/{z}/{x}/{y}.png", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 0, + "maxZoom": 12 + }, + "display": { + "opacity": 0.85 + }, + "licence": { + "name": "EMODnet data: free use with attribution", + "url": "https://emodnet.ec.europa.eu/en/emodnet-data-policy" + }, + "attribution": { + "text": "EMODnet Bathymetry Consortium — EMODnet Digital Bathymetry (DTM)", + "url": "https://emodnet.ec.europa.eu/en/bathymetry" + }, + "status": "community", + "reviewed": "2026-10-22" +} diff --git a/src/library/entries/epa-superfund-sites.json b/src/library/entries/epa-superfund-sites.json new file mode 100644 index 000000000..2f9d63049 --- /dev/null +++ b/src/library/entries/epa-superfund-sites.json @@ -0,0 +1,40 @@ +{ + "id": "epa-superfund-sites", + "kind": "dataset", + "family": "environment", + "title": "Superfund sites (US EPA)", + "provider": "US EPA", + "description": "The about 1,400 hazardous waste sites on the EPA's Superfund list, from the Facility Registry Service.", + "icon": "☢", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-180, 13, -64, 72] + }, + "source": { + "format": "arcgis", + "url": "https://geopub.epa.gov/arcgis/rest/services/EMEF/efpoints/MapServer/0", + "query": { + "outFields": "primary_name,city_name,state_code" + }, + "fetch": "browser-direct", + "cacheSeconds": 86400, + "maxFeatures": 2000, + "maxPages": 1 + }, + "display": { + "color": "#d69e2e", + "label": "primary_name", + "fields": ["city_name", "state_code"] + }, + "licence": { + "name": "U.S. EPA data: public domain", + "url": "https://www.epa.gov/web-policies-and-procedures/epa-disclaimers" + }, + "attribution": { + "text": "U.S. Environmental Protection Agency — Facility Registry Service (EnviroMapper)", + "url": "https://www.epa.gov/frs" + }, + "status": "community", + "reviewed": "2026-10-22" +} diff --git a/src/library/entries/epa-toxic-release-facilities.json b/src/library/entries/epa-toxic-release-facilities.json new file mode 100644 index 000000000..4cf515fea --- /dev/null +++ b/src/library/entries/epa-toxic-release-facilities.json @@ -0,0 +1,45 @@ +{ + "id": "epa-toxic-release-facilities", + "kind": "dataset", + "family": "environment", + "title": "Toxic release facilities (US EPA TRI)", + "provider": "US EPA", + "description": "The about 64,500 facilities that report releases to the EPA's Toxics Release Inventory. Loads the facilities in view below 15 km.", + "icon": "☣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-180, 13, -64, 72] + }, + "source": { + "format": "arcgis", + "url": "https://geopub.epa.gov/arcgis/rest/services/EMEF/efpoints/MapServer/1", + "query": { + "outFields": "primary_name,city_name,state_code" + }, + "fetch": "browser-direct", + "view": { + "maxAltitudeM": 15000, + "sample": [-95.2, 29.7, -95.1, 29.76] + }, + "cacheSeconds": 86400, + "maxBytes": 8388608, + "maxFeatures": 2000, + "maxPages": 1 + }, + "display": { + "color": "#ed8936", + "label": "primary_name", + "fields": ["city_name", "state_code"] + }, + "licence": { + "name": "U.S. EPA data: public domain", + "url": "https://www.epa.gov/web-policies-and-procedures/epa-disclaimers" + }, + "attribution": { + "text": "U.S. Environmental Protection Agency — Toxics Release Inventory via the Facility Registry Service", + "url": "https://www.epa.gov/toxics-release-inventory-tri-program" + }, + "status": "community", + "reviewed": "2026-10-22" +} diff --git a/src/library/entries/epc-england-wales.json b/src/library/entries/epc-england-wales.json new file mode 100644 index 000000000..70741b59a --- /dev/null +++ b/src/library/entries/epc-england-wales.json @@ -0,0 +1,66 @@ +{ + "id": "epc-england-wales", + "kind": "dataset", + "family": "infrastructure", + "title": "Home energy ratings, England and Wales (EPC)", + "provider": "Addresscloud / DLUHC", + "description": "Every domestic Energy Performance Certificate in England and Wales at its UPRN, coloured by current rating A–G. Loads the homes in view once you zoom in below 4 km.", + "icon": "⌂", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["GB"], + "bbox": [-5.8, 49.8, 1.8, 55.9] + }, + "source": { + "format": "geoparquet", + "url": "https://s3.us-west-2.amazonaws.com/us-west-2.opendata.source.coop/addresscloud/epc/geoparquet-local-authority/", + "partitions": { + "listUrl": "https://s3.us-west-2.amazonaws.com/us-west-2.opendata.source.coop?list-type=2&prefix=addresscloud/epc/geoparquet-local-authority/", + "keyBase": "https://s3.us-west-2.amazonaws.com/us-west-2.opendata.source.coop/" + }, + "fetch": "browser-direct", + "view": { + "maxAltitudeM": 4000, + "sample": [-3.19, 51.47, -3.16, 51.49] + }, + "cacheSeconds": 86400, + "maxBytes": 8388608, + "maxFeatures": 6000 + }, + "display": { + "color": "#a0aec0", + "label": "UPRN", + "fields": ["CURRENT_ENERGY_RATING", "POTENTIAL_ENERGY_RATING"], + "colorBy": { + "field": "CURRENT_ENERGY_RATING", + "colors": { + "A": "#008054", + "B": "#19b459", + "C": "#8dce46", + "D": "#ffd500", + "E": "#fcaa65", + "F": "#ef8023", + "G": "#e9153b" + }, + "labels": { + "A": "A · 92–100", + "B": "B · 81–91", + "C": "C · 69–80", + "D": "D · 55–68", + "E": "E · 39–54", + "F": "F · 21–38", + "G": "G · 1–20" + } + } + }, + "licence": { + "name": "Open Government Licence v3.0 (EPC data); OS Open UPRN under OGL", + "url": "https://epc.opendatacommunities.org/docs/copyright" + }, + "attribution": { + "text": "Energy Performance of Buildings Data, DLUHC; geocoded by Addresscloud with OS Open UPRN — contains OS data © Crown copyright and database right; via Source Cooperative", + "url": "https://source.coop/addresscloud/epc" + }, + "status": "community", + "reviewed": "2026-10-22" +} diff --git a/src/library/entries/fema-usa-structures.json b/src/library/entries/fema-usa-structures.json new file mode 100644 index 000000000..8d915d6c3 --- /dev/null +++ b/src/library/entries/fema-usa-structures.json @@ -0,0 +1,65 @@ +{ + "id": "fema-usa-structures", + "kind": "dataset", + "family": "infrastructure", + "title": "US building footprints (FEMA USA Structures)", + "provider": "FEMA / ORNL", + "description": "Footprints of every US structure over 450 sq ft, about 135 million, with occupancy class. Loads the buildings in view once you zoom in below 4 km.", + "icon": "▦", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US", "PR", "GU", "VI", "AS", "MP"] + }, + "source": { + "format": "arcgis", + "url": "https://services2.arcgis.com/FiaPA4ga0iQKduv3/arcgis/rest/services/USA_Structures_View/FeatureServer/0", + "query": { + "outFields": "OCC_CLS,PRIM_OCC,HEIGHT,SQFEET" + }, + "fetch": "browser-direct", + "view": { + "maxAltitudeM": 4000, + "sample": [-77.04, 38.885, -77.02, 38.9] + }, + "cacheSeconds": 86400, + "maxBytes": 8388608, + "maxFeatures": 2000, + "maxPages": 1 + }, + "display": { + "color": "#a0aec0", + "label": "PRIM_OCC", + "fields": ["OCC_CLS", "HEIGHT"], + "colorBy": { + "field": "OCC_CLS", + "colors": { + "Residential": "#3987e5", + "Commercial": "#c98500", + "Industrial": "#d55181", + "Utility and Misc": "#d55181", + "Government": "#008300", + "Education": "#008300", + "Assembly": "#008300" + }, + "labels": { + "Residential": "Residential", + "Commercial": "Commercial", + "Industrial": "Industrial and utility", + "Utility and Misc": "Industrial and utility", + "Government": "Public (government, education, assembly)", + "Education": "Public (government, education, assembly)", + "Assembly": "Public (government, education, assembly)" + } + } + }, + "licence": { + "name": "CC BY 4.0", + "url": "https://creativecommons.org/licenses/by/4.0/" + }, + "attribution": { + "text": "FEMA Response Geospatial Office and Oak Ridge National Laboratory — USA Structures", + "url": "https://www.arcgis.com/home/item.html?id=0ec8512ad21e4bb987d7e848d14e7e24" + }, + "status": "community", + "reviewed": "2026-10-22" +} diff --git a/src/library/entries/gbif-occurrence-density.json b/src/library/entries/gbif-occurrence-density.json new file mode 100644 index 000000000..c4b0a293d --- /dev/null +++ b/src/library/entries/gbif-occurrence-density.json @@ -0,0 +1,60 @@ +{ + "id": "gbif-occurrence-density", + "kind": "tiles", + "family": "environment", + "title": "Biodiversity records (GBIF)", + "provider": "GBIF", + "description": "Where more than two billion plant, animal and fungus observations have been recorded, as a hexagon density map.", + "icon": "❦", + "maintainer": "github:asturksever", + "coverage": { + "global": true + }, + "source": { + "format": "xyz", + "url": "https://api.gbif.org/v2/map/occurrence/density/{z}/{x}/{y}@1x.png?style=classic.poly&bin=hex", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 0, + "maxZoom": 14 + }, + "display": { + "opacity": 0.8, + "legend": [ + { + "label": "≤ 10 records", + "color": "#ffff00" + }, + { + "label": "11–100 records", + "color": "#ffcc00" + }, + { + "label": "101–1,000 records", + "color": "#ff9900" + }, + { + "label": "1,001–10,000 records", + "color": "#ff6600" + }, + { + "label": "10,001–100,000 records", + "color": "#d60a00" + }, + { + "label": "> 100,000 records", + "color": "#c2002d" + } + ] + }, + "licence": { + "name": "GBIF data use agreement (records under CC0, CC BY or CC BY-NC)", + "url": "https://www.gbif.org/terms" + }, + "attribution": { + "text": "GBIF.org — Global Biodiversity Information Facility", + "url": "https://www.gbif.org/" + }, + "status": "community", + "reviewed": "2026-10-22" +} diff --git a/src/library/entries/gdacs-disaster-alerts.json b/src/library/entries/gdacs-disaster-alerts.json new file mode 100644 index 000000000..b7d7787d2 --- /dev/null +++ b/src/library/entries/gdacs-disaster-alerts.json @@ -0,0 +1,56 @@ +{ + "id": "gdacs-disaster-alerts", + "kind": "dataset", + "family": "events", + "title": "Disaster alerts (GDACS)", + "provider": "GDACS", + "description": "Current earthquakes, tropical cyclones, floods, volcanoes, droughts and wildfires with UN–EC GDACS alert levels.", + "icon": "⚑", + "maintainer": "github:asturksever", + "coverage": { + "global": true + }, + "source": { + "format": "geojson", + "url": "https://www.gdacs.org/gdacsapi/api/events/geteventlist/SEARCH", + "query": { + "eventlist": "EQ;TC;FL;VO;DR;WF" + }, + "fetch": "browser-direct", + "cacheSeconds": 900, + "maxBytes": 8388608, + "maxFeatures": 2000 + }, + "display": { + "color": "#a0aec0", + "label": "name", + "fields": ["alertlevel", "country"], + "rank": { + "field": "fromdate", + "order": "desc" + }, + "colorBy": { + "field": "alertlevel", + "colors": { + "Red": "#d03b3b", + "Orange": "#ec835a", + "Green": "#0ca30c" + }, + "labels": { + "Red": "Red alert", + "Orange": "Orange alert", + "Green": "Green alert" + } + } + }, + "licence": { + "name": "GDACS terms of use (free, attribution required)", + "url": "https://www.gdacs.org/About/termofuse.aspx" + }, + "attribution": { + "text": "GDACS — Global Disaster Alert and Coordination System (UN, European Commission)", + "url": "https://www.gdacs.org/" + }, + "status": "community", + "reviewed": "2026-10-22" +} diff --git a/src/library/entries/geneva-sitg-ortho.json b/src/library/entries/geneva-sitg-ortho.json new file mode 100644 index 000000000..73eafc001 --- /dev/null +++ b/src/library/entries/geneva-sitg-ortho.json @@ -0,0 +1,48 @@ +{ + "id": "geneva-sitg-ortho", + "kind": "tiles", + "family": "imagery", + "title": "Orthophotos Canton de Genève (SITG)", + "provider": "SITG — Canton de Genève", + "description": "Aerial orthophotos: Canton of Geneva (~282 km²).", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["CH"], + "bbox": [5.95, 46.12, 6.32, 46.37] + }, + "source": { + "format": "bbox", + "url": "https://ge.ch/terags/rest/services/RASTER/ORTHOPHOTOS/MapServer/export?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=jpg&f=image", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 10, + "maxZoom": 20, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true, + "noDataColor": "#fdfdfd" + }, + "imagery": { + "type": "ortho", + "resolution": "5 cm", + "resolutionM": 0.05, + "trueOrtho": true, + "vintage": "2023–present", + "updateCycle": "Annual", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "SITG open data — free use with attribution", + "url": "https://ge.ch/sitg/media/sitg/documents/CGU_donnees_libres.pdf" + }, + "attribution": { + "text": "© SITG — Etat de Genève", + "url": "https://ge.ch/sitg/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/gpw-population-density.json b/src/library/entries/gpw-population-density.json new file mode 100644 index 000000000..10e31493e --- /dev/null +++ b/src/library/entries/gpw-population-density.json @@ -0,0 +1,60 @@ +{ + "id": "gpw-population-density", + "kind": "tiles", + "family": "demographics", + "title": "Population density 2020 (SEDAC GPW)", + "provider": "NASA GIBS", + "description": "Estimated people per square kilometre in 2020 from SEDAC's Gridded Population of the World v4.", + "icon": "◍", + "maintainer": "github:asturksever", + "coverage": { + "global": true + }, + "source": { + "format": "xyz", + "url": "https://gibs.earthdata.nasa.gov/wmts/epsg3857/best/GPW_Population_Density_2020/default/GoogleMapsCompatible_Level7/{z}/{y}/{x}.png", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 0, + "maxZoom": 7 + }, + "display": { + "opacity": 0.75, + "legend": [ + { + "label": "< 1 people per km²", + "color": "#fff2d1" + }, + { + "label": "5–7.5 people per km²", + "color": "#fed59a" + }, + { + "label": "25–50 people per km²", + "color": "#faab4d" + }, + { + "label": "100–125 people per km²", + "color": "#fc9943" + }, + { + "label": "250–325 people per km²", + "color": "#f55e2c" + }, + { + "label": "≥ 1,000 people per km²", + "color": "#bd0026" + } + ] + }, + "licence": { + "name": "CC BY 4.0", + "url": "https://sedac.ciesin.columbia.edu/data/collection/gpw-v4/terms-of-use" + }, + "attribution": { + "text": "CIESIN, Columbia University — Gridded Population of the World v4.11, via NASA GIBS", + "url": "https://sedac.ciesin.columbia.edu/data/collection/gpw-v4" + }, + "status": "community", + "reviewed": "2026-10-22" +} diff --git a/src/library/entries/grand-forks-nd.json b/src/library/entries/grand-forks-nd.json new file mode 100644 index 000000000..ee8cbb379 --- /dev/null +++ b/src/library/entries/grand-forks-nd.json @@ -0,0 +1,48 @@ +{ + "id": "grand-forks-nd", + "kind": "tiles", + "family": "imagery", + "title": "Grand Forks, North Dakota Aerial Photography", + "provider": "North Dakota GIS Hub", + "description": "Aerial orthophotos: Grand Forks, North Dakota jurisdiction.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-97.2308, 47.8072, -96.92, 48.0153] + }, + "source": { + "format": "bbox", + "url": "https://ndgishub.nd.gov/arcgis/rest/services/Imagery/AerialImage_GrandForks_2025/ImageServer/exportImage?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=jpgpng&f=image", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 11, + "maxZoom": 20, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "sub-foot to 1 ft (local program)", + "resolutionM": 0.15, + "trueOrtho": false, + "vintage": "2025–present", + "updateCycle": "Local flight cycle (typically annual to 3-yearly)", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Local government open data — see publisher terms", + "url": "https://www.gis.nd.gov/", + "operatorOptIn": true + }, + "attribution": { + "text": "Grand Forks/East Grand Forks Metropolitan Planning Organization (MPO)", + "url": "https://www.gis.nd.gov/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/gsi-japan-map.json b/src/library/entries/gsi-japan-map.json new file mode 100644 index 000000000..e7e1201c2 --- /dev/null +++ b/src/library/entries/gsi-japan-map.json @@ -0,0 +1,35 @@ +{ + "id": "gsi-japan-map", + "kind": "tiles", + "family": "imagery", + "title": "Japan standard map (GSI)", + "provider": "GSI", + "description": "The Geospatial Information Authority of Japan's standard map tiles.", + "icon": "⛰", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["JP"], + "bbox": [122, 20, 154, 46] + }, + "source": { + "format": "xyz", + "url": "https://cyberjapandata.gsi.go.jp/xyz/std/{z}/{x}/{y}.png", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 2, + "maxZoom": 18 + }, + "display": { + "opacity": 1 + }, + "licence": { + "name": "GSI Tiles terms (free use with attribution)", + "url": "https://maps.gsi.go.jp/development/ichiran.html" + }, + "attribution": { + "text": "Geospatial Information Authority of Japan (GSI Tiles)", + "url": "https://maps.gsi.go.jp/" + }, + "status": "community", + "reviewed": "2026-10-22" +} diff --git a/src/library/entries/gsi-japan-photo.json b/src/library/entries/gsi-japan-photo.json new file mode 100644 index 000000000..e5eef262e --- /dev/null +++ b/src/library/entries/gsi-japan-photo.json @@ -0,0 +1,48 @@ +{ + "id": "gsi-japan-photo", + "kind": "tiles", + "family": "imagery", + "title": "Japan aerial photos (GSI)", + "provider": "GSI", + "description": "Seamless aerial and satellite photography of Japan from the Geospatial Information Authority of Japan.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["JP"], + "bbox": [122, 20, 154, 46] + }, + "source": { + "format": "xyz", + "url": "https://cyberjapandata.gsi.go.jp/xyz/seamlessphoto/{z}/{x}/{y}.jpg", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 2, + "maxZoom": 18, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "countries", + "aerial": true, + "noDataColor": "#343434" + }, + "imagery": { + "type": "ortho", + "resolution": "20 cm (cities) – 40 cm", + "resolutionM": 0.2, + "trueOrtho": false, + "vintage": "2007–present", + "updateCycle": "Rolling", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "GSI Tiles terms (free use with attribution)", + "url": "https://maps.gsi.go.jp/development/ichiran.html" + }, + "attribution": { + "text": "Geospatial Information Authority of Japan (GSI Tiles)", + "url": "https://maps.gsi.go.jp/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/gvp-holocene-volcanoes.json b/src/library/entries/gvp-holocene-volcanoes.json new file mode 100644 index 000000000..fc0b8edd5 --- /dev/null +++ b/src/library/entries/gvp-holocene-volcanoes.json @@ -0,0 +1,47 @@ +{ + "id": "gvp-holocene-volcanoes", + "kind": "dataset", + "family": "environment", + "title": "Holocene volcanoes (Smithsonian GVP)", + "provider": "Smithsonian GVP", + "description": "The roughly 1,200 volcanoes with eruptions in the last 12,000 years, from the Smithsonian Global Volcanism Program's Volcanoes of the World.", + "icon": "▲", + "maintainer": "github:asturksever", + "coverage": { + "global": true + }, + "source": { + "format": "geojson", + "url": "https://webservices.volcano.si.edu/geoserver/GVP-VOTW/ows", + "query": { + "service": "WFS", + "version": "2.0.0", + "request": "GetFeature", + "typeName": "GVP-VOTW:Smithsonian_VOTW_Holocene_Volcanoes", + "outputFormat": "application/json" + }, + "fetch": "proxied", + "cacheSeconds": 86400, + "maxBytes": 8388608, + "maxFeatures": 2000 + }, + "display": { + "color": "#ff3d6b", + "label": "Volcano_Name", + "fields": ["Primary_Volcano_Type", "Last_Eruption_Year"], + "rank": { + "field": "Last_Eruption_Year", + "order": "desc" + } + }, + "licence": { + "name": "Smithsonian Terms of Use (personal, educational, non-commercial use; cite the Global Volcanism Program)", + "url": "https://www.si.edu/termsofuse" + }, + "attribution": { + "text": "Global Volcanism Program, Smithsonian Institution — Volcanoes of the World", + "url": "https://volcano.si.edu/" + }, + "status": "community", + "reviewed": "2026-10-21" +} diff --git a/src/library/entries/ia-statewide-imagery.json b/src/library/entries/ia-statewide-imagery.json new file mode 100644 index 000000000..9319b4b32 --- /dev/null +++ b/src/library/entries/ia-statewide-imagery.json @@ -0,0 +1,48 @@ +{ + "id": "ia-statewide-imagery", + "kind": "tiles", + "family": "imagery", + "title": "Iowa Statewide Imagery (2023)", + "provider": "Iowa State Univ. GIS / Iowa DNR", + "description": "Aerial orthophotos: Statewide coverage of Iowa.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-96.7456, 40.3273, -90.0366, 43.5847] + }, + "source": { + "format": "bbox", + "url": "https://ortho.gis.iastate.edu/arcgis/rest/services/ortho/naip_2023_cir/ImageServer/exportImage?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=jpgpng&f=image", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 19, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "1 m / 60 cm (NAIP)", + "resolutionM": 0.6, + "trueOrtho": false, + "vintage": "2023–present", + "updateCycle": "NAIP 2-year cycle", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Open state geodata — attribution requested", + "url": "https://geodata.iowa.gov/", + "operatorOptIn": true + }, + "attribution": { + "text": "USDA NAIP, Iowa State University GIS Facility", + "url": "https://geodata.iowa.gov/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/icgc-orto-catalunya.json b/src/library/entries/icgc-orto-catalunya.json new file mode 100644 index 000000000..bb0280b22 --- /dev/null +++ b/src/library/entries/icgc-orto-catalunya.json @@ -0,0 +1,47 @@ +{ + "id": "icgc-orto-catalunya", + "kind": "tiles", + "family": "imagery", + "title": "Ortofoto de Catalunya 25 cm", + "provider": "ICGC", + "description": "Aerial orthophotos: Catalonia (~32,100 km²).", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["ES"], + "bbox": [0.15, 40.52, 3.34, 42.87] + }, + "source": { + "format": "xyz", + "url": "https://geoserveis.icgc.cat/icc_mapesmultibase/noutm/wmts/orto/GRID3857/{z}/{x}/{y}.jpeg", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 7, + "maxZoom": 20, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "25 cm (15 cm in cities)", + "resolutionM": 0.15, + "trueOrtho": false, + "vintage": "2022–present", + "updateCycle": "Annual", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "CC BY 4.0", + "url": "https://www.icgc.cat/en/Ambits-tematics/Ortofoto" + }, + "attribution": { + "text": "© Institut Cartogràfic i Geològic de Catalunya", + "url": "https://www.icgc.cat/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/id-statewide-imagery.json b/src/library/entries/id-statewide-imagery.json new file mode 100644 index 000000000..e3b02557a --- /dev/null +++ b/src/library/entries/id-statewide-imagery.json @@ -0,0 +1,47 @@ +{ + "id": "id-statewide-imagery", + "kind": "tiles", + "family": "imagery", + "title": "Idaho Statewide Imagery (2023)", + "provider": "INSIDE Idaho", + "description": "Aerial orthophotos: Statewide coverage of Idaho.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-117.5533, 41.8913, -110.597, 49.0918] + }, + "source": { + "format": "bbox", + "url": "https://insideidaho.org/arcgis/rest/services/imagery/idaho_2023/ImageServer/exportImage?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=jpgpng&f=image", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 19, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "60 cm (NAIP)", + "resolutionM": 0.6, + "trueOrtho": false, + "vintage": "2023–present", + "updateCycle": "NAIP 2-year cycle", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Public domain / open — INSIDE Idaho terms", + "url": "https://insideidaho.org/" + }, + "attribution": { + "text": "United States Department of Agriculture, Farm Production and Conservation Business Center, Geospatial Enterprise Operations (FPAC-BC-GEO)", + "url": "https://insideidaho.org/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/ign-orthophotos-france.json b/src/library/entries/ign-orthophotos-france.json new file mode 100644 index 000000000..410546629 --- /dev/null +++ b/src/library/entries/ign-orthophotos-france.json @@ -0,0 +1,48 @@ +{ + "id": "ign-orthophotos-france", + "kind": "tiles", + "family": "imagery", + "title": "France aerial photos (IGN)", + "provider": "IGN", + "description": "IGN's national orthophotography of metropolitan France and Corsica.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["FR"], + "bbox": [-5.5, 41.2, 9.8, 51.2] + }, + "source": { + "format": "xyz", + "url": "https://data.geopf.fr/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=ORTHOIMAGERY.ORTHOPHOTOS&STYLE=normal&TILEMATRIXSET=PM&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&FORMAT=image/jpeg", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 19, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "coverage", + "aerial": true, + "noDataColor": "#0a3b59" + }, + "imagery": { + "type": "ortho", + "resolution": "20 cm", + "resolutionM": 0.2, + "trueOrtho": false, + "vintage": "2018–present", + "updateCycle": "3-year rolling refresh", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Licence Ouverte / Etalab 2.0", + "url": "https://www.etalab.gouv.fr/licence-ouverte-open-licence/" + }, + "attribution": { + "text": "© IGN — Géoplateforme", + "url": "https://geoservices.ign.fr/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/il-statewide-imagery.json b/src/library/entries/il-statewide-imagery.json new file mode 100644 index 000000000..111d0fbb5 --- /dev/null +++ b/src/library/entries/il-statewide-imagery.json @@ -0,0 +1,48 @@ +{ + "id": "il-statewide-imagery", + "kind": "tiles", + "family": "imagery", + "title": "Illinois Statewide Imagery", + "provider": "ISGS / IDOT", + "description": "Aerial orthophotos: Statewide coverage of Illinois.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-91.3996, 36.97, -87.469, 41.0314] + }, + "source": { + "format": "bbox", + "url": "https://data.isgs.illinois.edu/arcgis/rest/services/Imagery/IDOT_2017/ImageServer/exportImage?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=jpgpng&f=image", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 19, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "1 ft", + "resolutionM": 0.3048, + "trueOrtho": false, + "vintage": "2015–present", + "updateCycle": "Multi-year cycles", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Open state geodata — attribution requested", + "url": "https://clearinghouse.isgs.illinois.edu/", + "operatorOptIn": true + }, + "attribution": { + "text": "Illinois State Geological Survey / IDOT", + "url": "https://clearinghouse.isgs.illinois.edu/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/imf-portwatch-chokepoints.json b/src/library/entries/imf-portwatch-chokepoints.json new file mode 100644 index 000000000..cb224073a --- /dev/null +++ b/src/library/entries/imf-portwatch-chokepoints.json @@ -0,0 +1,39 @@ +{ + "id": "imf-portwatch-chokepoints", + "kind": "dataset", + "family": "infrastructure", + "title": "Maritime chokepoints (IMF PortWatch)", + "provider": "IMF PortWatch", + "description": "The 28 maritime chokepoints IMF PortWatch tracks, such as Suez and Hormuz, with their annual vessel transits and top cargo.", + "icon": "⧗", + "maintainer": "github:asturksever", + "coverage": { "global": true }, + "source": { + "format": "arcgis", + "url": "https://services9.arcgis.com/weJ1QsnbMYJlCHdG/arcgis/rest/services/PortWatch_chokepoints_database/FeatureServer/0", + "query": { + "outFields": "portname,vessel_count_total,vessel_count_tanker,industry_top1" + }, + "fetch": "browser-direct", + "cacheSeconds": 86400, + "maxFeatures": 100, + "maxPages": 1 + }, + "display": { + "color": "#4fd1c5", + "label": "portname", + "fields": ["vessel_count_total", "industry_top1"], + "rank": { "field": "vessel_count_total", "order": "desc" } + }, + "licence": { + "name": "IMF PortWatch terms; attribution required (reuse terms under review)", + "url": "https://www.imf.org/external/terms.htm", + "operatorOptIn": true + }, + "attribution": { + "text": "IMF PortWatch (portwatch.imf.org)", + "url": "https://portwatch.imf.org/" + }, + "status": "incubating", + "reviewed": "2026-10-22" +} diff --git a/src/library/entries/imf-portwatch-disruptions.json b/src/library/entries/imf-portwatch-disruptions.json new file mode 100644 index 000000000..c120eba2c --- /dev/null +++ b/src/library/entries/imf-portwatch-disruptions.json @@ -0,0 +1,57 @@ +{ + "id": "imf-portwatch-disruptions", + "kind": "dataset", + "family": "events", + "title": "Port disruptions (IMF PortWatch)", + "provider": "IMF PortWatch", + "description": "Areas of natural-hazard events that disrupted ports since 2019, such as cyclones and floods, with the ports affected, from IMF PortWatch and GDACS.", + "icon": "◌", + "maintainer": "github:asturksever", + "coverage": { + "global": true + }, + "source": { + "format": "arcgis", + "url": "https://services9.arcgis.com/weJ1QsnbMYJlCHdG/arcgis/rest/services/PortWatch_disruptions_database/FeatureServer/0", + "query": { + "outFields": "htmlname,alertlevel,country,fromdate,n_affectedports" + }, + "fetch": "browser-direct", + "cacheSeconds": 86400, + "maxFeatures": 500, + "maxPages": 1 + }, + "display": { + "color": "#a0aec0", + "label": "htmlname", + "fields": ["alertlevel", "n_affectedports"], + "rank": { + "field": "fromdate", + "order": "desc" + }, + "colorBy": { + "field": "alertlevel", + "colors": { + "RED": "#d03b3b", + "ORANGE": "#ec835a", + "GREEN": "#0ca30c" + }, + "labels": { + "RED": "Red alert", + "ORANGE": "Orange alert", + "GREEN": "Green alert" + } + } + }, + "licence": { + "name": "IMF PortWatch terms; attribution required (reuse terms under review)", + "url": "https://www.imf.org/external/terms.htm", + "operatorOptIn": true + }, + "attribution": { + "text": "IMF PortWatch (portwatch.imf.org), with GDACS event data", + "url": "https://portwatch.imf.org/" + }, + "status": "incubating", + "reviewed": "2026-10-22" +} diff --git a/src/library/entries/in-statewide-imagery.json b/src/library/entries/in-statewide-imagery.json new file mode 100644 index 000000000..09538ce3c --- /dev/null +++ b/src/library/entries/in-statewide-imagery.json @@ -0,0 +1,47 @@ +{ + "id": "in-statewide-imagery", + "kind": "tiles", + "family": "imagery", + "title": "Indiana Statewide Imagery (2016)", + "provider": "IndianaMap", + "description": "Aerial orthophotos: Statewide coverage of Indiana.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-88.1, 37.77, -84.78, 41.76] + }, + "source": { + "format": "bbox", + "url": "https://di-ingov.img.arcgis.com/arcgis/rest/services/DynamicWebMercator/Indiana_2016_2019_Imagery/ImageServer/exportImage?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=jpgpng&f=image", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 19, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "6 in – 1 ft", + "resolutionM": 0.1524, + "trueOrtho": false, + "vintage": "2016–present", + "updateCycle": "4-year statewide cycle (counties in 3 groups)", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "CC0 1.0 Universal (public domain dedication)", + "url": "https://creativecommons.org/publicdomain/zero/1.0/legalcode" + }, + "attribution": { + "text": "Indiana Geographic Information Office (CC0)", + "url": "https://registry.opendata.aws/in-imagery/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/jrc-surface-water.json b/src/library/entries/jrc-surface-water.json new file mode 100644 index 000000000..9bb0a8811 --- /dev/null +++ b/src/library/entries/jrc-surface-water.json @@ -0,0 +1,48 @@ +{ + "id": "jrc-surface-water", + "kind": "tiles", + "family": "environment", + "title": "Surface water occurrence (JRC)", + "provider": "EC JRC", + "description": "How often each place was covered by water between 1984 and 2021, from 38 years of Landsat imagery.", + "icon": "≋", + "maintainer": "github:asturksever", + "coverage": { + "global": true + }, + "source": { + "format": "xyz", + "url": "https://storage.googleapis.com/global-surface-water/tiles2021/occurrence/{z}/{x}/{y}.png", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 0, + "maxZoom": 13 + }, + "display": { + "opacity": 0.85, + "legend": [ + { + "label": "Sometimes water (> 0%)", + "color": "#ffffff" + }, + { + "label": "Half the time (50%)", + "color": "#bf7fbf" + }, + { + "label": "Always water (100%)", + "color": "#0000ff" + } + ] + }, + "licence": { + "name": "Copernicus programme: free, without restriction of use", + "url": "https://global-surface-water.appspot.com/download" + }, + "attribution": { + "text": "Source: EC JRC/Google — Pekel et al. (2016), Global Surface Water", + "url": "https://global-surface-water.appspot.com/" + }, + "status": "community", + "reviewed": "2026-10-22" +} diff --git a/src/library/entries/kartverket-admin-units.json b/src/library/entries/kartverket-admin-units.json new file mode 100644 index 000000000..e45921125 --- /dev/null +++ b/src/library/entries/kartverket-admin-units.json @@ -0,0 +1,46 @@ +{ + "id": "kartverket-admin-units", + "kind": "tiles", + "family": "infrastructure", + "title": "Norway counties and municipalities (Kartverket)", + "provider": "Kartverket", + "description": "Current county (fylke) and municipality (kommune) boundaries from Kartverket's administrative units.", + "icon": "⌗", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["NO"], + "bbox": [3.7, 57.6, 32, 71.6] + }, + "source": { + "format": "bbox", + "url": "https://wms.geonorge.no/skwms1/wms.adm_enheter2?service=WMS&request=GetMap&version=1.3.0&layers=fylker_gjel,kommuner_gjel&styles=&crs=EPSG:3857&bbox={bbox-epsg-3857}&width=256&height=256&format=image/png&transparent=true", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 4, + "maxZoom": 20, + "bounded": true + }, + "display": { + "opacity": 1, + "legend": [ + { + "label": "County boundary", + "color": "#6464ff" + }, + { + "label": "Municipality boundary", + "color": "#8a3e4e" + } + ] + }, + "licence": { + "name": "CC BY 4.0 (Kartverket free products)", + "url": "https://www.kartverket.no/api-og-data/vilkar-for-bruk" + }, + "attribution": { + "text": "© Kartverket", + "url": "https://www.kartverket.no/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/kartverket-cadastre.json b/src/library/entries/kartverket-cadastre.json new file mode 100644 index 000000000..13334eba2 --- /dev/null +++ b/src/library/entries/kartverket-cadastre.json @@ -0,0 +1,36 @@ +{ + "id": "kartverket-cadastre", + "kind": "tiles", + "family": "infrastructure", + "title": "Norway cadastral map (Kartverket)", + "provider": "Kartverket", + "description": "Property boundaries, parcel numbers and building outlines from the Norwegian cadastre (Matrikkelen), shown at street scale.", + "icon": "⌗", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["NO"], + "bbox": [3.7, 57.6, 32, 71.6] + }, + "source": { + "format": "bbox", + "url": "https://wms.geonorge.no/skwms1/wms.matrikkelkart?service=WMS&request=GetMap&version=1.3.0&layers=matrikkelkart&styles=&crs=EPSG:3857&bbox={bbox-epsg-3857}&width=256&height=256&format=image/png&transparent=true", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 14, + "maxZoom": 20, + "bounded": true + }, + "display": { + "opacity": 1 + }, + "licence": { + "name": "CC BY 4.0 (Kartverket free products)", + "url": "https://www.kartverket.no/api-og-data/vilkar-for-bruk" + }, + "attribution": { + "text": "© Kartverket", + "url": "https://www.kartverket.no/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/kartverket-depth.json b/src/library/entries/kartverket-depth.json new file mode 100644 index 000000000..d1ecd811a --- /dev/null +++ b/src/library/entries/kartverket-depth.json @@ -0,0 +1,102 @@ +{ + "id": "kartverket-depth", + "kind": "tiles", + "family": "environment", + "title": "Norway sea depths (Kartverket)", + "provider": "Kartverket", + "description": "Kartverket's depth areas and depth contours of Norwegian waters, shown from coastal scale (zoom in to see them).", + "icon": "≋", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["NO"], + "bbox": [3.7, 57.6, 32, 71.6] + }, + "source": { + "format": "bbox", + "url": "https://wms.geonorge.no/skwms1/wms.dybdedata2?service=WMS&request=GetMap&version=1.3.0&layers=Dybdelag,Dybdekontur&styles=&crs=EPSG:3857&bbox={bbox-epsg-3857}&width=256&height=256&format=image/png&transparent=true", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 11, + "maxZoom": 20, + "bounded": true + }, + "display": { + "opacity": 1, + "legend": [ + { + "label": "Minimum depth 0–5 m", + "color": "#e6ffff" + }, + { + "label": "Minimum depth 5–10 m", + "color": "#dbfaff" + }, + { + "label": "Minimum depth 10–20 m", + "color": "#d1f5ff" + }, + { + "label": "Minimum depth 20–30 m", + "color": "#c7f2ff" + }, + { + "label": "Minimum depth 30–40 m", + "color": "#c3f1ff" + }, + { + "label": "Minimum depth 40–50 m", + "color": "#bdefff" + }, + { + "label": "Minimum depth 50–100 m", + "color": "#b8edff" + }, + { + "label": "Minimum depth 100–150 m", + "color": "#abe8ff" + }, + { + "label": "Minimum depth 150–200 m", + "color": "#a6e5ff" + }, + { + "label": "Minimum depth 200–250 m", + "color": "#a1e5ff" + }, + { + "label": "Minimum depth 250–300 m", + "color": "#9ce3ff" + }, + { + "label": "Minimum depth 300–350 m", + "color": "#96e3ff" + }, + { + "label": "Minimum depth 350–400 m", + "color": "#91e0ff" + }, + { + "label": "Minimum depth 400–450 m", + "color": "#8ce0ff" + }, + { + "label": "Minimum depth 450–500 m", + "color": "#89deff" + }, + { + "label": "Minimum depth 500–2,000 m", + "color": "#87dcff" + } + ] + }, + "licence": { + "name": "CC BY 4.0 (Kartverket free products)", + "url": "https://www.kartverket.no/api-og-data/vilkar-for-bruk" + }, + "attribution": { + "text": "© Kartverket", + "url": "https://www.kartverket.no/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/kartverket-hillshade.json b/src/library/entries/kartverket-hillshade.json new file mode 100644 index 000000000..1198b30e7 --- /dev/null +++ b/src/library/entries/kartverket-hillshade.json @@ -0,0 +1,36 @@ +{ + "id": "kartverket-hillshade", + "kind": "tiles", + "family": "environment", + "title": "Norway hillshade (Kartverket)", + "provider": "Kartverket", + "description": "Kartverket's hillshade of Norway's terrain, a translucent shading to lay over imagery or maps.", + "icon": "⛰", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["NO"], + "bbox": [3.7, 57.6, 32, 71.6] + }, + "source": { + "format": "bbox", + "url": "https://wms.geonorge.no/skwms1/wms.fjellskygge?service=WMS&request=GetMap&version=1.3.0&layers=fjellskygge&styles=&crs=EPSG:3857&bbox={bbox-epsg-3857}&width=256&height=256&format=image/png&transparent=true", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 3, + "maxZoom": 20, + "bounded": true + }, + "display": { + "opacity": 1 + }, + "licence": { + "name": "CC BY 4.0 (Kartverket free products)", + "url": "https://www.kartverket.no/api-og-data/vilkar-for-bruk" + }, + "attribution": { + "text": "© Kartverket", + "url": "https://www.kartverket.no/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/kartverket-nautical-charts.json b/src/library/entries/kartverket-nautical-charts.json new file mode 100644 index 000000000..f1e736bca --- /dev/null +++ b/src/library/entries/kartverket-nautical-charts.json @@ -0,0 +1,36 @@ +{ + "id": "kartverket-nautical-charts", + "kind": "tiles", + "family": "movement", + "title": "Norway nautical charts (Kartverket)", + "provider": "Kartverket", + "description": "Kartverket's official raster nautical charts of the Norwegian coast and waters. Not for navigation.", + "icon": "⚓", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["NO"], + "bbox": [3.7, 57.6, 32, 71.6] + }, + "source": { + "format": "xyz", + "url": "https://cache.kartverket.no/v1/wmts/1.0.0/sjokartraster/default/webmercator/{z}/{y}/{x}.png", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 3, + "maxZoom": 18, + "bounded": true + }, + "display": { + "opacity": 1 + }, + "licence": { + "name": "CC BY 4.0 (Kartverket free products)", + "url": "https://www.kartverket.no/api-og-data/vilkar-for-bruk" + }, + "attribution": { + "text": "© Kartverket", + "url": "https://www.kartverket.no/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/kartverket-terrain-relief.json b/src/library/entries/kartverket-terrain-relief.json new file mode 100644 index 000000000..6f80a45f8 --- /dev/null +++ b/src/library/entries/kartverket-terrain-relief.json @@ -0,0 +1,36 @@ +{ + "id": "kartverket-terrain-relief", + "kind": "tiles", + "family": "environment", + "title": "Norway terrain relief (Kartverket DTM)", + "provider": "Kartverket", + "description": "Shaded colour relief of Kartverket's national terrain model, built from laser scanning.", + "icon": "⛰", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["NO"], + "bbox": [3.7, 57.6, 32, 71.6] + }, + "source": { + "format": "bbox", + "url": "https://wms.geonorge.no/skwms1/wms.terrengmodell?service=WMS&request=GetMap&version=1.3.0&layers=relieff&styles=&crs=EPSG:3857&bbox={bbox-epsg-3857}&width=256&height=256&format=image/png&transparent=true", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 3, + "maxZoom": 20, + "bounded": true + }, + "display": { + "opacity": 1 + }, + "licence": { + "name": "CC BY 4.0 (Kartverket free products)", + "url": "https://www.kartverket.no/api-og-data/vilkar-for-bruk" + }, + "attribution": { + "text": "© Kartverket", + "url": "https://www.kartverket.no/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/kartverket-topo-grey.json b/src/library/entries/kartverket-topo-grey.json new file mode 100644 index 000000000..4c1693aac --- /dev/null +++ b/src/library/entries/kartverket-topo-grey.json @@ -0,0 +1,36 @@ +{ + "id": "kartverket-topo-grey", + "kind": "tiles", + "family": "imagery", + "title": "Norway topographic map, greyscale (Kartverket)", + "provider": "Kartverket", + "description": "Kartverket's topographic map of Norway in greyscale, a quiet base for other layers.", + "icon": "⛰", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["NO"], + "bbox": [3.7, 57.6, 32, 71.6] + }, + "source": { + "format": "xyz", + "url": "https://cache.kartverket.no/v1/wmts/1.0.0/topograatone/default/webmercator/{z}/{y}/{x}.png", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 3, + "maxZoom": 18, + "bounded": true + }, + "display": { + "opacity": 1 + }, + "licence": { + "name": "CC BY 4.0 (Kartverket free products)", + "url": "https://www.kartverket.no/api-og-data/vilkar-for-bruk" + }, + "attribution": { + "text": "© Kartverket", + "url": "https://www.kartverket.no/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/kartverket-topo.json b/src/library/entries/kartverket-topo.json new file mode 100644 index 000000000..b96e705b6 --- /dev/null +++ b/src/library/entries/kartverket-topo.json @@ -0,0 +1,36 @@ +{ + "id": "kartverket-topo", + "kind": "tiles", + "family": "imagery", + "title": "Norway topographic map (Kartverket)", + "provider": "Kartverket", + "description": "Kartverket's national topographic map of Norway, from overview scale down to buildings and paths.", + "icon": "⛰", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["NO"], + "bbox": [3.7, 57.6, 32, 71.6] + }, + "source": { + "format": "xyz", + "url": "https://cache.kartverket.no/v1/wmts/1.0.0/topo/default/webmercator/{z}/{y}/{x}.png", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 3, + "maxZoom": 18, + "bounded": true + }, + "display": { + "opacity": 1 + }, + "licence": { + "name": "CC BY 4.0 (Kartverket free products)", + "url": "https://www.kartverket.no/api-og-data/vilkar-for-bruk" + }, + "attribution": { + "text": "© Kartverket", + "url": "https://www.kartverket.no/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/kartverket-toporaster.json b/src/library/entries/kartverket-toporaster.json new file mode 100644 index 000000000..d23948733 --- /dev/null +++ b/src/library/entries/kartverket-toporaster.json @@ -0,0 +1,36 @@ +{ + "id": "kartverket-toporaster", + "kind": "tiles", + "family": "imagery", + "title": "Norway topographic raster map (Kartverket)", + "provider": "Kartverket", + "description": "Kartverket's raster topographic map series of Norway, in the style of the printed maps.", + "icon": "⛰", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["NO"], + "bbox": [3.7, 57.6, 32, 71.6] + }, + "source": { + "format": "xyz", + "url": "https://cache.kartverket.no/v1/wmts/1.0.0/toporaster/default/webmercator/{z}/{y}/{x}.png", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 3, + "maxZoom": 18, + "bounded": true + }, + "display": { + "opacity": 1 + }, + "licence": { + "name": "CC BY 4.0 (Kartverket free products)", + "url": "https://www.kartverket.no/api-og-data/vilkar-for-bruk" + }, + "attribution": { + "text": "© Kartverket", + "url": "https://www.kartverket.no/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/ks-statewide-imagery.json b/src/library/entries/ks-statewide-imagery.json new file mode 100644 index 000000000..393ec24cb --- /dev/null +++ b/src/library/entries/ks-statewide-imagery.json @@ -0,0 +1,48 @@ +{ + "id": "ks-statewide-imagery", + "kind": "tiles", + "family": "imagery", + "title": "Kansas Statewide Imagery (2021)", + "provider": "Kansas DASC / KGS", + "description": "Aerial orthophotos: Statewide coverage of Kansas.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-102.472, 36.738, -94.035, 40.211] + }, + "source": { + "format": "bbox", + "url": "https://services.kgs.ku.edu/arcgis7/rest/services/IMAGERY_STATEWIDE/FSA_NAIP_2021_Color/ImageServer/exportImage?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=jpgpng&f=image", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 19, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "60 cm", + "resolutionM": 0.6, + "trueOrtho": false, + "vintage": "2021–present", + "updateCycle": "NAIP 2-year cycle", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Open state geodata — attribution requested", + "url": "https://www.kansasgis.org/", + "operatorOptIn": true + }, + "attribution": { + "text": "FSA, USDA,", + "url": "https://www.kansasgis.org/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/ky-statewide-imagery.json b/src/library/entries/ky-statewide-imagery.json new file mode 100644 index 000000000..af604ca85 --- /dev/null +++ b/src/library/entries/ky-statewide-imagery.json @@ -0,0 +1,47 @@ +{ + "id": "ky-statewide-imagery", + "kind": "tiles", + "family": "imagery", + "title": "Kentucky Statewide Imagery", + "provider": "KyFromAbove", + "description": "Aerial orthophotos: Statewide coverage of Kentucky.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-89.7044, 36.457, -81.8842, 39.1519] + }, + "source": { + "format": "bbox", + "url": "https://kyraster.ky.gov/arcgis/rest/services/ImageServices/Ky_KYAPED_Phase3_3IN_IR/ImageServer/exportImage?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=jpgpng&f=image", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 19, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "6 in (statewide), 3 in (urban)", + "resolutionM": 0.1524, + "trueOrtho": false, + "vintage": "2015–present", + "updateCycle": "Annual partial; full refresh every 3 years", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Public domain with attribution", + "url": "https://kygeonet.ky.gov/" + }, + "attribution": { + "text": "KyFromAbove", + "url": "https://registry.opendata.aws/kyfromabove/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/lake-county-in.json b/src/library/entries/lake-county-in.json new file mode 100644 index 000000000..8c9fb3e91 --- /dev/null +++ b/src/library/entries/lake-county-in.json @@ -0,0 +1,48 @@ +{ + "id": "lake-county-in", + "kind": "tiles", + "family": "imagery", + "title": "Lake County, Indiana Aerial Imagery", + "provider": "Lake County GIS", + "description": "Aerial orthophotos: Lake County, Indiana jurisdiction.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-87.5421, 41.1501, -87.2023, 41.7204] + }, + "source": { + "format": "xyz", + "url": "https://tiles.arcgis.com/tiles/8CXRnvSfSpwdf0R6/arcgis/rest/services/LakeIN2023Aerial/MapServer/tile/{z}/{y}/{x}", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 11, + "maxZoom": 22, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "sub-foot to 1 ft (local program)", + "resolutionM": 0.15, + "trueOrtho": false, + "vintage": "2023–present", + "updateCycle": "Local flight cycle (typically annual to 3-yearly)", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Local government open data — see publisher terms", + "url": "https://www.lakecountyin.org/", + "operatorOptIn": true + }, + "attribution": { + "text": "Lake County Surveyor's Office", + "url": "https://www.lakecountyin.org/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/lawrence-ks.json b/src/library/entries/lawrence-ks.json new file mode 100644 index 000000000..ad49183e0 --- /dev/null +++ b/src/library/entries/lawrence-ks.json @@ -0,0 +1,48 @@ +{ + "id": "lawrence-ks", + "kind": "tiles", + "family": "imagery", + "title": "Lawrence, Kansas Aerial Photos", + "provider": "City of Lawrence GIS", + "description": "Aerial orthophotos: Lawrence, Kansas jurisdiction.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-95.3997, 38.8881, -95.113, 39.0507] + }, + "source": { + "format": "xyz", + "url": "https://tiles.arcgis.com/tiles/8O9UlSTnqjKptoda/arcgis/rest/services/LAWRENCE_KS_AERIAL_2022/MapServer/tile/{z}/{y}/{x}", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 11, + "maxZoom": 22, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "sub-foot to 1 ft (local program)", + "resolutionM": 0.15, + "trueOrtho": false, + "vintage": "2022–present", + "updateCycle": "Local flight cycle (typically annual to 3-yearly)", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Local government open data — see publisher terms", + "url": "https://gis-lawrenceks.opendata.arcgis.com/", + "operatorOptIn": true + }, + "attribution": { + "text": "City of Lawrence GIS", + "url": "https://gis-lawrenceks.opendata.arcgis.com/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/lee-county-fl.json b/src/library/entries/lee-county-fl.json new file mode 100644 index 000000000..d453e9399 --- /dev/null +++ b/src/library/entries/lee-county-fl.json @@ -0,0 +1,48 @@ +{ + "id": "lee-county-fl", + "kind": "tiles", + "family": "imagery", + "title": "Lee County, Florida Aerial Imagery", + "provider": "Lee County GIS", + "description": "Aerial orthophotos: Lee County, Florida jurisdiction.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-82.3101, 26.3138, -81.5591, 26.796] + }, + "source": { + "format": "bbox", + "url": "https://gisimageserver.leegov.com/imageserver/rest/services/Aerials/Aerials2025/ImageServer/exportImage?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=jpgpng&f=image", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 11, + "maxZoom": 20, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "sub-foot to 1 ft (local program)", + "resolutionM": 0.15, + "trueOrtho": false, + "vintage": "2025–present", + "updateCycle": "Local flight cycle (typically annual to 3-yearly)", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Local government open data — see publisher terms", + "url": "https://www.leegov.com/gis", + "operatorOptIn": true + }, + "attribution": { + "text": "Eagle View, Lee County Property Appraiser, Lee County GIS", + "url": "https://www.leegov.com/gis" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/loudoun-county-va.json b/src/library/entries/loudoun-county-va.json new file mode 100644 index 000000000..d5cfb57bc --- /dev/null +++ b/src/library/entries/loudoun-county-va.json @@ -0,0 +1,48 @@ +{ + "id": "loudoun-county-va", + "kind": "tiles", + "family": "imagery", + "title": "Loudoun County, Virginia Imagery", + "provider": "Loudoun County GIS", + "description": "Aerial orthophotos: Loudoun County, Virginia jurisdiction.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-77.9661, 38.823, -77.2907, 39.3358] + }, + "source": { + "format": "bbox", + "url": "https://logis.loudoun.gov/image/rest/services/Aerial/COLOR_2025/ImageServer/exportImage?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=jpgpng&f=image", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 11, + "maxZoom": 20, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "sub-foot to 1 ft (local program)", + "resolutionM": 0.15, + "trueOrtho": false, + "vintage": "2025–present", + "updateCycle": "Local flight cycle (typically annual to 3-yearly)", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Local government open data — see publisher terms", + "url": "https://geohub-loudoungis.opendata.arcgis.com/", + "operatorOptIn": true + }, + "attribution": { + "text": "Loudoun County GIS", + "url": "https://geohub-loudoungis.opendata.arcgis.com/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/louisville-lojic-ky.json b/src/library/entries/louisville-lojic-ky.json new file mode 100644 index 000000000..82316012e --- /dev/null +++ b/src/library/entries/louisville-lojic-ky.json @@ -0,0 +1,49 @@ +{ + "id": "louisville-lojic-ky", + "kind": "tiles", + "family": "imagery", + "title": "Louisville / LOJIC, Kentucky Imagery", + "provider": "LOJIC", + "description": "Aerial orthophotos: Louisville / LOJIC, Kentucky jurisdiction.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-86.5156, 37.4289, -84.8574, 38.7994] + }, + "source": { + "format": "bbox", + "url": "https://services.lojic.org/arcgis/rest/services/Lojic/Imagery/MapServer/export?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=jpgpng&f=image", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 11, + "maxZoom": 20, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true, + "noDataColor": "#ffffff" + }, + "imagery": { + "type": "ortho", + "resolution": "sub-foot to 1 ft (local program)", + "resolutionM": 0.15, + "trueOrtho": false, + "vintage": "2020–present", + "updateCycle": "Local flight cycle (typically annual to 3-yearly)", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Local government open data — see publisher terms", + "url": "https://www.lojic.org/", + "operatorOptIn": true + }, + "attribution": { + "text": "LOJIC, USDA-NAIP", + "url": "https://www.lojic.org/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/lu-ortho-latest.json b/src/library/entries/lu-ortho-latest.json new file mode 100644 index 000000000..cbeb9bb6a --- /dev/null +++ b/src/library/entries/lu-ortho-latest.json @@ -0,0 +1,48 @@ +{ + "id": "lu-ortho-latest", + "kind": "tiles", + "family": "imagery", + "title": "Orthophoto officielle du Luxembourg", + "provider": "ACT Luxembourg / geoportail.lu", + "description": "Aerial orthophotos: 100% of Luxembourg (~2,586 km²).", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["LU"], + "bbox": [5.73, 49.44, 6.53, 50.19] + }, + "source": { + "format": "xyz", + "url": "https://wmts1.geoportail.lu/opendata/wmts/ortho_latest/GLOBAL_WEBMERCATOR_4_V3/{z}/{x}/{y}.jpeg", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 8, + "maxZoom": 20, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "coverage", + "aerial": true, + "noDataColor": "#ffffff" + }, + "imagery": { + "type": "ortho", + "resolution": "5–20 cm", + "resolutionM": 0.05, + "trueOrtho": false, + "vintage": "2023–present", + "updateCycle": "Annual (spring flight)", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "CC0 1.0 (Licence ouverte Luxembourg)", + "url": "https://data.public.lu/en/datasets/orthophoto-officelle-du-grand-duche-de-luxembourg-edition-2023/" + }, + "attribution": { + "text": "Administration du cadastre et de la topographie, Luxembourg (CC0)", + "url": "https://data.public.lu/en/datasets/?q=orthophoto" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/ma-statewide-imagery.json b/src/library/entries/ma-statewide-imagery.json new file mode 100644 index 000000000..960711fee --- /dev/null +++ b/src/library/entries/ma-statewide-imagery.json @@ -0,0 +1,48 @@ +{ + "id": "ma-statewide-imagery", + "kind": "tiles", + "family": "imagery", + "title": "Massachusetts Statewide Imagery", + "provider": "MassGIS (EOTSS)", + "description": "Aerial orthophotos: Statewide coverage of Massachusetts.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-73.51, 41.24, -69.93, 42.89] + }, + "source": { + "format": "xyz", + "url": "https://tiles.arcgis.com/tiles/hGdibHYSPO59RG1h/arcgis/rest/services/orthos2021/MapServer/tile/{z}/{y}/{x}", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 22, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "15–30 cm", + "resolutionM": 0.3, + "trueOrtho": false, + "vintage": "2015–present", + "updateCycle": "Roughly every 3 years", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Open state geodata — attribution requested", + "url": "https://gis.data.mass.gov/", + "operatorOptIn": true + }, + "attribution": { + "text": "MassGIS, Commonwealth of Massachusetts", + "url": "https://gis.data.mass.gov/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/macrostrat-geology.json b/src/library/entries/macrostrat-geology.json new file mode 100644 index 000000000..42f1ce711 --- /dev/null +++ b/src/library/entries/macrostrat-geology.json @@ -0,0 +1,88 @@ +{ + "id": "macrostrat-geology", + "kind": "tiles", + "family": "environment", + "title": "Geologic map (Macrostrat)", + "provider": "Macrostrat", + "description": "Bedrock geology worldwide, harmonised from hundreds of published geologic maps and coloured by rock age on the international (ICS) time-scale colours.", + "icon": "◒", + "maintainer": "github:asturksever", + "coverage": { + "global": true + }, + "source": { + "format": "xyz", + "url": "https://tiles.macrostrat.org/carto/{z}/{x}/{y}.png", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 0, + "maxZoom": 14 + }, + "display": { + "opacity": 0.7, + "legend": [ + { + "label": "Quaternary", + "color": "#f9f97f" + }, + { + "label": "Neogene", + "color": "#ffe619" + }, + { + "label": "Paleogene", + "color": "#fd9a52" + }, + { + "label": "Cretaceous", + "color": "#7fc64e" + }, + { + "label": "Jurassic", + "color": "#34b2c9" + }, + { + "label": "Triassic", + "color": "#812b92" + }, + { + "label": "Permian", + "color": "#f04028" + }, + { + "label": "Carboniferous", + "color": "#67a599" + }, + { + "label": "Devonian", + "color": "#cb8c37" + }, + { + "label": "Silurian", + "color": "#b3e1b6" + }, + { + "label": "Ordovician", + "color": "#009270" + }, + { + "label": "Cambrian", + "color": "#7fa056" + }, + { + "label": "Precambrian", + "color": "#f74370" + } + ] + }, + "licence": { + "name": "CC BY 4.0 (Macrostrat; source maps credited by their publishers)", + "url": "https://macrostrat.org/about" + }, + "attribution": { + "text": "Macrostrat — geologic maps from their original publishers", + "url": "https://macrostrat.org/" + }, + "status": "community", + "reviewed": "2026-10-22" +} diff --git a/src/library/entries/mapterhorn-terrain.json b/src/library/entries/mapterhorn-terrain.json new file mode 100644 index 000000000..59adc91ee --- /dev/null +++ b/src/library/entries/mapterhorn-terrain.json @@ -0,0 +1,32 @@ +{ + "id": "mapterhorn-terrain", + "kind": "tiles", + "family": "environment", + "title": "Global terrain shaded relief (Mapterhorn)", + "provider": "Mapterhorn", + "description": "Elevation of the whole land surface from Mapterhorn's open terrain tiles (Copernicus and national elevation models), coloured by height and hill-shaded.", + "icon": "⛰", + "maintainer": "github:asturksever", + "coverage": { "global": true }, + "source": { + "format": "xyz", + "url": "https://tiles.mapterhorn.com/{z}/{x}/{y}.webp", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "encoding": "terrarium", + "tileSize": 512, + "minZoom": 0, + "maxZoom": 15 + }, + "display": { "opacity": 0.85 }, + "licence": { + "name": "Open data, per source (mostly CC BY 4.0 and public domain)", + "url": "https://mapterhorn.com/attribution" + }, + "attribution": { + "text": "© Mapterhorn and its open elevation sources", + "url": "https://mapterhorn.com/attribution" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/maris-ms.json b/src/library/entries/maris-ms.json new file mode 100644 index 000000000..4766e23ef --- /dev/null +++ b/src/library/entries/maris-ms.json @@ -0,0 +1,48 @@ +{ + "id": "maris-ms", + "kind": "tiles", + "family": "imagery", + "title": "MARIS Imagery, Mississippi", + "provider": "MARIS", + "description": "Aerial orthophotos: MARIS Imagery, Mississippi jurisdiction.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-89.5658, 34.3427, -89.5141, 34.3953] + }, + "source": { + "format": "xyz", + "url": "https://tiles.arcgis.com/tiles/EAeOFj04EVkIaWLk/arcgis/rest/services/OleMiss_Basemap_ZL2/MapServer/tile/{z}/{y}/{x}", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 13, + "maxZoom": 22, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "sub-foot to 1 ft (local program)", + "resolutionM": 0.15, + "trueOrtho": false, + "vintage": "2020–present", + "updateCycle": "Local flight cycle (typically annual to 3-yearly)", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Local government open data — see publisher terms", + "url": "https://www.gis.ms.gov/", + "operatorOptIn": true + }, + "attribution": { + "text": "MARIS", + "url": "https://www.gis.ms.gov/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/md-statewide-imagery.json b/src/library/entries/md-statewide-imagery.json new file mode 100644 index 000000000..81cae225f --- /dev/null +++ b/src/library/entries/md-statewide-imagery.json @@ -0,0 +1,48 @@ +{ + "id": "md-statewide-imagery", + "kind": "tiles", + "family": "imagery", + "title": "Maryland Statewide Imagery", + "provider": "Maryland iMAP / MD DoIT", + "description": "Aerial orthophotos: Statewide coverage of Maryland.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-79.5157, 37.8702, -74.9964, 39.778] + }, + "source": { + "format": "bbox", + "url": "https://mdgeodata.md.gov/imagery/rest/services/NAIP/MD_NAIPImagery/ImageServer/exportImage?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=jpgpng&f=image", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 19, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "3 in – 1 m", + "resolutionM": 0.0762, + "trueOrtho": false, + "vintage": "2015–present", + "updateCycle": "Annual/biennial by region", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Open state geodata — attribution requested", + "url": "https://imap.maryland.gov/", + "operatorOptIn": true + }, + "attribution": { + "text": "Maryland iMAP", + "url": "https://imap.maryland.gov/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/me-statewide-imagery.json b/src/library/entries/me-statewide-imagery.json new file mode 100644 index 000000000..cbb823998 --- /dev/null +++ b/src/library/entries/me-statewide-imagery.json @@ -0,0 +1,48 @@ +{ + "id": "me-statewide-imagery", + "kind": "tiles", + "family": "imagery", + "title": "Maine Statewide Imagery", + "provider": "Maine GeoLibrary / MEGIS", + "description": "Aerial orthophotos: Statewide coverage of Maine.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-71.08, 42.98, -66.95, 47.46] + }, + "source": { + "format": "bbox", + "url": "https://gis.maine.gov/image/rest/services/NAIP/orthoNaip2006/ImageServer/exportImage?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=jpgpng&f=image", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 19, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "30 cm – 1 m", + "resolutionM": 0.3, + "trueOrtho": false, + "vintage": "2015–present", + "updateCycle": "Rolling regional", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Open state geodata — attribution requested", + "url": "https://www.maine.gov/geolib/", + "operatorOptIn": true + }, + "attribution": { + "text": "USDA Aerial Photography Field Office", + "url": "https://www.maine.gov/geolib/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/mi-statewide-imagery.json b/src/library/entries/mi-statewide-imagery.json new file mode 100644 index 000000000..eb0e52e52 --- /dev/null +++ b/src/library/entries/mi-statewide-imagery.json @@ -0,0 +1,48 @@ +{ + "id": "mi-statewide-imagery", + "kind": "tiles", + "family": "imagery", + "title": "Michigan Statewide Imagery (2024)", + "provider": "MiSAIL / DTMB", + "description": "Aerial orthophotos: Statewide coverage of Michigan (preview bounds cover the Lower Peninsula; service covers both peninsulas).", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-86.5, 41.7, -82.41, 45.9] + }, + "source": { + "format": "bbox", + "url": "https://imagery.michigan.gov/server/rest/services/Michigan_imagery_2024/ImageServer/exportImage?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=jpgpng&f=image", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 19, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "30 cm – 1 m", + "resolutionM": 0.3, + "trueOrtho": false, + "vintage": "2024–present", + "updateCycle": "Rolling", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Open state geodata — attribution requested", + "url": "https://gis-michigan.opendata.arcgis.com/", + "operatorOptIn": true + }, + "attribution": { + "text": "State of Michigan", + "url": "https://gis-michigan.opendata.arcgis.com/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/mn-statewide-imagery.json b/src/library/entries/mn-statewide-imagery.json new file mode 100644 index 000000000..7469e1b55 --- /dev/null +++ b/src/library/entries/mn-statewide-imagery.json @@ -0,0 +1,48 @@ +{ + "id": "mn-statewide-imagery", + "kind": "tiles", + "family": "imagery", + "title": "Minnesota Statewide Imagery", + "provider": "MnGeo", + "description": "Aerial orthophotos: Statewide coverage of Minnesota.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-97.24, 43.5, -89.49, 49.38] + }, + "source": { + "format": "bbox", + "url": "https://imageserver.gisdata.mn.gov/cgi-bin/wms?service=WMS&request=GetMap&version=1.1.1&layers=fsa&styles=&srs=EPSG:3857&bbox={bbox-epsg-3857}&width=256&height=256&format=image/jpeg", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 19, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true, + "noDataColor": "#ffffff" + }, + "imagery": { + "type": "ortho", + "resolution": "60 cm (FSA/NAIP)", + "resolutionM": 0.6, + "trueOrtho": false, + "vintage": "2015–present", + "updateCycle": "NAIP 2-year cycle", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Public domain / open — MnGeo terms", + "url": "https://www.mngeo.state.mn.us/" + }, + "attribution": { + "text": "MnGeo / State of Minnesota", + "url": "https://www.mngeo.state.mn.us/chouse/airphoto/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/mountain-village-co.json b/src/library/entries/mountain-village-co.json new file mode 100644 index 000000000..e1c4462fa --- /dev/null +++ b/src/library/entries/mountain-village-co.json @@ -0,0 +1,48 @@ +{ + "id": "mountain-village-co", + "kind": "tiles", + "family": "imagery", + "title": "Mountain Village, Colorado Orthoimagery", + "provider": "Town of Mountain Village", + "description": "Aerial orthophotos: Mountain Village, Colorado jurisdiction.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-107.8864, 37.9114, -107.8244, 37.9537] + }, + "source": { + "format": "xyz", + "url": "https://tiles.arcgis.com/tiles/Nefdxa42x2DnAd5Z/arcgis/rest/services/Mountain_Village_Orthos/MapServer/tile/{z}/{y}/{x}", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 13, + "maxZoom": 22, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "sub-foot to 1 ft (local program)", + "resolutionM": 0.15, + "trueOrtho": false, + "vintage": "2024–present", + "updateCycle": "Local flight cycle (typically annual to 3-yearly)", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Local government open data — see publisher terms", + "url": "https://www.townofmountainvillage.com/", + "operatorOptIn": true + }, + "attribution": { + "text": "Town of Mountain Village", + "url": "https://www.townofmountainvillage.com/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/ms-statewide-imagery.json b/src/library/entries/ms-statewide-imagery.json new file mode 100644 index 000000000..415168521 --- /dev/null +++ b/src/library/entries/ms-statewide-imagery.json @@ -0,0 +1,48 @@ +{ + "id": "ms-statewide-imagery", + "kind": "tiles", + "family": "imagery", + "title": "Mississippi Statewide Imagery", + "provider": "MARIS", + "description": "Aerial orthophotos: Statewide coverage of Mississippi.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-91.66, 30.17, -88.1, 35] + }, + "source": { + "format": "xyz", + "url": "https://tiles.arcgis.com/tiles/LLVEmB8Lsae3Um4s/arcgis/rest/services/ms_napp2/MapServer/tile/{z}/{y}/{x}", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 16, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "1 m (NAPP/NAIP)", + "resolutionM": 1, + "trueOrtho": false, + "vintage": "2015–present", + "updateCycle": "Periodic", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Open state geodata — attribution requested", + "url": "https://www.gis.ms.gov/", + "operatorOptIn": true + }, + "attribution": { + "text": "USDA-FSA-APFO", + "url": "https://www.gis.ms.gov/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/mt-statewide-imagery.json b/src/library/entries/mt-statewide-imagery.json new file mode 100644 index 000000000..4c9b7754b --- /dev/null +++ b/src/library/entries/mt-statewide-imagery.json @@ -0,0 +1,47 @@ +{ + "id": "mt-statewide-imagery", + "kind": "tiles", + "family": "imagery", + "title": "Montana Statewide Imagery (2023)", + "provider": "Montana State Library (MSDI)", + "description": "Aerial orthophotos: Statewide coverage of Montana.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-116.0637, 44.3115, -103.9988, 49.064] + }, + "source": { + "format": "bbox", + "url": "https://gisservice.mt.gov/arcgis/rest/services/MSDI_Framework/NAIP_2023/ImageServer/exportImage?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=jpgpng&f=image", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 19, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "60 cm (NAIP)", + "resolutionM": 0.6, + "trueOrtho": false, + "vintage": "2023–present", + "updateCycle": "NAIP 2-year cycle", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Public domain — Montana State Library open data", + "url": "https://msl.mt.gov/geoinfo/" + }, + "attribution": { + "text": "Montana State Library", + "url": "https://msl.mt.gov/geoinfo/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/naip.json b/src/library/entries/naip.json new file mode 100644 index 000000000..0e4c7462d --- /dev/null +++ b/src/library/entries/naip.json @@ -0,0 +1,47 @@ +{ + "id": "naip", + "kind": "tiles", + "family": "imagery", + "title": "NAIP — National Agriculture Imagery Program", + "provider": "USDA FSA / USGS", + "description": "Aerial orthophotos: Conterminous US, each state every 2–3 years.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-125, 24, -66.9, 49.4] + }, + "source": { + "format": "bbox", + "url": "https://imagery.nationalmap.gov/arcgis/rest/services/USGSNAIPImagery/ImageServer/exportImage?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=jpgpng&f=image", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 19, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "coverage", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "60 cm (30 cm in some states)", + "resolutionM": 0.3, + "trueOrtho": false, + "vintage": "2003–present", + "updateCycle": "2–3 year state cycles", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Public Domain (U.S. Government work)", + "url": "https://naip-usdaonline.hub.arcgis.com/" + }, + "attribution": { + "text": "USDA NAIP", + "url": "https://naip-usdaonline.hub.arcgis.com/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/nasa-black-marble.json b/src/library/entries/nasa-black-marble.json new file mode 100644 index 000000000..5abaefa24 --- /dev/null +++ b/src/library/entries/nasa-black-marble.json @@ -0,0 +1,30 @@ +{ + "id": "nasa-black-marble", + "kind": "tiles", + "family": "imagery", + "title": "Earth at night (NASA Black Marble 2016)", + "provider": "NASA GIBS", + "description": "VIIRS night lights composite: where people light the planet after dark. Drapes on globe maps and on Google 3D tiles.", + "icon": "☾", + "maintainer": "github:asturksever", + "coverage": { "global": true }, + "source": { + "format": "xyz", + "url": "https://gibs.earthdata.nasa.gov/wmts/epsg3857/best/VIIRS_Black_Marble/default/2016-01-01/GoogleMapsCompatible_Level8/{z}/{y}/{x}.png", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 0, + "maxZoom": 8 + }, + "display": { "opacity": 0.85 }, + "licence": { + "name": "NASA data: U.S. public domain; no endorsement implied", + "url": "https://www.earthdata.nasa.gov/engage/open-data-services-software-policies" + }, + "attribution": { + "text": "NASA Earth Observatory / GIBS — VIIRS Black Marble", + "url": "https://earthobservatory.nasa.gov/features/NightLights" + }, + "status": "community", + "reviewed": "2026-10-21" +} diff --git a/src/library/entries/nasa-eonet-events.json b/src/library/entries/nasa-eonet-events.json new file mode 100644 index 000000000..b5e747d80 --- /dev/null +++ b/src/library/entries/nasa-eonet-events.json @@ -0,0 +1,58 @@ +{ + "id": "nasa-eonet-events", + "kind": "dataset", + "family": "events", + "title": "NASA EONET open natural events", + "provider": "NASA EONET", + "description": "Wildfires, severe storms, volcanoes, sea and lake ice and other natural events NASA's Earth Observatory is tracking now.", + "icon": "✦", + "maintainer": "github:asturksever", + "coverage": { + "global": true + }, + "source": { + "format": "geojson", + "url": "https://eonet.gsfc.nasa.gov/api/v3/events/geojson", + "query": { + "status": "open", + "limit": "1000" + }, + "fetch": "browser-direct", + "cacheSeconds": 900, + "maxFeatures": 2000 + }, + "display": { + "color": "#a0aec0", + "label": "title", + "fields": ["date", "magnitudeValue"], + "rank": { + "field": "date", + "order": "desc" + }, + "colorBy": { + "field": "categories.0.title", + "colors": { + "Wildfires": "#c98500", + "Severe Storms": "#d55181", + "Sea and Lake Ice": "#3987e5", + "Volcanoes": "#008300" + }, + "labels": { + "Wildfires": "Wildfires", + "Severe Storms": "Severe storms", + "Sea and Lake Ice": "Sea and lake ice", + "Volcanoes": "Volcanoes" + } + } + }, + "licence": { + "name": "NASA data: U.S. public domain; no endorsement implied", + "url": "https://www.nasa.gov/nasa-brand-center/images-and-media/" + }, + "attribution": { + "text": "NASA Earth Observatory Natural Event Tracker (EONET)", + "url": "https://eonet.gsfc.nasa.gov/" + }, + "status": "community", + "reviewed": "2026-10-21" +} diff --git a/src/library/entries/nc-statewide-imagery.json b/src/library/entries/nc-statewide-imagery.json new file mode 100644 index 000000000..d41db21b7 --- /dev/null +++ b/src/library/entries/nc-statewide-imagery.json @@ -0,0 +1,47 @@ +{ + "id": "nc-statewide-imagery", + "kind": "tiles", + "family": "imagery", + "title": "North Carolina Statewide Imagery (2023)", + "provider": "NC OneMap", + "description": "Aerial orthophotos: Statewide coverage of North Carolina.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-84.452, 33.6938, -75.4041, 36.6379] + }, + "source": { + "format": "bbox", + "url": "https://services.nconemap.gov/secure/rest/services/Imagery/Orthoimagery_2020_2023/ImageServer/exportImage?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=jpgpng&f=image", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 19, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "6 in – 1 ft", + "resolutionM": 0.1524, + "trueOrtho": false, + "vintage": "2023–present", + "updateCycle": "Statewide 4-year cycle (quarterly regions)", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Public domain — NC OneMap terms of use", + "url": "https://www.nconemap.gov/pages/terms" + }, + "attribution": { + "text": "NC911, NCCGIA", + "url": "https://www.nconemap.gov/pages/imagery" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/nctcog-tx.json b/src/library/entries/nctcog-tx.json new file mode 100644 index 000000000..d4cd59236 --- /dev/null +++ b/src/library/entries/nctcog-tx.json @@ -0,0 +1,48 @@ +{ + "id": "nctcog-tx", + "kind": "tiles", + "family": "imagery", + "title": "North Central Texas (NCTCOG) Ortho", + "provider": "NCTCOG", + "description": "Aerial orthophotos: North Central Texas (NCTCOG) jurisdiction.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-97.5865, 32.5326, -96.9946, 33.0056] + }, + "source": { + "format": "bbox", + "url": "https://mapitimage.tarrantcounty.com/arcgis/rest/services/Aerials/2011_NCTCOG_Ortho/ImageServer/exportImage?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=jpgpng&f=image", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 11, + "maxZoom": 20, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "sub-foot to 1 ft (local program)", + "resolutionM": 0.15, + "trueOrtho": false, + "vintage": "2011–present", + "updateCycle": "Local flight cycle (typically annual to 3-yearly)", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Local government open data — see publisher terms", + "url": "https://www.nctcog.org/", + "operatorOptIn": true + }, + "attribution": { + "text": "North Central Texas Council of Governments", + "url": "https://www.nctcog.org/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/nd-statewide-imagery.json b/src/library/entries/nd-statewide-imagery.json new file mode 100644 index 000000000..dfa9e88d1 --- /dev/null +++ b/src/library/entries/nd-statewide-imagery.json @@ -0,0 +1,48 @@ +{ + "id": "nd-statewide-imagery", + "kind": "tiles", + "family": "imagery", + "title": "North Dakota Statewide Imagery (2013)", + "provider": "North Dakota GIS Hub", + "description": "Aerial orthophotos: Statewide coverage of North Dakota.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-104.3616, 45.7993, -96.3961, 49.1166] + }, + "source": { + "format": "xyz", + "url": "https://ndgishub.nd.gov/arcgis/rest/services/Imagery/AerialImage_DES_BaseMap_Cached/MapServer/tile/{z}/{y}/{x}", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 21, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "1 ft – 1 m", + "resolutionM": 0.3048, + "trueOrtho": false, + "vintage": "2013–present", + "updateCycle": "Periodic", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Open state geodata — attribution requested", + "url": "https://www.gis.nd.gov/", + "operatorOptIn": true + }, + "attribution": { + "text": "ND Dept. of Transportation, ND Dept. of Emergency Services, and the ND GIS Hub", + "url": "https://www.gis.nd.gov/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/new-orleans-rpc-la.json b/src/library/entries/new-orleans-rpc-la.json new file mode 100644 index 000000000..d2fb99732 --- /dev/null +++ b/src/library/entries/new-orleans-rpc-la.json @@ -0,0 +1,48 @@ +{ + "id": "new-orleans-rpc-la", + "kind": "tiles", + "family": "imagery", + "title": "New Orleans Regional Planning Commission Imagery", + "provider": "New Orleans RPC", + "description": "Aerial orthophotos: New Orleans Regional Planning Commission jurisdiction.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-90.0974, 29.8415, -89.9951, 29.9701] + }, + "source": { + "format": "xyz", + "url": "https://tiles.arcgis.com/tiles/3OuwdDUqRzQS6PA4/arcgis/rest/services/RPC2022imagery/MapServer/tile/{z}/{y}/{x}", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 11, + "maxZoom": 22, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "sub-foot to 1 ft (local program)", + "resolutionM": 0.15, + "trueOrtho": false, + "vintage": "2020–present", + "updateCycle": "Local flight cycle (typically annual to 3-yearly)", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Local government open data — see publisher terms", + "url": "https://www.norpc.org/", + "operatorOptIn": true + }, + "attribution": { + "text": "Regional Planning Commission for Jefferson, Orleans, Plaquemines, St. Bernard, St. Charles, St. John the Baptist, St. Tammany, and Tangipahoa Parishes (RPC)", + "url": "https://www.norpc.org/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/nh-statewide-imagery.json b/src/library/entries/nh-statewide-imagery.json new file mode 100644 index 000000000..da1549ff4 --- /dev/null +++ b/src/library/entries/nh-statewide-imagery.json @@ -0,0 +1,48 @@ +{ + "id": "nh-statewide-imagery", + "kind": "tiles", + "family": "imagery", + "title": "New Hampshire Statewide Imagery (2022)", + "provider": "NH GRANIT", + "description": "Aerial orthophotos: Statewide coverage of New Hampshire.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-72.6173, 42.6862, -70.5548, 45.3128] + }, + "source": { + "format": "bbox", + "url": "https://nhgeodata.unh.edu/image/rest/services/ImageServices/NH_2021_2022_6in_RGB/ImageServer/exportImage?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=jpgpng&f=image", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 19, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "6 in", + "resolutionM": 0.1524, + "trueOrtho": false, + "vintage": "2022–present", + "updateCycle": "Multi-year cycles", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Open — NH GRANIT terms", + "url": "https://www.granit.unh.edu/", + "operatorOptIn": true + }, + "attribution": { + "text": "NH GRANIT, University of New Hampshire", + "url": "https://www.granit.unh.edu/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/nj-statewide-imagery.json b/src/library/entries/nj-statewide-imagery.json new file mode 100644 index 000000000..098aad738 --- /dev/null +++ b/src/library/entries/nj-statewide-imagery.json @@ -0,0 +1,48 @@ +{ + "id": "nj-statewide-imagery", + "kind": "tiles", + "family": "imagery", + "title": "New Jersey Statewide Imagery (2015)", + "provider": "NJ Office of GIS (NJOGIS)", + "description": "Aerial orthophotos: Statewide coverage of New Jersey.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-75.56, 38.93, -73.89, 41.36] + }, + "source": { + "format": "xyz", + "url": "https://maps.nj.gov/arcgis/rest/services/Basemap/Orthos_Natural_2015_NJ_WM/MapServer/tile/{z}/{y}/{x}", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 20, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "1 ft", + "resolutionM": 0.3048, + "trueOrtho": false, + "vintage": "2015–present", + "updateCycle": "Roughly every 3 years", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Open — State of New Jersey provides data 'as is'", + "url": "https://njgin.nj.gov/", + "operatorOptIn": true + }, + "attribution": { + "text": "Project definition, funding requests, and data distribution by NJ Office of Information Technology, Office of Geographic Information Systems (NJOIT - OGIS). Funding for the imagery project provided by", + "url": "https://registry.opendata.aws/nj-imagery/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/noaa-tide-stations.json b/src/library/entries/noaa-tide-stations.json new file mode 100644 index 000000000..f195724ae --- /dev/null +++ b/src/library/entries/noaa-tide-stations.json @@ -0,0 +1,37 @@ +{ + "id": "noaa-tide-stations", + "kind": "dataset", + "family": "environment", + "title": "US tide stations (NOAA CO-OPS)", + "provider": "NOAA CO-OPS", + "description": "About 300 NOAA water-level stations on US coasts, the Great Lakes and Pacific islands, with their tide type.", + "icon": "≈", + "maintainer": "github:asturksever", + "coverage": { "countries": ["US"], "bbox": [-180, -15, 180, 72] }, + "source": { + "format": "json", + "url": "https://api.tidesandcurrents.noaa.gov/mdapi/prod/webapi/stations.json", + "query": { "type": "waterlevels" }, + "records": "stations", + "columns": { "lat": "lat", "lon": "lng" }, + "fetch": "browser-direct", + "cacheSeconds": 86400, + "maxBytes": 4194304, + "maxFeatures": 1000 + }, + "display": { + "color": "#63b3ed", + "label": "name", + "fields": ["state", "tideType"] + }, + "licence": { + "name": "NOAA data: U.S. public domain", + "url": "https://tidesandcurrents.noaa.gov/disclaimers.html" + }, + "attribution": { + "text": "NOAA Center for Operational Oceanographic Products and Services (CO-OPS)", + "url": "https://tidesandcurrents.noaa.gov/" + }, + "status": "community", + "reviewed": "2026-10-22" +} diff --git a/src/library/entries/nrw-dop.json b/src/library/entries/nrw-dop.json new file mode 100644 index 000000000..cb5c07da6 --- /dev/null +++ b/src/library/entries/nrw-dop.json @@ -0,0 +1,48 @@ +{ + "id": "nrw-dop", + "kind": "tiles", + "family": "imagery", + "title": "Digitale Orthophotos NRW (DOP 10 cm)", + "provider": "Geobasis NRW", + "description": "Aerial orthophotos: 100% of North Rhine-Westphalia (~34,100 km²).", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["DE"], + "bbox": [5.86, 50.32, 9.46, 52.53] + }, + "source": { + "format": "bbox", + "url": "https://www.wms.nrw.de/geobasis/wms_nw_dop?service=WMS&request=GetMap&version=1.3.0&layers=nw_dop_rgb&styles=&crs=EPSG:3857&bbox={bbox-epsg-3857}&width=256&height=256&format=image/jpeg", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 8, + "maxZoom": 20, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true, + "noDataColor": "#ffffff" + }, + "imagery": { + "type": "ortho", + "resolution": "10 cm", + "resolutionM": 0.1, + "trueOrtho": false, + "vintage": "2021–present", + "updateCycle": "Biennial per zone (rolling)", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Datenlizenz Deutschland – Zero (public-domain-like)", + "url": "https://www.govdata.de/dl-de/zero-2-0" + }, + "attribution": { + "text": "Geobasis NRW (dl-de/zero-2-0)", + "url": "https://www.opengeodata.nrw.de/produkte/geobasis/lusat/akt/dop/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/nsw-imagery.json b/src/library/entries/nsw-imagery.json new file mode 100644 index 000000000..64a7fb94f --- /dev/null +++ b/src/library/entries/nsw-imagery.json @@ -0,0 +1,47 @@ +{ + "id": "nsw-imagery", + "kind": "tiles", + "family": "imagery", + "title": "NSW Imagery (Six Maps)", + "provider": "Spatial Services NSW (DCS)", + "description": "Aerial orthophotos: New South Wales (~801,000 km²).", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["AU"], + "bbox": [140.9, -37.6, 153.7, -28.1] + }, + "source": { + "format": "xyz", + "url": "https://maps.six.nsw.gov.au/arcgis/rest/services/public/NSW_Imagery/MapServer/tile/{z}/{y}/{x}", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 4, + "maxZoom": 19, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "7.5 cm (metro) – 50 cm", + "resolutionM": 0.075, + "trueOrtho": false, + "vintage": "2015–present", + "updateCycle": "Rolling (metro yearly)", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "CC BY 4.0", + "url": "https://www.spatial.nsw.gov.au/products_and_services/web_services" + }, + "attribution": { + "text": "© Spatial Services NSW", + "url": "https://portal.spatial.nsw.gov.au/portal/home/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/ntad-national-bridges.json b/src/library/entries/ntad-national-bridges.json new file mode 100644 index 000000000..a3828fedb --- /dev/null +++ b/src/library/entries/ntad-national-bridges.json @@ -0,0 +1,57 @@ +{ + "id": "ntad-national-bridges", + "kind": "dataset", + "family": "infrastructure", + "title": "US bridges and their condition (NBI)", + "provider": "USDOT BTS", + "description": "All of the about 624,000 highway bridges in the National Bridge Inventory, coloured by condition. Loads the bridges in view below 15 km.", + "icon": "⌒", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US", "PR"] + }, + "source": { + "format": "arcgis", + "url": "https://services.arcgis.com/xOi1kZaI0eWDREZv/arcgis/rest/services/NTAD_National_Bridge_Inventory/FeatureServer/0", + "query": { + "outFields": "BRIDGE_CONDITION,FACILITY_CARRIED_007,FEATURES_DESC_006A,YEAR_BUILT_027,ADT_029" + }, + "fetch": "browser-direct", + "view": { + "maxAltitudeM": 15000, + "sample": [-80.02, 40.43, -79.98, 40.46] + }, + "cacheSeconds": 86400, + "maxBytes": 8388608, + "maxFeatures": 2000, + "maxPages": 1 + }, + "display": { + "color": "#a0aec0", + "label": "FACILITY_CARRIED_007", + "fields": ["BRIDGE_CONDITION", "YEAR_BUILT_027"], + "colorBy": { + "field": "BRIDGE_CONDITION", + "colors": { + "G": "#0ca30c", + "F": "#fab219", + "P": "#d03b3b" + }, + "labels": { + "G": "Good", + "F": "Fair", + "P": "Poor" + } + } + }, + "licence": { + "name": "U.S. government work: unrestricted public use", + "url": "https://www.bts.gov/ntad" + }, + "attribution": { + "text": "USDOT Bureau of Transportation Statistics — National Transportation Atlas Database, National Bridge Inventory", + "url": "https://www.bts.gov/ntad" + }, + "status": "community", + "reviewed": "2026-10-22" +} diff --git a/src/library/entries/nwa-benton-washington-ar.json b/src/library/entries/nwa-benton-washington-ar.json new file mode 100644 index 000000000..7ddaa5e21 --- /dev/null +++ b/src/library/entries/nwa-benton-washington-ar.json @@ -0,0 +1,48 @@ +{ + "id": "nwa-benton-washington-ar", + "kind": "tiles", + "family": "imagery", + "title": "Northwest Arkansas (Benton & Washington County) Imagery", + "provider": "Northwest Arkansas regional GIS", + "description": "Aerial orthophotos: Northwest Arkansas (Benton & Washington County) jurisdiction.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-94.6282, 35.7407, -93.7939, 36.5179] + }, + "source": { + "format": "xyz", + "url": "https://tiles.arcgis.com/tiles/G4bYaaas92zuKaUR/arcgis/rest/services/BentWashImg2024Pack/MapServer/tile/{z}/{y}/{x}", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 11, + "maxZoom": 22, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "sub-foot to 1 ft (local program)", + "resolutionM": 0.15, + "trueOrtho": false, + "vintage": "2024–present", + "updateCycle": "Local flight cycle (typically annual to 3-yearly)", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Local government open data — see publisher terms", + "url": "https://www.nwarpc.org/", + "operatorOptIn": true + }, + "attribution": { + "text": "NWARPC; Washington County; Benton County", + "url": "https://www.nwarpc.org/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/nws-active-alerts.json b/src/library/entries/nws-active-alerts.json new file mode 100644 index 000000000..fce1bc393 --- /dev/null +++ b/src/library/entries/nws-active-alerts.json @@ -0,0 +1,56 @@ +{ + "id": "nws-active-alerts", + "kind": "dataset", + "family": "weather", + "title": "US weather alerts with drawn areas (NWS)", + "provider": "NWS", + "description": "Active National Weather Service alerts that carry their own polygon, such as storm-based warnings. Zone-wide alerts have no drawn area and are left out.", + "icon": "⚠", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-180, 13, -64, 72] + }, + "source": { + "format": "geojson", + "url": "https://api.weather.gov/alerts/active", + "fetch": "browser-direct", + "cacheSeconds": 300, + "maxBytes": 8388608, + "maxFeatures": 2000 + }, + "display": { + "color": "#a0aec0", + "label": "event", + "fields": ["severity", "areaDesc"], + "rank": { + "field": "onset", + "order": "desc" + }, + "colorBy": { + "field": "severity", + "colors": { + "Extreme": "#d03b3b", + "Severe": "#ec835a", + "Moderate": "#fab219", + "Minor": "#0ca30c" + }, + "labels": { + "Extreme": "Extreme", + "Severe": "Severe", + "Moderate": "Moderate", + "Minor": "Minor" + } + } + }, + "licence": { + "name": "NWS data: U.S. public domain", + "url": "https://www.weather.gov/disclaimer" + }, + "attribution": { + "text": "NOAA National Weather Service", + "url": "https://www.weather.gov/" + }, + "status": "community", + "reviewed": "2026-10-22" +} diff --git a/src/library/entries/ny-statewide-imagery.json b/src/library/entries/ny-statewide-imagery.json new file mode 100644 index 000000000..29afcb556 --- /dev/null +++ b/src/library/entries/ny-statewide-imagery.json @@ -0,0 +1,47 @@ +{ + "id": "ny-statewide-imagery", + "kind": "tiles", + "family": "imagery", + "title": "New York Statewide Imagery (2019)", + "provider": "NYS GIS Program Office (ITS)", + "description": "Aerial orthophotos: Statewide coverage of New York.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-80.1606, 40.2704, -71.2889, 45.1325] + }, + "source": { + "format": "xyz", + "url": "https://orthos.its.ny.gov/arcgis/rest/services/wms/NAIP_2019/MapServer/tile/{z}/{y}/{x}", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 18, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "3 in – 1 ft", + "resolutionM": 0.0762, + "trueOrtho": false, + "vintage": "2019–present", + "updateCycle": "Statewide 3-year cycle", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Public domain — NYS open data", + "url": "https://data.gis.ny.gov/" + }, + "attribution": { + "text": "USDA; NYS ITS Geospatial Services", + "url": "https://data.gis.ny.gov/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/oakland-county-mi.json b/src/library/entries/oakland-county-mi.json new file mode 100644 index 000000000..3e0507562 --- /dev/null +++ b/src/library/entries/oakland-county-mi.json @@ -0,0 +1,48 @@ +{ + "id": "oakland-county-mi", + "kind": "tiles", + "family": "imagery", + "title": "Oakland County, Michigan Ortho Imagery", + "provider": "Oakland County GIS", + "description": "Aerial orthophotos: Oakland County, Michigan jurisdiction.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-83.7199, 42.4077, -83.0521, 42.9141] + }, + "source": { + "format": "bbox", + "url": "https://gisservices.oakgov.com/arcgis/rest/services/ImageServices/EnterpriseOrthoTC2025ImageService/ImageServer/exportImage?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=jpgpng&f=image", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 11, + "maxZoom": 20, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "sub-foot to 1 ft (local program)", + "resolutionM": 0.15, + "trueOrtho": false, + "vintage": "2025–present", + "updateCycle": "Local flight cycle (typically annual to 3-yearly)", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Local government open data — see publisher terms", + "url": "https://oakgov.maps.arcgis.com/", + "operatorOptIn": true + }, + "attribution": { + "text": "Oakland County GIS", + "url": "https://oakgov.maps.arcgis.com/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/oh-statewide-imagery.json b/src/library/entries/oh-statewide-imagery.json new file mode 100644 index 000000000..48ccf2484 --- /dev/null +++ b/src/library/entries/oh-statewide-imagery.json @@ -0,0 +1,47 @@ +{ + "id": "oh-statewide-imagery", + "kind": "tiles", + "family": "imagery", + "title": "Ohio Statewide Imagery", + "provider": "Ohio OGRIP (OSIP)", + "description": "Aerial orthophotos: Statewide coverage of Ohio.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-84.9418, 38.3739, -80.4714, 42.0065] + }, + "source": { + "format": "xyz", + "url": "https://maps.ohio.gov/image/rest/services/osip_most_current_cache/MapServer/tile/{z}/{y}/{x}", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 22, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "6 in – 1 ft", + "resolutionM": 0.1524, + "trueOrtho": false, + "vintage": "2015–present", + "updateCycle": "Rolling statewide (OSIP)", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Public domain — Ohio open data", + "url": "https://maps.ohio.gov/" + }, + "attribution": { + "text": "Ohio Geographically Referenced Information Program (OGRIP)", + "url": "https://maps.ohio.gov/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/openseamap-seamarks.json b/src/library/entries/openseamap-seamarks.json new file mode 100644 index 000000000..5512aaec0 --- /dev/null +++ b/src/library/entries/openseamap-seamarks.json @@ -0,0 +1,30 @@ +{ + "id": "openseamap-seamarks", + "kind": "tiles", + "family": "infrastructure", + "title": "Seamarks (OpenSeaMap)", + "provider": "OpenSeaMap", + "description": "Buoys, beacons, lights and harbour marks from OpenSeaMap, drawn over any map. Useful with AIS vessels.", + "icon": "⚓", + "maintainer": "github:asturksever", + "coverage": { "global": true }, + "source": { + "format": "xyz", + "url": "https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 18 + }, + "display": { "opacity": 1 }, + "licence": { + "name": "CC BY-SA 2.0; data © OpenStreetMap contributors (ODbL)", + "url": "https://openseamap.org/index.php?id=imprint&L=1" + }, + "attribution": { + "text": "© OpenSeaMap contributors, CC BY-SA; data © OpenStreetMap contributors", + "url": "https://openseamap.org/" + }, + "status": "community", + "reviewed": "2026-10-21" +} diff --git a/src/library/entries/or-statewide-imagery.json b/src/library/entries/or-statewide-imagery.json new file mode 100644 index 000000000..ed6cde3d4 --- /dev/null +++ b/src/library/entries/or-statewide-imagery.json @@ -0,0 +1,47 @@ +{ + "id": "or-statewide-imagery", + "kind": "tiles", + "family": "imagery", + "title": "Oregon Statewide Imagery (2000)", + "provider": "Oregon OSIP (DAS GEO)", + "description": "Aerial orthophotos: Statewide coverage of Oregon.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-124.8956, 41.7968, -116.3087, 46.4332] + }, + "source": { + "format": "bbox", + "url": "https://imagery.oregonexplorer.info/arcgis/rest/services/NAIP_2000/NAIP_2000_SL/ImageServer/exportImage?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=jpgpng&f=image", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 19, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "6 in – 1 ft", + "resolutionM": 0.1524, + "trueOrtho": false, + "vintage": "2000–present", + "updateCycle": "OSIP cycles (~3 years)", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Public domain — Oregon open data", + "url": "https://www.oregon.gov/geo/" + }, + "attribution": { + "text": "Oregon, GEO, DAS", + "url": "https://www.oregon.gov/geo/Pages/imagery.aspx" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/pa-statewide-imagery.json b/src/library/entries/pa-statewide-imagery.json new file mode 100644 index 000000000..ffe6d0018 --- /dev/null +++ b/src/library/entries/pa-statewide-imagery.json @@ -0,0 +1,48 @@ +{ + "id": "pa-statewide-imagery", + "kind": "tiles", + "family": "imagery", + "title": "Pennsylvania Statewide Imagery", + "provider": "PASDA / PAMAP (Penn State)", + "description": "Aerial orthophotos: Statewide coverage of Pennsylvania.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-80.52, 39.72, -74.69, 42.27] + }, + "source": { + "format": "bbox", + "url": "https://imagery.pasda.psu.edu/arcgis/rest/services/pasda/Aerials0306StateWide/MapServer/export?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=jpgpng&f=image", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 19, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true, + "noDataColor": "#fdfdfd" + }, + "imagery": { + "type": "ortho", + "resolution": "1 ft – 2 ft", + "resolutionM": 0.3048, + "trueOrtho": false, + "vintage": "2015–present", + "updateCycle": "PAMAP multi-year cycles", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Public domain — PASDA open data", + "url": "https://www.pasda.psu.edu/" + }, + "attribution": { + "text": "PASDA / PAMAP, Commonwealth of Pennsylvania", + "url": "https://www.pasda.psu.edu/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/pdok-aerial-netherlands.json b/src/library/entries/pdok-aerial-netherlands.json new file mode 100644 index 000000000..40401f241 --- /dev/null +++ b/src/library/entries/pdok-aerial-netherlands.json @@ -0,0 +1,48 @@ +{ + "id": "pdok-aerial-netherlands", + "kind": "tiles", + "family": "imagery", + "title": "Netherlands aerial photos (PDOK)", + "provider": "PDOK", + "description": "The latest high-resolution aerial photography of the whole Netherlands, from Beeldmateriaal Nederland.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["NL"], + "bbox": [3.2, 50.7, 7.3, 53.6] + }, + "source": { + "format": "xyz", + "url": "https://service.pdok.nl/hwh/luchtfotorgb/wmts/v1_0/Actueel_orthoHR/EPSG:3857/{z}/{x}/{y}.jpeg", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 19, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "coverage", + "aerial": true, + "noDataColor": "#ffffff" + }, + "imagery": { + "type": "ortho", + "resolution": "7.5–8 cm", + "resolutionM": 0.08, + "trueOrtho": false, + "vintage": "2023–present", + "updateCycle": "Annual", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "CC BY 4.0", + "url": "https://www.pdok.nl/introductie/-/article/luchtfoto-pdok" + }, + "attribution": { + "text": "Beeldmateriaal Nederland via PDOK", + "url": "https://www.pdok.nl/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/pl-ortofotomapa.json b/src/library/entries/pl-ortofotomapa.json new file mode 100644 index 000000000..8ca6f3426 --- /dev/null +++ b/src/library/entries/pl-ortofotomapa.json @@ -0,0 +1,48 @@ +{ + "id": "pl-ortofotomapa", + "kind": "tiles", + "family": "imagery", + "title": "Ortofotomapa Polski (GUGiK)", + "provider": "GUGiK / geoportal.gov.pl", + "description": "Aerial orthophotos: 100% of Poland (rolling; cities at 5 cm).", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["PL"], + "bbox": [14.12, 49, 24.15, 54.84] + }, + "source": { + "format": "bbox", + "url": "https://mapy.geoportal.gov.pl/wss/service/PZGIK/ORTO/WMS/StandardResolution?service=WMS&request=GetMap&version=1.3.0&layers=Raster&styles=default&crs=EPSG:3857&bbox={bbox-epsg-3857}&width=256&height=256&format=image/jpeg", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 7, + "maxZoom": 20, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "coverage", + "aerial": true, + "noDataColor": "#ffffff" + }, + "imagery": { + "type": "ortho", + "resolution": "5–25 cm", + "resolutionM": 0.05, + "trueOrtho": false, + "vintage": "2019–present", + "updateCycle": "Rolling (1–3 years per zone)", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Polish open geodata (free reuse, Prawo geodezyjne art. 40a)", + "url": "https://www.geoportal.gov.pl/" + }, + "attribution": { + "text": "© GUGiK / geoportal.gov.pl", + "url": "https://mapy.geoportal.gov.pl/imap/Imgp_2.html" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/pnoa-ma.json b/src/library/entries/pnoa-ma.json new file mode 100644 index 000000000..5ee8b2d6d --- /dev/null +++ b/src/library/entries/pnoa-ma.json @@ -0,0 +1,48 @@ +{ + "id": "pnoa-ma", + "kind": "tiles", + "family": "imagery", + "title": "PNOA Máxima Actualidad", + "provider": "IGN España / CNIG", + "description": "Aerial orthophotos: Mainland Spain and Balearic Islands (Canary Islands served by a separate PNOA service).", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["ES"], + "bbox": [-9.3, 36, 3.35, 43.8] + }, + "source": { + "format": "bbox", + "url": "https://www.ign.es/wms-inspire/pnoa-ma?service=WMS&request=GetMap&version=1.3.0&layers=OI.OrthoimageCoverage&styles=&crs=EPSG:3857&bbox={bbox-epsg-3857}&width=256&height=256&format=image/jpeg", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 20, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "coverage", + "aerial": true, + "noDataColor": "#ffffff" + }, + "imagery": { + "type": "ortho", + "resolution": "15–25 cm", + "resolutionM": 0.15, + "trueOrtho": false, + "vintage": "2020–present", + "updateCycle": "2–3 year refresh per zone", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "CC BY 4.0 (compatible) — Orden FOM/2807/2015", + "url": "https://www.scne.es/productos.html" + }, + "attribution": { + "text": "PNOA © Instituto Geográfico Nacional de España", + "url": "https://centrodedescargas.cnig.es/CentroDescargas/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/portland-metro-or.json b/src/library/entries/portland-metro-or.json new file mode 100644 index 000000000..239f5ec51 --- /dev/null +++ b/src/library/entries/portland-metro-or.json @@ -0,0 +1,48 @@ +{ + "id": "portland-metro-or", + "kind": "tiles", + "family": "imagery", + "title": "Portland Metro (RLIS), Oregon Aerial Photos", + "provider": "Oregon Metro RLIS", + "description": "Aerial orthophotos: Portland Metro (RLIS), Oregon jurisdiction.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-123.1535, 45.234, -122.3675, 45.7026] + }, + "source": { + "format": "xyz", + "url": "https://utility.arcgis.com/usrsvcs/servers/41cd821260944a668ef228f671b3ff46/rest/services/photo/2023aerialphoto/MapServer/tile/{z}/{y}/{x}", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 11, + "maxZoom": 22, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "sub-foot to 1 ft (local program)", + "resolutionM": 0.15, + "trueOrtho": false, + "vintage": "2023–present", + "updateCycle": "Local flight cycle (typically annual to 3-yearly)", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Local government open data — see publisher terms", + "url": "https://rlisdiscovery.oregonmetro.gov/", + "operatorOptIn": true + }, + "attribution": { + "text": "Oregon Metro RLIS", + "url": "https://rlisdiscovery.oregonmetro.gov/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/pt-dgt-ortos.json b/src/library/entries/pt-dgt-ortos.json new file mode 100644 index 000000000..cb3854958 --- /dev/null +++ b/src/library/entries/pt-dgt-ortos.json @@ -0,0 +1,48 @@ +{ + "id": "pt-dgt-ortos", + "kind": "tiles", + "family": "imagery", + "title": "Ortofotos de Portugal Continental (DGT)", + "provider": "Direção-Geral do Território", + "description": "Aerial orthophotos: Portugal Continental.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["PT"], + "bbox": [-9.52, 36.96, -6.19, 42.15] + }, + "source": { + "format": "bbox", + "url": "https://ortos.dgterritorio.gov.pt/wms/ortosat2023?service=WMS&request=GetMap&version=1.3.0&layers=ortoSat2023-CorVerdadeira&styles=&crs=EPSG:3857&bbox={bbox-epsg-3857}&width=256&height=256&format=image/jpeg", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 7, + "maxZoom": 18, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "coverage", + "aerial": true, + "noDataColor": "#ffffff" + }, + "imagery": { + "type": "ortho", + "resolution": "25–30 cm", + "resolutionM": 0.25, + "trueOrtho": false, + "vintage": "2021–present", + "updateCycle": "Multi-year campaigns", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "CC BY 4.0", + "url": "https://www.dgterritorio.gov.pt/" + }, + "attribution": { + "text": "© Direção-Geral do Território", + "url": "https://snig.dgterritorio.gov.pt/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/ri-statewide-imagery.json b/src/library/entries/ri-statewide-imagery.json new file mode 100644 index 000000000..e6895458a --- /dev/null +++ b/src/library/entries/ri-statewide-imagery.json @@ -0,0 +1,48 @@ +{ + "id": "ri-statewide-imagery", + "kind": "tiles", + "family": "imagery", + "title": "Rhode Island Statewide Imagery (2019)", + "provider": "RIGIS", + "description": "Aerial orthophotos: Statewide coverage of Rhode Island.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-71.9065, 41.1437, -71.1078, 42.0276] + }, + "source": { + "format": "xyz", + "url": "https://tiles.arcgis.com/tiles/S8zZg9pg23JUEexQ/arcgis/rest/services/RI_201811_RGB_3in_web/MapServer/tile/{z}/{y}/{x}", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 22, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "3–6 in", + "resolutionM": 0.1524, + "trueOrtho": false, + "vintage": "2019–present", + "updateCycle": "Periodic", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Open — RIGIS terms", + "url": "https://www.rigis.org/", + "operatorOptIn": true + }, + "attribution": { + "text": "URI EDC, RIGIS", + "url": "https://www.rigis.org/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/swisstopo-swissimage.json b/src/library/entries/swisstopo-swissimage.json new file mode 100644 index 000000000..48c5c5e21 --- /dev/null +++ b/src/library/entries/swisstopo-swissimage.json @@ -0,0 +1,49 @@ +{ + "id": "swisstopo-swissimage", + "kind": "tiles", + "family": "imagery", + "title": "Switzerland aerial photos (SWISSIMAGE 10 cm)", + "provider": "swisstopo", + "description": "swisstopo's orthophoto mosaic of Switzerland and Liechtenstein: 10 cm in the lowlands and main Alpine valleys, 25 cm over the Alps, renewed every three years.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["CH", "LI"], + "bbox": [5.9, 45.8, 10.5, 47.9] + }, + "source": { + "format": "xyz", + "url": "https://wmts.geo.admin.ch/1.0.0/ch.swisstopo.swissimage/default/current/3857/{z}/{x}/{y}.jpeg", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 7, + "maxZoom": 20, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "countries", + "aerial": true, + "noDataColor": "#ffffff" + }, + "imagery": { + "type": "ortho", + "resolution": "10 cm (plains) / 25 cm (Alps)", + "resolutionM": 0.1, + "trueOrtho": false, + "vintage": "2021–present", + "updateCycle": "3-year rolling refresh", + "orthoQuality": "±0.15 m (1σ) plains / ±0.25 m Alps, DTM-orthorectified", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "swisstopo free geodata (OGD): free use, including commercial, with source credit", + "url": "https://www.swisstopo.admin.ch/en/terms-of-use-free-geodata-and-geoservices" + }, + "attribution": { + "text": "©swisstopo — SWISSIMAGE 10 cm", + "url": "https://www.swisstopo.admin.ch/en/orthoimage-swissimage-10" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/tx-statewide-imagery.json b/src/library/entries/tx-statewide-imagery.json new file mode 100644 index 000000000..66e4edc52 --- /dev/null +++ b/src/library/entries/tx-statewide-imagery.json @@ -0,0 +1,47 @@ +{ + "id": "tx-statewide-imagery", + "kind": "tiles", + "family": "imagery", + "title": "Texas Statewide Imagery (2022)", + "provider": "TxGIO / TNRIS", + "description": "Aerial orthophotos: Statewide coverage of Texas.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-106.65, 25.84, -93.51, 36.5] + }, + "source": { + "format": "bbox", + "url": "https://imagery.geographic.texas.gov/server/rest/services/NAIP/NAIP22_NCCIR_60cm/ImageServer/exportImage?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=jpgpng&f=image", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 19, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "60 cm", + "resolutionM": 0.6, + "trueOrtho": false, + "vintage": "2022–present", + "updateCycle": "NAIP 2-year; StratMap regional", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Public domain — TxGIO open data", + "url": "https://geographic.texas.gov/" + }, + "attribution": { + "text": "Texas Geographic Information Office (TxGIO)", + "url": "https://data.geographic.texas.gov/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/union-county-oh.json b/src/library/entries/union-county-oh.json new file mode 100644 index 000000000..7ccf9ff49 --- /dev/null +++ b/src/library/entries/union-county-oh.json @@ -0,0 +1,49 @@ +{ + "id": "union-county-oh", + "kind": "tiles", + "family": "imagery", + "title": "Union County, Ohio Aerial Imagery", + "provider": "Union County GIS", + "description": "Aerial orthophotos: Union County, Ohio jurisdiction.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-83.6732, 39.9845, -83.0745, 40.5493] + }, + "source": { + "format": "bbox", + "url": "https://www7.co.union.oh.us/unioncountyohio/rest/services/AerialPhoto2023/MapServer/export?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=jpgpng&f=image", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 11, + "maxZoom": 20, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true, + "noDataColor": "#fdfdfd" + }, + "imagery": { + "type": "ortho", + "resolution": "sub-foot to 1 ft (local program)", + "resolutionM": 0.15, + "trueOrtho": false, + "vintage": "2023–present", + "updateCycle": "Local flight cycle (typically annual to 3-yearly)", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Local government open data — see publisher terms", + "url": "https://union-county-auditor-unionco.hub.arcgis.com/", + "operatorOptIn": true + }, + "attribution": { + "text": "Union County / City of Marysville GIS", + "url": "https://union-county-auditor-unionco.hub.arcgis.com/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/usgs-hydrography.json b/src/library/entries/usgs-hydrography.json new file mode 100644 index 000000000..5a1ff23e4 --- /dev/null +++ b/src/library/entries/usgs-hydrography.json @@ -0,0 +1,35 @@ +{ + "id": "usgs-hydrography", + "kind": "tiles", + "family": "environment", + "title": "US rivers and lakes (USGS)", + "provider": "USGS", + "description": "The National Hydrography Dataset's rivers, streams and water bodies, drawn over any map.", + "icon": "≋", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-180, 13, -64, 72] + }, + "source": { + "format": "xyz", + "url": "https://basemap.nationalmap.gov/arcgis/rest/services/USGSHydroCached/MapServer/tile/{z}/{y}/{x}", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 0, + "maxZoom": 16 + }, + "display": { + "opacity": 1 + }, + "licence": { + "name": "USGS The National Map: U.S. public domain", + "url": "https://www.usgs.gov/information-policies-and-instructions/copyrights-and-credits" + }, + "attribution": { + "text": "USGS The National Map — National Hydrography Dataset", + "url": "https://www.usgs.gov/national-hydrography" + }, + "status": "community", + "reviewed": "2026-10-22" +} diff --git a/src/library/entries/usgs-topo.json b/src/library/entries/usgs-topo.json new file mode 100644 index 000000000..ed708f6db --- /dev/null +++ b/src/library/entries/usgs-topo.json @@ -0,0 +1,35 @@ +{ + "id": "usgs-topo", + "kind": "tiles", + "family": "imagery", + "title": "US topographic map (USGS)", + "provider": "USGS", + "description": "The National Map's topographic basemap of the United States: contours, roads, places and land cover.", + "icon": "⛰", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-180, 13, -64, 72] + }, + "source": { + "format": "xyz", + "url": "https://basemap.nationalmap.gov/arcgis/rest/services/USGSTopo/MapServer/tile/{z}/{y}/{x}", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 0, + "maxZoom": 16 + }, + "display": { + "opacity": 1 + }, + "licence": { + "name": "USGS The National Map: U.S. public domain", + "url": "https://www.usgs.gov/information-policies-and-instructions/copyrights-and-credits" + }, + "attribution": { + "text": "USGS The National Map", + "url": "https://www.usgs.gov/programs/national-geospatial-program/national-map" + }, + "status": "community", + "reviewed": "2026-10-22" +} diff --git a/src/library/entries/va-statewide-imagery.json b/src/library/entries/va-statewide-imagery.json new file mode 100644 index 000000000..97a585104 --- /dev/null +++ b/src/library/entries/va-statewide-imagery.json @@ -0,0 +1,48 @@ +{ + "id": "va-statewide-imagery", + "kind": "tiles", + "family": "imagery", + "title": "Virginia Statewide Imagery", + "provider": "VGIN / VDEM", + "description": "Aerial orthophotos: Statewide coverage of Virginia.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-83.68, 36.54, -75.24, 39.47] + }, + "source": { + "format": "xyz", + "url": "https://vginmaps.vdem.virginia.gov/arcgis/rest/services/VBMP_Imagery/MostRecentImagery_WGS/MapServer/tile/{z}/{y}/{x}", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 22, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "1 ft (VBMP)", + "resolutionM": 0.3048, + "trueOrtho": false, + "vintage": "2015–present", + "updateCycle": "VBMP 3-year statewide cycle", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Open — VGIN VBMP terms", + "url": "https://vgin.vdem.virginia.gov/", + "operatorOptIn": true + }, + "attribution": { + "text": "Virginia Geographic Information Network (VGIN)", + "url": "https://vgin.vdem.virginia.gov/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/vlaanderen-omw.json b/src/library/entries/vlaanderen-omw.json new file mode 100644 index 000000000..5b3fa6eb5 --- /dev/null +++ b/src/library/entries/vlaanderen-omw.json @@ -0,0 +1,48 @@ +{ + "id": "vlaanderen-omw", + "kind": "tiles", + "family": "imagery", + "title": "Orthofotomozaïek Vlaanderen (winter)", + "provider": "Digitaal Vlaanderen", + "description": "Aerial orthophotos: Flanders + Brussels (~13,600 km²), flown yearly.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["BE"], + "bbox": [2.54, 50.68, 5.92, 51.51] + }, + "source": { + "format": "bbox", + "url": "https://geo.api.vlaanderen.be/OMWRGBMRVL/wms?service=WMS&request=GetMap&version=1.3.0&layers=Ortho&styles=&crs=EPSG:3857&bbox={bbox-epsg-3857}&width=256&height=256&format=image/png", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 8, + "maxZoom": 20, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true, + "noDataColor": "#ffffff" + }, + "imagery": { + "type": "ortho", + "resolution": "10 cm (winter) / 25 cm (summer)", + "resolutionM": 0.1, + "trueOrtho": false, + "vintage": "2022–present", + "updateCycle": "Annual", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Vlaanderen model licence for free reuse", + "url": "https://www.vlaanderen.be/digitaal-vlaanderen/onze-oplossingen/orthofotomozaieken" + }, + "attribution": { + "text": "© Digitaal Vlaanderen", + "url": "https://download.vlaanderen.be/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/vt-statewide-imagery.json b/src/library/entries/vt-statewide-imagery.json new file mode 100644 index 000000000..54c696580 --- /dev/null +++ b/src/library/entries/vt-statewide-imagery.json @@ -0,0 +1,47 @@ +{ + "id": "vt-statewide-imagery", + "kind": "tiles", + "family": "imagery", + "title": "Vermont Statewide Imagery", + "provider": "VCGI", + "description": "Aerial orthophotos: Statewide coverage of Vermont.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-73.5011, 42.6871, -71.4363, 45.0631] + }, + "source": { + "format": "bbox", + "url": "https://maps.vcgi.vermont.gov/arcgis/rest/services/EGC_services/IMG_VCGI_CIR_WM_CACHE/ImageServer/exportImage?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=jpgpng&f=image", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 19, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "30 cm", + "resolutionM": 0.3, + "trueOrtho": false, + "vintage": "2015–present", + "updateCycle": "Statewide roughly every 2 years", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Public domain — Vermont open data", + "url": "https://vcgi.vermont.gov/" + }, + "attribution": { + "text": "Vermont Center for Geographic Information (VCGI)", + "url": "https://registry.opendata.aws/vt-opendata/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/wallonie-ortho.json b/src/library/entries/wallonie-ortho.json new file mode 100644 index 000000000..820fd79f7 --- /dev/null +++ b/src/library/entries/wallonie-ortho.json @@ -0,0 +1,48 @@ +{ + "id": "wallonie-ortho", + "kind": "tiles", + "family": "imagery", + "title": "Orthophotos de Wallonie", + "provider": "Service public de Wallonie (SPW)", + "description": "Aerial orthophotos: Wallonia (~16,900 km²).", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["BE"], + "bbox": [2.84, 49.49, 6.41, 50.85] + }, + "source": { + "format": "bbox", + "url": "https://geoservices.wallonie.be/arcgis/services/IMAGERIE/ORTHO_LAST/MapServer/WMSServer?service=WMS&request=GetMap&version=1.3.0&layers=0&styles=&crs=EPSG:3857&bbox={bbox-epsg-3857}&width=256&height=256&format=image/jpeg", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 8, + "maxZoom": 20, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true, + "noDataColor": "#ffffff" + }, + "imagery": { + "type": "ortho", + "resolution": "25 cm", + "resolutionM": 0.25, + "trueOrtho": false, + "vintage": "2021–present", + "updateCycle": "Annual", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Conditions ouvertes SPW (attribution)", + "url": "https://geoportail.wallonie.be/catalogue-donnees" + }, + "attribution": { + "text": "© Service public de Wallonie", + "url": "https://geoportail.wallonie.be/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/wi-statewide-imagery.json b/src/library/entries/wi-statewide-imagery.json new file mode 100644 index 000000000..2f2bb3e0d --- /dev/null +++ b/src/library/entries/wi-statewide-imagery.json @@ -0,0 +1,48 @@ +{ + "id": "wi-statewide-imagery", + "kind": "tiles", + "family": "imagery", + "title": "Wisconsin Statewide Imagery (2020)", + "provider": "Wisconsin DOT / WisconsinView", + "description": "Aerial orthophotos: Statewide coverage of Wisconsin.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-93.0454, 42.3829, -86.6018, 47.1427] + }, + "source": { + "format": "bbox", + "url": "https://di-wisdot.img.arcgis.com/arcgis/rest/services/Statewide/WisconsinNAIP20/ImageServer/exportImage?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=jpgpng&f=image", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 19, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true + }, + "imagery": { + "type": "ortho", + "resolution": "60 cm (NAIP)", + "resolutionM": 0.6, + "trueOrtho": false, + "vintage": "2020–present", + "updateCycle": "NAIP 2-year cycle", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Open — WisconsinView / state open data", + "url": "https://www.wisconsinview.org/", + "operatorOptIn": true + }, + "attribution": { + "text": "WisDOT - NAIP 2020", + "url": "https://www.wisconsinview.org/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/entries/wri-global-power-plants.json b/src/library/entries/wri-global-power-plants.json new file mode 100644 index 000000000..512da80a1 --- /dev/null +++ b/src/library/entries/wri-global-power-plants.json @@ -0,0 +1,65 @@ +{ + "id": "wri-global-power-plants", + "kind": "dataset", + "family": "infrastructure", + "title": "Global power plants (WRI)", + "provider": "WRI", + "description": "The 10,000 largest of about 35,000 power plants worldwide, with fuel and capacity, from the WRI Global Power Plant Database (v1.3, 2021).", + "icon": "ϟ", + "maintainer": "github:asturksever", + "coverage": { + "global": true + }, + "source": { + "format": "csv", + "url": "https://raw.githubusercontent.com/wri/global-power-plant-database/master/output_database/global_power_plant_database.csv", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "maxBytes": 16777216, + "maxFeatures": 10000 + }, + "display": { + "color": "#a0aec0", + "label": "name", + "fields": ["primary_fuel", "capacity_mw"], + "rank": { + "field": "capacity_mw", + "order": "desc" + }, + "colorBy": { + "field": "primary_fuel", + "colors": { + "Coal": "#d55181", + "Gas": "#d55181", + "Oil": "#d55181", + "Petcoke": "#d55181", + "Cogeneration": "#d55181", + "Nuclear": "#c98500", + "Hydro": "#3987e5", + "Wind": "#008300", + "Solar": "#008300" + }, + "labels": { + "Coal": "Fossil (coal, gas, oil)", + "Gas": "Fossil (coal, gas, oil)", + "Oil": "Fossil (coal, gas, oil)", + "Petcoke": "Fossil (coal, gas, oil)", + "Cogeneration": "Fossil (coal, gas, oil)", + "Nuclear": "Nuclear", + "Hydro": "Hydro", + "Wind": "Wind and solar", + "Solar": "Wind and solar" + } + } + }, + "licence": { + "name": "CC BY 4.0", + "url": "https://creativecommons.org/licenses/by/4.0/" + }, + "attribution": { + "text": "World Resources Institute, Global Power Plant Database (2021)", + "url": "https://datasets.wri.org/dataset/globalpowerplantdatabase" + }, + "status": "community", + "reviewed": "2026-10-22" +} diff --git a/src/library/entries/wv-statewide-imagery.json b/src/library/entries/wv-statewide-imagery.json new file mode 100644 index 000000000..f5e9c31ea --- /dev/null +++ b/src/library/entries/wv-statewide-imagery.json @@ -0,0 +1,49 @@ +{ + "id": "wv-statewide-imagery", + "kind": "tiles", + "family": "imagery", + "title": "West Virginia Statewide Imagery", + "provider": "WV GIS Technical Center (WVU)", + "description": "Aerial orthophotos: Statewide coverage of West Virginia.", + "icon": "▣", + "maintainer": "github:asturksever", + "coverage": { + "countries": ["US"], + "bbox": [-82.6924, 37.1844, -77.681, 40.6905] + }, + "source": { + "format": "bbox", + "url": "https://services.wvgis.wvu.edu/arcgis/rest/services/Imagery_BaseMaps_EarthCover/wv_imagery_NAIP_2018_60cm/MapServer/export?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=jpgpng&f=image", + "fetch": "browser-direct", + "cacheSeconds": 86400, + "minZoom": 6, + "maxZoom": 19, + "bounded": true + }, + "display": { + "opacity": 1, + "outline": "bbox", + "aerial": true, + "noDataColor": "#fdfdfd" + }, + "imagery": { + "type": "ortho", + "resolution": "60 cm", + "resolutionM": 0.6, + "trueOrtho": false, + "vintage": "2015–present", + "updateCycle": "NAIP 2-year cycle", + "registry": "https://imagery-registry.netlify.app/" + }, + "licence": { + "name": "Open — WV GIS Technical Center terms", + "url": "https://wvgis.wvu.edu/", + "operatorOptIn": true + }, + "attribution": { + "text": "WV GIS Technical Center, West Virginia University", + "url": "https://wvgis.wvu.edu/" + }, + "status": "community", + "reviewed": "2026-10-23" +} diff --git a/src/library/extent.js b/src/library/extent.js new file mode 100644 index 000000000..ca07816e4 --- /dev/null +++ b/src/library/extent.js @@ -0,0 +1,104 @@ +/** + * The area a Data Library layer covers, for "Zoom to layer". Pure: no + * Cesium, so the server, tests and the browser share it. + * + * An extent is `{ west, south, east, north }` in degrees. `east` may exceed + * 180 when the set crosses the antimeridian (Fiji, the Aleutians), so the + * shorter way round is kept rather than a box spanning the whole planet. + */ + +/** The whole world, short of the poles a Web Mercator map cannot show. */ +export const WORLD_EXTENT = Object.freeze({ + west: -180, + south: -85, + east: 180, + north: 85, +}); +/** Smallest box a zoom frames, so a single point is not a ground-level dive. */ +export const MIN_EXTENT_SPAN_DEG = 0.05; + +function visit(coordinates, push) { + if (!Array.isArray(coordinates)) return; + if (typeof coordinates[0] === 'number') { + const [lon, lat] = coordinates; + if ( + Number.isFinite(lon) && + Number.isFinite(lat) && + Math.abs(lon) <= 180 && + Math.abs(lat) <= 90 + ) + push(lon, lat); + return; + } + for (const child of coordinates) visit(child, push); +} + +/** + * The bounding box of GeoJSON features, taking the shorter way round the + * antimeridian. + * @param {Array<{ geometry?: { coordinates?: unknown } }>} features + * @returns {{ west: number, south: number, east: number, north: number } | null} + */ +export function featureExtent(features) { + let south = Infinity; + let north = -Infinity; + let west = Infinity; + let east = -Infinity; + // The same longitudes shifted to 0–360, for sets that straddle ±180. + let westShifted = Infinity; + let eastShifted = -Infinity; + let count = 0; + for (const feature of features || []) + visit(feature?.geometry?.coordinates, (lon, lat) => { + count += 1; + south = Math.min(south, lat); + north = Math.max(north, lat); + west = Math.min(west, lon); + east = Math.max(east, lon); + const shifted = lon < 0 ? lon + 360 : lon; + westShifted = Math.min(westShifted, shifted); + eastShifted = Math.max(eastShifted, shifted); + }); + if (!count) return null; + // A set that straddles ±180 is narrower in 0–360 terms; `east` then + // exceeds 180 (all-negative longitudes never take this branch). + if (eastShifted - westShifted < east - west) + return { west: westShifted, south, east: eastShifted, north }; + return { west, south, east, north }; +} + +/** A manifest's declared coverage as an extent (the world when global). */ +export function coverageExtent(entry) { + const bbox = entry?.coverage?.bbox; + if (!entry?.coverage?.global && Array.isArray(bbox) && bbox.length === 4) { + const [west, south, east, north] = bbox; + return { west, south, east, north }; + } + return { ...WORLD_EXTENT }; +} + +/** + * Grow a box to at least `minSpan` degrees each way and clamp it to what the + * map can frame. + * @param {{ west: number, south: number, east: number, north: number }} extent + * @param {number} [minSpan] + */ +export function padExtent(extent, minSpan = MIN_EXTENT_SPAN_DEG) { + const grow = (low, high) => { + const missing = Math.max(0, minSpan - (high - low)) / 2; + return [low - missing, high + missing]; + }; + const [west, east] = grow(extent.west, extent.east); + const [south, north] = grow(extent.south, extent.north); + return { + west, + east, + south: Math.max(WORLD_EXTENT.south, south), + north: Math.min(WORLD_EXTENT.north, north), + }; +} + +/** Whether a box is so wide the zoom should show the whole globe instead. */ +export function isWorldScale(extent) { + return extent.east - extent.west >= 300; +} diff --git a/src/library/extent.test.mjs b/src/library/extent.test.mjs new file mode 100644 index 000000000..db41cad51 --- /dev/null +++ b/src/library/extent.test.mjs @@ -0,0 +1,75 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + WORLD_EXTENT, + coverageExtent, + featureExtent, + isWorldScale, + padExtent, +} from './extent.js'; + +const point = (lon, lat) => ({ + geometry: { type: 'Point', coordinates: [lon, lat] }, +}); + +test('the extent covers every coordinate, nested geometries included', () => { + assert.deepEqual( + featureExtent([ + point(-122, 37), + { + geometry: { + type: 'Polygon', + coordinates: [ + [ + [-80, 25], + [-79, 25], + [-79, 26], + [-80, 25], + ], + ], + }, + }, + { geometry: null }, + point(500, 0), + ]), + { west: -122, south: 25, east: -79, north: 37 }, + ); + assert.equal(featureExtent([]), null); + assert.equal(featureExtent([{ geometry: { coordinates: [NaN, 1] } }]), null); +}); + +test('a set straddling the antimeridian takes the short way round', () => { + assert.deepEqual(featureExtent([point(178, -17), point(-179, -16)]), { + west: 178, + south: -17, + east: 181, + north: -16, + }); + assert.deepEqual(featureExtent([point(-170, 0), point(-160, 1)]), { + west: -170, + south: 0, + east: -160, + north: 1, + }); +}); + +test('coverage falls back to the world, a point grows to a frameable box', () => { + assert.deepEqual( + coverageExtent({ coverage: { global: true } }), + WORLD_EXTENT, + ); + assert.deepEqual(coverageExtent({ coverage: { bbox: [1, 2, 3, 4] } }), { + west: 1, + south: 2, + east: 3, + north: 4, + }); + const padded = padExtent({ west: 10, south: 20, east: 10, north: 20 }); + assert.ok(Math.abs(padded.east - padded.west - 0.05) < 1e-9); + assert.equal( + padExtent({ west: 0, south: -89, east: 1, north: 89 }).north, + 85, + ); + assert.equal(isWorldScale(WORLD_EXTENT), true); + assert.equal(isWorldScale({ west: -125, east: -66 }), false); +}); diff --git a/src/library/formats.js b/src/library/formats.js new file mode 100644 index 000000000..e603a1276 --- /dev/null +++ b/src/library/formats.js @@ -0,0 +1,453 @@ +/** + * Turn what a Data Library upstream returns into bounded GeoJSON features. + * + * Shared by the browser (browser-direct entries) and the server's generic + * proxy (proxied entries), so a dataset looks the same whichever way it is + * fetched. Pure: no fetch, no DOM. Callers own transport, caps on bytes and + * cancellation; this module owns shape, coordinate sanity and feature caps. + */ + +const GEOMETRY_TYPES = new Set([ + 'Point', + 'MultiPoint', + 'LineString', + 'MultiLineString', + 'Polygon', + 'MultiPolygon', +]); +const LAT_COLUMNS = ['lat', 'latitude', 'y']; +const LON_COLUMNS = ['lon', 'lng', 'long', 'longitude', 'x']; +const MAX_DETAIL_CHARS = 80; +const MAX_NAME_CHARS = 120; + +/** Error for an upstream body that is not the declared format. */ +export class LibraryFormatError extends Error { + constructor(message) { + super(message); + this.name = 'LibraryFormatError'; + this.code = 'malformed'; + } +} + +const validPosition = (position) => + Array.isArray(position) && + position.length >= 2 && + Number.isFinite(position[0]) && + Number.isFinite(position[1]) && + Math.abs(position[0]) <= 180 && + Math.abs(position[1]) <= 90; + +function validCoordinates(type, coordinates, depth = 0) { + if (!Array.isArray(coordinates) || depth > 4) return false; + if (type === 'Point') return validPosition(coordinates); + if (type === 'MultiPoint' || type === 'LineString') + return coordinates.length > 0 && coordinates.every(validPosition); + if (type === 'MultiLineString' || type === 'Polygon') + return ( + coordinates.length > 0 && + coordinates.every((ring) => validCoordinates('LineString', ring)) + ); + if (type === 'MultiPolygon') + return ( + coordinates.length > 0 && + coordinates.every((polygon) => validCoordinates('Polygon', polygon)) + ); + return false; +} + +/** Only real, in-range geometries survive; everything else is dropped. */ +export function validLibraryGeometry(geometry) { + return ( + Boolean(geometry) && + GEOMETRY_TYPES.has(geometry.type) && + validCoordinates(geometry.type, geometry.coordinates) + ); +} + +function cleanValue(value, max) { + if (value === null || value === undefined) return ''; + if (typeof value === 'object') return ''; + const text = String(value).replace(/\s+/g, ' ').trim(); + if (!text || text === 'null' || text === 'undefined') return ''; + return text.length > max ? `${text.slice(0, max - 1)}…` : text; +} + +function readPath(properties, path) { + let value = properties; + for (const part of String(path).split('.')) { + if (!value || typeof value !== 'object') return undefined; + value = value[part]; + } + return value; +} + +/** + * Give a feature the presentation fields the map reads: `name` for its label + * and `__gevDetails` (up to two lines) for its card. Upstream properties are + * kept as they are; feed data is never translated. + * @param {object} properties + * @param {{ label?: string, fields?: string[] }} display + * @returns {object} + */ +export function presentLibraryProperties(properties, display = {}) { + const source = + properties && typeof properties === 'object' && !Array.isArray(properties) + ? properties + : {}; + const result = { ...source }; + const label = display.label + ? cleanValue(readPath(source, display.label), MAX_NAME_CHARS) + : cleanValue(source.name ?? source.title, MAX_NAME_CHARS); + if (label) result.name = label; + else delete result.name; + const details = []; + for (const field of display.fields || []) { + const value = cleanValue(readPath(source, field), MAX_DETAIL_CHARS); + if (value) details.push(`${field.split('.').pop()}: ${value}`); + if (details.length === 2) break; + } + result.__gevDetails = details; + // The value a colorBy legend groups on, flattened so nested fields + // (EONET's categories.0.title) colour the same in every renderer. + if (display.colorBy) + result.__gevColorKey = cleanValue( + readPath(source, display.colorBy.field), + MAX_NAME_CHARS, + ); + return result; +} + +/** Highest importance a manifest rank can add (below the named/unnamed gap). */ +export const LIBRARY_RANK_MAX = 600; + +function rankValue(properties, field) { + const raw = readPath(properties, field); + if (typeof raw === 'number') return Number.isFinite(raw) ? raw : null; + if (typeof raw !== 'string' || !raw.trim()) return null; + const number = Number(raw); + if (Number.isFinite(number)) return number; + const time = Date.parse(raw); + return Number.isFinite(time) ? time : null; +} + +/** + * Order candidates by the manifest's importance field, most important first, + * so a capped dataset keeps its top features and the map's level of detail + * can prefer them. Features without a value keep their order, last. + * @param {object[]} features + * @param {{ field: string, order?: 'desc'|'asc' }} rank + * @returns {Array<{ feature: object, value: number|null }>} + */ +function rankCandidates(features, rank) { + const sign = rank.order === 'asc' ? 1 : -1; + return features + .map((feature, index) => ({ + feature, + index, + value: rankValue(feature?.properties, rank.field), + })) + .sort((a, b) => { + if (a.value === null || b.value === null) + return (a.value === null) - (b.value === null) || a.index - b.index; + return sign * (a.value - b.value) || a.index - b.index; + }); +} + +/** + * Bound and present a list of candidate features. + * @param {unknown[]} candidates + * @param {{ maxFeatures: number, display?: object }} options + * @returns {{ features: object[], dropped: number, truncated: boolean }} + */ +export function boundLibraryFeatures(candidates, { maxFeatures, display }) { + const valid = []; + let dropped = 0; + for (const candidate of candidates) { + if (validLibraryGeometry(candidate?.geometry)) valid.push(candidate); + else dropped += 1; + } + const ranked = display?.rank + ? rankCandidates(valid, display.rank) + : valid.map((feature) => ({ feature, value: null })); + const kept = ranked.slice(0, maxFeatures); + const scored = kept.filter(({ value }) => value !== null).length; + const features = kept.map(({ feature, value }, position) => { + const properties = presentLibraryProperties(feature.properties, display); + // Importance for the map's level of detail: the top feature earns the + // most, the least important ranked feature none. + if (display?.rank) + properties.__gevRank = + value === null || scored < 2 + ? 0 + : Math.round(LIBRARY_RANK_MAX * (1 - position / (scored - 1))); + return { type: 'Feature', geometry: feature.geometry, properties }; + }); + return { features, dropped, truncated: ranked.length > maxFeatures }; +} + +function geojsonCandidates(payload) { + if (Array.isArray(payload)) return payload; + if (payload?.type === 'FeatureCollection' && Array.isArray(payload.features)) + return payload.features; + if (payload?.type === 'Feature') return [payload]; + throw new LibraryFormatError('not a GeoJSON FeatureCollection'); +} + +/** Parse JSON text, reporting a format error rather than a SyntaxError. */ +export function parseLibraryJson(text) { + try { + return JSON.parse(text); + } catch { + throw new LibraryFormatError('response is not JSON'); + } +} + +/** + * Split CSV text into rows (RFC 4180: quoted fields, doubled quotes, CRLF). + * @param {string} text + * @returns {string[][]} + */ +export function parseCsvRows(text) { + const rows = []; + let row = []; + let field = ''; + let quoted = false; + const input = String(text).replace(/^/, ''); + for (let index = 0; index < input.length; index++) { + const char = input[index]; + if (quoted) { + if (char === '"') { + if (input[index + 1] === '"') { + field += '"'; + index += 1; + } else quoted = false; + } else field += char; + continue; + } + if (char === '"' && field === '') quoted = true; + else if (char === ',') { + row.push(field); + field = ''; + } else if (char === '\n' || char === '\r') { + if (char === '\r' && input[index + 1] === '\n') index += 1; + row.push(field); + if (row.some((value) => value !== '')) rows.push(row); + row = []; + field = ''; + } else field += char; + } + if (quoted) throw new LibraryFormatError('unterminated quoted CSV field'); + row.push(field); + if (row.some((value) => value !== '')) rows.push(row); + return rows; +} + +function csvCandidates(text, columns) { + const rows = parseCsvRows(text); + if (rows.length < 1) throw new LibraryFormatError('CSV has no header'); + const header = rows[0].map((name) => name.trim()); + const lower = header.map((name) => name.toLowerCase()); + const find = (explicit, fallbacks) => + explicit + ? header.indexOf(explicit) + : lower.findIndex((name) => fallbacks.includes(name)); + const latIndex = find(columns?.lat, LAT_COLUMNS); + const lonIndex = find(columns?.lon, LON_COLUMNS); + if (latIndex < 0 || lonIndex < 0) + throw new LibraryFormatError('CSV has no latitude/longitude columns'); + return rows.slice(1).map((values) => { + const properties = {}; + header.forEach((name, index) => { + if (index !== latIndex && index !== lonIndex && name) + properties[name] = values[index] ?? ''; + }); + const lat = Number.parseFloat(values[latIndex]); + const lon = Number.parseFloat(values[lonIndex]); + return { + type: 'Feature', + geometry: { type: 'Point', coordinates: [lon, lat] }, + properties, + }; + }); +} + +/** + * Point features from a JSON array of records (an API that answers + * `{ "stations": [{ "lat": …, "lng": … }] }` rather than GeoJSON). + * @param {unknown} payload Parsed JSON. + * @param {{ records?: string, columns?: { lat: string, lon: string } }} source + */ +function jsonCandidates(payload, { records, columns } = {}) { + const list = records ? readPath(payload, records) : payload; + if (!Array.isArray(list)) + throw new LibraryFormatError('JSON has no record list at source.records'); + const pick = (record, explicit, fallbacks) => { + if (explicit) return explicit; + return Object.keys(record).find((key) => + fallbacks.includes(key.toLowerCase()), + ); + }; + return list.map((record) => { + if (!record || typeof record !== 'object' || Array.isArray(record)) + return { geometry: null }; + const latKey = pick(record, columns?.lat, LAT_COLUMNS); + const lonKey = pick(record, columns?.lon, LON_COLUMNS); + const properties = {}; + for (const [key, value] of Object.entries(record)) + if (key !== latKey && key !== lonKey) properties[key] = value; + return { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [ + Number.parseFloat(record[lonKey]), + Number.parseFloat(record[latKey]), + ], + }, + properties, + }; + }); +} + +/** + * Parse one upstream body in a declared vector format. + * @param {'geojson'|'geojsonl'|'csv'|'json'|'arcgis'} format + * @param {string} text + * @param {{ columns?: { lat: string, lon: string }, records?: string }} [source] + * @returns {{ candidates: unknown[], exceededTransferLimit: boolean }} + */ +export function parseLibraryBody(format, text, source = {}) { + if (format === 'csv') + return { + candidates: csvCandidates(text, source.columns), + exceededTransferLimit: false, + }; + if (format === 'geojsonl') { + const candidates = String(text) + .split('\n') + .filter((line) => line.trim()) + .map(parseLibraryJson); + return { candidates, exceededTransferLimit: false }; + } + const payload = parseLibraryJson(text); + if (format === 'json') + return { + candidates: jsonCandidates(payload, source), + exceededTransferLimit: false, + }; + if (format === 'arcgis' && payload?.error) + throw new LibraryFormatError('ArcGIS service returned an error'); + return { + candidates: geojsonCandidates(payload), + exceededTransferLimit: + format === 'arcgis' && + (payload?.exceededTransferLimit === true || + payload?.properties?.exceededTransferLimit === true), + }; +} + +/** + * The ArcGIS FeatureServer query URL for one page. The manifest's query may + * set `where` and `outFields`; geometry is always WGS84 GeoJSON. + * @param {string} layerUrl `.../FeatureServer/` or `.../MapServer/`. + * @param {Record|undefined} query + * @param {number} offset + * @param {number} pageSize + * @returns {string} + */ +export function arcgisPageUrl( + layerUrl, + query = {}, + offset = 0, + pageSize = 2000, + bbox = null, +) { + const url = new URL(`${String(layerUrl).replace(/\/+$/, '')}/query`); + const params = new URLSearchParams({ + where: '1=1', + outFields: '*', + ...query, + outSR: '4326', + f: 'geojson', + resultOffset: String(offset), + resultRecordCount: String(pageSize), + // A view-bounded load asks for what intersects the camera's box only. + ...(bbox + ? { + geometry: bbox.map((value) => value.toFixed(6)).join(','), + geometryType: 'esriGeometryEnvelope', + inSR: '4326', + spatialRel: 'esriSpatialRelIntersects', + } + : {}), + }); + url.search = params.toString(); + return url.href; +} + +/** + * The URL to request for a non-ArcGIS dataset: the manifest URL plus its + * declared query parameters. + * @param {{ url: string, query?: Record }} source + * @returns {string} + */ +export function datasetRequestUrl(source) { + const url = new URL(source.url); + for (const [name, value] of Object.entries(source.query || {})) + url.searchParams.set(name, value); + return url.href; +} + +/** + * Fetch and normalize a whole dataset through a caller-supplied page reader, + * following ArcGIS paging. The reader returns one page's text and owns byte + * caps, redirects and cancellation. + * @param {object} entry A validated library entry (kind dataset). + * @param {(url: string, budget: { remainingBytes: number }) => Promise} readPage + * @param {{ bbox?: number[]|null }} [options] A view-bounded ArcGIS load's + * `[west, south, east, north]`. + * @returns {Promise<{ type: 'FeatureCollection', features: object[], dropped: number, truncated: boolean }>} + */ +export async function loadLibraryDataset( + entry, + readPage, + { bbox = null } = {}, +) { + const { source, display } = entry; + const budget = { remainingBytes: source.maxBytes }; + const candidates = []; + let truncated = false; + if (source.format === 'arcgis') { + for (let page = 0; page < source.maxPages; page++) { + const url = arcgisPageUrl( + source.url, + source.query, + candidates.length, + 2000, + bbox, + ); + const parsed = parseLibraryBody('arcgis', await readPage(url, budget)); + candidates.push(...parsed.candidates); + if (!parsed.exceededTransferLimit || !parsed.candidates.length) break; + if (candidates.length >= source.maxFeatures) break; + if (page === source.maxPages - 1) truncated = true; + } + } else { + const parsed = parseLibraryBody( + source.format, + await readPage(datasetRequestUrl(source), budget), + source, + ); + candidates.push(...parsed.candidates); + } + const bounded = boundLibraryFeatures(candidates, { + maxFeatures: source.maxFeatures, + display, + }); + return { + type: 'FeatureCollection', + features: bounded.features, + dropped: bounded.dropped, + truncated: truncated || bounded.truncated, + }; +} diff --git a/src/library/formats.test.mjs b/src/library/formats.test.mjs new file mode 100644 index 000000000..4337ba818 --- /dev/null +++ b/src/library/formats.test.mjs @@ -0,0 +1,282 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + arcgisPageUrl, + boundLibraryFeatures, + loadLibraryDataset, + parseCsvRows, + parseLibraryBody, + presentLibraryProperties, + validLibraryGeometry, +} from './formats.js'; +import { validateLibraryManifest } from './manifest.js'; + +const point = (lon, lat, properties = {}) => ({ + type: 'Feature', + geometry: { type: 'Point', coordinates: [lon, lat] }, + properties, +}); + +function entry(source, display = {}) { + const { + ok, + errors, + entry: value, + } = validateLibraryManifest({ + id: 'fixture-entry', + kind: 'dataset', + family: 'events', + title: 'Fixture', + provider: 'Fixture', + description: 'Fixture dataset.', + maintainer: 'test', + coverage: { global: true }, + source: { fetch: 'proxied', url: 'https://data.example.org/x', ...source }, + display, + licence: { name: 'Public domain', url: 'https://example.org/licence' }, + attribution: { text: 'Fixture' }, + status: 'community', + }); + assert.deepEqual(errors, []); + assert.ok(ok); + return value; +} + +test('only real, in-range geometries survive', () => { + assert.ok(validLibraryGeometry({ type: 'Point', coordinates: [1, 2] })); + assert.equal( + validLibraryGeometry({ type: 'Point', coordinates: [181, 2] }), + false, + ); + assert.equal( + validLibraryGeometry({ type: 'Point', coordinates: [NaN, 2] }), + false, + ); + assert.equal( + validLibraryGeometry({ type: 'GeometryCollection', geometries: [] }), + false, + ); + assert.ok( + validLibraryGeometry({ + type: 'Polygon', + coordinates: [ + [ + [0, 0], + [1, 0], + [1, 1], + [0, 0], + ], + ], + }), + ); + assert.equal( + validLibraryGeometry({ type: 'Polygon', coordinates: [[]] }), + false, + ); +}); + +test('features are capped and counted, never silently passed whole', () => { + const candidates = [point(0, 0), point(500, 0), point(1, 1), point(2, 2)]; + const result = boundLibraryFeatures(candidates, { maxFeatures: 2 }); + assert.equal(result.features.length, 2); + assert.equal(result.dropped, 1); + assert.equal(result.truncated, true); +}); + +test('the manifest label and fields become the card copy; values stay as sent', () => { + const properties = presentLibraryProperties( + { + Plant_Name: ' Grand Coulee ', + PrimSource: 'hydroelectric', + Total_MW: 6809, + nested: { a: 1 }, + }, + { label: 'Plant_Name', fields: ['PrimSource', 'Total_MW', 'nested'] }, + ); + assert.equal(properties.name, 'Grand Coulee'); + assert.deepEqual(properties.__gevDetails, [ + 'PrimSource: hydroelectric', + 'Total_MW: 6809', + ]); + assert.equal(properties.Plant_Name, ' Grand Coulee '); +}); + +test('CSV parsing follows RFC 4180 and finds lat/lon columns', () => { + assert.deepEqual(parseCsvRows('a,b\r\n"x, y","he said ""hi"""\n\n'), [ + ['a', 'b'], + ['x, y', 'he said "hi"'], + ]); + const { candidates } = parseLibraryBody( + 'csv', + 'name,Latitude,Longitude\n"Pier, 39",37.8087,-122.4098\n', + ); + assert.deepEqual(candidates[0].geometry.coordinates, [-122.4098, 37.8087]); + assert.deepEqual(candidates[0].properties, { name: 'Pier, 39' }); + assert.throws( + () => parseLibraryBody('csv', 'name,value\na,1'), + /latitude\/longitude/, + ); + assert.throws(() => parseCsvRows('"open'), /unterminated/); +}); + +test('explicit CSV columns win over the guessed ones', () => { + const { candidates } = parseLibraryBody( + 'csv', + 'y,x,lat_wgs,lon_wgs\n1,2,10,20\n', + { + columns: { lat: 'lat_wgs', lon: 'lon_wgs' }, + }, + ); + assert.deepEqual(candidates[0].geometry.coordinates, [20, 10]); +}); + +test('GeoJSON Lines and malformed bodies', () => { + const { candidates } = parseLibraryBody( + 'geojsonl', + `${JSON.stringify(point(1, 2))}\n\n${JSON.stringify(point(3, 4))}\n`, + ); + assert.equal(candidates.length, 2); + assert.throws(() => parseLibraryBody('geojson', ''), /not JSON/); + assert.throws( + () => parseLibraryBody('geojson', '{"a":1}'), + /FeatureCollection/, + ); + assert.throws( + () => parseLibraryBody('arcgis', '{"error":{"code":499}}'), + /ArcGIS service returned an error/, + ); +}); + +test('ArcGIS pages are WGS84 GeoJSON with the manifest query', () => { + const url = new URL( + arcgisPageUrl( + 'https://s.example.org/FeatureServer/0/', + { where: 'MW >= 100', f: 'json' }, + 2000, + ), + ); + assert.equal(url.pathname, '/FeatureServer/0/query'); + assert.equal(url.searchParams.get('where'), 'MW >= 100'); + assert.equal(url.searchParams.get('f'), 'geojson'); + assert.equal(url.searchParams.get('outSR'), '4326'); + assert.equal(url.searchParams.get('resultOffset'), '2000'); +}); + +test('ArcGIS paging follows exceededTransferLimit up to maxPages', async () => { + const value = entry({ + format: 'arcgis', + url: 'https://s.example.org/FeatureServer/0', + maxPages: 2, + }); + const offsets = []; + const collection = await loadLibraryDataset(value, async (url) => { + offsets.push(new URL(url).searchParams.get('resultOffset')); + return JSON.stringify({ + type: 'FeatureCollection', + properties: { exceededTransferLimit: true }, + features: [point(offsets.length, 0)], + }); + }); + assert.deepEqual(offsets, ['0', '1']); + assert.equal(collection.features.length, 2); + assert.equal(collection.truncated, true); +}); + +test('a plain dataset request carries the manifest query once', async () => { + const value = entry({ + format: 'geojson', + url: 'https://data.example.org/x?keep=1', + query: { status: 'open' }, + }); + let requested; + const collection = await loadLibraryDataset(value, async (url) => { + requested = new URL(url); + return JSON.stringify({ + type: 'FeatureCollection', + features: [point(1, 1, { title: 'Storm' })], + }); + }); + assert.equal(requested.searchParams.get('keep'), '1'); + assert.equal(requested.searchParams.get('status'), 'open'); + assert.equal(collection.features[0].properties.name, 'Storm'); + assert.equal(collection.truncated, false); +}); + +test('a manifest rank keeps the most important features and scores them for the map', () => { + const candidates = [ + point(0, 0, { Plant_Name: 'small', Total_MW: '120' }), + point(1, 1, { Plant_Name: 'none' }), + point(2, 2, { Plant_Name: 'huge', Total_MW: 6809 }), + point(3, 3, { Plant_Name: 'mid', Total_MW: 900 }), + ]; + const { features, truncated } = boundLibraryFeatures(candidates, { + maxFeatures: 3, + display: { label: 'Plant_Name', rank: { field: 'Total_MW' } }, + }); + assert.equal(truncated, true); + assert.deepEqual( + features.map(({ properties }) => [properties.name, properties.__gevRank]), + [ + ['huge', 600], + ['mid', 300], + ['small', 0], + ], + ); + const dated = boundLibraryFeatures( + [ + point(0, 0, { date: '2026-10-01T00:00:00Z' }), + point(0, 0, { date: '2026-10-20T00:00:00Z' }), + ], + { maxFeatures: 5, display: { rank: { field: 'date', order: 'desc' } } }, + ); + assert.deepEqual( + dated.features.map(({ properties }) => properties.date), + ['2026-10-20T00:00:00Z', '2026-10-01T00:00:00Z'], + ); +}); + +test('a JSON record list becomes points from named or guessed lat/lon fields', () => { + const body = JSON.stringify({ + count: 3, + stations: [ + { id: '1611400', name: 'Nawiliwili', lat: 21.9544, lng: -159.3561 }, + { id: 'x', name: 'Bad', lat: 'n/a', lng: 1 }, + 'not a record', + ], + }); + const { candidates } = parseLibraryBody('json', body, { + records: 'stations', + columns: { lat: 'lat', lon: 'lng' }, + }); + assert.deepEqual(candidates[0].geometry.coordinates, [-159.3561, 21.9544]); + assert.deepEqual(candidates[0].properties, { + id: '1611400', + name: 'Nawiliwili', + }); + const bounded = boundLibraryFeatures(candidates, { maxFeatures: 10 }); + assert.equal(bounded.features.length, 1); + assert.equal(bounded.dropped, 2); + // Without columns, lat/latitude and lon/lng/longitude are found by name. + const guessed = parseLibraryBody( + 'json', + JSON.stringify([{ Latitude: '10', Longitude: '20', name: 'A' }]), + ); + assert.deepEqual(guessed.candidates[0].geometry.coordinates, [20, 10]); + assert.throws( + () => parseLibraryBody('json', '{"items":[]}', { records: 'stations' }), + /no record list/, + ); +}); + +test('a colorBy field, nested or not, becomes the flat colour key', () => { + const properties = presentLibraryProperties( + { categories: [{ title: 'Wildfires' }], title: 'Fire' }, + { + colorBy: { + field: 'categories.0.title', + colors: { Wildfires: '#c98500' }, + }, + }, + ); + assert.equal(properties.__gevColorKey, 'Wildfires'); +}); diff --git a/src/library/layers.js b/src/library/layers.js new file mode 100644 index 000000000..68d382dc6 --- /dev/null +++ b/src/library/layers.js @@ -0,0 +1,800 @@ +/** + * Layer modules for Data Library entries. A dataset renders through the same + * GeoJSON layer as Datacenters and Dams; tiles drape on whichever imagery + * host the active map stack offers, as Recent Imagery does. + */ +import * as Cesium from 'cesium'; +import { + createLocalGeoJsonLayer, + localDatasetError, +} from '../data/localGeojsonCore.js'; +import { + NATURAL_EARTH_CREDIT, + registerDynamicCredit, +} from '../data/dataCredits.js'; +import { loadCountryOutlines, outlineRings } from './outlines.js'; +import { NO_IMAGERY_HOST, resolveImageryHost } from '../maps/imageryHost.js'; +import { msg, t } from '../i18n/index.js'; +import { libraryLayerId } from './manifest.js'; +import { coverageExtent, featureExtent } from './extent.js'; +import { RELIEF_CLASSES, metresPerPixel, terrariumToRelief } from './relief.js'; + +const DEFAULT_DATASET_COLOR = '#8ad4ff'; +const TILE_ERROR_LIMIT = 24; +const TILES_UNAVAILABLE = msg('tiles unavailable'); +/** Imagery coverage outlines: a thin bright line that reads on photos. */ +const OUTLINE_COLOR = '#ffffff'; +const OUTLINE_WIDTH_PX = 2.5; +const ZOOM_IN_HINT = msg('zoom in to load this layer'); +const VIEW_DEBOUNCE_MS = 450; +// The query window's half-size: half the camera height each way, between +// about 330 m and 5.5 km. +const VIEW_HALF_PER_METRE = 0.5; +const VIEW_MIN_HALF_DEG = 0.003; +const VIEW_MAX_HALF_DEG = 0.05; + +/** + * The row's chips. Unlike other layers' row controls they stay visible while + * the layer is off (layerPanel.js honours `whileOff`). + * Both are icon actions that also work while the layer is off: Zoom to + * layer (the controller switches the layer on first) and Remove. + * @param {() => object|null} getExtent + * @param {(() => void) | null} onZoom + * @param {(() => void) | null} onRemove + */ +function libraryRowControls(getExtent, onZoom, onRemove, legend = []) { + const chips = []; + if (typeof onZoom === 'function') + chips.push({ + id: 'zoom', + icon: 'zoom_in_map', + action: true, + whileOff: true, + label: t('Zoom to layer'), + title: t('Zoom to layer · fly to where this layer has data'), + onClick: onZoom, + }); + if (typeof onRemove === 'function') + chips.push({ + id: 'remove', + icon: 'delete', + action: true, + whileOff: true, + label: t('Remove from Data Layers'), + title: t('Remove from Data Layers · add it again from the library'), + onClick: onRemove, + }); + return legend.length ? { chips, legend } : { chips }; +} + +/** + * The row legend: for `display.colorBy` one swatch per value, labelled from + * `colorBy.labels` (else the value itself) and counted when counts are + * given; otherwise the manifest's static `display.legend` (a tile layer's + * colour scale). Labels travel with the colours, so colour is never alone. + */ +export function colorByLegend(display, counts = null) { + const colorBy = display?.colorBy; + if (!colorBy) + return (display?.legend || []).map(({ label, color }) => ({ + label, + color, + })); + const labels = colorBy.labels || {}; + // Values sharing a label are one legend group (coal, gas and oil are + // "Fossil"); the first value's colour stands for the group. + const groups = new Map(); + for (const [value, color] of Object.entries(colorBy.colors)) { + const label = labels[value] || value; + const group = groups.get(label) || { label, color, count: 0 }; + group.count += counts?.get(value) || 0; + groups.set(label, group); + } + const items = [...groups.values()]; + // Values the manifest does not colour draw in the entry's colour. + let other = 0; + for (const [value, count] of counts || []) + if (!Object.hasOwn(colorBy.colors, value)) other += count; + if (other) + items.push({ + label: t('Other'), + color: display.color || DEFAULT_DATASET_COLOR, + count: other, + }); + return counts ? items : items.map(({ label, color }) => ({ label, color })); +} + +/** Count features by their colorBy key. */ +function countColorKeys(features) { + const counts = new Map(); + for (const feature of features) { + const key = String(feature?.properties?.__gevColorKey ?? ''); + counts.set(key, (counts.get(key) || 0) + 1); + } + return counts; +} + +function escapeHtml(text) { + return String(text).replace( + /[&<>"']/g, + (char) => + ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[ + char + ], + ); +} + +/** + * The attribution credit an entry registers while it is in use. + * @param {object} entry + * @returns {{ key: string, html: string }} + */ +export function libraryCredit(entry) { + const text = escapeHtml(entry.attribution.text); + const linked = entry.attribution.url + ? `${text}` + : text; + return { + key: `library:${entry.id}`, + html: `${linked} · ${escapeHtml(entry.licence.name)}`, + }; +} + +/** + * A dataset entry as a manager layer. + * @param {object} entry Validated dataset entry. + * @param {{ services: object, source: ReturnType }} options + */ +export function createLibraryDatasetLayer( + entry, + { services, source, onZoom = null, onRemove = null }, +) { + let truncated = false; + let loaded = 0; + let extent = null; + let rowListener = null; + let legendCounts = null; + const colorBy = entry.display.colorBy || null; + const layer = createLocalGeoJsonLayer( + { + id: libraryLayerId(entry.id), + url: null, + name: entry.title, + color: entry.display.color || DEFAULT_DATASET_COLOR, + icon: entry.icon, + source: entry.provider, + labels: true, + featureColor: colorBy + ? (properties) => + colorBy.colors[String(properties?.__gevColorKey ?? '')] || null + : null, + loadFeatures: async ({ signal }) => { + const result = await source.loadDataset(entry, { signal }); + truncated = result.truncated; + loaded = result.features.length; + extent = featureExtent(result.features); + if (colorBy) legendCounts = countColorKeys(result.features); + rowListener?.(); + return result.features; + }, + }, + services, + ); + const enable = layer.enable; + const getStats = layer.getStats; + return Object.assign(layer, { + libraryEntryId: entry.id, + async enable(viewer, ...rest) { + registerDynamicCredit(viewer, libraryCredit(entry)); + return enable(viewer, ...rest); + }, + getStats() { + const stats = getStats(); + // A capped dataset reads PARTIAL ("incomplete snapshot"), never as + // the whole upstream set. + return truncated && !stats.error + ? { ...stats, partial: true, acceptedRowCount: loaded } + : stats; + }, + /** Where the loaded features are, or null before the first load. */ + getExtent: () => extent, + getRowControls: () => + libraryRowControls( + () => extent, + onZoom, + onRemove, + colorByLegend(entry.display, legendCounts), + ), + setRowControlsListener(listener) { + rowListener = typeof listener === 'function' ? listener : null; + }, + }); +} + +/** + * Draw one Terrarium tile as shaded relief on a canvas. Cesium loads imagery + * bitmaps already flipped for WebGL and flips a canvas on upload, so a bitmap + * is drawn back north-up first and the canvas goes out north-up. + */ +function renderReliefTile(image, level, y) { + const { width, height } = image; + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext('2d', { willReadFrequently: true }); + if (typeof ImageBitmap !== 'undefined' && image instanceof ImageBitmap) { + context.translate(0, height); + context.scale(1, -1); + } + context.drawImage(image, 0, 0); + context.setTransform(1, 0, 0, 1, 0, 0); + const pixels = context.getImageData(0, 0, width, height).data; + const relief = terrariumToRelief( + pixels, + width, + height, + metresPerPixel(level, y, width), + ); + context.putImageData(new ImageData(relief, width, height), 0, 0); + image.close?.(); + return canvas; +} + +/** Wrap a provider so each elevation tile it loads becomes shaded relief. */ +function asReliefProvider(provider) { + const load = provider.requestImage.bind(provider); + provider.requestImage = (x, y, level, request) => { + const pending = load(x, y, level, request); + // Undefined means throttled: Cesium asks again later. + return pending?.then((image) => renderReliefTile(image, level, y)); + }; + return provider; +} + +/** The relief legend: elevation classes in metres. */ +const reliefLegend = () => + RELIEF_CLASSES.map(({ label, color }) => ({ label: t(label), color })); + +/** colorToAlpha threshold (max channel distance, 0–1) for `noDataColor`. */ +const NO_DATA_THRESHOLD = 0.02; + +/** + * A tiles entry as a manager layer. + * @param {object} entry Validated tiles entry. + * @param {{ source: object, getTileset?: () => object|null, mapStackController?: object, cesium?: object, requestRender?: (reason: string) => void }} options + */ +export function createLibraryTileLayer( + entry, + { + source, + getTileset = () => null, + mapStackController = null, + cesium = Cesium, + requestRender = () => {}, + onZoom = null, + onRemove = null, + }, +) { + const id = libraryLayerId(entry.id); + const extent = coverageExtent(entry); + const outlineMode = entry.display.outline || null; + let outline = null; + let outlineEpoch = 0; + + function removeOutline() { + outlineEpoch += 1; + if (outline && viewer?.dataSources) + viewer.dataSources.remove(outline, true); + outline = null; + } + + /** + * Draw the covered countries' borders as ground-clamped lines, so the edge + * of the imagery is plain on the map. A failed border fetch costs only the + * outline, never the imagery. + */ + async function drawOutline() { + if (!outlineMode || !viewer) return; + const epoch = ++outlineEpoch; + try { + const rings = outlineRings( + outlineMode === 'bbox' ? [] : await loadCountryOutlines(), + entry, + ); + if (epoch !== outlineEpoch || !enabled || !rings.length) return; + const source = new cesium.CustomDataSource(`${id}:outline`); + for (const ring of rings) + source.entities.add({ + polyline: { + positions: cesium.Cartesian3.fromDegreesArray(ring.flat()), + clampToGround: true, + width: OUTLINE_WIDTH_PX, + material: cesium.Color.fromCssColorString(OUTLINE_COLOR), + }, + }); + await viewer.dataSources.add(source); + if (epoch !== outlineEpoch || !enabled) { + viewer.dataSources.remove(source, true); + return; + } + outline = source; + if (outlineMode !== 'bbox') + registerDynamicCredit(viewer, NATURAL_EARTH_CREDIT); + requestRender(`library-tiles:${entry.id}`); + } catch (error) { + console.warn(`[Library] ${entry.id} outline unavailable:`, error); + } + } + const baseLegend = + entry.source.encoding === 'terrarium' + ? reliefLegend() + : colorByLegend(entry.display); + const legend = outlineMode + ? [...baseLegend, { label: t('Coverage outline'), color: OUTLINE_COLOR }] + : baseLegend; + let viewer = null; + let enabled = false; + let imagery = null; + let collection = null; + let tileErrors = 0; + let lastUpdate = null; + let unsubscribeMap = null; + let removeErrorListener = null; + let stopKeepingOnTop = null; + + /** + * Aerial photography sits above every other drape (basemaps, weather, + * other library tiles): raise it now and whenever a layer is added above. + */ + function keepOnTop(target, layer) { + const raise = () => { + if (target.isDestroyed?.() || !target.contains?.(layer)) return; + if (target.indexOf(layer) === target.length - 1) return; + target.raiseToTop(layer); + requestRender(`library-tiles:${entry.id}`); + }; + raise(); + const off = target.layerAdded?.addEventListener?.(raise); + stopKeepingOnTop = typeof off === 'function' ? off : null; + } + + function detach() { + removeErrorListener?.(); + removeErrorListener = null; + stopKeepingOnTop?.(); + stopKeepingOnTop = null; + if (imagery && collection && !collection.isDestroyed?.()) + collection.remove(imagery, true); + imagery = null; + collection = null; + } + + function attach() { + detach(); + const host = resolveImageryHost({ viewer, tileset: getTileset() }); + if (!host.collection) return false; + const bbox = entry.source.bounded ? entry.coverage.bbox : null; + const tileSize = entry.source.tileSize ?? 256; + const template = new cesium.UrlTemplateImageryProvider({ + url: source.tileTemplate(entry), + tileWidth: tileSize, + tileHeight: tileSize, + minimumLevel: entry.source.minZoom ?? 0, + maximumLevel: entry.source.maxZoom ?? 18, + tilingScheme: new cesium.WebMercatorTilingScheme(), + credit: entry.provider, + hasAlphaChannel: true, + // A bounded entry requests tiles inside its coverage only. + ...(bbox && cesium.Rectangle + ? { rectangle: cesium.Rectangle.fromDegrees(...bbox) } + : {}), + }); + const provider = + entry.source.encoding === 'terrarium' + ? asReliefProvider(template) + : template; + const onError = (event) => { + // A 4xx is the provider saying "no imagery here" (outside its + // coverage), not a failure: do not retry it or count it. + const status = event?.error?.statusCode; + if (status >= 400 && status < 500) { + if (event) event.retry = false; + return; + } + tileErrors += 1; + }; + provider.errorEvent?.addEventListener?.(onError); + removeErrorListener = () => + provider.errorEvent?.removeEventListener?.(onError); + imagery = host.collection.addImageryProvider(provider); + imagery.alpha = entry.display.opacity ?? 1; + if (entry.display.noDataColor && cesium.Color) { + // The provider's "no imagery here" fill, drawn transparent (JPEG + // noise along the edge of the fill sits just inside the threshold). + imagery.colorToAlpha = cesium.Color.fromCssColorString( + entry.display.noDataColor, + ); + imagery.colorToAlphaThreshold = NO_DATA_THRESHOLD; + } + collection = host.collection; + if (entry.display.aerial) keepOnTop(collection, imagery); + lastUpdate = Date.now(); + return true; + } + + function followMap() { + if (!enabled) return; + attach(); + requestRender(`library-tiles:${entry.id}`); + } + + return { + id, + name: entry.title, + icon: entry.icon, + source: entry.provider, + libraryEntryId: entry.id, + updateInterval: 0, + statsRefreshInterval: 1000, + async init(nextViewer) { + viewer = nextViewer; + }, + async update() {}, + async enable(nextViewer) { + viewer = nextViewer || viewer; + enabled = true; + tileErrors = 0; + registerDynamicCredit(viewer, libraryCredit(entry)); + attach(); + const off = mapStackController?.subscribe?.(followMap); + unsubscribeMap = typeof off === 'function' ? off : null; + void drawOutline(); + requestRender(`library-tiles:${entry.id}`); + }, + async disable() { + enabled = false; + unsubscribeMap?.(); + unsubscribeMap = null; + detach(); + removeOutline(); + requestRender(`library-tiles:${entry.id}`); + }, + async destroy() { + enabled = false; + unsubscribeMap?.(); + unsubscribeMap = null; + detach(); + removeOutline(); + viewer = null; + }, + getStats() { + if (!enabled) return { count: 0, lastUpdate }; + if (!imagery) + return { + count: 0, + lastUpdate, + status: 'idle', + statusMessage: NO_IMAGERY_HOST, + }; + return { + count: 1, + // A drape has no feature count; say what the row's ON means. + countLabel: t('DRAPED'), + lastUpdate, + error: tileErrors >= TILE_ERROR_LIMIT ? TILES_UNAVAILABLE : null, + }; + }, + /** The manifest's declared coverage (the world when global). */ + getExtent: () => extent, + getRowControls: () => + libraryRowControls(() => extent, onZoom, onRemove, legend), + /** QA/test view of where the drape lives. */ + getDiagnostics() { + return { + draped: Boolean(imagery), + tileErrors, + outlineRings: outline?.entities.values.length ?? 0, + }; + }, + }; +} + +/** + * A view-bounded dataset (`source.view`): a layer too large to load whole, + * such as every US building footprint. Below `view.maxAltitudeM` it loads + * what intersects the camera's box, after the camera settles, and replaces + * the previous set only once the new one is ready; higher up it asks the + * viewer to zoom in. Polygons are clamped to the ground (and to Google 3D + * tiles), as an outline on the surface. + * @param {object} entry Validated dataset entry with `source.view`. + * @param {{ source: object, onZoom?: Function|null, onRemove?: Function|null, cesium?: object, requestRender?: (reason: string) => void, debounceMs?: number }} options + */ +export function createLibraryViewLayer( + entry, + { + source, + onZoom = null, + onRemove = null, + cesium = Cesium, + requestRender = () => {}, + debounceMs = VIEW_DEBOUNCE_MS, + }, +) { + const id = libraryLayerId(entry.id); + const { maxAltitudeM, sample } = entry.source.view; + // Zoom lands on the sample area, low enough for the layer to load. + const extent = { + ...(sample + ? { west: sample[0], south: sample[1], east: sample[2], north: sample[3] } + : coverageExtent(entry)), + maxAltitudeM, + }; + const color = cesium.Color.fromCssColorString( + entry.display.color || DEFAULT_DATASET_COLOR, + ); + const colorBy = entry.display.colorBy || null; + const colorByValue = new Map( + Object.entries(colorBy?.colors || {}).map(([value, css]) => [ + value, + cesium.Color.fromCssColorString(css), + ]), + ); + let legendCounts = null; + /** A feature's colour: its colorBy value's, else the entry's. */ + function colorOf(entity) { + if (!colorBy) return color; + const properties = entity.properties?.getValue?.( + cesium.JulianDate?.now?.(), + ); + const key = properties?.__gevColorKey ?? properties?.[colorBy.field]; + return colorByValue.get(String(key ?? '')) || color; + } + let viewer = null; + let enabled = false; + let dataSource = null; + let controller = null; + let timer = null; + let removeMoveEnd = null; + let count = 0; + let truncated = false; + let loading = false; + let error = null; + let tooHigh = false; + let lastUpdate = null; + let loadedKey = null; + + const render = () => requestRender(`library-view:${entry.id}`); + + function abort() { + controller?.abort(); + controller = null; + } + + function clear() { + if (dataSource && viewer?.dataSources) + viewer.dataSources.remove(dataSource, true); + dataSource = null; + loadedKey = null; + count = 0; + truncated = false; + legendCounts = null; + } + + /** + * The box to query: a window around the point the camera looks at, sized + * by camera height (an oblique view's visible area reaches the horizon, + * far beyond what one page can fill), clipped to what is in view. + */ + function viewBox(height) { + const scene = viewer?.scene; + const canvas = scene?.canvas; + const ellipsoid = scene?.globe?.ellipsoid ?? cesium.Ellipsoid.WGS84; + const target = + (canvas && + viewer.camera.pickEllipsoid?.( + new cesium.Cartesian2( + canvas.clientWidth / 2, + canvas.clientHeight / 2, + ), + ellipsoid, + )) || + viewer.camera.positionWC; + const center = + target && cesium.Cartographic.fromCartesian(target, ellipsoid); + if (!center) return null; + const lat = (center.latitude * 180) / Math.PI; + const lon = (center.longitude * 180) / Math.PI; + const halfLat = Math.min( + VIEW_MAX_HALF_DEG, + Math.max(VIEW_MIN_HALF_DEG, (height * VIEW_HALF_PER_METRE) / 111_320), + ); + const halfLon = halfLat / Math.max(0.2, Math.cos((lat * Math.PI) / 180)); + let box = [lon - halfLon, lat - halfLat, lon + halfLon, lat + halfLat]; + const visible = viewer.camera.computeViewRectangle?.(ellipsoid); + if (visible && visible.west < visible.east) { + const degrees = [ + visible.west, + visible.south, + visible.east, + visible.north, + ].map((radians) => (radians * 180) / Math.PI); + box = [ + Math.max(box[0], degrees[0]), + Math.max(box[1], degrees[1]), + Math.min(box[2], degrees[2]), + Math.min(box[3], degrees[3]), + ]; + } + // A view across the antimeridian or off the globe is skipped. + return box[0] < box[2] && box[1] < box[3] && box[0] >= -180 && box[2] <= 180 + ? box + : null; + } + + async function refresh() { + timer = null; + if (!enabled || !viewer) return; + const height = viewer.camera?.positionCartographic?.height; + if (!(Number.isFinite(height) && height <= maxAltitudeM)) { + tooHigh = true; + abort(); + loading = false; + clear(); + render(); + return; + } + tooHigh = false; + const box = viewBox(height); + if (!box) return; + const key = box.map((value) => value.toFixed(4)).join(','); + if (key === loadedKey && dataSource) return; + abort(); + const current = new AbortController(); + controller = current; + loading = true; + error = null; + try { + const result = await source.loadDataset(entry, { + signal: current.signal, + bbox: box, + }); + if (!enabled || current.signal.aborted) return; + const loaded = await cesium.GeoJsonDataSource.load( + { type: 'FeatureCollection', features: result.features }, + { + clampToGround: true, + fill: color.withAlpha(0.35), + stroke: color, + markerColor: color, + }, + ); + if (!enabled || current.signal.aborted) return; + loaded.name = entry.title; + for (const entity of loaded.entities.values) { + const fill = colorOf(entity); + // Ground-clamped polygons cannot draw outlines; say so up front + // instead of letting Cesium warn on every entity. + if (entity.polygon) { + entity.polygon.outline = false; + if (colorBy) entity.polygon.material = fill.withAlpha(0.45); + } + // Points draw as small dots, not Cesium's default pins: a view can + // hold thousands of them. + if (entity.billboard && cesium.PointGraphics) { + entity.billboard = undefined; + entity.point = new cesium.PointGraphics({ + pixelSize: 6, + color: fill, + outlineColor: cesium.Color.BLACK.withAlpha(0.55), + outlineWidth: 1, + heightReference: cesium.HeightReference?.CLAMP_TO_GROUND, + }); + } + } + await viewer.dataSources.add(loaded); + if (!enabled || current.signal.aborted) { + viewer.dataSources.remove(loaded, true); + return; + } + const previous = dataSource; + dataSource = loaded; + if (previous) viewer.dataSources.remove(previous, true); + count = result.features.length; + truncated = result.truncated; + if (colorBy) legendCounts = countColorKeys(result.features); + lastUpdate = Date.now(); + loadedKey = key; + } catch (caught) { + if (current.signal.aborted || caught?.name === 'AbortError') return; + error = localDatasetError(caught); + } finally { + if (controller === current) { + controller = null; + loading = false; + } + render(); + } + } + + function schedule() { + clearTimeout(timer); + timer = setTimeout(() => void refresh(), debounceMs); + } + + function stop() { + clearTimeout(timer); + timer = null; + abort(); + loading = false; + removeMoveEnd?.(); + removeMoveEnd = null; + } + + return { + id, + name: entry.title, + icon: entry.icon, + source: entry.provider, + libraryEntryId: entry.id, + updateInterval: 0, + statsRefreshInterval: 1000, + async init(nextViewer) { + viewer = nextViewer; + }, + async update() {}, + async enable(nextViewer) { + viewer = nextViewer || viewer; + enabled = true; + error = null; + registerDynamicCredit(viewer, libraryCredit(entry)); + removeMoveEnd?.(); + removeMoveEnd = null; + const moveEnd = viewer?.camera?.moveEnd; + if (moveEnd?.addEventListener) { + moveEnd.addEventListener(schedule); + removeMoveEnd = () => moveEnd.removeEventListener(schedule); + } + await refresh(); + }, + async disable() { + enabled = false; + stop(); + clear(); + render(); + }, + async destroy() { + enabled = false; + stop(); + clear(); + viewer = null; + }, + getStats() { + if (!enabled) return { count: 0, lastUpdate }; + if (tooHigh) + return { + count: 0, + lastUpdate, + status: 'zoom-in', + statusMessage: ZOOM_IN_HINT, + }; + const stats = { count, lastUpdate, loading, error }; + // One page per view: a dense view reads PARTIAL, never complete. + return truncated && !error + ? { ...stats, partial: true, acceptedRowCount: count } + : stats; + }, + /** The sample area (or coverage) for Zoom to layer. */ + getExtent: () => extent, + getRowControls: () => + libraryRowControls( + () => extent, + onZoom, + onRemove, + colorByLegend(entry.display, legendCounts), + ), + /** QA/test view of the current load. */ + getDiagnostics() { + return { loadedKey, count, tooHigh, loading }; + }, + }; +} diff --git a/src/library/ledger.json b/src/library/ledger.json new file mode 100644 index 000000000..de9dee009 --- /dev/null +++ b/src/library/ledger.json @@ -0,0 +1,105 @@ +[ + ["nasa-eonet-events", "00"], + ["gvp-holocene-volcanoes", "01"], + ["eia-power-plants", "02"], + ["nasa-black-marble", "03"], + ["openseamap-seamarks", "04"], + ["wri-global-power-plants", "05"], + ["nws-active-alerts", "06"], + ["imf-portwatch-chokepoints", "07"], + ["imf-portwatch-disruptions", "08"], + ["noaa-tide-stations", "09"], + ["fema-usa-structures", "0a"], + ["epc-england-wales", "0b"], + ["macrostrat-geology", "0c"], + ["gbif-occurrence-density", "0d"], + ["usgs-topo", "0e"], + ["usgs-hydrography", "0f"], + ["pdok-aerial-netherlands", "0g"], + ["ign-orthophotos-france", "0h"], + ["bkg-topplus-open", "0i"], + ["gsi-japan-map", "0j"], + ["gsi-japan-photo", "0k"], + ["emodnet-bathymetry", "0l"], + ["gpw-population-density", "0m"], + ["jrc-surface-water", "0n"], + ["gdacs-disaster-alerts", "0o"], + ["ntad-national-bridges", "0p"], + ["epa-superfund-sites", "0q"], + ["epa-toxic-release-facilities", "0r"], + ["swisstopo-swissimage", "0s"], + ["ak-statewide-imagery", "0t"], + ["ar-statewide-imagery", "0u"], + ["az-statewide-imagery", "0v"], + ["bangor-me", "0w"], + ["basemap-at-orthofoto", "0x"], + ["bayern-dop40", "0y"], + ["bismarck-mandan-nd", "0z"], + ["ct-statewide-imagery", "10"], + ["dakota-county-mn", "11"], + ["dc-statewide-imagery", "12"], + ["de-statewide-imagery", "13"], + ["ee-maa-amet-ortofoto", "14"], + ["geneva-sitg-ortho", "15"], + ["grand-forks-nd", "16"], + ["ia-statewide-imagery", "17"], + ["icgc-orto-catalunya", "18"], + ["id-statewide-imagery", "19"], + ["il-statewide-imagery", "1a"], + ["in-statewide-imagery", "1b"], + ["ks-statewide-imagery", "1c"], + ["ky-statewide-imagery", "1d"], + ["lake-county-in", "1e"], + ["lawrence-ks", "1f"], + ["lee-county-fl", "1g"], + ["loudoun-county-va", "1h"], + ["louisville-lojic-ky", "1i"], + ["lu-ortho-latest", "1j"], + ["ma-statewide-imagery", "1k"], + ["maris-ms", "1l"], + ["md-statewide-imagery", "1m"], + ["me-statewide-imagery", "1n"], + ["mi-statewide-imagery", "1o"], + ["mn-statewide-imagery", "1p"], + ["mountain-village-co", "1q"], + ["ms-statewide-imagery", "1r"], + ["mt-statewide-imagery", "1s"], + ["naip", "1t"], + ["nc-statewide-imagery", "1u"], + ["nctcog-tx", "1v"], + ["nd-statewide-imagery", "1w"], + ["new-orleans-rpc-la", "1x"], + ["nh-statewide-imagery", "1y"], + ["nj-statewide-imagery", "1z"], + ["nrw-dop", "20"], + ["nsw-imagery", "21"], + ["nwa-benton-washington-ar", "22"], + ["ny-statewide-imagery", "23"], + ["oakland-county-mi", "24"], + ["oh-statewide-imagery", "25"], + ["or-statewide-imagery", "26"], + ["pa-statewide-imagery", "27"], + ["pl-ortofotomapa", "28"], + ["pnoa-ma", "29"], + ["portland-metro-or", "2a"], + ["pt-dgt-ortos", "2b"], + ["ri-statewide-imagery", "2c"], + ["tx-statewide-imagery", "2d"], + ["union-county-oh", "2e"], + ["va-statewide-imagery", "2f"], + ["vlaanderen-omw", "2g"], + ["vt-statewide-imagery", "2h"], + ["wallonie-ortho", "2i"], + ["wi-statewide-imagery", "2j"], + ["wv-statewide-imagery", "2k"], + ["kartverket-admin-units", "2l"], + ["kartverket-cadastre", "2m"], + ["kartverket-depth", "2n"], + ["kartverket-hillshade", "2o"], + ["kartverket-nautical-charts", "2p"], + ["kartverket-terrain-relief", "2q"], + ["kartverket-topo-grey", "2r"], + ["kartverket-topo", "2s"], + ["kartverket-toporaster", "2t"], + ["mapterhorn-terrain", "2u"] +] diff --git a/src/library/manifest.js b/src/library/manifest.js new file mode 100644 index 000000000..5d76af235 --- /dev/null +++ b/src/library/manifest.js @@ -0,0 +1,771 @@ +/** + * Data Library manifest contract. + * + * A library entry is a pointer to a live upstream the provider hosts: a query + * API, a provider-hosted static file or a tile endpoint. Nothing a manifest + * describes is bundled in this repository, so every instance — hosted or + * self-hosted — serves the same entries without hosting any data. + * + * Pure and portable: the server, the browser and the repo scripts all import + * this module, so it loads no catalogs and touches no DOM. See + * docs/DATA-LIBRARY.md for the authoring guide. + */ + +/** The three entry kinds this build renders. */ +export const LIBRARY_KINDS = Object.freeze(['dataset', 'tiles', 'pack']); + +/** Vector formats a `dataset` entry may declare, normalized to GeoJSON. */ +export const DATASET_FORMATS = Object.freeze([ + 'geojson', + 'geojsonl', + 'csv', + 'json', + 'arcgis', + 'geoparquet', +]); + +/** Raster formats a `tiles` entry may declare. */ +export const TILE_FORMATS = Object.freeze(['xyz', 'bbox']); +/** + * What a tile's pixels hold when they are not a picture: `terrarium` is + * elevation as terrain-RGB, drawn as shaded relief by the browser. + */ +export const TILE_ENCODINGS = Object.freeze(['terrarium']); +/** A `bbox` tile template's placeholder (MapLibre's name for it). */ +export const BBOX_TOKEN = '{bbox-epsg-3857}'; +const MERCATOR_HALF_WORLD_M = 20037508.342789244; + +/** + * A tile's Web Mercator bounds as `west,south,east,north` metres. + * @returns {string} + */ +export function tileBbox3857(z, x, y) { + const size = (2 * MERCATOR_HALF_WORLD_M) / 2 ** z; + const west = -MERCATOR_HALF_WORLD_M + x * size; + const north = MERCATOR_HALF_WORLD_M - y * size; + return [west, north - size, west + size, north] + .map((value) => value.toFixed(3)) + .join(','); +} + +/** + * One tile's upstream URL from a tiles entry's template: `{z}/{x}/{y}` for + * `xyz`, the tile's EPSG:3857 bounds for `bbox` (WMS GetMap, ArcGIS export). + */ +export function libraryTileUrl(template, z, x, y) { + return template + .replaceAll(BBOX_TOKEN, tileBbox3857(z, x, y)) + .replaceAll('{z}', String(z)) + .replaceAll('{x}', String(x)) + .replaceAll('{reverseY}', String(2 ** z - 1 - y)) + .replaceAll('{y}', String(y)); +} + +/** How the browser reaches the upstream. */ +export const FETCH_MODES = Object.freeze(['browser-direct', 'proxied']); + +/** Lifecycle states; only maintainers and the health probe change them. */ +export const LIBRARY_STATUSES = Object.freeze([ + 'incubating', + 'community', + 'verified', + 'stale', + 'archived', +]); + +/** Browse filter families, matching the Data Layers panel groups. */ +export const LIBRARY_FAMILIES = Object.freeze([ + 'movement', + 'cameras', + 'infrastructure', + 'events', + 'weather', + 'environment', + 'imagery', + 'demographics', +]); + +/** Host layers a `pack` entry may extend, with the env switch grammar. */ +export const PACK_HOSTS = Object.freeze(['cctv']); + +export const LIBRARY_ID_PATTERN = /^[a-z0-9][a-z0-9-]{2,47}$/; +export const LIBRARY_LAYER_PREFIX = 'lib-'; +export const DEFAULT_CACHE_SECONDS = 3600; +export const MIN_CACHE_SECONDS = 60; +export const MAX_CACHE_SECONDS = 86_400; +export const DEFAULT_MAX_BYTES = 16 * 1024 * 1024; +export const MAX_MAX_BYTES = 32 * 1024 * 1024; +export const DEFAULT_MAX_FEATURES = 10_000; +export const MAX_MAX_FEATURES = 20_000; +export const DEFAULT_ARCGIS_PAGES = 5; +export const MAX_ARCGIS_PAGES = 10; +export const MAX_TILE_ZOOM = 22; +/** View-bounded datasets load only below this camera height range (m). */ +export const MIN_VIEW_ALTITUDE_M = 300; +export const MAX_VIEW_ALTITUDE_M = 50_000; + +const TOP_LEVEL_KEYS = new Set([ + 'id', + 'kind', + 'family', + 'title', + 'provider', + 'description', + 'icon', + 'maintainer', + 'coverage', + 'source', + 'display', + 'pack', + 'licence', + 'attribution', + 'status', + 'regionsServed', + 'reviewed', + 'imagery', +]); +const SOURCE_KEYS = new Set([ + 'format', + 'url', + 'fetch', + 'query', + 'columns', + 'records', + 'partitions', + 'view', + 'cacheSeconds', + 'maxBytes', + 'maxFeatures', + 'maxPages', + 'minZoom', + 'maxZoom', + 'bounded', + 'encoding', + 'tileSize', + 'key', +]); +const DISPLAY_KEYS = new Set([ + 'color', + 'label', + 'fields', + 'rank', + 'colorBy', + 'legend', + 'outline', + 'opacity', + 'aerial', + 'noDataColor', +]); +const COUNTRY_PATTERN = /^[A-Z]{2}$/; +const COLOR_PATTERN = /^#[0-9a-f]{6}$/i; +const ENV_PATTERN = /^[A-Z][A-Z0-9_]{2,63}$/; +const FIELD_PATTERN = /^[A-Za-z_][A-Za-z0-9_.:-]{0,63}$/; +const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; +const KEY_PARAM_PATTERN = /^[A-Za-z][A-Za-z0-9_-]{0,31}$/; + +const isObject = (value) => + Boolean(value) && typeof value === 'object' && !Array.isArray(value); +const isText = (value, max) => + typeof value === 'string' && value.trim().length > 0 && value.length <= max; + +/** + * Parse an upstream URL a manifest may name: https, no credentials, no + * fragment, a real hostname. Tile templates keep their `{z}/{x}/{y}` tokens. + * @param {unknown} value + * @returns {URL|null} + */ +export function libraryUpstreamUrl(value) { + if (typeof value !== 'string' || value.length > 2048) return null; + let url; + try { + url = new URL( + value + .replaceAll(BBOX_TOKEN, '0,0,0,0') + .replace(/\{([xyz]|reverseY)\}/g, '0'), + ); + } catch { + return null; + } + if (url.protocol !== 'https:') return null; + if (url.username || url.password || url.hash) return null; + if (!url.hostname.includes('.') || /^[\d.]+$/.test(url.hostname)) return null; + return url; +} + +/** A licence or attribution link: https, no credentials; fragments allowed. */ +function httpsDocumentUrl(value) { + if (typeof value !== 'string' || value.length > 2048) return false; + try { + const url = new URL(value); + return url.protocol === 'https:' && !url.username && !url.password; + } catch { + return false; + } +} + +function validBbox(bbox) { + return ( + Array.isArray(bbox) && + bbox.length === 4 && + bbox.every(Number.isFinite) && + bbox[0] >= -180 && + bbox[2] <= 180 && + bbox[1] >= -90 && + bbox[3] <= 90 && + bbox[0] < bbox[2] && + bbox[1] < bbox[3] + ); +} + +function checkCoverage(coverage, errors) { + if (!isObject(coverage)) { + errors.push('coverage must be an object'); + return; + } + const { global, countries, bbox } = coverage; + if (global !== undefined && typeof global !== 'boolean') + errors.push('coverage.global must be a boolean'); + if (countries !== undefined) { + if ( + !Array.isArray(countries) || + !countries.length || + countries.some((code) => !COUNTRY_PATTERN.test(code)) + ) + errors.push('coverage.countries must list ISO 3166-1 alpha-2 codes'); + } + if (bbox !== undefined) { + if (!validBbox(bbox)) + errors.push('coverage.bbox must be [west, south, east, north] degrees'); + } + if (global !== true && countries === undefined && bbox === undefined) + errors.push('coverage needs global, countries or bbox'); +} + +function boundedInteger(value, min, max) { + return Number.isInteger(value) && value >= min && value <= max; +} + +function checkSource(entry, errors) { + const { source, kind } = entry; + if (!isObject(source)) { + errors.push('source must be an object'); + return; + } + for (const key of Object.keys(source)) + if (!SOURCE_KEYS.has(key)) + errors.push(`source.${key} is not a known field`); + const formats = + kind === 'dataset' + ? DATASET_FORMATS + : kind === 'tiles' + ? TILE_FORMATS + : ['host']; + if (!formats.includes(source.format)) + errors.push(`source.format must be one of ${formats.join(', ')}`); + if (!libraryUpstreamUrl(source.url)) + errors.push('source.url must be an https URL without credentials'); + if (kind === 'pack') return; + if (!FETCH_MODES.includes(source.fetch)) + errors.push(`source.fetch must be one of ${FETCH_MODES.join(', ')}`); + if (kind === 'tiles' && typeof source.url === 'string') { + if (source.format === 'bbox') { + if (!source.url.includes(BBOX_TOKEN)) + errors.push(`source.url must contain ${BBOX_TOKEN}`); + if (/\{[xyz]\}/.test(source.url)) + errors.push('a bbox template takes no {z}/{x}/{y}'); + } else { + for (const token of ['{z}', '{x}']) + if (!source.url.includes(token)) + errors.push(`source.url must contain ${token}`); + // TMS servers count rows from the south: Cesium's {reverseY}. + if (!source.url.includes('{y}') && !source.url.includes('{reverseY}')) + errors.push('source.url must contain {y} or {reverseY}'); + } + } + if ( + source.cacheSeconds !== undefined && + !boundedInteger(source.cacheSeconds, MIN_CACHE_SECONDS, MAX_CACHE_SECONDS) + ) + errors.push( + `source.cacheSeconds must be ${MIN_CACHE_SECONDS}–${MAX_CACHE_SECONDS}`, + ); + if ( + source.maxBytes !== undefined && + !boundedInteger(source.maxBytes, 1024, MAX_MAX_BYTES) + ) + errors.push(`source.maxBytes must be at most ${MAX_MAX_BYTES}`); + if ( + source.maxFeatures !== undefined && + !boundedInteger(source.maxFeatures, 1, MAX_MAX_FEATURES) + ) + errors.push(`source.maxFeatures must be at most ${MAX_MAX_FEATURES}`); + if (source.maxPages !== undefined) { + if (source.format !== 'arcgis') + errors.push('source.maxPages applies to arcgis only'); + else if (!boundedInteger(source.maxPages, 1, MAX_ARCGIS_PAGES)) + errors.push(`source.maxPages must be 1–${MAX_ARCGIS_PAGES}`); + } + if (source.query !== undefined) { + const valid = + isObject(source.query) && + Object.entries(source.query).every( + ([name, value]) => + FIELD_PATTERN.test(name) && + typeof value === 'string' && + value.length <= 512, + ); + if (!valid) errors.push('source.query must map field names to strings'); + } + if (source.view !== undefined) { + const { view } = source; + const valid = + isObject(view) && + Object.keys(view).every((key) => + ['maxAltitudeM', 'sample'].includes(key), + ) && + boundedInteger( + view.maxAltitudeM, + MIN_VIEW_ALTITUDE_M, + MAX_VIEW_ALTITUDE_M, + ) && + (view.sample === undefined || validBbox(view.sample)); + if (!valid) + errors.push( + `source.view needs maxAltitudeM (${MIN_VIEW_ALTITUDE_M}–${MAX_VIEW_ALTITUDE_M}) and an optional sample bbox`, + ); + // View-bounded loading queries the camera's box; this build does that + // for ArcGIS layers and partitioned GeoParquet, fetched by the browser. + if ( + !['arcgis', 'geoparquet'].includes(source.format) || + source.fetch !== 'browser-direct' + ) + errors.push( + 'source.view needs format arcgis or geoparquet and fetch browser-direct', + ); + } + if (source.partitions !== undefined) { + const { partitions } = source; + const host = libraryUpstreamUrl(source.url)?.host; + const listUrl = libraryUpstreamUrl(partitions?.listUrl); + const keyBase = libraryUpstreamUrl(partitions?.keyBase); + const valid = + source.format === 'geoparquet' && + isObject(partitions) && + Object.keys(partitions).every((key) => + ['listUrl', 'keyBase'].includes(key), + ) && + listUrl && + keyBase && + listUrl.host === host && + keyBase.host === host; + if (!valid) + errors.push( + 'source.partitions needs an S3 listUrl and keyBase on the source host (geoparquet only)', + ); + // A partitioned set is far too large to load whole. + if (source.view === undefined) + errors.push('a partitioned source must be view-bounded (source.view)'); + } + if (source.records !== undefined) { + if (source.format !== 'json') + errors.push('source.records applies to json only'); + else if (!FIELD_PATTERN.test(source.records)) + errors.push('source.records must be a property path'); + } + if (source.columns !== undefined) { + const valid = + ['csv', 'json'].includes(source.format) && + isObject(source.columns) && + FIELD_PATTERN.test(source.columns.lat ?? '') && + FIELD_PATTERN.test(source.columns.lon ?? ''); + if (!valid) + errors.push( + 'source.columns needs lat and lon column names (csv or json only)', + ); + } + if (source.bounded !== undefined) { + // Request tiles only inside coverage.bbox (a provider that answers + // outside its coverage with errors, or asks for fair use). + if (kind !== 'tiles' || typeof source.bounded !== 'boolean') + errors.push('source.bounded is a boolean for tiles'); + else if (source.bounded && !entry.coverage?.bbox) + errors.push('source.bounded needs coverage.bbox'); + } + if (source.encoding !== undefined) { + if (kind !== 'tiles' || !TILE_ENCODINGS.includes(source.encoding)) + errors.push( + `source.encoding is for tiles: one of ${TILE_ENCODINGS.join(', ')}`, + ); + else if (source.format !== 'xyz') + errors.push('an encoded tiles source must be xyz'); + } + if ( + source.tileSize !== undefined && + (kind !== 'tiles' || ![256, 512].includes(source.tileSize)) + ) + errors.push('source.tileSize is 256 or 512, for tiles'); + for (const zoom of ['minZoom', 'maxZoom']) { + if (source[zoom] === undefined) continue; + if (kind !== 'tiles') errors.push(`source.${zoom} applies to tiles only`); + else if (!boundedInteger(source[zoom], 0, MAX_TILE_ZOOM)) + errors.push(`source.${zoom} must be 0–${MAX_TILE_ZOOM}`); + } + if ( + Number.isInteger(source.minZoom) && + Number.isInteger(source.maxZoom) && + source.minZoom > source.maxZoom + ) + errors.push('source.minZoom must not exceed source.maxZoom'); + if (source.key !== undefined && source.key !== null) { + const { key } = source; + const valid = + isObject(key) && + LIBRARY_ID_PATTERN.test(key.id ?? '') && + ['query', 'header'].includes(key.in) && + KEY_PARAM_PATTERN.test(key.name ?? ''); + if (!valid) errors.push('source.key needs id, in (query|header) and name'); + // A key never reaches the browser: keyed entries go through the server. + if (source.fetch !== 'proxied') + errors.push('a keyed source must use fetch: proxied'); + } +} + +function checkDisplay(entry, errors) { + const { display, kind } = entry; + if (display === undefined) return; + if (!isObject(display)) { + errors.push('display must be an object'); + return; + } + for (const key of Object.keys(display)) + if (!DISPLAY_KEYS.has(key)) + errors.push(`display.${key} is not a known field`); + if (display.color !== undefined && !COLOR_PATTERN.test(display.color)) + errors.push('display.color must be #rrggbb'); + if (display.label !== undefined && !FIELD_PATTERN.test(display.label)) + errors.push('display.label must be a property name'); + if ( + display.fields !== undefined && + (!Array.isArray(display.fields) || + display.fields.length > 4 || + display.fields.some((field) => !FIELD_PATTERN.test(field))) + ) + errors.push('display.fields must list up to four property names'); + if ( + display.rank !== undefined && + !( + isObject(display.rank) && + FIELD_PATTERN.test(display.rank.field ?? '') && + ['desc', 'asc'].includes(display.rank.order ?? 'desc') && + Object.keys(display.rank).every((key) => ['field', 'order'].includes(key)) + ) + ) + errors.push('display.rank needs a property field and order (desc|asc)'); + if (display.colorBy !== undefined) { + const { colorBy } = display; + const colors = isObject(colorBy) + ? Object.entries(colorBy.colors || {}) + : []; + const valid = + isObject(colorBy) && + Object.keys(colorBy).every((key) => + ['field', 'colors', 'labels'].includes(key), + ) && + (colorBy.labels === undefined || + (isObject(colorBy.labels) && + Object.entries(colorBy.labels).every( + ([value, label]) => + Object.hasOwn(colorBy.colors || {}, value) && isText(label, 40), + ))) && + FIELD_PATTERN.test(colorBy.field ?? '') && + colors.length > 0 && + colors.length <= 32 && + colors.every( + ([value, color]) => + value.length > 0 && value.length <= 24 && COLOR_PATTERN.test(color), + ); + if (!valid) + errors.push( + 'display.colorBy needs a field and up to 32 value → #rrggbb colours', + ); + // A legend groups values by label; it must stay readable. + const groups = new Set( + colors.map(([value]) => colorBy.labels?.[value] ?? value), + ); + if (groups.size > 12) + errors.push('display.colorBy can show at most 12 legend groups'); + } + if (display.legend !== undefined) { + const valid = + Array.isArray(display.legend) && + display.legend.length > 0 && + display.legend.length <= 16 && + display.legend.every( + (item) => + isObject(item) && + Object.keys(item).every((key) => ['label', 'color'].includes(key)) && + isText(item.label, 40) && + COLOR_PATTERN.test(item.color ?? ''), + ); + if (!valid) + errors.push('display.legend needs 1–16 { label, color: #rrggbb } items'); + if (display.colorBy) + errors.push( + 'display.legend and display.colorBy: the colorBy legend is built from colorBy', + ); + } + if (display.outline !== undefined) { + // Outline the covered countries' borders (imagery edges): `countries` + // draws every part, `coverage` only the parts inside coverage.bbox. + // `bbox` draws coverage.bbox itself: a state or city inside a country. + if (!['coverage', 'countries', 'bbox'].includes(display.outline)) + errors.push('display.outline must be coverage, countries or bbox'); + if (kind !== 'tiles') errors.push('display.outline applies to tiles only'); + if (display.outline !== 'bbox' && !entry.coverage?.countries?.length) + errors.push('display.outline needs coverage.countries'); + if (display.outline !== 'countries' && !entry.coverage?.bbox) + errors.push(`display.outline: ${display.outline} needs coverage.bbox`); + } + if (display.aerial !== undefined) { + // Aerial photography stays above every other imagery drape. + if (kind !== 'tiles' || typeof display.aerial !== 'boolean') + errors.push('display.aerial is a boolean for tiles'); + } + if (display.noDataColor !== undefined) { + // The fill a provider paints where it has no imagery (PDOK's white + // outside the Netherlands); drawn transparent so it never hides the globe. + if (kind !== 'tiles') + errors.push('display.noDataColor applies to tiles only'); + else if (!COLOR_PATTERN.test(String(display.noDataColor))) + errors.push('display.noDataColor must be #rrggbb'); + } + if ( + display.opacity !== undefined && + !( + Number.isFinite(display.opacity) && + display.opacity > 0 && + display.opacity <= 1 + ) + ) + errors.push('display.opacity must be in (0, 1]'); + if ( + kind === 'tiles' && + (display.label || display.fields || display.rank || display.colorBy) + ) + errors.push( + 'display.label, fields, rank and colorBy apply to datasets only', + ); +} + +/** What an imagery entry shows: the registry's product types. */ +export const IMAGERY_TYPES = Object.freeze([ + 'ortho', + 'satellite-mosaic', + 'oblique', + 'historic', +]); +const IMAGERY_KEYS = new Set([ + 'type', + 'resolution', + 'resolutionM', + 'trueOrtho', + 'vintage', + 'updateCycle', + 'orthoQuality', + 'registry', +]); + +/** + * `imagery`: what a photo layer is, for viewers choosing between them — + * product type, ground resolution as published and in metres, whether it is + * a true orthophoto, the years flown and how often it is refreshed. + */ +function checkImagery(raw, errors) { + const { imagery } = raw; + if (raw.kind !== 'tiles' || !isObject(imagery)) { + errors.push('imagery applies to tiles only and is an object'); + return; + } + for (const key of Object.keys(imagery)) + if (!IMAGERY_KEYS.has(key)) + errors.push(`imagery.${key} is not a known field`); + if (!IMAGERY_TYPES.includes(imagery.type)) + errors.push(`imagery.type must be one of ${IMAGERY_TYPES.join(', ')}`); + if (!isText(imagery.resolution, 64)) + errors.push( + 'imagery.resolution is the published ground resolution (≤ 64 chars)', + ); + if ( + !Number.isFinite(imagery.resolutionM) || + imagery.resolutionM <= 0 || + imagery.resolutionM > 10_000 + ) + errors.push('imagery.resolutionM is the finest resolution in metres'); + if (typeof imagery.trueOrtho !== 'boolean') + errors.push('imagery.trueOrtho is a boolean'); + for (const [key, max] of [ + ['vintage', 32], + ['updateCycle', 80], + ['orthoQuality', 120], + ]) + if (imagery[key] !== undefined && !isText(imagery[key], max)) + errors.push(`imagery.${key} must be text (≤ ${max} chars)`); + if (imagery.registry !== undefined && !httpsDocumentUrl(imagery.registry)) + errors.push('imagery.registry must be an https link'); +} + +/** + * Validate one manifest. Strict: unknown fields are errors, so a typo cannot + * silently drop a licence or a cap. + * @param {unknown} raw Parsed manifest JSON. + * @returns {{ ok: boolean, errors: string[], entry: object|null }} + */ +export function validateLibraryManifest(raw) { + const errors = []; + if (!isObject(raw)) + return { ok: false, errors: ['manifest must be an object'], entry: null }; + for (const key of Object.keys(raw)) + if (!TOP_LEVEL_KEYS.has(key)) errors.push(`${key} is not a known field`); + if (!LIBRARY_ID_PATTERN.test(raw.id ?? '')) + errors.push('id must be 3–48 lowercase letters, digits or dashes'); + if (raw.kind === 'provider') + errors.push('provider entries are not supported by this build yet'); + else if (!LIBRARY_KINDS.includes(raw.kind)) + errors.push(`kind must be one of ${LIBRARY_KINDS.join(', ')}`); + if (!LIBRARY_FAMILIES.includes(raw.family)) + errors.push(`family must be one of ${LIBRARY_FAMILIES.join(', ')}`); + if (!isText(raw.title, 60)) errors.push('title is required (≤ 60 chars)'); + if (!isText(raw.provider, 32)) + errors.push('provider is required: a short source name (≤ 32 chars)'); + if (!isText(raw.description, 240)) + errors.push('description is required (≤ 240 chars)'); + if (raw.icon !== undefined && !isText(raw.icon, 4)) + errors.push('icon must be a short glyph'); + if (!isText(raw.maintainer, 80)) errors.push('maintainer is required'); + if (!LIBRARY_STATUSES.includes(raw.status)) + errors.push(`status must be one of ${LIBRARY_STATUSES.join(', ')}`); + if (raw.reviewed !== undefined && !DATE_PATTERN.test(raw.reviewed)) + errors.push('reviewed must be YYYY-MM-DD'); + checkCoverage(raw.coverage, errors); + if (LIBRARY_KINDS.includes(raw.kind)) checkSource(raw, errors); + checkDisplay(raw, errors); + if (raw.imagery !== undefined) checkImagery(raw, errors); + if (raw.kind === 'pack') { + const pack = raw.pack; + if ( + !isObject(pack) || + !PACK_HOSTS.includes(pack.host) || + !ENV_PATTERN.test(pack.env ?? '') + ) + errors.push( + `pack needs host (${PACK_HOSTS.join(', ')}) and its env switch`, + ); + } else if (raw.pack !== undefined) errors.push('pack applies to packs only'); + const { licence, attribution } = raw; + if ( + !isObject(licence) || + !isText(licence.name, 160) || + !httpsDocumentUrl(licence.url) || + (licence.operatorOptIn !== undefined && + typeof licence.operatorOptIn !== 'boolean') + ) + errors.push('licence needs name, an https url and optional operatorOptIn'); + if (raw.kind === 'pack' && licence?.operatorOptIn !== true) + errors.push('a pack is switched by its host: licence.operatorOptIn: true'); + if ( + !isObject(attribution) || + !isText(attribution.text, 200) || + (attribution.url !== undefined && !httpsDocumentUrl(attribution.url)) + ) + errors.push('attribution needs text and an optional https url'); + if (raw.regionsServed !== undefined && raw.regionsServed !== null) { + if ( + !Array.isArray(raw.regionsServed) || + !raw.regionsServed.length || + raw.regionsServed.some((code) => !COUNTRY_PATTERN.test(code)) + ) + errors.push('regionsServed must list ISO 3166-1 alpha-2 codes or null'); + } + if (errors.length) return { ok: false, errors, entry: null }; + return { ok: true, errors: [], entry: freezeEntry(raw) }; +} + +function deepFreeze(value) { + if (value && typeof value === 'object') { + for (const child of Object.values(value)) deepFreeze(child); + Object.freeze(value); + } + return value; +} + +/** Fill defaults so every consumer reads the same resolved caps. */ +function freezeEntry(raw) { + const entry = structuredClone(raw); + if (entry.kind !== 'pack') { + entry.source.cacheSeconds ??= DEFAULT_CACHE_SECONDS; + if (entry.kind === 'dataset') { + entry.source.maxBytes ??= DEFAULT_MAX_BYTES; + entry.source.maxFeatures ??= DEFAULT_MAX_FEATURES; + if (entry.source.format === 'arcgis') + entry.source.maxPages ??= DEFAULT_ARCGIS_PAGES; + } + entry.source.key ??= null; + } + entry.display ??= {}; + entry.licence.operatorOptIn ??= false; + entry.regionsServed ??= null; + entry.icon ??= entry.kind === 'tiles' ? '▦' : '◆'; + return deepFreeze(entry); +} + +/** + * Validate a whole catalog: every entry valid, ids unique. + * @param {unknown[]} manifests + * @returns {{ ok: boolean, errors: string[], entries: object[] }} + */ +export function validateLibraryCatalog(manifests) { + const errors = []; + const entries = []; + const ids = new Set(); + for (const [index, raw] of (manifests || []).entries()) { + const label = raw?.id || `#${index}`; + const result = validateLibraryManifest(raw); + for (const error of result.errors) errors.push(`${label}: ${error}`); + if (!result.ok) continue; + if (ids.has(result.entry.id)) { + errors.push(`${label}: duplicate id`); + continue; + } + ids.add(result.entry.id); + entries.push(result.entry); + } + return { ok: errors.length === 0, errors, entries }; +} + +/** The manager id a library entry's layer registers under. */ +export function libraryLayerId(entryId) { + return `${LIBRARY_LAYER_PREFIX}${entryId}`; +} + +/** The entry id behind a library layer id, or null. */ +export function libraryEntryIdOf(layerId) { + if (typeof layerId !== 'string' || !layerId.startsWith(LIBRARY_LAYER_PREFIX)) + return null; + const id = layerId.slice(LIBRARY_LAYER_PREFIX.length); + return LIBRARY_ID_PATTERN.test(id) ? id : null; +} + +/** + * Whether the operator switched an opt-in entry on. `GEV_LIBRARY_ENABLE` + * lists opt-in ids; `GEV_LIBRARY_DISABLE` removes any entry outright. + * @param {string|undefined} value Comma-separated ids. + * @returns {Set} + */ +export function parseLibraryIdList(value) { + return new Set( + String(value || '') + .split(',') + .map((id) => id.trim()) + .filter((id) => LIBRARY_ID_PATTERN.test(id)), + ); +} diff --git a/src/library/manifest.test.mjs b/src/library/manifest.test.mjs new file mode 100644 index 000000000..f65ef75c0 --- /dev/null +++ b/src/library/manifest.test.mjs @@ -0,0 +1,242 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + libraryEntryIdOf, + libraryLayerId, + libraryUpstreamUrl, + parseLibraryIdList, + validateLibraryCatalog, + validateLibraryManifest, +} from './manifest.js'; + +const dataset = (overrides = {}) => ({ + id: 'example-points', + kind: 'dataset', + family: 'infrastructure', + title: 'Example points', + provider: 'Example', + description: 'Points from an example provider.', + maintainer: 'github:someone', + coverage: { global: true }, + source: { + format: 'geojson', + url: 'https://data.example.org/points.geojson', + fetch: 'browser-direct', + }, + licence: { + name: 'CC BY 4.0', + url: 'https://creativecommons.org/licenses/by/4.0/', + }, + attribution: { text: 'Example contributors' }, + status: 'community', + ...overrides, +}); + +test('a minimal dataset validates and gets its caps filled', () => { + const { ok, errors, entry } = validateLibraryManifest(dataset()); + assert.deepEqual(errors, []); + assert.equal(ok, true); + assert.equal(entry.source.cacheSeconds, 3600); + assert.equal(entry.source.maxFeatures, 10_000); + assert.equal(entry.source.maxBytes, 16 * 1024 * 1024); + assert.equal(entry.source.key, null); + assert.equal(entry.licence.operatorOptIn, false); + assert.ok(Object.isFrozen(entry.source)); +}); + +test('unknown fields are errors, so a typo cannot drop a cap or a licence', () => { + const result = validateLibraryManifest( + dataset({ license: {}, source: { ...dataset().source, maxFeature: 5 } }), + ); + assert.equal(result.ok, false); + assert.ok(result.errors.includes('license is not a known field')); + assert.ok(result.errors.includes('source.maxFeature is not a known field')); +}); + +test('upstream URLs must be https without credentials or fragments', () => { + assert.ok(libraryUpstreamUrl('https://a.example.org/x?y=1')); + assert.equal(libraryUpstreamUrl('http://a.example.org/x'), null); + assert.equal(libraryUpstreamUrl('https://u:p@a.example.org/x'), null); + assert.equal(libraryUpstreamUrl('https://a.example.org/x#f'), null); + assert.equal(libraryUpstreamUrl('https://127.0.0.1/x'), null); + assert.equal(libraryUpstreamUrl('https://localhost/x'), null); + assert.ok(libraryUpstreamUrl('https://t.example.org/{z}/{x}/{y}.png')); +}); + +test('a keyed source must be proxied, so a key never reaches the browser', () => { + const keyed = dataset({ + source: { + ...dataset().source, + key: { id: 'firms', in: 'query', name: 'MAP_KEY' }, + }, + }); + assert.ok( + validateLibraryManifest(keyed).errors.includes( + 'a keyed source must use fetch: proxied', + ), + ); + keyed.source.fetch = 'proxied'; + assert.equal(validateLibraryManifest(keyed).ok, true); +}); + +test('tiles need a {z}/{x}/{y} template and a sane zoom range', () => { + const tiles = { + ...dataset(), + kind: 'tiles', + source: { + format: 'xyz', + url: 'https://t.example.org/{z}/{x}.png', + fetch: 'browser-direct', + minZoom: 9, + maxZoom: 4, + }, + }; + const { errors } = validateLibraryManifest(tiles); + assert.ok(errors.includes('source.url must contain {y} or {reverseY}')); + assert.ok(errors.includes('source.minZoom must not exceed source.maxZoom')); +}); + +test('a pack names its host switch and is always operator opt-in', () => { + const pack = { + ...dataset(), + kind: 'pack', + family: 'cameras', + source: { format: 'host', url: 'https://cams.example.org/' }, + pack: { host: 'cctv', env: 'CCTV_EXAMPLE_ENABLED' }, + }; + assert.ok( + validateLibraryManifest(pack).errors.includes( + 'a pack is switched by its host: licence.operatorOptIn: true', + ), + ); + pack.licence = { ...pack.licence, operatorOptIn: true }; + assert.equal(validateLibraryManifest(pack).ok, true); + pack.pack = { host: 'radio', env: 'X' }; + assert.equal(validateLibraryManifest(pack).ok, false); +}); + +test('provider entries are refused with a clear reason until the kind ships', () => { + assert.ok( + validateLibraryManifest(dataset({ kind: 'provider' })).errors.includes( + 'provider entries are not supported by this build yet', + ), + ); +}); + +test('coverage needs global, countries or a valid bbox', () => { + assert.ok( + validateLibraryManifest(dataset({ coverage: {} })).errors.includes( + 'coverage needs global, countries or bbox', + ), + ); + assert.equal( + validateLibraryManifest(dataset({ coverage: { countries: ['us'] } })).ok, + false, + ); + assert.equal( + validateLibraryManifest(dataset({ coverage: { bbox: [10, 0, 5, 1] } })).ok, + false, + ); +}); + +test('catalog validation rejects duplicate ids', () => { + const result = validateLibraryCatalog([dataset(), dataset()]); + assert.equal(result.ok, false); + assert.equal(result.entries.length, 1); + assert.match(result.errors[0], /duplicate id/); +}); + +test('library layer ids are namespaced and reversible', () => { + assert.equal(libraryLayerId('example-points'), 'lib-example-points'); + assert.equal(libraryEntryIdOf('lib-example-points'), 'example-points'); + assert.equal(libraryEntryIdOf('cctv'), null); + assert.equal(libraryEntryIdOf('lib-X'), null); +}); + +test('operator id lists keep only well-formed ids', () => { + assert.deepEqual( + [...parseLibraryIdList(' a-b-c , ,BAD,x, cctv-nyc-dot ')], + ['a-b-c', 'cctv-nyc-dot'], + ); +}); + +test('GeoParquet partitions must stay on the source host and be view-bounded', () => { + const parquet = dataset({ + source: { + format: 'geoparquet', + url: 'https://bucket.example.org/set/', + fetch: 'browser-direct', + partitions: { + listUrl: 'https://bucket.example.org?list-type=2&prefix=set/', + keyBase: 'https://elsewhere.example.org/', + }, + }, + }); + const { errors } = validateLibraryManifest(parquet); + assert.ok(errors.some((error) => /partitions needs/.test(error))); + assert.ok( + errors.includes('a partitioned source must be view-bounded (source.view)'), + ); +}); + +test('colorBy needs valid colours and readable legend groups', () => { + const coloured = dataset({ + display: { colorBy: { field: 'rating', colors: { A: '#008054' } } }, + }); + assert.equal(validateLibraryManifest(coloured).ok, true); + coloured.display.colorBy.colors.B = 'green'; + assert.ok( + validateLibraryManifest(coloured).errors.some((error) => + /colorBy needs a field/.test(error), + ), + ); +}); + +test('a static legend needs labelled colours and cannot double a colorBy legend', () => { + const tiles = { + ...dataset(), + kind: 'tiles', + source: { + format: 'xyz', + url: 'https://t.example.org/{z}/{x}/{y}.png', + fetch: 'browser-direct', + }, + display: { legend: [{ label: '< 1', color: '#fff2d1' }] }, + }; + assert.equal(validateLibraryManifest(tiles).ok, true); + tiles.display.legend.push({ label: '', color: 'red' }); + assert.ok( + validateLibraryManifest(tiles).errors.some((error) => + /display.legend/.test(error), + ), + ); +}); + +test('aerial tiles may be bounded, kept on top and hide a no-data fill', () => { + const tiles = { + ...dataset(), + kind: 'tiles', + coverage: { countries: ['NL'], bbox: [3.2, 50.7, 7.3, 53.6] }, + source: { + format: 'xyz', + url: 'https://t.example.org/{z}/{x}/{y}.jpeg', + fetch: 'browser-direct', + bounded: true, + }, + display: { aerial: true, noDataColor: '#ffffff' }, + }; + assert.deepEqual(validateLibraryManifest(tiles).errors, []); + const broken = { + ...tiles, + coverage: { global: true }, + display: { aerial: 'yes', noDataColor: 'white' }, + }; + const { errors } = validateLibraryManifest(broken); + assert.ok(errors.includes('source.bounded needs coverage.bbox')); + assert.ok(errors.includes('display.aerial is a boolean for tiles')); + assert.ok(errors.includes('display.noDataColor must be #rrggbb')); + const points = dataset({ display: { aerial: true, noDataColor: '#ffffff' } }); + const pointErrors = validateLibraryManifest(points).errors; + assert.ok(pointErrors.includes('display.aerial is a boolean for tiles')); + assert.ok(pointErrors.includes('display.noDataColor applies to tiles only')); +}); diff --git a/src/library/outlines.js b/src/library/outlines.js new file mode 100644 index 000000000..a51a6e4ed --- /dev/null +++ b/src/library/outlines.js @@ -0,0 +1,117 @@ +/** + * Coverage outlines for Data Library imagery: the borders of the countries an + * entry covers, so the edge of an aerial photo layer is plain on the map. + * + * Borders come from Natural Earth's 1:50m admin-0 countries (public domain), + * a provider-hosted file fetched live and kept for the session, so nothing is + * bundled. Pure apart from `loadCountryOutlines`, which takes `fetchImpl`. + */ +import { readResponseTextCapped } from '../sources/httpBody.js'; + +export const COUNTRY_OUTLINES_URL = + 'https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_50m_admin_0_countries.geojson'; +/** The 1:50m file is about 3 MB. */ +const OUTLINES_MAX_BYTES = 8 * 1024 * 1024; +/** Outline modes a manifest may ask for. */ +export const OUTLINE_MODES = Object.freeze(['coverage', 'countries', 'bbox']); + +let pending = null; + +/** + * Natural Earth country features, fetched once per page. + * @param {typeof fetch} fetchImpl + * @returns {Promise} + */ +export function loadCountryOutlines( + fetchImpl = (...args) => globalThis.fetch(...args), +) { + pending ||= (async () => { + const response = await fetchImpl(COUNTRY_OUTLINES_URL, { + credentials: 'omit', + referrerPolicy: 'no-referrer', + redirect: 'error', + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const text = await readResponseTextCapped(response, OUTLINES_MAX_BYTES); + const features = JSON.parse(text)?.features; + if (!Array.isArray(features)) throw new Error('not a FeatureCollection'); + return features; + })().catch((error) => { + pending = null; + throw error; + }); + return pending; +} + +/** For tests: forget the cached outlines. */ +export function resetCountryOutlines() { + pending = null; +} + +const ringBox = (ring) => { + let west = Infinity; + let south = Infinity; + let east = -Infinity; + let north = -Infinity; + for (const [lon, lat] of ring) { + west = Math.min(west, lon); + east = Math.max(east, lon); + south = Math.min(south, lat); + north = Math.max(north, lat); + } + return [west, south, east, north]; +}; + +const intersects = (a, b) => + a[0] <= b[2] && a[2] >= b[0] && a[1] <= b[3] && a[3] >= b[1]; + +/** + * The outer rings to draw for an entry. `countries` draws every part of each + * covered country; `coverage` only the parts inside the entry's coverage bbox + * (the European Netherlands, not its Caribbean islands). + * Natural Earth gives France and Norway ISO_A2 "-99"; ISO_A2_EH carries the + * code, so both are read. + * @param {object[]} features Natural Earth admin-0 features. + * @param {object} entry Validated library entry with `display.outline`. + * @returns {Array>} + */ +export function outlineRings(features, entry) { + const box = entry?.coverage?.bbox; + if (entry?.display?.outline === 'bbox') + return box + ? [ + [ + [box[0], box[1]], + [box[2], box[1]], + [box[2], box[3]], + [box[0], box[3]], + [box[0], box[1]], + ], + ] + : []; + const codes = new Set(entry?.coverage?.countries || []); + const bbox = entry?.coverage?.bbox; + const onlyInside = entry?.display?.outline === 'coverage' && bbox; + const rings = []; + for (const feature of features || []) { + const properties = feature?.properties || {}; + const code = [properties.ISO_A2_EH, properties.ISO_A2].find( + (value) => typeof value === 'string' && codes.has(value), + ); + if (!code) continue; + const { type, coordinates } = feature.geometry || {}; + const polygons = + type === 'Polygon' + ? [coordinates] + : type === 'MultiPolygon' + ? coordinates + : []; + for (const polygon of polygons) { + const outer = polygon?.[0]; + if (!Array.isArray(outer) || outer.length < 3) continue; + if (onlyInside && !intersects(ringBox(outer), bbox)) continue; + rings.push(outer); + } + } + return rings; +} diff --git a/src/library/outlines.test.mjs b/src/library/outlines.test.mjs new file mode 100644 index 000000000..a10dbaf18 --- /dev/null +++ b/src/library/outlines.test.mjs @@ -0,0 +1,66 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { outlineRings } from './outlines.js'; +import { libraryEntry } from './catalog.js'; + +const square = (lon, lat, size = 1) => [ + [lon, lat], + [lon + size, lat], + [lon + size, lat + size], + [lon, lat + size], + [lon, lat], +]; +const country = (code, polygons, iso = code) => ({ + properties: { ISO_A2_EH: code, ISO_A2: iso }, + geometry: { + type: 'MultiPolygon', + coordinates: polygons.map((ring) => [ring]), + }, +}); +const features = [ + // Netherlands: the European part and a Caribbean island. + country('NL', [square(4, 52), square(-68.3, 12.1, 0.3)]), + // Natural Earth writes France's ISO_A2 as -99; ISO_A2_EH carries FR. + country('FR', [square(2, 46), square(-53, 4)], '-99'), + country('BE', [square(4, 50.5)]), +]; + +test('coverage outlines keep only the parts inside the declared area', () => { + const rings = outlineRings(features, libraryEntry('pdok-aerial-netherlands')); + assert.equal(rings.length, 1); + assert.equal(rings[0][0][0], 4); +}); + +test('country outlines keep every part and read the ISO_A2_EH code', () => { + const everyPart = { + coverage: { countries: ['FR'] }, + display: { outline: 'countries' }, + }; + assert.equal( + outlineRings(features, everyPart).length, + 2, + 'metropolitan France and French Guiana', + ); + // IGN is clipped to metropolitan France, so its outline is too. + const ign = outlineRings(features, libraryEntry('ign-orthophotos-france')); + assert.equal(ign.length, 1); + assert.equal(ign[0][0][0], 2); + assert.deepEqual( + outlineRings(features, libraryEntry('nasa-black-marble')), + [], + ); +}); + +test('a bbox outline is the coverage rectangle, with no border file needed', () => { + const rings = outlineRings([], libraryEntry('lawrence-ks')); + const [west, south, east, north] = libraryEntry('lawrence-ks').coverage.bbox; + assert.deepEqual(rings, [ + [ + [west, south], + [east, south], + [east, north], + [west, north], + [west, south], + ], + ]); +}); diff --git a/src/library/parquet.js b/src/library/parquet.js new file mode 100644 index 000000000..4d5e4f8dc --- /dev/null +++ b/src/library/parquet.js @@ -0,0 +1,246 @@ +/** + * GeoParquet for the Data Library: one file, or a set partitioned across an + * S3-style bucket (such as Source Cooperative), read straight from the + * provider with HTTP range requests. + * + * Portable (server and browser); the caller supplies `fetchImpl`. The + * browser imports this module lazily, so the Parquet reader costs nothing + * until a GeoParquet entry is switched on. + */ +import { parquetMetadataAsync, parquetReadObjects } from 'hyparquet'; +import { decompress as zstdDecompress } from 'fzstd'; +import { libraryUpstreamUrl } from './manifest.js'; +import { readResponseBytesCapped } from '../sources/httpBody.js'; + +/** Footer bytes read first; small files' metadata fits in one request. */ +const FOOTER_FETCH_BYTES = 16 * 1024; +/** Partitions read per view, and how many decoded ones stay in memory. */ +export const MAX_PARTITIONS_PER_VIEW = 4; +const MAX_LISTED_KEYS = 5000; +const MAX_LIST_PAGES = 20; +const INDEX_CONCURRENCY = 8; + +/** Decompressors hyparquet does not carry itself (it has Snappy built in). */ +export const PARQUET_COMPRESSORS = Object.freeze({ + ZSTD: (input) => zstdDecompress(input), +}); + +function onSourceHost(entry, url) { + const target = libraryUpstreamUrl(url); + const declared = libraryUpstreamUrl(entry.source.url); + if (!target || !declared || target.host !== declared.host) + throw Object.assign(new Error('off_manifest_host'), { status: 502 }); + return target.href; +} + +/** + * A hyparquet AsyncBuffer over HTTP range requests, redirects refused, + * credentials and referrer withheld. + */ +export function rangeBuffer(url, byteLength, { fetchImpl, signal }) { + return { + byteLength, + async slice(start, end = byteLength) { + const response = await fetchImpl(url, { + signal, + redirect: 'error', + credentials: 'omit', + referrerPolicy: 'no-referrer', + headers: { Range: `bytes=${start}-${end - 1}` }, + }); + if (response.status !== 206 && response.status !== 200) { + await response.body?.cancel?.().catch?.(() => {}); + throw new Error(`HTTP ${response.status}`); + } + const buffer = await response.arrayBuffer(); + // A server that ignores Range answers with the whole file. + return response.status === 200 && buffer.byteLength === byteLength + ? buffer.slice(start, end) + : buffer; + }, + }; +} + +/** + * List the `.parquet` objects under an S3 ListObjectsV2 URL, following + * continuation tokens. + * @returns {Promise>} + */ +export async function listPartitions(entry, { fetchImpl, signal }) { + const { listUrl } = entry.source.partitions; + const objects = []; + let token = null; + for (let page = 0; page < MAX_LIST_PAGES; page++) { + const url = new URL(onSourceHost(entry, listUrl)); + if (token) url.searchParams.set('continuation-token', token); + const response = await fetchImpl(url.href, { + signal, + redirect: 'error', + credentials: 'omit', + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const xml = await response.text(); + for (const match of xml.matchAll( + /[\s\S]*?([^<]+)<\/Key>[\s\S]*?(\d+)<\/Size>[\s\S]*?<\/Contents>/g, + )) { + if (!match[1].endsWith('.parquet')) continue; + objects.push({ key: match[1], size: Number(match[2]) }); + if (objects.length >= MAX_LISTED_KEYS) return objects; + } + token = /([^<]+)<\/NextContinuationToken>/.exec( + xml, + )?.[1]; + if (!token) break; + } + return objects; +} + +/** The primary geometry column and its bbox from GeoParquet metadata. */ +export function geoParquetInfo(metadata) { + const raw = metadata?.key_value_metadata?.find( + ({ key }) => key === 'geo', + )?.value; + if (!raw) return { column: null, bbox: null }; + try { + const geo = JSON.parse(raw); + const column = geo.primary_column || Object.keys(geo.columns || {})[0]; + const bbox = geo.columns?.[column]?.bbox; + return { + column: column || null, + bbox: + Array.isArray(bbox) && bbox.length >= 4 && bbox.every(Number.isFinite) + ? bbox.length === 6 + ? [bbox[0], bbox[1], bbox[3], bbox[4]] + : bbox.slice(0, 4) + : null, + }; + } catch { + return { column: null, bbox: null }; + } +} + +/** + * Where each partition is: list the bucket, then read each file's footer + * (one small range request) for its GeoParquet bbox. Built once and cached + * by the caller; a partition without a bbox is left out. + * @returns {Promise>} + */ +export async function buildPartitionIndex(entry, { fetchImpl, signal }) { + const { keyBase } = entry.source.partitions; + const objects = await listPartitions(entry, { fetchImpl, signal }); + const index = []; + let next = 0; + async function worker() { + while (next < objects.length) { + const { key, size } = objects[next++]; + signal?.throwIfAborted?.(); + const url = onSourceHost(entry, new URL(key, keyBase).href); + try { + const metadata = await parquetMetadataAsync( + rangeBuffer(url, size, { fetchImpl, signal }), + { initialFetchSize: FOOTER_FETCH_BYTES }, + ); + const { bbox } = geoParquetInfo(metadata); + if (bbox) index.push({ url, bbox, size }); + } catch (error) { + if (signal?.aborted) throw error; + // One unreadable partition does not sink the index. + } + } + } + await Promise.all( + Array.from({ length: Math.min(INDEX_CONCURRENCY, objects.length) }, worker), + ); + return index.sort((a, b) => a.url.localeCompare(b.url)); +} + +const overlapArea = (a, b) => + Math.max(0, Math.min(a[2], b[2]) - Math.max(a[0], b[0])) * + Math.max(0, Math.min(a[3], b[3]) - Math.max(a[1], b[1])); + +const boxArea = (box) => Math.max(1e-12, (box[2] - box[0]) * (box[3] - box[1])); + +/** + * The partitions a box touches, most specific first, at most `limit`. + * Ranked by the share of each partition's own bbox the box covers: a few + * stray geocodes can stretch an area's bbox across the country, and such a + * partition must not outrank the area actually under the camera. + */ +export function partitionsForBox(index, box, limit = MAX_PARTITIONS_PER_VIEW) { + return index + .map((partition) => { + const overlap = overlapArea(partition.bbox, box); + return { partition, overlap, score: overlap / boxArea(partition.bbox) }; + }) + .filter(({ overlap }) => overlap > 0) + .sort((a, b) => b.score - a.score) + .slice(0, limit) + .map(({ partition }) => partition); +} + +/** The columns an entry needs: its geometry plus what it labels and colours. */ +export function neededColumns(entry, geometryColumn) { + const { display } = entry; + return [ + ...new Set( + [ + geometryColumn, + display.label, + ...(display.fields || []), + display.rank?.field, + display.colorBy?.field, + ] + .filter(Boolean) + .map((field) => field.split('.')[0]), + ), + ]; +} + +/** + * Read one GeoParquet file into GeoJSON features (geometry decoded from WKB + * by hyparquet), reading only the columns the entry uses. + * @returns {Promise} + */ +export async function readGeoParquetFeatures( + entry, + url, + { fetchImpl, signal, maxBytes = entry.source.maxBytes }, +) { + const response = await fetchImpl(onSourceHost(entry, url), { + signal, + redirect: 'error', + credentials: 'omit', + referrerPolicy: 'no-referrer', + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const bytes = await readResponseBytesCapped(response, maxBytes, signal); + const file = bytes.buffer.slice( + bytes.byteOffset, + bytes.byteOffset + bytes.byteLength, + ); + const metadata = await parquetMetadataAsync({ + byteLength: file.byteLength, + slice: async (start, end) => file.slice(start, end), + }); + const { column } = geoParquetInfo(metadata); + if (!column) throw new Error('not a GeoParquet file'); + const rows = await parquetReadObjects({ + file, + metadata, + columns: neededColumns(entry, column), + compressors: PARQUET_COMPRESSORS, + }); + return rows.map((row) => { + const { [column]: geometry, ...properties } = row; + return { type: 'Feature', geometry, properties }; + }); +} + +/** Whether a point or a geometry's first position lies in a box. */ +export function featureInBox(feature, box) { + let position = feature?.geometry?.coordinates; + while (Array.isArray(position?.[0])) position = position[0]; + if (!Array.isArray(position)) return false; + const [lon, lat] = position; + return lon >= box[0] && lon <= box[2] && lat >= box[1] && lat <= box[3]; +} diff --git a/src/library/parquet.test.mjs b/src/library/parquet.test.mjs new file mode 100644 index 000000000..5608194e6 --- /dev/null +++ b/src/library/parquet.test.mjs @@ -0,0 +1,121 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + featureInBox, + geoParquetInfo, + neededColumns, + partitionsForBox, +} from './parquet.js'; +import { createLibrarySource, robustExtent } from './source.js'; +import { libraryEntry } from './catalog.js'; + +const epc = libraryEntry('epc-england-wales'); + +test('partitions rank by how specific their bbox is, not by name order', () => { + const index = [ + // A few stray geocodes stretch this area's bbox across the country. + { url: 'https://h/Barnsley.parquet', bbox: [-3, 51, 0, 54] }, + { url: 'https://h/Sheffield.parquet', bbox: [-1.6, 53.3, -1.3, 53.5] }, + { url: 'https://h/Far.parquet', bbox: [1, 52, 2, 53] }, + ]; + const picked = partitionsForBox(index, [-1.48, 53.37, -1.46, 53.385]); + assert.deepEqual( + picked.map(({ url }) => url.split('/').pop()), + ['Sheffield.parquet', 'Barnsley.parquet'], + ); +}); + +test('the GeoParquet footer gives the geometry column and its bbox', () => { + const metadata = { + key_value_metadata: [ + { + key: 'geo', + value: JSON.stringify({ + primary_column: 'geometry', + columns: { + geometry: { encoding: 'WKB', bbox: [-0.36, 50.82, -0.22, 50.86] }, + }, + }), + }, + ], + }; + assert.deepEqual(geoParquetInfo(metadata), { + column: 'geometry', + bbox: [-0.36, 50.82, -0.22, 50.86], + }); + assert.deepEqual(geoParquetInfo({ key_value_metadata: [] }), { + column: null, + bbox: null, + }); +}); + +test('only the columns the entry uses are read', () => { + assert.deepEqual(neededColumns(epc, 'geometry'), [ + 'geometry', + 'UPRN', + 'CURRENT_ENERGY_RATING', + 'POTENTIAL_ENERGY_RATING', + ]); +}); + +test('a learned extent ignores stray points', () => { + const features = Array.from({ length: 200 }, (_, index) => ({ + geometry: { type: 'Point', coordinates: [-1.4 - index / 1e4, 53.4] }, + })); + features.push({ geometry: { type: 'Point', coordinates: [-2.6, 51.36] } }); + const [west, south] = robustExtent(features); + assert.ok(west > -1.5 && south > 53, String([west, south])); + assert.equal(featureInBox(features[0], [-1.5, 53, -1.3, 54]), true); + assert.equal(featureInBox(features.at(-1), [-1.5, 53, -1.3, 54]), false); +}); + +test('a partitioned view reads candidates in batches until one finds features', async () => { + const index = [ + { url: 'https://h/A.parquet', bbox: [-1.5, 53.3, -1.4, 53.45] }, + { url: 'https://h/B.parquet', bbox: [-1.55, 53.3, -1.35, 53.5] }, + ]; + const reads = []; + const point = (lon, lat, rating) => ({ + type: 'Feature', + geometry: { type: 'Point', coordinates: [lon, lat] }, + properties: { CURRENT_ENERGY_RATING: rating, UPRN: '1' }, + }); + const store = new Map(); + const source = createLibrarySource({ + entries: [epc], + fetchImpl: async () => ({ ok: false, status: 404 }), + storage: { + getItem: (key) => store.get(key) ?? null, + setItem: (key, value) => store.set(key, value), + }, + loadParquet: async () => ({ + ...(await import('./parquet.js')), + buildPartitionIndex: async () => index, + readGeoParquetFeatures: async (entry, url) => { + reads.push(url); + return url.endsWith('A.parquet') + ? [point(-1.47, 53.38, 'C'), point(-1.2, 53.0, 'D')] + : [point(-1.47, 53.381, 'B')]; + }, + }), + }); + const box = [-1.48, 53.37, -1.46, 53.385]; + const first = await source.loadDataset(epc, { bbox: box }); + assert.equal(first.features.length, 2); + assert.deepEqual(reads.sort(), [ + 'https://h/A.parquet', + 'https://h/B.parquet', + ]); + // The index is kept in this browser; extents are learned per partition. + assert.ok(store.get('gev:library:index:epc-england-wales')); + const learned = JSON.parse(store.get('gev:library:extent:epc-england-wales')); + assert.ok(learned['https://h/A.parquet']); + // A second view of the same area is served from the decoded cache. + await source.loadDataset(epc, { bbox: box }); + assert.equal(reads.length, 2); + // Without a view box nothing is read. + assert.deepEqual(await source.loadDataset(epc), { + features: [], + truncated: false, + }); +}); diff --git a/src/library/relief.js b/src/library/relief.js new file mode 100644 index 000000000..910d8d32a --- /dev/null +++ b/src/library/relief.js @@ -0,0 +1,109 @@ +/** + * Shaded relief from Terrarium-encoded elevation tiles (Mapterhorn and the + * like): each pixel's height is decoded, coloured by elevation class and lit + * by a north-west sun, so a terrain-RGB tile reads as a map instead of as + * noise. Pure: it takes and returns RGBA pixel arrays, so it runs in node + * tests and in the browser alike. + */ +import { msg } from '../i18n/marker.js'; + +/** + * Elevation classes, low to high: one orange ramp, light to dark (validated + * as an ordinal ramp on the dark panel surface). `below` is the class's upper + * bound in metres. + */ +export const RELIEF_CLASSES = Object.freeze([ + { below: 250, color: '#fee1d6', label: msg('Below 250 m') }, + { below: 500, color: '#ffbca3', label: msg('250–500 m') }, + { below: 1000, color: '#f6936f', label: msg('500–1,000 m') }, + { below: 2000, color: '#e46d40', label: msg('1,000–2,000 m') }, + { below: 3000, color: '#c24e1d', label: msg('2,000–3,000 m') }, + { below: Infinity, color: '#983709', label: msg('3,000 m and above') }, +]); + +const EARTH_CIRCUMFERENCE_M = 40075016.686; +// A north-west sun (azimuth 315°) in the mathematical angle hillshade uses. +const SUN_ANGLE = ((360 - 315 + 90) * Math.PI) / 180; +const SUN_ALTITUDE = (45 * Math.PI) / 180; + +const hex = (value) => + [1, 3, 5].map((i) => parseInt(value.slice(i, i + 2), 16)); +const CLASS_RGB = RELIEF_CLASSES.map(({ color }) => hex(color)); + +/** Height in metres of one Terrarium pixel. */ +export function terrariumHeight(r, g, b) { + return r * 256 + g + b / 256 - 32768; +} + +/** Ground metres per pixel at a tile's centre latitude. */ +export function metresPerPixel(level, y, tileSize) { + const n = 2 ** level; + const lat = Math.atan(Math.sinh(Math.PI * (1 - (2 * (y + 0.5)) / n))); + return (EARTH_CIRCUMFERENCE_M * Math.cos(lat)) / (tileSize * n); +} + +function classIndex(height) { + for (let i = 0; i < RELIEF_CLASSES.length; i++) + if (height < RELIEF_CLASSES[i].below) return i; + return RELIEF_CLASSES.length - 1; +} + +/** + * Turn Terrarium RGBA pixels into shaded-relief RGBA pixels. Sea (encoded as + * exactly 0 m) is transparent; land below sea level keeps the lowest class. + * Slopes use Horn's method, clamping at the tile edge. + * @param {Uint8ClampedArray} input Terrarium RGBA, width × height × 4. + * @param {number} width + * @param {number} height + * @param {number} pixelMetres Ground size of one pixel. + * @returns {Uint8ClampedArray} + */ +export function terrariumToRelief(input, width, height, pixelMetres) { + const heights = new Float32Array(width * height); + for (let i = 0, p = 0; p < heights.length; i += 4, p++) + heights[p] = terrariumHeight(input[i], input[i + 1], input[i + 2]); + const at = (x, y) => + heights[ + Math.min(height - 1, Math.max(0, y)) * width + + Math.min(width - 1, Math.max(0, x)) + ]; + const out = new Uint8ClampedArray(width * height * 4); + const spacing = Math.max(pixelMetres, 1e-3) * 8; + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const p = y * width + x; + const o = p * 4; + const h = heights[p]; + if (h === 0) continue; // sea: leave transparent + const dzdx = + (at(x + 1, y - 1) + + 2 * at(x + 1, y) + + at(x + 1, y + 1) - + (at(x - 1, y - 1) + 2 * at(x - 1, y) + at(x - 1, y + 1))) / + spacing; + const dzdy = + (at(x - 1, y + 1) + + 2 * at(x, y + 1) + + at(x + 1, y + 1) - + (at(x - 1, y - 1) + 2 * at(x, y - 1) + at(x + 1, y - 1))) / + spacing; + const slope = Math.atan(Math.hypot(dzdx, dzdy)); + const aspect = Math.atan2(dzdy, -dzdx); + const shade = Math.max( + 0, + Math.cos(SUN_ALTITUDE) * Math.cos(slope) + + Math.sin(SUN_ALTITUDE) * + Math.sin(slope) * + Math.cos(SUN_ANGLE - aspect), + ); + // Flat ground keeps its class colour; lit slopes brighten, shaded ones darken. + const light = 0.55 + 0.6 * shade; + const [r, g, b] = CLASS_RGB[classIndex(h)]; + out[o] = r * light; + out[o + 1] = g * light; + out[o + 2] = b * light; + out[o + 3] = 255; + } + } + return out; +} diff --git a/src/library/relief.test.mjs b/src/library/relief.test.mjs new file mode 100644 index 000000000..fc224ac46 --- /dev/null +++ b/src/library/relief.test.mjs @@ -0,0 +1,95 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + RELIEF_CLASSES, + metresPerPixel, + terrariumHeight, + terrariumToRelief, +} from './relief.js'; +import { libraryEntry } from './catalog.js'; +import { validateLibraryManifest } from './manifest.js'; + +/** Terrarium RGB for a height in metres. */ +function encode(height) { + const value = height + 32768; + const r = Math.floor(value / 256); + const g = Math.floor(value - r * 256); + const b = Math.round((value - Math.floor(value)) * 256); + return [r, g, b, 255]; +} + +function tile(width, height, heightAt) { + const pixels = new Uint8ClampedArray(width * height * 4); + for (let y = 0; y < height; y++) + for (let x = 0; x < width; x++) + pixels.set(encode(heightAt(x, y)), (y * width + x) * 4); + return pixels; +} + +test('Terrarium pixels decode to metres', () => { + assert.equal(terrariumHeight(...encode(0)), 0); + assert.equal(terrariumHeight(...encode(4478)), 4478); + assert.equal(terrariumHeight(...encode(-432)), -432); +}); + +test('sea is transparent and flat land takes its elevation class colour', () => { + const flat = (height) => + terrariumToRelief( + tile(3, 3, () => height), + 3, + 3, + 30, + ); + assert.equal(flat(0)[3], 0, 'sea (exactly 0 m) stays transparent'); + const near = (out, hex) => { + const target = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)); + return target.every((value, i) => Math.abs(out[16 + i] - value) <= 12); + }; + assert.equal(flat(-6)[19], 255); + assert.ok( + near(flat(-6), RELIEF_CLASSES[0].color), + 'land below sea level is the lowest class', + ); + assert.ok(near(flat(600), RELIEF_CLASSES[2].color), '600 m is 500–1,000 m'); + assert.ok( + near(flat(3500), RELIEF_CLASSES[5].color), + '3,500 m is the top class', + ); +}); + +test('a north-west sun lights north-west slopes and shades south-east ones', () => { + // Height rises to the south-east: the slope faces north-west, into the sun. + const facingSun = terrariumToRelief( + tile(5, 5, (x, y) => 400 + 40 * (x + y)), + 5, + 5, + 10, + ); + const facingAway = terrariumToRelief( + tile(5, 5, (x, y) => 800 - 40 * (x + y)), + 5, + 5, + 10, + ); + const centre = 12 * 4; + assert.ok(facingSun[centre] > facingAway[centre]); +}); + +test('pixel size shrinks with zoom and latitude', () => { + assert.ok(Math.abs(metresPerPixel(0, 0, 256) - 156543 * Math.cos(0)) > 0); + assert.ok(metresPerPixel(10, 300, 512) < metresPerPixel(9, 150, 512)); + const equator = metresPerPixel(4, 7.5, 512); + assert.ok(Math.abs(equator - 40075016.686 / (512 * 16)) / equator < 0.01); +}); + +test('only xyz tiles may declare an encoding and a 512 px tile size', () => { + const mapterhorn = libraryEntry('mapterhorn-terrain'); + assert.equal(mapterhorn.source.encoding, 'terrarium'); + assert.equal(mapterhorn.source.tileSize, 512); + const raw = structuredClone(mapterhorn); + raw.source.encoding = 'mapbox'; + raw.source.tileSize = 300; + const { errors } = validateLibraryManifest(raw); + assert.ok(errors.some((e) => e.startsWith('source.encoding is for tiles'))); + assert.ok(errors.includes('source.tileSize is 256 or 512, for tiles')); +}); diff --git a/src/library/selection.js b/src/library/selection.js new file mode 100644 index 000000000..513cedf4b --- /dev/null +++ b/src/library/selection.js @@ -0,0 +1,134 @@ +/** + * Which library entries a viewer has added and switched on, and how that + * travels in a share link. + * + * Library selections never use the core layer-token ledger. They persist in + * this browser under `gev:library:v1` and travel as `lib=.` with the + * two-character tokens from `ledger.json`. Tokens are permanent: a removed + * entry keeps its row so published links never change meaning. + */ +import { LIBRARY_ID_PATTERN } from './manifest.js'; + +export const LIBRARY_STORAGE_KEY = 'gev:library:v1'; +export const LIBRARY_SHARE_PARAM = 'lib'; +export const LIBRARY_TOKEN_PATTERN = /^[0-9a-z]{2}$/; +const MAX_SELECTED = 64; +const MAX_PARAM_CHARS = 3 * MAX_SELECTED; + +/** + * Validate the ledger: unique ids, unique two-character tokens, in order. + * @param {Array<[string, string]>} rows + * @returns {{ ok: boolean, errors: string[], tokenById: Map, idByToken: Map }} + */ +export function readLibraryLedger(rows) { + const errors = []; + const tokenById = new Map(); + const idByToken = new Map(); + for (const [index, row] of (rows || []).entries()) { + const [id, token] = Array.isArray(row) ? row : []; + if (!LIBRARY_ID_PATTERN.test(id ?? '')) + errors.push(`row ${index}: invalid id`); + else if (!LIBRARY_TOKEN_PATTERN.test(token ?? '')) + errors.push(`row ${index}: token must be two base-36 characters`); + else if (tokenById.has(id)) errors.push(`row ${index}: duplicate id ${id}`); + else if (idByToken.has(token)) + errors.push(`row ${index}: duplicate token ${token}`); + else { + tokenById.set(id, token); + idByToken.set(token, id); + } + } + return { ok: errors.length === 0, errors, tokenById, idByToken }; +} + +/** + * The next free token after the ledger's highest one ('00', '01', … 'zz'). + * @param {Array<[string, string]>} rows + * @returns {string|null} + */ +export function nextLibraryToken(rows) { + const used = (rows || []).map(([, token]) => Number.parseInt(token, 36)); + const next = used.length ? Math.max(...used) + 1 : 0; + if (next >= 36 * 36) return null; + return next.toString(36).padStart(2, '0'); +} + +function cleanIds(ids, allowed) { + const result = []; + for (const id of Array.isArray(ids) ? ids : []) { + if (typeof id !== 'string' || !LIBRARY_ID_PATTERN.test(id)) continue; + if (allowed && !allowed.has(id)) continue; + if (!result.includes(id)) result.push(id); + if (result.length === MAX_SELECTED) break; + } + return result; +} + +/** + * Normalize a stored or decoded selection. `on` is always a subset of + * `added`. + * @param {unknown} raw + * @param {Set} [allowed] Known entry ids. + * @returns {{ added: string[], on: string[] }} + */ +export function normalizeLibrarySelection(raw, allowed) { + const added = cleanIds(raw?.added, allowed); + const on = cleanIds(raw?.on, allowed).filter((id) => added.includes(id)); + return { added, on }; +} + +/** Read this browser's selection; an unreadable store reads as empty. */ +export function readStoredLibrarySelection(storage, allowed) { + try { + const text = storage?.getItem?.(LIBRARY_STORAGE_KEY); + return normalizeLibrarySelection(text ? JSON.parse(text) : null, allowed); + } catch { + return { added: [], on: [] }; + } +} + +/** Write this browser's selection; a refused write is not an error. */ +export function writeStoredLibrarySelection(storage, selection) { + try { + storage?.setItem?.( + LIBRARY_STORAGE_KEY, + JSON.stringify(normalizeLibrarySelection(selection)), + ); + return true; + } catch { + return false; + } +} + +/** + * Encode the switched-on entries as `lib=`; removes the param when none. + * @param {URLSearchParams} params + * @param {string[]} onIds + * @param {Map} tokenById + */ +export function encodeLibraryParam(params, onIds, tokenById) { + const tokens = cleanIds(onIds) + .map((id) => tokenById.get(id)) + .filter(Boolean); + if (tokens.length) params.set(LIBRARY_SHARE_PARAM, tokens.join('.')); + else params.delete(LIBRARY_SHARE_PARAM); +} + +/** + * Decode `lib=`. Unknown tokens are skipped, not fatal: a link made on a + * newer build still restores the entries this build knows. + * @param {URLSearchParams} params + * @param {Map} idByToken + * @returns {string[]|null} Entry ids, or null when the link has no `lib`. + */ +export function decodeLibraryParam(params, idByToken) { + const value = params?.get?.(LIBRARY_SHARE_PARAM); + if (value === null || value === undefined) return null; + if (value.length > MAX_PARAM_CHARS) return []; + return cleanIds( + value + .split('.') + .filter((token) => LIBRARY_TOKEN_PATTERN.test(token)) + .map((token) => idByToken.get(token)), + ); +} diff --git a/src/library/selection.test.mjs b/src/library/selection.test.mjs new file mode 100644 index 000000000..3ab8b9e0f --- /dev/null +++ b/src/library/selection.test.mjs @@ -0,0 +1,96 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + LIBRARY_STORAGE_KEY, + decodeLibraryParam, + encodeLibraryParam, + nextLibraryToken, + normalizeLibrarySelection, + readLibraryLedger, + readStoredLibrarySelection, + writeStoredLibrarySelection, +} from './selection.js'; + +const rows = [ + ['nasa-eonet-events', '00'], + ['eia-power-plants', '01'], +]; + +test('the ledger maps ids to two-character tokens both ways', () => { + const ledger = readLibraryLedger(rows); + assert.equal(ledger.ok, true); + assert.equal(ledger.tokenById.get('eia-power-plants'), '01'); + assert.equal(ledger.idByToken.get('00'), 'nasa-eonet-events'); + assert.equal(readLibraryLedger([...rows, ['other-entry', '01']]).ok, false); + assert.equal(readLibraryLedger([['bad-token', 'a']]).ok, false); + assert.equal(nextLibraryToken(rows), '02'); + assert.equal(nextLibraryToken([]), '00'); + assert.equal(nextLibraryToken([['x-y-z', 'zz']]), null); +}); + +test('lib= round-trips the switched-on entries and skips unknown tokens', () => { + const ledger = readLibraryLedger(rows); + const params = new URLSearchParams('lat=1'); + encodeLibraryParam( + params, + ['eia-power-plants', 'nasa-eonet-events', 'missing-entry'], + ledger.tokenById, + ); + assert.equal(params.get('lib'), '01.00'); + assert.deepEqual(decodeLibraryParam(params, ledger.idByToken), [ + 'eia-power-plants', + 'nasa-eonet-events', + ]); + assert.deepEqual( + decodeLibraryParam(new URLSearchParams('lib=zz.01.!!'), ledger.idByToken), + ['eia-power-plants'], + ); + assert.equal( + decodeLibraryParam(new URLSearchParams('lat=1'), ledger.idByToken), + null, + ); + encodeLibraryParam(params, [], ledger.tokenById); + assert.equal(params.has('lib'), false); +}); + +test('a selection keeps on within added and drops unknown ids', () => { + const allowed = new Set(['a-entry', 'b-entry']); + assert.deepEqual( + normalizeLibrarySelection( + { + added: ['a-entry', 'a-entry', 'zzz-unknown', 7], + on: ['b-entry', 'a-entry'], + }, + allowed, + ), + { added: ['a-entry'], on: ['a-entry'] }, + ); +}); + +test('storage failures read as empty and never throw', () => { + const throwing = { + getItem() { + throw new Error('blocked'); + }, + setItem() { + throw new Error('blocked'); + }, + }; + assert.deepEqual(readStoredLibrarySelection(throwing), { added: [], on: [] }); + assert.equal( + writeStoredLibrarySelection(throwing, { added: ['a-entry'] }), + false, + ); + const store = new Map(); + const storage = { + getItem: (key) => store.get(key) ?? null, + setItem: (key, value) => store.set(key, value), + }; + writeStoredLibrarySelection(storage, { added: ['a-entry'], on: ['a-entry'] }); + assert.deepEqual(JSON.parse(store.get(LIBRARY_STORAGE_KEY)), { + added: ['a-entry'], + on: ['a-entry'], + }); + store.set(LIBRARY_STORAGE_KEY, '{not json'); + assert.deepEqual(readStoredLibrarySelection(storage), { added: [], on: [] }); +}); diff --git a/src/library/source.js b/src/library/source.js new file mode 100644 index 000000000..8e15ec0d0 --- /dev/null +++ b/src/library/source.js @@ -0,0 +1,341 @@ +/** + * The browser's side of the Data Library: what this instance offers, and how + * an entry's data reaches the page. + * + * Browser-direct entries are fetched from their upstream without cookies or + * a referrer; proxied entries go through `/api/library`, which names entries + * by id only. A static build with no server still serves every browser-direct + * entry that is not operator opt-in. + */ +import { loadLibraryDataset, boundLibraryFeatures } from './formats.js'; +import { readResponseTextCapped } from '../sources/httpBody.js'; +import { BBOX_TOKEN } from './manifest.js'; + +/** Partitions read per view before giving up, in parallel batches. */ +const MAX_PARTITION_READS_PER_VIEW = 8; +const PARTITION_BATCH = 4; +/** Decoded partitions kept in memory, most recent first. */ +const PARTITION_CACHE_LIMIT = 4; +const INDEX_STORAGE_PREFIX = 'gev:library:index:'; +const EXTENT_STORAGE_PREFIX = 'gev:library:extent:'; + +const overlaps = (a, b) => + a[0] < b[2] && a[2] > b[0] && a[1] < b[3] && a[3] > b[1]; + +/** + * Where a partition's points really are: the box holding 98 % of them, so a + * few stray geocodes do not stretch it across the country. + */ +export function robustExtent(features) { + const lons = []; + const lats = []; + for (const feature of features) { + let position = feature?.geometry?.coordinates; + while (Array.isArray(position?.[0])) position = position[0]; + if (Array.isArray(position)) { + lons.push(position[0]); + lats.push(position[1]); + } + } + if (!lons.length) return null; + const at = (values, share) => { + values.sort((a, b) => a - b); + return values[ + Math.min(values.length - 1, Math.floor(share * values.length)) + ]; + }; + return [at(lons, 0.01), at(lats, 0.01), at(lons, 0.99), at(lats, 0.99)]; +} + +function storedJson(storage, key) { + try { + const text = storage?.getItem?.(key); + return text ? JSON.parse(text) : null; + } catch { + return null; + } +} + +function storeJson(storage, key, value) { + try { + storage?.setItem?.(key, JSON.stringify(value)); + } catch { + /* a full or blocked store only costs a rebuild later */ + } +} + +export const LIBRARY_API_BASE = '/api/library'; + +/** Reason codes the server or this module can report for an entry. */ +export const LIBRARY_UNAVAILABLE_REASONS = Object.freeze([ + 'operator-disabled', + 'operator-opt-in', + 'key-missing', + 'server-unavailable', +]); + +/** + * Availability when no library server answers: browser-direct entries work, + * proxied entries and anything the operator must switch on do not. + * @param {object[]} entries + * @returns {Map} + */ +export function staticLibraryAvailability(entries) { + return new Map( + entries.map((entry) => { + let reason = null; + if (entry.kind === 'pack' || entry.licence.operatorOptIn) + reason = 'operator-opt-in'; + else if (entry.source.fetch === 'proxied') reason = 'server-unavailable'; + return [entry.id, { available: reason === null, reason }]; + }), + ); +} + +/** + * @param {{ entries: object[], fetchImpl?: typeof fetch, base?: string }} options + */ +export function createLibrarySource({ + entries, + fetchImpl = (...args) => globalThis.fetch(...args), + base = LIBRARY_API_BASE, + storage = (() => { + try { + return globalThis.localStorage ?? null; + } catch { + return null; + } + })(), + now = () => Date.now(), + loadParquet = () => import('./parquet.js'), +}) { + const known = new Set(entries.map(({ id }) => id)); + const indexes = new Map(); + const partitionCache = new Map(); + + /** + * A partitioned entry's index: from this app's server (built once and + * shared), else built here and kept in this browser for `cacheSeconds`. + */ + function partitionIndex(entry, { signal } = {}) { + if (!indexes.has(entry.id)) + indexes.set( + entry.id, + (async () => { + try { + const response = await fetchImpl( + `${base}/index/${encodeURIComponent(entry.id)}`, + { signal, headers: { Accept: 'application/json' } }, + ); + if (response.ok) { + const body = await response.json(); + if (Array.isArray(body?.partitions)) return body.partitions; + } + } catch (error) { + if (signal?.aborted) throw error; + } + const key = `${INDEX_STORAGE_PREFIX}${entry.id}`; + const stored = storedJson(storage, key); + if ( + stored && + Array.isArray(stored.partitions) && + now() - stored.savedAt < entry.source.cacheSeconds * 1000 + ) + return stored.partitions; + const { buildPartitionIndex } = await loadParquet(); + const partitions = await buildPartitionIndex(entry, { + fetchImpl, + signal, + }); + storeJson(storage, key, { savedAt: now(), partitions }); + return partitions; + })().catch((error) => { + indexes.delete(entry.id); + throw error; + }), + ); + return indexes.get(entry.id); + } + + /** One partition's features, decoded once and kept for a few views. */ + async function partitionFeatures(entry, partition, { signal }) { + const cached = partitionCache.get(partition.url); + if (cached) { + partitionCache.delete(partition.url); + partitionCache.set(partition.url, cached); + return cached; + } + const { readGeoParquetFeatures } = await loadParquet(); + const features = await readGeoParquetFeatures(entry, partition.url, { + fetchImpl, + signal, + }); + partitionCache.set(partition.url, features); + while (partitionCache.size > PARTITION_CACHE_LIMIT) + partitionCache.delete(partitionCache.keys().next().value); + // Learn where this partition really is, for every later view. + const extent = robustExtent(features); + if (extent) { + const key = `${EXTENT_STORAGE_PREFIX}${entry.id}`; + const learned = storedJson(storage, key) || {}; + learned[partition.url] = extent; + storeJson(storage, key, learned); + } + return features; + } + + /** + * The features of a partitioned GeoParquet entry inside a box. Candidates + * come from the index, ranked by the partitions' footer bboxes; those whose + * learned extent misses the box are skipped; the rest are read in parallel + * batches until one batch finds features in view. + */ + async function loadPartitioned(entry, box, { signal }) { + const [{ partitionsForBox, featureInBox }, index] = await Promise.all([ + loadParquet(), + partitionIndex(entry, { signal }), + ]); + const learned = + storedJson(storage, `${EXTENT_STORAGE_PREFIX}${entry.id}`) || {}; + const candidates = partitionsForBox(index, box, Infinity) + .filter(({ url }) => !learned[url] || overlaps(learned[url], box)) + .slice(0, MAX_PARTITION_READS_PER_VIEW); + const inView = []; + for (let start = 0; start < candidates.length; start += PARTITION_BATCH) { + const batch = candidates.slice(start, start + PARTITION_BATCH); + const results = await Promise.all( + batch.map((partition) => + partitionFeatures(entry, partition, { signal }), + ), + ); + signal?.throwIfAborted?.(); + let found = false; + for (const features of results) { + const hits = features.filter((feature) => featureInBox(feature, box)); + if (hits.length) { + found = true; + inView.push(...hits); + } + } + if (found) break; + } + const bounded = boundLibraryFeatures(inView, { + maxFeatures: entry.source.maxFeatures, + display: entry.display, + }); + return { features: bounded.features, truncated: bounded.truncated }; + } + + /** + * What this instance can serve. Falls back to the static rule when the + * server has no library route (a static build) or does not answer. + * @param {{ signal?: AbortSignal }} [options] + * @returns {Promise<{ server: boolean, availability: Map }>} + */ + async function getAvailability({ signal } = {}) { + try { + const response = await fetchImpl(`${base}/catalog`, { + signal, + headers: { Accept: 'application/json' }, + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const body = await response.json(); + const availability = staticLibraryAvailability(entries); + for (const row of Array.isArray(body?.entries) ? body.entries : []) { + if (!known.has(row?.id)) continue; + availability.set(row.id, { + available: row.available === true, + reason: row.available === true ? null : String(row.reason || ''), + }); + } + return { server: true, availability }; + } catch (error) { + if (signal?.aborted) throw error; + return { + server: false, + availability: staticLibraryAvailability(entries), + }; + } + } + + /** + * Load a dataset entry's features. + * @param {object} entry + * @param {{ signal?: AbortSignal, bbox?: number[]|null }} [options] `bbox` + * bounds a view-bounded ArcGIS entry to the camera's box. + * @returns {Promise<{ features: object[], truncated: boolean }>} + */ + async function loadDataset(entry, { signal, bbox = null } = {}) { + if (entry.source.format === 'geoparquet') { + if (entry.source.partitions) { + if (!bbox) return { features: [], truncated: false }; + return loadPartitioned(entry, bbox, { signal }); + } + const { readGeoParquetFeatures, featureInBox } = await loadParquet(); + const features = await readGeoParquetFeatures(entry, entry.source.url, { + fetchImpl, + signal, + }); + const bounded = boundLibraryFeatures( + bbox + ? features.filter((feature) => featureInBox(feature, bbox)) + : features, + { maxFeatures: entry.source.maxFeatures, display: entry.display }, + ); + return { features: bounded.features, truncated: bounded.truncated }; + } + if (entry.source.fetch === 'proxied') { + const response = await fetchImpl( + `${base}/data/${encodeURIComponent(entry.id)}`, + { signal, headers: { Accept: 'application/json' } }, + ); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const body = await response.json(); + // The server already bounded the set; bound again so a page never + // trusts more than its manifest allows. + const bounded = boundLibraryFeatures(body?.features || [], { + maxFeatures: entry.source.maxFeatures, + display: entry.display, + }); + return { + features: bounded.features, + truncated: Boolean(body?.truncated) || bounded.truncated, + }; + } + const collection = await loadLibraryDataset( + entry, + async (url, budget) => { + const response = await fetchImpl(url, { + signal, + mode: 'cors', + credentials: 'omit', + referrerPolicy: 'no-referrer', + redirect: 'error', + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const text = await readResponseTextCapped( + response, + budget.remainingBytes, + signal, + ); + budget.remainingBytes -= new TextEncoder().encode(text).byteLength; + return text; + }, + { bbox }, + ); + return { features: collection.features, truncated: collection.truncated }; + } + + /** The tile URL template the imagery provider requests. */ + function tileTemplate(entry) { + if (entry.source.fetch === 'proxied') + return `${base}/tile/${encodeURIComponent(entry.id)}/{z}/{x}/{y}`; + // Cesium fills a tile's projected corners itself. + return entry.source.url.replaceAll( + BBOX_TOKEN, + '{westProjected},{southProjected},{eastProjected},{northProjected}', + ); + } + + return { getAvailability, loadDataset, tileTemplate }; +} diff --git a/src/library/tileLayer.test.mjs b/src/library/tileLayer.test.mjs new file mode 100644 index 000000000..c50df4b9a --- /dev/null +++ b/src/library/tileLayer.test.mjs @@ -0,0 +1,135 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { createLibraryTileLayer } from './layers.js'; +import { LIBRARY_ENTRIES, libraryEntry } from './catalog.js'; +import { libraryTileUrl } from './manifest.js'; +import { createLibrarySource } from './source.js'; + +/** A tiles entry without its border outline, so no network is touched. */ +function entryWithoutOutline(id) { + const entry = libraryEntry(id); + const { outline: _outline, ...display } = entry.display; + return { ...entry, display }; +} + +function globeViewer() { + return { + scene: { globe: { show: true } }, + imageryLayers: new Cesium.ImageryLayerCollection(), + dataSources: { add: async () => {}, remove: () => {} }, + }; +} + +const source = { tileTemplate: (entry) => entry.source.url }; +const tileProvider = (url) => + new Cesium.UrlTemplateImageryProvider({ + url, + tilingScheme: new Cesium.WebMercatorTilingScheme(), + }); + +test('aerial imagery drapes above every other imagery layer', async () => { + const viewer = globeViewer(); + const collection = viewer.imageryLayers; + const base = collection.addImageryProvider( + tileProvider('https://base.example.org/{z}/{x}/{y}.png'), + ); + const photos = createLibraryTileLayer( + entryWithoutOutline('pdok-aerial-netherlands'), + { source, cesium: Cesium }, + ); + await photos.init(viewer); + await photos.enable(viewer); + const top = () => collection.get(collection.length - 1); + assert.equal(collection.length, 2); + assert.notEqual(top(), base); + const pdok = top(); + + // A drape added later (weather, another library basemap) goes under it. + const weather = collection.addImageryProvider( + tileProvider('https://weather.example.org/{z}/{x}/{y}.png'), + ); + assert.equal(top(), pdok); + assert.equal(collection.indexOf(weather), 1); + + // PDOK's white "no imagery here" fill is drawn transparent, and its tiles + // are requested only inside the Netherlands bbox. + assert.ok(pdok.colorToAlpha.equals(Cesium.Color.WHITE)); + assert.ok( + pdok.colorToAlphaThreshold > 0 && pdok.colorToAlphaThreshold < 0.05, + ); + const west = Cesium.Math.toDegrees(pdok.imageryProvider.rectangle.west); + assert.ok(Math.abs(west - 3.2) < 1e-9); + + // Off: the drape and its listener are gone, so later layers stay put. + await photos.disable(); + assert.equal(collection.contains(pdok), false); + const later = collection.addImageryProvider( + tileProvider('https://later.example.org/{z}/{x}/{y}.png'), + ); + assert.equal(top(), later); +}); + +test('a non-aerial tile layer keeps its place and its own colours', async () => { + const viewer = globeViewer(); + const collection = viewer.imageryLayers; + const topo = createLibraryTileLayer(entryWithoutOutline('usgs-topo'), { + source, + cesium: Cesium, + }); + await topo.init(viewer); + await topo.enable(viewer); + const drape = collection.get(0); + collection.addImageryProvider( + tileProvider('https://weather.example.org/{z}/{x}/{y}.png'), + ); + assert.equal(collection.indexOf(drape), 0); + assert.equal(drape.colorToAlpha, undefined); + await topo.destroy(); +}); + +test('every shipped aerial photo entry is clipped to its coverage', () => { + const aerial = LIBRARY_ENTRIES.filter((entry) => entry.display.aerial); + assert.ok(aerial.length >= 60, 'the Open Imagery Register entries ship'); + for (const entry of aerial) { + // Kept on top, an unclipped drape would cover the whole globe with the + // provider's out-of-area fill. + assert.equal(entry.source.bounded, true, entry.id); + assert.ok(entry.imagery?.resolution, `${entry.id} names its resolution`); + } +}); + +test('bbox and TMS templates become the URLs Cesium and the proxy request', () => { + const source = createLibrarySource({ + entries: [], + base: '/api/library', + storage: null, + }); + const wms = { + id: 'example-wms', + source: { + fetch: 'browser-direct', + url: 'https://w.example.org/wms?bbox={bbox-epsg-3857}&width=256', + }, + }; + assert.equal( + source.tileTemplate(wms), + 'https://w.example.org/wms?bbox={westProjected},{southProjected},{eastProjected},{northProjected}&width=256', + ); + assert.equal( + source.tileTemplate({ + ...wms, + source: { ...wms.source, fetch: 'proxied' }, + }), + '/api/library/tile/example-wms/{z}/{x}/{y}', + ); + // The proxy fills the same tile's bounds in EPSG:3857 metres. + assert.equal( + libraryTileUrl(wms.source.url, 1, 1, 0), + 'https://w.example.org/wms?bbox=0.000,0.000,20037508.343,20037508.343&width=256', + ); + assert.equal( + libraryTileUrl('https://t.example.org/{z}/{x}/{reverseY}.png', 3, 2, 1), + 'https://t.example.org/3/2/6.png', + ); +}); diff --git a/src/library/viewLayer.test.mjs b/src/library/viewLayer.test.mjs new file mode 100644 index 000000000..9f9fd7abc --- /dev/null +++ b/src/library/viewLayer.test.mjs @@ -0,0 +1,192 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { createLibraryViewLayer } from './layers.js'; +import { libraryEntry } from './catalog.js'; + +const entry = libraryEntry('fema-usa-structures'); + +function fakeViewer({ height, lon = -77.03, lat = 38.89 }) { + const listeners = new Set(); + const added = []; + const viewer = { + camera: { + positionCartographic: { height }, + positionWC: Cesium.Cartesian3.fromDegrees(lon, lat, height), + moveEnd: { + addEventListener: (listener) => listeners.add(listener), + removeEventListener: (listener) => listeners.delete(listener), + }, + pickEllipsoid: () => Cesium.Cartesian3.fromDegrees(lon, lat, 0), + computeViewRectangle: () => + Cesium.Rectangle.fromDegrees(lon - 1, lat - 1, lon + 1, lat + 1), + }, + scene: { canvas: { clientWidth: 800, clientHeight: 600 }, globe: {} }, + dataSources: { + add: async (source) => added.push(source), + remove: (source) => added.splice(added.indexOf(source), 1), + }, + }; + return { viewer, listeners, added }; +} + +const fakeCesium = { + ...Cesium, + GeoJsonDataSource: { + load: async (collection) => ({ + collection, + entities: { values: collection.features.map(() => ({ polygon: {} })) }, + }), + }, +}; + +const polygon = { + type: 'Feature', + geometry: { + type: 'Polygon', + coordinates: [ + [ + [-77.03, 38.89], + [-77.029, 38.89], + [-77.029, 38.891], + [-77.03, 38.89], + ], + ], + }, + properties: { OCC_CLS: 'Government' }, +}; + +test('above its altitude a view-bounded layer loads nothing and asks to zoom in', async () => { + const calls = []; + const layer = createLibraryViewLayer(entry, { + source: { loadDataset: async (...args) => calls.push(args) }, + cesium: fakeCesium, + }); + const { viewer, listeners } = fakeViewer({ height: 900_000 }); + await layer.init(viewer); + await layer.enable(viewer); + assert.equal(calls.length, 0); + assert.equal(listeners.size, 1); + assert.deepEqual(layer.getStats(), { + count: 0, + lastUpdate: null, + status: 'zoom-in', + statusMessage: 'zoom in to load this layer', + }); + await layer.disable(viewer); + assert.equal(listeners.size, 0); +}); + +test('below it, the layer loads the box around the view centre and reads PARTIAL when capped', async () => { + const calls = []; + const layer = createLibraryViewLayer(entry, { + source: { + loadDataset: async (target, options) => { + calls.push(options.bbox); + return { features: [polygon, polygon], truncated: true }; + }, + }, + cesium: fakeCesium, + }); + const { viewer, added } = fakeViewer({ height: 2000 }); + await layer.init(viewer); + await layer.enable(viewer); + assert.equal(calls.length, 1); + const [west, south, east, north] = calls[0]; + // A window around the centre sized by height (±1 km here), not the + // ±1° visible rectangle. + assert.ok(north - south < 0.05 && north - south > 0.005, String(calls[0])); + assert.ok(west < -77.03 && east > -77.03 && south < 38.89 && north > 38.89); + assert.equal(added.length, 1); + assert.equal(added[0].entities.values[0].polygon.outline, false); + const stats = layer.getStats(); + assert.equal(stats.count, 2); + assert.equal(stats.partial, true); + // The same view does not refetch. + await layer.enable(viewer); + assert.equal(calls.length, 1); + assert.deepEqual(layer.getExtent(), { + west: -77.04, + south: 38.885, + east: -77.02, + north: 38.9, + maxAltitudeM: 4000, + }); + await layer.destroy(viewer); + assert.equal(added.length, 0); +}); + +test('the row legend labels each colour and counts what is in view', async () => { + const { colorByLegend } = await import('./layers.js'); + const bridges = libraryEntry('ntad-national-bridges'); + assert.deepEqual(colorByLegend(bridges.display), [ + { label: 'Good', color: '#0ca30c' }, + { label: 'Fair', color: '#fab219' }, + { label: 'Poor', color: '#d03b3b' }, + ]); + assert.deepEqual( + colorByLegend(bridges.display, new Map([['P', 2]])).map( + ({ count }) => count, + ), + [0, 0, 2], + ); + // A tile layer's static scale. + assert.equal( + colorByLegend(libraryEntry('gpw-population-density').display).length, + 6, + ); + assert.deepEqual( + colorByLegend(libraryEntry('nasa-black-marble').display), + [], + ); + // A loaded view layer counts by value. + const layer = createLibraryViewLayer(bridges, { + source: { + loadDataset: async () => ({ + features: [ + { ...polygon, properties: { __gevColorKey: 'G' } }, + { ...polygon, properties: { __gevColorKey: 'G' } }, + { ...polygon, properties: { __gevColorKey: 'P' } }, + ], + truncated: false, + }), + }, + cesium: fakeCesium, + }); + const { viewer } = fakeViewer({ height: 3000 }); + await layer.init(viewer); + await layer.enable(viewer); + assert.deepEqual( + layer.getRowControls().legend.map(({ label, count }) => [label, count]), + [ + ['Good', 2], + ['Fair', 0], + ['Poor', 1], + ], + ); + await layer.destroy(viewer); +}); + +test('values sharing a label are one legend group, and uncoloured values count as Other', async () => { + const { colorByLegend } = await import('./layers.js'); + const legend = colorByLegend( + libraryEntry('wri-global-power-plants').display, + new Map([ + ['Coal', 3], + ['Gas', 2], + ['Wind', 4], + ['Biomass', 1], + ]), + ); + assert.deepEqual( + legend.map(({ label, count }) => [label, count]), + [ + ['Fossil (coal, gas, oil)', 5], + ['Nuclear', 0], + ['Hydro', 0], + ['Wind and solar', 4], + ['Other', 1], + ], + ); + assert.equal(legend.at(-1).color, '#a0aec0'); +}); diff --git a/src/navigationPolicy.js b/src/navigationPolicy.js index 255ce6926..312a1ccdb 100644 --- a/src/navigationPolicy.js +++ b/src/navigationPolicy.js @@ -28,6 +28,7 @@ const COCKPIT_REFUSALS = Object.freeze({ train: msg('Exit cockpit to fly to a train'), route: msg('Exit cockpit to fly to a route'), file: msg('Exit cockpit to fly to a file'), + layer: msg('Exit cockpit to fly to a layer'), drawing: msg('Exit cockpit to fly to a drawing'), scene: msg('Exit cockpit to fly to a scene'), // Radar, satellite and lightning fly to the layer's coverage area (their diff --git a/src/sharelink.js b/src/sharelink.js index fcef04ea5..74ad5b05a 100644 --- a/src/sharelink.js +++ b/src/sharelink.js @@ -108,6 +108,7 @@ export class ShareLinkManager { this._scopeTerminusPct = null; this._mapStack = 'photoreal'; this._layerStateProvider = null; + this._libraryParamsEncoder = null; this._panelStateProvider = null; this._styleParamStateProvider = null; this._initialRestorePending = false; @@ -475,6 +476,20 @@ export class ShareLinkManager { this._layerStateProvider = typeof provider === 'function' ? provider : null; } + /** + * Install the Data Library's `lib=` writer. Library selections travel in + * their own field and never touch the core layer-token ledger. + * @param {((params: URLSearchParams) => void) | null} encoder + */ + setLibraryParamsEncoder(encoder) { + this._libraryParamsEncoder = typeof encoder === 'function' ? encoder : null; + } + + /** Called when the Data Library selection changes. */ + onLibraryStateChange() { + this._scheduleUpdate(); + } + /** * Install the source of the entity the camera follows, used for share links: * `({ nowMs }) => ({ kind, id, offset, lastKnown }) | null`, in the share @@ -701,6 +716,7 @@ export class ShareLinkManager { params.set('map', this._mapStack); const layerState = this._layerStateProvider?.(); if (layerState) encodeLayerStateParams(params, layerState); + this._libraryParamsEncoder?.(params); this._encodePanelStateParam(params, this._panelStateProvider?.()); encodeStyleParamState( params, @@ -734,6 +750,7 @@ export class ShareLinkManager { this._removeCameraChanged = null; this._unwatchNavigation?.(); this._layerStateProvider = null; + this._libraryParamsEncoder = null; this._panelStateProvider = null; this._styleParamStateProvider = null; this._followSubjectProvider = null; diff --git a/src/tooling/previewServing.test.mjs b/src/tooling/previewServing.test.mjs index daaf78386..d5b048ff1 100644 --- a/src/tooling/previewServing.test.mjs +++ b/src/tooling/previewServing.test.mjs @@ -129,6 +129,9 @@ test('real dev and built-preview servers serve provider JSON and terminate unkno ['/api/mapillary/status', 200], ['/api/mapillary/tiles/coverage/14/1/2', 503], ['/api/radio/unknown', 404], + ['/api/library/catalog', 200], + ['/api/library/tile/nasa-black-marble/0/0/0', 404], + ['/api/library/tile/gvp-holocene-volcanoes/0/0/0', 404], ['/api/setup/status', isPreview ? 404 : 200], ['/api/setup/update', 404], ['/api/does-not-exist', 404], diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js index c78a17645..25a4749ff 100644 --- a/src/ui/layerPanel.js +++ b/src/ui/layerPanel.js @@ -120,6 +120,17 @@ const PANEL_ORDER = PANEL_GROUPS.flatMap(({ label, ids, containerId }) => const PANEL_POSITIONS = new Map( PANEL_ORDER.map(({ id }, index) => [id, index]), ); +// Data Library layers follow every core group, in the order they were added. +const LIBRARY_GROUP = msg('Library'); +const panelPosition = (layer) => + layer.libraryEntryId + ? PANEL_ORDER.length + 1 + : (PANEL_POSITIONS.get(layer.id) ?? PANEL_ORDER.length); +const panelGroup = (layer) => + layer.libraryEntryId + ? LIBRARY_GROUP + : (PANEL_ORDER[PANEL_POSITIONS.get(layer.id)]?.label ?? + msg('Other layers')); function englishPanelLabel(layer) { return PANEL_LABELS[layer.id] || LAYER_NAME_KEYS[layer.id] || layer.name; @@ -372,6 +383,10 @@ export class LayerPanel { this._groupContainers = new Map(); this._toggleContainer = null; } + /** Rebuild the rows after the layer set changed (a Data Library add/remove). */ + rerender() { + this._renderToggles(); + } _renderToggles() { if (this._destroyed || !this._toggleContainer) return; this._releaseBindings(); @@ -380,19 +395,13 @@ export class LayerPanel { // its toggle and metadata. const layers = this.getAll() .slice() - .sort( - (a, b) => - (PANEL_POSITIONS.get(a.id) ?? PANEL_ORDER.length) - - (PANEL_POSITIONS.get(b.id) ?? PANEL_ORDER.length), - ); + .sort((a, b) => panelPosition(a) - panelPosition(b)); this._rows = []; this._rowViews = new Map(); let previousGroup = ''; for (const layer of layers) { if (!layer.showInTogglePanel) continue; - const group = - PANEL_ORDER[PANEL_POSITIONS.get(layer.id)]?.label ?? - msg('Other layers'); + const group = panelGroup(layer); const containerId = PANEL_ORDER[PANEL_POSITIONS.get(layer.id)]?.containerId; const container = this._groupContainers.has(containerId) @@ -485,6 +494,7 @@ export class LayerPanel { id: layer.id, // English name for voice tools, which must not receive translated text. englishName: englishPanelLabel(layer), + libraryEntry: layer.libraryEntryId || null, icon: layer.icon, name: panelLabel(layer), count: row.count ? presentLayerCount(layer.stats) : null, @@ -504,7 +514,12 @@ export class LayerPanel { * controls for good, and a shown info line stays. */ _presentControls(row, layer) { - const controls = layer.enabled ? this._rowControlsFor(layer.id) : null; + // A quiet row stays quiet while off, except Data Library rows, whose + // actions (zoom, remove) work on a layer that is off. + const controls = + layer.enabled || layer.libraryEntryId + ? this._rowControlsFor(layer.id) + : null; if (controls?.readout) { // The row became a rail readout: its controls go for good. row.controls = false; @@ -580,7 +595,8 @@ export class LayerPanel { ...(live?.chips || []), ...(live?.segmented?.options || []), ].find((entry) => entry.id === chipId); - if (!chip || chip.disabled || !this.isEnabled(id)) return; + if (!chip || chip.disabled || (!this.isEnabled(id) && !chip.whileOff)) + return; if (typeof chip.onClick === 'function') chip.onClick(); else if (chip.params) this.setLayerParams(id, chip.params, { origin: 'user' }); diff --git a/src/ui/layerPanel/LayerRows.vue b/src/ui/layerPanel/LayerRows.vue index 81e2f31ed..ee0490913 100644 --- a/src/ui/layerPanel/LayerRows.vue +++ b/src/ui/layerPanel/LayerRows.vue @@ -33,6 +33,7 @@ defineProps({ }" :data-layer-id="item.id" :data-layer-name="item.englishName" + :data-library-entry="item.libraryEntry || undefined" >
({ + ...row(false), + key: 'lib-night-lights', + id: 'lib-night-lights', + libraryEntry: 'nasa-black-marble', + controls: { hidden: false, chips, legend: [], list: null }, +}); + +test('a Data Library row names its entry and draws icon action chips', () => { + document.body.replaceChildren(); + const container = document.body.appendChild(document.createElement('div')); + const zoom = { + id: 'zoom', + icon: 'zoom_in_map', + action: true, + label: 'Zoom to layer', + }; + render(h(LayerRows, { items: [libraryRow([zoom])] }), container); + const element = container.querySelector('[data-layer-id="lib-night-lights"]'); + assert.equal(element.dataset.libraryEntry, 'nasa-black-marble'); + const button = element.querySelector('[data-chip-id="zoom"]'); + const glyph = button.children[0]; + assert.equal(glyph.className, 'material-symbols-outlined'); + assert.equal(glyph.textContent, 'zoom_in_map'); + assert.equal(glyph.getAttribute('aria-hidden'), 'true'); + assert.equal(button.getAttribute('aria-label'), 'Zoom to layer'); + assert.equal(button.title, 'Zoom to layer'); + assert.equal(button.getAttribute('aria-pressed'), null); + assert.ok(button.classList.contains('chip-icon')); + + // The same id as a text toggle drops the icon semantics. + render( + h(LayerRows, { + items: [libraryRow([{ id: 'zoom', label: 'ZOOM', active: true }])], + }), + container, + ); + const toggle = element.querySelector('[data-chip-id="zoom"]'); + assert.equal(toggle.textContent, 'ZOOM'); + assert.equal(toggle.getAttribute('aria-label'), null); + assert.equal(toggle.getAttribute('aria-pressed'), 'true'); + assert.equal(toggle.classList.contains('chip-icon'), false); + render(null, container); +}); + +test('a core row carries no library entry attribute', () => { + document.body.replaceChildren(); + const container = document.body.appendChild(document.createElement('div')); + render(h(LayerRows, { items: [row(false)] }), container); + const element = container.querySelector('[data-layer-id="flights"]'); + assert.equal(element.hasAttribute('data-library-entry'), false); + render(null, container); +}); diff --git a/src/ui/libraryBrowser.js b/src/ui/libraryBrowser.js new file mode 100644 index 000000000..857b0f04d --- /dev/null +++ b/src/ui/libraryBrowser.js @@ -0,0 +1,604 @@ +/** + * DATA LAYERS ▸ Browse data library: the dialog that lists every Data Library + * entry this build knows, with its licence, coverage and what this instance + * needs to serve it, and adds or removes it from the panel. + * + * A `dialog[data-panel-surface]` per docs/panel-surfaces.md: this module owns + * width, height, labels, initial and return focus, Escape and dismissal, and + * closes itself when clean view, recording or Cockpit hides the panels. + */ +import { formatNumber, msg, t, tn } from '../i18n/index.js'; + +const PANEL_HIDING_MODES = ['ui-clean-view', 'recording-mode', 'cockpit-mode']; +/** How long Remove all stays armed after its first press. */ +const RESET_CONFIRM_MS = 4000; +/** How often the open dialog rereads its stat tiles (the panel's own pace). */ +const STATS_REFRESH_MS = 1000; + +const FAMILY_LABELS = Object.freeze({ + movement: msg('Movement'), + cameras: msg('Cameras'), + infrastructure: msg('Infrastructure'), + events: msg('Events'), + weather: msg('Weather'), + environment: msg('Environment'), + imagery: msg('Imagery'), + demographics: msg('Demographics'), +}); + +const KIND_LABELS = Object.freeze({ + dataset: msg('DATASET'), + tiles: msg('TILES'), + pack: msg('PACK'), +}); + +const STATUS_LABELS = Object.freeze({ + incubating: msg('INCUBATING'), + community: msg('COMMUNITY'), + verified: msg('VERIFIED'), + stale: msg('STALE'), + archived: msg('ARCHIVED'), +}); + +function element(tag, className, text) { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; +} + +function badge(text, tone = '') { + const node = element( + 'span', + `data-library-badge${tone ? ` data-library-badge--${tone}` : ''}`, + text, + ); + return node; +} + +const compact = (value) => + formatNumber(value, { notation: 'compact', maximumFractionDigits: 1 }); + +/** + * The four stat tiles at the top of Browse: label, value and one line of + * breakdown each. Text only; nothing here is a data colour. + * @param {ReturnType['summary']>} summary + * @returns {Array<{ id: string, label: string, value: string, detail: string }>} + */ +export function libraryStatTiles(summary) { + const { kinds } = summary; + return [ + { + id: 'entries', + label: t('Datasets'), + value: compact(summary.entries), + detail: t('{datasets} · {tiles} · {packs}', { + datasets: tn(kinds.dataset, '{count} dataset', '{count} datasets'), + tiles: tn(kinds.tiles, '{count} tile layer', '{count} tile layers'), + packs: tn(kinds.pack, '{count} pack', '{count} packs'), + }), + }, + { + id: 'themes', + label: t('Themes'), + value: compact(summary.themes.length), + // The count alone: the theme names are the Theme filter below. + detail: '', + }, + { + id: 'added', + label: t('Added'), + value: compact(summary.added), + detail: summary.added + ? tn(summary.on, '{count} on the map', '{count} on the map') + : t('none yet'), + }, + { + id: 'features', + label: t('Map features'), + value: summary.featureLayers ? compact(summary.features) : '—', + detail: summary.featureLayers + ? tn( + summary.featureLayers, + 'from {count} dataset', + 'from {count} datasets', + ) + : t('switch on a dataset to load features'), + }, + ]; +} + +/** The sentence explaining what this instance needs to serve an entry. */ +export function libraryReasonText({ entry, available, reason }) { + if (entry.kind === 'pack') + return available + ? t('Switched on by this instance · part of the CCTV layer') + : t('Operator opt-in: set {env}=1 on the server', { + env: entry.pack.env, + }); + if (available) return ''; + if (reason === 'operator-opt-in') + return t('Operator opt-in: add {id} to GEV_LIBRARY_ENABLE on the server', { + id: entry.id, + }); + if (reason === 'operator-disabled') + return t("Turned off by this instance's operator"); + if (reason === 'key-missing') return t('Needs a provider key on the server'); + if (reason === 'server-unavailable') + return t("Needs this app's server · not available in a static build"); + return t('Checking what this instance serves…'); +} + +function coverageText(entry) { + const { coverage } = entry; + if (coverage.global) return t('Global'); + if (coverage.countries?.length) return coverage.countries.join(' · '); + return t('Regional'); +} + +const IMAGERY_TYPE_LABELS = Object.freeze({ + ortho: msg('Orthophoto'), + 'satellite-mosaic': msg('Satellite mosaic'), + oblique: msg('Oblique'), + historic: msg('Historic imagery'), +}); + +/** + * An imagery entry's specs as label/value pairs for its card: what it is, + * how sharp, when it was flown and how often it is refreshed. + * @param {object} entry + * @returns {Array<{ label: string, value: string }>} + */ +export function imagerySpecs(entry) { + const imagery = entry?.imagery; + if (!imagery) return []; + const type = + imagery.type === 'ortho' && imagery.trueOrtho + ? t('True orthophoto') + : t(IMAGERY_TYPE_LABELS[imagery.type]); + return [ + { label: t('Type'), value: type }, + { label: t('Resolution'), value: imagery.resolution }, + imagery.vintage && { label: t('Flown'), value: imagery.vintage }, + imagery.updateCycle && { label: t('Updates'), value: imagery.updateCycle }, + imagery.orthoQuality && { + label: t('Ortho quality'), + value: imagery.orthoQuality, + }, + ].filter(Boolean); +} + +/** + * Whether a row matches the current filters. + * @param {{ entry: object, available: boolean, added: boolean }} row + * @param {{ query: string, family: string, availableOnly: boolean }} filter + */ +export function libraryRowMatches(row, { query, family, availableOnly }) { + const { entry } = row; + if (entry.status === 'archived' && !row.added) return false; + if (family && entry.family !== family) return false; + if (availableOnly && !row.available) return false; + const needle = String(query || '') + .trim() + .toLocaleLowerCase(); + if (!needle) return true; + return [ + entry.title, + entry.provider, + entry.description, + entry.id, + entry.imagery?.resolution, + ...(entry.coverage.countries || []), + ] + .join(' ') + .toLocaleLowerCase() + .includes(needle); +} + +/** + * Browse order: what a viewer can add first, then what this instance would + * need to switch on, then packs (which a host layer owns), each by title. + * @param {Array<{ entry: object, available: boolean }>} rows + */ +export function sortLibraryRows(rows) { + const rank = ({ entry, available }) => + entry.kind === 'pack' ? 2 : available ? 0 : 1; + return [...rows].sort( + (a, b) => + rank(a) - rank(b) || a.entry.title.localeCompare(b.entry.title, 'en'), + ); +} + +/** + * @param {{ library: ReturnType, trigger: HTMLElement|null }} options + * @returns {{ open: () => void, close: () => void, destroy: () => void }} + */ +export function mountLibraryBrowser({ library, trigger }) { + const removers = []; + const listen = (node, type, listener) => { + node.addEventListener(type, listener); + removers.push(() => node.removeEventListener(type, listener)); + }; + const filter = { query: '', family: '', availableOnly: false }; + let dialog = null; + let list = null; + let status = null; + let stats = null; + let statsTimer = null; + let resetButton = null; + // Remove all asks twice: the first press arms it for a few seconds. + let resetArmed = false; + let resetTimer = null; + let resetting = false; + + function disarmReset() { + clearTimeout(resetTimer); + resetTimer = null; + resetArmed = false; + } + + function build() { + dialog = element('dialog', 'data-library-dialog'); + dialog.id = 'data-library'; + dialog.setAttribute('data-panel-surface', ''); + dialog.setAttribute('aria-labelledby', 'data-library-title'); + dialog.setAttribute('aria-describedby', 'data-library-intro'); + + const header = element('header', 'data-library-header'); + header.setAttribute('data-panel-header', ''); + const title = element( + 'h2', + 'panel-title panel-surface-title', + t('DATA LIBRARY'), + ); + title.id = 'data-library-title'; + const close = element( + 'button', + 'panel-surface-control data-library-close', + '×', + ); + close.type = 'button'; + close.setAttribute('aria-label', t('Close data library')); + listen(close, 'click', () => api.close()); + header.append(title, close); + + const body = element('div', 'data-library-body'); + body.setAttribute('data-panel-body', ''); + stats = element('dl', 'data-library-stats'); + stats.setAttribute('aria-label', t('Data Library at a glance')); + const intro = element( + 'p', + 'data-library-intro', + t( + 'Live sources beyond the core layers. Each one is fetched from its provider while it is on; nothing is stored in this app. Add one and it appears in Data Layers under Library.', + ), + ); + intro.id = 'data-library-intro'; + + const filters = element('div', 'data-library-filters'); + const searchLabel = element('label', 'data-library-field'); + searchLabel.append(element('span', '', t('Search'))); + const search = element('input', 'panel-surface-control'); + search.type = 'search'; + search.autocomplete = 'off'; + search.placeholder = t('Name, provider or country'); + listen(search, 'input', () => { + filter.query = search.value; + render(); + }); + searchLabel.append(search); + + const familyLabel = element('label', 'data-library-field'); + familyLabel.append(element('span', '', t('Theme'))); + const family = element('select', 'panel-surface-control'); + family.append(new Option(t('All themes'), '')); + for (const [value, label] of Object.entries(FAMILY_LABELS)) + family.append(new Option(t(label), value)); + listen(family, 'change', () => { + filter.family = family.value; + render(); + }); + familyLabel.append(family); + + const availableLabel = element('label', 'data-library-check'); + const available = element('input'); + available.type = 'checkbox'; + listen(available, 'change', () => { + filter.availableOnly = available.checked; + render(); + }); + availableLabel.append( + available, + element('span', '', t('Only what this instance serves')), + ); + filters.append(searchLabel, familyLabel, availableLabel); + + const toolbar = element('div', 'data-library-toolbar'); + status = element('p', 'data-library-status'); + status.setAttribute('role', 'status'); + status.setAttribute('aria-live', 'polite'); + resetButton = element('button', 'panel-surface-control data-library-reset'); + resetButton.type = 'button'; + listen(resetButton, 'click', () => { + if (resetting) return; + if (!resetArmed) { + resetArmed = true; + resetTimer = setTimeout(() => { + disarmReset(); + render(); + }, RESET_CONFIRM_MS); + render(); + return; + } + disarmReset(); + resetting = true; + render(); + Promise.resolve(library.reset()) + .catch((error) => console.warn('[Library] reset failed:', error)) + .finally(() => { + resetting = false; + render(); + dialog?.querySelector('input[type="search"]')?.focus(); + }); + }); + toolbar.append(status, resetButton); + + list = element('ul', 'data-library-list'); + listen(list, 'click', (event) => { + const button = event.target?.closest?.('button[data-library-action]'); + if (!button || button.disabled) return; + const id = button.dataset.libraryId; + const kind = button.dataset.libraryAction; + if (kind === 'zoom') { + // Close first so the flight is visible; focus returns to Browse. + // An entry that is off is switched on first. + api.close(); + Promise.resolve(library.zoom(id)).catch((error) => + console.warn(`[Library] ${id} zoom failed:`, error), + ); + return; + } + const action = kind === 'add' ? library.add(id) : library.remove(id); + Promise.resolve(action).catch((error) => + console.warn(`[Library] ${id} failed:`, error), + ); + }); + + body.append(stats, intro, filters, toolbar, list); + dialog.append(header, body); + listen(dialog, 'cancel', (event) => { + event.preventDefault(); + api.close(); + }); + // A click on the backdrop (the dialog box itself, outside its content) + // dismisses, like Escape. + listen(dialog, 'click', (event) => { + if (event.target === dialog) api.close(); + }); + document.body.append(dialog); + return search; + } + + function renderRow({ entry, available, reason, added, on, busy, canZoom }) { + const item = element('li', 'data-library-item'); + item.dataset.libraryId = entry.id; + item.classList.toggle('is-unavailable', !available); + + const head = element('div', 'data-library-item-head'); + const icon = element('span', 'data-library-icon', entry.icon); + icon.setAttribute('aria-hidden', 'true'); + const name = element('span', 'data-library-name', entry.title); + const badges = element('span', 'data-library-badges'); + badges.append(badge(t(KIND_LABELS[entry.kind]))); + badges.append( + badge( + t(STATUS_LABELS[entry.status]), + entry.status === 'verified' + ? 'good' + : entry.status === 'stale' + ? 'warn' + : '', + ), + ); + if (entry.licence.operatorOptIn) badges.append(badge(t('OPT-IN'), 'warn')); + else if (entry.kind !== 'pack' && entry.source.key) + badges.append(badge(t('KEY'), 'warn')); + else if (entry.kind !== 'pack') badges.append(badge(t('KEYLESS'))); + head.append(icon, name, badges); + + const description = element('p', 'data-library-description'); + description.textContent = entry.description; + + const meta = element('p', 'data-library-meta'); + const provider = element('span', '', entry.provider); + provider.setAttribute('translate', 'no'); + const licence = element('a', '', entry.licence.name); + licence.href = entry.licence.url; + licence.target = '_blank'; + licence.rel = 'noopener noreferrer'; + meta.append( + provider, + document.createTextNode(' · '), + element('span', '', coverageText(entry)), + document.createTextNode(' · '), + licence, + ); + + item.append(head, description); + const specs = imagerySpecs(entry); + if (specs.length) { + const list = element('dl', 'data-library-specs'); + for (const { label, value } of specs) { + const row = element('div', 'data-library-spec'); + row.append(element('dt', '', label), element('dd', '', value)); + list.append(row); + } + item.append(list); + } + item.append(meta); + const note = libraryReasonText({ entry, available, reason }); + if (note) item.append(element('p', 'data-library-reason', note)); + + if (entry.kind !== 'pack') { + const actions = element('div', 'data-library-actions'); + if (added && canZoom) { + const zoom = element( + 'button', + 'panel-surface-control data-library-action', + t('ZOOM'), + ); + zoom.type = 'button'; + zoom.dataset.libraryId = entry.id; + zoom.dataset.libraryAction = 'zoom'; + zoom.disabled = busy; + zoom.setAttribute( + 'aria-label', + t('Zoom to {name}', { name: entry.title }), + ); + actions.append(zoom); + } + const action = element( + 'button', + 'panel-surface-control data-library-action', + ); + action.type = 'button'; + action.dataset.libraryId = entry.id; + action.dataset.libraryAction = added ? 'remove' : 'add'; + action.textContent = busy + ? t('WORKING…') + : added + ? t('REMOVE') + : t('ADD'); + action.disabled = busy || (!added && !available); + action.setAttribute( + 'aria-label', + added + ? t('Remove {name} from Data Layers', { name: entry.title }) + : t('Add {name} to Data Layers', { name: entry.title }), + ); + if (added && on) item.classList.add('is-on'); + actions.append(action); + item.append(actions); + } + return item; + } + + /** Rewrite the stat tiles in place; unchanged text is not touched. */ + function renderStats() { + if (!dialog?.open || !stats) return; + for (const tile of libraryStatTiles(library.summary())) { + let node = stats.querySelector(`[data-stat="${tile.id}"]`); + if (!node) { + node = element('div', 'data-library-stat'); + node.dataset.stat = tile.id; + node.append( + element('dt', 'data-library-stat-label'), + element('dd', 'data-library-stat-value'), + element('dd', 'data-library-stat-detail'), + ); + stats.append(node); + } + const [label, value, detail] = node.children; + if (label.textContent !== tile.label) label.textContent = tile.label; + if (value.textContent !== tile.value) value.textContent = tile.value; + if (detail.textContent !== tile.detail) detail.textContent = tile.detail; + detail.hidden = !tile.detail; + } + } + + function render() { + if (!dialog?.open) return; + renderStats(); + const rows = library.list(); + const shown = sortLibraryRows( + rows.filter((row) => libraryRowMatches(row, filter)), + ); + const focusedId = document.activeElement?.dataset?.libraryId; + const focusedAction = document.activeElement?.dataset?.libraryAction; + list.replaceChildren(...shown.map(renderRow)); + if (focusedId) { + const row = `button[data-library-id="${CSS.escape(focusedId)}"]`; + // The same control when it survived the render (Add becomes Remove). + ( + list.querySelector( + `${row}[data-library-action="${CSS.escape(focusedAction || '')}"]`, + ) || + list.querySelector(`${row}:not([data-library-action="zoom"])`) || + list.querySelector(row) + )?.focus(); + } + const added = rows.filter((row) => row.added).length; + if (!added && !resetting) disarmReset(); + resetButton.hidden = added === 0 && !resetting; + resetButton.disabled = resetting; + resetButton.classList.toggle('is-armed', resetArmed); + resetButton.textContent = resetting + ? t('WORKING…') + : resetArmed + ? tn(added, 'CONFIRM · REMOVE {count}', 'CONFIRM · REMOVE ALL {count}') + : t('REMOVE ALL'); + resetButton.setAttribute( + 'aria-label', + resetArmed + ? t('Confirm: remove every added entry from Data Layers') + : t('Remove every added entry from Data Layers'), + ); + // The stat tiles carry the totals; this line answers the filter. + status.textContent = !library.ready + ? t('Checking what this instance serves…') + : t('Showing {shown} of {total}', { + shown: shown.length, + total: rows.length, + }); + } + + const api = { + open() { + const search = dialog ? null : build(); + if (dialog.open) return; + render(); + dialog.showModal(); + render(); + // Feature counts arrive as datasets finish loading, outside the + // library's own notifications; refresh the tiles while open. + clearInterval(statsTimer); + statsTimer = setInterval(renderStats, STATS_REFRESH_MS); + (search || dialog.querySelector('input[type="search"]'))?.focus(); + }, + close() { + disarmReset(); + clearInterval(statsTimer); + statsTimer = null; + if (!dialog?.open) return; + dialog.close(); + trigger?.focus?.(); + }, + destroy() { + api.close(); + unsubscribe(); + observer?.disconnect(); + for (const remove of removers.splice(0)) remove(); + dialog?.remove(); + dialog = null; + }, + }; + + const unsubscribe = library.subscribe(render); + if (trigger) listen(trigger, 'click', () => api.open()); + const observer = + typeof MutationObserver === 'function' + ? new MutationObserver(() => { + if ( + PANEL_HIDING_MODES.some((mode) => + document.body.classList.contains(mode), + ) + ) + api.close(); + }) + : null; + observer?.observe(document.body, { + attributes: true, + attributeFilter: ['class'], + }); + return api; +} diff --git a/src/ui/libraryBrowser.test.mjs b/src/ui/libraryBrowser.test.mjs new file mode 100644 index 000000000..061024a09 --- /dev/null +++ b/src/ui/libraryBrowser.test.mjs @@ -0,0 +1,146 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + libraryReasonText, + libraryRowMatches, + libraryStatTiles, + sortLibraryRows, +} from './libraryBrowser.js'; +import { libraryEntry } from '../library/catalog.js'; +import { localInfrastructureOverlayCopy } from '../data/localGeojsonCore.js'; + +const row = (id, extra = {}) => ({ + entry: libraryEntry(id), + available: true, + added: false, + ...extra, +}); +const all = { query: '', family: '', availableOnly: false }; + +test('Browse filters by text, theme and what this instance serves', () => { + assert.ok( + libraryRowMatches(row('eia-power-plants'), { ...all, query: 'eia' }), + ); + assert.ok( + libraryRowMatches(row('eia-power-plants'), { ...all, query: 'us' }), + ); + assert.equal( + libraryRowMatches(row('eia-power-plants'), { ...all, query: 'volcano' }), + false, + ); + assert.equal( + libraryRowMatches(row('eia-power-plants'), { ...all, family: 'events' }), + false, + ); + assert.equal( + libraryRowMatches(row('cctv-nyc-dot', { available: false }), { + ...all, + availableOnly: true, + }), + false, + ); +}); + +test('the guidance names the exact operator switch', () => { + assert.match( + libraryReasonText({ ...row('cctv-nyc-dot'), available: false }), + /CCTV_NYC_ENABLED=1/, + ); + assert.match( + libraryReasonText({ ...row('cctv-nyc-dot'), available: true }), + /part of the CCTV layer/, + ); + assert.match( + libraryReasonText({ + ...row('gvp-holocene-volcanoes'), + available: false, + reason: 'server-unavailable', + }), + /static build/, + ); + assert.equal(libraryReasonText(row('nasa-eonet-events')), ''); +}); + +test('library features show their manifest card lines on the map', () => { + const copy = localInfrastructureOverlayCopy( + { + name: 'Grand Coulee', + __gevDetails: ['PrimSource: hydroelectric', 'Total_MW: 6809', 'extra'], + }, + 'lib-eia-power-plants', + ); + assert.deepEqual(copy, { + title: 'Grand Coulee', + details: ['PrimSource: hydroelectric', 'Total_MW: 6809'], + }); +}); + +test('Browse lists what a viewer can add first and packs last', () => { + const order = sortLibraryRows([ + row('cctv-nyc-dot', { available: false }), + row('nasa-eonet-events'), + row('gvp-holocene-volcanoes', { available: false }), + row('eia-power-plants'), + ]).map(({ entry }) => entry.id); + assert.deepEqual(order, [ + 'nasa-eonet-events', + 'eia-power-plants', + 'gvp-holocene-volcanoes', + 'cctv-nyc-dot', + ]); +}); + +test('the stat tiles summarise the library in words and compact numbers', () => { + const tiles = libraryStatTiles({ + entries: 8, + kinds: { dataset: 3, tiles: 2, pack: 3 }, + themes: ['cameras', 'environment', 'events', 'imagery', 'infrastructure'], + available: 5, + unavailable: 3, + added: 2, + on: 1, + features: 3712, + featureLayers: 2, + }); + assert.deepEqual( + tiles.map(({ id, value, detail }) => [id, value, detail]), + [ + ['entries', '8', '3 datasets · 2 tile layers · 3 packs'], + ['themes', '5', ''], + ['added', '2', '1 on the map'], + ['features', '3.7K', 'from 2 datasets'], + ], + ); + const empty = libraryStatTiles({ + entries: 1, + kinds: { dataset: 1, tiles: 0, pack: 0 }, + themes: ['events'], + available: 1, + unavailable: 0, + added: 0, + on: 0, + features: 0, + featureLayers: 0, + }); + assert.deepEqual( + empty.map(({ value, detail }) => [value, detail]), + [ + ['1', '1 dataset · 0 tile layers · 0 packs'], + ['1', ''], + ['0', 'none yet'], + ['—', 'switch on a dataset to load features'], + ], + ); +}); + +test('an imagery card lists type, resolution, years flown and refresh', async () => { + const { imagerySpecs } = await import('./libraryBrowser.js'); + const specs = imagerySpecs(libraryEntry('geneva-sitg-ortho')); + assert.deepEqual( + specs.map(({ label }) => label), + ['Type', 'Resolution', 'Flown', 'Updates'], + ); + assert.equal(specs[0].value, 'True orthophoto'); + assert.equal(specs[1].value, '5 cm'); + assert.deepEqual(imagerySpecs(libraryEntry('eia-power-plants')), []); +}); diff --git a/src/ui/rail/RailChips.vue b/src/ui/rail/RailChips.vue index 5f04372d9..1419ee7f8 100644 --- a/src/ui/rail/RailChips.vue +++ b/src/ui/rail/RailChips.vue @@ -2,13 +2,16 @@ import UiButton from '../kit/UiButton.vue'; // Keyed chips shared by rail cards; the owner decides what a click does. +// An `icon` chip shows a Material Symbol (named in index.html's `icon_names` +// subset) with its label as the accessible name and tip; an `action` chip +// (Zoom, Remove) is a plain button, not an on/off toggle. defineProps({ chips: { type: Array, default: () => [] }, onSelect: { type: Function, default: () => {} }, }); const chipClass = (chip) => - `data-toggle-chip chip-${chip.state || (chip.active ? 'active' : 'idle')}`; + `data-toggle-chip chip-${chip.state || (chip.active ? 'active' : 'idle')}${chip.icon ? ' chip-icon' : ''}`; diff --git a/src/ui/railTestFixture.mjs b/src/ui/railTestFixture.mjs index 8d71377c6..271e78b44 100644 --- a/src/ui/railTestFixture.mjs +++ b/src/ui/railTestFixture.mjs @@ -26,6 +26,10 @@ export function railFixture(onWrite = () => {}) { getAttribute(key) { return this.attributes.get(key) ?? null; }, + removeAttribute(key) { + onWrite('attribute'); + this.attributes.delete(key); + }, appendChild(child) { return this.insertBefore(child, null); }, diff --git a/src/ui/styles/library.css b/src/ui/styles/library.css new file mode 100644 index 000000000..3ad671e8e --- /dev/null +++ b/src/ui/styles/library.css @@ -0,0 +1,368 @@ +/* Data Library: the Browse button under DATA LAYERS and its dialog. + The dialog is a dialog[data-panel-surface] (docs/panel-surfaces.md): the + shared skin draws the surface; this file owns geometry and the list. */ + +.data-library-footer { + padding: 8px 10px 4px; + border-top: 1px solid var(--glass-border); +} + +.data-library-browse { + width: 100%; + min-height: 30px; + padding: 6px 10px; + border: 1px dashed var(--glass-border-hover); + border-radius: var(--btn-radius); + background: transparent; + color: var(--text-secondary); + font: 600 10px/1.4 var(--font-mono); + letter-spacing: 1.2px; + cursor: pointer; + transition: + color var(--transition-fast), + border-color var(--transition-fast), + background var(--transition-fast); +} + +.data-library-browse:hover, +.data-library-browse:focus-visible { + color: var(--text-primary); + border-color: var(--accent); + background: var(--accent-dim); +} + +.data-library-browse:focus-visible { + outline: 2px solid var(--text-primary); + outline-offset: 2px; +} + +.data-library-dialog { + /* Frosted rather than opaque: a lighter surface than the rail panels + (0.72) keeps the globe's colour visible, and the blur stops the HUD text + behind the dialog from competing with the dialog's own. */ + --glass-bg: rgba(12, 12, 20, 0.55); + width: min(560px, calc(100vw - 32px)); + max-height: min(720px, calc(100dvh - 48px)); + margin: auto; + color: var(--text-primary); + -webkit-backdrop-filter: blur(22px) saturate(1.3); + backdrop-filter: blur(22px) saturate(1.3); + /* The blur follows the surface's shape (Cyber's cut corners). The dialog + has no outward popups for a clip to hide. */ + clip-path: var(--panel-surface-frame); +} + +.data-library-dialog::backdrop { + background: rgba(0, 0, 0, 0.12); +} + +.data-library-close { + min-width: 30px; + font-size: 16px; + line-height: 1; +} + +/* At a glance: four stat tiles above the intro. Text tokens only. */ +.data-library-stats { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 6px; + margin: 0 0 12px; +} + +.data-library-stat { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + padding: 8px 10px; + border: 1px solid var(--glass-border); + border-radius: 8px; + background: rgba(8, 10, 16, 0.38); +} + +.data-library-stat-label { + color: var(--text-secondary); + font: 600 9px/1.3 var(--font-mono); + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.data-library-stat-value { + margin: 0; + color: var(--text-primary); + font: 600 20px/1.15 var(--font-sans); +} + +.data-library-stat-detail { + margin: 0; + color: rgba(232, 234, 237, 0.68); + font-size: 10px; + line-height: 1.35; + overflow-wrap: anywhere; +} + +:root[data-ui-theme='cyber'] .data-library-stat { + border-radius: 0; +} + +:root[data-ui-theme='cyber'] .data-library-stat-value { + font-family: var(--font-mono); +} + +.data-library-intro { + margin: 0 0 10px; + color: rgba(232, 234, 237, 0.72); +} + +.data-library-filters { + display: grid; + grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr); + gap: 8px; + margin-bottom: 6px; +} + +.data-library-field { + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; + color: var(--text-secondary); + font-size: 10px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.data-library-field .panel-surface-control { + text-transform: none; + letter-spacing: normal; + font-size: 12px; +} + +.data-library-check { + grid-column: 1 / -1; + display: flex; + align-items: center; + gap: 6px; + color: var(--text-secondary); +} + +.data-library-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin: 4px 0 8px; +} + +.data-library-status { + margin: 0; + color: var(--text-secondary); + font: 10px/1.4 var(--font-mono); +} + +.data-library-reset { + min-height: 26px; + padding: 3px 10px; + font: 600 10px/1.4 var(--font-mono); + letter-spacing: 1px; +} + +.data-library-reset.is-armed { + border-color: var(--panel-surface-error); + color: var(--panel-surface-error); +} + +.data-library-list { + display: flex; + flex-direction: column; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} + +.data-library-item { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + column-gap: 10px; + row-gap: 4px; + padding: 10px; + border: 1px solid var(--glass-border); + border-radius: 10px; + background: rgba(8, 10, 16, 0.38); +} + +.data-library-item.is-on { + border-color: var(--accent-glow); +} + +.data-library-item.is-unavailable .data-library-name, +.data-library-item.is-unavailable .data-library-description { + color: var(--text-secondary); +} + +.data-library-item > :not(.data-library-actions) { + grid-column: 1; +} + +.data-library-actions { + grid-column: 2; + grid-row: 1 / span 3; + align-self: center; + display: flex; + flex-direction: column; + gap: 6px; +} + +.data-library-action { + min-width: 82px; + font: 600 10px/1.4 var(--font-mono); + letter-spacing: 1px; +} + +.data-library-item-head { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 6px; + min-width: 0; +} + +.data-library-icon { + width: 14px; + color: var(--accent); + text-align: center; +} + +.data-library-name { + font-weight: 600; + overflow-wrap: anywhere; +} + +.data-library-badges { + display: inline-flex; + flex-wrap: wrap; + gap: 4px; +} + +.data-library-badge { + padding: 0 5px; + border: 1px solid var(--glass-border-hover); + border-radius: 4px; + color: var(--text-secondary); + font: 600 9px/1.6 var(--font-mono); + letter-spacing: 0.08em; +} + +.data-library-badge--good { + border-color: var(--accent-glow); + color: var(--accent); +} + +.data-library-badge--warn { + border-color: rgba(255, 194, 61, 0.45); + color: #ffc23d; +} + +.data-library-description, +.data-library-meta, +.data-library-reason { + margin: 0; + overflow-wrap: anywhere; +} + +.data-library-meta { + color: rgba(232, 234, 237, 0.68); + font-size: 11px; +} + +.data-library-meta a { + color: inherit; + text-decoration: underline; + text-underline-offset: 2px; +} + +.data-library-meta a:hover, +.data-library-meta a:focus-visible { + color: var(--text-primary); +} + +/* Imagery specs: label over value, wrapping into as many columns as fit. */ +.data-library-specs { + display: flex; + flex-wrap: wrap; + gap: 4px 16px; + margin: 0; +} + +.data-library-spec { + display: flex; + flex-direction: column; + min-width: 0; +} + +.data-library-spec dt { + color: rgba(232, 234, 237, 0.56); + font: 9px/1.4 var(--font-mono); + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.data-library-spec dd { + margin: 0; + color: var(--text-primary); + font-size: 11px; + overflow-wrap: anywhere; +} + +.data-library-reason { + color: #ffc23d; + font: 10px/1.5 var(--font-mono); +} + +:root[data-ui-theme='cyber'] .data-library-dialog { + --glass-bg: rgba(18, 24, 27, 0.62); + width: min(600px, calc(100vw - 32px)); +} + +:root[data-ui-theme='cyber'] .data-library-item { + border-radius: 0; +} + +:root[data-ui-theme='cyber'] + :is(.data-library-icon, .data-library-badge--good) { + color: var(--cyber-red-bright); + border-color: var(--cyber-red-bright); +} + +@media (max-width: 720px) { + .data-library-stats { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .data-library-filters { + grid-template-columns: minmax(0, 1fr); + } + + .data-library-item { + grid-template-columns: minmax(0, 1fr); + } + + .data-library-actions { + grid-column: 1; + grid-row: auto; + flex-direction: row; + justify-self: start; + } +} + +body:is(.ui-clean-view, .recording-mode) .data-library-dialog { + display: none; +} + +/* Library rows list their legend one item per line, as Fire Perimeters' + longer labels do, with the zoom and remove icons on their own line. */ +.data-toggle-row[data-library-entry] .data-toggle-legend-item { + flex-basis: 100%; +} diff --git a/src/ui/styles/radio.css b/src/ui/styles/radio.css index 73021e564..43676f6fa 100644 --- a/src/ui/styles/radio.css +++ b/src/ui/styles/radio.css @@ -905,6 +905,30 @@ html:not([lang='en']) outline: none; } +/* An icon action chip (RailChips.vue `icon`): a square button sized to the + text chips beside it. */ +.data-toggle-chip.chip-icon { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 24px; + height: 20px; + padding: 0 4px; + letter-spacing: 0; + /* A glyph alone needs more contrast than a text chip to read. */ + color: var(--text-secondary); +} + +.data-toggle-chip.chip-icon:hover, +.data-toggle-chip.chip-icon:focus-visible { + color: var(--text-primary); +} + +.data-toggle-chip.chip-icon .material-symbols-outlined { + font-size: 15px; + line-height: 1; +} + .data-toggle-chip:hover, .data-toggle-chip:focus-visible { border-color: var(--glass-border-hover); diff --git a/src/ui/styles/scenes.css b/src/ui/styles/scenes.css index edbd8edb9..6d4dfefea 100644 --- a/src/ui/styles/scenes.css +++ b/src/ui/styles/scenes.css @@ -437,6 +437,7 @@ html:not([lang='en']) #scene-panel .scene-controls .scene-btn { #control-panel.collapsed .button-grid, #data-panel.collapsed .data-toggle-list, +#data-panel.collapsed .data-library-footer, #global-context-panel.collapsed #radio-panel, #global-context-panel.collapsed #military-awareness-panel, #global-context-panel.collapsed .global-context-actions, diff --git a/src/ui/views/LayerPanelsView.vue b/src/ui/views/LayerPanelsView.vue index 66272d8f0..8e9a58583 100644 --- a/src/ui/views/LayerPanelsView.vue +++ b/src/ui/views/LayerPanelsView.vue @@ -43,6 +43,11 @@ async function importChosen(event) {
+ +
diff --git a/style.css b/style.css index c91a5b488..e17959363 100644 --- a/style.css +++ b/style.css @@ -38,6 +38,7 @@ @import './src/ui/styles/world-timeline.css'; @import './src/ui/styles/panel-surfaces.css'; @import './src/ui/styles/segmented-control.css'; +@import './src/ui/styles/library.css'; @import './src/ui/styles/kit.css'; @import './src/ui/styles/cjk.css'; @import './src/ui/styles/rtl.css'; From 11706a68cb2daaf53fe46f0a189094c2f9919a98 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Sat, 7 Nov 2026 18:24:01 +0000 Subject: [PATCH 22/55] Add Amsterdam traffic and ANPR camera positions to the Data Library The City of Amsterdam's verkeersinformatiesystemen register lists where its ~500 traffic and ANPR cameras stand, with type and asset number, but publishes no pictures. That fits a Data Library dataset better than a CCTV pack: the register answers GeoJSON in WGS84 with CORS headers, so the entry fetches it browser-direct with no proxy code. The register names no licence ("public unless stated otherwise"), so the entry stays operator opt-in, as #251 had it. The dataset and the position-only camera idea come from @Maher-Reven's #251, which this replaces; the entry lists them as maintainer and in its attribution. Co-authored-by: Maher Kurdi <72787268+Maher-Reven@users.noreply.github.com> --- DATA_SOURCES.md | 1 + changelog.d/1604-data-library.md | 4 +- scripts/package-boundaries.json | 1 + src/library/catalog.js | 2 + .../entries/amsterdam-traffic-cameras.json | 39 +++++++++++++++++++ src/library/ledger.json | 3 +- 6 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 src/library/entries/amsterdam-traffic-cameras.json diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 80a03cc71..d01cff4d8 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -297,6 +297,7 @@ Each Data Library entry is a manifest in `src/library/entries/` pointing at a li | Entry | How it is served | Licence / terms | Attribution | | --- | --- | --- | --- | | **Alaska Statewide Imagery (2023)** (`ak-statewide-imagery`, `apps.geo.fpac.usda.gov`) | tiles, bbox, browser-direct | [Public Domain (U.S. Government work)](https://www.usgs.gov/information-policies-and-instructions/copyrights-and-credits) | "USDA / Alaska Geospatial Council" | +| **Amsterdam traffic and ANPR camera positions** (`amsterdam-traffic-cameras`, `api.data.amsterdam.nl`) | dataset, geojson, browser-direct; operator adds `amsterdam-traffic-cameras` to `GEV_LIBRARY_ENABLE` | [Gemeente Amsterdam open data: public unless stated otherwise; no named licence](https://schemas.data.amsterdam.nl/datasets/verkeersinformatiesystemen/dataset) | "Gemeente Amsterdam, verkeersinformatiesystemen register (entry contributed by @Maher-Reven)" | | **Arkansas Statewide Imagery (2023)** (`ar-statewide-imagery`, `gis.arkansas.gov`) | tiles, bbox, browser-direct | [Public domain — Arkansas GIS Office open data](https://gis.arkansas.gov/) | "Arkansas GIS Office" | | **Arizona Statewide Imagery (2017)** (`az-statewide-imagery`, `azgeo.az.gov`) | tiles, xyz, browser-direct; operator adds `az-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open state geodata — attribution requested](https://azgeo.az.gov/) | "AZGEO" | | **Bangor, Maine Orthoimagery** (`bangor-me`, `tiles.arcgis.com`) | tiles, xyz, browser-direct; operator adds `bangor-me` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://www.bangormaine.gov/) | "City of Bangor GIS" | diff --git a/changelog.d/1604-data-library.md b/changelog.d/1604-data-library.md index 7b5b211fa..385459daa 100644 --- a/changelog.d/1604-data-library.md +++ b/changelog.d/1604-data-library.md @@ -8,7 +8,9 @@ generic `/api/library` proxy serves entries without CORS. Seeded with NASA EONET events, EIA and WRI power plants, NWS alerts, NOAA tide stations, Smithsonian volcanoes, NASA Black Marble, OpenSeaMap seamarks, and the - opt-in IMF PortWatch and CCTV packs. FEMA USA Structures and the + opt-in IMF PortWatch and CCTV packs, plus the City of Amsterdam's + traffic and ANPR camera positions (opt-in; contributed by @Maher-Reven in + #251). FEMA USA Structures and the National Bridge Inventory load what is in view once you zoom in, and Addresscloud's EPC ratings stream from Source Cooperative's GeoParquet on S3, coloured A–G with live legend counts. Open tile overlays (geology, diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index cc317f90b..17241ac1d 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -3498,6 +3498,7 @@ "src/library/selection.js", "src/library/ledger.json", "src/library/entries/ak-statewide-imagery.json", + "src/library/entries/amsterdam-traffic-cameras.json", "src/library/entries/ar-statewide-imagery.json", "src/library/entries/az-statewide-imagery.json", "src/library/entries/bangor-me.json", diff --git a/src/library/catalog.js b/src/library/catalog.js index c04656e13..3a5319bb2 100644 --- a/src/library/catalog.js +++ b/src/library/catalog.js @@ -12,6 +12,7 @@ import ledgerRows from './ledger.json' with { type: 'json' }; import akStatewideImagery from './entries/ak-statewide-imagery.json' with { type: 'json' }; import arStatewideImagery from './entries/ar-statewide-imagery.json' with { type: 'json' }; import azStatewideImagery from './entries/az-statewide-imagery.json' with { type: 'json' }; +import amsterdamTrafficCameras from './entries/amsterdam-traffic-cameras.json' with { type: 'json' }; import bangorMe from './entries/bangor-me.json' with { type: 'json' }; import basemapAtOrthofoto from './entries/basemap-at-orthofoto.json' with { type: 'json' }; import bayernDop40 from './entries/bayern-dop40.json' with { type: 'json' }; @@ -119,6 +120,7 @@ import wvStatewideImagery from './entries/wv-statewide-imagery.json' with { type /** Raw manifests in file-name order; the validated form is below. */ export const LIBRARY_MANIFESTS = Object.freeze([ akStatewideImagery, + amsterdamTrafficCameras, arStatewideImagery, azStatewideImagery, bangorMe, diff --git a/src/library/entries/amsterdam-traffic-cameras.json b/src/library/entries/amsterdam-traffic-cameras.json new file mode 100644 index 000000000..04058886f --- /dev/null +++ b/src/library/entries/amsterdam-traffic-cameras.json @@ -0,0 +1,39 @@ +{ + "id": "amsterdam-traffic-cameras", + "kind": "dataset", + "family": "cameras", + "title": "Amsterdam traffic and ANPR camera positions", + "provider": "Gemeente Amsterdam", + "description": "Where the City of Amsterdam's traffic and ANPR cameras stand (about 500), from its traffic information systems register. Positions and camera type only: the city publishes no pictures.", + "icon": "◉", + "maintainer": "github:Maher-Reven", + "coverage": { "countries": ["NL"], "bbox": [4.72, 52.27, 5.08, 52.44] }, + "source": { + "format": "geojson", + "url": "https://api.data.amsterdam.nl/v1/verkeersinformatiesystemen/verkeersinformatiesystemen/", + "query": { + "objectSoort": "Camera", + "_format": "geojson", + "_pageSize": "1000" + }, + "fetch": "browser-direct", + "cacheSeconds": 86400, + "maxFeatures": 1000 + }, + "display": { + "color": "#e53e3e", + "label": "standplaats", + "fields": ["typeGedetailleerd", "objectnummer"] + }, + "licence": { + "name": "Gemeente Amsterdam open data: public unless stated otherwise; no named licence", + "url": "https://schemas.data.amsterdam.nl/datasets/verkeersinformatiesystemen/dataset", + "operatorOptIn": true + }, + "attribution": { + "text": "Gemeente Amsterdam, verkeersinformatiesystemen register (entry contributed by @Maher-Reven)", + "url": "https://api.data.amsterdam.nl/v1/docs/" + }, + "status": "community", + "reviewed": "2026-11-07" +} diff --git a/src/library/ledger.json b/src/library/ledger.json index de9dee009..9caf0feea 100644 --- a/src/library/ledger.json +++ b/src/library/ledger.json @@ -101,5 +101,6 @@ ["kartverket-topo-grey", "2r"], ["kartverket-topo", "2s"], ["kartverket-toporaster", "2t"], - ["mapterhorn-terrain", "2u"] + ["mapterhorn-terrain", "2u"], + ["amsterdam-traffic-cameras", "2v"] ] From 871b7f0fcc57b7e1c2820a42b81d9d36fa1cff09 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Thu, 12 Nov 2026 17:05:21 +0000 Subject: [PATCH 23/55] Remove Data Library entries whose licence or provider links are broken A link check of every manifest found 404 licence or provider pages for OpenSeaMap seamarks, EMODnet bathymetry and the PDOK, basemap.at, Geneva SITG, ICGC Catalunya, Luxembourg and Vlaanderen aerial photos, and no answer from the SEDAC GPW, Minnesota and Bangor hosts. Drop those 11 entries, renumber the unpublished share-token ledger, point the tests at remaining entries and regenerate DATA_SOURCES.md. --- DATA_SOURCES.md | 11 - changelog.d/1604-data-library.md | 6 +- docs/CURRENT-STATE.md | 35 ++-- scripts/package-boundaries.json | 15 -- src/library/catalog.js | 22 -- src/library/entries/bangor-me.json | 48 ----- src/library/entries/basemap-at-orthofoto.json | 48 ----- src/library/entries/emodnet-bathymetry.json | 34 ---- src/library/entries/geneva-sitg-ortho.json | 48 ----- .../entries/gpw-population-density.json | 60 ------ src/library/entries/icgc-orto-catalunya.json | 47 ----- src/library/entries/lu-ortho-latest.json | 48 ----- src/library/entries/mn-statewide-imagery.json | 48 ----- src/library/entries/openseamap-seamarks.json | 30 --- .../entries/pdok-aerial-netherlands.json | 48 ----- src/library/entries/vlaanderen-omw.json | 48 ----- src/library/ledger.json | 189 +++++++++--------- src/library/outlines.test.mjs | 6 +- src/library/tileLayer.test.mjs | 20 +- src/library/viewLayer.test.mjs | 2 +- src/ui/libraryBrowser.test.mjs | 14 +- 21 files changed, 139 insertions(+), 688 deletions(-) delete mode 100644 src/library/entries/bangor-me.json delete mode 100644 src/library/entries/basemap-at-orthofoto.json delete mode 100644 src/library/entries/emodnet-bathymetry.json delete mode 100644 src/library/entries/geneva-sitg-ortho.json delete mode 100644 src/library/entries/gpw-population-density.json delete mode 100644 src/library/entries/icgc-orto-catalunya.json delete mode 100644 src/library/entries/lu-ortho-latest.json delete mode 100644 src/library/entries/mn-statewide-imagery.json delete mode 100644 src/library/entries/openseamap-seamarks.json delete mode 100644 src/library/entries/pdok-aerial-netherlands.json delete mode 100644 src/library/entries/vlaanderen-omw.json diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index d01cff4d8..759dff105 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -300,8 +300,6 @@ Each Data Library entry is a manifest in `src/library/entries/` pointing at a li | **Amsterdam traffic and ANPR camera positions** (`amsterdam-traffic-cameras`, `api.data.amsterdam.nl`) | dataset, geojson, browser-direct; operator adds `amsterdam-traffic-cameras` to `GEV_LIBRARY_ENABLE` | [Gemeente Amsterdam open data: public unless stated otherwise; no named licence](https://schemas.data.amsterdam.nl/datasets/verkeersinformatiesystemen/dataset) | "Gemeente Amsterdam, verkeersinformatiesystemen register (entry contributed by @Maher-Reven)" | | **Arkansas Statewide Imagery (2023)** (`ar-statewide-imagery`, `gis.arkansas.gov`) | tiles, bbox, browser-direct | [Public domain — Arkansas GIS Office open data](https://gis.arkansas.gov/) | "Arkansas GIS Office" | | **Arizona Statewide Imagery (2017)** (`az-statewide-imagery`, `azgeo.az.gov`) | tiles, xyz, browser-direct; operator adds `az-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open state geodata — attribution requested](https://azgeo.az.gov/) | "AZGEO" | -| **Bangor, Maine Orthoimagery** (`bangor-me`, `tiles.arcgis.com`) | tiles, xyz, browser-direct; operator adds `bangor-me` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://www.bangormaine.gov/) | "City of Bangor GIS" | -| **basemap.at Orthofoto** (`basemap-at-orthofoto`, `mapsneu.wien.gv.at`) | tiles, xyz, browser-direct | [CC BY 4.0](https://basemap.at/nutzungsbedingungen/) | "basemap.at — Datenquelle: basemap.at" | | **Digitales Orthophoto Bayern (DOP 40 cm, open)** (`bayern-dop40`, `geoservices.bayern.de`) | tiles, bbox, browser-direct | [CC BY 4.0](https://geodaten.bayern.de/opengeodata/) | "© Bayerische Vermessungsverwaltung" | | **Bismarck–Mandan, North Dakota Aerial Photography** (`bismarck-mandan-nd`, `ndgishub.nd.gov`) | tiles, bbox, browser-direct; operator adds `bismarck-mandan-nd` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://www.gis.nd.gov/) | "95West Aerial Mapping" | | **TopPlusOpen basemap (BKG)** (`bkg-topplus-open`, `sgx.geodatenzentrum.de`) | tiles, xyz, browser-direct | [Datenlizenz Deutschland – Namensnennung 2.0](https://www.govdata.de/dl-de/by-2-0) | "© BKG, dl-de/by-2-0" | @@ -314,21 +312,17 @@ Each Data Library entry is a manifest in `src/library/entries/` pointing at a li | **Delaware Statewide Imagery (2024)** (`de-statewide-imagery`, `imagery.firstmap.delaware.gov`) | tiles, bbox, browser-direct; operator adds `de-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open state geodata — attribution requested](https://firstmap.delaware.gov/) | "This data is meant for use by requested parties. This data cannot be used without express consent.This data is meant for use by requested parties. This data cannot be used without express consent." | | **Eesti ortofoto (Maa-amet)** (`ee-maa-amet-ortofoto`, `tiles.maaamet.ee`) | tiles, xyz, browser-direct | [Maa-amet open data licence (attribution)](https://geoportaal.maaamet.ee/eng/Spatial-Data/Orthophotos-p309.html) | "© Maa-amet (Estonian Land Board)" | | **US power plants, 100 MW and up (EIA)** (`eia-power-plants`, `services2.arcgis.com`) | dataset, arcgis, browser-direct | [U.S. Energy Information Administration data: public domain](https://www.eia.gov/about/copyrights_reuse.php) | "U.S. Energy Information Administration (EIA-860, EIA-860M, EIA-923)" | -| **European seabed depth (EMODnet)** (`emodnet-bathymetry`, `tiles.emodnet-bathymetry.eu`) | tiles, xyz, browser-direct | [EMODnet data: free use with attribution](https://emodnet.ec.europa.eu/en/emodnet-data-policy) | "EMODnet Bathymetry Consortium — EMODnet Digital Bathymetry (DTM)" | | **Superfund sites (US EPA)** (`epa-superfund-sites`, `geopub.epa.gov`) | dataset, arcgis, browser-direct | [U.S. EPA data: public domain](https://www.epa.gov/web-policies-and-procedures/epa-disclaimers) | "U.S. Environmental Protection Agency — Facility Registry Service (EnviroMapper)" | | **Toxic release facilities (US EPA TRI)** (`epa-toxic-release-facilities`, `geopub.epa.gov`) | dataset, arcgis, browser-direct | [U.S. EPA data: public domain](https://www.epa.gov/web-policies-and-procedures/epa-disclaimers) | "U.S. Environmental Protection Agency — Toxics Release Inventory via the Facility Registry Service" | | **Home energy ratings, England and Wales (EPC)** (`epc-england-wales`, `s3.us-west-2.amazonaws.com`) | dataset, geoparquet, browser-direct | [Open Government Licence v3.0 (EPC data); OS Open UPRN under OGL](https://epc.opendatacommunities.org/docs/copyright) | "Energy Performance of Buildings Data, DLUHC; geocoded by Addresscloud with OS Open UPRN — contains OS data © Crown copyright and database right; via Source Cooperative" | | **US building footprints (FEMA USA Structures)** (`fema-usa-structures`, `services2.arcgis.com`) | dataset, arcgis, browser-direct | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | "FEMA Response Geospatial Office and Oak Ridge National Laboratory — USA Structures" | | **Biodiversity records (GBIF)** (`gbif-occurrence-density`, `api.gbif.org`) | tiles, xyz, browser-direct | [GBIF data use agreement (records under CC0, CC BY or CC BY-NC)](https://www.gbif.org/terms) | "GBIF.org — Global Biodiversity Information Facility" | | **Disaster alerts (GDACS)** (`gdacs-disaster-alerts`, `www.gdacs.org`) | dataset, geojson, browser-direct | [GDACS terms of use (free, attribution required)](https://www.gdacs.org/About/termofuse.aspx) | "GDACS — Global Disaster Alert and Coordination System (UN, European Commission)" | -| **Orthophotos Canton de Genève (SITG)** (`geneva-sitg-ortho`, `ge.ch`) | tiles, bbox, browser-direct | [SITG open data — free use with attribution](https://ge.ch/sitg/media/sitg/documents/CGU_donnees_libres.pdf) | "© SITG — Etat de Genève" | -| **Population density 2020 (SEDAC GPW)** (`gpw-population-density`, `gibs.earthdata.nasa.gov`) | tiles, xyz, browser-direct | [CC BY 4.0](https://sedac.ciesin.columbia.edu/data/collection/gpw-v4/terms-of-use) | "CIESIN, Columbia University — Gridded Population of the World v4.11, via NASA GIBS" | | **Grand Forks, North Dakota Aerial Photography** (`grand-forks-nd`, `ndgishub.nd.gov`) | tiles, bbox, browser-direct; operator adds `grand-forks-nd` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://www.gis.nd.gov/) | "Grand Forks/East Grand Forks Metropolitan Planning Organization (MPO)" | | **Japan standard map (GSI)** (`gsi-japan-map`, `cyberjapandata.gsi.go.jp`) | tiles, xyz, browser-direct | [GSI Tiles terms (free use with attribution)](https://maps.gsi.go.jp/development/ichiran.html) | "Geospatial Information Authority of Japan (GSI Tiles)" | | **Japan aerial photos (GSI)** (`gsi-japan-photo`, `cyberjapandata.gsi.go.jp`) | tiles, xyz, browser-direct | [GSI Tiles terms (free use with attribution)](https://maps.gsi.go.jp/development/ichiran.html) | "Geospatial Information Authority of Japan (GSI Tiles)" | | **Holocene volcanoes (Smithsonian GVP)** (`gvp-holocene-volcanoes`, `webservices.volcano.si.edu`) | dataset, geojson, proxied | [Smithsonian Terms of Use (personal, educational, non-commercial use; cite the Global Volcanism Program)](https://www.si.edu/termsofuse) | "Global Volcanism Program, Smithsonian Institution — Volcanoes of the World" | | **Iowa Statewide Imagery (2023)** (`ia-statewide-imagery`, `ortho.gis.iastate.edu`) | tiles, bbox, browser-direct; operator adds `ia-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open state geodata — attribution requested](https://geodata.iowa.gov/) | "USDA NAIP, Iowa State University GIS Facility" | -| **Ortofoto de Catalunya 25 cm** (`icgc-orto-catalunya`, `geoserveis.icgc.cat`) | tiles, xyz, browser-direct | [CC BY 4.0](https://www.icgc.cat/en/Ambits-tematics/Ortofoto) | "© Institut Cartogràfic i Geològic de Catalunya" | | **Idaho Statewide Imagery (2023)** (`id-statewide-imagery`, `insideidaho.org`) | tiles, bbox, browser-direct | [Public domain / open — INSIDE Idaho terms](https://insideidaho.org/) | "United States Department of Agriculture, Farm Production and Conservation Business Center, Geospatial Enterprise Operations (FPAC-BC-GEO)" | | **France aerial photos (IGN)** (`ign-orthophotos-france`, `data.geopf.fr`) | tiles, xyz, browser-direct | [Licence Ouverte / Etalab 2.0](https://www.etalab.gouv.fr/licence-ouverte-open-licence/) | "© IGN — Géoplateforme" | | **Illinois Statewide Imagery** (`il-statewide-imagery`, `data.isgs.illinois.edu`) | tiles, bbox, browser-direct; operator adds `il-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open state geodata — attribution requested](https://clearinghouse.isgs.illinois.edu/) | "Illinois State Geological Survey / IDOT" | @@ -352,7 +346,6 @@ Each Data Library entry is a manifest in `src/library/entries/` pointing at a li | **Lee County, Florida Aerial Imagery** (`lee-county-fl`, `gisimageserver.leegov.com`) | tiles, bbox, browser-direct; operator adds `lee-county-fl` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://www.leegov.com/gis) | "Eagle View, Lee County Property Appraiser, Lee County GIS" | | **Loudoun County, Virginia Imagery** (`loudoun-county-va`, `logis.loudoun.gov`) | tiles, bbox, browser-direct; operator adds `loudoun-county-va` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://geohub-loudoungis.opendata.arcgis.com/) | "Loudoun County GIS" | | **Louisville / LOJIC, Kentucky Imagery** (`louisville-lojic-ky`, `services.lojic.org`) | tiles, bbox, browser-direct; operator adds `louisville-lojic-ky` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://www.lojic.org/) | "LOJIC, USDA-NAIP" | -| **Orthophoto officielle du Luxembourg** (`lu-ortho-latest`, `wmts1.geoportail.lu`) | tiles, xyz, browser-direct | [CC0 1.0 (Licence ouverte Luxembourg)](https://data.public.lu/en/datasets/orthophoto-officelle-du-grand-duche-de-luxembourg-edition-2023/) | "Administration du cadastre et de la topographie, Luxembourg (CC0)" | | **Massachusetts Statewide Imagery** (`ma-statewide-imagery`, `tiles.arcgis.com`) | tiles, xyz, browser-direct; operator adds `ma-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open state geodata — attribution requested](https://gis.data.mass.gov/) | "MassGIS, Commonwealth of Massachusetts" | | **Geologic map (Macrostrat)** (`macrostrat-geology`, `tiles.macrostrat.org`) | tiles, xyz, browser-direct | [CC BY 4.0 (Macrostrat; source maps credited by their publishers)](https://macrostrat.org/about) | "Macrostrat — geologic maps from their original publishers" | | **Global terrain shaded relief (Mapterhorn)** (`mapterhorn-terrain`, `tiles.mapterhorn.com`) | tiles, xyz, browser-direct | [Open data, per source (mostly CC BY 4.0 and public domain)](https://mapterhorn.com/attribution) | "© Mapterhorn and its open elevation sources" | @@ -360,7 +353,6 @@ Each Data Library entry is a manifest in `src/library/entries/` pointing at a li | **Maryland Statewide Imagery** (`md-statewide-imagery`, `mdgeodata.md.gov`) | tiles, bbox, browser-direct; operator adds `md-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open state geodata — attribution requested](https://imap.maryland.gov/) | "Maryland iMAP" | | **Maine Statewide Imagery** (`me-statewide-imagery`, `gis.maine.gov`) | tiles, bbox, browser-direct; operator adds `me-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open state geodata — attribution requested](https://www.maine.gov/geolib/) | "USDA Aerial Photography Field Office" | | **Michigan Statewide Imagery (2024)** (`mi-statewide-imagery`, `imagery.michigan.gov`) | tiles, bbox, browser-direct; operator adds `mi-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open state geodata — attribution requested](https://gis-michigan.opendata.arcgis.com/) | "State of Michigan" | -| **Minnesota Statewide Imagery** (`mn-statewide-imagery`, `imageserver.gisdata.mn.gov`) | tiles, bbox, browser-direct | [Public domain / open — MnGeo terms](https://www.mngeo.state.mn.us/) | "MnGeo / State of Minnesota" | | **Mountain Village, Colorado Orthoimagery** (`mountain-village-co`, `tiles.arcgis.com`) | tiles, xyz, browser-direct; operator adds `mountain-village-co` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://www.townofmountainvillage.com/) | "Town of Mountain Village" | | **Mississippi Statewide Imagery** (`ms-statewide-imagery`, `tiles.arcgis.com`) | tiles, xyz, browser-direct; operator adds `ms-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open state geodata — attribution requested](https://www.gis.ms.gov/) | "USDA-FSA-APFO" | | **Montana Statewide Imagery (2023)** (`mt-statewide-imagery`, `gisservice.mt.gov`) | tiles, bbox, browser-direct | [Public domain — Montana State Library open data](https://msl.mt.gov/geoinfo/) | "Montana State Library" | @@ -382,10 +374,8 @@ Each Data Library entry is a manifest in `src/library/entries/` pointing at a li | **New York Statewide Imagery (2019)** (`ny-statewide-imagery`, `orthos.its.ny.gov`) | tiles, xyz, browser-direct | [Public domain — NYS open data](https://data.gis.ny.gov/) | "USDA; NYS ITS Geospatial Services" | | **Oakland County, Michigan Ortho Imagery** (`oakland-county-mi`, `gisservices.oakgov.com`) | tiles, bbox, browser-direct; operator adds `oakland-county-mi` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://oakgov.maps.arcgis.com/) | "Oakland County GIS" | | **Ohio Statewide Imagery** (`oh-statewide-imagery`, `maps.ohio.gov`) | tiles, xyz, browser-direct | [Public domain — Ohio open data](https://maps.ohio.gov/) | "Ohio Geographically Referenced Information Program (OGRIP)" | -| **Seamarks (OpenSeaMap)** (`openseamap-seamarks`, `tiles.openseamap.org`) | tiles, xyz, browser-direct | [CC BY-SA 2.0; data © OpenStreetMap contributors (ODbL)](https://openseamap.org/index.php?id=imprint&L=1) | "© OpenSeaMap contributors, CC BY-SA; data © OpenStreetMap contributors" | | **Oregon Statewide Imagery (2000)** (`or-statewide-imagery`, `imagery.oregonexplorer.info`) | tiles, bbox, browser-direct | [Public domain — Oregon open data](https://www.oregon.gov/geo/) | "Oregon, GEO, DAS" | | **Pennsylvania Statewide Imagery** (`pa-statewide-imagery`, `imagery.pasda.psu.edu`) | tiles, bbox, browser-direct | [Public domain — PASDA open data](https://www.pasda.psu.edu/) | "PASDA / PAMAP, Commonwealth of Pennsylvania" | -| **Netherlands aerial photos (PDOK)** (`pdok-aerial-netherlands`, `service.pdok.nl`) | tiles, xyz, browser-direct | [CC BY 4.0](https://www.pdok.nl/introductie/-/article/luchtfoto-pdok) | "Beeldmateriaal Nederland via PDOK" | | **Ortofotomapa Polski (GUGiK)** (`pl-ortofotomapa`, `mapy.geoportal.gov.pl`) | tiles, bbox, browser-direct | [Polish open geodata (free reuse, Prawo geodezyjne art. 40a)](https://www.geoportal.gov.pl/) | "© GUGiK / geoportal.gov.pl" | | **PNOA Máxima Actualidad** (`pnoa-ma`, `www.ign.es`) | tiles, bbox, browser-direct | [CC BY 4.0 (compatible) — Orden FOM/2807/2015](https://www.scne.es/productos.html) | "PNOA © Instituto Geográfico Nacional de España" | | **Portland Metro (RLIS), Oregon Aerial Photos** (`portland-metro-or`, `utility.arcgis.com`) | tiles, xyz, browser-direct; operator adds `portland-metro-or` to `GEV_LIBRARY_ENABLE` | [Local government open data — see publisher terms](https://rlisdiscovery.oregonmetro.gov/) | "Oregon Metro RLIS" | @@ -397,7 +387,6 @@ Each Data Library entry is a manifest in `src/library/entries/` pointing at a li | **US rivers and lakes (USGS)** (`usgs-hydrography`, `basemap.nationalmap.gov`) | tiles, xyz, browser-direct | [USGS The National Map: U.S. public domain](https://www.usgs.gov/information-policies-and-instructions/copyrights-and-credits) | "USGS The National Map — National Hydrography Dataset" | | **US topographic map (USGS)** (`usgs-topo`, `basemap.nationalmap.gov`) | tiles, xyz, browser-direct | [USGS The National Map: U.S. public domain](https://www.usgs.gov/information-policies-and-instructions/copyrights-and-credits) | "USGS The National Map" | | **Virginia Statewide Imagery** (`va-statewide-imagery`, `vginmaps.vdem.virginia.gov`) | tiles, xyz, browser-direct; operator adds `va-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open — VGIN VBMP terms](https://vgin.vdem.virginia.gov/) | "Virginia Geographic Information Network (VGIN)" | -| **Orthofotomozaïek Vlaanderen (winter)** (`vlaanderen-omw`, `geo.api.vlaanderen.be`) | tiles, bbox, browser-direct | [Vlaanderen model licence for free reuse](https://www.vlaanderen.be/digitaal-vlaanderen/onze-oplossingen/orthofotomozaieken) | "© Digitaal Vlaanderen" | | **Vermont Statewide Imagery** (`vt-statewide-imagery`, `maps.vcgi.vermont.gov`) | tiles, bbox, browser-direct | [Public domain — Vermont open data](https://vcgi.vermont.gov/) | "Vermont Center for Geographic Information (VCGI)" | | **Orthophotos de Wallonie** (`wallonie-ortho`, `geoservices.wallonie.be`) | tiles, bbox, browser-direct | [Conditions ouvertes SPW (attribution)](https://geoportail.wallonie.be/catalogue-donnees) | "© Service public de Wallonie" | | **Wisconsin Statewide Imagery (2020)** (`wi-statewide-imagery`, `di-wisdot.img.arcgis.com`) | tiles, bbox, browser-direct; operator adds `wi-statewide-imagery` to `GEV_LIBRARY_ENABLE` | [Open — WisconsinView / state open data](https://www.wisconsinview.org/) | "WisDOT - NAIP 2020" | diff --git a/changelog.d/1604-data-library.md b/changelog.d/1604-data-library.md index 385459daa..ec40f58f5 100644 --- a/changelog.d/1604-data-library.md +++ b/changelog.d/1604-data-library.md @@ -7,15 +7,15 @@ and travel in share links as `lib=`, separate from the layer tokens; one generic `/api/library` proxy serves entries without CORS. Seeded with NASA EONET events, EIA and WRI power plants, NWS alerts, NOAA tide stations, - Smithsonian volcanoes, NASA Black Marble, OpenSeaMap seamarks, and the + Smithsonian volcanoes, NASA Black Marble, and the opt-in IMF PortWatch and CCTV packs, plus the City of Amsterdam's traffic and ANPR camera positions (opt-in; contributed by @Maher-Reven in #251). FEMA USA Structures and the National Bridge Inventory load what is in view once you zoom in, and Addresscloud's EPC ratings stream from Source Cooperative's GeoParquet on S3, coloured A–G with live legend counts. Open tile overlays (geology, - biodiversity, population, surface water, national aerial photos and maps) - join GDACS alerts and EPA Superfund and TRI facilities. 64 aerial photo + biodiversity, surface water, national aerial photos and maps) + join GDACS alerts and EPA Superfund and TRI facilities. 57 aerial photo layers from the Open Imagery Register (national programmes across Europe, NSW, NAIP and US state, county and city services, WMS and ArcGIS export included) show their resolution, true-ortho flag, years flown and refresh diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c453e4f3a..bfedd6346 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -441,29 +441,28 @@ critical #d03b3b); categorical ones (fuel, EONET category, building occupancy) use at most four hues that pass all-pairs colour-vision checks on the dark globe, plus a neutral Other. Tile layers carry a static `display.legend` copied from the provider's own scale (GBIF classic.poly, -JRC occurrence, GIBS GPW, Macrostrat's ICS period colours); EMODnet publishes -no machine-readable scale and has none. Aerial photo layers (PDOK, IGN, GSI +JRC occurrence, Macrostrat's ICS period colours). Aerial photo layers (IGN, GSI photos, swisstopo SWISSIMAGE) set `display.outline`: their covered countries' borders draw as a thin white ground-clamped line, so the imagery's edge is plain, with a "Coverage outline" legend item. Borders come from Natural Earth's 1:50m admin-0 countries (public domain, fetched live once per page and credited); `countries` draws every part (Japan's islands, Liechtenstein beside -Switzerland), `coverage` only the parts inside `coverage.bbox` (the European -Netherlands, metropolitan France). A failed border fetch costs only the +Switzerland), `coverage` only the parts inside `coverage.bbox` (metropolitan +France). A failed border fetch costs only the outline. The same layers set `display.aerial`: they drape above every other imagery layer and raise themselves again whenever another drape is added, so a basemap or weather overlay switched on later goes underneath. Each is `source.bounded` (tiles requested inside `coverage.bbox` only) and names its provider's no-imagery fill in `display.noDataColor`, drawn transparent through -Cesium's colour-to-alpha (max channel distance 0.02): measured fills are PDOK -and swisstopo white, IGN's flat ocean blue (#0a3b59) and GSI's ocean grey -(#343434). Without both, PDOK's white fill covered the whole globe. A 4xx tile +Cesium's colour-to-alpha (max channel distance 0.02): measured fills are +swisstopo white, IGN's flat ocean blue (#0a3b59) and GSI's ocean grey +(#343434). Without both, a white fill covered the whole globe. A 4xx tile answer counts as "no imagery here", never as a tile error. IGN's overseas -departments are outside its single bbox and are not shown. The aerial set now holds 68 -entries: 64 imported from the Open Imagery Register (national programmes in -Austria, Belgium, Germany, Estonia, Spain, Luxembourg, Poland, Portugal, -Geneva, Australia's NSW, and 51 US services: NAIP plus state, county and -city programmes) plus PDOK, IGN, GSI and SWISSIMAGE, each with an `imagery` +departments are outside its single bbox and are not shown. The aerial set now holds 60 +entries: 57 imported from the Open Imagery Register (national programmes in +Belgium (Wallonia), Germany, Estonia, Spain, Poland, Portugal, Australia's +NSW, and 49 US services: NAIP plus state, county and city programmes) plus +IGN, GSI and SWISSIMAGE, each with an `imagery` block (type, published resolution, true ortho, years flown, refresh) that Browse shows as a spec list. Tiles take `xyz` (with `{reverseY}` for TMS) or `bbox` templates (`{bbox-epsg-3857}`: WMS GetMap and ArcGIS export, filled @@ -558,7 +557,7 @@ registered in the Data attribution popover while it is in use. Seeds: NASA EONET open events (GeoJSON, browser-direct), EIA US power plants ≥ 100 MW (ArcGIS, browser-direct, ranked by capacity), Smithsonian GVP Holocene volcanoes (WFS GeoJSON, proxied — the upstream sends no CORS headers), NASA -Black Marble 2016 night lights and OpenSeaMap seamarks (tiles), and the NYC, +Black Marble 2016 night lights (tiles), and the NYC, Lithuania and FL511 CCTV packs. Added from the Ask dataset registry (its live, mappable sources that are not core layers): the WRI Global Power Plant Database (provider-hosted CSV, the 10,000 largest of ~35,000 by capacity, so it reads @@ -576,10 +575,12 @@ open/freemium registry review (commercial sources excluded): GDACS disaster alerts; the National Bridge Inventory (view-bounded, coloured Good/Fair/Poor); EPA Superfund sites and Toxics Release Inventory facilities (view-bounded); and tile overlays for Macrostrat geology, GBIF biodiversity density, USGS -topographic and hydrography maps, PDOK (Netherlands) and IGN (France) aerial -photos, BKG TopPlusOpen, GSI Japan map and photos, EMODnet bathymetry, SEDAC -GPW 2020 population density (legend from the GIBS colour map) and JRC surface -water occurrence. `npm run library:check` validates manifests, +topographic and hydrography maps, IGN (France) aerial photos, BKG +TopPlusOpen, GSI Japan map and photos and JRC surface water occurrence. +Entries whose licence or provider pages no longer resolve (404 or unreachable +host, checked 2026-11-12) were removed: OpenSeaMap seamarks, EMODnet +bathymetry, SEDAC GPW population density, PDOK, basemap.at, Geneva SITG, +ICGC Catalunya, Luxembourg, Vlaanderen, Minnesota and Bangor aerial photos. `npm run library:check` validates manifests, the ledger (append-only with `--base-ref`, run in CI on pull requests) and the generated DATA_SOURCES.md section; `npm run library:probe` hits every upstream once; `npm run qa:data-library` is the browser gate (fixture-backed). diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 17241ac1d..6f40a9206 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -3501,8 +3501,6 @@ "src/library/entries/amsterdam-traffic-cameras.json", "src/library/entries/ar-statewide-imagery.json", "src/library/entries/az-statewide-imagery.json", - "src/library/entries/bangor-me.json", - "src/library/entries/basemap-at-orthofoto.json", "src/library/entries/bayern-dop40.json", "src/library/entries/bismarck-mandan-nd.json", "src/library/entries/bkg-topplus-open.json", @@ -3515,21 +3513,17 @@ "src/library/entries/de-statewide-imagery.json", "src/library/entries/ee-maa-amet-ortofoto.json", "src/library/entries/eia-power-plants.json", - "src/library/entries/emodnet-bathymetry.json", "src/library/entries/epa-superfund-sites.json", "src/library/entries/epa-toxic-release-facilities.json", "src/library/entries/epc-england-wales.json", "src/library/entries/fema-usa-structures.json", "src/library/entries/gbif-occurrence-density.json", "src/library/entries/gdacs-disaster-alerts.json", - "src/library/entries/geneva-sitg-ortho.json", - "src/library/entries/gpw-population-density.json", "src/library/entries/grand-forks-nd.json", "src/library/entries/gsi-japan-map.json", "src/library/entries/gsi-japan-photo.json", "src/library/entries/gvp-holocene-volcanoes.json", "src/library/entries/ia-statewide-imagery.json", - "src/library/entries/icgc-orto-catalunya.json", "src/library/entries/id-statewide-imagery.json", "src/library/entries/ign-orthophotos-france.json", "src/library/entries/il-statewide-imagery.json", @@ -3553,7 +3547,6 @@ "src/library/entries/lee-county-fl.json", "src/library/entries/loudoun-county-va.json", "src/library/entries/louisville-lojic-ky.json", - "src/library/entries/lu-ortho-latest.json", "src/library/entries/ma-statewide-imagery.json", "src/library/entries/macrostrat-geology.json", "src/library/entries/mapterhorn-terrain.json", @@ -3561,7 +3554,6 @@ "src/library/entries/md-statewide-imagery.json", "src/library/entries/me-statewide-imagery.json", "src/library/entries/mi-statewide-imagery.json", - "src/library/entries/mn-statewide-imagery.json", "src/library/entries/mountain-village-co.json", "src/library/entries/ms-statewide-imagery.json", "src/library/entries/mt-statewide-imagery.json", @@ -3583,10 +3575,8 @@ "src/library/entries/ny-statewide-imagery.json", "src/library/entries/oakland-county-mi.json", "src/library/entries/oh-statewide-imagery.json", - "src/library/entries/openseamap-seamarks.json", "src/library/entries/or-statewide-imagery.json", "src/library/entries/pa-statewide-imagery.json", - "src/library/entries/pdok-aerial-netherlands.json", "src/library/entries/pl-ortofotomapa.json", "src/library/entries/pnoa-ma.json", "src/library/entries/portland-metro-or.json", @@ -3598,7 +3588,6 @@ "src/library/entries/usgs-hydrography.json", "src/library/entries/usgs-topo.json", "src/library/entries/va-statewide-imagery.json", - "src/library/entries/vlaanderen-omw.json", "src/library/entries/vt-statewide-imagery.json", "src/library/entries/wallonie-ortho.json", "src/library/entries/wi-statewide-imagery.json", @@ -3608,7 +3597,6 @@ "src/library/entries/gvp-holocene-volcanoes.json", "src/library/entries/nasa-black-marble.json", "src/library/entries/nasa-eonet-events.json", - "src/library/entries/openseamap-seamarks.json", "src/library/catalog.js", "src/library/source.js", "src/library/layers.js", @@ -3624,11 +3612,9 @@ "src/library/entries/epc-england-wales.json", "src/library/parquet.js", "src/library/entries/bkg-topplus-open.json", - "src/library/entries/emodnet-bathymetry.json", "src/library/entries/epa-superfund-sites.json", "src/library/entries/epa-toxic-release-facilities.json", "src/library/entries/gbif-occurrence-density.json", - "src/library/entries/gpw-population-density.json", "src/library/entries/gdacs-disaster-alerts.json", "src/library/entries/gsi-japan-map.json", "src/library/entries/ign-orthophotos-france.json", @@ -3637,7 +3623,6 @@ "src/library/entries/ntad-national-bridges.json", "src/library/entries/usgs-hydrography.json", "src/library/entries/macrostrat-geology.json", - "src/library/entries/pdok-aerial-netherlands.json", "src/library/entries/swisstopo-swissimage.json", "src/library/entries/usgs-topo.json", "src/library/outlines.js", diff --git a/src/library/catalog.js b/src/library/catalog.js index 3a5319bb2..e1993a078 100644 --- a/src/library/catalog.js +++ b/src/library/catalog.js @@ -13,8 +13,6 @@ import akStatewideImagery from './entries/ak-statewide-imagery.json' with { type import arStatewideImagery from './entries/ar-statewide-imagery.json' with { type: 'json' }; import azStatewideImagery from './entries/az-statewide-imagery.json' with { type: 'json' }; import amsterdamTrafficCameras from './entries/amsterdam-traffic-cameras.json' with { type: 'json' }; -import bangorMe from './entries/bangor-me.json' with { type: 'json' }; -import basemapAtOrthofoto from './entries/basemap-at-orthofoto.json' with { type: 'json' }; import bayernDop40 from './entries/bayern-dop40.json' with { type: 'json' }; import bismarckMandanNd from './entries/bismarck-mandan-nd.json' with { type: 'json' }; import bkgTopplusOpen from './entries/bkg-topplus-open.json' with { type: 'json' }; @@ -27,21 +25,17 @@ import dcStatewideImagery from './entries/dc-statewide-imagery.json' with { type import deStatewideImagery from './entries/de-statewide-imagery.json' with { type: 'json' }; import eeMaaAmetOrtofoto from './entries/ee-maa-amet-ortofoto.json' with { type: 'json' }; import eiaPowerPlants from './entries/eia-power-plants.json' with { type: 'json' }; -import emodnetBathymetry from './entries/emodnet-bathymetry.json' with { type: 'json' }; import epaSuperfundSites from './entries/epa-superfund-sites.json' with { type: 'json' }; import epaToxicReleaseFacilities from './entries/epa-toxic-release-facilities.json' with { type: 'json' }; import epcEnglandWales from './entries/epc-england-wales.json' with { type: 'json' }; import femaUsaStructures from './entries/fema-usa-structures.json' with { type: 'json' }; import gbifOccurrenceDensity from './entries/gbif-occurrence-density.json' with { type: 'json' }; import gdacsDisasterAlerts from './entries/gdacs-disaster-alerts.json' with { type: 'json' }; -import genevaSitgOrtho from './entries/geneva-sitg-ortho.json' with { type: 'json' }; -import gpwPopulationDensity from './entries/gpw-population-density.json' with { type: 'json' }; import grandForksNd from './entries/grand-forks-nd.json' with { type: 'json' }; import gsiJapanMap from './entries/gsi-japan-map.json' with { type: 'json' }; import gsiJapanPhoto from './entries/gsi-japan-photo.json' with { type: 'json' }; import gvpHoloceneVolcanoes from './entries/gvp-holocene-volcanoes.json' with { type: 'json' }; import iaStatewideImagery from './entries/ia-statewide-imagery.json' with { type: 'json' }; -import icgcOrtoCatalunya from './entries/icgc-orto-catalunya.json' with { type: 'json' }; import idStatewideImagery from './entries/id-statewide-imagery.json' with { type: 'json' }; import ignOrthophotosFrance from './entries/ign-orthophotos-france.json' with { type: 'json' }; import ilStatewideImagery from './entries/il-statewide-imagery.json' with { type: 'json' }; @@ -65,7 +59,6 @@ import lawrenceKs from './entries/lawrence-ks.json' with { type: 'json' }; import leeCountyFl from './entries/lee-county-fl.json' with { type: 'json' }; import loudounCountyVa from './entries/loudoun-county-va.json' with { type: 'json' }; import louisvilleLojicKy from './entries/louisville-lojic-ky.json' with { type: 'json' }; -import luOrthoLatest from './entries/lu-ortho-latest.json' with { type: 'json' }; import maStatewideImagery from './entries/ma-statewide-imagery.json' with { type: 'json' }; import macrostratGeology from './entries/macrostrat-geology.json' with { type: 'json' }; import mapterhornTerrain from './entries/mapterhorn-terrain.json' with { type: 'json' }; @@ -73,7 +66,6 @@ import marisMs from './entries/maris-ms.json' with { type: 'json' }; import mdStatewideImagery from './entries/md-statewide-imagery.json' with { type: 'json' }; import meStatewideImagery from './entries/me-statewide-imagery.json' with { type: 'json' }; import miStatewideImagery from './entries/mi-statewide-imagery.json' with { type: 'json' }; -import mnStatewideImagery from './entries/mn-statewide-imagery.json' with { type: 'json' }; import mountainVillageCo from './entries/mountain-village-co.json' with { type: 'json' }; import msStatewideImagery from './entries/ms-statewide-imagery.json' with { type: 'json' }; import mtStatewideImagery from './entries/mt-statewide-imagery.json' with { type: 'json' }; @@ -95,10 +87,8 @@ import nwsActiveAlerts from './entries/nws-active-alerts.json' with { type: 'jso import nyStatewideImagery from './entries/ny-statewide-imagery.json' with { type: 'json' }; import oaklandCountyMi from './entries/oakland-county-mi.json' with { type: 'json' }; import ohStatewideImagery from './entries/oh-statewide-imagery.json' with { type: 'json' }; -import openseamapSeamarks from './entries/openseamap-seamarks.json' with { type: 'json' }; import orStatewideImagery from './entries/or-statewide-imagery.json' with { type: 'json' }; import paStatewideImagery from './entries/pa-statewide-imagery.json' with { type: 'json' }; -import pdokAerialNetherlands from './entries/pdok-aerial-netherlands.json' with { type: 'json' }; import plOrtofotomapa from './entries/pl-ortofotomapa.json' with { type: 'json' }; import pnoaMa from './entries/pnoa-ma.json' with { type: 'json' }; import portlandMetroOr from './entries/portland-metro-or.json' with { type: 'json' }; @@ -110,7 +100,6 @@ import unionCountyOh from './entries/union-county-oh.json' with { type: 'json' } import usgsHydrography from './entries/usgs-hydrography.json' with { type: 'json' }; import usgsTopo from './entries/usgs-topo.json' with { type: 'json' }; import vaStatewideImagery from './entries/va-statewide-imagery.json' with { type: 'json' }; -import vlaanderenOmw from './entries/vlaanderen-omw.json' with { type: 'json' }; import vtStatewideImagery from './entries/vt-statewide-imagery.json' with { type: 'json' }; import wallonieOrtho from './entries/wallonie-ortho.json' with { type: 'json' }; import wiStatewideImagery from './entries/wi-statewide-imagery.json' with { type: 'json' }; @@ -123,8 +112,6 @@ export const LIBRARY_MANIFESTS = Object.freeze([ amsterdamTrafficCameras, arStatewideImagery, azStatewideImagery, - bangorMe, - basemapAtOrthofoto, bayernDop40, bismarckMandanNd, bkgTopplusOpen, @@ -137,21 +124,17 @@ export const LIBRARY_MANIFESTS = Object.freeze([ deStatewideImagery, eeMaaAmetOrtofoto, eiaPowerPlants, - emodnetBathymetry, epaSuperfundSites, epaToxicReleaseFacilities, epcEnglandWales, femaUsaStructures, gbifOccurrenceDensity, gdacsDisasterAlerts, - genevaSitgOrtho, - gpwPopulationDensity, grandForksNd, gsiJapanMap, gsiJapanPhoto, gvpHoloceneVolcanoes, iaStatewideImagery, - icgcOrtoCatalunya, idStatewideImagery, ignOrthophotosFrance, ilStatewideImagery, @@ -175,7 +158,6 @@ export const LIBRARY_MANIFESTS = Object.freeze([ leeCountyFl, loudounCountyVa, louisvilleLojicKy, - luOrthoLatest, maStatewideImagery, macrostratGeology, mapterhornTerrain, @@ -183,7 +165,6 @@ export const LIBRARY_MANIFESTS = Object.freeze([ mdStatewideImagery, meStatewideImagery, miStatewideImagery, - mnStatewideImagery, mountainVillageCo, msStatewideImagery, mtStatewideImagery, @@ -205,10 +186,8 @@ export const LIBRARY_MANIFESTS = Object.freeze([ nyStatewideImagery, oaklandCountyMi, ohStatewideImagery, - openseamapSeamarks, orStatewideImagery, paStatewideImagery, - pdokAerialNetherlands, plOrtofotomapa, pnoaMa, portlandMetroOr, @@ -220,7 +199,6 @@ export const LIBRARY_MANIFESTS = Object.freeze([ usgsHydrography, usgsTopo, vaStatewideImagery, - vlaanderenOmw, vtStatewideImagery, wallonieOrtho, wiStatewideImagery, diff --git a/src/library/entries/bangor-me.json b/src/library/entries/bangor-me.json deleted file mode 100644 index ee3848d0e..000000000 --- a/src/library/entries/bangor-me.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "id": "bangor-me", - "kind": "tiles", - "family": "imagery", - "title": "Bangor, Maine Orthoimagery", - "provider": "City of Bangor GIS", - "description": "Aerial orthophotos: Bangor, Maine jurisdiction.", - "icon": "▣", - "maintainer": "github:asturksever", - "coverage": { - "countries": ["US"], - "bbox": [-68.9126, 44.7674, -68.6451, 44.8951] - }, - "source": { - "format": "xyz", - "url": "https://tiles.arcgis.com/tiles/yIxdYDwtUG29jfAN/arcgis/rest/services/2024_Orthoimagery_Tiles/MapServer/tile/{z}/{y}/{x}", - "fetch": "browser-direct", - "cacheSeconds": 86400, - "minZoom": 11, - "maxZoom": 22, - "bounded": true - }, - "display": { - "opacity": 1, - "outline": "bbox", - "aerial": true - }, - "imagery": { - "type": "ortho", - "resolution": "sub-foot to 1 ft (local program)", - "resolutionM": 0.15, - "trueOrtho": false, - "vintage": "2024–present", - "updateCycle": "Local flight cycle (typically annual to 3-yearly)", - "registry": "https://imagery-registry.netlify.app/" - }, - "licence": { - "name": "Local government open data — see publisher terms", - "url": "https://www.bangormaine.gov/", - "operatorOptIn": true - }, - "attribution": { - "text": "City of Bangor GIS", - "url": "https://www.bangormaine.gov/" - }, - "status": "community", - "reviewed": "2026-10-23" -} diff --git a/src/library/entries/basemap-at-orthofoto.json b/src/library/entries/basemap-at-orthofoto.json deleted file mode 100644 index 48e9eca2a..000000000 --- a/src/library/entries/basemap-at-orthofoto.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "id": "basemap-at-orthofoto", - "kind": "tiles", - "family": "imagery", - "title": "basemap.at Orthofoto", - "provider": "basemap.at / geoland.at", - "description": "Aerial orthophotos: 100% of Austria (mosaic of state orthophotos).", - "icon": "▣", - "maintainer": "github:asturksever", - "coverage": { - "countries": ["AT"], - "bbox": [9.53, 46.37, 17.17, 49.02] - }, - "source": { - "format": "xyz", - "url": "https://mapsneu.wien.gv.at/basemap/bmaporthofoto30cm/normal/google3857/{z}/{y}/{x}.jpeg", - "fetch": "browser-direct", - "cacheSeconds": 86400, - "minZoom": 6, - "maxZoom": 19, - "bounded": true - }, - "display": { - "opacity": 1, - "outline": "coverage", - "aerial": true, - "noDataColor": "#fdfdfd" - }, - "imagery": { - "type": "ortho", - "resolution": "20–30 cm", - "resolutionM": 0.2, - "trueOrtho": false, - "vintage": "2021–present", - "updateCycle": "Rolling (per state flight cycles)", - "registry": "https://imagery-registry.netlify.app/" - }, - "licence": { - "name": "CC BY 4.0", - "url": "https://basemap.at/nutzungsbedingungen/" - }, - "attribution": { - "text": "basemap.at — Datenquelle: basemap.at", - "url": "https://basemap.at/" - }, - "status": "community", - "reviewed": "2026-10-23" -} diff --git a/src/library/entries/emodnet-bathymetry.json b/src/library/entries/emodnet-bathymetry.json deleted file mode 100644 index 58b99fea3..000000000 --- a/src/library/entries/emodnet-bathymetry.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "id": "emodnet-bathymetry", - "kind": "tiles", - "family": "environment", - "title": "European seabed depth (EMODnet)", - "provider": "EMODnet", - "description": "Shaded relief of the seabed across European seas from the EMODnet Digital Bathymetry model.", - "icon": "≈", - "maintainer": "github:asturksever", - "coverage": { - "bbox": [-36, 15, 43, 90] - }, - "source": { - "format": "xyz", - "url": "https://tiles.emodnet-bathymetry.eu/2020/baselayer/web_mercator/{z}/{x}/{y}.png", - "fetch": "browser-direct", - "cacheSeconds": 86400, - "minZoom": 0, - "maxZoom": 12 - }, - "display": { - "opacity": 0.85 - }, - "licence": { - "name": "EMODnet data: free use with attribution", - "url": "https://emodnet.ec.europa.eu/en/emodnet-data-policy" - }, - "attribution": { - "text": "EMODnet Bathymetry Consortium — EMODnet Digital Bathymetry (DTM)", - "url": "https://emodnet.ec.europa.eu/en/bathymetry" - }, - "status": "community", - "reviewed": "2026-10-22" -} diff --git a/src/library/entries/geneva-sitg-ortho.json b/src/library/entries/geneva-sitg-ortho.json deleted file mode 100644 index 73eafc001..000000000 --- a/src/library/entries/geneva-sitg-ortho.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "id": "geneva-sitg-ortho", - "kind": "tiles", - "family": "imagery", - "title": "Orthophotos Canton de Genève (SITG)", - "provider": "SITG — Canton de Genève", - "description": "Aerial orthophotos: Canton of Geneva (~282 km²).", - "icon": "▣", - "maintainer": "github:asturksever", - "coverage": { - "countries": ["CH"], - "bbox": [5.95, 46.12, 6.32, 46.37] - }, - "source": { - "format": "bbox", - "url": "https://ge.ch/terags/rest/services/RASTER/ORTHOPHOTOS/MapServer/export?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=jpg&f=image", - "fetch": "browser-direct", - "cacheSeconds": 86400, - "minZoom": 10, - "maxZoom": 20, - "bounded": true - }, - "display": { - "opacity": 1, - "outline": "bbox", - "aerial": true, - "noDataColor": "#fdfdfd" - }, - "imagery": { - "type": "ortho", - "resolution": "5 cm", - "resolutionM": 0.05, - "trueOrtho": true, - "vintage": "2023–present", - "updateCycle": "Annual", - "registry": "https://imagery-registry.netlify.app/" - }, - "licence": { - "name": "SITG open data — free use with attribution", - "url": "https://ge.ch/sitg/media/sitg/documents/CGU_donnees_libres.pdf" - }, - "attribution": { - "text": "© SITG — Etat de Genève", - "url": "https://ge.ch/sitg/" - }, - "status": "community", - "reviewed": "2026-10-23" -} diff --git a/src/library/entries/gpw-population-density.json b/src/library/entries/gpw-population-density.json deleted file mode 100644 index 10e31493e..000000000 --- a/src/library/entries/gpw-population-density.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "id": "gpw-population-density", - "kind": "tiles", - "family": "demographics", - "title": "Population density 2020 (SEDAC GPW)", - "provider": "NASA GIBS", - "description": "Estimated people per square kilometre in 2020 from SEDAC's Gridded Population of the World v4.", - "icon": "◍", - "maintainer": "github:asturksever", - "coverage": { - "global": true - }, - "source": { - "format": "xyz", - "url": "https://gibs.earthdata.nasa.gov/wmts/epsg3857/best/GPW_Population_Density_2020/default/GoogleMapsCompatible_Level7/{z}/{y}/{x}.png", - "fetch": "browser-direct", - "cacheSeconds": 86400, - "minZoom": 0, - "maxZoom": 7 - }, - "display": { - "opacity": 0.75, - "legend": [ - { - "label": "< 1 people per km²", - "color": "#fff2d1" - }, - { - "label": "5–7.5 people per km²", - "color": "#fed59a" - }, - { - "label": "25–50 people per km²", - "color": "#faab4d" - }, - { - "label": "100–125 people per km²", - "color": "#fc9943" - }, - { - "label": "250–325 people per km²", - "color": "#f55e2c" - }, - { - "label": "≥ 1,000 people per km²", - "color": "#bd0026" - } - ] - }, - "licence": { - "name": "CC BY 4.0", - "url": "https://sedac.ciesin.columbia.edu/data/collection/gpw-v4/terms-of-use" - }, - "attribution": { - "text": "CIESIN, Columbia University — Gridded Population of the World v4.11, via NASA GIBS", - "url": "https://sedac.ciesin.columbia.edu/data/collection/gpw-v4" - }, - "status": "community", - "reviewed": "2026-10-22" -} diff --git a/src/library/entries/icgc-orto-catalunya.json b/src/library/entries/icgc-orto-catalunya.json deleted file mode 100644 index bb0280b22..000000000 --- a/src/library/entries/icgc-orto-catalunya.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "id": "icgc-orto-catalunya", - "kind": "tiles", - "family": "imagery", - "title": "Ortofoto de Catalunya 25 cm", - "provider": "ICGC", - "description": "Aerial orthophotos: Catalonia (~32,100 km²).", - "icon": "▣", - "maintainer": "github:asturksever", - "coverage": { - "countries": ["ES"], - "bbox": [0.15, 40.52, 3.34, 42.87] - }, - "source": { - "format": "xyz", - "url": "https://geoserveis.icgc.cat/icc_mapesmultibase/noutm/wmts/orto/GRID3857/{z}/{x}/{y}.jpeg", - "fetch": "browser-direct", - "cacheSeconds": 86400, - "minZoom": 7, - "maxZoom": 20, - "bounded": true - }, - "display": { - "opacity": 1, - "outline": "bbox", - "aerial": true - }, - "imagery": { - "type": "ortho", - "resolution": "25 cm (15 cm in cities)", - "resolutionM": 0.15, - "trueOrtho": false, - "vintage": "2022–present", - "updateCycle": "Annual", - "registry": "https://imagery-registry.netlify.app/" - }, - "licence": { - "name": "CC BY 4.0", - "url": "https://www.icgc.cat/en/Ambits-tematics/Ortofoto" - }, - "attribution": { - "text": "© Institut Cartogràfic i Geològic de Catalunya", - "url": "https://www.icgc.cat/" - }, - "status": "community", - "reviewed": "2026-10-23" -} diff --git a/src/library/entries/lu-ortho-latest.json b/src/library/entries/lu-ortho-latest.json deleted file mode 100644 index cbeb9bb6a..000000000 --- a/src/library/entries/lu-ortho-latest.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "id": "lu-ortho-latest", - "kind": "tiles", - "family": "imagery", - "title": "Orthophoto officielle du Luxembourg", - "provider": "ACT Luxembourg / geoportail.lu", - "description": "Aerial orthophotos: 100% of Luxembourg (~2,586 km²).", - "icon": "▣", - "maintainer": "github:asturksever", - "coverage": { - "countries": ["LU"], - "bbox": [5.73, 49.44, 6.53, 50.19] - }, - "source": { - "format": "xyz", - "url": "https://wmts1.geoportail.lu/opendata/wmts/ortho_latest/GLOBAL_WEBMERCATOR_4_V3/{z}/{x}/{y}.jpeg", - "fetch": "browser-direct", - "cacheSeconds": 86400, - "minZoom": 8, - "maxZoom": 20, - "bounded": true - }, - "display": { - "opacity": 1, - "outline": "coverage", - "aerial": true, - "noDataColor": "#ffffff" - }, - "imagery": { - "type": "ortho", - "resolution": "5–20 cm", - "resolutionM": 0.05, - "trueOrtho": false, - "vintage": "2023–present", - "updateCycle": "Annual (spring flight)", - "registry": "https://imagery-registry.netlify.app/" - }, - "licence": { - "name": "CC0 1.0 (Licence ouverte Luxembourg)", - "url": "https://data.public.lu/en/datasets/orthophoto-officelle-du-grand-duche-de-luxembourg-edition-2023/" - }, - "attribution": { - "text": "Administration du cadastre et de la topographie, Luxembourg (CC0)", - "url": "https://data.public.lu/en/datasets/?q=orthophoto" - }, - "status": "community", - "reviewed": "2026-10-23" -} diff --git a/src/library/entries/mn-statewide-imagery.json b/src/library/entries/mn-statewide-imagery.json deleted file mode 100644 index 7469e1b55..000000000 --- a/src/library/entries/mn-statewide-imagery.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "id": "mn-statewide-imagery", - "kind": "tiles", - "family": "imagery", - "title": "Minnesota Statewide Imagery", - "provider": "MnGeo", - "description": "Aerial orthophotos: Statewide coverage of Minnesota.", - "icon": "▣", - "maintainer": "github:asturksever", - "coverage": { - "countries": ["US"], - "bbox": [-97.24, 43.5, -89.49, 49.38] - }, - "source": { - "format": "bbox", - "url": "https://imageserver.gisdata.mn.gov/cgi-bin/wms?service=WMS&request=GetMap&version=1.1.1&layers=fsa&styles=&srs=EPSG:3857&bbox={bbox-epsg-3857}&width=256&height=256&format=image/jpeg", - "fetch": "browser-direct", - "cacheSeconds": 86400, - "minZoom": 6, - "maxZoom": 19, - "bounded": true - }, - "display": { - "opacity": 1, - "outline": "bbox", - "aerial": true, - "noDataColor": "#ffffff" - }, - "imagery": { - "type": "ortho", - "resolution": "60 cm (FSA/NAIP)", - "resolutionM": 0.6, - "trueOrtho": false, - "vintage": "2015–present", - "updateCycle": "NAIP 2-year cycle", - "registry": "https://imagery-registry.netlify.app/" - }, - "licence": { - "name": "Public domain / open — MnGeo terms", - "url": "https://www.mngeo.state.mn.us/" - }, - "attribution": { - "text": "MnGeo / State of Minnesota", - "url": "https://www.mngeo.state.mn.us/chouse/airphoto/" - }, - "status": "community", - "reviewed": "2026-10-23" -} diff --git a/src/library/entries/openseamap-seamarks.json b/src/library/entries/openseamap-seamarks.json deleted file mode 100644 index 5512aaec0..000000000 --- a/src/library/entries/openseamap-seamarks.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "id": "openseamap-seamarks", - "kind": "tiles", - "family": "infrastructure", - "title": "Seamarks (OpenSeaMap)", - "provider": "OpenSeaMap", - "description": "Buoys, beacons, lights and harbour marks from OpenSeaMap, drawn over any map. Useful with AIS vessels.", - "icon": "⚓", - "maintainer": "github:asturksever", - "coverage": { "global": true }, - "source": { - "format": "xyz", - "url": "https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png", - "fetch": "browser-direct", - "cacheSeconds": 86400, - "minZoom": 6, - "maxZoom": 18 - }, - "display": { "opacity": 1 }, - "licence": { - "name": "CC BY-SA 2.0; data © OpenStreetMap contributors (ODbL)", - "url": "https://openseamap.org/index.php?id=imprint&L=1" - }, - "attribution": { - "text": "© OpenSeaMap contributors, CC BY-SA; data © OpenStreetMap contributors", - "url": "https://openseamap.org/" - }, - "status": "community", - "reviewed": "2026-10-21" -} diff --git a/src/library/entries/pdok-aerial-netherlands.json b/src/library/entries/pdok-aerial-netherlands.json deleted file mode 100644 index 40401f241..000000000 --- a/src/library/entries/pdok-aerial-netherlands.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "id": "pdok-aerial-netherlands", - "kind": "tiles", - "family": "imagery", - "title": "Netherlands aerial photos (PDOK)", - "provider": "PDOK", - "description": "The latest high-resolution aerial photography of the whole Netherlands, from Beeldmateriaal Nederland.", - "icon": "▣", - "maintainer": "github:asturksever", - "coverage": { - "countries": ["NL"], - "bbox": [3.2, 50.7, 7.3, 53.6] - }, - "source": { - "format": "xyz", - "url": "https://service.pdok.nl/hwh/luchtfotorgb/wmts/v1_0/Actueel_orthoHR/EPSG:3857/{z}/{x}/{y}.jpeg", - "fetch": "browser-direct", - "cacheSeconds": 86400, - "minZoom": 6, - "maxZoom": 19, - "bounded": true - }, - "display": { - "opacity": 1, - "outline": "coverage", - "aerial": true, - "noDataColor": "#ffffff" - }, - "imagery": { - "type": "ortho", - "resolution": "7.5–8 cm", - "resolutionM": 0.08, - "trueOrtho": false, - "vintage": "2023–present", - "updateCycle": "Annual", - "registry": "https://imagery-registry.netlify.app/" - }, - "licence": { - "name": "CC BY 4.0", - "url": "https://www.pdok.nl/introductie/-/article/luchtfoto-pdok" - }, - "attribution": { - "text": "Beeldmateriaal Nederland via PDOK", - "url": "https://www.pdok.nl/" - }, - "status": "community", - "reviewed": "2026-10-23" -} diff --git a/src/library/entries/vlaanderen-omw.json b/src/library/entries/vlaanderen-omw.json deleted file mode 100644 index 5b3fa6eb5..000000000 --- a/src/library/entries/vlaanderen-omw.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "id": "vlaanderen-omw", - "kind": "tiles", - "family": "imagery", - "title": "Orthofotomozaïek Vlaanderen (winter)", - "provider": "Digitaal Vlaanderen", - "description": "Aerial orthophotos: Flanders + Brussels (~13,600 km²), flown yearly.", - "icon": "▣", - "maintainer": "github:asturksever", - "coverage": { - "countries": ["BE"], - "bbox": [2.54, 50.68, 5.92, 51.51] - }, - "source": { - "format": "bbox", - "url": "https://geo.api.vlaanderen.be/OMWRGBMRVL/wms?service=WMS&request=GetMap&version=1.3.0&layers=Ortho&styles=&crs=EPSG:3857&bbox={bbox-epsg-3857}&width=256&height=256&format=image/png", - "fetch": "browser-direct", - "cacheSeconds": 86400, - "minZoom": 8, - "maxZoom": 20, - "bounded": true - }, - "display": { - "opacity": 1, - "outline": "bbox", - "aerial": true, - "noDataColor": "#ffffff" - }, - "imagery": { - "type": "ortho", - "resolution": "10 cm (winter) / 25 cm (summer)", - "resolutionM": 0.1, - "trueOrtho": false, - "vintage": "2022–present", - "updateCycle": "Annual", - "registry": "https://imagery-registry.netlify.app/" - }, - "licence": { - "name": "Vlaanderen model licence for free reuse", - "url": "https://www.vlaanderen.be/digitaal-vlaanderen/onze-oplossingen/orthofotomozaieken" - }, - "attribution": { - "text": "© Digitaal Vlaanderen", - "url": "https://download.vlaanderen.be/" - }, - "status": "community", - "reviewed": "2026-10-23" -} diff --git a/src/library/ledger.json b/src/library/ledger.json index 9caf0feea..b2ff323aa 100644 --- a/src/library/ledger.json +++ b/src/library/ledger.json @@ -3,104 +3,93 @@ ["gvp-holocene-volcanoes", "01"], ["eia-power-plants", "02"], ["nasa-black-marble", "03"], - ["openseamap-seamarks", "04"], - ["wri-global-power-plants", "05"], - ["nws-active-alerts", "06"], - ["imf-portwatch-chokepoints", "07"], - ["imf-portwatch-disruptions", "08"], - ["noaa-tide-stations", "09"], - ["fema-usa-structures", "0a"], - ["epc-england-wales", "0b"], - ["macrostrat-geology", "0c"], - ["gbif-occurrence-density", "0d"], - ["usgs-topo", "0e"], - ["usgs-hydrography", "0f"], - ["pdok-aerial-netherlands", "0g"], - ["ign-orthophotos-france", "0h"], - ["bkg-topplus-open", "0i"], - ["gsi-japan-map", "0j"], - ["gsi-japan-photo", "0k"], - ["emodnet-bathymetry", "0l"], - ["gpw-population-density", "0m"], - ["jrc-surface-water", "0n"], - ["gdacs-disaster-alerts", "0o"], - ["ntad-national-bridges", "0p"], - ["epa-superfund-sites", "0q"], - ["epa-toxic-release-facilities", "0r"], - ["swisstopo-swissimage", "0s"], - ["ak-statewide-imagery", "0t"], - ["ar-statewide-imagery", "0u"], - ["az-statewide-imagery", "0v"], - ["bangor-me", "0w"], - ["basemap-at-orthofoto", "0x"], - ["bayern-dop40", "0y"], - ["bismarck-mandan-nd", "0z"], - ["ct-statewide-imagery", "10"], - ["dakota-county-mn", "11"], - ["dc-statewide-imagery", "12"], - ["de-statewide-imagery", "13"], - ["ee-maa-amet-ortofoto", "14"], - ["geneva-sitg-ortho", "15"], - ["grand-forks-nd", "16"], - ["ia-statewide-imagery", "17"], - ["icgc-orto-catalunya", "18"], - ["id-statewide-imagery", "19"], - ["il-statewide-imagery", "1a"], - ["in-statewide-imagery", "1b"], - ["ks-statewide-imagery", "1c"], - ["ky-statewide-imagery", "1d"], - ["lake-county-in", "1e"], - ["lawrence-ks", "1f"], - ["lee-county-fl", "1g"], - ["loudoun-county-va", "1h"], - ["louisville-lojic-ky", "1i"], - ["lu-ortho-latest", "1j"], - ["ma-statewide-imagery", "1k"], - ["maris-ms", "1l"], - ["md-statewide-imagery", "1m"], - ["me-statewide-imagery", "1n"], - ["mi-statewide-imagery", "1o"], - ["mn-statewide-imagery", "1p"], - ["mountain-village-co", "1q"], - ["ms-statewide-imagery", "1r"], - ["mt-statewide-imagery", "1s"], - ["naip", "1t"], - ["nc-statewide-imagery", "1u"], - ["nctcog-tx", "1v"], - ["nd-statewide-imagery", "1w"], - ["new-orleans-rpc-la", "1x"], - ["nh-statewide-imagery", "1y"], - ["nj-statewide-imagery", "1z"], - ["nrw-dop", "20"], - ["nsw-imagery", "21"], - ["nwa-benton-washington-ar", "22"], - ["ny-statewide-imagery", "23"], - ["oakland-county-mi", "24"], - ["oh-statewide-imagery", "25"], - ["or-statewide-imagery", "26"], - ["pa-statewide-imagery", "27"], - ["pl-ortofotomapa", "28"], - ["pnoa-ma", "29"], - ["portland-metro-or", "2a"], - ["pt-dgt-ortos", "2b"], - ["ri-statewide-imagery", "2c"], - ["tx-statewide-imagery", "2d"], - ["union-county-oh", "2e"], - ["va-statewide-imagery", "2f"], - ["vlaanderen-omw", "2g"], - ["vt-statewide-imagery", "2h"], - ["wallonie-ortho", "2i"], - ["wi-statewide-imagery", "2j"], - ["wv-statewide-imagery", "2k"], - ["kartverket-admin-units", "2l"], - ["kartverket-cadastre", "2m"], - ["kartverket-depth", "2n"], - ["kartverket-hillshade", "2o"], - ["kartverket-nautical-charts", "2p"], - ["kartverket-terrain-relief", "2q"], - ["kartverket-topo-grey", "2r"], - ["kartverket-topo", "2s"], - ["kartverket-toporaster", "2t"], - ["mapterhorn-terrain", "2u"], - ["amsterdam-traffic-cameras", "2v"] + ["wri-global-power-plants", "04"], + ["nws-active-alerts", "05"], + ["imf-portwatch-chokepoints", "06"], + ["imf-portwatch-disruptions", "07"], + ["noaa-tide-stations", "08"], + ["fema-usa-structures", "09"], + ["epc-england-wales", "0a"], + ["macrostrat-geology", "0b"], + ["gbif-occurrence-density", "0c"], + ["usgs-topo", "0d"], + ["usgs-hydrography", "0e"], + ["ign-orthophotos-france", "0f"], + ["bkg-topplus-open", "0g"], + ["gsi-japan-map", "0h"], + ["gsi-japan-photo", "0i"], + ["jrc-surface-water", "0j"], + ["gdacs-disaster-alerts", "0k"], + ["ntad-national-bridges", "0l"], + ["epa-superfund-sites", "0m"], + ["epa-toxic-release-facilities", "0n"], + ["swisstopo-swissimage", "0o"], + ["ak-statewide-imagery", "0p"], + ["ar-statewide-imagery", "0q"], + ["az-statewide-imagery", "0r"], + ["bayern-dop40", "0s"], + ["bismarck-mandan-nd", "0t"], + ["ct-statewide-imagery", "0u"], + ["dakota-county-mn", "0v"], + ["dc-statewide-imagery", "0w"], + ["de-statewide-imagery", "0x"], + ["ee-maa-amet-ortofoto", "0y"], + ["grand-forks-nd", "0z"], + ["ia-statewide-imagery", "10"], + ["id-statewide-imagery", "11"], + ["il-statewide-imagery", "12"], + ["in-statewide-imagery", "13"], + ["ks-statewide-imagery", "14"], + ["ky-statewide-imagery", "15"], + ["lake-county-in", "16"], + ["lawrence-ks", "17"], + ["lee-county-fl", "18"], + ["loudoun-county-va", "19"], + ["louisville-lojic-ky", "1a"], + ["ma-statewide-imagery", "1b"], + ["maris-ms", "1c"], + ["md-statewide-imagery", "1d"], + ["me-statewide-imagery", "1e"], + ["mi-statewide-imagery", "1f"], + ["mountain-village-co", "1g"], + ["ms-statewide-imagery", "1h"], + ["mt-statewide-imagery", "1i"], + ["naip", "1j"], + ["nc-statewide-imagery", "1k"], + ["nctcog-tx", "1l"], + ["nd-statewide-imagery", "1m"], + ["new-orleans-rpc-la", "1n"], + ["nh-statewide-imagery", "1o"], + ["nj-statewide-imagery", "1p"], + ["nrw-dop", "1q"], + ["nsw-imagery", "1r"], + ["nwa-benton-washington-ar", "1s"], + ["ny-statewide-imagery", "1t"], + ["oakland-county-mi", "1u"], + ["oh-statewide-imagery", "1v"], + ["or-statewide-imagery", "1w"], + ["pa-statewide-imagery", "1x"], + ["pl-ortofotomapa", "1y"], + ["pnoa-ma", "1z"], + ["portland-metro-or", "20"], + ["pt-dgt-ortos", "21"], + ["ri-statewide-imagery", "22"], + ["tx-statewide-imagery", "23"], + ["union-county-oh", "24"], + ["va-statewide-imagery", "25"], + ["vt-statewide-imagery", "26"], + ["wallonie-ortho", "27"], + ["wi-statewide-imagery", "28"], + ["wv-statewide-imagery", "29"], + ["kartverket-admin-units", "2a"], + ["kartverket-cadastre", "2b"], + ["kartverket-depth", "2c"], + ["kartverket-hillshade", "2d"], + ["kartverket-nautical-charts", "2e"], + ["kartverket-terrain-relief", "2f"], + ["kartverket-topo-grey", "2g"], + ["kartverket-topo", "2h"], + ["kartverket-toporaster", "2i"], + ["mapterhorn-terrain", "2j"], + ["amsterdam-traffic-cameras", "2k"] ] diff --git a/src/library/outlines.test.mjs b/src/library/outlines.test.mjs index a10dbaf18..ce492a850 100644 --- a/src/library/outlines.test.mjs +++ b/src/library/outlines.test.mjs @@ -26,7 +26,11 @@ const features = [ ]; test('coverage outlines keep only the parts inside the declared area', () => { - const rings = outlineRings(features, libraryEntry('pdok-aerial-netherlands')); + const netherlands = { + coverage: { countries: ['NL'], bbox: [3.2, 50.7, 7.3, 53.6] }, + display: { outline: 'coverage' }, + }; + const rings = outlineRings(features, netherlands); assert.equal(rings.length, 1); assert.equal(rings[0][0][0], 4); }); diff --git a/src/library/tileLayer.test.mjs b/src/library/tileLayer.test.mjs index c50df4b9a..d26ce18d4 100644 --- a/src/library/tileLayer.test.mjs +++ b/src/library/tileLayer.test.mjs @@ -35,7 +35,7 @@ test('aerial imagery drapes above every other imagery layer', async () => { tileProvider('https://base.example.org/{z}/{x}/{y}.png'), ); const photos = createLibraryTileLayer( - entryWithoutOutline('pdok-aerial-netherlands'), + entryWithoutOutline('ign-orthophotos-france'), { source, cesium: Cesium }, ); await photos.init(viewer); @@ -43,27 +43,27 @@ test('aerial imagery drapes above every other imagery layer', async () => { const top = () => collection.get(collection.length - 1); assert.equal(collection.length, 2); assert.notEqual(top(), base); - const pdok = top(); + const ign = top(); // A drape added later (weather, another library basemap) goes under it. const weather = collection.addImageryProvider( tileProvider('https://weather.example.org/{z}/{x}/{y}.png'), ); - assert.equal(top(), pdok); + assert.equal(top(), ign); assert.equal(collection.indexOf(weather), 1); - // PDOK's white "no imagery here" fill is drawn transparent, and its tiles - // are requested only inside the Netherlands bbox. - assert.ok(pdok.colorToAlpha.equals(Cesium.Color.WHITE)); + // IGN's dark blue "no imagery here" fill is drawn transparent, and its + // tiles are requested only inside the France bbox. assert.ok( - pdok.colorToAlphaThreshold > 0 && pdok.colorToAlphaThreshold < 0.05, + ign.colorToAlpha.equals(Cesium.Color.fromCssColorString('#0a3b59')), ); - const west = Cesium.Math.toDegrees(pdok.imageryProvider.rectangle.west); - assert.ok(Math.abs(west - 3.2) < 1e-9); + assert.ok(ign.colorToAlphaThreshold > 0 && ign.colorToAlphaThreshold < 0.05); + const west = Cesium.Math.toDegrees(ign.imageryProvider.rectangle.west); + assert.ok(Math.abs(west - -5.5) < 1e-9); // Off: the drape and its listener are gone, so later layers stay put. await photos.disable(); - assert.equal(collection.contains(pdok), false); + assert.equal(collection.contains(ign), false); const later = collection.addImageryProvider( tileProvider('https://later.example.org/{z}/{x}/{y}.png'), ); diff --git a/src/library/viewLayer.test.mjs b/src/library/viewLayer.test.mjs index 9f9fd7abc..77cb410dc 100644 --- a/src/library/viewLayer.test.mjs +++ b/src/library/viewLayer.test.mjs @@ -132,7 +132,7 @@ test('the row legend labels each colour and counts what is in view', async () => ); // A tile layer's static scale. assert.equal( - colorByLegend(libraryEntry('gpw-population-density').display).length, + colorByLegend(libraryEntry('gbif-occurrence-density').display).length, 6, ); assert.deepEqual( diff --git a/src/ui/libraryBrowser.test.mjs b/src/ui/libraryBrowser.test.mjs index 061024a09..b3e270093 100644 --- a/src/ui/libraryBrowser.test.mjs +++ b/src/ui/libraryBrowser.test.mjs @@ -135,12 +135,24 @@ test('the stat tiles summarise the library in words and compact numbers', () => test('an imagery card lists type, resolution, years flown and refresh', async () => { const { imagerySpecs } = await import('./libraryBrowser.js'); - const specs = imagerySpecs(libraryEntry('geneva-sitg-ortho')); + const specs = imagerySpecs({ + imagery: { + type: 'ortho', + trueOrtho: true, + resolution: '5 cm', + vintage: '2024', + updateCycle: 'Annual', + }, + }); assert.deepEqual( specs.map(({ label }) => label), ['Type', 'Resolution', 'Flown', 'Updates'], ); assert.equal(specs[0].value, 'True orthophoto'); assert.equal(specs[1].value, '5 cm'); + assert.equal( + imagerySpecs(libraryEntry('ign-orthophotos-france'))[0].value, + 'Orthophoto', + ); assert.deepEqual(imagerySpecs(libraryEntry('eia-power-plants')), []); }); From 24ccdd4dc35b65b3ee88992f9d1dea8e0a4908ac Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Thu, 12 Nov 2026 22:37:34 +0000 Subject: [PATCH 24/55] Fix Data Library share restore, dense tile layers and EPC partition reads - Share links: the compact /share/ format carries the switched-on library entries (field 29), so Copy Link keeps them; a share opened in the same tab restores them; any share decides what is on, so one with every library entry off no longer switches on the recipient's stored entries. - A tile entry too dense at its minimum zoom (Norway cadastre z14, depth z11) is hidden below that scale via minimumTerrainLevel instead of allocating millions of tiles when zoomed out. - EPC partitions keep the full extent for exclusion and the 98 % extent for ranking only; the old trimmed-only extents are retired. - Partition index footers that fail transiently are retried; an incomplete index is never cached as complete, a complete prior index is kept, and it is rebuilt after five minutes. - Views that leave touching partitions unread are partial; listing and footer bodies are capped and ranges validated. - The Browse dialog relabels after a language switch. --- docs/CURRENT-STATE.md | 18 +++ docs/SHARE-LINK-FORMAT.md | 68 ++++----- scripts/qa-data-library.mjs | 101 ++++++++++++++ server/providers/library.js | 41 +++++- server/providers/library.test.mjs | 53 ++++++- src/app/tools.js | 7 +- src/library/controller.js | 105 +++++++++++--- src/library/controller.test.mjs | 109 +++++++++++++++ src/library/layers.js | 46 +++++- src/library/parquet.js | 170 +++++++++++++++++------ src/library/parquet.test.mjs | 191 ++++++++++++++++++++++++- src/library/source.js | 193 ++++++++++++++++++++------ src/library/tileLayer.test.mjs | 34 ++++- src/share/legacyShareState.js | 1 + src/share/viewCodec.js | 42 ++++++ src/share/viewCodec.test.mjs | 15 ++ src/sharelink.js | 34 +++++ src/sharelink.navigation.test.mjs | 44 ++++++ src/ui/libraryBrowser.js | 56 ++++++-- src/ui/libraryBrowser.locale.test.mjs | 54 +++++++ 20 files changed, 1220 insertions(+), 162 deletions(-) create mode 100644 src/ui/libraryBrowser.locale.test.mjs diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index bfedd6346..41208cd6c 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -585,6 +585,24 @@ the ledger (append-only with `--base-ref`, run in CI on pull requests) and the generated DATA_SOURCES.md section; `npm run library:probe` hits every upstream once; `npm run qa:data-library` is the browser gate (fixture-backed). +Shares: the compact `/share/` link carries the switched-on entries in +field 29 (docs/SHARE-LINK-FORMAT.md), so Copy Link keeps them, and a share +opened later in the same tab (pasted address, Back/Forward) restores them too. +Any share decides what is on, as for core layers: one with no library entry +on switches the recipient's stored ones off and leaves them listed. A tile +entry whose coverage needs more than 256 tiles at its minimum zoom (Norway's +cadastre from z14, sea depths from z11) is hidden below that scale through +Cesium's `minimumTerrainLevel`, so zoomed out it requests nothing. EPC +partitions learn two extents (`gev:library:extent:v2:`): every point's box +rules a partition out, the 98 % box only ranks it, so homes at a partition's +edge are found on later visits; the v1 trimmed-only key is dropped. A +partition index whose footers keep failing after two retries is +`complete: false`: neither the server nor the browser keeps it as whole, a +complete earlier index is served instead, and it is rebuilt after five +minutes. A view that leaves partitions touching it unread is PARTIAL. Listing +pages and footer reads are size-capped and ranges are validated. The Browse +dialog rebuilds on a language switch, keeping its filters. + ## Utilities panel — October 29, 2026 UTILITIES is its own left-side panel, between Data Layers and Scenes, with diff --git a/docs/SHARE-LINK-FORMAT.md b/docs/SHARE-LINK-FORMAT.md index 9c61fc7cd..4c3254a99 100644 --- a/docs/SHARE-LINK-FORMAT.md +++ b/docs/SHARE-LINK-FORMAT.md @@ -117,36 +117,37 @@ probability of about 255 in 256 (many of them already fail as base64url). ## Field registry (top level) -| # | State key | Type | Default | Encoding | -| --- | ---------------------------- | ----- | ----------- | ----------------------------------------------------------------- | -| 1 | `view.kind` | enum | see below | 0 place, 1 thing | -| 2 | `lat` | coord | 0 | zigzag of degrees × 10⁴ | -| 3 | `lon` | coord | 0 | zigzag of degrees × 10⁴ | -| 4 | `alt` | sint | 800 | whole metres | -| 5 | `heading` | angle | 0 | whole degrees, see angles | -| 6 | `pitch` | sint | −35 | whole degrees | -| 7 | `roll` | angle | 0 | whole degrees | -| 8 | `sharedAtMs` | epoch | none | zigzag seconds since 2026-01-01T00:00:00Z | -| 9 | booleans | flags | 0 | bit set when the value differs from default | -| 10 | `layerState.enabledLayerIds` | LEN | none | layer bitset, see layers | -| 11 | `layerState.options` | LEN | none | packed option triples, see layers | -| 12 | `panelState` | LEN | none | packed panel entries, see panels | -| 13 | thing | LEN | none | sub-message, see thing views | -| 14 | `style` | enum | `normal` | normal, retro, surveillance, thermal, anime, noir, snow (retired) | -| 15 | `mapStack` | enum | `photoreal` | photoreal, bing-aerial, bing-labels, esri-imagery, osm | -| 16 | `hudVariant` | enum | style | tactical, operator (retired), minimal, cyber | -| 17 | `bloomIntensity` | uint | style | | -| 18 | `bloomVersion` | uint | 2 | | -| 19 | `sharpenIntensity` | uint | style | | -| 20 | `detectionMode` | enum | style | OFF, SPARSE, BALANCED, DENSE | -| 21 | `detectionDensity` | uint | style | one of 0, 25, 50, 75, 100 | -| 22 | `detectionAllocation` | enum | style | ELASTIC, WEIGHTED | -| 23 | `detectionFadePct` | uint | style | 0–40 | -| 24 | `detectionOutsideOpacityPct` | uint | style | 0–100 | -| 25 | `scopeFeatherPct` | uint | 100 | 0–200 | -| 26 | `scopeTerminusPct` | uint | none | 94–100; absent is the adaptive terminus | -| 27 | `styleParams` | LEN | style | packed `(token, value × 100)` pairs | -| 28 | `worldTime` | epoch | none (live) | zigzag seconds since 2026-01-01T00:00:00Z | +| # | State key | Type | Default | Encoding | +| --- | ---------------------------- | ----- | ----------- | --------------------------------------------------------------------- | +| 1 | `view.kind` | enum | see below | 0 place, 1 thing | +| 2 | `lat` | coord | 0 | zigzag of degrees × 10⁴ | +| 3 | `lon` | coord | 0 | zigzag of degrees × 10⁴ | +| 4 | `alt` | sint | 800 | whole metres | +| 5 | `heading` | angle | 0 | whole degrees, see angles | +| 6 | `pitch` | sint | −35 | whole degrees | +| 7 | `roll` | angle | 0 | whole degrees | +| 8 | `sharedAtMs` | epoch | none | zigzag seconds since 2026-01-01T00:00:00Z | +| 9 | booleans | flags | 0 | bit set when the value differs from default | +| 10 | `layerState.enabledLayerIds` | LEN | none | layer bitset, see layers | +| 11 | `layerState.options` | LEN | none | packed option triples, see layers | +| 12 | `panelState` | LEN | none | packed panel entries, see panels | +| 13 | thing | LEN | none | sub-message, see thing views | +| 14 | `style` | enum | `normal` | normal, retro, surveillance, thermal, anime, noir, snow (retired) | +| 15 | `mapStack` | enum | `photoreal` | photoreal, bing-aerial, bing-labels, esri-imagery, osm | +| 16 | `hudVariant` | enum | style | tactical, operator (retired), minimal, cyber | +| 17 | `bloomIntensity` | uint | style | | +| 18 | `bloomVersion` | uint | 2 | | +| 19 | `sharpenIntensity` | uint | style | | +| 20 | `detectionMode` | enum | style | OFF, SPARSE, BALANCED, DENSE | +| 21 | `detectionDensity` | uint | style | one of 0, 25, 50, 75, 100 | +| 22 | `detectionAllocation` | enum | style | ELASTIC, WEIGHTED | +| 23 | `detectionFadePct` | uint | style | 0–40 | +| 24 | `detectionOutsideOpacityPct` | uint | style | 0–100 | +| 25 | `scopeFeatherPct` | uint | 100 | 0–200 | +| 26 | `scopeTerminusPct` | uint | none | 94–100; absent is the adaptive terminus | +| 27 | `styleParams` | LEN | style | packed `(token, value × 100)` pairs | +| 28 | `worldTime` | epoch | none (live) | zigzag seconds since 2026-01-01T00:00:00Z | +| 29 | `library` | LEN | none | Data Library ledger tokens, two base-36 characters each, concatenated | Field 1 absent means `thing` when field 13 is present and `place` otherwise, so v1 never writes it; a later view kind writes its code. @@ -164,6 +165,11 @@ Field 28 is the world time the link was made at, written only when it was away from live. A link without it opens live. Unlike field 8 it may lie ahead of the link's creation (a forecast moment), and it opens paused at that time. +Field 29 lists the Data Library entries switched on, by their permanent +tokens in `src/library/ledger.json` (the long form's `lib=ab.cd`). A share +without it switches no library entry on; the recipient's added entries stay +in their panel, off. Tokens this build does not know are skipped. + Flag bits (field 9): 0 `bloom`, 1 `sharpen`, 2 `hudVisible`, 3 `celestialRing` (each defaulting by style), 4 `scopeEnabled` (default on). @@ -391,7 +397,7 @@ the app that can open it. offers keeps its code: `snow` (6) is retired, so a link that names it opens as Normal and the parameters written for it are dropped. 5. **Add, don't change.** A new setting takes the next free field number - (28 onwards; fields 1–15 have one-byte tags and are all taken). A new + (30 onwards; fields 1–15 have one-byte tags and are all taken). A new boolean takes the next flag bit instead, which costs nothing when it is at its default. 6. **New view kinds and entity kinds** get the next code. A new entity kind's diff --git a/scripts/qa-data-library.mjs b/scripts/qa-data-library.mjs index e8adc49c7..f37f38fbb 100644 --- a/scripts/qa-data-library.mjs +++ b/scripts/qa-data-library.mjs @@ -629,6 +629,107 @@ try { assert.deepEqual(stored, { added: [], on: [] }); }); + const onIds = (target) => + target.evaluate(() => + window.__gevDataLibrary.library + .list() + .filter((row) => row.on) + .map((row) => row.entry.id) + .sort(), + ); + + await step( + 'a copied /share link opens the same library layers in a clean browser', + async () => { + await page.evaluate(async () => { + const { library } = window.__gevDataLibrary; + await library.add('nasa-eonet-events'); + await library.add('nasa-black-marble'); + }); + await page.waitForFunction( + () => /(?:^|&)lib=00\.03(?:&|$)/.test(location.hash.slice(1)), + { timeout: 15_000 }, + ); + const href = await page.evaluate(() => + window.__gevDataLibrary.buildShareLink(), + ); + assert.match(new URL(href).pathname, /\/share\//); + // A separate profile: nothing stored, nothing added. + const context = await browser.createBrowserContext(); + try { + const recipient = await context.newPage(); + await recipient.setRequestInterception(true); + recipient.on('request', (request) => { + const fixture = fixtureFor(new URL(request.url())); + if (fixture) request.respond(fixture); + else request.continue(); + }); + await recipient.evaluateOnNewDocument(() => { + try { + localStorage.setItem('gev:first-run-mission:v1', 'suppressed'); + } catch { + /* the gate still runs */ + } + }); + await recipient.goto(href, { waitUntil: 'domcontentloaded' }); + await recipient.waitForFunction( + () => window.__gevDataLibrary?.library.ready, + { timeout: 180_000 }, + ); + await recipient.waitForFunction( + () => + window.__gevDataLibrary.library.list().filter((row) => row.on) + .length === 2, + { timeout: 60_000 }, + ); + assert.deepEqual(await onIds(recipient), [ + 'nasa-black-marble', + 'nasa-eonet-events', + ]); + } finally { + await context.close(); + } + }, + ); + + await step( + 'a share opened in the same tab switches library layers on and off', + async () => { + const navigate = (lib) => + page.evaluate((value) => { + const params = new URLSearchParams(location.hash.slice(1)); + params.set('lat', String(Number(params.get('lat')) + 1)); + if (value === null) params.delete('lib'); + else params.set('lib', value); + location.hash = params.toString(); + }, lib); + await navigate('00'); + await page.waitForFunction( + () => { + const on = window.__gevDataLibrary.library + .list() + .filter((row) => row.on) + .map((row) => row.entry.id); + return on.length === 1 && on[0] === 'nasa-eonet-events'; + }, + { timeout: 30_000 }, + ); + // A share with every library layer off: the added ones stay listed, off. + await navigate(null); + await page.waitForFunction( + () => window.__gevDataLibrary.library.list().every((row) => !row.on), + { timeout: 30_000 }, + ); + assert.deepEqual( + (await libraryRows(page)).map(({ id, on }) => [id, on]).sort(), + [ + ['nasa-black-marble', false], + ['nasa-eonet-events', false], + ], + ); + }, + ); + assert.deepEqual(errors, [], 'no library errors on the page'); console.log(`# ${count} passed`); } finally { diff --git a/server/providers/library.js b/server/providers/library.js index c01b45aa3..b0c17ebef 100644 --- a/server/providers/library.js +++ b/server/providers/library.js @@ -19,6 +19,8 @@ const DATASET_TIMEOUT_MS = 30_000; const TILE_TIMEOUT_MS = 10_000; /** Each listing page or footer read while building a partition index. */ const INDEX_TIMEOUT_MS = 20_000; +/** How long an incomplete partition index is served before a rebuild. */ +const PARTIAL_INDEX_MS = 5 * 60_000; const TILE_MAX_BYTES = 2 * 1024 * 1024; const TILE_CACHE_LIMIT = 512; const IN_FLIGHT_LIMIT = 64; @@ -107,6 +109,7 @@ export function libraryProxy({ const datasets = new Map(); const tiles = new Map(); const indexes = new Map(); + const partialIndexes = new Map(); const inFlight = new Map(); const allowData = makeRateLimiter({ windowMs: 60_000, @@ -231,25 +234,51 @@ export function libraryProxy({ if (!available) return json(res, 403, { error: reason }); if (!allowData(clientKey(req))) return json(res, 429, { error: 'rate_limited' }); + // Only a complete index is cached for cacheSeconds. An incomplete one + // (footers that kept failing) is served as `complete: false` and kept + // for PARTIAL_INDEX_MS only, so the next request soon rebuilds it; a + // complete index from before keeps being served while rebuilds fail. + const key = `index:${id}`; + const partial = partialIndexes.get(key); + if (partial && now() - partial.savedAt < PARTIAL_INDEX_MS) { + const previous = indexes.get(key); + return json(res, 200, previous?.value ?? partial.value); + } try { const { value } = await cached( indexes, - `index:${id}`, + key, entry.source.cacheSeconds * 1000, - async () => ({ - partitions: await buildPartitionIndex(entry, { + async () => { + const built = await buildPartitionIndex(entry, { fetchImpl: (url, options = {}) => fetchImpl(url, { ...options, signal: options.signal ?? AbortSignal.timeout(INDEX_TIMEOUT_MS), }), - }), - builtAt: now(), - }), + }); + const value = { + partitions: built.partitions, + complete: built.complete, + builtAt: now(), + }; + if (!built.complete) { + // Recorded here too: with an earlier complete index, cached() + // serves that one and swallows this error, and the rebuild + // still waits PARTIAL_INDEX_MS. + partialIndexes.set(key, { value, savedAt: now() }); + throw Object.assign(new Error('index_incomplete'), { + partialIndex: value, + }); + } + partialIndexes.delete(key); + return value; + }, ); return json(res, 200, value); } catch (error) { + if (error?.partialIndex) return json(res, 200, error.partialIndex); const status = error?.status === 429 ? 429 : 502; return json(res, status, { error: 'library_upstream_unavailable' }); } diff --git a/server/providers/library.test.mjs b/server/providers/library.test.mjs index eca9f7060..2e009b33c 100644 --- a/server/providers/library.test.mjs +++ b/server/providers/library.test.mjs @@ -6,6 +6,7 @@ import { parseLibraryTilePath, } from './library.js'; import { validateLibraryCatalog } from '../../src/library/manifest.js'; +import { libraryEntry } from '../../src/library/catalog.js'; const base = { family: 'events', @@ -100,8 +101,8 @@ const point = (title) => ({ properties: { title }, }); -function install({ env = {}, fetchImpl, now } = {}) { - const plugin = libraryProxy({ entries, env, fetchImpl, now }); +function install({ env = {}, fetchImpl, now, catalog = entries } = {}) { + const plugin = libraryProxy({ entries: catalog, env, fetchImpl, now }); let handler; plugin.configurePreviewServer({ middlewares: { @@ -330,3 +331,51 @@ test('only GET is served', async () => { await plugin.handler({ url: '/catalog', method: 'POST', headers: {} }, res); assert.equal(res.status, 405); }); + +test('a partition index is cached only when complete; a complete one outlives failed rebuilds', async () => { + const epc = libraryEntry('epc-england-wales'); + let clock = 0; + let footers = 'ok'; + let listings = 0; + const listing = `a.parquet100`; + const fetchImpl = async (url) => { + if (url.includes('list-type')) { + listings += 1; + return new Response(listing); + } + return footers === 'ok' + ? new Response(new Uint8Array(100)) + : new Response('', { status: 503 }); + }; + const request = install({ catalog: [epc], now: () => clock, fetchImpl }); + const first = await request('/index/epc-england-wales'); + assert.equal(first.status, 200); + assert.equal(first.body.complete, true); + const builtAt = first.body.builtAt; + + // Past cacheSeconds the rebuild fails: the complete index is still served, + // and no rebuild runs again until the partial pause has passed. + clock += (epc.source.cacheSeconds + 1) * 1000; + footers = 'fail'; + const kept = await request('/index/epc-england-wales'); + assert.equal(kept.body.complete, true); + assert.equal(kept.body.builtAt, builtAt); + const listed = listings; + await request('/index/epc-england-wales'); + assert.equal(listings, listed); + + // A server that never had a complete index serves the partial one as such, + // and rebuilds after the pause. + const fresh = install({ catalog: [epc], now: () => clock, fetchImpl }); + const partial = await fresh('/index/epc-england-wales'); + assert.equal(partial.status, 200); + assert.equal(partial.body.complete, false); + const before = listings; + await fresh('/index/epc-england-wales'); + assert.equal(listings, before); + clock += 6 * 60_000; + footers = 'ok'; + const rebuilt = await fresh('/index/epc-england-wales'); + assert.equal(rebuilt.body.complete, true); + assert.equal(listings, before + 1); +}); diff --git a/src/app/tools.js b/src/app/tools.js index 927e343bb..f22d97b7f 100644 --- a/src/app/tools.js +++ b/src/app/tools.js @@ -142,7 +142,12 @@ export function createApplicationTools({ if (!signal?.aborted) console.warn('[Library] start failed:', error); }); // The live gate's handle (scripts/qa-data-library.mjs). - const libraryHandle = { library: dataLibrary, browser: libraryBrowser }; + const libraryHandle = { + library: dataLibrary, + browser: libraryBrowser, + // What Copy Link would copy. + buildShareLink: () => styleManager.shareLinkManager?.buildLink?.() ?? null, + }; window.__gevDataLibrary = libraryHandle; defer(() => { if (window.__gevDataLibrary === libraryHandle) diff --git a/src/library/controller.js b/src/library/controller.js index 49c02bbae..b90654c7b 100644 --- a/src/library/controller.js +++ b/src/library/controller.js @@ -69,6 +69,9 @@ export function createDataLibrary({ let serverPresent = false; let selection = { added: [], on: [] }; let started = false; + // Set once availability is known; a share navigated to before then waits. + let loaded = false; + let queuedShare = null; let destroyed = false; const listeners = new Set(); const pending = new Map(); @@ -165,6 +168,73 @@ export function createDataLibrary({ persist(); }); + /** Entry ids for share tokens; unknown tokens are skipped. */ + const idsForTokens = (list) => + (list || []).map((token) => tokens.idByToken.get(token)).filter(Boolean); + + /** + * The share this page opened with: null without one, else the entry ids it + * switches on ([] for a share that switches none on). + */ + function openingShare() { + if (shareLink && shareLink.initialLibraryTokens !== undefined) { + const linked = shareLink.initialLibraryTokens; + return linked === null ? null : idsForTokens(linked); + } + const hash = String(initialHash || '').replace(/^#/, ''); + if (!hash) return null; + const params = new URLSearchParams(hash); + const linked = decodeLibraryParam(params, tokens.idByToken); + // Any share (a camera position) decides what is on; no `lib`, nothing. + if (linked === null && params.has('lat') && params.has('lon')) return []; + return linked; + } + + /** + * Apply a selection: register and switch on what `on` names, switch off + * every other added entry that is on. `origin` marks it as a restore so it + * is never remembered as the viewer's own choice. + */ + function applySelection(next, origin) { + selection = normalizeLibrarySelection(next, switchable); + for (const id of selection.added) { + const entry = byId.get(id); + if (!entry || !addable(id)) continue; + const wanted = selection.on.includes(id); + if (wanted && !register(entry)) continue; + if (!isRegistered(id)) continue; + const layerId = libraryLayerId(id); + if (wanted === (dataManager.isEnabled?.(layerId) === true)) continue; + dataManager + .setEnabled(layerId, wanted, { origin }) + .catch((error) => + console.warn(`[Library] ${id} restore failed:`, error), + ); + } + writeStoredLibrarySelection(storage, selection); + shareLink?.onLibraryStateChange?.(); + notify(); + } + + /** + * A share opened in this tab after start-up: its entries join the library + * and it alone decides which are on. + * @param {string[]} shareTokens + */ + function applyShare(shareTokens) { + if (destroyed) return; + if (!loaded) { + queuedShare = [...(shareTokens || [])]; + return; + } + const linked = idsForTokens(shareTokens); + applySelection( + { added: [...selection.added, ...linked], on: linked }, + 'share-restore', + ); + } + shareLink?.setLibraryShareListener?.(applyShare); + shareLink?.setLibraryParamsEncoder?.((params) => encodeLibraryParam( params, @@ -186,29 +256,23 @@ export function createDataLibrary({ availability = answer.availability; serverPresent = answer.server; const stored = readStoredLibrarySelection(storage, switchable); - const hash = String(initialHash || '').replace(/^#/, ''); - const linked = hash - ? decodeLibraryParam(new URLSearchParams(hash), tokens.idByToken) - : null; + // A share opened in this tab while availability loaded wins over the + // opening one. + const linked = queuedShare ? idsForTokens(queuedShare) : openingShare(); + queuedShare = null; + loaded = true; // A link's entries join the viewer's library and open switched on; with a - // link, it alone decides what is on, as it does for core layers. - const added = [...stored.added, ...(linked || [])]; - const on = linked ?? stored.on; - selection = normalizeLibrarySelection({ added, on }, switchable); - const restoreOrigin = linked ? 'share-restore' : 'local-restore'; - for (const id of selection.added) { + // link, it alone decides what is on, as it does for core layers, so a + // share with every library entry off switches the stored ones off. + // Added entries register even while off, so their rows show. + for (const id of [...stored.added, ...(linked || [])]) { const entry = byId.get(id); - if (!entry || !addable(id) || !register(entry)) continue; - if (selection.on.includes(id)) - dataManager - .setEnabled(libraryLayerId(id), true, { origin: restoreOrigin }) - .catch((error) => - console.warn(`[Library] ${id} restore failed:`, error), - ); + if (entry && addable(id)) register(entry); } - writeStoredLibrarySelection(storage, selection); - shareLink?.onLibraryStateChange?.(); - notify(); + applySelection( + { added: [...stored.added, ...(linked || [])], on: linked ?? stored.on }, + linked ? 'share-restore' : 'local-restore', + ); } /** @@ -388,6 +452,7 @@ export function createDataLibrary({ destroyed = true; unsubscribeManager?.(); shareLink?.setLibraryParamsEncoder?.(null); + shareLink?.setLibraryShareListener?.(null); listeners.clear(); }, }; diff --git a/src/library/controller.test.mjs b/src/library/controller.test.mjs index 2e32a120b..53e553233 100644 --- a/src/library/controller.test.mjs +++ b/src/library/controller.test.mjs @@ -207,6 +207,115 @@ test('a link decides what is on and adds its entries to the library', async (t) }); }); +/** A ShareLinkManager stand-in: the opening share and in-tab navigation. */ +function fakeShareLink(initialLibraryTokens) { + const link = { + initialLibraryTokens, + listener: null, + setLibraryShareListener(listener) { + link.listener = listener; + }, + setLibraryParamsEncoder() {}, + onLibraryStateChange() {}, + }; + return link; +} + +test('a share with every library entry off keeps the stored ones off', async (t) => { + const manager = sealedManager(t); + const storage = memoryStorage( + JSON.stringify({ + added: ['night-lights', 'sea-marks'], + on: ['night-lights', 'sea-marks'], + }), + ); + // A compact /share/ link with no library field, and a long-form link + // with no lib=: both are shares that switch no library entry on. + for (const options of [ + { shareLink: fakeShareLink([]) }, + { initialHash: '#lat=1&lon=2&l=' }, + ]) { + const library = createDataLibrary({ + entries, + ledger, + dataManager: manager, + services: {}, + storage, + source: source({}), + ...options, + }); + await library.start(); + assert.equal(manager.isEnabled('lib-night-lights'), false); + assert.equal(manager.isEnabled('lib-sea-marks'), false); + // The recipient's entries stay in their panel, off. + assert.deepEqual( + library + .list() + .filter((row) => row.added) + .map((row) => row.entry.id), + ['night-lights', 'sea-marks'], + ); + library.destroy(); + } +}); + +test('the opening share link restores its library tokens', async (t) => { + const manager = sealedManager(t); + const storage = memoryStorage( + JSON.stringify({ added: ['night-lights'], on: ['night-lights'] }), + ); + const library = createDataLibrary({ + entries, + ledger, + dataManager: manager, + services: {}, + storage, + shareLink: fakeShareLink(['01']), + source: source({}), + }); + await library.start(); + await manager.waitForLayerSettled?.('lib-sea-marks'); + assert.equal(manager.isEnabled('lib-sea-marks'), true); + assert.equal(manager.isEnabled('lib-night-lights'), false); +}); + +test('navigating between shares switches library entries on and off', async (t) => { + const manager = sealedManager(t); + const shareLink = fakeShareLink(['00']); + const library = createDataLibrary({ + entries, + ledger, + dataManager: manager, + services: {}, + storage: memoryStorage(), + shareLink, + source: source({}), + }); + // A share navigated to before availability loads waits for it and wins. + const started = library.start(); + shareLink.listener(['01']); + await started; + const settle = () => + Promise.all( + ['lib-night-lights', 'lib-sea-marks'].map((id) => + manager.waitForLayerSettled?.(id), + ), + ); + await settle(); + assert.equal(manager.isEnabled('lib-sea-marks'), true); + assert.equal(manager.isEnabled('lib-night-lights'), false); + shareLink.listener(['00']); + await settle(); + assert.equal(manager.isEnabled('lib-night-lights'), true); + assert.equal(manager.isEnabled('lib-sea-marks'), false); + shareLink.listener([]); + await settle(); + assert.equal(manager.isEnabled('lib-night-lights'), false); + assert.equal(manager.isEnabled('lib-sea-marks'), false); + library.destroy(); + assert.equal(shareLink.listener, null); +}); + test('zoom flies to an added layer through the caller, reset removes everything', async (t) => { const manager = sealedManager(t); const storage = memoryStorage(); diff --git a/src/library/layers.js b/src/library/layers.js index 68d382dc6..42b6eb237 100644 --- a/src/library/layers.js +++ b/src/library/layers.js @@ -258,6 +258,44 @@ const reliefLegend = () => /** colorToAlpha threshold (max channel distance, 0–1) for `noDataColor`. */ const NO_DATA_THRESHOLD = 0.02; +/** The most tiles an entry's coverage may need at its minimum zoom. */ +const COARSE_TILE_BUDGET = 256; + +function webMercatorTileSpan(bbox, zoom) { + const n = 2 ** zoom; + const clampLat = (lat) => Math.max(-85.0511, Math.min(85.0511, lat)); + const tileX = (lon) => + Math.min(n - 1, Math.max(0, Math.floor(((lon + 180) / 360) * n))); + const tileY = (lat) => { + const rad = (clampLat(lat) * Math.PI) / 180; + const y = (1 - Math.log(Math.tan(rad) + 1 / Math.cos(rad)) / Math.PI) / 2; + return Math.min(n - 1, Math.max(0, Math.floor(y * n))); + }; + const [west, south, east, north] = bbox; + return (tileX(east) - tileX(west) + 1) * (tileY(south) - tileY(north) + 1); +} + +/** + * The globe level below which a tile entry is hidden, or undefined when it + * draws at every scale. Cesium fetches an imagery layer's minimum level for + * every globe tile it covers, however far out the camera is: an entry whose + * coverage needs more than COARSE_TILE_BUDGET tiles at its minimum zoom + * (Norway's cadastre from z14 is about two million) would allocate them all + * when zoomed out. Such an entry is shown only from the globe level that + * matches its minimum zoom (a geographic level spans two Web Mercator ones, + * so level z − 1). + * @param {object} entry + * @returns {number|undefined} + */ +export function minimumTerrainLevelFor(entry) { + const minZoom = entry?.source?.minZoom ?? 0; + if (minZoom <= 0) return undefined; + const bbox = entry.coverage?.bbox ?? [-180, -85.0511, 180, 85.0511]; + return webMercatorTileSpan(bbox, minZoom) > COARSE_TILE_BUDGET + ? minZoom - 1 + : undefined; +} + /** * A tiles entry as a manager layer. * @param {object} entry Validated tiles entry. @@ -406,7 +444,13 @@ export function createLibraryTileLayer( provider.errorEvent?.addEventListener?.(onError); removeErrorListener = () => provider.errorEvent?.removeEventListener?.(onError); - imagery = host.collection.addImageryProvider(provider); + const minimumTerrainLevel = minimumTerrainLevelFor(entry); + imagery = + minimumTerrainLevel !== undefined && cesium.ImageryLayer + ? new cesium.ImageryLayer(provider, { minimumTerrainLevel }) + : null; + if (imagery) host.collection.add(imagery); + else imagery = host.collection.addImageryProvider(provider); imagery.alpha = entry.display.opacity ?? 1; if (entry.display.noDataColor && cesium.Color) { // The provider's "no imagery here" fill, drawn transparent (JPEG diff --git a/src/library/parquet.js b/src/library/parquet.js index 4d5e4f8dc..29bb7ce99 100644 --- a/src/library/parquet.js +++ b/src/library/parquet.js @@ -10,7 +10,10 @@ import { parquetMetadataAsync, parquetReadObjects } from 'hyparquet'; import { decompress as zstdDecompress } from 'fzstd'; import { libraryUpstreamUrl } from './manifest.js'; -import { readResponseBytesCapped } from '../sources/httpBody.js'; +import { + readResponseBytesCapped, + readResponseTextCapped, +} from '../sources/httpBody.js'; /** Footer bytes read first; small files' metadata fits in one request. */ const FOOTER_FETCH_BYTES = 16 * 1024; @@ -19,6 +22,13 @@ export const MAX_PARTITIONS_PER_VIEW = 4; const MAX_LISTED_KEYS = 5000; const MAX_LIST_PAGES = 20; const INDEX_CONCURRENCY = 8; +/** One listing page: 1,000 keys of XML is well under a megabyte. */ +const MAX_LIST_PAGE_BYTES = 4 * 1024 * 1024; +/** A whole file sent in answer to a range request is read only up to this. */ +const MAX_UNRANGED_BYTES = 64 * 1024 * 1024; +/** Extra passes over footers that failed, before the index is incomplete. */ +const FOOTER_RETRIES = 2; +const FOOTER_RETRY_DELAY_MS = 500; /** Decompressors hyparquet does not carry itself (it has Snappy built in). */ export const PARQUET_COMPRESSORS = Object.freeze({ @@ -33,30 +43,67 @@ function onSourceHost(entry, url) { return target.href; } +/** Mark a failure worth retrying: network, 429, 5xx or a short read. */ +const transient = (error) => Object.assign(error, { transient: true }); + /** * A hyparquet AsyncBuffer over HTTP range requests, redirects refused, * credentials and referrer withheld. */ export function rangeBuffer(url, byteLength, { fetchImpl, signal }) { + if (!Number.isSafeInteger(byteLength) || byteLength <= 0) + throw new RangeError(`invalid object size ${byteLength}`); return { byteLength, async slice(start, end = byteLength) { - const response = await fetchImpl(url, { - signal, - redirect: 'error', - credentials: 'omit', - referrerPolicy: 'no-referrer', - headers: { Range: `bytes=${start}-${end - 1}` }, - }); + if ( + !Number.isSafeInteger(start) || + !Number.isSafeInteger(end) || + start < 0 || + end > byteLength || + end <= start + ) + throw new RangeError(`invalid range ${start}-${end} of ${byteLength}`); + let response; + try { + response = await fetchImpl(url, { + signal, + redirect: 'error', + credentials: 'omit', + referrerPolicy: 'no-referrer', + headers: { Range: `bytes=${start}-${end - 1}` }, + }); + } catch (error) { + if (signal?.aborted) throw error; + throw transient(error); + } if (response.status !== 206 && response.status !== 200) { await response.body?.cancel?.().catch?.(() => {}); - throw new Error(`HTTP ${response.status}`); + const error = new Error(`HTTP ${response.status}`); + throw response.status === 429 || response.status >= 500 + ? transient(error) + : error; } - const buffer = await response.arrayBuffer(); - // A server that ignores Range answers with the whole file. - return response.status === 200 && buffer.byteLength === byteLength - ? buffer.slice(start, end) - : buffer; + // A server that ignores Range answers with the whole file; anything + // else must be exactly the bytes asked for. + const whole = response.status === 200; + const expected = whole ? byteLength : end - start; + const bytes = await readResponseBytesCapped( + response, + whole ? Math.min(byteLength, MAX_UNRANGED_BYTES) : expected, + signal, + ); + if (bytes.byteLength !== expected) + throw transient( + new Error( + `short read: ${bytes.byteLength} of ${expected} bytes from ${start}`, + ), + ); + const buffer = bytes.buffer.slice( + bytes.byteOffset, + bytes.byteOffset + bytes.byteLength, + ); + return whole ? buffer.slice(start, end) : buffer; }, }; } @@ -79,12 +126,19 @@ export async function listPartitions(entry, { fetchImpl, signal }) { credentials: 'omit', }); if (!response.ok) throw new Error(`HTTP ${response.status}`); - const xml = await response.text(); + const xml = await readResponseTextCapped( + response, + MAX_LIST_PAGE_BYTES, + signal, + ); for (const match of xml.matchAll( /[\s\S]*?([^<]+)<\/Key>[\s\S]*?(\d+)<\/Size>[\s\S]*?<\/Contents>/g, )) { - if (!match[1].endsWith('.parquet')) continue; - objects.push({ key: match[1], size: Number(match[2]) }); + const size = Number(match[2]); + if (!match[1].endsWith('.parquet') || !Number.isSafeInteger(size)) + continue; + if (size <= 0) continue; + objects.push({ key: match[1], size }); if (objects.length >= MAX_LISTED_KEYS) return objects; } token = /([^<]+)<\/NextContinuationToken>/.exec( @@ -119,39 +173,71 @@ export function geoParquetInfo(metadata) { } } +const pause = (ms, signal) => + new Promise((resolve, reject) => { + const timer = setTimeout(resolve, ms); + signal?.addEventListener?.( + 'abort', + () => { + clearTimeout(timer); + reject(signal.reason); + }, + { once: true }, + ); + }); + /** * Where each partition is: list the bucket, then read each file's footer - * (one small range request) for its GeoParquet bbox. Built once and cached - * by the caller; a partition without a bbox is left out. - * @returns {Promise>} + * (one small range request) for its GeoParquet bbox. A partition whose + * metadata is unreadable or has no bbox is left out; a footer that fails to + * load (network, 429, 5xx, short read) is retried + * (FOOTER_RETRIES more passes) and, if it still fails, the index is + * `complete: false` so callers never keep it as if it were whole. + * @returns {Promise<{ partitions: Array<{ url: string, bbox: number[], size: number }>, complete: boolean, failed: number }>} */ -export async function buildPartitionIndex(entry, { fetchImpl, signal }) { +export async function buildPartitionIndex( + entry, + { fetchImpl, signal, retryDelayMs = FOOTER_RETRY_DELAY_MS }, +) { const { keyBase } = entry.source.partitions; const objects = await listPartitions(entry, { fetchImpl, signal }); - const index = []; - let next = 0; - async function worker() { - while (next < objects.length) { - const { key, size } = objects[next++]; - signal?.throwIfAborted?.(); - const url = onSourceHost(entry, new URL(key, keyBase).href); - try { - const metadata = await parquetMetadataAsync( - rangeBuffer(url, size, { fetchImpl, signal }), - { initialFetchSize: FOOTER_FETCH_BYTES }, - ); - const { bbox } = geoParquetInfo(metadata); - if (bbox) index.push({ url, bbox, size }); - } catch (error) { - if (signal?.aborted) throw error; - // One unreadable partition does not sink the index. + const partitions = []; + let pending = objects; + for (let pass = 0; pass <= FOOTER_RETRIES && pending.length; pass++) { + if (pass) await pause(retryDelayMs * pass, signal); + const failed = []; + let next = 0; + const queue = pending; + async function worker() { + while (next < queue.length) { + const object = queue[next++]; + signal?.throwIfAborted?.(); + const url = onSourceHost(entry, new URL(object.key, keyBase).href); + try { + const metadata = await parquetMetadataAsync( + rangeBuffer(url, object.size, { fetchImpl, signal }), + { initialFetchSize: FOOTER_FETCH_BYTES }, + ); + const { bbox } = geoParquetInfo(metadata); + if (bbox) partitions.push({ url, bbox, size: object.size }); + } catch (error) { + if (signal?.aborted) throw error; + // A footer that arrived but does not parse will not parse next + // time either: that file is left out. Only fetch failures retry. + if (error?.transient) failed.push(object); + } } } + await Promise.all( + Array.from({ length: Math.min(INDEX_CONCURRENCY, queue.length) }, worker), + ); + pending = failed; } - await Promise.all( - Array.from({ length: Math.min(INDEX_CONCURRENCY, objects.length) }, worker), - ); - return index.sort((a, b) => a.url.localeCompare(b.url)); + return { + partitions: partitions.sort((a, b) => a.url.localeCompare(b.url)), + complete: pending.length === 0, + failed: pending.length, + }; } const overlapArea = (a, b) => diff --git a/src/library/parquet.test.mjs b/src/library/parquet.test.mjs index 5608194e6..65cf382ce 100644 --- a/src/library/parquet.test.mjs +++ b/src/library/parquet.test.mjs @@ -90,7 +90,7 @@ test('a partitioned view reads candidates in batches until one finds features', }, loadParquet: async () => ({ ...(await import('./parquet.js')), - buildPartitionIndex: async () => index, + buildPartitionIndex: async () => ({ partitions: index, complete: true }), readGeoParquetFeatures: async (entry, url) => { reads.push(url); return url.endsWith('A.parquet') @@ -108,8 +108,11 @@ test('a partitioned view reads candidates in batches until one finds features', ]); // The index is kept in this browser; extents are learned per partition. assert.ok(store.get('gev:library:index:epc-england-wales')); - const learned = JSON.parse(store.get('gev:library:extent:epc-england-wales')); - assert.ok(learned['https://h/A.parquet']); + const learned = JSON.parse( + store.get('gev:library:extent:v2:epc-england-wales'), + ); + assert.ok(learned['https://h/A.parquet'].full); + assert.ok(learned['https://h/A.parquet'].trimmed); // A second view of the same area is served from the decoded cache. await source.loadDataset(epc, { bbox: box }); assert.equal(reads.length, 2); @@ -119,3 +122,185 @@ test('a partitioned view reads candidates in batches until one finds features', truncated: false, }); }); + +test('a revisit still finds homes at the edge of a partition', async () => { + // 200 homes in a cluster and one real home at the partition's edge: the + // trimmed (98 %) extent leaves the edge home out, the full one keeps it. + const point = (lon, lat) => ({ + type: 'Feature', + geometry: { type: 'Point', coordinates: [lon, lat] }, + properties: { CURRENT_ENERGY_RATING: 'C', UPRN: String(lon) }, + }); + const homes = Array.from({ length: 200 }, (_, index) => + point(-1.4 - index / 1e4, 53.4), + ); + const edge = point(-1.2, 53.3); + homes.push(edge); + const index = [ + { url: 'https://h/Edge.parquet', bbox: [-1.5, 53.2, -1.1, 53.5] }, + ]; + const store = new Map([ + // A v1 extent (trimmed only) from an older build: never read again. + [ + 'gev:library:extent:epc-england-wales', + JSON.stringify({ 'https://h/Edge.parquet': [-1.42, 53.4, -1.4, 53.4] }), + ], + ]); + const storage = { + getItem: (key) => store.get(key) ?? null, + setItem: (key, value) => store.set(key, value), + removeItem: (key) => store.delete(key), + }; + const visit = () => + createLibrarySource({ + entries: [epc], + fetchImpl: async () => ({ ok: false, status: 404 }), + storage, + loadParquet: async () => ({ + ...(await import('./parquet.js')), + buildPartitionIndex: async () => ({ + partitions: index, + complete: true, + }), + readGeoParquetFeatures: async () => homes, + }), + }); + const edgeBox = [-1.21, 53.29, -1.19, 53.31]; + // First visit: the old v1 extent would have skipped the partition. + const first = await visit().loadDataset(epc, { bbox: edgeBox }); + assert.equal(first.features.length, 1); + assert.equal(store.has('gev:library:extent:epc-england-wales'), false); + // A later visit (new page, nothing decoded) reads the learned v2 extents. + const later = await visit().loadDataset(epc, { bbox: edgeBox }); + assert.equal(later.features.length, 1); + assert.deepEqual(later.features[0].geometry.coordinates, [-1.2, 53.3]); + // A box outside every point of the partition is still ruled out. + const outside = await visit().loadDataset(epc, { + bbox: [-1.12, 53.45, -1.11, 53.46], + }); + assert.equal(outside.features.length, 0); +}); + +test('a footer that keeps failing leaves the index incomplete, and a retry recovers', async () => { + const { buildPartitionIndex } = await import('./parquet.js'); + const entry = { + ...epc, + source: { + ...epc.source, + partitions: { + listUrl: 'https://s3.us-west-2.amazonaws.com/bucket?list-type=2', + keyBase: 'https://s3.us-west-2.amazonaws.com/bucket/', + }, + }, + }; + const listing = `a.parquet100b.parquet100`; + let footerFailures = 0; + const fetchImpl = async (url) => { + if (url.includes('list-type')) + return new Response(listing, { status: 200 }); + if (url.endsWith('b.parquet') && footerFailures > 0) { + footerFailures -= 1; + return new Response('', { status: 503 }); + } + // Not a Parquet footer: readable, but with no bbox, so left out. + return new Response(new Uint8Array(100), { status: 200 }); + }; + footerFailures = 2; // fails once, then succeeds on the first retry + const recovered = await buildPartitionIndex(entry, { + fetchImpl, + retryDelayMs: 0, + }); + assert.equal(recovered.complete, true); + footerFailures = 100; // fails on every pass + const broken = await buildPartitionIndex(entry, { + fetchImpl, + retryDelayMs: 0, + }); + assert.equal(broken.complete, false); + assert.equal(broken.failed, 1); +}); + +test('the browser keeps a complete index over an incomplete rebuild and retries later', async () => { + let clock = 30 * 24 * 3600_000; + const complete = [{ url: 'https://h/A.parquet', bbox: [-2, 53, -1, 54] }]; + const store = new Map([ + [ + 'gev:library:index:epc-england-wales', + // Past cacheSeconds: due for a rebuild. + JSON.stringify({ savedAt: 0, complete: true, partitions: complete }), + ], + ]); + let builds = 0; + let nextComplete = false; + const source = createLibrarySource({ + entries: [epc], + now: () => clock, + fetchImpl: async () => ({ ok: false, status: 404 }), + storage: { + getItem: (key) => store.get(key) ?? null, + setItem: (key, value) => store.set(key, value), + }, + loadParquet: async () => ({ + ...(await import('./parquet.js')), + buildPartitionIndex: async () => { + builds += 1; + return nextComplete + ? { partitions: complete, complete: true, failed: 0 } + : { partitions: [], complete: false, failed: 3 }; + }, + readGeoParquetFeatures: async () => [], + }), + }); + const box = [-1.5, 53.4, -1.4, 53.5]; + await source.loadDataset(epc, { bbox: box }); + assert.equal(builds, 1); + // The old complete index stays stored; the incomplete one is not saved. + assert.equal( + JSON.parse(store.get('gev:library:index:epc-england-wales')).savedAt, + 0, + ); + // Within the retry pause the incomplete result is reused, not rebuilt. + await source.loadDataset(epc, { bbox: box }); + assert.equal(builds, 1); + // After it, the index is rebuilt; a complete build is kept. + clock += 6 * 60_000; + nextComplete = true; + await source.loadDataset(epc, { bbox: box }); + assert.equal(builds, 2); + assert.equal( + JSON.parse(store.get('gev:library:index:epc-england-wales')).savedAt, + clock, + ); +}); + +test('a view that leaves touching partitions unread is partial', async () => { + const index = Array.from({ length: 6 }, (_, n) => ({ + url: `https://h/P${n}.parquet`, + bbox: [-1.5 - n / 100, 53.3, -1.4, 53.45], + })); + const point = (lon, lat) => ({ + type: 'Feature', + geometry: { type: 'Point', coordinates: [lon, lat] }, + properties: { CURRENT_ENERGY_RATING: 'C', UPRN: '1' }, + }); + const reads = []; + const source = createLibrarySource({ + entries: [epc], + fetchImpl: async () => ({ ok: false, status: 404 }), + storage: null, + loadParquet: async () => ({ + ...(await import('./parquet.js')), + buildPartitionIndex: async () => ({ partitions: index, complete: true }), + readGeoParquetFeatures: async (entry, url) => { + reads.push(url); + return [point(-1.45, 53.4)]; + }, + }), + }); + const result = await source.loadDataset(epc, { + bbox: [-1.46, 53.39, -1.44, 53.41], + }); + // The first batch of four found homes; two touching partitions stay unread. + assert.equal(reads.length, 4); + assert.equal(result.truncated, true); +}); diff --git a/src/library/source.js b/src/library/source.js index 8e15ec0d0..85f37c5d0 100644 --- a/src/library/source.js +++ b/src/library/source.js @@ -17,7 +17,13 @@ const PARTITION_BATCH = 4; /** Decoded partitions kept in memory, most recent first. */ const PARTITION_CACHE_LIMIT = 4; const INDEX_STORAGE_PREFIX = 'gev:library:index:'; -const EXTENT_STORAGE_PREFIX = 'gev:library:extent:'; +/** How long an incomplete partition index is used before a rebuild. */ +const PARTIAL_INDEX_RETRY_MS = 5 * 60_000; +// v2 stores `{ full, trimmed }` per partition. The v1 key held only the +// trimmed box, which wrongly excluded real points at a partition's edge; it +// is no longer read and is removed on first use. +const EXTENT_STORAGE_PREFIX = 'gev:library:extent:v2:'; +const RETIRED_EXTENT_STORAGE_PREFIX = 'gev:library:extent:'; const overlaps = (a, b) => a[0] < b[2] && a[2] > b[0] && a[1] < b[3] && a[3] > b[1]; @@ -47,6 +53,58 @@ export function robustExtent(features) { return [at(lons, 0.01), at(lats, 0.01), at(lons, 0.99), at(lats, 0.99)]; } +/** The box holding every point of a partition, or null. */ +export function fullExtent(features) { + let extent = null; + for (const feature of features) { + let position = feature?.geometry?.coordinates; + while (Array.isArray(position?.[0])) position = position[0]; + if (!Array.isArray(position)) continue; + const [lon, lat] = position; + if (!Number.isFinite(lon) || !Number.isFinite(lat)) continue; + if (!extent) extent = [lon, lat, lon, lat]; + else { + extent[0] = Math.min(extent[0], lon); + extent[1] = Math.min(extent[1], lat); + extent[2] = Math.max(extent[2], lon); + extent[3] = Math.max(extent[3], lat); + } + } + return extent; +} + +const isBox = (value) => + Array.isArray(value) && value.length === 4 && value.every(Number.isFinite); + +/** + * Candidate partitions for a box. A learned full extent that misses the box + * rules a partition out (no point of it can be in view); the learned 98 % + * extent only ranks, so stray geocodes neither stretch the ranking nor hide + * the real points at a partition's edge. + */ +export function rankPartitions(index, box, learned, partitionsForBox) { + const known = (url) => { + const value = learned?.[url]; + return value && isBox(value.full) && isBox(value.trimmed) ? value : null; + }; + const ranked = partitionsForBox( + index + .filter(({ url }) => !known(url) || overlaps(known(url).full, box)) + .map((partition) => { + const extent = known(partition.url); + // Rank by where most points are, unless that misses the view while + // the full extent does not: then the partition's own box ranks it. + return extent && overlaps(extent.trimmed, box) + ? { ...partition, bbox: extent.trimmed } + : partition; + }), + box, + Infinity, + ); + const byUrl = new Map(index.map((partition) => [partition.url, partition])); + return ranked.map(({ url }) => byUrl.get(url)); +} + function storedJson(storage, key) { try { const text = storage?.getItem?.(key); @@ -118,43 +176,76 @@ export function createLibrarySource({ * shared), else built here and kept in this browser for `cacheSeconds`. */ function partitionIndex(entry, { signal } = {}) { - if (!indexes.has(entry.id)) - indexes.set( - entry.id, - (async () => { - try { - const response = await fetchImpl( - `${base}/index/${encodeURIComponent(entry.id)}`, - { signal, headers: { Accept: 'application/json' } }, - ); - if (response.ok) { - const body = await response.json(); - if (Array.isArray(body?.partitions)) return body.partitions; - } - } catch (error) { - if (signal?.aborted) throw error; + const memo = indexes.get(entry.id); + if (memo && (memo.retryAt === null || now() < memo.retryAt)) + return memo.promise; + const promise = (async () => { + const key = `${INDEX_STORAGE_PREFIX}${entry.id}`; + const stored = storedJson(storage, key); + const storedComplete = + stored && Array.isArray(stored.partitions) && stored.complete !== false + ? stored + : null; + let partial = null; + try { + const response = await fetchImpl( + `${base}/index/${encodeURIComponent(entry.id)}`, + { signal, headers: { Accept: 'application/json' } }, + ); + if (response.ok) { + const body = await response.json(); + if (Array.isArray(body?.partitions)) { + if (body.complete !== false) + return { partitions: body.partitions, complete: true }; + partial = body.partitions; } - const key = `${INDEX_STORAGE_PREFIX}${entry.id}`; - const stored = storedJson(storage, key); - if ( - stored && - Array.isArray(stored.partitions) && - now() - stored.savedAt < entry.source.cacheSeconds * 1000 - ) - return stored.partitions; - const { buildPartitionIndex } = await loadParquet(); - const partitions = await buildPartitionIndex(entry, { - fetchImpl, - signal, - }); - storeJson(storage, key, { savedAt: now(), partitions }); - return partitions; - })().catch((error) => { + } + } catch (error) { + if (signal?.aborted) throw error; + } + if ( + storedComplete && + now() - storedComplete.savedAt < entry.source.cacheSeconds * 1000 + ) + return { partitions: storedComplete.partitions, complete: true }; + // The server's index is incomplete, or there is no server: build here. + const { buildPartitionIndex } = await loadParquet(); + const built = await buildPartitionIndex(entry, { fetchImpl, signal }); + if (built.complete) { + storeJson(storage, key, { + savedAt: now(), + complete: true, + partitions: built.partitions, + }); + return { partitions: built.partitions, complete: true }; + } + // Still incomplete: an older complete index beats a partial one, even + // past its age; otherwise use the larger partial set for now. + if (storedComplete) + return { partitions: storedComplete.partitions, complete: false }; + const partitions = + partial && partial.length > built.partitions.length + ? partial + : built.partitions; + return { partitions, complete: false }; + })().then( + (result) => { + // An incomplete index is used for now and rebuilt after a pause. + const current = indexes.get(entry.id); + if (current?.promise === promise) + current.retryAt = result.complete + ? null + : now() + PARTIAL_INDEX_RETRY_MS; + return result.partitions; + }, + (error) => { + if (indexes.get(entry.id)?.promise === promise) indexes.delete(entry.id); - throw error; - }), - ); - return indexes.get(entry.id); + throw error; + }, + ); + indexes.set(entry.id, { promise, retryAt: null }); + return promise; } /** One partition's features, decoded once and kept for a few views. */ @@ -174,11 +265,12 @@ export function createLibrarySource({ while (partitionCache.size > PARTITION_CACHE_LIMIT) partitionCache.delete(partitionCache.keys().next().value); // Learn where this partition really is, for every later view. - const extent = robustExtent(features); - if (extent) { + const full = fullExtent(features); + const trimmed = robustExtent(features); + if (full && trimmed) { const key = `${EXTENT_STORAGE_PREFIX}${entry.id}`; const learned = storedJson(storage, key) || {}; - learned[partition.url] = extent; + learned[partition.url] = { full, trimmed }; storeJson(storage, key, learned); } return features; @@ -195,11 +287,18 @@ export function createLibrarySource({ loadParquet(), partitionIndex(entry, { signal }), ]); + try { + storage?.removeItem?.(`${RETIRED_EXTENT_STORAGE_PREFIX}${entry.id}`); + } catch { + /* a blocked store keeps a key nobody reads */ + } const learned = storedJson(storage, `${EXTENT_STORAGE_PREFIX}${entry.id}`) || {}; - const candidates = partitionsForBox(index, box, Infinity) - .filter(({ url }) => !learned[url] || overlaps(learned[url], box)) - .slice(0, MAX_PARTITION_READS_PER_VIEW); + const ranked = rankPartitions(index, box, learned, partitionsForBox); + const candidates = ranked.slice(0, MAX_PARTITION_READS_PER_VIEW); + // Every partition the box touches but this view leaves unread (the read + // budget, or the early stop below) makes the result partial. + let unread = ranked.length - candidates.length; const inView = []; for (let start = 0; start < candidates.length; start += PARTITION_BATCH) { const batch = candidates.slice(start, start + PARTITION_BATCH); @@ -217,13 +316,19 @@ export function createLibrarySource({ inView.push(...hits); } } - if (found) break; + if (found) { + unread += candidates.length - (start + batch.length); + break; + } } const bounded = boundLibraryFeatures(inView, { maxFeatures: entry.source.maxFeatures, display: entry.display, }); - return { features: bounded.features, truncated: bounded.truncated }; + return { + features: bounded.features, + truncated: bounded.truncated || unread > 0, + }; } /** diff --git a/src/library/tileLayer.test.mjs b/src/library/tileLayer.test.mjs index d26ce18d4..94fe4a999 100644 --- a/src/library/tileLayer.test.mjs +++ b/src/library/tileLayer.test.mjs @@ -1,7 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; -import { createLibraryTileLayer } from './layers.js'; +import { createLibraryTileLayer, minimumTerrainLevelFor } from './layers.js'; import { LIBRARY_ENTRIES, libraryEntry } from './catalog.js'; import { libraryTileUrl } from './manifest.js'; import { createLibrarySource } from './source.js'; @@ -133,3 +133,35 @@ test('bbox and TMS templates become the URLs Cesium and the proxy request', () = 'https://t.example.org/3/2/6.png', ); }); + +test('an entry too dense to draw zoomed out is hidden below its scale', async () => { + const cadastre = entryWithoutOutline('kartverket-cadastre'); + assert.equal(cadastre.source.minZoom, 14); + assert.equal(minimumTerrainLevelFor(cadastre), 13); + // Small coverage, or a low minimum zoom, draws at every scale. + assert.equal( + minimumTerrainLevelFor(libraryEntry('lee-county-fl')), + undefined, + ); + assert.equal( + minimumTerrainLevelFor(libraryEntry('ign-orthophotos-france')), + undefined, + ); + + // Switched on zoomed out, then zoomed further out: no globe tile coarser + // than level 13 takes imagery from it, so no z14 tile is ever requested. + const viewer = globeViewer(); + const layer = createLibraryTileLayer(cadastre, { source, cesium: Cesium }); + await layer.init(viewer); + await layer.enable(viewer); + const imagery = viewer.imageryLayers.get(viewer.imageryLayers.length - 1); + const terrain = new Cesium.EllipsoidTerrainProvider(); + for (const level of [0, 4, 12]) + assert.equal( + imagery._createTileImagerySkeletons({ level, data: {} }, terrain), + false, + `globe level ${level} takes no cadastre imagery`, + ); + await layer.disable(); + assert.equal(viewer.imageryLayers.length, 0); +}); diff --git a/src/share/legacyShareState.js b/src/share/legacyShareState.js index 1f54445e4..391293afe 100644 --- a/src/share/legacyShareState.js +++ b/src/share/legacyShareState.js @@ -48,6 +48,7 @@ export const LEGACY_SHARE_STATE_KEYS = Object.freeze([ 'layerState', 'layerStateInvalid', 'panelState', + 'library', 'sharedAtMs', ]); diff --git a/src/share/viewCodec.js b/src/share/viewCodec.js index 4031c9b31..fd8059799 100644 --- a/src/share/viewCodec.js +++ b/src/share/viewCodec.js @@ -390,6 +390,9 @@ export const SHARE_VIEW_FIELDS = freezeAll([ { number: 26, key: 'scopeTerminusPct', type: 'uint', default: null }, { number: 27, key: 'styleParams', type: 'bytes', default: BY_STYLE }, { number: 28, key: 'worldTime', type: 'epoch', default: null }, + // Data Library entries switched on, as their two-character ledger tokens + // concatenated (src/library/ledger.json). Absent means none. + { number: 29, key: 'library', type: 'bytes', default: null }, ]); const FIELD_BY_KEY = new Map(SHARE_VIEW_FIELDS.map((f) => [f.key, f])); const fieldNumber = (key) => FIELD_BY_KEY.get(key).number; @@ -450,6 +453,33 @@ export function createDefaultShareViewState(requested = 'normal') { /* Normalization: the one canonical state both directions agree on. */ /* ------------------------------------------------------------------ */ +/** The most Data Library tokens a share carries (the library's own cap). */ +export const SHARE_LIBRARY_MAX_TOKENS = 64; +const LIBRARY_TOKEN = /^[0-9a-z]{2}$/; + +/** + * Data Library share tokens from a `lib=` value (`ab.cd`) or a token list: + * unique, two base-36 characters each, at most SHARE_LIBRARY_MAX_TOKENS. + * Malformed tokens are skipped. Null or undefined stays null (no field). + * @param {string|string[]|null|undefined} value + * @returns {string[]|null} + */ +export function normalizeLibraryTokens(value) { + if (value === null || value === undefined) return null; + const list = Array.isArray(value) + ? value + : typeof value === 'string' && value.length <= 3 * SHARE_LIBRARY_MAX_TOKENS + ? value.split('.') + : []; + const tokens = []; + for (const token of list) { + if (typeof token !== 'string' || !LIBRARY_TOKEN.test(token)) continue; + if (!tokens.includes(token)) tokens.push(token); + if (tokens.length === SHARE_LIBRARY_MAX_TOKENS) break; + } + return tokens; +} + const finite = (value) => typeof value === 'number' ? Number.isFinite(value) @@ -761,6 +791,7 @@ export function normalizeShareViewState(input = {}) { ? normalizeLayerState(source.layerState) : null; state.panelState = normalizePanelState(source.panelState); + state.library = normalizeLibraryTokens(source.library); state.sharedAtMs = epochMsOr(source.sharedAtMs); state.worldTime = isWorldTime(source.worldTime) ? Math.floor(source.worldTime / 1000) * 1000 @@ -1030,6 +1061,10 @@ export function encodeShareView(input) { case 'panelState': if (state.panelState) encodePanels(writer, state.panelState); break; + case 'library': + if (state.library?.length) + writer.textField(field.number, state.library.join('')); + break; case 'thing': if (thing) encodeThing(writer, thing, state); break; @@ -1438,6 +1473,13 @@ export function decodeShareView(code, { nowMs = Date.now() } = {}) { unknown.code('layer-option', 'without-layers'); if (get('panelState')) raw.panelState = decodePanels(get('panelState').len, unknown); + if (get('library')) { + const text = asciiText(get('library').len.rest()); + const tokens = text.match(/.{1,2}/g) || []; + for (const token of tokens) + if (!LIBRARY_TOKEN.test(token)) unknown.code('library', token); + raw.library = normalizeLibraryTokens(tokens); + } if (get('styleParams') && !retiredStyle) raw.styleParams = decodeStyleParams( get('styleParams').len, diff --git a/src/share/viewCodec.test.mjs b/src/share/viewCodec.test.mjs index 8e663fce9..42dd6b547 100644 --- a/src/share/viewCodec.test.mjs +++ b/src/share/viewCodec.test.mjs @@ -58,6 +58,7 @@ import { followOffsetToEnu, layerTokenFromNumber, layerTokenNumber, + normalizeLibraryTokens, normalizeShareViewState, } from './viewCodec.js'; @@ -507,6 +508,7 @@ test('the field registry is well formed and permanent', () => { [26, 'scopeTerminusPct', null], [27, 'styleParams', BY_STYLE], [28, 'worldTime', null], + [29, 'library', null], ], ); assert.deepEqual( @@ -1571,3 +1573,16 @@ test('the follow offset for a camera is the inverse of followCameraFor', () => { null, ); }); + +test('Data Library tokens round-trip and malformed ones are skipped', () => { + const state = { lat: 1, lon: 2, library: ['00', '1z', '0a'] }; + const decoded = decodeShareView(encodeShareView(state)); + assert.equal(decoded.ok, true); + assert.deepEqual(decoded.state.library, ['00', '1z', '0a']); + // None on: no field, and the decoder reads no tokens. + const none = decodeShareView(encodeShareView({ lat: 1, lon: 2 })); + assert.equal(none.state.library, null); + assert.deepEqual(normalizeLibraryTokens('01.!!.01.zz'), ['01', 'zz']); + assert.equal(normalizeLibraryTokens(null), null); + assert.deepEqual(normalizeLibraryTokens('x'.repeat(500)), []); +}); diff --git a/src/sharelink.js b/src/sharelink.js index 74ad5b05a..6577afeae 100644 --- a/src/sharelink.js +++ b/src/sharelink.js @@ -17,6 +17,7 @@ import { SHARE_VIEW_STYLE_PARAMS, decodeShareView, encodeShareView, + normalizeLibraryTokens, } from './share/viewCodec.js'; import { fromLegacyShareState, @@ -109,6 +110,10 @@ export class ShareLinkManager { this._mapStack = 'photoreal'; this._layerStateProvider = null; this._libraryParamsEncoder = null; + this._libraryShareListener = null; + // The opening share's Data Library tokens: null when the page opened + // without a share, [] for a share that switches no library entry on. + this._initialLibraryTokens = null; this._panelStateProvider = null; this._styleParamStateProvider = null; this._initialRestorePending = false; @@ -161,10 +166,32 @@ export class ShareLinkManager { const state = code === null ? this._parseShareHash(hash) : this._parseShareCode(code); if (!state) return null; + // A share decides which library entries are on, as it does for core + // layers: one without `lib` switches none on. + this._initialLibraryTokens = state.library ?? []; this._beginIncomingRestore(state); return state; } + /** + * The Data Library tokens of the share this page opened with, or null when + * it opened without one. Read by the library once it knows its entries. + * @returns {string[]|null} + */ + get initialLibraryTokens() { + return this._initialLibraryTokens ? [...this._initialLibraryTokens] : null; + } + + /** + * Install the Data Library's listener for shares opened in this tab after + * start-up. It receives the share's tokens ([] when it switches none on). + * @param {((tokens: string[]) => void) | null} listener + */ + setLibraryShareListener(listener) { + this._libraryShareListener = + typeof listener === 'function' ? listener : null; + } + /** * The restore state for a share code, in the long form's parsed shape plus * `shareView` (what the view follows, with its offset and last-known fix) @@ -273,6 +300,7 @@ export class ShareLinkManager { params.has('l') && decodedLayerState === null, panelState: decodePanelStateParams(params), + library: normalizeLibraryTokens(params.get('lib')), sharedAtMs: decodeShareCreatedAtMs(params), }; return state; @@ -324,6 +352,11 @@ export class ShareLinkManager { if (!state) return; this._beginIncomingRestore(state); onNavigate(state); + try { + this._libraryShareListener?.(state.library ?? []); + } catch (error) { + console.warn('[ShareLink] library restore failed:', error); + } } /** @@ -751,6 +784,7 @@ export class ShareLinkManager { this._unwatchNavigation?.(); this._layerStateProvider = null; this._libraryParamsEncoder = null; + this._libraryShareListener = null; this._panelStateProvider = null; this._styleParamStateProvider = null; this._followSubjectProvider = null; diff --git a/src/sharelink.navigation.test.mjs b/src/sharelink.navigation.test.mjs index e486ba58d..10e167bc4 100644 --- a/src/sharelink.navigation.test.mjs +++ b/src/sharelink.navigation.test.mjs @@ -448,3 +448,47 @@ test('a link made away from live carries its world time to the page that opens i assert.equal(liveOpener.parseInitialHash().worldTime, null); liveOpener.destroy(); }); + +test('a copied link carries the Data Library selection to a clean recipient', (t) => { + installTab(t, SHARE_A); + const sharer = new ShareLinkManager(fakeViewer()); + sharer.setLibraryParamsEncoder((params) => params.set('lib', '03.0a')); + const code = new URL(sharer.buildLink()).pathname.split('/').at(-1); + sharer.destroy(); + const opener = new ShareLinkManager(fakeViewer(), { initialShareCode: code }); + assert.deepEqual(opener.parseInitialHash().library, ['03', '0a']); + assert.deepEqual(opener.initialLibraryTokens, ['03', '0a']); + opener.destroy(); + + // A link with every library entry off is a share that switches none on. + const none = new ShareLinkManager(fakeViewer()); + const noneCode = new URL(none.buildLink()).pathname.split('/').at(-1); + none.destroy(); + const noneOpener = new ShareLinkManager(fakeViewer(), { + initialShareCode: noneCode, + }); + noneOpener.parseInitialHash(); + assert.deepEqual(noneOpener.initialLibraryTokens, []); + noneOpener.destroy(); + + // A page opened without a share leaves the library its stored selection. + installTab(t, ''); + const plain = new ShareLinkManager(fakeViewer()); + plain.parseInitialHash(); + assert.equal(plain.initialLibraryTokens, null); + plain.destroy(); +}); + +test('a share navigated to in this tab hands its library tokens over', (t) => { + const tab = installTab(t, SHARE_A); + const manager = new ShareLinkManager(fakeViewer()); + manager.parseInitialHash(); + const received = []; + manager.setLibraryShareListener((tokens) => received.push(tokens)); + manager.watchNavigation(() => {}); + tab.navigate(`${SHARE_B}&lib=01`); + tab.navigate(SHARE_A); + tab.back(); + assert.deepEqual(received, [['01'], [], ['01']]); + manager.destroy(); +}); diff --git a/src/ui/libraryBrowser.js b/src/ui/libraryBrowser.js index 857b0f04d..84989b60b 100644 --- a/src/ui/libraryBrowser.js +++ b/src/ui/libraryBrowser.js @@ -7,7 +7,7 @@ * width, height, labels, initial and return focus, Escape and dismissal, and * closes itself when clean view, recording or Cockpit hides the panels. */ -import { formatNumber, msg, t, tn } from '../i18n/index.js'; +import { formatNumber, msg, onLocaleChange, t, tn } from '../i18n/index.js'; const PANEL_HIDING_MODES = ['ui-clean-view', 'recording-mode', 'cockpit-mode']; /** How long Remove all stays armed after its first press. */ @@ -219,6 +219,12 @@ export function mountLibraryBrowser({ library, trigger }) { node.addEventListener(type, listener); removers.push(() => node.removeEventListener(type, listener)); }; + // Listeners on the dialog's own nodes, released when it is rebuilt. + const dialogRemovers = []; + const listenDialog = (node, type, listener) => { + node.addEventListener(type, listener); + dialogRemovers.push(() => node.removeEventListener(type, listener)); + }; const filter = { query: '', family: '', availableOnly: false }; let dialog = null; let list = null; @@ -259,7 +265,7 @@ export function mountLibraryBrowser({ library, trigger }) { ); close.type = 'button'; close.setAttribute('aria-label', t('Close data library')); - listen(close, 'click', () => api.close()); + listenDialog(close, 'click', () => api.close()); header.append(title, close); const body = element('div', 'data-library-body'); @@ -282,7 +288,8 @@ export function mountLibraryBrowser({ library, trigger }) { search.type = 'search'; search.autocomplete = 'off'; search.placeholder = t('Name, provider or country'); - listen(search, 'input', () => { + search.value = filter.query; + listenDialog(search, 'input', () => { filter.query = search.value; render(); }); @@ -291,10 +298,16 @@ export function mountLibraryBrowser({ library, trigger }) { const familyLabel = element('label', 'data-library-field'); familyLabel.append(element('span', '', t('Theme'))); const family = element('select', 'panel-surface-control'); - family.append(new Option(t('All themes'), '')); + const option = (text, value) => { + const node = element('option', '', text); + node.value = value; + return node; + }; + family.append(option(t('All themes'), '')); for (const [value, label] of Object.entries(FAMILY_LABELS)) - family.append(new Option(t(label), value)); - listen(family, 'change', () => { + family.append(option(t(label), value)); + family.value = filter.family; + listenDialog(family, 'change', () => { filter.family = family.value; render(); }); @@ -303,7 +316,8 @@ export function mountLibraryBrowser({ library, trigger }) { const availableLabel = element('label', 'data-library-check'); const available = element('input'); available.type = 'checkbox'; - listen(available, 'change', () => { + available.checked = filter.availableOnly; + listenDialog(available, 'change', () => { filter.availableOnly = available.checked; render(); }); @@ -319,7 +333,7 @@ export function mountLibraryBrowser({ library, trigger }) { status.setAttribute('aria-live', 'polite'); resetButton = element('button', 'panel-surface-control data-library-reset'); resetButton.type = 'button'; - listen(resetButton, 'click', () => { + listenDialog(resetButton, 'click', () => { if (resetting) return; if (!resetArmed) { resetArmed = true; @@ -344,7 +358,7 @@ export function mountLibraryBrowser({ library, trigger }) { toolbar.append(status, resetButton); list = element('ul', 'data-library-list'); - listen(list, 'click', (event) => { + listenDialog(list, 'click', (event) => { const button = event.target?.closest?.('button[data-library-action]'); if (!button || button.disabled) return; const id = button.dataset.libraryId; @@ -366,13 +380,13 @@ export function mountLibraryBrowser({ library, trigger }) { body.append(stats, intro, filters, toolbar, list); dialog.append(header, body); - listen(dialog, 'cancel', (event) => { + listenDialog(dialog, 'cancel', (event) => { event.preventDefault(); api.close(); }); // A click on the backdrop (the dialog box itself, outside its content) // dismisses, like Escape. - listen(dialog, 'click', (event) => { + listenDialog(dialog, 'click', (event) => { if (event.target === dialog) api.close(); }); document.body.append(dialog); @@ -576,13 +590,33 @@ export function mountLibraryBrowser({ library, trigger }) { destroy() { api.close(); unsubscribe(); + offLocale(); observer?.disconnect(); for (const remove of removers.splice(0)) remove(); + for (const remove of dialogRemovers.splice(0)) remove(); dialog?.remove(); dialog = null; }, }; + /** + * Every label is translated when the dialog is built, so a language switch + * rebuilds it, keeping its filters and, if it was open, opening it again. + */ + function relabel() { + if (!dialog) return; + const wasOpen = dialog.open; + disarmReset(); + clearInterval(statsTimer); + statsTimer = null; + if (wasOpen) dialog.close(); + for (const remove of dialogRemovers.splice(0)) remove(); + dialog.remove(); + dialog = null; + if (wasOpen) api.open(); + } + const offLocale = onLocaleChange(relabel); + const unsubscribe = library.subscribe(render); if (trigger) listen(trigger, 'click', () => api.open()); const observer = diff --git a/src/ui/libraryBrowser.locale.test.mjs b/src/ui/libraryBrowser.locale.test.mjs new file mode 100644 index 000000000..045647b74 --- /dev/null +++ b/src/ui/libraryBrowser.locale.test.mjs @@ -0,0 +1,54 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { installVueDom } from '../testSupport/vueDom.mjs'; +import { setLocale, t } from '../i18n/index.js'; + +test('a language switch relabels the open dialog and keeps its filters', async (context) => { + context.after(installVueDom()); + context.after(() => setLocale('en')); + const { mountLibraryBrowser } = await import('./libraryBrowser.js'); + const library = { + ready: true, + subscribe: () => () => {}, + list: () => [], + summary: () => ({ + entries: 0, + kinds: { dataset: 0, tiles: 0, pack: 0 }, + themes: [], + available: 0, + unavailable: 0, + added: 0, + on: 0, + features: 0, + featureLayers: 0, + }), + reset: async () => 0, + }; + const browser = mountLibraryBrowser({ library, trigger: null }); + context.after(() => browser.destroy()); + browser.open(); + const search = () => + document.querySelector('#data-library input[type="search"]'); + search().value = 'kartverket'; + search().dispatchEvent(new Event('input')); + assert.equal( + document.querySelector('#data-library-title').textContent, + 'DATA LIBRARY', + ); + + setLocale('de'); + const title = document.querySelector('#data-library-title'); + assert.equal(title.textContent, t('DATA LIBRARY')); + assert.notEqual(title.textContent, 'DATA LIBRARY'); + assert.equal(document.querySelectorAll('#data-library').length, 1); + assert.equal(document.querySelector('#data-library').open, true); + assert.equal(search().value, 'kartverket'); + + // Closed and reopened later, it is still in the new language. + browser.close(); + browser.open(); + assert.equal( + document.querySelector('#data-library-title').textContent, + t('DATA LIBRARY'), + ); +}); From 1a2589d4ef7bf5895a0999c16bd0de58424c9b17 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Thu, 12 Nov 2026 23:01:00 +0000 Subject: [PATCH 25/55] Port the Data Library Browse dialog to Vue and Pinia The dialog is now DataLibraryDialog.vue (with DataLibraryCard.vue for each entry), placed with the Browse button by DataLibraryBrowse.vue in LayerPanelsView and teleported to the body. It renders what the gev-data-library store holds and sends clicks and typing to the owner. src/ui/libraryBrowser.js is now the framework-free owner: it presents the open state, filters, the rows they show, the stat summary and Remove all through `publish`, and its controls answer the view. Its text helpers are what the view renders with t(), so every label follows the locale without rebuilding anything. Only showModal/close and focus moves stay imperative; focus returns to Browse also when the browser closes the dialog on Escape itself. Component tests mount the view over the real owner (open, filters, Add to Remove keeping focus, Zoom, Remove all, a language switch, clean view). --- docs/CURRENT-STATE.md | 8 +- scripts/package-boundaries.json | 8 +- src/app/tools.js | 18 +- src/ui/libraryBrowser.js | 576 ++++++------------ src/ui/libraryBrowser.locale.test.mjs | 54 -- src/ui/stores/dataLibrary.js | 29 + src/ui/views/LayerPanelsView.vue | 8 +- src/ui/views/panels/DataLibraryBrowse.vue | 29 + src/ui/views/panels/DataLibraryCard.vue | 93 +++ .../views/panels/DataLibraryDialog.test.mjs | 237 +++++++ src/ui/views/panels/DataLibraryDialog.vue | 187 ++++++ 11 files changed, 773 insertions(+), 474 deletions(-) delete mode 100644 src/ui/libraryBrowser.locale.test.mjs create mode 100644 src/ui/stores/dataLibrary.js create mode 100644 src/ui/views/panels/DataLibraryBrowse.vue create mode 100644 src/ui/views/panels/DataLibraryCard.vue create mode 100644 src/ui/views/panels/DataLibraryDialog.test.mjs create mode 100644 src/ui/views/panels/DataLibraryDialog.vue diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 41208cd6c..0a56e55a4 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -600,8 +600,12 @@ partition index whose footers keep failing after two retries is `complete: false`: neither the server nor the browser keeps it as whole, a complete earlier index is served instead, and it is rebuilt after five minutes. A view that leaves partitions touching it unread is PARTIAL. Listing -pages and footer reads are size-capped and ranges are validated. The Browse -dialog rebuilds on a language switch, keeping its filters. +pages and footer reads are size-capped and ranges are validated. The Browse dialog is a Vue view (src/ui/views/panels/DataLibraryDialog.vue +and DataLibraryCard.vue, teleported to the body from LayerPanelsView) over the +`gev-data-library` Pinia store; src/ui/libraryBrowser.js is its framework-free +owner (open state, filters, the rows they show, Remove all) and the text +helpers the view renders, so every label follows the locale. Only the modal +open/close and focus moves are imperative. ## Utilities panel — October 29, 2026 diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 6f40a9206..f9244b9b2 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -105,6 +105,11 @@ "src/ui/views/panels/DrawSwatches.vue", "src/annotations/drawMarks.js", "src/ui/stores/display.js", + "src/ui/stores/dataLibrary.js", + "src/ui/views/panels/DataLibraryCard.vue", + "src/ui/views/panels/DataLibraryDialog.vue", + "src/ui/views/panels/DataLibraryBrowse.vue", + "src/ui/libraryBrowser.js", "src/ui/localSdrPresentation.js", "src/ui/unitLabels.js", "src/layers/localAdsb/feedNames.js", @@ -3750,7 +3755,8 @@ "src/layers/satellites/sensors.js", "src/data/ucsSatellites.js", "src/data/imagingSensors.js", - "src/data/planespottersPhoto.js" + "src/data/planespottersPhoto.js", + "src/ui/stores/dataLibrary.js" ], "external": [ "hls.js", diff --git a/src/app/tools.js b/src/app/tools.js index f22d97b7f..476e252a5 100644 --- a/src/app/tools.js +++ b/src/app/tools.js @@ -13,7 +13,8 @@ import { initImageryBoxTool } from '../ui/imageryBoxTool.js'; import { createRecentImageryPanel } from '../ui/recentImagery.js'; import { createDataLibrary } from '../library/controller.js'; import { flyToLibraryExtent } from '../library/camera.js'; -import { mountLibraryBrowser } from '../ui/libraryBrowser.js'; +import { createLibraryBrowser } from '../ui/libraryBrowser.js'; +import { useDataLibraryStore } from '../ui/stores/dataLibrary.js'; import { localGeoJsonServices } from './localGeojsonServices.js'; import { initGevVoiceCommands } from '../voice/gevRealtime.js'; import { installViews, isEmbeddedInline } from './embed.js'; @@ -131,13 +132,18 @@ export function createApplicationTools({ ) !== false, }); defer(() => dataLibrary.destroy()); - const libraryTrigger = document.getElementById('data-library-browse'); - const libraryBrowser = mountLibraryBrowser({ + // Browse: the owner presents the dialog through the store; LayerPanelsView + // draws the Browse button and the dialog from it. + const libraryStore = useDataLibraryStore(applicationPinia()); + const libraryBrowser = createLibraryBrowser({ library: dataLibrary, - trigger: libraryTrigger, + publish: (view) => libraryStore.publish(view), + }); + libraryStore.connect(libraryBrowser.controls); + defer(() => { + libraryBrowser.destroy(); + libraryStore.connect(null); }); - defer(() => libraryBrowser.destroy()); - if (libraryTrigger) libraryTrigger.hidden = false; dataLibrary.start({ signal }).catch((error) => { if (!signal?.aborted) console.warn('[Library] start failed:', error); }); diff --git a/src/ui/libraryBrowser.js b/src/ui/libraryBrowser.js index 84989b60b..ba3a0339e 100644 --- a/src/ui/libraryBrowser.js +++ b/src/ui/libraryBrowser.js @@ -1,13 +1,20 @@ /** - * DATA LAYERS ▸ Browse data library: the dialog that lists every Data Library - * entry this build knows, with its licence, coverage and what this instance - * needs to serve it, and adds or removes it from the panel. + * DATA LAYERS ▸ Browse data library: the owner of the dialog that lists every + * Data Library entry this build knows, with its licence, coverage and what + * this instance needs to serve it, and adds or removes it from the panel. * - * A `dialog[data-panel-surface]` per docs/panel-surfaces.md: this module owns - * width, height, labels, initial and return focus, Escape and dismissal, and - * closes itself when clean view, recording or Cockpit hides the panels. + * Framework-free: `createLibraryBrowser` presents the dialog's state (open, + * filters, the rows they show, the stat summary, Remove all) through + * `publish`, and its `controls` answer the view + * (src/ui/views/panels/DataLibraryDialog.vue, placed by LayerPanelsView). The + * text helpers below are what the view renders; they call `t()`, so text + * rendered through them follows the locale. + * + * A `dialog[data-panel-surface]` per docs/panel-surfaces.md: the view owns + * width, height, labels and focus; this owner closes the dialog when clean + * view, recording or Cockpit hides the panels. */ -import { formatNumber, msg, onLocaleChange, t, tn } from '../i18n/index.js'; +import { formatNumber, msg, t, tn } from '../i18n/index.js'; const PANEL_HIDING_MODES = ['ui-clean-view', 'recording-mode', 'cockpit-mode']; /** How long Remove all stays armed after its first press. */ @@ -40,20 +47,43 @@ const STATUS_LABELS = Object.freeze({ archived: msg('ARCHIVED'), }); -function element(tag, className, text) { - const node = document.createElement(tag); - if (className) node.className = className; - if (text !== undefined) node.textContent = text; - return node; +/** The Theme filter's choices, translated: all themes first. */ +export function libraryThemeOptions() { + return [ + { value: '', label: t('All themes') }, + ...Object.entries(FAMILY_LABELS).map(([value, label]) => ({ + value, + label: t(label), + })), + ]; } -function badge(text, tone = '') { - const node = element( - 'span', - `data-library-badge${tone ? ` data-library-badge--${tone}` : ''}`, - text, - ); - return node; +/** + * An entry card's badges: kind, status, then how it is served (operator + * opt-in, a provider key, or keyless). `tone` is '', 'good' or 'warn'. + * @param {object} entry + * @returns {Array<{ label: string, tone: string }>} + */ +export function libraryBadges(entry) { + const badges = [ + { label: t(KIND_LABELS[entry.kind]), tone: '' }, + { + label: t(STATUS_LABELS[entry.status]), + tone: + entry.status === 'verified' + ? 'good' + : entry.status === 'stale' + ? 'warn' + : '', + }, + ]; + if (entry.licence.operatorOptIn) + badges.push({ label: t('OPT-IN'), tone: 'warn' }); + else if (entry.kind !== 'pack' && entry.source.key) + badges.push({ label: t('KEY'), tone: 'warn' }); + else if (entry.kind !== 'pack') + badges.push({ label: t('KEYLESS'), tone: '' }); + return badges; } const compact = (value) => @@ -129,7 +159,8 @@ export function libraryReasonText({ entry, available, reason }) { return t('Checking what this instance serves…'); } -function coverageText(entry) { +/** Where an entry covers, for its card line. */ +export function coverageText(entry) { const { coverage } = entry; if (coverage.global) return t('Global'); if (coverage.countries?.length) return coverage.countries.join(' · '); @@ -210,32 +241,27 @@ export function sortLibraryRows(rows) { } /** - * @param {{ library: ReturnType, trigger: HTMLElement|null }} options - * @returns {{ open: () => void, close: () => void, destroy: () => void }} + * The Browse dialog's owner. Presents its state through `publish` and answers + * the view through `controls`; never touches the dialog's markup. + * @param {{ library: ReturnType, publish?: (view: object|null) => void, body?: HTMLElement|null }} options + * @returns {{ controls: object, open: () => void, close: () => void, destroy: () => void }} */ -export function mountLibraryBrowser({ library, trigger }) { - const removers = []; - const listen = (node, type, listener) => { - node.addEventListener(type, listener); - removers.push(() => node.removeEventListener(type, listener)); - }; - // Listeners on the dialog's own nodes, released when it is rebuilt. - const dialogRemovers = []; - const listenDialog = (node, type, listener) => { - node.addEventListener(type, listener); - dialogRemovers.push(() => node.removeEventListener(type, listener)); - }; +export function createLibraryBrowser({ + library, + publish = () => {}, + body = globalThis.document?.body ?? null, +}) { const filter = { query: '', family: '', availableOnly: false }; - let dialog = null; - let list = null; - let status = null; - let stats = null; + let open = false; + let destroyed = false; + let rows = []; let statsTimer = null; - let resetButton = null; // Remove all asks twice: the first press arms it for a few seconds. let resetArmed = false; let resetTimer = null; let resetting = false; + // Bumped when focus should go back to the search field (after Remove all). + let focusSearch = 0; function disarmReset() { clearTimeout(resetTimer); @@ -243,396 +269,134 @@ export function mountLibraryBrowser({ library, trigger }) { resetArmed = false; } - function build() { - dialog = element('dialog', 'data-library-dialog'); - dialog.id = 'data-library'; - dialog.setAttribute('data-panel-surface', ''); - dialog.setAttribute('aria-labelledby', 'data-library-title'); - dialog.setAttribute('aria-describedby', 'data-library-intro'); - - const header = element('header', 'data-library-header'); - header.setAttribute('data-panel-header', ''); - const title = element( - 'h2', - 'panel-title panel-surface-title', - t('DATA LIBRARY'), - ); - title.id = 'data-library-title'; - const close = element( - 'button', - 'panel-surface-control data-library-close', - '×', - ); - close.type = 'button'; - close.setAttribute('aria-label', t('Close data library')); - listenDialog(close, 'click', () => api.close()); - header.append(title, close); - - const body = element('div', 'data-library-body'); - body.setAttribute('data-panel-body', ''); - stats = element('dl', 'data-library-stats'); - stats.setAttribute('aria-label', t('Data Library at a glance')); - const intro = element( - 'p', - 'data-library-intro', - t( - 'Live sources beyond the core layers. Each one is fetched from its provider while it is on; nothing is stored in this app. Add one and it appears in Data Layers under Library.', - ), - ); - intro.id = 'data-library-intro'; - - const filters = element('div', 'data-library-filters'); - const searchLabel = element('label', 'data-library-field'); - searchLabel.append(element('span', '', t('Search'))); - const search = element('input', 'panel-surface-control'); - search.type = 'search'; - search.autocomplete = 'off'; - search.placeholder = t('Name, provider or country'); - search.value = filter.query; - listenDialog(search, 'input', () => { - filter.query = search.value; - render(); + /** + * Publish the dialog. Rows are reread from the library unless only the + * stat tiles changed, so an unchanged row list keeps its identity and its + * cards do not re-render. + */ + function present({ rowsChanged = true } = {}) { + if (destroyed) return; + if (rowsChanged || !rows.length) rows = library.list(); + const added = rows.filter((row) => row.added).length; + if (!added && !resetting && resetArmed) disarmReset(); + publish({ + open, + ready: library.ready, + filter: { ...filter }, + shown: open + ? sortLibraryRows(rows.filter((row) => libraryRowMatches(row, filter))) + : [], + total: rows.length, + added, + summary: library.summary(), + reset: { + visible: added > 0 || resetting, + armed: resetArmed, + busy: resetting, + }, + focusSearch, }); - searchLabel.append(search); + } - const familyLabel = element('label', 'data-library-field'); - familyLabel.append(element('span', '', t('Theme'))); - const family = element('select', 'panel-surface-control'); - const option = (text, value) => { - const node = element('option', '', text); - node.value = value; - return node; - }; - family.append(option(t('All themes'), '')); - for (const [value, label] of Object.entries(FAMILY_LABELS)) - family.append(option(t(label), value)); - family.value = filter.family; - listenDialog(family, 'change', () => { - filter.family = family.value; - render(); - }); - familyLabel.append(family); + function close() { + disarmReset(); + clearInterval(statsTimer); + statsTimer = null; + if (!open) return; + open = false; + present(); + } - const availableLabel = element('label', 'data-library-check'); - const available = element('input'); - available.type = 'checkbox'; - available.checked = filter.availableOnly; - listenDialog(available, 'change', () => { - filter.availableOnly = available.checked; - render(); - }); - availableLabel.append( - available, - element('span', '', t('Only what this instance serves')), - ); - filters.append(searchLabel, familyLabel, availableLabel); - - const toolbar = element('div', 'data-library-toolbar'); - status = element('p', 'data-library-status'); - status.setAttribute('role', 'status'); - status.setAttribute('aria-live', 'polite'); - resetButton = element('button', 'panel-surface-control data-library-reset'); - resetButton.type = 'button'; - listenDialog(resetButton, 'click', () => { + const controls = { + open() { + if (destroyed || open) return; + open = true; + present(); + // Feature counts arrive as datasets finish loading, outside the + // library's own notifications; refresh the tiles while open. + clearInterval(statsTimer); + statsTimer = setInterval( + () => present({ rowsChanged: false }), + STATS_REFRESH_MS, + ); + }, + close, + setQuery(value) { + filter.query = String(value ?? ''); + present({ rowsChanged: false }); + }, + setFamily(value) { + filter.family = String(value ?? ''); + present({ rowsChanged: false }); + }, + setAvailableOnly(value) { + filter.availableOnly = Boolean(value); + present({ rowsChanged: false }); + }, + add(id) { + Promise.resolve(library.add(id)).catch((error) => + console.warn(`[Library] ${id} failed:`, error), + ); + }, + remove(id) { + Promise.resolve(library.remove(id)).catch((error) => + console.warn(`[Library] ${id} failed:`, error), + ); + }, + zoom(id) { + // Close first so the flight is visible; focus returns to Browse. An + // entry that is off is switched on first. + close(); + Promise.resolve(library.zoom(id)).catch((error) => + console.warn(`[Library] ${id} zoom failed:`, error), + ); + }, + reset() { if (resetting) return; if (!resetArmed) { resetArmed = true; resetTimer = setTimeout(() => { disarmReset(); - render(); + present({ rowsChanged: false }); }, RESET_CONFIRM_MS); - render(); + present({ rowsChanged: false }); return; } disarmReset(); resetting = true; - render(); + present({ rowsChanged: false }); Promise.resolve(library.reset()) .catch((error) => console.warn('[Library] reset failed:', error)) .finally(() => { resetting = false; - render(); - dialog?.querySelector('input[type="search"]')?.focus(); + focusSearch += 1; + present(); }); - }); - toolbar.append(status, resetButton); - - list = element('ul', 'data-library-list'); - listenDialog(list, 'click', (event) => { - const button = event.target?.closest?.('button[data-library-action]'); - if (!button || button.disabled) return; - const id = button.dataset.libraryId; - const kind = button.dataset.libraryAction; - if (kind === 'zoom') { - // Close first so the flight is visible; focus returns to Browse. - // An entry that is off is switched on first. - api.close(); - Promise.resolve(library.zoom(id)).catch((error) => - console.warn(`[Library] ${id} zoom failed:`, error), - ); - return; - } - const action = kind === 'add' ? library.add(id) : library.remove(id); - Promise.resolve(action).catch((error) => - console.warn(`[Library] ${id} failed:`, error), - ); - }); - - body.append(stats, intro, filters, toolbar, list); - dialog.append(header, body); - listenDialog(dialog, 'cancel', (event) => { - event.preventDefault(); - api.close(); - }); - // A click on the backdrop (the dialog box itself, outside its content) - // dismisses, like Escape. - listenDialog(dialog, 'click', (event) => { - if (event.target === dialog) api.close(); - }); - document.body.append(dialog); - return search; - } - - function renderRow({ entry, available, reason, added, on, busy, canZoom }) { - const item = element('li', 'data-library-item'); - item.dataset.libraryId = entry.id; - item.classList.toggle('is-unavailable', !available); - - const head = element('div', 'data-library-item-head'); - const icon = element('span', 'data-library-icon', entry.icon); - icon.setAttribute('aria-hidden', 'true'); - const name = element('span', 'data-library-name', entry.title); - const badges = element('span', 'data-library-badges'); - badges.append(badge(t(KIND_LABELS[entry.kind]))); - badges.append( - badge( - t(STATUS_LABELS[entry.status]), - entry.status === 'verified' - ? 'good' - : entry.status === 'stale' - ? 'warn' - : '', - ), - ); - if (entry.licence.operatorOptIn) badges.append(badge(t('OPT-IN'), 'warn')); - else if (entry.kind !== 'pack' && entry.source.key) - badges.append(badge(t('KEY'), 'warn')); - else if (entry.kind !== 'pack') badges.append(badge(t('KEYLESS'))); - head.append(icon, name, badges); - - const description = element('p', 'data-library-description'); - description.textContent = entry.description; - - const meta = element('p', 'data-library-meta'); - const provider = element('span', '', entry.provider); - provider.setAttribute('translate', 'no'); - const licence = element('a', '', entry.licence.name); - licence.href = entry.licence.url; - licence.target = '_blank'; - licence.rel = 'noopener noreferrer'; - meta.append( - provider, - document.createTextNode(' · '), - element('span', '', coverageText(entry)), - document.createTextNode(' · '), - licence, - ); - - item.append(head, description); - const specs = imagerySpecs(entry); - if (specs.length) { - const list = element('dl', 'data-library-specs'); - for (const { label, value } of specs) { - const row = element('div', 'data-library-spec'); - row.append(element('dt', '', label), element('dd', '', value)); - list.append(row); - } - item.append(list); - } - item.append(meta); - const note = libraryReasonText({ entry, available, reason }); - if (note) item.append(element('p', 'data-library-reason', note)); - - if (entry.kind !== 'pack') { - const actions = element('div', 'data-library-actions'); - if (added && canZoom) { - const zoom = element( - 'button', - 'panel-surface-control data-library-action', - t('ZOOM'), - ); - zoom.type = 'button'; - zoom.dataset.libraryId = entry.id; - zoom.dataset.libraryAction = 'zoom'; - zoom.disabled = busy; - zoom.setAttribute( - 'aria-label', - t('Zoom to {name}', { name: entry.title }), - ); - actions.append(zoom); - } - const action = element( - 'button', - 'panel-surface-control data-library-action', - ); - action.type = 'button'; - action.dataset.libraryId = entry.id; - action.dataset.libraryAction = added ? 'remove' : 'add'; - action.textContent = busy - ? t('WORKING…') - : added - ? t('REMOVE') - : t('ADD'); - action.disabled = busy || (!added && !available); - action.setAttribute( - 'aria-label', - added - ? t('Remove {name} from Data Layers', { name: entry.title }) - : t('Add {name} to Data Layers', { name: entry.title }), - ); - if (added && on) item.classList.add('is-on'); - actions.append(action); - item.append(actions); - } - return item; - } - - /** Rewrite the stat tiles in place; unchanged text is not touched. */ - function renderStats() { - if (!dialog?.open || !stats) return; - for (const tile of libraryStatTiles(library.summary())) { - let node = stats.querySelector(`[data-stat="${tile.id}"]`); - if (!node) { - node = element('div', 'data-library-stat'); - node.dataset.stat = tile.id; - node.append( - element('dt', 'data-library-stat-label'), - element('dd', 'data-library-stat-value'), - element('dd', 'data-library-stat-detail'), - ); - stats.append(node); - } - const [label, value, detail] = node.children; - if (label.textContent !== tile.label) label.textContent = tile.label; - if (value.textContent !== tile.value) value.textContent = tile.value; - if (detail.textContent !== tile.detail) detail.textContent = tile.detail; - detail.hidden = !tile.detail; - } - } - - function render() { - if (!dialog?.open) return; - renderStats(); - const rows = library.list(); - const shown = sortLibraryRows( - rows.filter((row) => libraryRowMatches(row, filter)), - ); - const focusedId = document.activeElement?.dataset?.libraryId; - const focusedAction = document.activeElement?.dataset?.libraryAction; - list.replaceChildren(...shown.map(renderRow)); - if (focusedId) { - const row = `button[data-library-id="${CSS.escape(focusedId)}"]`; - // The same control when it survived the render (Add becomes Remove). - ( - list.querySelector( - `${row}[data-library-action="${CSS.escape(focusedAction || '')}"]`, - ) || - list.querySelector(`${row}:not([data-library-action="zoom"])`) || - list.querySelector(row) - )?.focus(); - } - const added = rows.filter((row) => row.added).length; - if (!added && !resetting) disarmReset(); - resetButton.hidden = added === 0 && !resetting; - resetButton.disabled = resetting; - resetButton.classList.toggle('is-armed', resetArmed); - resetButton.textContent = resetting - ? t('WORKING…') - : resetArmed - ? tn(added, 'CONFIRM · REMOVE {count}', 'CONFIRM · REMOVE ALL {count}') - : t('REMOVE ALL'); - resetButton.setAttribute( - 'aria-label', - resetArmed - ? t('Confirm: remove every added entry from Data Layers') - : t('Remove every added entry from Data Layers'), - ); - // The stat tiles carry the totals; this line answers the filter. - status.textContent = !library.ready - ? t('Checking what this instance serves…') - : t('Showing {shown} of {total}', { - shown: shown.length, - total: rows.length, - }); - } - - const api = { - open() { - const search = dialog ? null : build(); - if (dialog.open) return; - render(); - dialog.showModal(); - render(); - // Feature counts arrive as datasets finish loading, outside the - // library's own notifications; refresh the tiles while open. - clearInterval(statsTimer); - statsTimer = setInterval(renderStats, STATS_REFRESH_MS); - (search || dialog.querySelector('input[type="search"]'))?.focus(); - }, - close() { - disarmReset(); - clearInterval(statsTimer); - statsTimer = null; - if (!dialog?.open) return; - dialog.close(); - trigger?.focus?.(); - }, - destroy() { - api.close(); - unsubscribe(); - offLocale(); - observer?.disconnect(); - for (const remove of removers.splice(0)) remove(); - for (const remove of dialogRemovers.splice(0)) remove(); - dialog?.remove(); - dialog = null; }, }; - /** - * Every label is translated when the dialog is built, so a language switch - * rebuilds it, keeping its filters and, if it was open, opening it again. - */ - function relabel() { - if (!dialog) return; - const wasOpen = dialog.open; - disarmReset(); - clearInterval(statsTimer); - statsTimer = null; - if (wasOpen) dialog.close(); - for (const remove of dialogRemovers.splice(0)) remove(); - dialog.remove(); - dialog = null; - if (wasOpen) api.open(); - } - const offLocale = onLocaleChange(relabel); - - const unsubscribe = library.subscribe(render); - if (trigger) listen(trigger, 'click', () => api.open()); + const unsubscribe = library.subscribe(() => present()); const observer = - typeof MutationObserver === 'function' + typeof MutationObserver === 'function' && body ? new MutationObserver(() => { - if ( - PANEL_HIDING_MODES.some((mode) => - document.body.classList.contains(mode), - ) - ) - api.close(); + if (PANEL_HIDING_MODES.some((mode) => body.classList.contains(mode))) + close(); }) : null; - observer?.observe(document.body, { - attributes: true, - attributeFilter: ['class'], - }); - return api; + observer?.observe(body, { attributes: true, attributeFilter: ['class'] }); + present(); + + return { + controls, + open: controls.open, + close, + destroy() { + if (destroyed) return; + close(); + destroyed = true; + unsubscribe(); + observer?.disconnect(); + publish(null); + }, + }; } diff --git a/src/ui/libraryBrowser.locale.test.mjs b/src/ui/libraryBrowser.locale.test.mjs deleted file mode 100644 index 045647b74..000000000 --- a/src/ui/libraryBrowser.locale.test.mjs +++ /dev/null @@ -1,54 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { installVueDom } from '../testSupport/vueDom.mjs'; -import { setLocale, t } from '../i18n/index.js'; - -test('a language switch relabels the open dialog and keeps its filters', async (context) => { - context.after(installVueDom()); - context.after(() => setLocale('en')); - const { mountLibraryBrowser } = await import('./libraryBrowser.js'); - const library = { - ready: true, - subscribe: () => () => {}, - list: () => [], - summary: () => ({ - entries: 0, - kinds: { dataset: 0, tiles: 0, pack: 0 }, - themes: [], - available: 0, - unavailable: 0, - added: 0, - on: 0, - features: 0, - featureLayers: 0, - }), - reset: async () => 0, - }; - const browser = mountLibraryBrowser({ library, trigger: null }); - context.after(() => browser.destroy()); - browser.open(); - const search = () => - document.querySelector('#data-library input[type="search"]'); - search().value = 'kartverket'; - search().dispatchEvent(new Event('input')); - assert.equal( - document.querySelector('#data-library-title').textContent, - 'DATA LIBRARY', - ); - - setLocale('de'); - const title = document.querySelector('#data-library-title'); - assert.equal(title.textContent, t('DATA LIBRARY')); - assert.notEqual(title.textContent, 'DATA LIBRARY'); - assert.equal(document.querySelectorAll('#data-library').length, 1); - assert.equal(document.querySelector('#data-library').open, true); - assert.equal(search().value, 'kartverket'); - - // Closed and reopened later, it is still in the new language. - browser.close(); - browser.open(); - assert.equal( - document.querySelector('#data-library-title').textContent, - t('DATA LIBRARY'), - ); -}); diff --git a/src/ui/stores/dataLibrary.js b/src/ui/stores/dataLibrary.js new file mode 100644 index 000000000..d2a81c273 --- /dev/null +++ b/src/ui/stores/dataLibrary.js @@ -0,0 +1,29 @@ +import { defineStore } from 'pinia'; +import { markRaw, shallowRef } from 'vue'; + +/** + * DATA LAYERS ▸ Browse data library as its view renders it. The browser owner + * (src/ui/libraryBrowser.js) presents whether the dialog is open, the rows + * the filters show, the stat tiles' summary and Remove all's state, and + * publishes them here; the view sends clicks and typing to the connected + * owner. + */ +export const useDataLibraryStore = defineStore('gev-data-library', () => { + /** The presented browser; null until the owner first publishes. */ + const view = shallowRef(null); + + /** @param {?object} next A fresh presentation; every publish re-renders. */ + function publish(next) { + view.value = next && markRaw(next); + } + + /** The browser owner the controls call, or null while none is wired. */ + const controls = shallowRef(null); + + /** @param {?object} next The owner's controls, or null to disconnect. */ + function connect(next) { + controls.value = next && markRaw(next); + } + + return { view, publish, controls, connect }; +}); diff --git a/src/ui/views/LayerPanelsView.vue b/src/ui/views/LayerPanelsView.vue index 8e9a58583..b87525530 100644 --- a/src/ui/views/LayerPanelsView.vue +++ b/src/ui/views/LayerPanelsView.vue @@ -14,6 +14,7 @@ import StreetLevelPanel from './panels/StreetLevelPanel.vue'; import DrawControls from './panels/DrawControls.vue'; import GeoImport from './panels/GeoImport.vue'; import UiSelect from '../kit/UiSelect.js'; +import DataLibraryBrowse from './panels/DataLibraryBrowse.vue'; // Scene sharing (src/scenes/sharing.js) offers its authoring bar and presents // its dialog through this store; without this view, neither is drawn. @@ -43,11 +44,8 @@ async function importChosen(event) {
- - + +
diff --git a/src/ui/views/panels/DataLibraryBrowse.vue b/src/ui/views/panels/DataLibraryBrowse.vue new file mode 100644 index 000000000..34b4f357c --- /dev/null +++ b/src/ui/views/panels/DataLibraryBrowse.vue @@ -0,0 +1,29 @@ + + + diff --git a/src/ui/views/panels/DataLibraryCard.vue b/src/ui/views/panels/DataLibraryCard.vue new file mode 100644 index 000000000..10b3f26b7 --- /dev/null +++ b/src/ui/views/panels/DataLibraryCard.vue @@ -0,0 +1,93 @@ + + + diff --git a/src/ui/views/panels/DataLibraryDialog.test.mjs b/src/ui/views/panels/DataLibraryDialog.test.mjs new file mode 100644 index 000000000..da997ceae --- /dev/null +++ b/src/ui/views/panels/DataLibraryDialog.test.mjs @@ -0,0 +1,237 @@ +// The Browse dialog renders what src/ui/libraryBrowser.js presents and sends +// clicks and typing back to it. +import '../../../testSupport/registerDom.mjs'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createApp, defineComponent, h, nextTick } from 'vue'; +import { createPinia, storeToRefs } from 'pinia'; +import { setLocale, t } from '../../../i18n/index.js'; +import { libraryEntry } from '../../../library/catalog.js'; +import { createLibraryBrowser } from '../../libraryBrowser.js'; +import { useDataLibraryStore } from '../../stores/dataLibrary.js'; +import { applicationI18n } from '../../i18n.js'; +import DataLibraryDialog from './DataLibraryDialog.vue'; + +const IDS = ['nasa-eonet-events', 'eia-power-plants', 'cctv-nyc-dot']; + +/** A library with three entries; Add and Remove change what it lists. */ +function fakeLibrary() { + const added = new Set(); + const listeners = new Set(); + const notify = () => listeners.forEach((listener) => listener()); + return { + ready: true, + added, + calls: [], + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + list: () => + IDS.map((id) => ({ + entry: libraryEntry(id), + available: id !== 'cctv-nyc-dot', + reason: id === 'cctv-nyc-dot' ? 'operator-opt-in' : null, + added: added.has(id), + on: added.has(id), + busy: false, + canZoom: added.has(id), + })), + summary: () => ({ + entries: IDS.length, + kinds: { dataset: 2, tiles: 0, pack: 1 }, + themes: ['cameras', 'events', 'infrastructure'], + available: 2, + unavailable: 1, + added: added.size, + on: added.size, + features: 0, + featureLayers: 0, + }), + async add(id) { + added.add(id); + notify(); + return true; + }, + async remove(id) { + added.delete(id); + notify(); + return true; + }, + async zoom(id) { + this.calls.push(['zoom', id]); + return true; + }, + async reset() { + const count = added.size; + added.clear(); + notify(); + return count; + }, + }; +} + +const settle = async () => { + for (let i = 0; i < 4; i += 1) { + await nextTick(); + await new Promise((resolve) => setTimeout(resolve, 0)); + } +}; + +function mount(t_) { + document.body.replaceChildren(); + const browse = document.createElement('button'); + browse.id = 'data-library-browse'; + document.body.append(browse); + const pinia = createPinia(); + const store = useDataLibraryStore(pinia); + const library = fakeLibrary(); + const owner = createLibraryBrowser({ + library, + publish: (view) => store.publish(view), + }); + store.connect(owner.controls); + const Host = defineComponent({ + setup() { + const { view } = storeToRefs(store); + return () => (view.value ? h(DataLibraryDialog, { view: view.value }) : null); + }, + }); + // The page's i18n, as mountApplicationViews installs it: the locale views + // re-render on. + const app = createApp(Host).use(pinia).use(applicationI18n()); + app.mount(document.body.appendChild(document.createElement('div'))); + t_.after(() => { + owner.destroy(); + app.unmount(); + setLocale('en'); + }); + return { owner, library }; +} + +const $ = (selector) => document.querySelector(selector); +const shownIds = () => + [...document.querySelectorAll('#data-library .data-library-item')].map( + (item) => item.dataset.libraryId, + ); + +test('Browse opens a modal on the search field, packs last; Escape returns to Browse', async (t_) => { + const { owner } = mount(t_); + assert.equal($('#data-library').open, false); + owner.open(); + await settle(); + assert.equal($('#data-library').open, true); + assert.equal(document.activeElement, $('#data-library input[type="search"]')); + assert.equal($('#data-library-title').textContent.trim(), 'DATA LIBRARY'); + assert.equal(shownIds().at(-1), 'cctv-nyc-dot'); + assert.match( + $('[data-library-id="cctv-nyc-dot"] .data-library-reason').textContent, + /CCTV_NYC_ENABLED=1/, + ); + assert.equal( + $('[data-library-id="cctv-nyc-dot"] .data-library-action'), + null, + 'a pack offers no Add', + ); + $('#data-library').dispatchEvent(new Event('cancel', { cancelable: true })); + await settle(); + assert.equal($('#data-library').open, false); + assert.equal(document.activeElement, $('#data-library-browse')); +}); + +test('filters narrow the list as the viewer types and picks', async (t_) => { + const { owner } = mount(t_); + owner.open(); + await settle(); + const search = $('#data-library input[type="search"]'); + search.value = 'eonet'; + search.dispatchEvent(new Event('input')); + await settle(); + assert.deepEqual(shownIds(), ['nasa-eonet-events']); + assert.match($('.data-library-status').textContent, /1 of 3/); + search.value = ''; + search.dispatchEvent(new Event('input')); + const only = $('.data-library-check input'); + only.checked = true; + only.dispatchEvent(new Event('change')); + await settle(); + assert.equal(shownIds().includes('cctv-nyc-dot'), false); + const theme = $('#data-library select'); + theme.value = 'events'; + theme.dispatchEvent(new Event('change')); + await settle(); + assert.deepEqual(shownIds(), ['nasa-eonet-events']); +}); + +test('Add becomes Remove on the same focused button; Zoom closes first', async (t_) => { + const { owner, library } = mount(t_); + owner.open(); + await settle(); + const add = $('button[data-library-id="nasa-eonet-events"][data-library-action="add"]'); + add.focus(); + add.click(); + await settle(); + assert.deepEqual([...library.added], ['nasa-eonet-events']); + const remove = $( + 'button[data-library-id="nasa-eonet-events"]:not([data-library-action="zoom"])', + ); + assert.equal(remove.dataset.libraryAction, 'remove'); + assert.equal(document.activeElement, remove, 'focus stays on the button'); + assert.equal($('[data-stat="added"] .data-library-stat-value').textContent, '1'); + $('button[data-library-id="nasa-eonet-events"][data-library-action="zoom"]').click(); + await settle(); + assert.equal($('#data-library').open, false); + assert.deepEqual(library.calls, [['zoom', 'nasa-eonet-events']]); +}); + +test('Remove all asks twice, then focuses the search field', async (t_) => { + const { owner, library } = mount(t_); + await library.add('nasa-eonet-events'); + await library.add('eia-power-plants'); + owner.open(); + await settle(); + const reset = () => $('#data-library .data-library-reset'); + assert.equal(reset().hidden, false); + reset().click(); + await settle(); + assert.equal(reset().classList.contains('is-armed'), true); + assert.match(reset().textContent, /CONFIRM/); + assert.equal(library.added.size, 2, 'one press removes nothing'); + reset().click(); + await settle(); + assert.equal(library.added.size, 0); + assert.equal(reset().hidden, true); + assert.equal(document.activeElement, $('#data-library input[type="search"]')); +}); + +test('a language switch relabels the open dialog and keeps its filters', async (t_) => { + const { owner } = mount(t_); + owner.open(); + await settle(); + const search = $('#data-library input[type="search"]'); + search.value = 'eonet'; + search.dispatchEvent(new Event('input')); + await settle(); + setLocale('de'); + await settle(); + const title = $('#data-library-title').textContent.trim(); + assert.equal(title, t('DATA LIBRARY')); + assert.notEqual(title, 'DATA LIBRARY'); + assert.equal( + $('.data-library-action').textContent.trim(), + t('ADD'), + 'cards follow the locale too', + ); + assert.equal($('#data-library').open, true); + assert.equal($('#data-library input[type="search"]').value, 'eonet'); +}); + +test('clean view closes an open dialog', async (t_) => { + const { owner } = mount(t_); + owner.open(); + await settle(); + document.body.classList.add('ui-clean-view'); + await settle(); + assert.equal($('#data-library').open, false); + document.body.classList.remove('ui-clean-view'); +}); diff --git a/src/ui/views/panels/DataLibraryDialog.vue b/src/ui/views/panels/DataLibraryDialog.vue new file mode 100644 index 000000000..99b4b7d0d --- /dev/null +++ b/src/ui/views/panels/DataLibraryDialog.vue @@ -0,0 +1,187 @@ + + + From d58ba23fba2e432ae8845061bec7d4c8fef5d4eb Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 12 Nov 2026 15:48:14 -0800 Subject: [PATCH 26/55] Refresh live Data Library datasets once older than cacheSeconds A dataset layer loaded its features once and kept them until it was removed: the parsed set survives disable/enable, so NWS alerts, GDACS and EONET showed what they held when first switched on for the whole session. A library dataset now asks the manager for a refresh every cacheSeconds. Once its data is that old it loads again, and swaps the new set in only when it is ready, so the old features stay drawn while the request runs and stay if it fails. An enable after cacheSeconds also loads afresh. Disable and destroy cancel a refresh in flight. --- src/data/localGeojsonCore.js | 9 ++ src/library/datasetLayer.test.mjs | 154 ++++++++++++++++++++++++++++++ src/library/layers.js | 66 ++++++++++++- 3 files changed, 227 insertions(+), 2 deletions(-) create mode 100644 src/library/datasetLayer.test.mjs diff --git a/src/data/localGeojsonCore.js b/src/data/localGeojsonCore.js index e079b5e8a..ddc52a9a3 100644 --- a/src/data/localGeojsonCore.js +++ b/src/data/localGeojsonCore.js @@ -1102,6 +1102,15 @@ export function createLocalGeoJsonLayer( disable: disableLayer, + /** + * Forget the parsed features so the next enable() loads them again: a + * caller-owned live loader (the Data Library) uses it once its data is + * older than the source allows. + */ + dropCachedFeatures: () => { + _cachedFeatures = null; + }, + destroy: (viewer) => { if (_destroyed) return; _destroyed = true; diff --git a/src/library/datasetLayer.test.mjs b/src/library/datasetLayer.test.mjs new file mode 100644 index 000000000..8d143c2d9 --- /dev/null +++ b/src/library/datasetLayer.test.mjs @@ -0,0 +1,154 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { localGeoJsonServices } from '../app/localGeojsonServices.js'; +import { createLibraryDatasetLayer } from './layers.js'; + +const entry = { + id: 'test-alerts', + kind: 'dataset', + title: 'Test alerts', + icon: '!', + provider: 'Test', + display: { color: '#ff0000' }, + source: { cacheSeconds: 60 }, + attribution: { text: 'Test' }, + licence: { name: 'CC0', url: 'https://example.org/licence' }, +}; + +const point = (name) => ({ + type: 'Feature', + properties: { name }, + geometry: { + type: 'Polygon', + coordinates: [ + [ + [0, 0], + [0.01, 0], + [0, 0.01], + [0, 0], + ], + ], + }, +}); + +const deferred = () => { + let resolve; + let reject; + const promise = new Promise((done, fail) => { + resolve = done; + reject = fail; + }); + return { promise, resolve, reject }; +}; + +function harness(t, loads) { + const previousWindow = globalThis.window; + globalThis.window = { dispatchEvent() {} }; + const sources = new Set(); + const event = { addEventListener: () => () => {} }; + const viewer = { + dataSources: { + async add(source) { + sources.add(source); + return source; + }, + remove(source) { + return sources.delete(source); + }, + }, + scene: { canvas: {}, preRender: event, requestRender() {} }, + camera: { moveEnd: event }, + }; + let calls = 0; + const source = { + async loadDataset(_entry, { signal }) { + const answer = loads[calls++]; + return typeof answer === 'function' ? answer(signal) : answer; + }, + }; + const layer = createLibraryDatasetLayer(entry, { + services: { + ...localGeoJsonServices, + overlayHost: { setVisible() {}, setEntries() {}, clearSource() {} }, + }, + source, + screenSpaceEventHandlerFactory: () => ({ + setInputAction() {}, + destroy() {}, + }), + }); + t.after(() => { + layer.destroy(viewer); + if (previousWindow === undefined) delete globalThis.window; + else globalThis.window = previousWindow; + }); + return { layer, viewer, sources, calls: () => calls }; +} + +const result = (...names) => ({ + features: names.map(point), + truncated: false, +}); + +test('a library dataset refreshes on the manager tick once older than cacheSeconds', async (t) => { + t.mock.timers.enable({ apis: ['Date'], now: 0 }); + const env = harness(t, [result('a'), result('b', 'c')]); + assert.equal(env.layer.refreshInterval, 60_000); + await env.layer.enable(env.viewer); + assert.equal(env.layer.getStats().count, 1); + await env.layer.update(env.viewer); + assert.equal(env.calls(), 1, 'fresh data is not refetched'); + t.mock.timers.tick(60_000); + await env.layer.update(env.viewer); + assert.equal(env.calls(), 2); + assert.equal(env.layer.getStats().count, 2); + assert.equal(env.sources.size, 1, 'the old source is replaced, not kept'); +}); + +test('a stale library dataset reloads on the next enable', async (t) => { + t.mock.timers.enable({ apis: ['Date'], now: 0 }); + const env = harness(t, [result('a'), result('b', 'c')]); + await env.layer.enable(env.viewer); + env.layer.disable(env.viewer); + await env.layer.enable(env.viewer); + assert.equal(env.calls(), 1, 'a re-enable within cacheSeconds reuses the data'); + env.layer.disable(env.viewer); + t.mock.timers.tick(60_000); + await env.layer.enable(env.viewer); + assert.equal(env.calls(), 2); + assert.equal(env.layer.getStats().count, 2); +}); + +test('a failed refresh keeps the drawn features', async (t) => { + t.mock.timers.enable({ apis: ['Date'], now: 0 }); + const env = harness(t, [ + result('a'), + () => Promise.reject(new Error('HTTP 503')), + ]); + await env.layer.enable(env.viewer); + t.mock.timers.tick(60_000); + await assert.rejects(env.layer.update(env.viewer), /HTTP 503/); + assert.equal(env.layer.getStats().count, 1); + assert.equal(env.sources.size, 1); +}); + +test('disable during a refresh cancels it and draws nothing late', async (t) => { + t.mock.timers.enable({ apis: ['Date'], now: 0 }); + const pending = deferred(); + let refreshSignal = null; + const env = harness(t, [ + result('a'), + (signal) => { + refreshSignal = signal; + return pending.promise; + }, + ]); + await env.layer.enable(env.viewer); + t.mock.timers.tick(60_000); + const refreshing = env.layer.update(env.viewer); + env.layer.disable(env.viewer); + assert.equal(refreshSignal.aborted, true); + pending.resolve(result('b', 'c')); + await refreshing; + assert.equal(env.sources.size, 0); +}); diff --git a/src/library/layers.js b/src/library/layers.js index 42b6eb237..c1c42b793 100644 --- a/src/library/layers.js +++ b/src/library/layers.js @@ -147,13 +147,27 @@ export function libraryCredit(entry) { */ export function createLibraryDatasetLayer( entry, - { services, source, onZoom = null, onRemove = null }, + { + services, + source, + onZoom = null, + onRemove = null, + screenSpaceEventHandlerFactory, + }, ) { let truncated = false; let loaded = 0; let extent = null; let rowListener = null; let legendCounts = null; + // Live data: features older than the source's cacheSeconds are loaded + // again, on the next enable and on a timer while the layer is on. + const maxAgeMs = entry.source.cacheSeconds * 1000; + let loadedAt = 0; + let prefetched = null; + let refreshController = null; + let on = false; + const stale = () => Date.now() - loadedAt >= maxAgeMs; const colorBy = entry.display.colorBy || null; const layer = createLocalGeoJsonLayer( { @@ -164,12 +178,18 @@ export function createLibraryDatasetLayer( icon: entry.icon, source: entry.provider, labels: true, + ...(screenSpaceEventHandlerFactory + ? { screenSpaceEventHandlerFactory } + : {}), featureColor: colorBy ? (properties) => colorBy.colors[String(properties?.__gevColorKey ?? '')] || null : null, loadFeatures: async ({ signal }) => { - const result = await source.loadDataset(entry, { signal }); + const result = + prefetched ?? (await source.loadDataset(entry, { signal })); + prefetched = null; + loadedAt = Date.now(); truncated = result.truncated; loaded = result.features.length; extent = featureExtent(result.features); @@ -181,13 +201,55 @@ export function createLibraryDatasetLayer( services, ); const enable = layer.enable; + const disable = layer.disable; + const destroy = layer.destroy; const getStats = layer.getStats; + const cancelRefresh = () => { + refreshController?.abort(); + refreshController = null; + prefetched = null; + }; return Object.assign(layer, { libraryEntryId: entry.id, + refreshInterval: maxAgeMs, async enable(viewer, ...rest) { + on = true; registerDynamicCredit(viewer, libraryCredit(entry)); + if (stale()) layer.dropCachedFeatures(); return enable(viewer, ...rest); }, + /** + * The manager's refresh tick: once the data is stale, load it again and + * swap it in only when the new set is ready, so the old features stay + * drawn while the request runs and stay if it fails. + */ + async update(viewer) { + if (!on || !stale() || refreshController) return; + const current = new AbortController(); + refreshController = current; + try { + const result = await source.loadDataset(entry, { + signal: current.signal, + }); + if (!on || current.signal.aborted) return; + prefetched = result; + layer.dropCachedFeatures(); + disable(viewer); + await enable(viewer); + } finally { + if (refreshController === current) refreshController = null; + } + }, + disable(viewer, ...rest) { + on = false; + cancelRefresh(); + return disable(viewer, ...rest); + }, + destroy(viewer, ...rest) { + on = false; + cancelRefresh(); + return destroy(viewer, ...rest); + }, getStats() { const stats = getStats(); // A capped dataset reads PARTIAL ("incomplete snapshot"), never as From 0f13c0a1c2d933903145db2e8461084b32e9cbef Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 12 Nov 2026 15:49:11 -0800 Subject: [PATCH 27/55] Let a newer share switch off a library layer still switching on A share restore skipped every entry whose settled state already matched the share, but a layer still switching on for an earlier share reads as off until it settles. Opening a second share in that window left the first share's layer to come on anyway. The restore now compares with the manager's effective visibility (the newest request), so the newer share supersedes the earlier one. --- src/library/controller.js | 7 ++++++- src/library/controller.test.mjs | 29 +++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/library/controller.js b/src/library/controller.js index b90654c7b..9e2807e4a 100644 --- a/src/library/controller.js +++ b/src/library/controller.js @@ -204,7 +204,12 @@ export function createDataLibrary({ if (wanted && !register(entry)) continue; if (!isRegistered(id)) continue; const layerId = libraryLayerId(id); - if (wanted === (dataManager.isEnabled?.(layerId) === true)) continue; + // Compare with the newest request, not settled state: a layer still + // switching on for an earlier share must be switched off by this one. + const current = + dataManager.isEffectivelyEnabled?.(layerId) ?? + dataManager.isEnabled?.(layerId); + if (wanted === (current === true)) continue; dataManager .setEnabled(layerId, wanted, { origin }) .catch((error) => diff --git a/src/library/controller.test.mjs b/src/library/controller.test.mjs index 53e553233..4b75e7b91 100644 --- a/src/library/controller.test.mjs +++ b/src/library/controller.test.mjs @@ -316,6 +316,35 @@ test('navigating between shares switches library entries on and off', async (t) assert.equal(shareLink.listener, null); }); +test('a share opened while an earlier one is still switching on wins', async (t) => { + const manager = sealedManager(t); + const shareLink = fakeShareLink(null); + const library = createDataLibrary({ + entries, + ledger, + dataManager: manager, + services: {}, + storage: memoryStorage(), + shareLink, + source: source({}), + }); + await library.start(); + const settle = () => + Promise.all( + ['lib-night-lights', 'lib-sea-marks'].map((id) => + manager.waitForLayerSettled?.(id), + ), + ); + // Neither share waits for the one before it to settle. + shareLink.listener(['00']); + assert.equal(manager.isEnabled('lib-night-lights'), false, 'still enabling'); + shareLink.listener(['01']); + await settle(); + assert.equal(manager.isEnabled('lib-night-lights'), false); + assert.equal(manager.isEnabled('lib-sea-marks'), true); + library.destroy(); +}); + test('zoom flies to an added layer through the caller, reset removes everything', async (t) => { const manager = sealedManager(t); const storage = memoryStorage(); From 7d91a1875b623819665926bc857e6604edcd8d98 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 12 Nov 2026 15:49:56 -0800 Subject: [PATCH 28/55] Cancel a view-bounded load when the camera returns to the drawn view Moving from view A to B started B's request; moving back to A before it finished returned early because A was already drawn, so B's request ran on and its features replaced A's while the camera was at A. Returning to the drawn view now cancels the request in between. --- src/library/layers.js | 11 ++++++++- src/library/viewLayer.test.mjs | 41 ++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/src/library/layers.js b/src/library/layers.js index c1c42b793..ffbb257f2 100644 --- a/src/library/layers.js +++ b/src/library/layers.js @@ -753,7 +753,16 @@ export function createLibraryViewLayer( const box = viewBox(height); if (!box) return; const key = box.map((value) => value.toFixed(4)).join(','); - if (key === loadedKey && dataSource) return; + // Back on the view already drawn: a request for the view in between is + // obsolete and must not replace it. + if (key === loadedKey && dataSource) { + if (controller) { + abort(); + loading = false; + render(); + } + return; + } abort(); const current = new AbortController(); controller = current; diff --git a/src/library/viewLayer.test.mjs b/src/library/viewLayer.test.mjs index 77cb410dc..53f9bd65a 100644 --- a/src/library/viewLayer.test.mjs +++ b/src/library/viewLayer.test.mjs @@ -116,6 +116,47 @@ test('below it, the layer loads the box around the view centre and reads PARTIAL assert.equal(added.length, 0); }); +test('returning to the drawn view cancels the request for the view in between', async () => { + let release; + const signals = []; + let call = 0; + const layer = createLibraryViewLayer(entry, { + source: { + loadDataset: async (target, { signal }) => { + signals.push(signal); + call += 1; + if (call === 2) + await new Promise((resolve) => { + release = resolve; + }); + return { features: [polygon], truncated: false }; + }, + }, + cesium: fakeCesium, + }); + const { viewer, added } = fakeViewer({ height: 2000 }); + const moveTo = (lon, lat) => { + viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees(lon, lat, 2000); + viewer.camera.pickEllipsoid = () => Cesium.Cartesian3.fromDegrees(lon, lat, 0); + viewer.camera.computeViewRectangle = () => + Cesium.Rectangle.fromDegrees(lon - 1, lat - 1, lon + 1, lat + 1); + }; + await layer.init(viewer); + await layer.enable(viewer); + const drawn = added[0]; + moveTo(-77.2, 38.95); + const between = layer.enable(viewer); + await new Promise((resolve) => setImmediate(resolve)); + moveTo(-77.03, 38.89); + await layer.enable(viewer); + assert.equal(signals[1].aborted, true); + release(); + await between; + assert.deepEqual(added, [drawn]); + assert.equal(layer.getStats().loading, false); + await layer.destroy(viewer); +}); + test('the row legend labels each colour and counts what is in view', async () => { const { colorByLegend } = await import('./layers.js'); const bridges = libraryEntry('ntad-national-bridges'); From e05a9566e412529da90b07ab1c62a24b38702ae4 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 12 Nov 2026 15:50:46 -0800 Subject: [PATCH 29/55] Retry a GeoParquet footer whose body fails mid-stream Only a failed fetch() marked a footer read as worth retrying. A body cut off after its headers arrived (a reset, the proxy's 20-second timeout) was taken for an unparseable file: the partition was left out and the index still read as complete, so the server cached it without that partition for the entry's cacheSeconds (24 hours for EPC). Such a read is now retried like any fetch failure; an oversized body is still final. --- src/library/parquet.js | 19 ++++++++++++---- src/library/parquet.test.mjs | 44 ++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/src/library/parquet.js b/src/library/parquet.js index 29bb7ce99..eba0c047b 100644 --- a/src/library/parquet.js +++ b/src/library/parquet.js @@ -88,11 +88,20 @@ export function rangeBuffer(url, byteLength, { fetchImpl, signal }) { // else must be exactly the bytes asked for. const whole = response.status === 200; const expected = whole ? byteLength : end - start; - const bytes = await readResponseBytesCapped( - response, - whole ? Math.min(byteLength, MAX_UNRANGED_BYTES) : expected, - signal, - ); + let bytes; + try { + bytes = await readResponseBytesCapped( + response, + whole ? Math.min(byteLength, MAX_UNRANGED_BYTES) : expected, + signal, + ); + } catch (error) { + // A body cut off mid-stream (a reset, the proxy's timeout) is a + // fetch failure worth retrying; an oversized one is not. + if (signal?.aborted || error?.code === 'RESPONSE_TOO_LARGE') + throw error; + throw transient(error); + } if (bytes.byteLength !== expected) throw transient( new Error( diff --git a/src/library/parquet.test.mjs b/src/library/parquet.test.mjs index 65cf382ce..8044b4f1e 100644 --- a/src/library/parquet.test.mjs +++ b/src/library/parquet.test.mjs @@ -220,6 +220,50 @@ test('a footer that keeps failing leaves the index incomplete, and a retry recov assert.equal(broken.failed, 1); }); +test('a footer whose body fails mid-stream is retried, not left out', async () => { + const { buildPartitionIndex } = await import('./parquet.js'); + const entry = { + ...epc, + source: { + ...epc.source, + partitions: { + listUrl: 'https://s3.us-west-2.amazonaws.com/bucket?list-type=2', + keyBase: 'https://s3.us-west-2.amazonaws.com/bucket/', + }, + }, + }; + const listing = `a.parquet100`; + let cutOff = 0; + const fetchImpl = async (url) => { + if (url.includes('list-type')) + return new Response(listing, { status: 200 }); + if (cutOff > 0) { + cutOff -= 1; + // Headers arrive, then the connection drops. + const body = new ReadableStream({ + pull(controller) { + controller.error(new Error('connection reset')); + }, + }); + return new Response(body, { status: 206 }); + } + return new Response(new Uint8Array(100), { status: 200 }); + }; + cutOff = 100; + const broken = await buildPartitionIndex(entry, { + fetchImpl, + retryDelayMs: 0, + }); + assert.equal(broken.complete, false, 'never reported whole'); + assert.equal(broken.failed, 1); + cutOff = 1; + const recovered = await buildPartitionIndex(entry, { + fetchImpl, + retryDelayMs: 0, + }); + assert.equal(recovered.complete, true); +}); + test('the browser keeps a complete index over an incomplete rebuild and retries later', async () => { let clock = 30 * 24 * 3600_000; const complete = [{ url: 'https://h/A.parquet', bbox: [-2, 53, -1, 54] }]; From 134239fa5ae15546968322bba2d02165fc382935 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 12 Nov 2026 15:51:52 -0800 Subject: [PATCH 30/55] Read a view over an incomplete GeoParquet index as PARTIAL The browser's partition index kept whether it was complete only to schedule a rebuild and handed the partitions on without it, so a view counted unread partitions from an index that might be missing some and could read as complete, even empty, over an area it had never indexed. A view over an incomplete index now reads PARTIAL until a rebuild completes. --- src/library/parquet.test.mjs | 30 ++++++++++++++++++++++++++++++ src/library/source.js | 14 ++++++++++---- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/src/library/parquet.test.mjs b/src/library/parquet.test.mjs index 8044b4f1e..5dba367d5 100644 --- a/src/library/parquet.test.mjs +++ b/src/library/parquet.test.mjs @@ -181,6 +181,36 @@ test('a revisit still finds homes at the edge of a partition', async () => { assert.equal(outside.features.length, 0); }); +test('a view over an incomplete index reads PARTIAL', async () => { + const home = { + type: 'Feature', + geometry: { type: 'Point', coordinates: [-1.2, 53.3] }, + properties: { CURRENT_ENERGY_RATING: 'C', UPRN: '1' }, + }; + const visit = (complete) => + createLibrarySource({ + entries: [epc], + fetchImpl: async () => ({ ok: false, status: 404 }), + storage: null, + loadParquet: async () => ({ + ...(await import('./parquet.js')), + buildPartitionIndex: async () => ({ + partitions: [ + { url: 'https://h/A.parquet', bbox: [-1.5, 53.2, -1.1, 53.5] }, + ], + complete, + }), + readGeoParquetFeatures: async () => [home], + }), + }); + const box = [-1.21, 53.29, -1.19, 53.31]; + const whole = await visit(true).loadDataset(epc, { bbox: box }); + assert.equal(whole.truncated, false); + const partial = await visit(false).loadDataset(epc, { bbox: box }); + assert.equal(partial.features.length, 1); + assert.equal(partial.truncated, true); +}); + test('a footer that keeps failing leaves the index incomplete, and a retry recovers', async () => { const { buildPartitionIndex } = await import('./parquet.js'); const entry = { diff --git a/src/library/source.js b/src/library/source.js index 85f37c5d0..f4062afa5 100644 --- a/src/library/source.js +++ b/src/library/source.js @@ -236,7 +236,7 @@ export function createLibrarySource({ current.retryAt = result.complete ? null : now() + PARTIAL_INDEX_RETRY_MS; - return result.partitions; + return result; }, (error) => { if (indexes.get(entry.id)?.promise === promise) @@ -283,7 +283,7 @@ export function createLibrarySource({ * batches until one batch finds features in view. */ async function loadPartitioned(entry, box, { signal }) { - const [{ partitionsForBox, featureInBox }, index] = await Promise.all([ + const [{ partitionsForBox, featureInBox }, built] = await Promise.all([ loadParquet(), partitionIndex(entry, { signal }), ]); @@ -294,7 +294,12 @@ export function createLibrarySource({ } const learned = storedJson(storage, `${EXTENT_STORAGE_PREFIX}${entry.id}`) || {}; - const ranked = rankPartitions(index, box, learned, partitionsForBox); + const ranked = rankPartitions( + built.partitions, + box, + learned, + partitionsForBox, + ); const candidates = ranked.slice(0, MAX_PARTITION_READS_PER_VIEW); // Every partition the box touches but this view leaves unread (the read // budget, or the early stop below) makes the result partial. @@ -327,7 +332,8 @@ export function createLibrarySource({ }); return { features: bounded.features, - truncated: bounded.truncated || unread > 0, + // An incomplete index may be missing partitions this box needs. + truncated: bounded.truncated || unread > 0 || !built.complete, }; } From e25cce874d56418ac2480d255a402eb3d906653e Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 12 Nov 2026 15:52:31 -0800 Subject: [PATCH 31/55] Read an ArcGIS load stopped at maxFeatures as truncated Paging stopped once maxFeatures were read before it noted that the upstream had more, so a page holding exactly the cap with exceededTransferLimit set read as the whole dataset. FEMA USA Structures (2,000 features, one page) never showed PARTIAL in a dense view. Stopping at the cap or the last page with more upstream now marks the load truncated. --- src/library/formats.js | 11 +++++++++-- src/library/formats.test.mjs | 21 +++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/library/formats.js b/src/library/formats.js index e603a1276..c3d887997 100644 --- a/src/library/formats.js +++ b/src/library/formats.js @@ -429,8 +429,15 @@ export async function loadLibraryDataset( const parsed = parseLibraryBody('arcgis', await readPage(url, budget)); candidates.push(...parsed.candidates); if (!parsed.exceededTransferLimit || !parsed.candidates.length) break; - if (candidates.length >= source.maxFeatures) break; - if (page === source.maxPages - 1) truncated = true; + // The upstream holds more than was read: stopping here, at the + // feature cap or the last page, leaves the dataset partial. + if ( + candidates.length >= source.maxFeatures || + page === source.maxPages - 1 + ) { + truncated = true; + break; + } } } else { const parsed = parseLibraryBody( diff --git a/src/library/formats.test.mjs b/src/library/formats.test.mjs index 4337ba818..fe164e4c7 100644 --- a/src/library/formats.test.mjs +++ b/src/library/formats.test.mjs @@ -182,6 +182,27 @@ test('ArcGIS paging follows exceededTransferLimit up to maxPages', async () => { assert.equal(collection.truncated, true); }); +test('ArcGIS stopping at maxFeatures with more upstream reads as truncated', async () => { + const value = entry({ + format: 'arcgis', + url: 'https://s.example.org/FeatureServer/0', + maxFeatures: 3, + maxPages: 5, + }); + let pages = 0; + const collection = await loadLibraryDataset(value, async () => { + pages += 1; + return JSON.stringify({ + type: 'FeatureCollection', + properties: { exceededTransferLimit: true }, + features: [point(1, 0), point(2, 0), point(3, 0)], + }); + }); + assert.equal(pages, 1); + assert.equal(collection.features.length, 3); + assert.equal(collection.truncated, true); +}); + test('a plain dataset request carries the manifest query once', async () => { const value = entry({ format: 'geojson', From f33a6ada52ffcb2ad583912e0918106305803a0a Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 12 Nov 2026 15:53:29 -0800 Subject: [PATCH 32/55] Refuse a Data Library entry past the 64-entry cap before it registers Add registered and switched on an entry before the selection was cut to its first 64, so a 65th was dropped from the selection but stayed on: Browse showed it as not added, share links left it out and Remove all missed it. Start-up registered a share's entries the same way past the cap. Add now refuses an entry a full library cannot keep, and start-up registers only what the selection keeps. --- src/library/controller.js | 17 +++++++--- src/library/controller.test.mjs | 58 +++++++++++++++++++++++++++++++++ src/library/selection.js | 3 +- 3 files changed, 72 insertions(+), 6 deletions(-) diff --git a/src/library/controller.js b/src/library/controller.js index 9e2807e4a..a2e6ecf9f 100644 --- a/src/library/controller.js +++ b/src/library/controller.js @@ -9,6 +9,7 @@ import { LIBRARY_ENTRIES, LIBRARY_LEDGER } from './catalog.js'; import { libraryEntryIdOf, libraryLayerId } from './manifest.js'; import { + MAX_SELECTED, decodeLibraryParam, encodeLibraryParam, normalizeLibrarySelection, @@ -270,14 +271,17 @@ export function createDataLibrary({ // link, it alone decides what is on, as it does for core layers, so a // share with every library entry off switches the stored ones off. // Added entries register even while off, so their rows show. - for (const id of [...stored.added, ...(linked || [])]) { + // Only what the selection keeps registers: past its cap an entry would + // be a row the library no longer knows. + const next = normalizeLibrarySelection( + { added: [...stored.added, ...(linked || [])], on: linked ?? stored.on }, + switchable, + ); + for (const id of next.added) { const entry = byId.get(id); if (entry && addable(id)) register(entry); } - applySelection( - { added: [...stored.added, ...(linked || [])], on: linked ?? stored.on }, - linked ? 'share-restore' : 'local-restore', - ); + applySelection(next, linked ? 'share-restore' : 'local-restore'); } /** @@ -287,6 +291,9 @@ export function createDataLibrary({ */ async function add(id) { if (destroyed || !addable(id) || pending.has(id)) return false; + // A full library refuses the entry before its layer exists. + if (!selection.added.includes(id) && selection.added.length >= MAX_SELECTED) + return false; const entry = byId.get(id); const work = (async () => { if (!register(entry)) return false; diff --git a/src/library/controller.test.mjs b/src/library/controller.test.mjs index 4b75e7b91..4ca490dd6 100644 --- a/src/library/controller.test.mjs +++ b/src/library/controller.test.mjs @@ -345,6 +345,64 @@ test('a share opened while an earlier one is still switching on wins', async (t) library.destroy(); }); +test('a full library refuses another entry before its layer exists', async (t) => { + const many = validateLibraryCatalog( + Array.from({ length: 66 }, (_, index) => tiles(`entry-${index}`)), + ).entries; + const manyLedger = many.map(({ id }, index) => [ + id, + index.toString(36).padStart(2, '0'), + ]); + const availability = { + async getAvailability() { + return { + server: true, + availability: new Map( + many.map(({ id }) => [id, { available: true, reason: null }]), + ), + }; + }, + tileTemplate: (entry) => entry.source.url, + }; + const full = JSON.stringify({ + added: many.slice(0, 64).map(({ id }) => id), + on: [], + }); + const manager = sealedManager(t); + const library = createDataLibrary({ + entries: many, + ledger: manyLedger, + dataManager: manager, + services: {}, + storage: memoryStorage(full), + shareLink: fakeShareLink(null), + source: availability, + }); + await library.start(); + assert.equal(manager.layers.size, 64); + assert.equal(await library.add('entry-64'), false); + assert.equal(manager.layers.size, 64, 'no layer outside the selection'); + // A share into a full library adds no row the library cannot keep. + const shared = sealedManager(t); + const opened = createDataLibrary({ + entries: many, + ledger: manyLedger, + dataManager: shared, + services: {}, + storage: memoryStorage(full), + shareLink: fakeShareLink([manyLedger[65][1]]), + source: availability, + }); + await opened.start(); + assert.equal(shared.layers.has('lib-entry-65'), false); + assert.equal( + opened.list().filter((row) => row.added).length, + shared.layers.size, + ); + library.destroy(); + opened.destroy(); +}); + test('zoom flies to an added layer through the caller, reset removes everything', async (t) => { const manager = sealedManager(t); const storage = memoryStorage(); diff --git a/src/library/selection.js b/src/library/selection.js index 513cedf4b..ba565b8c3 100644 --- a/src/library/selection.js +++ b/src/library/selection.js @@ -12,7 +12,8 @@ import { LIBRARY_ID_PATTERN } from './manifest.js'; export const LIBRARY_STORAGE_KEY = 'gev:library:v1'; export const LIBRARY_SHARE_PARAM = 'lib'; export const LIBRARY_TOKEN_PATTERN = /^[0-9a-z]{2}$/; -const MAX_SELECTED = 64; +/** The most entries one library (and one share link) holds. */ +export const MAX_SELECTED = 64; const MAX_PARAM_CHARS = 3 * MAX_SELECTED; /** From 53f0a32a1b646ecc692d8be08121e6f300aa6103 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 12 Nov 2026 15:57:02 -0800 Subject: [PATCH 33/55] Keep a Data Library entry whose layer fails to tear down Remove dropped an entry from the selection and reported success even when the manager could not disable or destroy its layer, leaving a registered, possibly visible layer that Browse, share links and saved state no longer knew. Remove now keeps the entry and reports failure, so its row and Remove all still reach it and a later Remove can retry. --- src/library/controller.js | 9 ++++++-- src/library/controller.test.mjs | 41 +++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/src/library/controller.js b/src/library/controller.js index a2e6ecf9f..22cfb68f9 100644 --- a/src/library/controller.js +++ b/src/library/controller.js @@ -325,8 +325,13 @@ export function createDataLibrary({ async function remove(id) { if (destroyed || pending.has(id)) return false; const work = (async () => { - if (isRegistered(id)) - await dataManager.unregisterLibraryLayer(libraryLayerId(id)); + // A layer that would not tear down is still there: keep it in the + // library so its row, Remove and Remove all still reach it. + if ( + isRegistered(id) && + !(await dataManager.unregisterLibraryLayer(libraryLayerId(id))) + ) + return false; selection = { added: selection.added.filter((other) => other !== id), on: selection.on.filter((other) => other !== id), diff --git a/src/library/controller.test.mjs b/src/library/controller.test.mjs index 4ca490dd6..fa4305a5d 100644 --- a/src/library/controller.test.mjs +++ b/src/library/controller.test.mjs @@ -403,6 +403,47 @@ test('a full library refuses another entry before its layer exists', async (t) = opened.destroy(); }); +test('a layer that fails to tear down stays in the library', async (t) => { + const manager = sealedManager(t); + const storage = memoryStorage(); + const library = createDataLibrary({ + entries, + ledger, + dataManager: manager, + services: {}, + storage, + shareLink: fakeShareLink(null), + source: source({}), + }); + await library.start(); + await library.add('night-lights'); + const { module } = manager.layers.get('lib-night-lights'); + const disable = module.disable; + module.disable = async () => { + throw new Error('stuck'); + }; + t.mock.method(console, 'warn', () => {}); + try { + assert.equal(await library.remove('night-lights'), false); + assert.equal(await library.reset(), 0); + assert.equal(manager.layers.has('lib-night-lights'), true); + assert.deepEqual( + JSON.parse(storage.store.get(LIBRARY_STORAGE_KEY)).added, + ['night-lights'], + ); + assert.equal( + library.list().find((row) => row.entry.id === 'night-lights').added, + true, + ); + } finally { + // Once it tears down, Remove works (and the manager can shut down). + module.disable = disable; + } + assert.equal(await library.remove('night-lights'), true); + assert.equal(manager.layers.has('lib-night-lights'), false); + library.destroy(); +}); + test('zoom flies to an added layer through the caller, reset removes everything', async (t) => { const manager = sealedManager(t); const storage = memoryStorage(); From 2d5db9f63dd6ac17b3f754fdc176af229f63b516 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 12 Nov 2026 15:58:22 -0800 Subject: [PATCH 34/55] Never let a share rewrite the Data Library entries remembered as on Every restore wrote the selection it applied to browser storage, so opening a link that switched no library entry on stored every saved entry as off, and the next bare visit restored them off. Core layer state never writes a recipient's preferences from a share. The library now keeps what is remembered as on apart from what a share switches on for the session: a share's entries still join the library, but only a local restore and the viewer's own choices change what is remembered. --- src/library/controller.js | 29 +++++++++++++++++++------ src/library/controller.test.mjs | 38 ++++++++++++++++++++++++++++++++- 2 files changed, 60 insertions(+), 7 deletions(-) diff --git a/src/library/controller.js b/src/library/controller.js index 22cfb68f9..fffafb1f2 100644 --- a/src/library/controller.js +++ b/src/library/controller.js @@ -69,6 +69,10 @@ export function createDataLibrary({ let availability = new Map(); let serverPresent = false; let selection = { added: [], on: [] }; + // What this browser remembers as on. Like core layer state, a share + // decides what is on for the session but never rewrites it: only the + // viewer's explicit choices and a local restore do. + let savedOn = []; let started = false; // Set once availability is known; a share navigated to before then waits. let loaded = false; @@ -89,7 +93,11 @@ export function createDataLibrary({ const persist = () => { selection = normalizeLibrarySelection(selection, switchable); - writeStoredLibrarySelection(storage, selection); + savedOn = savedOn.filter((id) => selection.added.includes(id)); + writeStoredLibrarySelection(storage, { + added: selection.added, + on: savedOn, + }); shareLink?.onLibraryStateChange?.(); notify(); }; @@ -163,9 +171,16 @@ export function createDataLibrary({ return; } const on = new Set(selection.on); - if (change.enabled) on.add(id); - else on.delete(id); + const saved = new Set(savedOn); + if (change.enabled) { + on.add(id); + saved.add(id); + } else { + on.delete(id); + saved.delete(id); + } selection = { ...selection, on: [...on] }; + savedOn = [...saved]; persist(); }); @@ -217,9 +232,10 @@ export function createDataLibrary({ console.warn(`[Library] ${id} restore failed:`, error), ); } - writeStoredLibrarySelection(storage, selection); - shareLink?.onLibraryStateChange?.(); - notify(); + // A share's entries join the library; only a local restore sets what + // is remembered as on. + if (origin !== 'share-restore') savedOn = selection.on; + persist(); } /** @@ -262,6 +278,7 @@ export function createDataLibrary({ availability = answer.availability; serverPresent = answer.server; const stored = readStoredLibrarySelection(storage, switchable); + savedOn = stored.on; // A share opened in this tab while availability loaded wins over the // opening one. const linked = queuedShare ? idsForTokens(queuedShare) : openingShare(); diff --git a/src/library/controller.test.mjs b/src/library/controller.test.mjs index fa4305a5d..9aac21764 100644 --- a/src/library/controller.test.mjs +++ b/src/library/controller.test.mjs @@ -201,12 +201,48 @@ test('a link decides what is on and adds its entries to the library', async (t) await manager.waitForLayerSettled?.('lib-sea-marks'); assert.equal(manager.isEnabled('lib-sea-marks'), true); assert.equal(manager.isEnabled('lib-night-lights'), false); + // The link's entry joins the library; what is remembered as on stays + // the viewer's own. assert.deepEqual(JSON.parse(storage.store.get(LIBRARY_STORAGE_KEY)), { added: ['night-lights', 'sea-marks'], - on: ['sea-marks'], + on: ['night-lights'], }); }); +test('a share never rewrites what this browser remembers as on', async (t) => { + const saved = JSON.stringify({ + added: ['night-lights', 'sea-marks'], + on: ['night-lights'], + }); + const storage = memoryStorage(saved); + const manager = sealedManager(t); + const shareLink = fakeShareLink([]); + const library = createDataLibrary({ + entries, + ledger, + dataManager: manager, + services: {}, + storage, + shareLink, + source: source({}), + }); + await library.start(); + assert.equal(manager.isEnabled('lib-night-lights'), false); + shareLink.listener(['01']); + await manager.waitForLayerSettled?.('lib-sea-marks'); + assert.equal(manager.isEnabled('lib-sea-marks'), true); + assert.deepEqual(JSON.parse(storage.store.get(LIBRARY_STORAGE_KEY)), { + added: ['night-lights', 'sea-marks'], + on: ['night-lights'], + }); + // An explicit choice is remembered, and only that one. + await manager.setEnabled('lib-sea-marks', false, { origin: 'user' }); + await manager.setEnabled('lib-night-lights', true, { origin: 'user' }); + await manager.setEnabled('lib-night-lights', false, { origin: 'user' }); + assert.deepEqual(JSON.parse(storage.store.get(LIBRARY_STORAGE_KEY)).on, []); + library.destroy(); +}); + /** A ShareLinkManager stand-in: the opening share and in-tab navigation. */ function fakeShareLink(initialLibraryTokens) { const link = { From 8d90cac7dc5e0501254fc0fa31798c663ce222d4 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 12 Nov 2026 15:59:14 -0800 Subject: [PATCH 35/55] Keep lib= in the URL before the library starts and for entries it cannot serve The share encoder wrote only registered entries. Until availability loaded nothing was registered, so any hash write in the meantime (a camera move during start-up) deleted lib= from the address bar, and an entry this instance could not serve (its proxy unreachable) was dropped for good: copying the link or reloading lost those entries. The URL now carries what the page opened with until the library starts, and every entry that is on afterwards, served here or not. --- src/library/controller.js | 8 +++++++- src/library/controller.test.mjs | 34 +++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/library/controller.js b/src/library/controller.js index fffafb1f2..6be6d29ca 100644 --- a/src/library/controller.js +++ b/src/library/controller.js @@ -257,10 +257,16 @@ export function createDataLibrary({ } shareLink?.setLibraryShareListener?.(applyShare); + // The URL carries what is on. Until availability loads (or when it never + // does) that is still what the page opened with, so a hash write in the + // meantime keeps it; an entry this instance cannot serve stays in it too. shareLink?.setLibraryParamsEncoder?.((params) => encodeLibraryParam( params, - selection.on.filter((id) => isRegistered(id)), + loaded + ? selection.on + : ((queuedShare ? idsForTokens(queuedShare) : openingShare()) ?? + readStoredLibrarySelection(storage, switchable).on), tokens.tokenById, ), ); diff --git a/src/library/controller.test.mjs b/src/library/controller.test.mjs index 9aac21764..51daf7f2a 100644 --- a/src/library/controller.test.mjs +++ b/src/library/controller.test.mjs @@ -480,6 +480,40 @@ test('a layer that fails to tear down stays in the library', async (t) => { library.destroy(); }); +test('the URL keeps the opening lib= before availability loads and for entries it cannot serve', async (t) => { + const manager = sealedManager(t); + const shareLink = shareLinkDouble(); + let answer; + const pending = new Promise((resolve) => { + answer = resolve; + }); + const library = createDataLibrary({ + entries, + ledger, + dataManager: manager, + services: {}, + storage: memoryStorage(), + shareLink, + initialHash: '#lat=1&lon=2&lib=00.01', + source: { ...source({}), getAvailability: () => pending }, + }); + const started = library.start(); + // A camera move rewrites the hash before the catalog answers. + assert.equal(shareLink.encode(), '00.01'); + answer({ + server: false, + availability: new Map([ + ['night-lights', { available: false, reason: 'server-unavailable' }], + ['sea-marks', { available: true, reason: null }], + ]), + }); + await started; + await manager.waitForLayerSettled?.('lib-sea-marks'); + assert.equal(manager.layers.has('lib-night-lights'), false); + assert.equal(shareLink.encode(), '00.01'); + library.destroy(); +}); + test('zoom flies to an added layer through the caller, reset removes everything', async (t) => { const manager = sealedManager(t); const storage = memoryStorage(); From 9e57fd84c838f47259d02523b661c71f17e714a8 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 12 Nov 2026 16:02:07 -0800 Subject: [PATCH 36/55] Draw Data Library coverage outlines from the bundled Natural Earth countries Imagery outlines fetched Natural Earth's 1:50m countries (about 3 MB) from the natural-earth-vector repository's master branch on GitHub at run time: a moving, rate-limited source whose failures silently removed the outlines. Core already ships the pinned 1:10m countries pack, so the outlines now read that, once, the first time an outline needs it. --- src/data/local_data/natural_earth/README.md | 5 +- src/library/outlines.js | 53 ++++++++++++--------- src/library/outlines.test.mjs | 22 +++++++++ 3 files changed, 55 insertions(+), 25 deletions(-) diff --git a/src/data/local_data/natural_earth/README.md b/src/data/local_data/natural_earth/README.md index a36b6d548..027aae226 100644 --- a/src/data/local_data/natural_earth/README.md +++ b/src/data/local_data/natural_earth/README.md @@ -3,9 +3,10 @@ Offline polygons with no network dependency. The voice-annotation resolver (`src/data/naturalEarthRegions.js`) reads the named regions, so "outline the Alps" resolves to the real range geometry; area annotations read the -countries, states and provinces (`src/data/adminBoundaries.js`); and the News +countries, states and provinces (`src/data/adminBoundaries.js`); the News layer reads the countries and the disputed areas -(`server/providers/news/areas.js`). +(`server/providers/news/areas.js`); and Data Library imagery draws its +coverage outlines from the countries (`src/library/outlines.js`). | File | Source dataset | Features | |------|----------------|----------| diff --git a/src/library/outlines.js b/src/library/outlines.js index a51a6e4ed..9ba477bc8 100644 --- a/src/library/outlines.js +++ b/src/library/outlines.js @@ -2,40 +2,47 @@ * Coverage outlines for Data Library imagery: the borders of the countries an * entry covers, so the edge of an aerial photo layer is plain on the map. * - * Borders come from Natural Earth's 1:50m admin-0 countries (public domain), - * a provider-hosted file fetched live and kept for the session, so nothing is - * bundled. Pure apart from `loadCountryOutlines`, which takes `fetchImpl`. + * Borders come from the bundled Natural Earth 1:10m admin-0 countries pack + * (public domain, pinned; see src/data/local_data/natural_earth/README.md), + * read once the first outline needs it. Pure apart from + * `loadCountryOutlines`. */ -import { readResponseTextCapped } from '../sources/httpBody.js'; +import { loadBundledJson } from '../data/bundledJson.js'; +import { decodeRing } from '../data/adminBoundaries.js'; -export const COUNTRY_OUTLINES_URL = - 'https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_50m_admin_0_countries.geojson'; -/** The 1:50m file is about 3 MB. */ -const OUTLINES_MAX_BYTES = 8 * 1024 * 1024; +const COUNTRIES_PACK_URL = new URL( + '../data/local_data/natural_earth/countries.json', + import.meta.url, +); /** Outline modes a manifest may ask for. */ export const OUTLINE_MODES = Object.freeze(['coverage', 'countries', 'bbox']); let pending = null; /** - * Natural Earth country features, fetched once per page. - * @param {typeof fetch} fetchImpl + * Country features with closed outer rings, in the GeoJSON shape + * `outlineRings` reads, loaded once per page. + * @param {(url: URL) => Promise} [loadPack] * @returns {Promise} */ -export function loadCountryOutlines( - fetchImpl = (...args) => globalThis.fetch(...args), -) { +export function loadCountryOutlines(loadPack = loadBundledJson) { pending ||= (async () => { - const response = await fetchImpl(COUNTRY_OUTLINES_URL, { - credentials: 'omit', - referrerPolicy: 'no-referrer', - redirect: 'error', - }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - const text = await readResponseTextCapped(response, OUTLINES_MAX_BYTES); - const features = JSON.parse(text)?.features; - if (!Array.isArray(features)) throw new Error('not a FeatureCollection'); - return features; + const pack = await loadPack(COUNTRIES_PACK_URL); + if (!Array.isArray(pack?.features)) throw new Error('not a country pack'); + const decimals = pack.meta?.decimals; + return pack.features.map((feature) => ({ + type: 'Feature', + properties: { ISO_A2: feature.iso2 }, + geometry: { + type: 'MultiPolygon', + coordinates: (feature.polygons || []).map((polygon) => + polygon.map((encoded) => { + const ring = decodeRing(encoded, feature.d ?? decimals); + return ring.length ? [...ring, ring[0]] : ring; + }), + ), + }, + })); })().catch((error) => { pending = null; throw error; diff --git a/src/library/outlines.test.mjs b/src/library/outlines.test.mjs index ce492a850..500b20686 100644 --- a/src/library/outlines.test.mjs +++ b/src/library/outlines.test.mjs @@ -68,3 +68,25 @@ test('a bbox outline is the coverage rectangle, with no border file needed', () ], ]); }); + +test('outlines come from the bundled Natural Earth countries, closed and in coverage', async () => { + const { loadCountryOutlines, resetCountryOutlines } = await import( + './outlines.js' + ); + resetCountryOutlines(); + const countries = await loadCountryOutlines(); + for (const id of ['ign-orthophotos-france', 'ee-maa-amet-ortofoto']) { + const entry = libraryEntry(id); + const rings = outlineRings(countries, entry); + assert.ok(rings.length > 0, id); + for (const ring of rings) assert.deepEqual(ring.at(-1), ring[0]); + } + // A pack that cannot load is retried on the next call. + resetCountryOutlines(); + await assert.rejects( + loadCountryOutlines(async () => { + throw new Error('unreadable'); + }), + ); + assert.ok((await loadCountryOutlines()).length > 200); +}); From 1a657e2a64e9186f3458605a4d4b101c6fdb3d01 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 12 Nov 2026 16:04:55 -0800 Subject: [PATCH 37/55] Keep the library layer namespace in the layer lifecycle package The core layer lifecycle imported the whole Data Library manifest contract only to check the lib- prefix, so the layer-lifecycle package owned a feature module. The namespace rule (prefix, entry id pattern, the two id helpers) now lives in src/data/libraryLayerIds.js, which the lifecycle owns; the manifest contract imports and re-exports it, so library callers are unchanged. --- scripts/package-boundaries.json | 3 ++- src/data/libraryLayerIds.js | 22 ++++++++++++++++++++++ src/data/lifecycle.js | 2 +- src/library/manifest.js | 27 +++++++++++++-------------- 4 files changed, 38 insertions(+), 16 deletions(-) create mode 100644 src/data/libraryLayerIds.js diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index f9244b9b2..873f37f80 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -3500,6 +3500,7 @@ "src/voice/geminiCaptureWorklet.js", "src/ui/libraryBrowser.js", "src/library/manifest.js", + "src/data/libraryLayerIds.js", "src/library/selection.js", "src/library/ledger.json", "src/library/entries/ak-statewide-imagery.json", @@ -4571,7 +4572,7 @@ "modules": [ "src/data/lifecycle.js", "src/data/cameraArrival.js", - "src/library/manifest.js" + "src/data/libraryLayerIds.js" ], "external": [] }, diff --git a/src/data/libraryLayerIds.js b/src/data/libraryLayerIds.js new file mode 100644 index 000000000..56bb1fddd --- /dev/null +++ b/src/data/libraryLayerIds.js @@ -0,0 +1,22 @@ +/** + * The Data Library's layer namespace, owned by the layer lifecycle: the + * manager accepts a late registration only under `lib-`, and the + * library names its layers with the same rule. + */ + +/** A library entry id: lowercase, digits and hyphens, 3–48 characters. */ +export const LIBRARY_ID_PATTERN = /^[a-z0-9][a-z0-9-]{2,47}$/; +export const LIBRARY_LAYER_PREFIX = 'lib-'; + +/** The manager id a library entry's layer registers under. */ +export function libraryLayerId(entryId) { + return `${LIBRARY_LAYER_PREFIX}${entryId}`; +} + +/** The entry id behind a library layer id, or null. */ +export function libraryEntryIdOf(layerId) { + if (typeof layerId !== 'string' || !layerId.startsWith(LIBRARY_LAYER_PREFIX)) + return null; + const id = layerId.slice(LIBRARY_LAYER_PREFIX.length); + return LIBRARY_ID_PATTERN.test(id) ? id : null; +} diff --git a/src/data/lifecycle.js b/src/data/lifecycle.js index c67c4287f..97d784261 100644 --- a/src/data/lifecycle.js +++ b/src/data/lifecycle.js @@ -1,5 +1,5 @@ import { cancelCameraArrival } from './cameraArrival.js'; -import { libraryEntryIdOf } from '../library/manifest.js'; +import { libraryEntryIdOf } from './libraryLayerIds.js'; function cloneLayerParams(value) { if (Array.isArray(value)) return value.map(cloneLayerParams); diff --git a/src/library/manifest.js b/src/library/manifest.js index 5d76af235..8b4595c85 100644 --- a/src/library/manifest.js +++ b/src/library/manifest.js @@ -10,6 +10,12 @@ * this module, so it loads no catalogs and touches no DOM. See * docs/DATA-LIBRARY.md for the authoring guide. */ +import { + LIBRARY_ID_PATTERN, + LIBRARY_LAYER_PREFIX, + libraryEntryIdOf, + libraryLayerId, +} from '../data/libraryLayerIds.js'; /** The three entry kinds this build renders. */ export const LIBRARY_KINDS = Object.freeze(['dataset', 'tiles', 'pack']); @@ -88,8 +94,6 @@ export const LIBRARY_FAMILIES = Object.freeze([ /** Host layers a `pack` entry may extend, with the env switch grammar. */ export const PACK_HOSTS = Object.freeze(['cctv']); -export const LIBRARY_ID_PATTERN = /^[a-z0-9][a-z0-9-]{2,47}$/; -export const LIBRARY_LAYER_PREFIX = 'lib-'; export const DEFAULT_CACHE_SECONDS = 3600; export const MIN_CACHE_SECONDS = 60; export const MAX_CACHE_SECONDS = 86_400; @@ -742,18 +746,13 @@ export function validateLibraryCatalog(manifests) { return { ok: errors.length === 0, errors, entries }; } -/** The manager id a library entry's layer registers under. */ -export function libraryLayerId(entryId) { - return `${LIBRARY_LAYER_PREFIX}${entryId}`; -} - -/** The entry id behind a library layer id, or null. */ -export function libraryEntryIdOf(layerId) { - if (typeof layerId !== 'string' || !layerId.startsWith(LIBRARY_LAYER_PREFIX)) - return null; - const id = layerId.slice(LIBRARY_LAYER_PREFIX.length); - return LIBRARY_ID_PATTERN.test(id) ? id : null; -} +// The layer namespace belongs to the layer lifecycle; the library reads it. +export { + LIBRARY_ID_PATTERN, + LIBRARY_LAYER_PREFIX, + libraryEntryIdOf, + libraryLayerId, +}; /** * Whether the operator switched an opt-in entry on. `GEV_LIBRARY_ENABLE` From 2c4d865ef90c81593c02f844b0fda278fcd9c604 Mon Sep 17 00:00:00 2001 From: Said Turksever <28983926+asturksever@users.noreply.github.com> Date: Fri, 13 Nov 2026 01:12:58 +0000 Subject: [PATCH 38/55] fix(shortcuts): a shortcut key held with Cmd, Ctrl or Alt runs no shortcut The application shortcut listener read the key alone, so copying with Cmd+C or Ctrl+C toggled the CCTV layer and opened the nearest camera, Cmd+F opened the layers panel and Cmd+1 to 6 changed the visual style. Chords are now left to the browser. Shift is not a chord, a held key still repeats, and Escape dismisses search either way. --- docs/CURRENT-STATE.md | 7 +++++-- src/ui/applicationShortcuts.js | 10 +++++++++- src/ui/visualInput.test.mjs | 19 ++++++++++++++++--- 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index bdfefe043..0e4b03194 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2325,8 +2325,11 @@ Browser acceptance: `QA_BASE_URL=http://127.0.0.1:4173 node scripts/qa-segmented `ui/input` supplies the bubbling application shortcut listener and generated shader-parameter rows. Number/style keys, H/O/V/F/D/C actions, native form-control -typing and Escape behavior retain their existing mappings. Capture-phase -surfaces continue to arbitrate their own keyboard events first. +typing and Escape behavior retain their existing mappings. A shortcut key held +with Cmd, Ctrl or Alt runs no shortcut, so the browser's own chord (Cmd+C to +copy, Cmd+F to find, Cmd+1 to switch tab) does not also toggle CCTV, the layers +panel or a style; Shift does not count, and Escape dismisses search either way. +Capture-phase surfaces continue to arbitrate their own keyboard events first. The UI facade retains shader values, share-restore authority, render requests, search dismissal, visibility and Cockpit portal policy. Parameter rows preserve diff --git a/src/ui/applicationShortcuts.js b/src/ui/applicationShortcuts.js index 57214a570..195a557f7 100644 --- a/src/ui/applicationShortcuts.js +++ b/src/ui/applicationShortcuts.js @@ -10,7 +10,8 @@ const STYLE_KEYS = Object.freeze({ /** * Bind the application's existing bubbling keyboard shortcuts. * Capture-phase surfaces keep first refusal. The caller owns each action and - * persistence; form controls keep native typing except for Escape. + * persistence; form controls keep native typing except for Escape, and a key + * held with Cmd, Ctrl or Alt is left to the browser. * @param {object} options * @param {Document} options.documentRef Keyboard event target. * @param {HTMLElement} options.searchInput Additional editing target. @@ -27,6 +28,13 @@ export function bindApplicationShortcuts({ event.target?.matches?.('select, input, textarea') || event.target === searchInput; if (isFormControl && event.key !== 'Escape') return; + // Cmd+C, Ctrl+F, Cmd+1 and the like belong to the browser and the system. + // Shift is not a chord here: Shift+C is still C. + if ( + (event.metaKey || event.ctrlKey || event.altKey) && + event.key !== 'Escape' + ) + return; if (STYLE_KEYS[event.key]) actions.setStyle(STYLE_KEYS[event.key]); if (event.key === 'Escape') actions.dismissSearch(); diff --git a/src/ui/visualInput.test.mjs b/src/ui/visualInput.test.mjs index d49a19bda..541e8775f 100644 --- a/src/ui/visualInput.test.mjs +++ b/src/ui/visualInput.test.mjs @@ -117,10 +117,23 @@ test('the supplied search target retains editing even without form markup', () = assert.deepEqual(f.calls, [['dismissSearch']]); }); -test('shortcut extraction does not change repeat or modifier policy', () => { +test('a held key still repeats its shortcut, and Shift is not a chord', () => { const f = shortcuts(); - f.press('h', new Element(), { repeat: true, ctrlKey: true }); - assert.deepEqual(f.calls, [['toggleHud']]); + f.press('h', new Element(), { repeat: true }); + f.press('C', new Element(), { shiftKey: true }); + assert.deepEqual(f.calls, [['toggleHud'], ['toggleCctv']]); +}); + +test('browser and system chords on a shortcut key run no shortcut', () => { + const f = shortcuts(); + for (const modifier of ['metaKey', 'ctrlKey', 'altKey']) + for (const key of ['1', '6', 'h', 'o', 'v', 'f', 'd', 'c', 'C']) + f.press(key, new Element(), { [modifier]: true }); + // Copying (Cmd+C, Ctrl+C) used to turn CCTV on and open the nearest camera. + assert.deepEqual(f.calls, []); + f.press('c'); + f.press('Escape', new Element(), { ctrlKey: true }); + assert.deepEqual(f.calls, [['toggleCctv'], ['dismissSearch']]); }); test('destroy synchronously removes shortcuts and a replacement binds once', () => { From 8ac0d95f1bc5b4a8ec418ac3c8454a1af06637b1 Mon Sep 17 00:00:00 2001 From: Said Turksever <28983926+asturksever@users.noreply.github.com> Date: Fri, 13 Nov 2026 01:13:42 +0000 Subject: [PATCH 39/55] docs(changelog): shortcut keys ignore Cmd, Ctrl and Alt chords (#1691) --- changelog.d/1691-shortcuts-ignore-modifier-chords.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelog.d/1691-shortcuts-ignore-modifier-chords.md diff --git a/changelog.d/1691-shortcuts-ignore-modifier-chords.md b/changelog.d/1691-shortcuts-ignore-modifier-chords.md new file mode 100644 index 000000000..4a850507e --- /dev/null +++ b/changelog.d/1691-shortcuts-ignore-modifier-chords.md @@ -0,0 +1,4 @@ +- Leave browser and system chords alone: a shortcut key held with Cmd, Ctrl or + Alt no longer runs its single-key shortcut. Copying with Cmd+C or Ctrl+C used + to turn the CCTV layer on and open the nearest camera, Cmd+F opened the layers + panel and Cmd+1 to 6 changed the visual style (asturksever, #1691). From 4fe8796e89a23f1055604a802e16e436d85f20df Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 12 Nov 2026 17:47:29 -0800 Subject: [PATCH 40/55] Swap refreshed Data Library features in place, on every tick The live refresh had three faults. Its data age counted from when a fetch finished, so with the manager ticking every cacheSeconds the next tick found the data just short of due and data refreshed every other tick. It switched the layer off and on to redraw, which removed the old features before the new ones were built, closed an open feature card and, if the rebuild failed, left the layer on but empty. And the tick the manager runs right after enable refetched after a failed first load, so a second failure switched the layer off instead of showing UNAVAILABLE. The local GeoJSON layer gains replaceFeatures(): it builds the new set while the old one stays drawn, removes the old source only once the new one is in the scene, keeps a selected feature that is still in the new set (same id, or same place and label), and on a failed rebuild puts the old features, records and selection back and rejects. The library's refresh uses it, counts age from the request, allows a little slack on the tick, and does nothing until a first load has succeeded. --- src/data/localGeojsonCore.js | 118 +++++++++++++++++++++++++++++- src/library/datasetLayer.test.mjs | 118 +++++++++++++++++++++++++----- src/library/layers.js | 52 +++++++------ 3 files changed, 246 insertions(+), 42 deletions(-) diff --git a/src/data/localGeojsonCore.js b/src/data/localGeojsonCore.js index ddc52a9a3..7fb70cf67 100644 --- a/src/data/localGeojsonCore.js +++ b/src/data/localGeojsonCore.js @@ -365,6 +365,12 @@ export function createLocalGeoJsonLayer( * @type {Array|null} */ let _cachedFeatures = null; + /** + * The source a `replaceFeatures()` rebuild is replacing: it stays drawn + * until the new one is in the scene, and comes back if the rebuild fails. + * @type {{source: object, stemRecords: object[], count: number, lastUpdate: number|null, error: string|null}|null} + */ + let _outgoing = null; /** * Globe-LOD active set: the record ids allowed to carry a live stem right * now. This bounds geometry refreshes and ground-sample work to the @@ -449,7 +455,19 @@ export function createLocalGeoJsonLayer( * survive a disable is the per-frame visualizer walk and the entity memory. * @param {object} viewer */ + const releaseOutgoing = (viewer) => { + if (!_outgoing) return; + const { source } = _outgoing; + _outgoing = null; + try { + viewer?.dataSources?.remove(source, true); + } catch { + /* already gone */ + } + }; + const releaseDataSource = (viewer) => { + releaseOutgoing(viewer); if (!_dataSource) return; const source = _dataSource; _dataSource = null; @@ -489,7 +507,7 @@ export function createLocalGeoJsonLayer( } }; - return { + const layer = { id, name, icon, @@ -726,7 +744,7 @@ export function createLocalGeoJsonLayer( // Store references for bounded stem scaling and native picking. feature.__localBaseCarto = carto; feature.__localBaseCartesian = base; - registerEntityContext(feature, { + const context = registerEntityContext(feature, { id: `${id}:${recordId}`, layerId: id, layerName: name, @@ -769,6 +787,7 @@ export function createLocalGeoJsonLayer( _stemRecords.push({ id: recordId, entity: feature, + context, carto, base, tip, @@ -1111,6 +1130,99 @@ export function createLocalGeoJsonLayer( _cachedFeatures = null; }, + /** + * Draw a new feature set in place of the current one while the layer is + * on (a live loader's refresh). The current features stay drawn until + * the new ones are in the scene; a selected feature stays selected when + * the new set still has it (same id, or same place and label); a failed + * rebuild keeps the current features and rejects. Off, the set is only + * kept for the next enable(). + * @param {object} viewer + * @param {object[]} features GeoJSON features. + */ + replaceFeatures: async (viewer, features) => { + if (_destroyed) return; + const previousFeatures = _cachedFeatures; + _cachedFeatures = features; + if (!_enabled || !_dataSource || _loadPromise) return; + const selected = + viewer?.selectedEntity?.__localLayerId === id + ? viewer.selectedEntity + : null; + const selectedLabel = selected + ? _stemRecords.find((record) => record.entity === selected)?.context + ?.label + : undefined; + _outgoing = { + source: _dataSource, + stemRecords: _stemRecords, + count: _count, + lastUpdate: _lastUpdate, + error: _error, + }; + const outgoing = _outgoing; + _dataSource = null; + await coreEnable(viewer); + // Disabled or destroyed meanwhile: release took both sources. + if (_destroyed || !_enabled || _outgoing !== outgoing) return; + _outgoing = null; + if (!_dataSource) { + // The rebuild failed: the current features come back as they were. + const failure = _error; + _cachedFeatures = previousFeatures; + _dataSource = outgoing.source; + _stemRecords = outgoing.stemRecords; + _count = outgoing.count; + _lastUpdate = outgoing.lastUpdate; + _error = outgoing.error; + _stemGeometryDirty = true; + _lastVisibilityUpdate = Number.NEGATIVE_INFINITY; + for (const record of _stemRecords) + if (record.context) + registerEntityContext(record.entity, record.context); + if (selected && viewer.selectedEntity === selected) + selectEntityContext(selected); + throw new Error(failure || 'dataset unavailable'); + } + try { + viewer?.dataSources?.remove(outgoing.source, true); + } catch { + /* already gone */ + } + // Records the new set no longer has go with the old source. + removeEntityContextsForLayer(id, { + retainIds: new Set( + _stemRecords.map((record) => record.context?.id).filter(Boolean), + ), + }); + if (selected) { + const same = (record) => { + const old = selected.__localBaseCarto; + const next = record.entity.__localBaseCarto; + return ( + old && + next && + Math.abs(old.longitude - next.longitude) < 1e-9 && + Math.abs(old.latitude - next.latitude) < 1e-9 && + record.context?.label === selectedLabel + ); + }; + const match = _stemRecords.find((record) => + selected.id !== undefined && record.entity.id === selected.id + ? true + : same(record), + ); + if (match) { + viewer.selectedEntity = match.entity; + selectEntityContext(match.entity); + } else if (viewer.selectedEntity === selected) { + viewer.selectedEntity = undefined; + clearSelectedEntityContextForLayer(id); + } + } + viewer?.scene?.requestRender?.(); + }, + destroy: (viewer) => { if (_destroyed) return; _destroyed = true; @@ -1135,6 +1247,8 @@ export function createLocalGeoJsonLayer( _error = null; }, }; + const coreEnable = layer.enable; + return layer; } function compareLocalOverlayRecords(a, b) { diff --git a/src/library/datasetLayer.test.mjs b/src/library/datasetLayer.test.mjs index 8d143c2d9..a8f2f1922 100644 --- a/src/library/datasetLayer.test.mjs +++ b/src/library/datasetLayer.test.mjs @@ -15,21 +15,27 @@ const entry = { licence: { name: 'CC0', url: 'https://example.org/licence' }, }; -const point = (name) => ({ - type: 'Feature', - properties: { name }, - geometry: { - type: 'Polygon', - coordinates: [ - [ - [0, 0], - [0.01, 0], - [0, 0.01], - [0, 0], +// One small triangle per name, each in its own place. +const point = (name) => { + const x = name.charCodeAt(0) / 100; + return { + type: 'Feature', + properties: { name }, + geometry: { + type: 'Polygon', + coordinates: [ + [ + [x, 0], + [x + 0.01, 0], + [x, 0.01], + [x, 0], + ], ], - ], - }, -}); + }, + }; +}; +// A clock that is never 0, which reads as "never loaded". +const START = 1_000_000; const deferred = () => { let resolve; @@ -91,7 +97,7 @@ const result = (...names) => ({ }); test('a library dataset refreshes on the manager tick once older than cacheSeconds', async (t) => { - t.mock.timers.enable({ apis: ['Date'], now: 0 }); + t.mock.timers.enable({ apis: ['Date'], now: START }); const env = harness(t, [result('a'), result('b', 'c')]); assert.equal(env.layer.refreshInterval, 60_000); await env.layer.enable(env.viewer); @@ -105,8 +111,84 @@ test('a library dataset refreshes on the manager tick once older than cacheSecon assert.equal(env.sources.size, 1, 'the old source is replaced, not kept'); }); +test('a slow upstream still refreshes on every tick, not every other one', async (t) => { + t.mock.timers.enable({ apis: ['Date'], now: START }); + // Each request takes two seconds to answer. + const slow = (value) => async () => { + t.mock.timers.tick(2000); + return value; + }; + const env = harness(t, [ + slow(result('a')), + slow(result('b')), + slow(result('c')), + ]); + await env.layer.enable(env.viewer); + // The manager ticks every refreshInterval from when the layer came on. + t.mock.timers.setTime(START + 60_000); + await env.layer.update(env.viewer); + t.mock.timers.setTime(START + 120_000); + await env.layer.update(env.viewer); + assert.equal(env.calls(), 3); +}); + +test('a failed first load is not retried by the tick that follows enable', async (t) => { + t.mock.timers.enable({ apis: ['Date'], now: START }); + t.mock.method(console, 'error', () => {}); + const env = harness(t, [ + () => Promise.reject(new Error('HTTP 503')), + result('a'), + ]); + await env.layer.enable(env.viewer); + assert.ok(env.layer.getStats().error); + // Resolving (not rejecting) keeps the manager from failing the switch-on. + await env.layer.update(env.viewer); + assert.equal(env.calls(), 1); +}); + +test('a refresh keeps the old features drawn until the new ones are in, and the selection', async (t) => { + t.mock.timers.enable({ apis: ['Date'], now: START }); + const env = harness(t, [result('a', 'b'), result('a', 'c')]); + await env.layer.enable(env.viewer); + const [first] = env.sources; + const selected = first.entities.values.find( + (entity) => entity.properties.name.getValue() === 'a', + ); + env.viewer.selectedEntity = selected; + const sizes = []; + const add = env.viewer.dataSources.add; + env.viewer.dataSources.add = async (source) => { + sizes.push(env.sources.size); + return add(source); + }; + t.mock.timers.tick(60_000); + await env.layer.update(env.viewer); + assert.deepEqual(sizes, [1], 'the old source was still drawn'); + assert.equal(env.sources.size, 1); + const [next] = env.sources; + assert.notEqual(next, first); + assert.ok(next.entities.values.includes(env.viewer.selectedEntity)); + assert.equal(env.viewer.selectedEntity.properties.name.getValue(), 'a'); +}); + +test('a refresh whose rebuild fails keeps the drawn features', async (t) => { + t.mock.timers.enable({ apis: ['Date'], now: START }); + t.mock.method(console, 'error', () => {}); + const env = harness(t, [result('a'), result('b', 'c')]); + await env.layer.enable(env.viewer); + const [first] = env.sources; + env.viewer.dataSources.add = async () => { + throw new Error('scene full'); + }; + t.mock.timers.tick(60_000); + await assert.rejects(env.layer.update(env.viewer)); + assert.deepEqual([...env.sources], [first]); + assert.equal(env.layer.getStats().count, 1); + assert.equal(env.layer.getStats().error, null); +}); + test('a stale library dataset reloads on the next enable', async (t) => { - t.mock.timers.enable({ apis: ['Date'], now: 0 }); + t.mock.timers.enable({ apis: ['Date'], now: START }); const env = harness(t, [result('a'), result('b', 'c')]); await env.layer.enable(env.viewer); env.layer.disable(env.viewer); @@ -120,7 +202,7 @@ test('a stale library dataset reloads on the next enable', async (t) => { }); test('a failed refresh keeps the drawn features', async (t) => { - t.mock.timers.enable({ apis: ['Date'], now: 0 }); + t.mock.timers.enable({ apis: ['Date'], now: START }); const env = harness(t, [ result('a'), () => Promise.reject(new Error('HTTP 503')), @@ -133,7 +215,7 @@ test('a failed refresh keeps the drawn features', async (t) => { }); test('disable during a refresh cancels it and draws nothing late', async (t) => { - t.mock.timers.enable({ apis: ['Date'], now: 0 }); + t.mock.timers.enable({ apis: ['Date'], now: START }); const pending = deferred(); let refreshSignal = null; const env = harness(t, [ diff --git a/src/library/layers.js b/src/library/layers.js index ffbb257f2..64ee55247 100644 --- a/src/library/layers.js +++ b/src/library/layers.js @@ -161,13 +161,25 @@ export function createLibraryDatasetLayer( let rowListener = null; let legendCounts = null; // Live data: features older than the source's cacheSeconds are loaded - // again, on the next enable and on a timer while the layer is on. + // again, on the next enable and on the manager's refresh tick while the + // layer is on. Their age counts from when they were requested, and a tick + // allows a little slack, so a tick every cacheSeconds refreshes on every + // tick rather than every other one. const maxAgeMs = entry.source.cacheSeconds * 1000; + const tickSlackMs = Math.min(5000, maxAgeMs / 10); + /** When the drawn data was requested; 0 until a load succeeds. */ let loadedAt = 0; - let prefetched = null; let refreshController = null; let on = false; - const stale = () => Date.now() - loadedAt >= maxAgeMs; + const age = () => Date.now() - loadedAt; + function take(result, requestedAt) { + loadedAt = requestedAt; + truncated = result.truncated; + loaded = result.features.length; + extent = featureExtent(result.features); + if (colorBy) legendCounts = countColorKeys(result.features); + rowListener?.(); + } const colorBy = entry.display.colorBy || null; const layer = createLocalGeoJsonLayer( { @@ -186,15 +198,9 @@ export function createLibraryDatasetLayer( colorBy.colors[String(properties?.__gevColorKey ?? '')] || null : null, loadFeatures: async ({ signal }) => { - const result = - prefetched ?? (await source.loadDataset(entry, { signal })); - prefetched = null; - loadedAt = Date.now(); - truncated = result.truncated; - loaded = result.features.length; - extent = featureExtent(result.features); - if (colorBy) legendCounts = countColorKeys(result.features); - rowListener?.(); + const requestedAt = Date.now(); + const result = await source.loadDataset(entry, { signal }); + take(result, requestedAt); return result.features; }, }, @@ -207,7 +213,6 @@ export function createLibraryDatasetLayer( const cancelRefresh = () => { refreshController?.abort(); refreshController = null; - prefetched = null; }; return Object.assign(layer, { libraryEntryId: entry.id, @@ -215,27 +220,30 @@ export function createLibraryDatasetLayer( async enable(viewer, ...rest) { on = true; registerDynamicCredit(viewer, libraryCredit(entry)); - if (stale()) layer.dropCachedFeatures(); + if (loadedAt && age() >= maxAgeMs) layer.dropCachedFeatures(); return enable(viewer, ...rest); }, /** - * The manager's refresh tick: once the data is stale, load it again and - * swap it in only when the new set is ready, so the old features stay - * drawn while the request runs and stay if it fails. + * The manager's refresh tick: once the data is due, load it again and + * swap it in where it is drawn, so the old features (and a selected + * one) stay while the request runs and stay if it fails. Nothing to + * refresh before a first load has succeeded: the tick that follows + * enable() must not retry a failed load and fail the switch-on. */ async update(viewer) { - if (!on || !stale() || refreshController) return; + if (!on || !loadedAt || refreshController) return; + if (age() < maxAgeMs - tickSlackMs) return; const current = new AbortController(); refreshController = current; + const requestedAt = Date.now(); try { const result = await source.loadDataset(entry, { signal: current.signal, }); if (!on || current.signal.aborted) return; - prefetched = result; - layer.dropCachedFeatures(); - disable(viewer); - await enable(viewer); + await layer.replaceFeatures(viewer, result.features); + if (!on || current.signal.aborted) return; + take(result, requestedAt); } finally { if (refreshController === current) refreshController = null; } From 837eff7328a5b30a050083c315541783fcfed695 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 12 Nov 2026 17:49:01 -0800 Subject: [PATCH 41/55] Let a share fit a full Data Library and show entries it cannot serve A share's entries joined the library after the stored ones, and the selection kept its first 64, so a link opened into a full library never showed what it shared and the next hash write dropped its lib=. Now the oldest entries the share leaves off make room for it, and their layers are removed with them. Browse counted an entry as added only while its layer was registered, but an entry this instance cannot serve (switched off by the operator, or its server unreachable) stayed in the selection and held one of the 64 places. A library full of them looked empty and refused every Add. Such entries now show as added, so they can be removed one by one or with Remove all. --- src/library/controller.js | 43 ++++++++++++++-- src/library/controller.test.mjs | 89 +++++++++++++++++++++++++++++---- 2 files changed, 118 insertions(+), 14 deletions(-) diff --git a/src/library/controller.js b/src/library/controller.js index 6be6d29ca..9fdecd041 100644 --- a/src/library/controller.js +++ b/src/library/controller.js @@ -184,6 +184,28 @@ export function createDataLibrary({ persist(); }); + /** + * The library after a share's entries join it. A share always fits: in a + * full library the oldest entries it leaves off make room for it, so a + * link opened into a full library still shows what it shares. + */ + function withShared(added, linked) { + const kept = [...new Set(added)].filter((id) => switchable.has(id)); + const incoming = [...new Set(linked)].filter( + (id) => switchable.has(id) && !kept.includes(id), + ); + let room = MAX_SELECTED - kept.length - incoming.length; + const result = []; + for (const id of kept) { + if (room < 0 && !linked.includes(id)) { + room += 1; + continue; + } + result.push(id); + } + return [...result, ...incoming]; + } + /** Entry ids for share tokens; unknown tokens are skipped. */ const idsForTokens = (list) => (list || []).map((token) => tokens.idByToken.get(token)).filter(Boolean); @@ -212,7 +234,17 @@ export function createDataLibrary({ * is never remembered as the viewer's own choice. */ function applySelection(next, origin) { + const before = selection.added; selection = normalizeLibrarySelection(next, switchable); + // Entries a share made room for leave with their layers. + for (const id of before) { + if (selection.added.includes(id) || !isRegistered(id)) continue; + dataManager + .unregisterLibraryLayer(libraryLayerId(id)) + .catch((error) => + console.warn(`[Library] ${id} could not be removed:`, error), + ); + } for (const id of selection.added) { const entry = byId.get(id); if (!entry || !addable(id)) continue; @@ -251,7 +283,7 @@ export function createDataLibrary({ } const linked = idsForTokens(shareTokens); applySelection( - { added: [...selection.added, ...linked], on: linked }, + { added: withShared(selection.added, linked), on: linked }, 'share-restore', ); } @@ -297,7 +329,10 @@ export function createDataLibrary({ // Only what the selection keeps registers: past its cap an entry would // be a row the library no longer knows. const next = normalizeLibrarySelection( - { added: [...stored.added, ...(linked || [])], on: linked ?? stored.on }, + { + added: linked ? withShared(stored.added, linked) : stored.added, + on: linked ?? stored.on, + }, switchable, ); for (const id of next.added) { @@ -423,7 +458,9 @@ export function createDataLibrary({ entry, available: state.available, reason: state.reason, - added: selection.added.includes(entry.id) && isRegistered(entry.id), + // An entry this instance cannot serve now stays added (and can be + // removed) without a layer: it still holds a place in the library. + added: selection.added.includes(entry.id), on: dataManager.isEnabled?.(libraryLayerId(entry.id)) === true, busy: pending.has(entry.id), canZoom: typeof zoomTo === 'function' && isRegistered(entry.id), diff --git a/src/library/controller.test.mjs b/src/library/controller.test.mjs index 51daf7f2a..46bed73ca 100644 --- a/src/library/controller.test.mjs +++ b/src/library/controller.test.mjs @@ -418,25 +418,92 @@ test('a full library refuses another entry before its layer exists', async (t) = assert.equal(manager.layers.size, 64); assert.equal(await library.add('entry-64'), false); assert.equal(manager.layers.size, 64, 'no layer outside the selection'); - // A share into a full library adds no row the library cannot keep. - const shared = sealedManager(t); - const opened = createDataLibrary({ + library.destroy(); +}); + +test('a share opened into a full library still shows what it shares', async (t) => { + const many = validateLibraryCatalog( + Array.from({ length: 67 }, (_, index) => tiles(`entry-${index}`)), + ).entries; + const manyLedger = many.map(({ id }, index) => [ + id, + index.toString(36).padStart(2, '0'), + ]); + const availability = { + async getAvailability() { + return { + server: true, + availability: new Map( + many.map(({ id }) => [id, { available: true, reason: null }]), + ), + }; + }, + tileTemplate: (entry) => entry.source.url, + }; + const full = JSON.stringify({ + added: many.slice(0, 64).map(({ id }) => id), + on: ['entry-0'], + }); + const manager = sealedManager(t); + const shareLink = { + ...shareLinkDouble(), + initialLibraryTokens: [manyLedger[64][1]], + listener: null, + setLibraryShareListener(listener) { + this.listener = listener; + }, + }; + const library = createDataLibrary({ entries: many, ledger: manyLedger, - dataManager: shared, + dataManager: manager, services: {}, storage: memoryStorage(full), - shareLink: fakeShareLink([manyLedger[65][1]]), + shareLink, source: availability, }); - await opened.start(); - assert.equal(shared.layers.has('lib-entry-65'), false); - assert.equal( - opened.list().filter((row) => row.added).length, - shared.layers.size, + await library.start(); + await manager.waitForLayerSettled?.('lib-entry-64'); + assert.equal(manager.isEnabled('lib-entry-64'), true); + // The oldest entry the share leaves off made room. + assert.equal(manager.layers.has('lib-entry-0'), false); + assert.equal(manager.layers.size, 64); + assert.equal(shareLink.encode(), manyLedger[64][1]); + // A share navigated to in this tab makes room the same way, and the + // entry it evicts leaves with its layer. + shareLink.listener([manyLedger[65][1]]); + await manager.waitForLayerSettled?.('lib-entry-65'); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(manager.isEnabled('lib-entry-65'), true); + assert.equal(manager.layers.has('lib-entry-1'), false); + assert.equal(manager.layers.size, 64); + library.destroy(); +}); + +test('entries this instance cannot serve still show as added and can be removed', async (t) => { + const manager = sealedManager(t); + const storage = memoryStorage( + JSON.stringify({ added: ['night-lights'], on: ['night-lights'] }), ); + const library = createDataLibrary({ + entries, + ledger, + dataManager: manager, + services: {}, + storage, + shareLink: fakeShareLink(null), + source: source({ + 'night-lights': { available: false, reason: 'disabled' }, + }), + }); + await library.start(); + assert.equal(manager.layers.size, 0); + const row = library.list().find(({ entry }) => entry.id === 'night-lights'); + assert.equal(row.added, true); + assert.equal(library.summary().added, 1); + assert.equal(await library.remove('night-lights'), true); + assert.deepEqual(JSON.parse(storage.store.get(LIBRARY_STORAGE_KEY)).added, []); library.destroy(); - opened.destroy(); }); test('a layer that fails to tear down stays in the library', async (t) => { From 8cca78bcd58f2837db8d67e647255dc57c88a7f2 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 12 Nov 2026 17:49:38 -0800 Subject: [PATCH 42/55] Stop sharing a Data Library entry as on once its failed Remove switched it off When Remove switched a layer off and then its destroy failed, the manager published no visibility change, so the selection still listed the entry as on: the link shared it as on and a reload restored it on while its row read off. A failed Remove now drops the entry from what is on and remembered whenever its layer ended up off. --- src/library/controller.js | 13 +++++++++++- src/library/controller.test.mjs | 35 +++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/library/controller.js b/src/library/controller.js index 9fdecd041..200bbd07e 100644 --- a/src/library/controller.js +++ b/src/library/controller.js @@ -388,8 +388,19 @@ export function createDataLibrary({ if ( isRegistered(id) && !(await dataManager.unregisterLibraryLayer(libraryLayerId(id))) - ) + ) { + // Switched off before teardown failed: it is no longer on, so the + // link and what is remembered must not say it is. + if (!dataManager.isEnabled?.(libraryLayerId(id))) { + selection = { + ...selection, + on: selection.on.filter((other) => other !== id), + }; + savedOn = savedOn.filter((other) => other !== id); + persist(); + } return false; + } selection = { added: selection.added.filter((other) => other !== id), on: selection.on.filter((other) => other !== id), diff --git a/src/library/controller.test.mjs b/src/library/controller.test.mjs index 46bed73ca..970d6c54c 100644 --- a/src/library/controller.test.mjs +++ b/src/library/controller.test.mjs @@ -581,6 +581,41 @@ test('the URL keeps the opening lib= before availability loads and for entries i library.destroy(); }); +test('a layer switched off before its teardown failed is no longer shared as on', async (t) => { + const manager = sealedManager(t); + const storage = memoryStorage(); + const shareLink = shareLinkDouble(); + const library = createDataLibrary({ + entries, + ledger, + dataManager: manager, + services: {}, + storage, + shareLink, + initialHash: '', + source: source({}), + }); + await library.start(); + await library.add('night-lights'); + assert.equal(shareLink.encode(), '00'); + const { module } = manager.layers.get('lib-night-lights'); + const destroy = module.destroy; + module.destroy = async () => { + throw new Error('stuck'); + }; + t.mock.method(console, 'warn', () => {}); + try { + assert.equal(await library.remove('night-lights'), false); + assert.equal(manager.isEnabled('lib-night-lights'), false); + assert.equal(shareLink.encode(), null); + const saved = JSON.parse(storage.store.get(LIBRARY_STORAGE_KEY)); + assert.deepEqual(saved, { added: ['night-lights'], on: [] }); + } finally { + module.destroy = destroy; + } + library.destroy(); +}); + test('zoom flies to an added layer through the caller, reset removes everything', async (t) => { const manager = sealedManager(t); const storage = memoryStorage(); From 92d6188045ba18aa36fcdefad4ed9867a82086ac Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 12 Nov 2026 17:56:17 -0800 Subject: [PATCH 43/55] Stop a moved-on view from cancelling the GeoParquet index the next view needs The browser shared one partition-index build per entry but ran it under the first view's signal. A camera move cancels the old view before the next one starts, so the next view was handed the same build, already cancelled, and drew nothing (the view layer takes an AbortError for its own cancellation) until the camera moved again. EPC hit this on its first view after any quick move. The build now belongs to no view: each view stops waiting on its own signal, and the build runs on for the next view and every view after it. --- src/library/parquet.test.mjs | 48 ++++++++++++++++++++++++++++++++++++ src/library/source.js | 40 +++++++++++++++++++++++------- 2 files changed, 79 insertions(+), 9 deletions(-) diff --git a/src/library/parquet.test.mjs b/src/library/parquet.test.mjs index 5dba367d5..ae0d91695 100644 --- a/src/library/parquet.test.mjs +++ b/src/library/parquet.test.mjs @@ -211,6 +211,54 @@ test('a view over an incomplete index reads PARTIAL', async () => { assert.equal(partial.truncated, true); }); +test('a view that moves on does not cancel the index build the next view waits for', async () => { + const home = { + type: 'Feature', + geometry: { type: 'Point', coordinates: [-1.2, 53.3] }, + properties: { CURRENT_ENERGY_RATING: 'C', UPRN: '1' }, + }; + let release; + let buildSignal = null; + let builds = 0; + const source = createLibrarySource({ + entries: [epc], + fetchImpl: async () => ({ ok: false, status: 404 }), + storage: null, + loadParquet: async () => ({ + ...(await import('./parquet.js')), + buildPartitionIndex: async (_entry, { signal }) => { + builds += 1; + buildSignal = signal; + await new Promise((resolve) => { + release = resolve; + }); + signal?.throwIfAborted(); + return { + partitions: [ + { url: 'https://h/A.parquet', bbox: [-1.5, 53.2, -1.1, 53.5] }, + ], + complete: true, + }; + }, + readGeoParquetFeatures: async () => [home], + }), + }); + const box = [-1.21, 53.29, -1.19, 53.31]; + const tick = () => new Promise((resolve) => setImmediate(resolve)); + const first = new AbortController(); + const a = source.loadDataset(epc, { bbox: box, signal: first.signal }); + await tick(); + first.abort(); + await assert.rejects(a); + const b = source.loadDataset(epc, { bbox: box, signal: new AbortController().signal }); + await tick(); + release(); + const view = await b; + assert.equal(view.features.length, 1); + assert.equal(builds, 1, 'the second view reused the build'); + assert.equal(buildSignal, undefined, 'no view owns the build'); +}); + test('a footer that keeps failing leaves the index incomplete, and a retry recovers', async () => { const { buildPartitionIndex } = await import('./parquet.js'); const entry = { diff --git a/src/library/source.js b/src/library/source.js index f4062afa5..1c6f544a6 100644 --- a/src/library/source.js +++ b/src/library/source.js @@ -176,9 +176,30 @@ export function createLibrarySource({ * shared), else built here and kept in this browser for `cacheSeconds`. */ function partitionIndex(entry, { signal } = {}) { - const memo = indexes.get(entry.id); - if (memo && (memo.retryAt === null || now() < memo.retryAt)) - return memo.promise; + let memo = indexes.get(entry.id); + if (!memo || (memo.retryAt !== null && now() >= memo.retryAt)) + memo = startPartitionIndex(entry); + return waitForIndex(entry, memo, signal); + } + + /** + * One caller's wait on the shared index build. The build belongs to no + * view: a view that moves on stops waiting, while the build runs on for + * the next view (which a camera move starts right after cancelling the + * last) and is kept for every view after it. + */ + function waitForIndex(entry, memo, signal) { + if (signal?.aborted) return Promise.reject(signal.reason); + return new Promise((resolve, reject) => { + const onAbort = () => reject(signal.reason); + signal?.addEventListener?.('abort', onAbort, { once: true }); + memo.promise + .then(resolve, reject) + .finally(() => signal?.removeEventListener?.('abort', onAbort)); + }); + } + + function startPartitionIndex(entry) { const promise = (async () => { const key = `${INDEX_STORAGE_PREFIX}${entry.id}`; const stored = storedJson(storage, key); @@ -190,7 +211,7 @@ export function createLibrarySource({ try { const response = await fetchImpl( `${base}/index/${encodeURIComponent(entry.id)}`, - { signal, headers: { Accept: 'application/json' } }, + { headers: { Accept: 'application/json' } }, ); if (response.ok) { const body = await response.json(); @@ -200,8 +221,8 @@ export function createLibrarySource({ partial = body.partitions; } } - } catch (error) { - if (signal?.aborted) throw error; + } catch { + /* no server index: build one here */ } if ( storedComplete && @@ -210,7 +231,7 @@ export function createLibrarySource({ return { partitions: storedComplete.partitions, complete: true }; // The server's index is incomplete, or there is no server: build here. const { buildPartitionIndex } = await loadParquet(); - const built = await buildPartitionIndex(entry, { fetchImpl, signal }); + const built = await buildPartitionIndex(entry, { fetchImpl }); if (built.complete) { storeJson(storage, key, { savedAt: now(), @@ -244,8 +265,9 @@ export function createLibrarySource({ throw error; }, ); - indexes.set(entry.id, { promise, retryAt: null }); - return promise; + const memo = { promise, retryAt: null }; + indexes.set(entry.id, memo); + return memo; } /** One partition's features, decoded once and kept for a few views. */ From d1966b9751e8b17214bbc8a95eb50c2f5f076e00 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 12 Nov 2026 17:56:51 -0800 Subject: [PATCH 44/55] Clear a view-bounded layer's error when the camera returns to the drawn view A failed request for the view in between left its error set, and the early return for the already drawn view never cleared it, so the drawn features showed with an error state. --- src/library/layers.js | 5 ++++- src/library/viewLayer.test.mjs | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/library/layers.js b/src/library/layers.js index 64ee55247..8cc5b4afc 100644 --- a/src/library/layers.js +++ b/src/library/layers.js @@ -764,9 +764,12 @@ export function createLibraryViewLayer( // Back on the view already drawn: a request for the view in between is // obsolete and must not replace it. if (key === loadedKey && dataSource) { - if (controller) { + // What is drawn is this view's, so an error from the view in between + // no longer applies. + if (controller || error) { abort(); loading = false; + error = null; render(); } return; diff --git a/src/library/viewLayer.test.mjs b/src/library/viewLayer.test.mjs index 53f9bd65a..47ce79c4c 100644 --- a/src/library/viewLayer.test.mjs +++ b/src/library/viewLayer.test.mjs @@ -157,6 +157,40 @@ test('returning to the drawn view cancels the request for the view in between', await layer.destroy(viewer); }); +test('returning to the drawn view clears an error from the view in between', async () => { + let call = 0; + const layer = createLibraryViewLayer(entry, { + source: { + loadDataset: async () => { + call += 1; + if (call === 2) throw new Error('HTTP 503'); + return { features: [polygon], truncated: false }; + }, + }, + cesium: fakeCesium, + debounceMs: 0, + }); + const { viewer, listeners } = fakeViewer({ height: 2000 }); + const moveTo = async (lon, lat) => { + viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees(lon, lat, 2000); + viewer.camera.pickEllipsoid = () => + Cesium.Cartesian3.fromDegrees(lon, lat, 0); + viewer.camera.computeViewRectangle = () => + Cesium.Rectangle.fromDegrees(lon - 1, lat - 1, lon + 1, lat + 1); + // The camera settles: the layer's moveEnd listener schedules a load. + for (const listener of listeners) listener(); + await new Promise((resolve) => setTimeout(resolve, 5)); + }; + await layer.init(viewer); + await layer.enable(viewer); + await moveTo(-77.2, 38.95); + assert.ok(layer.getStats().error); + await moveTo(-77.03, 38.89); + assert.equal(layer.getStats().error, null); + assert.equal(layer.getStats().count, 1); + await layer.destroy(viewer); +}); + test('the row legend labels each colour and counts what is in view', async () => { const { colorByLegend } = await import('./layers.js'); const bridges = libraryEntry('ntad-national-bridges'); From 1d476c87eeaf40c499b07ca03d6f48055aace242 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 12 Nov 2026 18:02:41 -0800 Subject: [PATCH 45/55] fix(location): a POI key held with Cmd, Ctrl or Alt runs no POI The points-of-interest keys (Q/W/E/R/T while a city is expanded) are a second bare-key document listener, and it did not look at modifiers either: Alt+R or a delivered Ctrl+R picked the fourth POI and flew the camera there. It now leaves Cmd, Ctrl and Alt chords to the browser like the application shortcuts; Shift still counts as the key. The docs and the changelog entry say so. --- changelog.d/1691-shortcuts-ignore-modifier-chords.md | 3 ++- docs/CURRENT-STATE.md | 2 ++ src/ui/locationControls.js | 3 +++ src/ui/locationControls.test.mjs | 10 ++++++++++ 4 files changed, 17 insertions(+), 1 deletion(-) diff --git a/changelog.d/1691-shortcuts-ignore-modifier-chords.md b/changelog.d/1691-shortcuts-ignore-modifier-chords.md index 4a850507e..6bc84f8f8 100644 --- a/changelog.d/1691-shortcuts-ignore-modifier-chords.md +++ b/changelog.d/1691-shortcuts-ignore-modifier-chords.md @@ -1,4 +1,5 @@ - Leave browser and system chords alone: a shortcut key held with Cmd, Ctrl or Alt no longer runs its single-key shortcut. Copying with Cmd+C or Ctrl+C used to turn the CCTV layer on and open the nearest camera, Cmd+F opened the layers - panel and Cmd+1 to 6 changed the visual style (asturksever, #1691). + panel and Cmd+1 to 6 changed the visual style. The Q/W/E/R/T keys of an + expanded city's points of interest follow the same rule (asturksever, #1691). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 0e4b03194..6b9628030 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2329,6 +2329,8 @@ typing and Escape behavior retain their existing mappings. A shortcut key held with Cmd, Ctrl or Alt runs no shortcut, so the browser's own chord (Cmd+C to copy, Cmd+F to find, Cmd+1 to switch tab) does not also toggle CCTV, the layers panel or a style; Shift does not count, and Escape dismisses search either way. +The Q/W/E/R/T keys of an expanded city's points of interest follow the same +rule. Capture-phase surfaces continue to arbitrate their own keyboard events first. The UI facade retains shader values, share-restore authority, render requests, diff --git a/src/ui/locationControls.js b/src/ui/locationControls.js index 6fd827788..388fb51e1 100644 --- a/src/ui/locationControls.js +++ b/src/ui/locationControls.js @@ -88,6 +88,9 @@ export class LocationControls { event.target === elements.search ) return; + // As for the application shortcuts: Cmd+R, Ctrl+W, Alt+E and the like + // belong to the browser and the system. Shift is not a chord. + if (event.metaKey || event.ctrlKey || event.altKey) return; const index = POI_KEYS.indexOf(event.key.toUpperCase()); if (index !== -1 && index < (cities[cityId]?.pois.length || 0)) onPoi(cityId, index); diff --git a/src/ui/locationControls.test.mjs b/src/ui/locationControls.test.mjs index ccae6454b..2863564c8 100644 --- a/src/ui/locationControls.test.mjs +++ b/src/ui/locationControls.test.mjs @@ -191,6 +191,16 @@ test('location and POI keys route once while form controls retain typing', () => f.controls.reset(); assert.deepEqual(f.calls, [['poi', 'a', 1], ['reset']]); }); +test('POI keys held with Cmd, Ctrl or Alt run no POI; Shift still does', () => { + const f = fixture(); + const target = { matches: () => false }; + for (const modifier of ['metaKey', 'ctrlKey', 'altKey']) + for (const key of ['q', 'w', 'r', 'R']) + f.doc.fire('keydown', { key, target, [modifier]: true }); + assert.deepEqual(f.calls, []); + f.doc.fire('keydown', { key: 'W', target, shiftKey: true }); + assert.deepEqual(f.calls, [['poi', 'a', 1]]); +}); test('typing clears a failed search error and closing the emptied field drops it too', () => { const f = fixture(); f.elements.search.value = 'Pariss'; From 6e4df4c2871a2f9f59c3eb44876dfd29a37f5c4d Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 12 Nov 2026 20:10:02 -0800 Subject: [PATCH 46/55] fix(cctv): a new camera's frame shows the moment it is decoded, without a fade The preview settled when the image had loaded, so the panel's took the src while it was still decoding and then faded in over 180 ms: every camera click lingered on an empty box. The preview now settles once the frame is decoded (a rejected decode still shows the loaded frame, and a decode that never settles shows it after one second), and the fade is gone. --- docs/CURRENT-STATE.md | 4 +++- src/ui/cctvControls.test.mjs | 45 ++++++++++++++++++++++++++++++++++++ src/ui/cctvFrames.js | 26 ++++++++++++++++++++- src/ui/styles/cctv.css | 5 +++- 4 files changed, 77 insertions(+), 3 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 02c1996fb..0d0e26f00 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2224,7 +2224,9 @@ Frame loading, calibration editing and status display have separate modules; providers, camera placement and navigation policy retain their existing owners. Changing cameras invalidates old image callbacks and cancels an unfinished calibration edit. Failed refreshes preserve settled pixels for the same camera, -while a newly selected camera never shows the prior camera's image. Disposal +while a newly selected camera never shows the prior camera's image. A frame is +shown without a fade once it is decoded (or after one second if its decode never +settles), so a new camera's preview is blank only while its frame loads. Disposal releases listeners, subscriptions, image handlers, summary timers and chip-hide timers. ## UI disposal diff --git a/src/ui/cctvControls.test.mjs b/src/ui/cctvControls.test.mjs index 9a5d746d7..c2ef0e776 100644 --- a/src/ui/cctvControls.test.mjs +++ b/src/ui/cctvControls.test.mjs @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { CctvControls } from './cctvControls.js'; +import { CCTV_FRAME_DECODE_WAIT_MS } from './cctvFrames.js'; function element() { const classes = new Set(); @@ -59,6 +60,50 @@ test('a late image completion cannot replace a newer camera preview', (t) => { assert.equal(controls._frame.cameraId, 'b'); }); +test('a camera frame settles once decoded, and a stale decode cannot land', async (t) => { + const { controls, requests } = fixture(t); + const decodes = []; + const queue = (src, id) => { + controls._queueCctvFrame(src, id, true); + const request = requests.at(-1); + request.decode = () => + new Promise((resolve) => decodes.push({ src, resolve })); + return request; + }; + const first = queue('first.jpg', 'a'); + first.onload(); + assert.equal(controls._frame.hasFrame, false); + const second = queue('second.jpg', 'b'); + second.onload(); + decodes[1].resolve(); + await Promise.resolve(); + decodes[0].resolve(); + await Promise.resolve(); + assert.equal(controls._frame.src, 'second.jpg'); + assert.equal(controls._frame.cameraId, 'b'); + assert.equal(controls._frame.hasFrame, true); +}); + +test('a loaded frame shows when its decode rejects or never settles', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const { controls, requests } = fixture(t); + controls._queueCctvFrame('broken.jpg', 'a', true); + requests[0].decode = () => Promise.reject(new Error('EncodingError')); + requests[0].onload(); + await Promise.resolve(); + await Promise.resolve(); + assert.equal(controls._frame.src, 'broken.jpg'); + assert.equal(controls._frame.hasFrame, true); + + controls._queueCctvFrame('hidden.jpg', 'b', true); + requests[1].decode = () => new Promise(() => {}); + requests[1].onload(); + assert.equal(controls._frame.hasFrame, false); + t.mock.timers.tick(CCTV_FRAME_DECODE_WAIT_MS); + assert.equal(controls._frame.src, 'hidden.jpg'); + assert.equal(controls._frame.hasFrame, true); +}); + test('failed refresh preserves settled pixels, but changing cameras clears them', (t) => { const { controls, requests } = fixture(t); controls._queueCctvFrame('first.jpg', 'a', true); diff --git a/src/ui/cctvFrames.js b/src/ui/cctvFrames.js index cdc9185f7..761711174 100644 --- a/src/ui/cctvFrames.js +++ b/src/ui/cctvFrames.js @@ -77,6 +77,9 @@ export function cctvSourceLabelText(label) { return SOURCE_LABELS[text] ? t(SOURCE_LABELS[text]) : text; } +/** Longest wait for a loaded frame's decode before it is shown anyway. */ +export const CCTV_FRAME_DECODE_WAIT_MS = 1000; + /** The preview before any frame: nothing requested, nothing shown. */ export const EMPTY_CCTV_FRAME = Object.freeze({ cameraId: '', @@ -127,7 +130,28 @@ export function _queueCctvFrame(src, cameraId, cameraChanged) { const preloader = new Image(); this._cctvFramePreloader = preloader; - preloader.onload = () => this._settleCctvFrame(token, src, true); + // Settle once the image is decoded, not merely loaded: the panel's + // then paints the frame the moment it takes the src, instead of showing + // its empty box for the decode. + // A decode that rejects after a load still has drawable data, as it was + // before decoding was awaited. A decode that never settles (a hidden tab may + // hold it until the page draws) shows the loaded frame after + // CCTV_FRAME_DECODE_WAIT_MS rather than leaving the preview loading. + preloader.onload = () => { + if (typeof preloader.decode !== 'function') { + this._settleCctvFrame(token, src, true); + return; + } + let settled = false; + const settle = () => { + if (settled) return; + settled = true; + clearTimeout(timer); + this._settleCctvFrame(token, src, true); + }; + const timer = setTimeout(settle, CCTV_FRAME_DECODE_WAIT_MS); + preloader.decode().then(settle, settle); + }; preloader.onerror = () => this._settleCctvFrame(token, src, false); preloader.src = src; } diff --git a/src/ui/styles/cctv.css b/src/ui/styles/cctv.css index 7754e563f..fd406485d 100644 --- a/src/ui/styles/cctv.css +++ b/src/ui/styles/cctv.css @@ -250,8 +250,11 @@ html:not([lang='en']) .cctv-controls .scene-btn { height: auto; object-fit: contain; display: block; + /* No fade: a camera change hides the old frame at once (never its pixels + under the new camera) and shows the new one as soon as it is decoded. A + 180 ms fade-in after every arriving frame made each camera click linger + on black. */ opacity: 0; - transition: opacity 180ms ease; } #cctv-video { From 2445af349d709bdc4b57cbf5c59947f49198124c Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 12 Nov 2026 20:10:02 -0800 Subject: [PATCH 47/55] fix(cctv): on a globe map the camera plane ends where the view meets the ground The activation probe raycast only the photoreal tiles. On a globe map the ray missed, or ran on through the Earth and met a primitive thousands of kilometres away, and the monitor plane stood at the camera's full range: about a kilometre wide and hundreds of metres up. A hit at or beyond the range now counts as none, and on a globe map the plane also clamps short of the globe's terrain along the axis, or flat ground at the mount's ground while that terrain is still loading, whichever is nearer. --- docs/CURRENT-STATE.md | 4 +++ src/data/cctv.js | 1 + src/data/cctv.test.mjs | 12 ++++++++ src/layers/cctv/geometry.js | 58 ++++++++++++++++++++++++++++++++----- src/layers/cctv/index.js | 1 + 5 files changed, 69 insertions(+), 7 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 02c1996fb..2487af0a7 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -6570,6 +6570,10 @@ silently demoting every later lookup for the session. far cap, retargeting the existing video/canvas texture pipeline. Stills are drawn at their source aspect, centered on the placeholder dark (`#01070c`), instead of being stretched to the 16:9 canvas. Manual calibration only — auto-calibration and the drape mesh pipeline are deleted. A one-shot activation obstruction probe (`pickFromRay` on camera activation, clamping the plane short of the first hit) remains; + a hit at or beyond the camera's range counts as a miss, and on a globe map the plane also clamps + short of the globe's terrain along the axis (flat ground at the mount's ground when terrain is + missed or met beyond the range), whichever is nearer, so it never stands at the full range there + (about a kilometre wide and hundreds of metres up); ground placement is superseded by the shared-floor v3 behavior below. Calibration persists to `godsEyeView.cctv.calibration.v2` (wiped clean, no v1 import); a panel-only CAL badge shows `CALIBRATED`/`CURATED`/`RAW PRIOR` (no in-world tint). Panel is titled "CCTV" (not "CCTV diff --git a/src/data/cctv.js b/src/data/cctv.js index ed3f56654..884638a96 100644 --- a/src/data/cctv.js +++ b/src/data/cctv.js @@ -29,6 +29,7 @@ export const writeCalibrationStoreV2 = layer.writeCalibrationStoreV2; export const deriveCalBadge = layer.deriveCalBadge; export const computeFrustumGeometry = layer.computeFrustumGeometry; export const activationProbeClampRange = layer.activationProbeClampRange; +export const flatGroundAxisDistance = layer.flatGroundAxisDistance; export const frameSignatureFromPixels = layer.frameSignatureFromPixels; export const _createCctvProjectionPlaneForTest = layer._createCctvProjectionPlaneForTest; diff --git a/src/data/cctv.test.mjs b/src/data/cctv.test.mjs index bcd5c48ab..087422584 100644 --- a/src/data/cctv.test.mjs +++ b/src/data/cctv.test.mjs @@ -35,6 +35,7 @@ import cctvLayer, { _pushAmbientCardEntriesForTest, _setCctvOverlayHostForTest, activationProbeClampRange, + flatGroundAxisDistance, bindCctvWorldClickGesture, clearProbeClampOnDeactivation, computeFrustumGeometry, @@ -655,6 +656,17 @@ test('activation obstruction clamp uses the field-derived 12 m floor', () => { assert.equal(activationProbeClampRange(210, 250), null); }); +test('on a globe map the axis ends on flat ground at the mount ground', () => { + // A 24 m mount pitched 30° down meets the ground 48 m along its axis. + assert.ok(Math.abs(flatGroundAxisDistance(24, -30) - 48) < 1e-9); + // The default 700 m range then clamps just short of that, not 700 m out. + const clamp = activationProbeClampRange(700, flatGroundAxisDistance(24, -30)); + assert.ok(Math.abs(clamp - 44) < 1e-9); + assert.equal(flatGroundAxisDistance(24, 0), null); + assert.equal(flatGroundAxisDistance(24, 10), null); + assert.equal(flatGroundAxisDistance(0, -30), null); +}); + test('deactivation clears the probe clamp and restores nominal frustum geometry', () => { const record = { camera: { ...UNCLAMPED_CAMERA }, diff --git a/src/layers/cctv/geometry.js b/src/layers/cctv/geometry.js index a1402c6c2..148c4a253 100644 --- a/src/layers/cctv/geometry.js +++ b/src/layers/cctv/geometry.js @@ -260,6 +260,21 @@ export function createGeometry({ state: layerState, services, parts, source }) { return Math.max(PROBE_MIN_RANGE_M, hitDistance - PROBE_CLEARANCE_M); } + /** + * Distance along a pitched-down view axis to flat ground at the mount's own + * ground height, or null for an axis at or above the horizon. + * @param {number} mountHeightM Mount height above its ground. + * @param {number} pitchDeg Axis pitch (negative looks down). + * @returns {number|null} + */ + + function flatGroundAxisDistance(mountHeightM, pitchDeg) { + const height = Number(mountHeightM); + const pitch = Number(pitchDeg); + if (!Number.isFinite(height) || height <= 0 || !(pitch < 0)) return null; + return height / Math.sin(Cesium.Math.toRadians(Math.min(89, -pitch))); + } + /** * Converts a computeFrustumGeometry result into the Cartesian3 positions the * entities consume. Fresh objects per call (geometry updates are rare — @@ -616,10 +631,14 @@ export function createGeometry({ state: layerState, services, parts, source }) { * range, clamp the plane's effective range just short of the first hit so the * "big and dramatic" true end cap never clips into downtown buildings. * - * This is the ONLY raycast in the whole CCTV subsystem — once per activation, - * never per-frame (the zero-raycast invariant applies to steady state). The - * per-camera range slider overrides the clamp: a user-set rangeScale skips the - * probe entirely. Probe failure/miss keeps the unclamped range. + * On a globe map it also picks the globe's terrain along the same axis + * (flat ground at the mount's ground when terrain is missed or met beyond + * the range) and clamps at the nearer end. These are the ONLY raycasts in the + * whole CCTV subsystem — once per activation, never per-frame (the + * zero-raycast invariant applies to steady state). The per-camera range + * slider overrides the clamp: a user-set rangeScale skips the probe + * entirely. A miss on the photoreal stack or a probe failure keeps the + * unclamped range. * @param {Object} record - Camera record being activated. */ @@ -646,9 +665,33 @@ export function createGeometry({ state: layerState, services, parts, source }) { for (const runtime of layerState._projectionEntities) { if (runtime?.planeEntity) exclude.push(runtime.planeEntity); } - const hit = scene.pickFromRay(new Cesium.Ray(mountPos, dir), exclude); - if (!hit?.position) return; - const dist = Cesium.Cartesian3.distance(mountPos, hit.position); + const ray = new Cesium.Ray(mountPos, dir); + // pickFromRay hits only primitives (the photoreal tiles), never the + // globe: on a globe map the ray runs on through the Earth and can meet + // a primitive thousands of kilometres away, which blocks nothing. Such + // a hit or none left the plane at the full nominal range, about a + // kilometre wide and hundreds of metres up. A hit at or beyond the range + // counts as none. On a globe map the axis also ends on the globe's + // terrain, the nearer of that and any primitive hit (a primitive behind + // a hill the axis has already entered blocks nothing). Terrain still + // loading at a coarse level can be missed or met beyond the range; then + // flat ground at the mount's own ground stands in for it. + const inRange = (distance) => + Number.isFinite(distance) && distance > 0 && distance < camera.rangeM; + const hit = scene.pickFromRay(ray, exclude)?.position; + const hitDist = hit ? Cesium.Cartesian3.distance(mountPos, hit) : NaN; + const distances = inRange(hitDist) ? [hitDist] : []; + if (scene.globe?.show) { + const terrain = scene.globe.pick(ray, scene); + const terrainDist = terrain + ? Cesium.Cartesian3.distance(mountPos, terrain) + : NaN; + const ground = inRange(terrainDist) + ? terrainDist + : flatGroundAxisDistance(camera.mountHeightM, camera.pitchDeg); + if (Number.isFinite(ground) && ground > 0) distances.push(ground); + } + const dist = distances.length ? Math.min(...distances) : null; record.probeClampRangeM = activationProbeClampRange(camera.rangeM, dist); } catch { // probe failure → keep the unclamped range @@ -831,6 +874,7 @@ export function createGeometry({ state: layerState, services, parts, source }) { hasShippedFootprint, footprintPose, activationProbeClampRange, + flatGroundAxisDistance, frustumCartesians, frustumFrameEcef, applyFrustumGeometry, diff --git a/src/layers/cctv/index.js b/src/layers/cctv/index.js index 36e1360b4..ba251da34 100644 --- a/src/layers/cctv/index.js +++ b/src/layers/cctv/index.js @@ -73,6 +73,7 @@ export function createCctvLayer({ services, source }) { deriveCalBadge: parts.calibration.deriveCalBadge, computeFrustumGeometry: parts.geometry.computeFrustumGeometry, activationProbeClampRange: parts.geometry.activationProbeClampRange, + flatGroundAxisDistance: parts.geometry.flatGroundAxisDistance, frameSignatureFromPixels: parts.frames.frameSignatureFromPixels, _createCctvProjectionPlaneForTest: parts.testing._createCctvProjectionPlaneForTest, From 347e77d1f6c76e8fc5404cba36ba6125711fa737 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 12 Nov 2026 20:10:02 -0800 Subject: [PATCH 48/55] feat(vessels): a selected vessel shows its Open Vessel Photos photo and MMSI Selecting a vessel asks Open Vessel Photos (a free, keyless API) for its photo by MMSI, once per MMSI. The CONTACTS view shows it under the vessel's name and MMSI with the photographer, the photo's open licence and a link to the vessel's page; only images on Open Vessel Photos' own host are shown, and an answer about another MMSI is no photo. The vessel source exposes the lookup as getEnrichment({kind: 'photo'}) and fetchOpenVesselPhoto for applications with their own vessel source. --- DATA_SOURCES.md | 6 +- docs/CURRENT-STATE.md | 9 ++ scripts/format-scope.json | 4 + scripts/package-boundaries.json | 18 ++-- src/data/dataCredits.js | 7 ++ src/data/dataCredits.test.mjs | 7 ++ src/data/openVesselPhoto.js | 135 ++++++++++++++++++++++++++++ src/data/openVesselPhoto.test.mjs | 120 +++++++++++++++++++++++++ src/i18n/locales/ar.json | 2 + src/i18n/locales/de.json | 2 + src/i18n/locales/es.json | 2 + src/i18n/locales/fr.json | 2 + src/i18n/locales/he.json | 2 + src/i18n/locales/it.json | 2 + src/i18n/locales/ja.json | 2 + src/i18n/locales/pt.json | 2 + src/i18n/locales/ru.json | 2 + src/i18n/locales/sv.json | 2 + src/i18n/locales/uk.json | 2 + src/i18n/locales/zh-CN.json | 2 + src/i18n/locales/zh-Hant.json | 2 + src/i18n/same-as-english.json | 1 + src/layers/awareness/panel.js | 22 +++-- src/layers/awareness/panel.test.mjs | 47 +++++++++- src/layers/awareness/subject.js | 20 +++-- src/layers/vessels/index.js | 5 ++ src/layers/vessels/photos.js | 56 ++++++++++++ src/layers/vessels/photos.test.mjs | 87 ++++++++++++++++++ src/layers/vessels/selection.js | 1 + src/sources/live/index.js | 1 + src/sources/live/standalone.js | 38 ++++++++ 31 files changed, 591 insertions(+), 19 deletions(-) create mode 100644 src/data/openVesselPhoto.js create mode 100644 src/data/openVesselPhoto.test.mjs create mode 100644 src/layers/vessels/photos.js create mode 100644 src/layers/vessels/photos.test.mjs diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 759dff105..b1f44764c 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -31,6 +31,7 @@ How to read this: | **adsb.lol** | Military flights + aircraft traces | ODbL 1.0 | "adsb.lol" (ODbL) | | **adsbdb** (`api.adsbdb.com`) | Aircraft type, model name and registration by ICAO hex for enriched flights; airline and origin/destination airports for the tracked flight's callsign | No licence or rate limit is published for the service. Its own credits: aircraft data from PlaneBase, ICAO-to-N-number conversion by Guillaume Michel, and flight route data that "is the work of David Taylor, Edinburgh and Jim Mason, Glasgow, and may not be copied, published, or incorporated into other databases without the explicit permission of David J Taylor, Edinburgh" | "adsbdb — api.adsbdb.com" (courtesy); route data credited to David Taylor, Edinburgh, and Jim Mason, Glasgow | | **Planespotters.net** (`api.planespotters.net/pub/photos/hex/{hex}`) | One thumbnail photo of the tracked aircraft, by ICAO hex, in the CONTEXT panel's CONTACTS view under the tracked flight's name | [Planespotters photo API terms](https://www.planespotters.net/photo/api); each photo remains the copyright of its photographer. Thumbnails may be hotlinked on the condition that the photographer is named next to the photo and the photo links to its Planespotters page with a followable link | "Photo © {photographer}" beside every photo that names one, linked to its Planespotters page; "Planespotters.net" in Data attribution | +| **Open Vessel Photos** (`openvesselphotos.org/v1/mmsi/{mmsi}`) | One photo of the selected vessel, by MMSI, in the CONTEXT panel's CONTACTS view under the vessel's name and MMSI | Free, keyless public API ([README](https://github.com/louptheron/openvesselphotos)); each photo keeps the open licence its author chose (CC BY, CC BY-SA, CC0, or public domain), given with it in the API | "Photo: {photographer} · {licence}" beside every photo, linked to the vessel's Open Vessel Photos page; "Open Vessel Photos" in Data attribution | | **AISStream.io** | Live vessels (AIS) | Free, beta, no formal ToS; AIS is a public broadcast | "AISStream.io" (courtesy) | | **CelesTrak** | Satellite TLEs (SGP4) and SATCAT records (object type, operational status, owner) that file each satellite under a class | US-government-origin data, no license; citation requested | "CelesTrak (celestrak.org), Dr. T.S. Kelso" | | **The Space Devs — Launch Library 2 v2.3** | Recent launch, payload, stage, and recovery metadata for Space Missions (30d) | [The Space Devs terms of use](https://github.com/TheSpaceDevs/Tutorials/blob/main/faqs/faq_TSD.md#terms-of-use): data may be used and shared in any form; avoid forwarding it without added value; attribution is encouraged (not mandatory). [Official API limits](https://ll.thespacedevs.com/docs/): 15 unauthenticated calls/hour; optional token | "Launch Library 2 — The Space Devs" (courtesy attribution) | @@ -238,7 +239,8 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **adsbdb enrichment.** `/api/adsbdb` resolves an ICAO hex to aircraft type, model name and registration, and the tracked flight's callsign to an airline and an origin/destination pair. Fan-out is deliberately small because adsbdb is a free community API that publishes no quota: at most 4 requests in flight, dispatches dripped to roughly 5/s, and each key asked for at most once per session (for the flights layer's enrichment, a lookup that failed temporarily, such as an outage, a timeout, a rate limit or an authorization refusal that may clear, may be asked again after a 60-second cool-down; an answer, found or not, and a permanent refusal such as a 404 are final; Local ADS-B lookups are asked once), with route lookups reserved for the tracked aircraft. The dev-server proxy caches each key for 24 hours in `.gev-cache/adsbdb.json`, negative answers included, so fresh entries survive server restarts. That gitignored cache remains local to the deployment and is not bundled with this repo. adsbdb publishes no general service license; its README says the route data may not be copied, published, or incorporated into other databases without the explicit permission of David J Taylor, Edinburgh. Deployers are responsible for obtaining any permission their use requires. Enrichment is best-effort and fail-silent — a flight that gets no answer keeps what the flight feed already gave it. -- **Planespotters photos.** When you track an aircraft, the browser asks `https://api.planespotters.net/pub/photos/hex/{hex}` directly (the API answers cross-origin, so no key and no server proxy are involved) for that aircraft only, once per hex per session, through the same enrichment queue as adsbdb, with an 8-second timeout. A miss is remembered for the session; a request that failed temporarily (an outage, the timeout or a rate limit) may be asked again after a 60-second cool-down while the aircraft stays tracked, as for the flights layer's other enrichment. Either way no photo shows meanwhile. The first photo whose image is an `https://` URL on `*.plnspttrs.net`, and whose page is an `https://www.planespotters.net` URL is shown in the CONTEXT panel's CONTACTS view, under the tracked flight's name (`src/ui/views/panels/AwarenessPanel.vue`), and nowhere else; a vessel, an installation, a military aircraft or a converted TR-3B has none, and a military aircraft or TR-3B is never looked up. Anything else is dropped, so the app never shows or links outside Planespotters (`src/data/planespottersPhoto.js`, also exported as `gods-eye-view/sources/planespotters` for servers that cache the lookup). The photo follows the attribution rules: the thumbnail is hotlinked from the URL the API gives (never copied or re-hosted), the photographer's name, when the answer gives one, is visible text next to it ("Photo © {photographer}"; a photo whose answer names no one shows no credit line), and the photo and credit are a plain link to the photo's Planespotters page with `target="_blank" rel="noopener"` and never `rel="nofollow"`. If the image fails to load, the photo and its credit are hidden. +- **Planespotters photos.** When you track an aircraft, the browser asks `https://api.planespotters.net/pub/photos/hex/{hex}` directly (the API answers cross-origin, so no key and no server proxy are involved) for that aircraft only, once per hex per session, through the same enrichment queue as adsbdb, with an 8-second timeout. A miss is remembered for the session; a request that failed temporarily (an outage, the timeout or a rate limit) may be asked again after a 60-second cool-down while the aircraft stays tracked, as for the flights layer's other enrichment. Either way no photo shows meanwhile. The first photo whose image is an `https://` URL on `*.plnspttrs.net`, and whose page is an `https://www.planespotters.net` URL is shown in the CONTEXT panel's CONTACTS view, under the tracked flight's name (`src/ui/views/panels/AwarenessPanel.vue`), and nowhere else; a vessel (see Open Vessel Photos below), an installation, a military aircraft or a converted TR-3B has none, and a military aircraft or TR-3B is never looked up. Anything else is dropped, so the app never shows or links outside Planespotters (`src/data/planespottersPhoto.js`, also exported as `gods-eye-view/sources/planespotters` for servers that cache the lookup). The photo follows the attribution rules: the thumbnail is hotlinked from the URL the API gives (never copied or re-hosted), the photographer's name, when the answer gives one, is visible text next to it ("Photo © {photographer}"; a photo whose answer names no one shows no credit line), and the photo and credit are a plain link to the photo's Planespotters page with `target="_blank" rel="noopener"` and never `rel="nofollow"`. If the image fails to load, the photo and its credit are hidden. +- **Open Vessel Photos.** When you select a vessel with a nine-digit MMSI, the browser asks `https://openvesselphotos.org/v1/mmsi/{mmsi}` directly (the API is keyless and answers cross-origin, so no key and no server proxy are involved; account credentials are never sent) for that vessel only, once per MMSI per session, with an 8-second timeout. A miss (a 404 or a vessel without photos) is remembered for the session; a failed request may be asked again after five minutes. The first photo whose image is an `https://openvesselphotos.org` URL and which names a licence is shown, at the 800 px size, in the CONTEXT panel's CONTACTS view under the vessel's name and MMSI (`src/ui/views/panels/AwarenessPanel.vue`), and nowhere else (`src/data/openVesselPhoto.js`, `src/layers/vessels/photos.js`). The image is hotlinked from the URL the API gives (never copied or re-hosted); the photographer and licence are visible text next to it ("Photo: {photographer} · {licence}", or "Photo · {licence}" when the answer names no one), and the photo and credit link to the vessel's Open Vessel Photos page with `target="_blank" rel="noopener"`. If the image fails to load, the photo and its credit are hidden. - **Launch Library 2.** `/api/launches` makes a server-side rolling-30-day query against the supported v2.3 detailed launch endpoint, caches successful responses for 15 minutes in memory and on disk, and serves the last successful response during a throttle or transient outage. Anonymous access is limited to 15 calls/hour; deployments can provide `LL2_API_TOKEN` for authenticated access. The Space Devs' published terms permit using and sharing the API data in any form, ask users not to forward it without adding value, disclaim complete accuracy, and encourage—but do not require—attribution. This app keeps a courtesy credit. Payload and stage/recovery records are shown only when supplied. Failed launches expose their source status and never receive fallback orbit geometry or a live/estimated marker. LL2 supplies launch context and event timing, not continuous ascent telemetry or live orbital state. - **TfL JamCams.** The camera list comes from the keyless `api.tfl.gov.uk` endpoint (an optional `TFL_APP_KEY` raises its rate limit); frames come from TfL's public S3 bucket. The "Powered by TfL Open Data" attribution is required by TfL's terms and is registered in the Data attribution popover. - **Ontario 511 CCTV.** The camera list comes from the `511on.ca/api/v2/get/cameras` endpoint, which now requires a free developer key (`ONTARIO_511_API_KEY`: sign up at 511on.ca/my511/register, then request it while logged in at 511on.ca/developers/doc; throttled to 10 calls a minute). Without the key the pack is skipped. Frames are server-registered still images under `511on.ca/map/Cctv/`. The source is live-fetched at runtime and attributed under the Open Government Licence - Ontario. Access is governed by Ontario 511's Developer's Access Agreement (511on.ca/developers/resources): the data stays under the Open Government Licence - Ontario unless otherwise specified; key requests are reviewed case by case and ask for the intended use and any third parties that will receive the data; attribution by link to 511on.ca is encouraged, and the Ontario 511 logo may not be altered or used to imply endorsement (this app links and shows no logo); and the data is provided "as is", a disclaimer distributors should pass on, which the credit does. @@ -686,4 +688,4 @@ NOAA density imagery with the raw GLM product. ## In-app attribution -The required Google Maps / Cesium credit renders on the on-globe credit line (`#cesium-credits`, bottom-left) and must stay visible — including in clean-view and recording modes (the whole line, logo + "Google Maps" + the "Data attribution" link, stays on screen; only the GEV panels/HUD fade). The layer-specific credits (adsb.lol, adsbdb, Planespotters.net, TeleGeography, OSM datacenters/dams/roads, NASA FIRMS, CelesTrak, USGS, City of Austin, Fintraffic, GBFS, Radio Browser, OpenSky, AISStream) are registered into the expandable **"Data attribution"** popover on that credit line via `viewer.creditDisplay.addStaticCredit(new Cesium.Credit(html, /* showOnScreen */ false))` — see `src/data/dataCredits.js`. When you add a new data source, add its license and attribution to this file **and** append an entry to `DATA_CREDITS` in `src/data/dataCredits.js` so it surfaces in the app. +The required Google Maps / Cesium credit renders on the on-globe credit line (`#cesium-credits`, bottom-left) and must stay visible — including in clean-view and recording modes (the whole line, logo + "Google Maps" + the "Data attribution" link, stays on screen; only the GEV panels/HUD fade). The layer-specific credits (adsb.lol, adsbdb, Planespotters.net, Open Vessel Photos, TeleGeography, OSM datacenters/dams/roads, NASA FIRMS, CelesTrak, USGS, City of Austin, Fintraffic, GBFS, Radio Browser, OpenSky, AISStream) are registered into the expandable **"Data attribution"** popover on that credit line via `viewer.creditDisplay.addStaticCredit(new Cesium.Credit(html, /* showOnScreen */ false))` — see `src/data/dataCredits.js`. When you add a new data source, add its license and attribution to this file **and** append an entry to `DATA_CREDITS` in `src/data/dataCredits.js` so it surfaces in the app. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 02c1996fb..d62662e29 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3199,6 +3199,15 @@ stays in view or tracked; an answer, found or not, and a permanent refusal stay final for the session. A route belongs to the callsign it was looked up for, so a callsign change drops the old route. See `DATA_SOURCES.md`. +A selected vessel gets a photo the same way. The browser asks Open Vessel +Photos for it by MMSI (`fetchOpenVesselPhoto` in +`src/sources/live/standalone.js`, read by `src/data/openVesselPhoto.js`), once +per MMSI (`src/layers/vessels/photos.js`), and the CONTACTS view shows it under +the vessel's name and MMSI ("NAME · MMSI 366999709") with the photographer, +the photo's open licence and a link to the vessel's page. A vessel without an +MMSI is never looked up; a miss stays final for the session, and a failed +lookup is asked again after five minutes. + `gods-eye-view/server/providers/live` is a Node-only entry for the existing plugins and shared request helpers. Importing it starts no sockets or timers. The existing aircraft normalizer is separately available through the portable diff --git a/scripts/format-scope.json b/scripts/format-scope.json index b49781778..b580e5193 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -385,6 +385,8 @@ "src/layers/vessels/index.js", "src/layers/vessels/ingestion.js", "src/layers/vessels/lifecycle.js", + "src/layers/vessels/photos.js", + "src/layers/vessels/photos.test.mjs", "src/layers/vessels/policy.js", "src/layers/vessels/queries.js", "src/layers/vessels/rendering.js", @@ -1335,6 +1337,8 @@ "src/app/sourceComposition.js", "src/app/sourceComposition.test.mjs", "src/data/dataCredits.js", + "src/data/openVesselPhoto.js", + "src/data/openVesselPhoto.test.mjs", "src/data/layerState.js", "src/data/layerState.test.mjs", "src/data/spriteOrder.js", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 873f37f80..ae86026d5 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1474,7 +1474,8 @@ "src/sources/live/index.js", "src/sources/live/standalone.js", "src/sources/live/vessels.js", - "src/data/planespottersPhoto.js" + "src/data/planespottersPhoto.js", + "src/data/openVesselPhoto.js" ], "external": [] }, @@ -1696,7 +1697,9 @@ "src/i18n/locales/sv.json", "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-Hant.json" + "src/i18n/locales/zh-Hant.json", + "src/layers/vessels/photos.js", + "src/data/openVesselPhoto.js" ], "external": ["cesium"] }, @@ -3757,7 +3760,9 @@ "src/data/ucsSatellites.js", "src/data/imagingSensors.js", "src/data/planespottersPhoto.js", - "src/ui/stores/dataLibrary.js" + "src/ui/stores/dataLibrary.js", + "src/data/openVesselPhoto.js", + "src/layers/vessels/photos.js" ], "external": [ "hls.js", @@ -4467,7 +4472,9 @@ "src/layers/satellites/sensors.js", "src/data/ucsSatellites.js", "src/data/imagingSensors.js", - "src/data/planespottersPhoto.js" + "src/data/planespottersPhoto.js", + "src/layers/vessels/photos.js", + "src/data/openVesselPhoto.js" ], "external": [ "hls.js", @@ -5719,7 +5726,8 @@ "src/layers/satellites/sensors.js", "src/data/ucsSatellites.js", "src/data/imagingSensors.js", - "src/data/planespottersPhoto.js" + "src/data/planespottersPhoto.js", + "src/data/openVesselPhoto.js" ], "external": ["@mapbox/vector-tile", "pbf"] }, diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index a452fa0cf..c9281fd4a 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -141,6 +141,13 @@ export const DATA_CREDITS = [ 'Planespotters.net ' + '· each photo is © its photographer, credited beside it', }, + { + key: 'openvesselphotos', + html: + 'Vessel photos: ' + + 'Open Vessel Photos ' + + "· each photo keeps its author's open licence, credited beside it", + }, { key: 'aisstream', html: diff --git a/src/data/dataCredits.test.mjs b/src/data/dataCredits.test.mjs index 8e7179338..3c9fb0fee 100644 --- a/src/data/dataCredits.test.mjs +++ b/src/data/dataCredits.test.mjs @@ -51,6 +51,13 @@ test('Planespotters is credited with a plain followable link', () => { assert.doesNotMatch(credit.html, /nofollow/); }); +test('Open Vessel Photos is credited with a plain followable link', () => { + const credit = DATA_CREDITS.find((entry) => entry.key === 'openvesselphotos'); + assert.ok(credit, 'the selected vessel photos come from Open Vessel Photos'); + assert.match(credit.html, /href="https:\/\/openvesselphotos\.org"/); + assert.doesNotMatch(credit.html, /nofollow/); +}); + test('the UCS Satellite Database is acknowledged with its version and webpage', () => { // UCS database manual, section 3: references name the version (the Excel // file's name) and link www.ucsusa.org/satellite_database. diff --git a/src/data/openVesselPhoto.js b/src/data/openVesselPhoto.js new file mode 100644 index 000000000..d0749b1aa --- /dev/null +++ b/src/data/openVesselPhoto.js @@ -0,0 +1,135 @@ +/** + * @module openVesselPhoto + * @description Reads an Open Vessel Photos answer into the one photo every + * surface shows. Open Vessel Photos (https://openvesselphotos.org) serves a + * free, keyless JSON and image API by MMSI. Every photo keeps the open licence + * its author chose (CC BY, CC BY-SA, CC0 or public domain), so it is shown + * with its credit and licence and links back to the vessel's page. A photo + * whose image is not on Open Vessel Photos' own host, or whose answer names + * no licence, is "no photo": nothing arbitrary is ever shown or linked. + * + * Accepts the upstream answer (`GET https://openvesselphotos.org/v1/mmsi/{mmsi}`, + * `{mmsi, photos: [...]}`) and an already-read photo + * (`{found, src, width, height, photographer, licence, link}`). + * Pure: no fetch, no DOM. + */ + +/** The only host a photo may come from or link to. */ +const HOST = 'openvesselphotos.org'; +/** The API's per-vessel answer. */ +const API = `https://${HOST}/v1/mmsi/`; +/** The image width asked for: sharp in the panel on a 2× screen, ~60 KB. */ +const IMAGE_SIZE = '800'; + +const NO_PHOTO = Object.freeze({ found: false }); + +/** A vessel's MMSI: nine digits. */ +export const MMSI = /^\d{9}$/; + +/** The API answer for one MMSI. */ +export function openVesselPhotosUrl(mmsi) { + return API + mmsi; +} + +/** The vessel's page on Open Vessel Photos. */ +function vesselPage(mmsi) { + return `https://${HOST}/vessels/mmsi/${mmsi}`; +} + +/** The parsed `https:` URL on the Open Vessel Photos host, or null. */ +function ownUrl(value) { + if (typeof value !== 'string' || !value) return null; + let url; + try { + url = new URL(value); + } catch { + return null; + } + return url.protocol === 'https:' && + !url.username && + !url.password && + !url.port && + url.hostname === HOST + ? url + : null; +} + +function dimension(value) { + const number = Number(value); + return value != null && Number.isFinite(number) && number > 0 + ? Math.round(number) + : null; +} + +function text(value) { + return typeof value === 'string' ? value.trim() : ''; +} + +/** One upstream photo of the vessel `mmsi`, checked. */ +function checkedUpstream(photo, mmsi) { + if (!photo || typeof photo !== 'object') return null; + const image = ownUrl(photo.urls?.mmsi); + const licence = text(photo.licenceName) || text(photo.licence); + if (!image || !licence) return null; + image.search = ''; + image.hash = ''; + image.searchParams.set('size', IMAGE_SIZE); + // The answer gives the full image's size; the 800 px one keeps its shape. + const width = dimension(photo.width); + const height = dimension(photo.height); + const scale = + width && width > Number(IMAGE_SIZE) ? Number(IMAGE_SIZE) / width : 1; + return { + found: true, + src: image.href, + width: width ? Math.round(width * scale) : null, + height: height ? Math.round(height * scale) : null, + photographer: text(photo.credit), + licence, + link: vesselPage(mmsi), + }; +} + +/** An already-read photo, checked again. */ +function checkedRead(photo) { + const image = ownUrl(photo.src); + const page = ownUrl(photo.link); + const licence = text(photo.licence); + if (!image || !page || !licence) return null; + return { + found: true, + src: image.href, + width: dimension(photo.width), + height: dimension(photo.height), + photographer: text(photo.photographer), + licence, + link: page.href, + }; +} + +/** + * Read an Open Vessel Photos answer into the first photo that passes the + * checks. + * @param {*} payload Upstream answer or already-read photo. + * @param {?string} [requestedMmsi] The MMSI asked for; an upstream answer + * naming another is no photo. + * @returns {{found: true, src: string, width: ?number, height: ?number, + * photographer: string, licence: string, link: string} | {found: false}} + */ +export function parseOpenVesselPhoto(payload, requestedMmsi = null) { + if (!payload || typeof payload !== 'object') return NO_PHOTO; + if (Array.isArray(payload.photos)) { + const mmsi = String(payload.mmsi ?? ''); + if (!MMSI.test(mmsi)) return NO_PHOTO; + // An answer about another vessel is no photo of this one. + if (requestedMmsi != null && mmsi !== String(requestedMmsi)) + return NO_PHOTO; + for (const photo of payload.photos) { + const checked = checkedUpstream(photo, mmsi); + if (checked) return checked; + } + return NO_PHOTO; + } + if (payload.found !== true) return NO_PHOTO; + return checkedRead(payload) || NO_PHOTO; +} diff --git a/src/data/openVesselPhoto.test.mjs b/src/data/openVesselPhoto.test.mjs new file mode 100644 index 000000000..e43fe397c --- /dev/null +++ b/src/data/openVesselPhoto.test.mjs @@ -0,0 +1,120 @@ +// src/data/openVesselPhoto.test.mjs — an Open Vessel Photos answer is read +// into one credited, licensed photo, and nothing outside Open Vessel Photos' +// own host is ever shown or linked. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + openVesselPhotosUrl, + parseOpenVesselPhoto, +} from './openVesselPhoto.js'; + +const UPSTREAM = Object.freeze({ + mmsi: '366999709', + name: '01 USCGC Sockeye WPB87337', + photos: [ + { + n: 1, + urls: { + cfr: null, + mmsi: 'https://openvesselphotos.org/v1/mmsi/366999709/photos/1.jpg', + }, + credit: 'Gordon Leggett', + licence: 'CC-BY-4.0', + licenceName: 'CC BY 4.0', + width: 1600, + height: 1200, + }, + ], +}); + +const PHOTO = Object.freeze({ + found: true, + src: 'https://openvesselphotos.org/v1/mmsi/366999709/photos/1.jpg?size=800', + width: 800, + height: 600, + photographer: 'Gordon Leggett', + licence: 'CC BY 4.0', + link: 'https://openvesselphotos.org/vessels/mmsi/366999709', +}); + +const withPhoto = (overrides) => ({ + ...UPSTREAM, + photos: [{ ...UPSTREAM.photos[0], ...overrides }], +}); + +test('the upstream answer maps to the 800 px image, its credit, licence and page', () => { + assert.deepEqual(parseOpenVesselPhoto(UPSTREAM), PHOTO); + assert.equal( + openVesselPhotosUrl('366999709'), + 'https://openvesselphotos.org/v1/mmsi/366999709', + ); +}); + +test('an image narrower than 800 px keeps its own size', () => { + const photo = parseOpenVesselPhoto(withPhoto({ width: 640, height: 480 })); + assert.equal(photo.width, 640); + assert.equal(photo.height, 480); +}); + +test('an already-read photo passes through its checks again', () => { + assert.deepEqual(parseOpenVesselPhoto(PHOTO), PHOTO); + assert.deepEqual(parseOpenVesselPhoto({ ...PHOTO, licence: '' }), { + found: false, + }); +}); + +test('a photo off the Open Vessel Photos host, or without a licence, is no photo', () => { + for (const overrides of [ + { urls: { mmsi: 'https://example.com/1.jpg' } }, + { + urls: { + mmsi: 'http://openvesselphotos.org/v1/mmsi/366999709/photos/1.jpg', + }, + }, + { urls: { mmsi: 'https://openvesselphotos.org.evil.com/1.jpg' } }, + { urls: { mmsi: 'https://user@openvesselphotos.org/1.jpg' } }, + { licence: '', licenceName: '' }, + ]) + assert.deepEqual(parseOpenVesselPhoto(withPhoto(overrides)), { + found: false, + }); + assert.deepEqual( + parseOpenVesselPhoto({ ...PHOTO, link: 'https://example.com/' }), + { found: false }, + ); +}); + +test('a vessel without photos, or an answer with a bad MMSI, is no photo', () => { + assert.deepEqual(parseOpenVesselPhoto({ ...UPSTREAM, photos: [] }), { + found: false, + }); + assert.deepEqual(parseOpenVesselPhoto({ ...UPSTREAM, mmsi: '12/../x' }), { + found: false, + }); + assert.deepEqual(parseOpenVesselPhoto(null), { found: false }); +}); + +test('the next photo stands in when the first fails its checks', () => { + const second = { + ...UPSTREAM.photos[0], + n: 2, + urls: { + mmsi: 'https://openvesselphotos.org/v1/mmsi/366999709/photos/2.jpg', + }, + }; + const photo = parseOpenVesselPhoto({ + ...UPSTREAM, + photos: [{ ...UPSTREAM.photos[0], licenceName: '', licence: '' }, second], + }); + assert.equal( + photo.src, + 'https://openvesselphotos.org/v1/mmsi/366999709/photos/2.jpg?size=800', + ); +}); + +test('an answer about another vessel than the one asked for is no photo', () => { + assert.deepEqual(parseOpenVesselPhoto(UPSTREAM, '366999709'), PHOTO); + assert.deepEqual(parseOpenVesselPhoto(UPSTREAM, '227000101'), { + found: false, + }); +}); diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index 5b0da6060..e5b833615 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -3804,6 +3804,8 @@ "(+{minutes} min late)": "(تأخير {minutes} د)", "Exit cockpit to fly to a train": "اخرج من القمرة للانتقال إلى قطار", "Photo © {photographer}": "صورة © {photographer}", + "Photo: {photographer} · {licence}": "صورة: {photographer} · {licence}", + "Photo · {licence}": "صورة · {licence}", "The shared flight isn't reporting now — showing where it was last seen": "الرحلة المشتركة: لا بيانات واردة الآن — يُعرض آخر موضع معروف", "The shared flight isn't reporting now — showing where it was at {time}": "الرحلة المشتركة: لا بيانات واردة الآن — يُعرض موضعها عند {time}", "The shared military flight isn't reporting now — showing where it was last seen": "الرحلة العسكرية المشتركة: لا بيانات واردة الآن — يُعرض آخر موضع معروف", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 55b9301f9..060244aa6 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -3480,6 +3480,8 @@ "(+{minutes} min late)": "(+{minutes} min Verspätung)", "Exit cockpit to fly to a train": "Cockpit verlassen, um zu einem Zug zu fliegen", "Photo © {photographer}": "Foto © {photographer}", + "Photo: {photographer} · {licence}": "Foto: {photographer} · {licence}", + "Photo · {licence}": "Foto · {licence}", "The shared flight isn't reporting now — showing where it was last seen": "Der geteilte Flug sendet gerade nicht – letzte bekannte Position wird gezeigt", "The shared flight isn't reporting now — showing where it was at {time}": "Der geteilte Flug sendet gerade nicht – Position um {time} wird gezeigt", "The shared military flight isn't reporting now — showing where it was last seen": "Der geteilte Militärflug sendet gerade nicht – letzte bekannte Position wird gezeigt", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 3f7a5e7b0..dda1633aa 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -3561,6 +3561,8 @@ "(+{minutes} min late)": "(+{minutes} min de retraso)", "Exit cockpit to fly to a train": "Sal de la cabina para volar a un tren", "Photo © {photographer}": "Foto © {photographer}", + "Photo: {photographer} · {licence}": "Foto: {photographer} · {licence}", + "Photo · {licence}": "Foto · {licence}", "The shared flight isn't reporting now — showing where it was last seen": "El vuelo compartido no está transmitiendo ahora; se muestra dónde se vio por última vez", "The shared flight isn't reporting now — showing where it was at {time}": "El vuelo compartido no está transmitiendo ahora; se muestra dónde estaba a las {time}", "The shared military flight isn't reporting now — showing where it was last seen": "El vuelo militar compartido no está transmitiendo ahora; se muestra dónde se vio por última vez", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 870d75390..161ce716a 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -3561,6 +3561,8 @@ "(+{minutes} min late)": "(+{minutes} min de retard)", "Exit cockpit to fly to a train": "Quittez le cockpit pour rejoindre un train", "Photo © {photographer}": "Photo © {photographer}", + "Photo: {photographer} · {licence}": "Photo : {photographer} · {licence}", + "Photo · {licence}": "Photo · {licence}", "The shared flight isn't reporting now — showing where it was last seen": "Le vol partagé n'émet pas actuellement — affichage de sa dernière position connue", "The shared flight isn't reporting now — showing where it was at {time}": "Le vol partagé n'émet pas actuellement — affichage de sa position à {time}", "The shared military flight isn't reporting now — showing where it was last seen": "Le vol militaire partagé n'émet pas actuellement — affichage de sa dernière position connue", diff --git a/src/i18n/locales/he.json b/src/i18n/locales/he.json index b0bd1eac2..1e6066ab9 100644 --- a/src/i18n/locales/he.json +++ b/src/i18n/locales/he.json @@ -3385,6 +3385,8 @@ "(+{minutes} min late)": "(+{minutes} דק׳ איחור)", "Exit cockpit to fly to a train": "צא מתא הטייס כדי לטוס לרכבת", "Photo © {photographer}": "צילום © {photographer}", + "Photo: {photographer} · {licence}": "צילום: {photographer} · {licence}", + "Photo · {licence}": "צילום · {licence}", "1 h/s": "שעה/שנ׳", "1 d/s": "יום/שנ׳", "1 wk/s": "שבוע/שנ׳", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 9a840d7c1..2864e1d17 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -3561,6 +3561,8 @@ "(+{minutes} min late)": "(+{minutes} min di ritardo)", "Exit cockpit to fly to a train": "Esci dal cockpit per volare a un treno", "Photo © {photographer}": "Foto © {photographer}", + "Photo: {photographer} · {licence}": "Foto: {photographer} · {licence}", + "Photo · {licence}": "Foto · {licence}", "The shared flight isn't reporting now — showing where it was last seen": "Il volo condiviso non sta trasmettendo ora: viene mostrata l'ultima posizione nota", "The shared flight isn't reporting now — showing where it was at {time}": "Il volo condiviso non sta trasmettendo ora: viene mostrata la posizione alle {time}", "The shared military flight isn't reporting now — showing where it was last seen": "Il volo militare condiviso non sta trasmettendo ora: viene mostrata l'ultima posizione nota", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 5dc71c358..20a65fcb4 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -3399,6 +3399,8 @@ "(+{minutes} min late)": "({minutes}分遅れ)", "Exit cockpit to fly to a train": "列車へ移動するにはコックピットを終了してください", "Photo © {photographer}": "写真 © {photographer}", + "Photo: {photographer} · {licence}": "写真:{photographer} · {licence}", + "Photo · {licence}": "写真 · {licence}", "The shared flight isn't reporting now — showing where it was last seen": "共有されたフライトは現在報告していません。最後に確認された位置を表示しています", "The shared flight isn't reporting now — showing where it was at {time}": "共有されたフライトは現在報告していません。{time}時点の位置を表示しています", "The shared military flight isn't reporting now — showing where it was last seen": "共有された軍用機は現在報告していません。最後に確認された位置を表示しています", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index cb34c1594..8b1e5e7ae 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -3561,6 +3561,8 @@ "(+{minutes} min late)": "(+{minutes} min de atraso)", "Exit cockpit to fly to a train": "Saia da cabine para voar até um trem", "Photo © {photographer}": "Foto © {photographer}", + "Photo: {photographer} · {licence}": "Foto: {photographer} · {licence}", + "Photo · {licence}": "Foto · {licence}", "The shared flight isn't reporting now — showing where it was last seen": "O voo compartilhado não está transmitindo agora — mostrando a última posição conhecida", "The shared flight isn't reporting now — showing where it was at {time}": "O voo compartilhado não está transmitindo agora — mostrando onde estava às {time}", "The shared military flight isn't reporting now — showing where it was last seen": "O voo militar compartilhado não está transmitindo agora — mostrando a última posição conhecida", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index de517a550..6a366e4b8 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3642,6 +3642,8 @@ "(+{minutes} min late)": "(+{minutes} мин опоздания)", "Exit cockpit to fly to a train": "Выйдите из кабины, чтобы перелететь к поезду", "Photo © {photographer}": "Фото © {photographer}", + "Photo: {photographer} · {licence}": "Фото: {photographer} · {licence}", + "Photo · {licence}": "Фото · {licence}", "The shared flight isn't reporting now — showing where it was last seen": "Общий рейс сейчас не передаёт данные — показано последнее известное место", "The shared flight isn't reporting now — showing where it was at {time}": "Общий рейс сейчас не передаёт данные — показано место в {time}", "The shared military flight isn't reporting now — showing where it was last seen": "Общий военный рейс сейчас не передаёт данные — показано последнее известное место", diff --git a/src/i18n/locales/sv.json b/src/i18n/locales/sv.json index 3d4c93d9b..6aa87a8d4 100644 --- a/src/i18n/locales/sv.json +++ b/src/i18n/locales/sv.json @@ -3472,6 +3472,8 @@ "(+{minutes} min late)": "(+{minutes} min försenad)", "Exit cockpit to fly to a train": "Lämna cockpiten för att flyga till ett tåg", "Photo © {photographer}": "Foto © {photographer}", + "Photo: {photographer} · {licence}": "Foto: {photographer} · {licence}", + "Photo · {licence}": "Foto · {licence}", "The shared flight isn't reporting now — showing where it was last seen": "Den delade flygningen rapporterar inte just nu – visar senast kända position", "The shared flight isn't reporting now — showing where it was at {time}": "Den delade flygningen rapporterar inte just nu – visar positionen från {time}", "The shared military flight isn't reporting now — showing where it was last seen": "Den delade militärflygningen rapporterar inte just nu – visar senast kända position", diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index f64f6e8e0..acb9e92a8 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -3642,6 +3642,8 @@ "(+{minutes} min late)": "(+{minutes} хв запізнення)", "Exit cockpit to fly to a train": "Вийдіть із кабіни, щоб перелетіти до поїзда", "Photo © {photographer}": "Фото © {photographer}", + "Photo: {photographer} · {licence}": "Фото: {photographer} · {licence}", + "Photo · {licence}": "Фото · {licence}", "The shared flight isn't reporting now — showing where it was last seen": "Спільний рейс зараз не передає даних — показано останнє відоме місце", "The shared flight isn't reporting now — showing where it was at {time}": "Спільний рейс зараз не передає даних — показано місце о {time}", "The shared military flight isn't reporting now — showing where it was last seen": "Спільний військовий рейс зараз не передає даних — показано останнє відоме місце", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index da34f1c26..86ecd4f40 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -3399,6 +3399,8 @@ "(+{minutes} min late)": "(晚点 {minutes} 分钟)", "Exit cockpit to fly to a train": "请先退出座舱,再飞往列车", "Photo © {photographer}": "照片 © {photographer}", + "Photo: {photographer} · {licence}": "照片:{photographer} · {licence}", + "Photo · {licence}": "照片 · {licence}", "The shared flight isn't reporting now — showing where it was last seen": "共享的航班目前没有发送信号——显示其最后出现的位置", "The shared flight isn't reporting now — showing where it was at {time}": "共享的航班目前没有发送信号——显示其在 {time} 的位置", "The shared military flight isn't reporting now — showing where it was last seen": "共享的军用航班目前没有发送信号——显示其最后出现的位置", diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index 52e6b2033..6f7bc4801 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -3399,6 +3399,8 @@ "(+{minutes} min late)": "(誤點 {minutes} 分鐘)", "Exit cockpit to fly to a train": "請先退出座艙,再飛往列車", "Photo © {photographer}": "照片 © {photographer}", + "Photo: {photographer} · {licence}": "照片:{photographer} · {licence}", + "Photo · {licence}": "照片 · {licence}", "The shared flight isn't reporting now — showing where it was last seen": "共享的航班目前沒有發送信號——顯示其最後出現的位置", "The shared flight isn't reporting now — showing where it was at {time}": "共享的航班目前沒有發送信號——顯示其在 {time} 的位置", "The shared military flight isn't reporting now — showing where it was last seen": "共享的軍用航班目前沒有發送信號——顯示其最後出現的位置", diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index b90685e68..e9735a668 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -625,6 +625,7 @@ "▲ {frp} MW", "SATELLITE", "Photo © {photographer}", + "Photo · {licence}", "PACK", "{count} packs", "Orthophoto", diff --git a/src/layers/awareness/panel.js b/src/layers/awareness/panel.js index bd5078499..9d9bf75af 100644 --- a/src/layers/awareness/panel.js +++ b/src/layers/awareness/panel.js @@ -182,7 +182,22 @@ export function createPanel({ state: layerState, parts }) { : null; } - /** The tracked airliner's photo with its credit, or null. The subject + /** The credit line: the photographer, and an open photo's licence. A + * photo whose answer names no photographer shows no photographer. */ + function photoCredit(photo) { + if (photo.licence) + return photo.photographer + ? t('Photo: {photographer} · {licence}', { + photographer: photo.photographer, + licence: photo.licence, + }) + : t('Photo · {licence}', { licence: photo.licence }); + return photo.photographer + ? t('Photo © {photographer}', { photographer: photo.photographer }) + : ''; + } + + /** The subject's photo with its credit, or null. The subject * refresh resolves it; only the subject it was resolved for shows it. */ function presentPhoto(subject) { const held = layerState.subjectPhoto; @@ -198,10 +213,7 @@ export function createPanel({ state: layerState, parts }) { width: photo.width ?? null, height: photo.height ?? null, link: photo.link, - // A photo whose answer names no photographer shows no credit line. - credit: photo.photographer - ? t('Photo © {photographer}', { photographer: photo.photographer }) - : '', + credit: photoCredit(photo), }; } diff --git a/src/layers/awareness/panel.test.mjs b/src/layers/awareness/panel.test.mjs index ec361080d..7d21f8fa4 100644 --- a/src/layers/awareness/panel.test.mjs +++ b/src/layers/awareness/panel.test.mjs @@ -113,7 +113,52 @@ test('the Contacts view has no photo without one, or with another subject’s', ); }); -test('only the tracked flight has a photo: not a vessel, an installation, a military aircraft or another flight', () => { +test('the selected vessel shows its own photo, by its id', () => { + const asked = []; + const { resolveSubjectPhoto, subjectFromContext } = createSubject({ + state: {}, + services: { + flights: {}, + military: {}, + vessels: { + getVesselPhoto: (id) => { + asked.push(id); + return id === 'uuid-1' ? PHOTO : null; + }, + }, + installations: {}, + }, + parts: { navigation: { isFlightLayer: () => false } }, + }); + assert.deepEqual( + resolveSubjectPhoto({ layerId: 'ais-live-vessels', id: 'uuid-1' }), + PHOTO, + ); + assert.equal( + resolveSubjectPhoto({ layerId: 'ais-live-vessels', id: 'uuid-2' }), + null, + ); + assert.deepEqual(asked, ['uuid-1', 'uuid-2']); + const vessel = (label, mmsi) => + subjectFromContext({ + layerId: 'ais-live-vessels', + id: 'ais-uuid-1', + label, + latitude: 38.3, + longitude: -123.05, + properties: { vesselId: 'uuid-1', mmsi }, + }); + assert.equal( + vessel('USCGC SOCKEYE', '366999709').label, + 'USCGC SOCKEYE · MMSI 366999709', + ); + assert.equal(vessel('USCGC SOCKEYE', '366999709').id, 'uuid-1'); + // A vessel named by its MMSI, or without one, is not given it twice. + assert.equal(vessel('MMSI 366999709', '366999709').label, 'MMSI 366999709'); + assert.equal(vessel('USCGC SOCKEYE', null).label, 'USCGC SOCKEYE'); +}); + +test('only the tracked flight has a flight photo: not an installation, a military aircraft or another flight', () => { const tracked = { layerId: 'flights', id: 'abc123', diff --git a/src/layers/awareness/subject.js b/src/layers/awareness/subject.js index e8bb26a8c..e60da476e 100644 --- a/src/layers/awareness/subject.js +++ b/src/layers/awareness/subject.js @@ -406,17 +406,20 @@ export function createSubject({ state: layerState, services, parts, source }) { } /** - * The tracked airliner's Planespotters photo, for the Contacts panel. + * The subject's photo, for the Contacts panel: the tracked airliner's + * Planespotters photo, or the selected vessel's Open Vessel Photos photo. * - * Only the flights layer looks photos up, and only for the plane it tracks; - * a vessel, an installation, a military aircraft or an untracked flight has - * none. + * The flights layer looks photos up only for the plane it tracks, the + * vessel layer only for the vessel selected; an installation, a military + * aircraft or an untracked flight has none. * @param {{layerId: string, id: string}|null} subject Current subject. * @param {?object} [tracked] The subject's tracked descriptor, when the * caller already has it. - * @returns {?{src: string, width: ?number, height: ?number, photographer: string, link: string}} + * @returns {?{src: string, width: ?number, height: ?number, photographer: string, link: string, licence?: string}} */ function resolveSubjectPhoto(subject, tracked = trackedSubjectOf(subject)) { + if (subject?.layerId === 'ais-live-vessels') + return aisLiveVesselsLayer.getVesselPhoto?.(subject.id) ?? null; return subject?.layerId === 'flights' && tracked?.photo ? tracked.photo : null; @@ -517,10 +520,15 @@ export function createSubject({ state: layerState, services, parts, source }) { const latitude = Number(record.latitude); const longitude = Number(record.longitude); if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null; + // A vessel reads as its name and MMSI; a vessel named by its MMSI alone + // already carries it. + const label = String(record.label || record.id); + const mmsi = String(record.properties?.mmsi || ''); return { layerId: record.layerId, id: record.properties?.vesselId || record.id, - label: record.label || record.id, + // i18n-ignore: MMSI is a radio identity code, as on the vessel card + label: mmsi && !label.includes(mmsi) ? `${label} · MMSI ${mmsi}` : label, memberNames: record.properties?.memberNames || [], position: Cesium.Cartesian3.fromDegrees(longitude, latitude, 0), }; diff --git a/src/layers/vessels/index.js b/src/layers/vessels/index.js index bd8282e84..c6d1429dc 100644 --- a/src/layers/vessels/index.js +++ b/src/layers/vessels/index.js @@ -7,6 +7,7 @@ import { createRendering } from './rendering.js'; import { createSelection } from './selection.js'; import { createTracking } from './tracking.js'; import { createCards } from './cards.js'; +import { createPhotos } from './photos.js'; import { createTesting } from './testing.js'; import { createEvidence } from './evidence.js'; import { createQueries } from './queries.js'; @@ -24,6 +25,7 @@ export function createVesselLayer({ source, services, options = {} } = {}) { parts.selection = createSelection(context); parts.tracking = createTracking(context); parts.cards = createCards(context); + parts.photos = createPhotos(context); parts.testing = createTesting(context); parts.evidence = createEvidence(context); parts.queries = createQueries(context); @@ -90,6 +92,9 @@ export function createVesselLayer({ source, services, options = {} } = {}) { buildVesselCard: parts.cards.buildVesselCard, buildSelectedVesselCard: parts.cards.buildSelectedVesselCard, cardScreenSeparated: parts.cards.cardScreenSeparated, + /** The vessel's Open Vessel Photos photo, once its lookup answered. */ + getVesselPhoto: (id) => + parts.photos.vesselPhoto(vesselState.state.records.byId.get(id)), }); Object.defineProperty(layer, 'testing', { value: parts.testing }); return layer; diff --git a/src/layers/vessels/photos.js b/src/layers/vessels/photos.js new file mode 100644 index 000000000..6a29b19a9 --- /dev/null +++ b/src/layers/vessels/photos.js @@ -0,0 +1,56 @@ +import { MMSI, parseOpenVesselPhoto } from '../../data/openVesselPhoto.js'; + +/** A failed lookup is asked again after this long; a miss is final. */ +const PHOTO_RETRY_MS = 5 * 60 * 1000; +/** Lookups remembered per session, oldest dropped first. */ +const PHOTO_CACHE_LIMIT = 500; + +/** + * The selected vessel's photo, looked up once per MMSI when it is selected. + * The answer is read with `parseOpenVesselPhoto` whatever shape the source + * returns, so only a checked photo is ever kept. A source without + * `getEnrichment` has no photos. + */ +export function createPhotos({ vesselState }) { + /** MMSI → photo, `null` for a final miss, or `{failedAtMs}`/`{pending}`. */ + const answers = new Map(); + + function remember(mmsi, answer) { + answers.delete(mmsi); + answers.set(mmsi, answer); + if (answers.size > PHOTO_CACHE_LIMIT) + answers.delete(answers.keys().next().value); + } + + /** Ask for the record's photo unless it is known, asked or cooling down. */ + function requestVesselPhoto(record, now = Date.now()) { + const mmsi = String(record?.mmsi || ''); + const source = vesselState._source; + if (!MMSI.test(mmsi) || typeof source?.getEnrichment !== 'function') return; + const known = answers.get(mmsi); + if (known !== undefined) { + if (known?.pending || known === null || known.found) return; + if (now - known.failedAtMs < PHOTO_RETRY_MS) return; + } + remember(mmsi, { pending: true }); + Promise.resolve() + .then(() => source.getEnrichment({ kind: 'photo', id: mmsi })) + .then( + (answer) => { + const photo = parseOpenVesselPhoto(answer); + remember(mmsi, photo.found ? photo : null); + }, + () => remember(mmsi, { failedAtMs: Date.now() }), + ); + } + + /** The vessel's checked photo, or null. */ + function vesselPhoto(record) { + const answer = answers.get(String(record?.mmsi || '')); + if (!answer?.found) return null; + const { src, width, height, photographer, licence, link } = answer; + return { src, width, height, photographer, licence, link }; + } + + return { requestVesselPhoto, vesselPhoto }; +} diff --git a/src/layers/vessels/photos.test.mjs b/src/layers/vessels/photos.test.mjs new file mode 100644 index 000000000..2e1163853 --- /dev/null +++ b/src/layers/vessels/photos.test.mjs @@ -0,0 +1,87 @@ +// src/layers/vessels/photos.test.mjs — a selected vessel's photo is asked for +// once per MMSI; a miss is final and a failure is retried only after a pause. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createPhotos } from './photos.js'; + +const ANSWER = Object.freeze({ + mmsi: '366999709', + photos: [ + { + urls: { + mmsi: 'https://openvesselphotos.org/v1/mmsi/366999709/photos/1.jpg', + }, + credit: 'Gordon Leggett', + licenceName: 'CC BY 4.0', + width: 1600, + height: 1200, + }, + ], +}); + +const settle = () => new Promise((resolve) => setImmediate(resolve)); + +function fixture(answer) { + const asked = []; + const source = { + getSnapshot() {}, + async getEnrichment(query) { + asked.push(query); + return answer(query); + }, + }; + const photos = createPhotos({ vesselState: { _source: source } }); + return { photos, asked }; +} + +test('a selected vessel is asked once and shows its checked photo', async () => { + const { photos, asked } = fixture(() => ANSWER); + const record = { id: 'v1', mmsi: '366999709' }; + photos.requestVesselPhoto(record); + photos.requestVesselPhoto(record); + await settle(); + photos.requestVesselPhoto(record); + assert.deepEqual(asked, [{ kind: 'photo', id: '366999709' }]); + assert.deepEqual(photos.vesselPhoto(record), { + src: 'https://openvesselphotos.org/v1/mmsi/366999709/photos/1.jpg?size=800', + width: 800, + height: 600, + photographer: 'Gordon Leggett', + licence: 'CC BY 4.0', + link: 'https://openvesselphotos.org/vessels/mmsi/366999709', + }); +}); + +test('a vessel without a valid MMSI, or a source without lookups, is never asked', async () => { + const { photos, asked } = fixture(() => ANSWER); + photos.requestVesselPhoto({ id: 'v1', mmsi: '' }); + photos.requestVesselPhoto({ id: 'v2', mmsi: '12345' }); + await settle(); + assert.equal(asked.length, 0); + const bare = createPhotos({ vesselState: { _source: { getSnapshot() {} } } }); + bare.requestVesselPhoto({ id: 'v3', mmsi: '366999709' }); + assert.equal(bare.vesselPhoto({ mmsi: '366999709' }), null); +}); + +test('a miss is final; a failure is asked again only after the pause', async () => { + let fail = true; + const { photos, asked } = fixture(({ id }) => { + if (id === '111111111') return { mmsi: id, photos: [] }; + if (fail) throw new Error('down'); + return ANSWER; + }); + const missing = { mmsi: '111111111' }; + const flaky = { mmsi: '366999709' }; + photos.requestVesselPhoto(missing); + photos.requestVesselPhoto(flaky, 0); + await settle(); + photos.requestVesselPhoto(missing); + photos.requestVesselPhoto(flaky, Date.now() + 1000); + assert.equal(asked.length, 2); + fail = false; + photos.requestVesselPhoto(flaky, Date.now() + 10 * 60 * 1000); + await settle(); + assert.equal(asked.length, 3); + assert.equal(photos.vesselPhoto(missing), null); + assert.equal(photos.vesselPhoto(flaky).photographer, 'Gordon Leggett'); +}); diff --git a/src/layers/vessels/selection.js b/src/layers/vessels/selection.js index 99f3b674a..717a69408 100644 --- a/src/layers/vessels/selection.js +++ b/src/layers/vessels/selection.js @@ -186,6 +186,7 @@ export function createSelection({ // click, not up to VISIBILITY_UPDATE_MS later. components.rendering.updateVisibility(true); components.cards.updateSelectedVesselHud(record); + components.photos.requestVesselPhoto(record); if (registerSelectedContext(record)) { selectEntityContext(record); } diff --git a/src/sources/live/index.js b/src/sources/live/index.js index 102a4262c..dbd675c67 100644 --- a/src/sources/live/index.js +++ b/src/sources/live/index.js @@ -16,4 +16,5 @@ export { createFlightSource, createMilitarySource, createVesselSource, + fetchOpenVesselPhoto, } from './standalone.js'; diff --git a/src/sources/live/standalone.js b/src/sources/live/standalone.js index b244266b0..41e858a93 100644 --- a/src/sources/live/standalone.js +++ b/src/sources/live/standalone.js @@ -12,6 +12,11 @@ import { } from './aircraft.js'; import { normalizeVesselTrack, vesselSnapshot } from './vessels.js'; import { parsePlanespottersPhoto } from '../../data/planespottersPhoto.js'; +import { + MMSI, + openVesselPhotosUrl, + parseOpenVesselPhoto, +} from '../../data/openVesselPhoto.js'; const defaultFetch = (...args) => globalThis.fetch(...args); const header = (response, name) => response.headers?.get?.(name); @@ -42,6 +47,34 @@ async function planespottersPhoto(fetchImpl, hex, signal) { return parsePlanespottersPhoto(payload); } +/** + * A vessel's Open Vessel Photos photo, read and host-checked. The API is + * keyless and answers with `access-control-allow-origin: *`, so the browser + * asks it straight. An MMSI it does not know answers 404: no photo. + * @param {Function} fetchImpl fetch. + * @param {string} mmsi The vessel's nine-digit MMSI. + * @param {AbortSignal} [signal] Abort signal. + * @returns {Promise} A `parseOpenVesselPhoto` result. + */ +export async function fetchOpenVesselPhoto(fetchImpl, mmsi, signal) { + const id = String(mmsi || ''); + if (!MMSI.test(id)) + throw new LiveSourceError('unsupported', 'Enrichment unavailable'); + const timeout = AbortSignal.timeout(PHOTO_TIMEOUT_MS); + const { response, payload } = await readResponse( + fetchImpl, + openVesselPhotosUrl(id), + { + signal: signal ? AbortSignal.any([signal, timeout]) : timeout, + credentials: 'omit', + }, + 'Open Vessel Photos', + ); + if (response.status === 404) return parseOpenVesselPhoto(null); + if (!response.ok) throw httpError(response, 'Open Vessel Photos'); + return parseOpenVesselPhoto(payload, id); +} + function openSkyError(response) { const error = httpError(response, 'Flights'); const mode = String( @@ -235,6 +268,11 @@ export function createVesselSource({ status: response.status, }; }, + async getEnrichment(query, { signal } = {}) { + if (query.kind !== 'photo') + throw new LiveSourceError('unsupported', 'Enrichment unavailable'); + return fetchOpenVesselPhoto(fetchImpl, query.id, signal); + }, async getTrack(reference, { signal } = {}) { const { response, payload } = await readResponse( fetchImpl, From 23aa1027fc66c56e982f557db2fb7c7041d39ac5 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 12 Nov 2026 20:11:17 -0800 Subject: [PATCH 49/55] docs(changelog): 1693-cctv-frame-no-flash --- changelog.d/1693-cctv-frame-no-flash.md | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 changelog.d/1693-cctv-frame-no-flash.md diff --git a/changelog.d/1693-cctv-frame-no-flash.md b/changelog.d/1693-cctv-frame-no-flash.md new file mode 100644 index 000000000..363d28129 --- /dev/null +++ b/changelog.d/1693-cctv-frame-no-flash.md @@ -0,0 +1,2 @@ +- Show a new CCTV camera's frame the moment it is decoded, with no fade, so + switching cameras no longer lingers on an empty preview (samehkhamis, #1693). From d5b030e7fa404152ca1439925c0162ce03f8eb4a Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 12 Nov 2026 20:11:19 -0800 Subject: [PATCH 50/55] docs(changelog): 1694-cctv-globe-plane-clamp --- changelog.d/1694-cctv-globe-plane-clamp.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1694-cctv-globe-plane-clamp.md diff --git a/changelog.d/1694-cctv-globe-plane-clamp.md b/changelog.d/1694-cctv-globe-plane-clamp.md new file mode 100644 index 000000000..54adb8545 --- /dev/null +++ b/changelog.d/1694-cctv-globe-plane-clamp.md @@ -0,0 +1,3 @@ +- End a CCTV camera's monitor plane where its view meets the ground on globe + maps too, instead of a kilometre-wide plane hundreds of metres up + (samehkhamis, #1694). From 2bc3c1bed1cabae518bd3f2f0d3cecc2831ccc56 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 12 Nov 2026 20:11:20 -0800 Subject: [PATCH 51/55] docs(changelog): 1695-vessel-photos --- changelog.d/1695-vessel-photos.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1695-vessel-photos.md diff --git a/changelog.d/1695-vessel-photos.md b/changelog.d/1695-vessel-photos.md new file mode 100644 index 000000000..71d91c9db --- /dev/null +++ b/changelog.d/1695-vessel-photos.md @@ -0,0 +1,3 @@ +- Show a selected vessel's photo from Open Vessel Photos, credited with its + photographer and open licence, and its MMSI beside its name in the CONTACTS + view (samehkhamis, #1695). From 5f96009b678af9ebf002bf10391ead114ca3d035 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 12 Nov 2026 22:01:18 -0800 Subject: [PATCH 52/55] feat(i18n): an ./i18n entry for an application with its own language choice startPageLocale, followPageLocale and switchLocale start, follow and switch the page's language as the standalone application and the globe picker do, for an application that offers the language elsewhere and passes localePicker: false. The standalone application uses them. --- docs/APPLICATION.md | 4 +- docs/CURRENT-STATE.md | 4 ++ docs/I18N.md | 25 ++++++++ package.json | 1 + scripts/format-scope.json | 2 + scripts/package-boundaries.json | 22 +++++++ src/app/controls.js | 3 +- src/i18n/page.js | 81 ++++++++++++++++++++++++ src/i18n/page.test.mjs | 106 ++++++++++++++++++++++++++++++++ src/mapStartup.test.mjs | 7 +-- src/standalone/application.js | 19 ++---- src/ui/applicationShell.js | 4 +- 12 files changed, 255 insertions(+), 23 deletions(-) create mode 100644 src/i18n/page.js create mode 100644 src/i18n/page.test.mjs diff --git a/docs/APPLICATION.md b/docs/APPLICATION.md index d3ca6ffce..7c2206467 100644 --- a/docs/APPLICATION.md +++ b/docs/APPLICATION.md @@ -224,7 +224,9 @@ leaving out `command-dock` the voice control and its pointer reticle; leaving out `context` the Bhote Koshi reconstruction panel; and leaving out `intel-hud` the Intel HUD overlay (`#intel-hud`), whose readouts the HUD implementation publishes to the `gev-hud` store. An unknown -view name is an error. A host that does not initialize Core's locale passes +view name is an error. An application that offers its own language choice +starts and switches the language with the `./i18n` entry (`startPageLocale`, +`followPageLocale`, `switchLocale`; see [I18N.md](./I18N.md)) and passes `localePicker: false` to `createApplicationControls` instead of editing the markup. Node tests that import application modules register the component loader with `node --import=/build/register-vue`. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index f7f993b85..6073b5c84 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5080,6 +5080,10 @@ Shipped locales: **en** (source and fallback), **ar**, **de**, **es**, **fr**, * views re-render, code that published translated text words it again through `onLocaleChange`, and ``/`dir` and the page's `data-i18n` markup follow. Nothing is lost; a toast already on screen keeps its words. + An application that offers the language elsewhere (a menu) uses the + `./i18n` entry (`src/i18n/page.js`): `startPageLocale` at boot, + `followPageLocale` for the markup and `switchLocale`, which does what a + picker choice does, with `localePicker: false`. - **Right to left.** Arabic (`ar`) and Hebrew (`he`) are in `RTL_LOCALES`, so ``: the title, style indicator, both panel stacks and their icon rails swap sides through logical properties (`inset-inline-*`, diff --git a/docs/I18N.md b/docs/I18N.md index 0d57f14d5..bacfdc509 100644 --- a/docs/I18N.md +++ b/docs/I18N.md @@ -59,6 +59,30 @@ The language picker switches in place, without a reload. A view that calls `t()` A toast already on screen keeps its words. +## An application with its own language choice + +An application that composes Core and offers the language somewhere else (a menu, a settings page) imports the `./i18n` entry (`src/i18n/page.js`) and passes `localePicker: false` to `createApplicationControls`: + +```js +import { + LOCALES, + LOCALE_NAMES, + followPageLocale, + getLocale, + onLocaleChange, + startPageLocale, + switchLocale, +} from '/i18n'; + +startPageLocale(document); // before any view renders: ?lang=, stored choice, browser, English; translates data-i18n markup +const stopFollowing = followPageLocale(document); // /dir and data-i18n markup follow each switch + +for (const code of LOCALES) addMenuItem(LOCALE_NAMES[code], code === getLocale()); +menu.onChoose = (code) => switchLocale(code); // switched in place and stored (in ?lang when storage is blocked) +``` + +`switchLocale` does what a choice in the globe picker does, and returns `'unchanged'` for the active language. `onLocaleChange` tells the application's own menu to mark the new language. + ## Rules - **Pass literals.** `t('Close')`, not `t(label)` (unless `label` holds a `msg()` value) and never a template literal with `${…}`. @@ -109,6 +133,7 @@ Icons (`aria-hidden="true"`, Material Symbols), comments, CSS values and lowerca | `src/ui/i18n.js` | the page's vue-i18n instance: the active locale and the catalogs | | `src/i18n/marker.js` | `msg` alone, for modules the server or tools import (no catalogs) | | `src/i18n/locale.js` | shipped languages, their names, choosing the language at boot | +| `src/i18n/page.js` | the `./i18n` entry: start, follow and switch the page's language | | `src/i18n/locales/.json` | one catalog per language, keyed by English text | | `src/i18n/same-as-english.json` | per language, the entries that stay as in English on purpose | | `scripts/i18n.mjs` | `npm run i18n`: extracts messages, checks the catalogs, finds unmarked UI text | diff --git a/package.json b/package.json index 6c11697bc..c1476342d 100644 --- a/package.json +++ b/package.json @@ -99,6 +99,7 @@ "./application": "./src/app/application.js", "./application/viewer": "./src/app/viewer.js", "./time": "./src/time/worldClock.js", + "./i18n": "./src/i18n/page.js", "./build/vite": { "node": "./build/vite.js" }, diff --git a/scripts/format-scope.json b/scripts/format-scope.json index b580e5193..df01df506 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1342,6 +1342,8 @@ "src/data/layerState.js", "src/data/layerState.test.mjs", "src/data/spriteOrder.js", + "src/i18n/page.js", + "src/i18n/page.test.mjs", "src/i18n/locales/ar.json", "src/i18n/locales/de.json", "src/i18n/locales/es.json", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index ae86026d5..cfc18f7dd 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -175,6 +175,28 @@ ], "external": ["vue", "pinia", "vue-i18n"] }, + "i18n": { + "exports": ["./i18n"], + "modules": [ + "src/i18n/page.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/ar.json", + "src/i18n/locales/de.json", + "src/i18n/locales/es.json", + "src/i18n/locales/fr.json", + "src/i18n/locales/he.json", + "src/i18n/locales/it.json", + "src/i18n/locales/ja.json", + "src/i18n/locales/pt.json", + "src/i18n/locales/ru.json", + "src/i18n/locales/sv.json", + "src/i18n/locales/uk.json", + "src/i18n/locales/zh-CN.json", + "src/i18n/locales/zh-Hant.json" + ], + "external": [] + }, "time": { "exports": ["./time"], "modules": ["src/time/worldClock.js"], diff --git a/src/app/controls.js b/src/app/controls.js index 99bc2555f..def69325d 100644 --- a/src/app/controls.js +++ b/src/app/controls.js @@ -9,7 +9,8 @@ import { t } from '../i18n/index.js'; * Construct the existing controls and camera presentation. `initialShareCode` * opens a share code (the `` of `/share/`) at start-up, for a host * whose address does not carry one. `localePicker: false` removes the language - * picker, for a host that does not initialize Core's locale. + * picker, for a host that offers its own language choice (`switchLocale` from + * the `./i18n` entry). */ export function createApplicationControls({ scene: { viewer, mapStackController, operations }, diff --git a/src/i18n/page.js b/src/i18n/page.js new file mode 100644 index 000000000..5ecf13fe8 --- /dev/null +++ b/src/i18n/page.js @@ -0,0 +1,81 @@ +/** + * @module i18n/page + * @description The page's interface language, for an application that + * composes Core and offers its own language choice (a menu item, a settings + * page) instead of the globe picker. It starts the language the standalone + * application starts, keeps ``/`` and the page's + * `data-i18n` markup in step, and switches in place, stored for the next + * visit, exactly as the picker does. Guide: docs/I18N.md. + */ +import { + DEFAULT_LOCALE, + LOCALES, + LOCALE_NAMES, + applyDocumentLanguage, + isRtlLocale, + normalizeLocale, + resolveLocale, +} from './locale.js'; +import { + applyDocumentTranslations, + getLocale, + onLocaleChange, + persistLocale, + setLocale, +} from './index.js'; + +export { + DEFAULT_LOCALE, + LOCALES, + LOCALE_NAMES, + applyDocumentTranslations, + getLocale, + isRtlLocale, + onLocaleChange, +}; + +/** + * Start the page in its language: `?lang=`, then the stored choice, then the + * browser's languages, then English. Call it before any view renders, so + * every lookup and ``/`` agree from the first frame; the + * page's static `data-i18n` markup (a loading screen) is translated now. + * @param {Document} [doc] + * @param {Parameters[0]} [inputs] Location, storage + * and languages, for tests. + * @returns {string} The active locale. + */ +export function startPageLocale(doc = globalThis.document, inputs) { + const locale = setLocale(resolveLocale(inputs)); + applyDocumentLanguage(doc, locale); + applyDocumentTranslations(doc); + return locale; +} + +/** + * Keep ``/`` and the page's `data-i18n` markup in step + * with every language switch. + * @param {Document} [doc] + * @returns {() => void} Stops following. + */ +export function followPageLocale(doc = globalThis.document) { + return onLocaleChange(() => { + applyDocumentLanguage(doc, getLocale()); + applyDocumentTranslations(doc); + }); +} + +/** + * Switch the page to `locale` in place and keep it for the next visit. The + * active language, or an unknown code that would fall back to it, changes + * nothing. + * @param {string} locale One of LOCALES. + * @param {Parameters[1]} [options] Location, history + * and storage, for tests. + * @returns {'unchanged'|'switched'} + */ +export function switchLocale(locale, options) { + const next = normalizeLocale(locale) || DEFAULT_LOCALE; + if (next === getLocale()) return 'unchanged'; + setLocale(persistLocale(next, options)); + return 'switched'; +} diff --git a/src/i18n/page.test.mjs b/src/i18n/page.test.mjs new file mode 100644 index 000000000..626912daa --- /dev/null +++ b/src/i18n/page.test.mjs @@ -0,0 +1,106 @@ +// src/i18n/page.test.mjs — an application with its own language choice starts, +// follows and switches the page's language as the globe picker does. +import { afterEach, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { LOCALE_STORAGE_KEY } from './locale.js'; +import { + followPageLocale, + getLocale, + startPageLocale, + switchLocale, +} from './page.js'; +import { setLocale } from './index.js'; + +afterEach(() => setLocale('en')); + +function memoryStorage() { + const data = new Map(); + return { + data, + getItem: (key) => data.get(key) ?? null, + setItem: (key, value) => data.set(key, String(value)), + }; +} + +function page(elements = []) { + return { + documentElement: { lang: '', dir: '' }, + querySelectorAll: (selector) => + selector === '[data-i18n]' ? elements : [], + }; +} + +/** A data-i18n element, as applyDocumentTranslations reads one. */ +function marked(text) { + return { + textContent: text, + hasAttribute: (name) => name === 'data-i18n', + getAttribute: () => null, + }; +} + +test('the page starts in ?lang=, else the stored choice, with lang and dir set', () => { + const doc = page(); + const storage = memoryStorage(); + storage.setItem(LOCALE_STORAGE_KEY, 'de'); + assert.equal( + startPageLocale(doc, { + location: { search: '?lang=ar' }, + storage, + languages: ['ru'], + }), + 'ar', + ); + assert.equal(getLocale(), 'ar'); + assert.deepEqual(doc.documentElement, { lang: 'ar', dir: 'rtl' }); + assert.equal( + startPageLocale(doc, { location: { search: '' }, storage, languages: [] }), + 'de', + ); + assert.deepEqual(doc.documentElement, { lang: 'de', dir: 'ltr' }); +}); + +test("the page's data-i18n markup is in its language from the start", () => { + const close = marked('Close'); + startPageLocale(page([close]), { + location: { search: '?lang=de' }, + storage: memoryStorage(), + languages: [], + }); + assert.equal(close.textContent, 'Schließen'); +}); + +test('a switch is stored, drops ?lang, and the followed page takes it', () => { + const doc = page(); + const stop = followPageLocale(doc); + const storage = memoryStorage(); + const replaced = []; + const options = { + location: { href: 'https://example.test/?lang=fr#view' }, + history: { replaceState: (_s, _t, url) => replaced.push(url) }, + storage, + }; + assert.equal(switchLocale('he', options), 'switched'); + assert.equal(getLocale(), 'he'); + assert.equal(storage.data.get(LOCALE_STORAGE_KEY), 'he'); + assert.deepEqual(replaced, ['https://example.test/#view']); + assert.deepEqual(doc.documentElement, { lang: 'he', dir: 'rtl' }); + stop(); + switchLocale('ja', options); + assert.deepEqual(doc.documentElement, { lang: 'he', dir: 'rtl' }); +}); + +test('choosing the active language, or one that falls back to it, changes nothing', () => { + const storage = memoryStorage(); + const options = { + location: { href: 'https://example.test/' }, + history: { replaceState() {} }, + storage, + }; + assert.equal(switchLocale('en', options), 'unchanged'); + assert.equal(switchLocale('xx', options), 'unchanged'); + assert.equal(storage.data.size, 0); + assert.equal(switchLocale('pt-BR', options), 'switched'); + assert.equal(getLocale(), 'pt'); + assert.equal(switchLocale('pt', options), 'unchanged'); +}); diff --git a/src/mapStartup.test.mjs b/src/mapStartup.test.mjs index 9d438643d..ab50fa19b 100644 --- a/src/mapStartup.test.mjs +++ b/src/mapStartup.test.mjs @@ -64,12 +64,9 @@ async function standaloneStartup(Cesium, credentials, fetchImpl) { document: { getElementById: () => ({ querySelector: () => ({}) }), }, - resolveLocale: () => 'en', - setLocale() {}, - getLocale: () => 'en', - onLocaleChange: () => () => {}, + startPageLocale: () => 'en', + followPageLocale: () => () => {}, mountApplicationViews() {}, - applyDocumentLanguage() {}, applyDocumentTranslations() {}, CITY_POIS: [], createApplication: (options) => options, diff --git a/src/standalone/application.js b/src/standalone/application.js index fe152fd9b..85948d8e1 100644 --- a/src/standalone/application.js +++ b/src/standalone/application.js @@ -6,13 +6,11 @@ import { createStandaloneScene } from './scene.js'; import { createStandaloneControls } from './controls.js'; import { createStandaloneData } from './data.js'; import { createStandaloneTools } from './tools.js'; -import { resolveLocale, applyDocumentLanguage } from '../i18n/locale.js'; import { applyDocumentTranslations, - getLocale, - onLocaleChange, - setLocale, -} from '../i18n/index.js'; + followPageLocale, + startPageLocale, +} from '../i18n/page.js'; import { mountApplicationViews } from '../ui/views/index.js'; import { createGoogleTokenSource } from '../maps/googleTokens.js'; @@ -37,9 +35,7 @@ export function createStandaloneApplication({ // scripts execute after parsing, so the DOM is ready here: the views // translate as they render, and the page's static data-i18n* markup (the // loading screen) is translated here and after every language switch. - const activeLocale = resolveLocale(); - setLocale(activeLocale); - applyDocumentLanguage(document, activeLocale); + startPageLocale(document); // Views render the page's markup; wiring reads it. mountApplicationViews(document); applyDocumentTranslations(document); @@ -50,12 +46,7 @@ export function createStandaloneApplication({ return createApplication({ createScene: async (context) => { // A language switch applies in place; the page's own markup follows. - context.defer( - onLocaleChange(() => { - applyDocumentLanguage(document, getLocale()); - applyDocumentTranslations(document); - }), - ); + context.defer(followPageLocale(document)); placeSearch = createStandalonePlaceSearch({ // The bundled city and landmark data the offline name provider reads. // The search package takes it as plain data rather than importing it, diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index 0adedd7a5..b6d77b912 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -96,7 +96,7 @@ export class StyleManager extends ShellFacade { * @param {Cesium.Viewer} viewer - The CesiumJS viewer instance. * @param {object} [options] * @param {boolean} [options.localePicker=true] - false removes the language - * picker, for a host that does not initialize Core's locale. + * picker, for a host that offers its own language choice. */ constructor( viewer, @@ -764,7 +764,7 @@ export class StyleManager extends ShellFacade { this._lifetime.own( onLocaleChange(() => nextTick(() => this._sizeDisplayLabelColumn())), ); - // A host that does not initialize Core's locale shows no picker. + // A host that offers its own language choice shows no picker. if (this._localePickerEnabled === false) return; this._publishLocalePicker(); } From ad9bd0f4f919115adfcf178e6626f9c8b1ed86ef Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 12 Nov 2026 22:07:30 -0800 Subject: [PATCH 53/55] docs(changelog): 1696-i18n-entry --- changelog.d/1696-i18n-entry.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1696-i18n-entry.md diff --git a/changelog.d/1696-i18n-entry.md b/changelog.d/1696-i18n-entry.md new file mode 100644 index 000000000..7fc2b0a5a --- /dev/null +++ b/changelog.d/1696-i18n-entry.md @@ -0,0 +1,3 @@ +- Add an `./i18n` entry so an application with its own language menu can + start, follow and switch the interface language as the globe picker does + (samehkhamis, #1696). From 7314dac1ce962a79df15b6eb85f09b3d0af7d429 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Fri, 13 Nov 2026 17:49:54 +0400 Subject: [PATCH 54/55] i18n: review and fix the strings added since the last translation review Reviews the 474 strings that 27 pull requests added since the last review, in all twelve catalogs, and fixes what the review confirmed: wrong agreement when a placeholder takes a neutral value, plural-less counts that read "1 sightings", terms that differ from the glossaries, and typography the language guides set. Also applies most of the findings earlier reviews left open. zh-Hant is converted from zh-CN again. The new strings had been translated separately with Taiwan vocabulary. The zh-Hant guide lists two more characters OpenCC picks wrongly. Three tests expected the old German and Spanish wording. --- docs/translations/zh-Hant.md | 2 +- src/i18n/locales/ar.json | 134 ++++----- src/i18n/locales/de.json | 114 ++++---- src/i18n/locales/es.json | 116 ++++---- src/i18n/locales/fr.json | 214 +++++++------- src/i18n/locales/he.json | 94 +++--- src/i18n/locales/it.json | 130 ++++----- src/i18n/locales/ja.json | 184 ++++++------ src/i18n/locales/pt.json | 126 ++++---- src/i18n/locales/ru.json | 172 +++++------ src/i18n/locales/sv.json | 24 +- src/i18n/locales/uk.json | 220 +++++++------- src/i18n/locales/zh-CN.json | 174 +++++------ src/i18n/locales/zh-Hant.json | 406 +++++++++++++------------- src/i18n/same-as-english.json | 7 +- src/layers/firms/windowLabel.test.mjs | 5 +- src/ui/sceneControls.test.mjs | 2 +- src/voice/speech.test.mjs | 2 +- 18 files changed, 1063 insertions(+), 1063 deletions(-) diff --git a/docs/translations/zh-Hant.md b/docs/translations/zh-Hant.md index ac355516b..d44c510c4 100644 --- a/docs/translations/zh-Hant.md +++ b/docs/translations/zh-Hant.md @@ -4,7 +4,7 @@ Catalog: `src/i18n/locales/zh-Hant.json`. General rules: [TRANSLATORS.md](../TRA `zh-Hant` is the Simplified Chinese catalog (`zh-CN`, credited to abiyeenzo, #914, and EDIT-Aeterna, #979) written in Traditional characters with OpenCC character conversion. Wording and terminology are the same as in `zh-CN`: change a term in [zh-CN.md](./zh-CN.md) and the `zh-CN` catalog first, then convert. Only the script differs. -After `s2tw` conversion, fix the characters OpenCC picks wrongly: 年曆史 → 年歷史, 下遊 → 下游, 並道 → 併道, 發佈 → 發布, 公佈 → 公布. +After `s2tw` conversion, fix the characters OpenCC picks wrongly: 年曆史 → 年歷史, 下遊 → 下游, 並道 → 併道, 發佈 → 發布, 公佈 → 公布, 控制檯 → 控制台, and 周 → 週 where it means a week (1 週/秒). ## Style diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index e5b833615..46a29dc2f 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -1,5 +1,5 @@ { - "News feed unavailable": "موجز الأخبار غير متاح", + "News feed unavailable": "مصدر بيانات الأخبار غير متاح", "Developing: {name}": "تطورات: {name}", "{count} events today": { "zero": "{count} حدث اليوم", @@ -39,13 +39,13 @@ "Iran–Hormuz": "إيران–هرمز", "Yemen–Red Sea": "اليمن–البحر الأحمر", "Sudan": "السودان", - "Sahel": "الساحل", + "Sahel": "الساحل الأفريقي", "Elsewhere": "أماكن أخرى", "Strike": "ضربة", "Alert": "إنذار", "Air defence": "دفاع جوي", "Ground fighting": "قتال بري", - "Casualties": "إصابات", + "Casualties": "ضحايا", "Humanitarian": "إنساني", "Disaster": "كارثة", "Protest": "احتجاج", @@ -65,20 +65,20 @@ "many": "{count} مصدرًا", "other": "{count} مصدر" }, - "News feed not configured on this server": "موجز الأخبار غير مهيأ على هذا الخادم", + "News feed not configured on this server": "مصدر بيانات الأخبار غير مهيأ على هذا الخادم", "Malformed news hotspots response": "استجابة بؤر الأخبار غير صالحة", - "Malformed news feed response": "استجابة موجز الأخبار غير صالحة", + "Malformed news feed response": "استجابة مصدر بيانات الأخبار غير صالحة", "{place} (placed by model)": "{place} (حدده النموذج)", "English headline by the model": "عنوان إنجليزي من النموذج", - "Not placed on the map": "غير موضوع على الخريطة", + "Not placed on the map": "لم يُحدَّد موقعه على الخريطة", "Close": "إغلاق", "Any severity": "أي درجة خطورة", "{severity} and above": "{severity} فأعلى", "{severity} only": "{severity} فقط", "All categories": "كل الفئات", "Reading the news…": "جارٍ قراءة الأخبار…", - "News, last {hours} hours": "الأخبار، آخر {hours} ساعة", - "Last {hours} h": "آخر {hours} ساعة", + "News, last {hours} hours": "الأخبار، الساعات الأخيرة: {hours}", + "Last {hours} h": "آخر {hours} س", "Time window": "الفترة الزمنية", "Conflicts": "النزاعات", "Local": "محلي", @@ -87,9 +87,9 @@ "Hotspots": "بؤر ساخنة", "Severity": "الخطورة", "Category": "الفئة", - "Nothing reported near here in the last {hours} h": "لم يُبلَّغ عن شيء قريب من هنا في آخر {hours} ساعة", + "Nothing reported near here in the last {hours} h": "لم يُبلَّغ عن شيء قريب من هنا في آخر {hours} س", "Latest news": "آخر الأخبار", - "No news matches these filters in the last {hours} h": "لا أخبار تطابق هذه المرشحات في آخر {hours} ساعة", + "No news matches these filters in the last {hours} h": "لا أخبار تطابق هذه المرشحات في آخر {hours} س", "Load older news": "تحميل أخبار أقدم", "How to read the map": "كيف تقرأ الخريطة", "Colour: severity · icon: type of event": "اللون: الخطورة · الرمز: نوع الحدث", @@ -442,7 +442,7 @@ "AVAILABLE MISSIONS": "المهام المتاحة", "SELECT A MISSION TO INSPECT": "اختر مهمة لفحصها", "LOADING 30-DAY MISSION INDEX": "جارٍ تحميل فهرس مهام 30 يومًا", - "Space Missions unavailable: the mission index could not load. Try again later.": "المهام الفضائية غير متاحة: تعذّر تحميل فهرس المهام. حاول مرة أخرى لاحقًا.", + "Space Missions unavailable: the mission index could not load. Try again later.": "المهام الفضائية غير متاحة: تعذّر تحميل فهرس المهام. أعد المحاولة لاحقًا.", "30-DAY MISSION INDEX UNAVAILABLE": "فهرس مهام 30 يومًا غير متاح", "RADIO": "الراديو", "ENABLE": "تفعيل", @@ -536,12 +536,12 @@ "PLACE / CONDITIONS / POSITION": "المكان / الأحوال / الموضع", "POSITION UNAVAILABLE": "الموضع غير متاح", "REGIONAL NEWS UNAVAILABLE": "الأخبار الإقليمية غير متاحة", - "CACHED · REFRESH FAILED": "مخزنة · فشل التحديث", + "CACHED · REFRESH FAILED": "مخزّنة · فشل التحديث", "REGION UNAVAILABLE": "المنطقة غير متاحة", "NO NEWS IN THE LAST {hours} H": "لا أخبار خلال آخر {hours} س", "SOURCE": "المصدر", "{domain} · {age}": "{domain} · {age}", - "{source} · CACHED {age}": "{source} · مخزنة {age}", + "{source} · CACHED {age}": "{source} · مخزّنة {age}", "TIME UNKNOWN": "الوقت غير معروف", "{count}M AGO": "قبل {count} د", "{count}H AGO": "قبل {count} س", @@ -3385,7 +3385,7 @@ "Reset map bearing to north": "إعادة اتجاه الخريطة إلى الشمال", "Malformed cyclone snapshot": "لقطة الأعاصير مشوّهة", "Cyclone request timed out": "انتهت مهلة طلب الأعاصير", - "Regional sample · 250 nm radius · cells from visited views over {minutes} min": "عيّنة إقليمية · نصف قطر 250 ميلًا بحريًا · خلايا من المناظر التي زرتها خلال {minutes} د", + "Regional sample · 250 nm radius · cells from visited views over {minutes} min": "عيّنة إقليمية · نصف قطر 250 ميلًا بحريًا · خلايا من المناطق التي استعرضتها خلال {minutes} د", "RADAR · {units}": "رادار · {units}", "Color: {units}": "اللون: {units}", "Left panels": "اللوحات اليمنى", @@ -3411,7 +3411,7 @@ "Add the current view, style and layers as the next shot": "إضافة العرض الحالي والنمط والطبقات كلقطة تالية", "+ CAPTURE SHOT": "+ التقاط لقطة", "Replace the selected shot with the current view": "استبدال اللقطة المحددة بالعرض الحالي", - "Click a shot to select it, double-click to rename.": "انقر لقطة لتحديدها، وانقر نقرًا مزدوجًا لإعادة تسميتها.", + "Click a shot to select it, double-click to rename.": "انقر على لقطة لتحديدها، وانقر عليها نقرًا مزدوجًا لإعادة تسميتها.", "Play from this scene through the rest of the project": "تشغيل من هذا المشهد حتى نهاية المشروع", "Stop playback (Esc)": "إيقاف التشغيل (Esc)", "Play the shot after the selected one, moving on to the next scene when needed": "تشغيل اللقطة التي تلي المحددة، مع الانتقال إلى المشهد التالي عند الحاجة", @@ -3618,7 +3618,7 @@ "Within {km} km of the view centre": "ضمن {km} كم من مركز العرض", "{region}, {country}": "{region}، {country}", "via {source}": "عبر {source}", - "Local in the News layer: the day’s news from the outlets of the country in view": "المحلي في طبقة الأخبار: أخبار اليوم من وسائل إعلام البلد المعروض", + "Local in the News layer: the day’s news from the outlets of the country in view": "قسم «محلي» في طبقة الأخبار: أخبار اليوم من وسائل إعلام البلد المعروض", "{provider} refused its key": "رفض {provider} مفتاحه", "Check {envVars} in Provider Settings": "تحقق من {envVars} في «إعدادات المزوّدين»", "{provider} key required": "مفتاح {provider} مطلوب", @@ -3633,7 +3633,7 @@ "Date unknown": "التاريخ غير معروف", "Position: centre of the protected area": "الموقع: مركز المنطقة المحمية", "Position approximate (within about {km} km)": "الموقع تقريبي (في حدود {km} كم تقريبًا)", - "Position surveyed (within about {m} m)": "الموقع مُسَح (في حدود {m} م تقريبًا)", + "Position surveyed (within about {m} m)": "الموقع محدَّد بالمسح (في حدود {m} م تقريبًا)", "Vessel: {value}": "السفينة: {value}", "What happened: {value}": "ما حدث: {value}", "Depth: {m} m ({quality})": "العمق: {m} م ({quality})", @@ -3644,24 +3644,24 @@ "Source: {value}": "المصدر: {value}", "Click the card to open EMODnet ↗": "انقر على البطاقة لفتح EMODnet ↗", "Click the card to open the source ↗": "انقر على البطاقة لفتح المصدر ↗", - "Wikidata unavailable; showing EMODnet only": "ويكي بيانات غير متاحة؛ يُعرض EMODnet فقط", - "EMODnet unavailable; showing Wikidata only": "EMODnet غير متاح؛ تُعرض ويكي بيانات فقط", + "Wikidata unavailable; showing EMODnet only": "Wikidata غير متاح؛ يُعرض EMODnet فقط", + "EMODnet unavailable; showing Wikidata only": "EMODnet غير متاح؛ يُعرض Wikidata فقط", "Shipwrecks unavailable": "بيانات حطام السفن غير متاحة", - "EMODnet (European waters) · Wikidata (worldwide)": "EMODnet (المياه الأوروبية) · ويكي بيانات (العالم)", + "EMODnet (European waters) · Wikidata (worldwide)": "EMODnet (المياه الأوروبية) · Wikidata (العالم)", "{count} wrecks": { - "zero": "لا حطام ({count})", - "one": "حطام واحد ({count})", - "two": "حطامان ({count})", - "few": "{count} حطامات", - "many": "{count} حطامًا", - "other": "{count} حطام" + "zero": "{count} سفينة غارقة", + "one": "سفينة غارقة واحدة ({count})", + "two": "سفينتان غارقتان ({count})", + "few": "{count} سفن غارقة", + "many": "{count} سفينة غارقة", + "other": "{count} سفينة غارقة" }, "1900–1945": "1900–1945", - "Shipwreck": "غرق سفينة", + "Shipwreck": "حطام سفينة", "Fatalities: {count}": "الوفيات: {count}", "Survivors: {count}": "الناجون: {count}", "Click the card to open the article ↗": "انقر على البطاقة لفتح المقالة ↗", - "European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "يأتي حطام السفن التراثي الأوروبي من EMODnet Human Activities، ويأتي الحطام البارز في العالم من ويكي بيانات. يبيّن اللون متى غرقت السفينة، ويكبر حجم العلامة مع عدد الوفيات. انقر على علامة لعرض بطاقتها.", + "European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "يأتي حطام السفن التراثي الأوروبي من EMODnet Human Activities، ويأتي الحطام البارز في العالم من Wikidata. يبيّن اللون متى غرقت السفينة، ويكبر حجم العلامة مع عدد الوفيات. انقر على علامة لعرض بطاقتها.", "All years": "كل السنوات", "Period": "الفترة", "From": "من", @@ -3689,9 +3689,9 @@ "Other or unknown": "أخرى أو غير معروفة", "{label} {detail} — click to retry": "{label} {detail} — انقر لإعادة المحاولة", "Wildlife sightings source unavailable": "مصدر مشاهدات الحياة البرية غير متاح", - "Show only community-confirmed (research-grade) sightings": "إظهار المشاهدات المؤكدة مجتمعياً (بدرجة بحثية) فقط", - "No recent sightings in view": "لا توجد مشاهدات حديثة في العرض", - "Acartia feed unavailable": "تعذر الوصول إلى مصدر Acartia", + "Show only community-confirmed (research-grade) sightings": "إظهار المشاهدات المؤكدة مجتمعيًا (بدرجة بحثية) فقط", + "No recent sightings in view": "لا توجد مشاهدات حديثة في نطاق العرض", + "Acartia feed unavailable": "مصدر Acartia غير متاح", "Show all taxa": "إظهار جميع المجموعات التصنيفية", "BIRDS": "الطيور", "Show only birds": "إظهار الطيور فقط", @@ -3706,31 +3706,31 @@ "All rights reserved": "جميع الحقوق محفوظة", "Cetacean": "حوتي", "Wildlife Sightings": "مشاهدات الحياة البرية", - "Exit cockpit to fly to a sighting": "اخرج من قمرة القيادة للانتقال إلى مشاهدة", - "Exit cockpit to fly to a sighting area": "اخرج من قمرة القيادة للانتقال إلى منطقة مشاهدة", + "Exit cockpit to fly to a sighting": "اخرج من القمرة للانتقال إلى مشاهدة", + "Exit cockpit to fly to a sighting area": "اخرج من القمرة للانتقال إلى منطقة مشاهدة", "Wildlife": "الحياة البرية", "Zoom in for local sightings": "قرّب لرؤية المشاهدات المحلية", "Loading photo...": "جارٍ تحميل الصورة…", "Photo unavailable": "الصورة غير متاحة", - "Location obscured to a 0.2° cell": "الموقع مخفي ضمن خلية بمقدار 0.2°", + "Location obscured to a 0.2° cell": "الموقع مموّه ضمن خلية بحجم 0.2°", "Photo of {name}": "صورة {name}", "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", - "{count} sightings in view": "{count} مشاهدة ضمن النطاق", + "{count} sightings in view": "المشاهدات في نطاق العرض: {count}", "Close sighting": "إغلاق المشاهدة", - "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.": "حياة برية شوهدت خلال 48 ساعة (الحيتان 7 أيام). يوضح اللون ورمز الشارة المجموعة التصنيفية. تُعرض الأنواع الحساسة في خلايا بحجم 0.2° فقط؛ وتُقرّب مشاهدات الثدييات البحرية في بحر ساليش عبر Acartia إلى 0.01°. ليست إحصاءً شاملاً.", + "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.": "حياة برية شوهدت خلال 48 ساعة (الحيتان 7 أيام). يوضح اللون ورمز الشارة المجموعة التصنيفية. تُعرض الأنواع الحساسة في خلايا بحجم 0.2° فقط؛ وتُقرّب مشاهدات الثدييات البحرية في بحر ساليش عبر Acartia إلى 0.01°. ليست إحصاءً شاملًا.", "Open {name} on iNaturalist": "فتح {name} على iNaturalist", "Open {name} on Acartia": "فتح {name} على Acartia", "iNaturalist": "iNaturalist", "iNaturalist contributors · licences per record": "مساهمو iNaturalist · التراخيص لكل سجل", "iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "مساهمو iNaturalist · Acartia {count} (بحر ساليش) · التراخيص لكل سجل", - "{days}d": "{days}ي", + "{days}d": "{days} ي", "All markers": "كل العلامات", "Show markers of every age": "عرض العلامات من جميع الأعمار", - "Maximum event age: {days}d; undated markers are kept": "الحد الأقصى لعمر الحدث: {days} يوم؛ تُحفظ العلامات غير المؤرخة", + "Maximum event age: {days}d; undated markers are kept": "الحد الأقصى لعمر الحدث: {days} ي؛ تبقى العلامات غير المؤرخة ظاهرة", "event age filter|All": "الكل", "Event age": "عمر الحدث", - "Event age: {days}d · undated kept": "عمر الحدث: {days} يوم · الاحتفاظ بغير المؤرخ", + "Event age: {days}d · undated kept": "عمر الحدث: {days} ي · غير المؤرخة تبقى ظاهرة", "Close camera preview": "إغلاق معاينة الكاميرا", "Refresh map still": "تحديث صورة الخريطة الثابتة", "The map still uses a separate request and may show a different moment.": "تستخدم صورة الخريطة طلبًا منفصلًا وقد تعرض لحظة مختلفة.", @@ -3755,11 +3755,11 @@ "STARLINK": "STARLINK", "The Starlink constellation (thousands of extra points)": "كوكبة Starlink (آلاف النقاط الإضافية)", "PROBES": "المسابير", - "Space science, technology demonstration and other payloads": "علوم الفضاء والعروض التقنية وحمولات أخرى", + "Space science, technology demonstration and other payloads": "علوم الفضاء واختبار التقنيات وحمولات أخرى", "DEBRIS": "الحطام", "Rocket bodies, fragments and dead satellites": "مراحل الصواريخ والشظايا والأقمار المعطلة", - "Classes may lag: SATCAT is a cached copy": "قد تتأخر الفئات: SATCAT نسخة مخزنة", - "Classes may lag: SATCAT unavailable": "قد تتأخر الفئات: SATCAT غير متاح", + "Classes may lag: SATCAT is a cached copy": "قد لا تكون الفئات محدّثة: SATCAT نسخة مخزّنة", + "Classes may lag: SATCAT unavailable": "قد لا تكون الفئات محدّثة: SATCAT غير متاح", "CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "يعطي SATCAT من CelesTrak نوع كل جسم وحالته. من دون نسخة حديثة تستند الفئات إلى قاعدة بيانات UCS والأسماء ومجموعات CelesTrak، فقد يظهر قمر صناعي توقف عن العمل منذ ذلك الحين وكأنه يعمل.", "STARLINK ···": "STARLINK ···", "STARLINK ✕": "STARLINK ✕", @@ -3780,7 +3780,7 @@ "Zoom out (−)": "تصغير (−)", "ORBITAL VIEW · OFF-NADIR": "العرض المداري · انحراف عن النظير", "World time": "وقت العالم", - "Back to live": "العودة إلى البث المباشر", + "Back to live": "العودة إلى الوضع المباشر", "Live": "مباشر", "1 h/s": "ساعة/ث", "1 d/s": "يوم/ث", @@ -3789,18 +3789,18 @@ "Scroll or pinch to zoom, drag to move along · double-click to see it all": "مرّر أو اقرص للتكبير، واسحب للتنقل · انقر نقرًا مزدوجًا لرؤية الكل", "1 min/s": "دقيقة/ث", "Rail": "السكك الحديدية", - "Urban rail": "قطارات حضرية", + "Urban rail": "القطارات الحضرية", "Local rail": "قطارات الضواحي", - "Regional rail": "قطارات إقليمية", - "National rail": "قطارات وطنية", - "International rail": "قطارات دولية", + "Regional rail": "القطارات الإقليمية", + "National rail": "القطارات الوطنية", + "International rail": "القطارات الدولية", "Hide {category}": "إخفاء {category}", "Show {category}": "إظهار {category}", "Live GPS": "GPS مباشر", "Estimated from station reports": "مقدَّر من بلاغات المحطات", "To {headsign}": "إلى {headsign}", - "Next {stop} {time} {relative}": "المحطة التالية {stop} {time} {relative}", - "Prev {stop} {time} {relative}": "المحطة السابقة {stop} {time} {relative}", + "Next {stop} {time} {relative}": "المحطة التالية: {stop} {time} {relative}", + "Prev {stop} {time} {relative}": "المحطة السابقة: {stop} {time} {relative}", "(+{minutes} min late)": "(تأخير {minutes} د)", "Exit cockpit to fly to a train": "اخرج من القمرة للانتقال إلى قطار", "Photo © {photographer}": "صورة © {photographer}", @@ -3813,16 +3813,16 @@ "The shared vessel isn't reporting now — showing where it was last seen": "السفينة المشتركة: لا بيانات واردة الآن — يُعرض آخر موضع معروف", "The shared vessel isn't reporting now — showing where it was at {time}": "السفينة المشتركة: لا بيانات واردة الآن — يُعرض موضعها عند {time}", "The shared satellite isn't reporting now — showing where it was last seen": "القمر الصناعي المشترك: لا بيانات واردة الآن — يُعرض آخر موضع معروف", - "The shared satellite isn't reporting now — showing where it was at {time}": "القمر الصناعي المشترك: لا بيانات واردة الآن — يُعرض موضعها عند {time}", + "The shared satellite isn't reporting now — showing where it was at {time}": "القمر الصناعي المشترك: لا بيانات واردة الآن — يُعرض موضعه عند {time}", "ICAO: {id}": "ICAO: {id}", "NORAD: {id}": "NORAD: {id}", "MMSI: {id}": "MMSI: {id}", "Vessel UUID: {id}": "معرّف UUID للسفينة: {id}", "Latitude must be within ±90° and longitude within ±180°": "يجب أن يكون خط العرض ضمن ±90° وخط الطول ضمن ±180°", - "Street Level is unavailable": "مستوى الشارع غير متاح", + "Street Level is unavailable": "«مستوى الشارع» غير متاح", "No street-level imagery source is configured": "لم يُعَدّ أي مصدر لصور مستوى الشارع", - "Street Level could not be enabled": "تعذّر تفعيل مستوى الشارع", - "Street Level changed while it looked for imagery": "تغيّر مستوى الشارع أثناء البحث عن الصور", + "Street Level could not be enabled": "تعذّر تفعيل «مستوى الشارع»", + "Street Level changed while it looked for imagery": "تغيّرت حالة «مستوى الشارع» أثناء البحث عن الصور", "Aim the view at a street": "وجّه العرض نحو شارع", "{source} is not a street-level imagery source here": "{source} ليس مصدرًا لصور مستوى الشارع هنا", "{source} needs its key": "يحتاج {source} إلى مفتاحه", @@ -3830,13 +3830,13 @@ "{source} could not be switched on": "تعذّر تفعيل {source}", "{source} is not available on this map": "{source} غير متاح على هذه الخريطة", "{source} imagery could not be opened": "تعذّر فتح صور {source}", - "{place} has no position to look for imagery at": "لا يوجد لـ{place} موضع للبحث عن الصور فيه", + "{place} has no position to look for imagery at": "{place}: لا موضع للبحث عن الصور فيه", "No {source} imagery near {place}": "لا توجد صور {source} قرب {place}", "{reason} (filters on: {filters})": "{reason} (المرشحات المفعّلة: {filters})", "tiles unavailable": "البلاطات غير متاحة", - "zoom in to load this layer": "كبّر لتحميل هذه الطبقة", + "zoom in to load this layer": "قرّب لتحميل هذه الطبقة", "Zoom to layer": "تكبير إلى الطبقة", - "Zoom to layer · fly to where this layer has data": "تكبير إلى الطبقة · الطيران إلى حيث توجد بيانات هذه الطبقة", + "Zoom to layer · fly to where this layer has data": "تكبير إلى الطبقة · الانتقال إلى حيث توجد بيانات هذه الطبقة", "Remove from Data Layers": "إزالة من طبقات البيانات", "Remove from Data Layers · add it again from the library": "إزالة من طبقات البيانات · أضِفها مجددًا من المكتبة", "Coverage outline": "حدود التغطية", @@ -3847,7 +3847,7 @@ "1,000–2,000 m": "1,000–2,000 م", "2,000–3,000 m": "2,000–3,000 م", "3,000 m and above": "3,000 م فأكثر", - "Exit cockpit to fly to a layer": "اخرج من قمرة القيادة للطيران إلى طبقة", + "Exit cockpit to fly to a layer": "اخرج من القمرة للانتقال إلى طبقة", "Library": "المكتبة", "Environment": "البيئة", "Imagery": "الصور", @@ -3863,24 +3863,24 @@ "{datasets} · {tiles} · {packs}": "{datasets} · {tiles} · {packs}", "{count} datasets": { "zero": "{count} مجموعة بيانات", - "one": "{count} مجموعة بيانات", - "two": "{count} مجموعتا بيانات", + "one": "مجموعة بيانات واحدة ({count})", + "two": "مجموعتا بيانات ({count})", "few": "{count} مجموعات بيانات", "many": "{count} مجموعة بيانات", "other": "{count} مجموعة بيانات" }, "{count} tile layers": { "zero": "{count} طبقة بلاطات", - "one": "{count} طبقة بلاطات", - "two": "{count} طبقتا بلاطات", + "one": "طبقة بلاطات واحدة ({count})", + "two": "طبقتا بلاطات ({count})", "few": "{count} طبقات بلاطات", "many": "{count} طبقة بلاطات", "other": "{count} طبقة بلاطات" }, "{count} packs": { "zero": "{count} حزمة", - "one": "{count} حزمة", - "two": "{count} حزمتان", + "one": "حزمة واحدة ({count})", + "two": "حزمتان ({count})", "few": "{count} حزم", "many": "{count} حزمة", "other": "{count} حزمة" @@ -3899,8 +3899,8 @@ "Map features": "معالم الخريطة", "from {count} datasets": { "zero": "من {count} مجموعة بيانات", - "one": "من {count} مجموعة بيانات", - "two": "من {count} مجموعتَي بيانات", + "one": "من مجموعة بيانات واحدة ({count})", + "two": "من مجموعتَي بيانات ({count})", "few": "من {count} مجموعات بيانات", "many": "من {count} مجموعة بيانات", "other": "من {count} مجموعة بيانات" @@ -3923,7 +3923,7 @@ "Resolution": "الدقة", "Flown": "تاريخ التصوير", "Updates": "التحديث", - "Ortho quality": "جودة التعامد", + "Ortho quality": "جودة التصحيح التعامدي", "DATA LIBRARY": "مكتبة البيانات", "Close data library": "إغلاق مكتبة البيانات", "Data Library at a glance": "مكتبة البيانات في لمحة", @@ -3932,7 +3932,7 @@ "Theme": "الموضوع", "All themes": "كل المواضيع", "Only what this instance serves": "فقط ما يقدّمه هذا المثيل", - "OPT-IN": "اختياري", + "OPT-IN": "بتفعيل المشغّل", "KEY": "مفتاح", "KEYLESS": "بلا مفتاح", "ZOOM": "تكبير", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 060244aa6..ca668a74e 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -1,6 +1,6 @@ { "News feed unavailable": "Nachrichtenfeed nicht verfügbar", - "Developing: {name}": "In Entwicklung: {name}", + "Developing: {name}": "Aktuelle Entwicklung: {name}", "{count} events today": { "one": "{count} Ereignis heute", "other": "{count} Ereignisse heute" @@ -13,11 +13,11 @@ "Video": "Video", "Photos": "Fotos", "Claimed by {who}": "Behauptet von {who}", - "Claimed": "Behauptung", + "Claimed": "Behauptet", "Not yet confirmed by others": "Noch nicht von anderen bestätigt", - "Reported by {side} sources": "Gemeldet von Quellen aus {side}", - "Reported by {sides}": "Gemeldet von {sides}", - "{side} sources": "Quellen aus {side}", + "Reported by {side} sources": "Gemeldet von Quellen: {side}", + "Reported by {sides}": "Gemeldet von Quellen: {sides}", + "{side} sources": "{side}", "Official": "Offiziell", "Reported by both sides": "Von beiden Seiten gemeldet", "Independently reported": "Unabhängig gemeldet", @@ -47,8 +47,8 @@ "Significant": "Erheblich", "Major": "Schwer", "age|now": "jetzt", - "{minutes} min": "{minutes} Min.", - "{hours} h": "{hours} Std.", + "{minutes} min": "{minutes} min", + "{hours} h": "{hours} h", "{count} sources": { "one": "{count} Quelle", "other": "{count} Quellen" @@ -65,8 +65,8 @@ "{severity} only": "Nur {severity}", "All categories": "Alle Kategorien", "Reading the news…": "Nachrichten werden gelesen…", - "News, last {hours} hours": "Nachrichten der letzten {hours} Stunden", - "Last {hours} h": "Letzte {hours} Std.", + "News, last {hours} hours": "Nachrichten, letzte {hours} h", + "Last {hours} h": "Letzte {hours} h", "Time window": "Zeitraum", "Conflicts": "Konflikte", "Local": "Lokal", @@ -82,8 +82,8 @@ "How to read the map": "So liest man die Karte", "Colour: severity · icon: type of event": "Farbe: Schweregrad · Symbol: Art des Ereignisses", "Solid rim: reported by both sides or independently. Dashed rim: a claim. Thin rim: one source, or one side’s own press.": "Durchgehender Rand: von beiden Seiten oder unabhängig gemeldet. Gestrichelter Rand: eine Behauptung. Dünner Rand: eine Quelle oder die eigene Presse einer Seite.", - "Larger: more sources · fainter: older. Picture card: a major or fast-growing event with two or more sources.": "Größer: mehr Quellen · blasser: älter. Bildkarte: ein schweres oder schnell wachsendes Ereignis mit zwei oder mehr Quellen.", - "Side labels describe the source, not whether a report is true.": "Seitenangaben beschreiben die Quelle, nicht ob eine Meldung stimmt.", + "Larger: more sources · fainter: older. Picture card: a major or fast-growing event with two or more sources.": "Größer: mehr Quellen · blasser: älter. Infokarte mit Bild: ein schweres oder schnell wachsendes Ereignis mit zwei oder mehr Quellen.", + "Side labels describe the source, not whether a report is true.": "Angaben zur Seite beschreiben die Quelle, nicht ob eine Meldung stimmt.", "Exit cockpit to fly to a news report": "Cockpit verlassen, um zu einer Meldung zu fliegen", "News items in the feed": "Meldungen im Feed", "Flying to Austin, TX...": "Flug nach Austin, TX...", @@ -252,7 +252,7 @@ "loading...": "lädt...", "KEY REQUIRED": "SCHLÜSSEL ERFORDERLICH", "LIVE · updated {age}": "LIVE · aktualisiert {age}", - "24 h to {day} {time} UTC": "24 Std. bis {day} {time} UTC", + "24 h to {day} {time} UTC": "24 h bis {day} {time} UTC", "loading mapped installation context": "Kontext der kartierten Anlage wird geladen", "MAPPED INSTALLATION": "KARTIERTE ANLAGE", "LAUNCH SITE · {site}": "STARTPLATZ · {site}", @@ -282,15 +282,15 @@ "{value} KM/S": "{value} KM/S", "{direction} {angle}°": "{direction} {angle}°", "OFF-NADIR {angle}° · {offset}": "NADIRABWEICHUNG {angle}° · {offset}", - "SITES STANDBY": "ORTE STANDBY", - "ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "KARTIERTE ANLAGEN FÜR ORTS-PINGS AKTIVIEREN", + "SITES STANDBY": "STANDORTE STANDBY", + "ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "KARTIERTE ANLAGEN FÜR STANDORT-PINGS AKTIVIEREN", "Release the camera from {name}": "Kamera von {name} lösen", "RETURN · {name}": "ZURÜCK · {name}", "PREVIOUS SATELLITE": "VORHERIGER SATELLIT", "Return to {name}": "Zurück zu {name}", "SATELLITE": "SATELLIT", "Ride {name}": "Mit {name} fliegen", - "Unnamed site": "Unbenannter Ort", + "Unnamed site": "Unbenannter Standort", "OFF-NADIR {angle}°": "NADIRABWEICHUNG {angle}°", "FOCUS · {name}": "FOKUS · {name}", "Keep the camera on {name}": "Kamera auf {name} halten", @@ -493,8 +493,8 @@ "other": "{count} Kameras geladen · CCTV aktivieren" }, "{count} more cameras outside this view · pan or zoom out": { - "one": "{count} weitere Kamera außerhalb dieser Ansicht · verschieben oder herauszoomen", - "other": "{count} weitere Kameras außerhalb dieser Ansicht · verschieben oder herauszoomen" + "one": "{count} weitere Kamera außerhalb dieser Ansicht · Karte verschieben oder herauszoomen", + "other": "{count} weitere Kameras außerhalb dieser Ansicht · Karte verschieben oder herauszoomen" }, "Enable CCTV to load camera intersections": "CCTV aktivieren, um Kreuzungskameras zu laden", "Enable CCTV to start camera-linked intelligence summaries.": "CCTV aktivieren, um kameragestützte Lagezusammenfassungen zu starten.", @@ -1619,7 +1619,7 @@ "Low-accuracy score 2–10%": "Ungenauigkeitswert 2–10 %", "Low-accuracy score > 10%": "Ungenauigkeitswert > 10 %", "GNSS integrity source unavailable": "GNSS-Integritätsquelle nicht verfügbar", - "Regional sample · 250 nm radius · cells from visited views over {minutes} min": "Regionale Stichprobe · Radius 250 NM · Zellen aus besuchten Ansichten über {minutes} min", + "Regional sample · 250 nm radius · cells from visited views over {minutes} min": "Regionale Stichprobe · Radius 250 NM · Zellen aus besuchten Ansichten der letzten {minutes} min", "Retrying automatically at {time} UTC": "Automatischer neuer Versuch um {time} UTC", "Retrying automatically after provider cooldown": "Automatischer neuer Versuch nach der Sperrzeit des Anbieters", "Updates automatically after pan or zoom and once a minute. The public feed covers a 250-nautical-mile circle around the view centre; zooming out does not make it a worldwide snapshot. Blank cells are outside sampled coverage or have insufficient observations.": "Aktualisiert sich nach Schwenken oder Zoomen und einmal pro Minute automatisch. Der öffentliche Feed deckt einen Kreis von 250 Seemeilen um die Bildmitte ab; Herauszoomen macht daraus keinen weltweiten Snapshot. Leere Zellen liegen außerhalb der Stichprobe oder haben zu wenige Beobachtungen.", @@ -3107,7 +3107,7 @@ "Restricted": "Flugbeschränkungsgebiet", "Prohibited & Restricted Airspace": "Sperr- und Flugbeschränkungsgebiete", "scene|PLAY": "START", - "AUTHORING": "ERSTELLUNG", + "AUTHORING": "SZENEN", "Create and edit scenes": "Szenen erstellen und bearbeiten", "SCENE": "SZENE", "Scene": "Szene", @@ -3129,8 +3129,8 @@ "Download the timing log of the last play": "Zeitprotokoll der letzten Wiedergabe herunterladen", "Edit anchors, data packs and shot details": "Anker, Datenpakete und Aufnahmedetails bearbeiten", "Download this scene as JSON or an asset bundle": "Diese Szene als JSON oder Asset-Bundle herunterladen", - "Left panels": "Linke Panels", - "Right panels": "Rechte Panels", + "Left panels": "Linke Bereiche", + "Right panels": "Rechte Bereiche", "CONFIRMED": "BESTÄTIGT", "PAD / RTLS": "STARTPLATZ / RTLS", "fire confidence filter|All": "Alle", @@ -3292,20 +3292,20 @@ "Too many chat requests. Try again in {seconds} s.": "Zu viele Chat-Anfragen. In {seconds} s erneut versuchen.", "The provider refused access for this key. Check the key's project, region or permissions.": "Der Anbieter hat den Zugriff für diesen Schlüssel verweigert. Projekt, Region oder Berechtigungen des Schlüssels prüfen.", "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "Das Anbieterkonto hat kein Guthaben mehr. Die Abrechnung prüfen oder in den Einstellungen einen anderen KI-Anbieter wählen.", - "Last 24 h of news. Each report links to its publisher; places and categories are read from the text and data automatically and can be wrong. Side labels, where shown, describe the source, not whether a report is true.": "Letzte 24 h Nachrichten. Jede Meldung verlinkt auf ihren Herausgeber; Orte und Kategorien werden automatisch aus Text und Daten gelesen und können falsch sein. Seitenangaben, wo vorhanden, beschreiben die Quelle, nicht ob eine Meldung stimmt.", + "Last 24 h of news. Each report links to its publisher; places and categories are read from the text and data automatically and can be wrong. Side labels, where shown, describe the source, not whether a report is true.": "Nachrichten der letzten 24 h. Jede Meldung verlinkt auf ihren Herausgeber; Orte und Kategorien werden automatisch aus Text und Daten gelesen und können falsch sein. Angaben zur Seite, wo vorhanden, beschreiben die Quelle, nicht ob eine Meldung stimmt.", "Economy": "Wirtschaft", "News from {area}": "Nachrichten: {area}", "Within {km} km of the view centre": "Im Umkreis von {km} km um die Bildmitte", "{region}, {country}": "{region}, {country}", "via {source}": "über {source}", - "Local in the News layer: the day’s news from the outlets of the country in view": "Lokal in der Nachrichtenebene: die Nachrichten des Tages aus den Medien des Landes im Blick", + "Local in the News layer: the day’s news from the outlets of the country in view": "„Lokal“ in der Ebene „Nachrichten“: die Nachrichten des Tages aus den Medien des Landes im Bild", "{provider} refused its key": "{provider} hat den Schlüssel abgelehnt", "Check {envVars} in Provider Settings": "{envVars} in den Anbieter-Einstellungen prüfen", - "{provider} key required": "{provider}-Schlüssel erforderlich", + "{provider} key required": "Schlüssel für {provider} erforderlich", "{provider} limit reached for today": "{provider}: Tageslimit erreicht", "News resumes tomorrow, or with a paid plan": "Nachrichten gibt es morgen wieder, oder mit einem kostenpflichtigen Tarif", "News key required": "Nachrichtenschlüssel erforderlich", - "Precise conflict points in the News layer, beside GDELT’s articles": "Genaue Konfliktpunkte in der Nachrichtenebene, neben den Artikeln von GDELT", + "Precise conflict points in the News layer, beside GDELT’s articles": "Genaue Konfliktpunkte in der Ebene „Nachrichten“, neben den Artikeln von GDELT", "Worldwide": "Weltweit", "Shipwrecks": "Schiffswracks", "Before 1900": "Vor 1900", @@ -3322,8 +3322,8 @@ "Cargo or finds: {value}": "Ladung oder Funde: {value}", "Protected wreck": "Geschütztes Wrack", "Source: {value}": "Quelle: {value}", - "Click the card to open EMODnet ↗": "Karte anklicken, um EMODnet zu öffnen ↗", - "Click the card to open the source ↗": "Karte anklicken, um die Quelle zu öffnen ↗", + "Click the card to open EMODnet ↗": "Eintrag anklicken, um EMODnet zu öffnen ↗", + "Click the card to open the source ↗": "Eintrag anklicken, um die Quelle zu öffnen ↗", "Wikidata unavailable; showing EMODnet only": "Wikidata nicht verfügbar; nur EMODnet wird angezeigt", "EMODnet unavailable; showing Wikidata only": "EMODnet nicht verfügbar; nur Wikidata wird angezeigt", "Shipwrecks unavailable": "Schiffswracks nicht verfügbar", @@ -3333,11 +3333,11 @@ "other": "{count} Wracks" }, "1900–1945": "1900–1945", - "Shipwreck": "Schiffbruch", + "Shipwreck": "Schiffswrack", "Fatalities: {count}": "Todesopfer: {count}", "Survivors: {count}": "Überlebende: {count}", - "Click the card to open the article ↗": "Karte anklicken, um den Artikel zu öffnen ↗", - "European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "Europäische Kulturerbe-Wracks stammen von EMODnet Human Activities, bekannte Wracks weltweit von Wikidata. Die Farbe zeigt, wann das Schiff sank; die Markierung wächst mit der Zahl der Todesopfer. Klicke auf eine Markierung für ihre Karte.", + "Click the card to open the article ↗": "Eintrag anklicken, um den Artikel zu öffnen ↗", + "European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "Europäische Kulturerbe-Wracks stammen von EMODnet Human Activities, bekannte Wracks weltweit von Wikidata. Die Farbe zeigt, wann das Schiff sank; die Markierung wächst mit der Zahl der Todesopfer. Für die Infokarte eine Markierung anklicken.", "All years": "Alle Jahre", "Period": "Zeitraum", "From": "Von", @@ -3363,11 +3363,11 @@ "Fungi": "Pilze", "Other animals": "Andere Tiere", "Other or unknown": "Andere oder unbekannt", - "{label} {detail} — click to retry": "{label} {detail} — zum Wiederholen klicken", + "{label} {detail} — click to retry": "{label}: {detail} – zum erneuten Versuch klicken", "Wildlife sightings source unavailable": "Quelle für Naturbeobachtungen nicht verfügbar", "Show only community-confirmed (research-grade) sightings": "Nur gemeinschaftlich bestätigte Beobachtungen (Forschungsqualität) anzeigen", - "No recent sightings in view": "Keine aktuellen Beobachtungen im Ausschnitt", - "Acartia feed unavailable": "Acartia-Datenstrom nicht verfügbar", + "No recent sightings in view": "Keine aktuellen Beobachtungen im Bild", + "Acartia feed unavailable": "Acartia-Feed nicht verfügbar", "Show all taxa": "Alle Artengruppen anzeigen", "BIRDS": "VÖGEL", "Show only birds": "Nur Vögel anzeigen", @@ -3385,48 +3385,48 @@ "Exit cockpit to fly to a sighting": "Cockpit verlassen, um zu einer Beobachtung zu fliegen", "Exit cockpit to fly to a sighting area": "Cockpit verlassen, um zu einem Beobachtungsgebiet zu fliegen", "Wildlife": "Tierwelt", - "Zoom in for local sightings": "Für lokale Beobachtungen vergrößern", + "Zoom in for local sightings": "Hineinzoomen für lokale Beobachtungen", "Loading photo...": "Foto wird geladen…", "Photo unavailable": "Foto nicht verfügbar", "Location obscured to a 0.2° cell": "Standort auf eine 0,2°-Rasterzelle verschleiert", "Photo of {name}": "Foto von {name}", "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", - "{count} sightings in view": "{count} Sichtungen im Ausschnitt", - "Close sighting": "Sichtung schließen", - "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.": "In den letzten 48 h beobachtete Wildtiere (Wale 7 Tage). Farbe und Abzeichensymbol zeigen die Artengruppe. Empfindliche Arten erscheinen nur als 0,2°-Zellen; Acartia-Meldungen zu Meeressäugern aus der Salish-See werden auf 0,01° gerundet. Keine Bestandszählung.", + "{count} sightings in view": "Beobachtungen im Bild: {count}", + "Close sighting": "Beobachtung schließen", + "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.": "In den letzten 48 h beobachtete Wildtiere (Wale 7 Tage). Farbe und Symbol der Markierung zeigen die Artengruppe. Empfindliche Arten erscheinen nur als 0,2°-Zellen; Acartia-Meldungen zu Meeressäugern aus der Salish-See werden auf 0,01° gerundet. Keine Bestandszählung.", "Open {name} on iNaturalist": "{name} auf iNaturalist öffnen", "Open {name} on Acartia": "{name} auf Acartia öffnen", "iNaturalist": "iNaturalist", "iNaturalist contributors · licences per record": "iNaturalist-Beitragende · Lizenzen je Eintrag", "iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "iNaturalist-Beitragende · Acartia {count} (Salish-See) · Lizenzen je Eintrag", - "{days}d": "{days}T", + "{days}d": "{days} T", "All markers": "Alle Markierungen", "Show markers of every age": "Markierungen jeden Alters anzeigen", - "Maximum event age: {days}d; undated markers are kept": "Maximales Ereignisalter: {days} Tage; undatierte Markierungen bleiben", + "Maximum event age: {days}d; undated markers are kept": "Maximales Ereignisalter: {days} T; undatierte Markierungen bleiben erhalten", "event age filter|All": "Alle", "Event age": "Ereignisalter", - "Event age: {days}d · undated kept": "Ereignisalter: {days} Tage · undatierte bleiben", + "Event age: {days}d · undated kept": "Ereignisalter: {days} T · undatierte bleiben", "Close camera preview": "Kameravorschau schließen", "Refresh map still": "Kartenstandbild aktualisieren", "The map still uses a separate request and may show a different moment.": "Das Kartenstandbild wird separat abgerufen und kann einen anderen Moment zeigen.", - "Video plays from Québec 511, which sees your IP address.": "Das Video wird von Québec 511 abgespielt, das Ihre IP-Adresse sieht.", + "Video plays from Québec 511, which sees your IP address.": "Das Video wird von Québec 511 abgespielt; die IP-Adresse ist für diesen Anbieter sichtbar.", "Press OPEN to load the photo": "Auf ÖFFNEN klicken, um das Foto zu laden", "Captured {month}": "Aufgenommen im {month}", "this spot": "diese Stelle", "the view centre": "die Bildmitte", - "{source} at {place}": "{source} bei {place}", - "Street level at {place}": "Straßenebene bei {place}", + "{source} at {place}": "{source}: {place}", + "Street level at {place}": "Straßenebene: {place}", "Switching map": "Karte wird gewechselt", "Opening imagery": "Bilder werden geöffnet", "Street View is ready: press {open} to load it": "Street View ist bereit: Auf {open} klicken, um es zu laden", - "{source} cannot open this link's street-level photo without a working key": "{source} kann das Straßenfoto dieses Links ohne gültigen Schlüssel nicht öffnen", + "{source} cannot open this link's street-level photo without a working key": "{source} kann das Foto auf Straßenebene aus diesem Link ohne gültigen Schlüssel nicht öffnen", "Street-level photos are hidden in the current view. Leave it first.": "Fotos auf Straßenebene sind in dieser Ansicht ausgeblendet. Zuerst die Ansicht verlassen.", "STATIONS": "STATIONEN", "Crewed stations and the craft that visit them": "Bemannte Stationen und die Raumfahrzeuge, die sie anfliegen", "IMAGING": "BILDGEBUNG", "Earth observation: optical, radar, weather and science": "Erdbeobachtung: optisch, Radar, Wetter und Wissenschaft", - "TELECOM": "TELEKOM", + "TELECOM": "KOMM.", "Communications and navigation satellites, Starlink excepted": "Kommunikations- und Navigationssatelliten, ohne Starlink", "STARLINK": "STARLINK", "The Starlink constellation (thousands of extra points)": "Die Starlink-Konstellation (Tausende zusätzliche Punkte)", @@ -3443,11 +3443,11 @@ "SENSOR SWATH ≈ {value} KM": "SENSORSTREIFEN ≈ {value} KM", "SENSOR": "SENSOR", "Zoom to the imaging sensor swath": "Auf den Streifen des Bildsensors zoomen", - "<1 H OLD": "<1 STD ALT", - "{value} H OLD": "{value} STD ALT", + "<1 H OLD": "<1 H ALT", + "{value} H OLD": "{value} H ALT", "{value} D OLD": "{value} T ALT", "FWD": "VORAUS", - "AFT": "ACHTERN", + "AFT": "ACHTERAUS", "RIGHT": "RECHTS", "LEFT": "LINKS", "TRACK UP": "KURS OBEN", @@ -3456,17 +3456,17 @@ "Zoom out (−)": "Herauszoomen (−)", "ORBITAL VIEW · OFF-NADIR": "ORBITALANSICHT · NADIRABWEICHUNG", "World time": "Weltzeit", - "Back to live": "Zurück zu live", + "Back to live": "Zurück zu Live", "Live": "Live", - "1 h/s": "1 Std./s", + "1 h/s": "1 h/s", "1 d/s": "1 Tag/s", "1 wk/s": "1 Wo./s", "Play speed": "Wiedergabegeschwindigkeit", "Scroll or pinch to zoom, drag to move along · double-click to see it all": "Scrollen oder mit zwei Fingern zoomen, ziehen zum Verschieben · Doppelklick zeigt alles", - "1 min/s": "1 Min./s", + "1 min/s": "1 min/s", "Rail": "Bahn", "Urban rail": "Stadtverkehr", - "Local rail": "Nahverkehr", + "Local rail": "Vorortverkehr", "Regional rail": "Regionalverkehr", "National rail": "Fernverkehr", "International rail": "International", @@ -3476,7 +3476,7 @@ "Estimated from station reports": "Geschätzt aus Bahnhofsmeldungen", "To {headsign}": "Nach {headsign}", "Next {stop} {time} {relative}": "Nächster Halt {stop} {time} {relative}", - "Prev {stop} {time} {relative}": "Letzter Halt {stop} {time} {relative}", + "Prev {stop} {time} {relative}": "Vorheriger Halt {stop} {time} {relative}", "(+{minutes} min late)": "(+{minutes} min Verspätung)", "Exit cockpit to fly to a train": "Cockpit verlassen, um zu einem Zug zu fliegen", "Photo © {photographer}": "Foto © {photographer}", @@ -3502,7 +3502,7 @@ "Aim the view at a street": "Die Ansicht auf eine Straße richten", "{source} is not a street-level imagery source here": "{source} ist hier keine Quelle für Bilder auf Straßenebene", "{source} needs its key": "{source} braucht seinen Schlüssel", - "{source} needs the {map} map, which is not available": "{source} braucht die {map}-Karte, die nicht verfügbar ist", + "{source} needs the {map} map, which is not available": "{source} braucht die Karte „{map}“, die nicht verfügbar ist", "{source} could not be switched on": "{source} konnte nicht eingeschaltet werden", "{source} is not available on this map": "{source} ist auf dieser Karte nicht verfügbar", "{source} imagery could not be opened": "Bilder von {source} konnten nicht geöffnet werden", @@ -3568,13 +3568,13 @@ "Turned off by this instance's operator": "Vom Betreiber dieser Instanz ausgeschaltet", "Needs a provider key on the server": "Benötigt einen Anbieterschlüssel auf dem Server", "Needs this app's server · not available in a static build": "Benötigt den Server dieser App · in einem statischen Build nicht verfügbar", - "Checking what this instance serves…": "Prüfe, was diese Instanz bereitstellt…", + "Checking what this instance serves…": "Es wird geprüft, was diese Instanz bereitstellt…", "Regional": "Regional", "Orthophoto": "Orthofoto", "Satellite mosaic": "Satellitenmosaik", "Oblique": "Schrägluftbild", "Historic imagery": "Historische Luftbilder", - "True orthophoto": "True Orthofoto", + "True orthophoto": "True-Orthofoto", "Type": "Typ", "Resolution": "Auflösung", "Flown": "Befliegung", @@ -3583,7 +3583,7 @@ "DATA LIBRARY": "DATENBIBLIOTHEK", "Close data library": "Datenbibliothek schließen", "Data Library at a glance": "Datenbibliothek auf einen Blick", - "Live sources beyond the core layers. Each one is fetched from its provider while it is on; nothing is stored in this app. Add one and it appears in Data Layers under Library.": "Live-Quellen über die Kernebenen hinaus. Jede wird von ihrem Anbieter abgerufen, solange sie eingeschaltet ist; in dieser App wird nichts gespeichert. Fügen Sie eine hinzu, und sie erscheint in den Datenebenen unter Bibliothek.", + "Live sources beyond the core layers. Each one is fetched from its provider while it is on; nothing is stored in this app. Add one and it appears in Data Layers under Library.": "Live-Quellen über die Kernebenen hinaus. Jede wird von ihrem Anbieter abgerufen, solange sie eingeschaltet ist; in dieser App wird nichts gespeichert. Hinzugefügte Quellen erscheinen in den Datenebenen unter „Bibliothek“.", "Name, provider or country": "Name, Anbieter oder Land", "Theme": "Thema", "All themes": "Alle Themen", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index dda1633aa..2a374d7ec 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -3,12 +3,12 @@ "Developing: {name}": "En desarrollo: {name}", "{count} events today": { "one": "{count} suceso hoy", - "many": "{count} sucesos hoy", + "many": "{count} de sucesos hoy", "other": "{count} sucesos hoy" }, "{count} reports today": { "one": "{count} noticia hoy", - "many": "{count} noticias hoy", + "many": "{count} de noticias hoy", "other": "{count} noticias hoy" }, "Disputed: {name}": "En disputa: {name}", @@ -17,9 +17,9 @@ "Claimed by {who}": "Según {who}", "Claimed": "Afirmación", "Not yet confirmed by others": "Aún sin confirmar por otros", - "Reported by {side} sources": "Informado por fuentes de {side}", - "Reported by {sides}": "Informado por {sides}", - "{side} sources": "fuentes de {side}", + "Reported by {side} sources": "Informado por fuentes: {side}", + "Reported by {sides}": "Informado por fuentes: {sides}", + "{side} sources": "{side}", "Official": "Oficial", "Reported by both sides": "Informado por ambos bandos", "Independently reported": "Informado de forma independiente", @@ -43,7 +43,7 @@ "Humanitarian": "Humanitario", "Disaster": "Catástrofe", "Protest": "Protesta", - "Political": "Político", + "Political": "Política", "No physical event": "Sin hecho físico", "Minor incident": "Incidente menor", "Significant": "Significativo", @@ -53,34 +53,34 @@ "{hours} h": "{hours} h", "{count} sources": { "one": "{count} fuente", - "many": "{count} fuentes", + "many": "{count} de fuentes", "other": "{count} fuentes" }, "News feed not configured on this server": "Noticias no configuradas en este servidor", "Malformed news hotspots response": "Respuesta de focos de noticias mal formada", "Malformed news feed response": "Respuesta de noticias mal formada", "{place} (placed by model)": "{place} (ubicado por el modelo)", - "English headline by the model": "Titular en inglés del modelo", + "English headline by the model": "Titular traducido al inglés por el modelo", "Not placed on the map": "Sin ubicar en el mapa", "Close": "Cerrar", "Any severity": "Cualquier gravedad", - "{severity} and above": "{severity} o más", + "{severity} and above": "{severity} o superior", "{severity} only": "Solo {severity}", "All categories": "Todas las categorías", "Reading the news…": "Leyendo las noticias…", - "News, last {hours} hours": "Noticias de las últimas {hours} horas", - "Last {hours} h": "Últimas {hours} h", + "News, last {hours} hours": "Noticias desde hace {hours} h", + "Last {hours} h": "Desde hace {hours} h", "Time window": "Periodo", "Conflicts": "Conflictos", "Local": "Local", "News sections": "Secciones de noticias", - "Zoom in to see local news": "Acerca el mapa para ver noticias locales", + "Zoom in to see local news": "Acércate para ver noticias locales", "Hotspots": "Focos", "Severity": "Gravedad", "Category": "Categoría", - "Nothing reported near here in the last {hours} h": "Nada informado cerca de aquí en las últimas {hours} h", + "Nothing reported near here in the last {hours} h": "No hay noticias cerca de aquí desde hace {hours} h", "Latest news": "Últimas noticias", - "No news matches these filters in the last {hours} h": "Ninguna noticia de las últimas {hours} h coincide con estos filtros", + "No news matches these filters in the last {hours} h": "Desde hace {hours} h no hay noticias que coincidan con estos filtros", "Load older news": "Cargar noticias anteriores", "How to read the map": "Cómo leer el mapa", "Colour: severity · icon: type of event": "Color: gravedad · icono: tipo de suceso", @@ -219,8 +219,8 @@ "Landmark: --": "Punto de interés: --", "Search any location": "Buscar un lugar", "Search any location...": "Buscar un lugar…", - "LOOK STBD {bearing}°": "VISTA ESTRIBOR {bearing}°", - "LOOK PORT {bearing}°": "VISTA BABOR {bearing}°", + "LOOK STBD {bearing}°": "VISTA A ESTRIBOR {bearing}°", + "LOOK PORT {bearing}°": "VISTA A BABOR {bearing}°", "LOOK ASTERN": "VISTA A POPA", "UP {angle}°": "ARRIBA {angle}°", "DOWN {angle}°": "ABAJO {angle}°", @@ -232,7 +232,7 @@ "BRIDGE WINDOW · FWD": "VENTANA DEL PUENTE · AVANTE", "OPTICAL PLANE · 01": "PLANO ÓPTICO · 01", "HEADING HOLD · AIS": "BLOQUEO DE PROA · AIS", - "NO HEADING · AIS": "SIN PROA · AIS", + "NO HEADING · AIS": "PROA N/D · AIS", "VISOR LOCK · ACTIVE": "BLOQUEO DE VISERA · ACTIVO", "FIRST PERSON": "PRIMERA PERSONA", "BRIDGE VIEW": "VISTA DEL PUENTE", @@ -240,7 +240,7 @@ "Cockpit vision style": "Estilo de visión de la cabina", "Current aircraft heading": "Rumbo actual de la aeronave", "Look ahead: turn the bridge view back to the bow": "Mirar a proa: girar de nuevo la vista del puente hacia la proa", - "Look ahead (Home or 0)": "Mirar a proa (Inicio o 0)", + "Look ahead (Home or 0)": "Mirar a proa (Home o 0)", "LOOK AHEAD": "MIRAR A PROA", "Current vessel heading": "Proa actual del buque", "GROUND SPEED · KTS": "VEL. SUELO · KN", @@ -353,7 +353,7 @@ "TRACK ACQUIRED": "RASTREO ADQUIRIDO", "{label} · COURSE {heading}°": "{label} · RUMBO {heading}°", "{label} · HEADING {heading}°": "{label} · PROA {heading}°", - "{label} · NO HEADING": "{label} · SIN PROA", + "{label} · NO HEADING": "{label} · PROA N/D", "{label} · NADIR VIEW": "{label} · VISTA AL NADIR", "Select flight {title}": "Seleccionar el vuelo {title}", "CONTEXT STANDBY": "CONTEXTO EN ESPERA", @@ -502,9 +502,9 @@ "other": "{count} cámaras cargadas · activa CCTV para activarlas" }, "{count} more cameras outside this view · pan or zoom out": { - "one": "{count} cámara más fuera de esta vista · desplaza o aleja", - "many": "{count} de cámaras más fuera de esta vista · desplaza o aleja", - "other": "{count} cámaras más fuera de esta vista · desplaza o aleja" + "one": "{count} cámara más fuera de esta vista · desplaza el mapa o aléjate", + "many": "{count} de cámaras más fuera de esta vista · desplaza el mapa o aléjate", + "other": "{count} cámaras más fuera de esta vista · desplaza el mapa o aléjate" }, "No summary available.": "No hay resumen disponible.", "Celestial ring — available in Normal style": "Anillo celestial — disponible en el estilo Normal", @@ -694,7 +694,7 @@ "Directory metadata only": "Solo metadatos del directorio", "Choose a globe marker or use next.": "Elige un marcador del globo o pulsa SIG.", "TAGS · {tags}": "ETIQUETAS · {tags}", - "Ready — playback starts only from your action": "Preparada: la reproducción empieza cuando pulses ESCUCHAR", + "Ready — playback starts only from your action": "Preparada: la reproducción solo empieza cuando tú la inicias", "Connecting directly to broadcaster…": "Conectando directamente con la emisora…", "Buffering broadcaster stream…": "Almacenando en búfer el flujo de la emisora…", "Playing {station}": "Reproduciendo {station}", @@ -756,7 +756,7 @@ "{direction} {angle}°": "{direction} {angle}°", "OFF-NADIR {angle}° · {offset}": "FUERA DEL NADIR {angle}° · {offset}", "SITES STANDBY": "SITIOS EN ESPERA", - "ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "ACTIVAR INSTALACIONES CARTOGRAFIADAS PARA PINGS DE SITIOS", + "ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "ACTIVA INSTALACIONES MAPEADAS PARA PINGS DE SITIOS", "Release the camera from {name}": "Soltar la cámara de {name}", "RETURN · {name}": "VOLVER · {name}", "PREVIOUS SATELLITE": "SATÉLITE ANTERIOR", @@ -785,7 +785,7 @@ "Hide the two-line element set": "Ocultar el conjunto de elementos de dos líneas", "Two-line element set": "Conjunto de elementos de dos líneas", "Return the view to nadir": "Volver la vista al nadir", - "Return to nadir (Home or 0)": "Volver al nadir (Inicio o 0)", + "Return to nadir (Home or 0)": "Volver al nadir (Home o 0)", "NADIR": "AL NADIR", "REPLAY ASCENT": "REPETIR ASCENSO", "Replay the estimated ascent with a following camera": "Reproduce el ascenso estimado con una cámara de seguimiento", @@ -1773,10 +1773,10 @@ "Weather data by Open-Meteo.com": "Datos meteorológicos de Open-Meteo.com", "ESC EXIT": "ESC SALIR", "ARROWS LOOK": "FLECHAS MIRAR", - "HOME RESET": "INICIO RESTABLECER", + "HOME RESET": "HOME RESTABLECER", "+/− ZOOM": "+/− ACERCAR/ALEJAR", "ARROWS TILT": "FLECHAS INCLINAR", - "HOME NADIR": "INICIO NADIR", + "HOME NADIR": "HOME AL NADIR", "SIG": "SIG", "NEWS": "NOTICIAS", "LOCAL": "LOCAL", @@ -2096,7 +2096,7 @@ "{scope} · INSTALLATIONS {coverage}": "{scope} · INSTALACIONES: {coverage}", "MILITARY FEED OFF · PARTIAL PICTURE": "FUENTE MILITAR DESACTIVADA · PANORAMA PARCIAL", "FLIGHTS FEED OFF · PARTIAL PICTURE": "FUENTE DE VUELOS DESACTIVADA · PANORAMA PARCIAL", - "NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "AERONAVES CERCANAS INCOMPLETAS · NO IMPLICA TODO DESPEJADO", + "NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "AERONAVES CERCANAS: DATOS INCOMPLETOS · NO IMPLICA TODO DESPEJADO", "{source} · LAST {hours} H": "{source} · ÚLTIMAS {hours} H", "ft": "ft", "kts": "kn", @@ -2339,7 +2339,7 @@ "Regional sample · 250 nm radius · cells from visited views over {minutes} min": "Muestra regional · radio de 250 NM · celdas de las vistas visitadas en {minutes} min", "Retrying automatically at {time} UTC": "Reintento automático a las {time} UTC", "Retrying automatically after provider cooldown": "Reintento automático tras la pausa del proveedor", - "Updates automatically after pan or zoom and once a minute. The public feed covers a 250-nautical-mile circle around the view centre; zooming out does not make it a worldwide snapshot. Blank cells are outside sampled coverage or have insufficient observations.": "Se actualiza automáticamente al desplazar o hacer zoom y una vez por minuto. El feed público cubre un círculo de 250 millas náuticas alrededor del centro de la vista; alejarte no lo convierte en una instantánea mundial. Las celdas vacías están fuera de la cobertura muestreada o no tienen observaciones suficientes.", + "Updates automatically after pan or zoom and once a minute. The public feed covers a 250-nautical-mile circle around the view centre; zooming out does not make it a worldwide snapshot. Blank cells are outside sampled coverage or have insufficient observations.": "Se actualiza automáticamente al desplazar el mapa o hacer zoom y una vez por minuto. La fuente pública cubre un círculo de 250 millas náuticas alrededor del centro de la vista; alejarte no la convierte en una instantánea mundial. Las celdas vacías están fuera de la cobertura muestreada o no tienen observaciones suficientes.", "Low-accuracy score = 100 × max(0, bad − 1) / total: one aircraft is discounted, so this is not the raw share. Cells need {aircraft} aircraft over {minutes} minutes. Navigation-integrity anomalies are not a detection of jamming or spoofing.": "Índice de baja precisión = 100 × max(0, malas − 1) / total: se descuenta una aeronave, así que no es la proporción bruta. Cada celda necesita {aircraft} aeronaves en {minutes} minutos. Las anomalías de integridad de navegación no son una detección de interferencias ni de suplantación.", "SQUAWK {code} · {status}": "SQUAWK {code} · {status}", "ADS-B PRIORITY · {status}": "ADS-B PRIORIDAD · {status}", @@ -2450,7 +2450,7 @@ "TomTom key unavailable": "Clave de TomTom no disponible", "TomTom daily budget reached": "Se alcanzó el límite diario de TomTom", "Monthly traffic allowance used": "Cupo mensual de tráfico agotado", - "Traffic rate limit reached": "Límite de solicitudes de tráfico alcanzado", + "Traffic rate limit reached": "Límite de peticiones de tráfico alcanzado", "Traffic service unavailable": "Servicio de tráfico no disponible", "TomTom upstream unreachable": "Servidor de TomTom inaccesible", "TomTom flow unavailable": "Flujo de TomTom no disponible", @@ -3114,7 +3114,7 @@ "Changing provider reconnects voice if it is on.": "Cambiar de proveedor vuelve a conectar la voz si está activada.", "Gemini voice could not connect. Try again.": "No se pudo conectar la voz de Gemini. Inténtalo de nuevo.", "Gemini voice requires a same-origin app request": "La voz de Gemini requiere una solicitud del mismo origen que la aplicación", - "Add a Gemini API key in Provider Settings": "Añade una clave de API de Gemini en los ajustes de proveedores", + "Add a Gemini API key in Provider Settings": "Añade una clave de API de Gemini en Ajustes de proveedores", "The Gemini Live model configuration is invalid": "La configuración del modelo de Gemini Live no es válida", "Gemini voice is starting too often. Try again shortly.": "La voz de Gemini se está iniciando con demasiada frecuencia. Vuelve a intentarlo en breve.", "Gemini quota is unavailable. Try again later.": "La cuota de Gemini no está disponible. Inténtalo más tarde.", @@ -3215,18 +3215,18 @@ "Add the current view, style and layers as the next shot": "Añade la vista, el estilo y las capas actuales como el siguiente plano", "Replace the selected shot with the current view": "Sustituye el plano seleccionado por la vista actual", "Click a shot to select it, double-click to rename.": "Haz clic en un plano para seleccionarlo y doble clic para cambiarle el nombre.", - "scene|PLAY": "REPROD.", + "scene|PLAY": "REPRODUCIR", "Stop playback (Esc)": "Detén la reproducción (Esc)", "SAVE & SHARE": "GUARDAR Y COMPARTIR", "Download every scene as a JSON file you can import later": "Descarga todas las escenas como un archivo JSON que puedes importar más tarde", "Load scenes from an exported JSON file": "Carga escenas desde un archivo JSON exportado", "Download the timing log of the last play": "Descarga el registro de tiempos de la última reproducción", - "Play from this scene through the rest of the project": "Reproduce desde esta escena hasta el resto del proyecto", + "Play from this scene through the rest of the project": "Reproduce desde esta escena hasta el final del proyecto", "Play the shot after the selected one, moving on to the next scene when needed": "Reproduce el plano posterior al seleccionado y pasa a la siguiente escena si hace falta", "Edit anchors, data packs and shot details": "Edita anclas, paquetes de datos y detalles de los planos", "Download this scene as JSON or an asset bundle": "Descarga esta escena como JSON o como paquete de recursos", - "Left panels": "Paneles izquierdos", - "Right panels": "Paneles derechos", + "Left panels": "Paneles de la izquierda", + "Right panels": "Paneles de la derecha", "UTILITIES": "UTILIDADES", "Exit cockpit to fly to a drawing": "Sal de la cabina para volar a un dibujo", "Directions, drawing and your own files": "Indicaciones, dibujo y tus propios archivos", @@ -3393,7 +3393,7 @@ "Date unknown": "Fecha desconocida", "Position: centre of the protected area": "Posición: centro de la zona protegida", "Position approximate (within about {km} km)": "Posición aproximada (margen de unos {km} km)", - "Position surveyed (within about {m} m)": "Posición levantada (margen de unos {m} m)", + "Position surveyed (within about {m} m)": "Posición medida (margen de unos {m} m)", "Vessel: {value}": "Embarcación: {value}", "What happened: {value}": "Qué ocurrió: {value}", "Depth: {m} m ({quality})": "Profundidad: {m} m ({quality})", @@ -3402,8 +3402,8 @@ "Cargo or finds: {value}": "Carga o hallazgos: {value}", "Protected wreck": "Pecio protegido", "Source: {value}": "Fuente: {value}", - "Click the card to open EMODnet ↗": "Haz clic en la ficha para abrir EMODnet ↗", - "Click the card to open the source ↗": "Haz clic en la ficha para abrir la fuente ↗", + "Click the card to open EMODnet ↗": "Haz clic en la tarjeta para abrir EMODnet ↗", + "Click the card to open the source ↗": "Haz clic en la tarjeta para abrir la fuente ↗", "Wikidata unavailable; showing EMODnet only": "Wikidata no disponible; solo se muestra EMODnet", "EMODnet unavailable; showing Wikidata only": "EMODnet no disponible; solo se muestra Wikidata", "Shipwrecks unavailable": "Naufragios no disponibles", @@ -3417,8 +3417,8 @@ "Shipwreck": "Naufragio", "Fatalities: {count}": "Fallecidos: {count}", "Survivors: {count}": "Supervivientes: {count}", - "Click the card to open the article ↗": "Haz clic en la ficha para abrir el artículo ↗", - "European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "Los pecios históricos europeos proceden de EMODnet Human Activities; los naufragios notables de todo el mundo, de Wikidata. El color indica cuándo se hundió el barco; el marcador crece con el número de muertos. Haz clic en un marcador para ver su ficha.", + "Click the card to open the article ↗": "Haz clic en la tarjeta para abrir el artículo ↗", + "European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "Los pecios históricos europeos proceden de EMODnet Human Activities; los naufragios notables de todo el mundo, de Wikidata. El color indica cuándo se hundió el barco; el marcador crece con el número de muertos. Haz clic en un marcador para ver su tarjeta.", "All years": "Todos los años", "Period": "Periodo", "From": "Desde", @@ -3447,7 +3447,7 @@ "{label} {detail} — click to retry": "{label} {detail} — haz clic para reintentar", "Wildlife sightings source unavailable": "Fuente de avistamientos no disponible", "Show only community-confirmed (research-grade) sightings": "Mostrar solo observaciones confirmadas por la comunidad (grado de investigación)", - "No recent sightings in view": "No hay avistamientos recientes en la vista", + "No recent sightings in view": "No hay avistamientos recientes a la vista", "Acartia feed unavailable": "Fuente Acartia no disponible", "Show all taxa": "Mostrar todos los grupos taxonómicos", "BIRDS": "AVES", @@ -3466,33 +3466,33 @@ "Exit cockpit to fly to a sighting": "Sal de la cabina para volar a un avistamiento", "Exit cockpit to fly to a sighting area": "Sal de la cabina para volar a un área de avistamiento", "Wildlife": "Vida silvestre", - "Zoom in for local sightings": "Acerca para ver avistamientos locales", + "Zoom in for local sightings": "Acércate para ver avistamientos locales", "Loading photo...": "Cargando foto…", "Photo unavailable": "Foto no disponible", "Location obscured to a 0.2° cell": "Ubicación oculta en una celda de 0,2°", "Photo of {name}": "Foto de {name}", "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", - "{count} sightings in view": "{count} avistamientos en la vista", + "{count} sightings in view": "Avistamientos a la vista: {count}", "Close sighting": "Cerrar avistamiento", - "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.": "Fauna observada en las últimas 48 h (cetáceos 7 días). El color y el símbolo de la insignia indican el grupo taxonómico. Las especies sensibles solo se muestran en celdas de 0,2°; los informes de mamíferos marinos de Acartia en el mar de Salish se redondean a 0,01°. No es un censo.", + "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.": "Fauna y flora observadas en las últimas 48 h (cetáceos 7 días). El color y el símbolo de la insignia indican el grupo taxonómico. Las especies sensibles solo se muestran en celdas de 0,2°; los informes de mamíferos marinos de Acartia en el mar de Salish se redondean a 0,01°. No es un censo.", "Open {name} on iNaturalist": "Abrir {name} en iNaturalist", "Open {name} on Acartia": "Abrir {name} en Acartia", "iNaturalist": "iNaturalist", "iNaturalist contributors · licences per record": "Colaboradores de iNaturalist · licencias por registro", "iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Colaboradores de iNaturalist · Acartia {count} (mar de Salish) · licencias por registro", - "{days}d": "{days}d", + "{days}d": "{days} d", "All markers": "Todos los marcadores", "Show markers of every age": "Mostrar marcadores de cualquier antigüedad", - "Maximum event age: {days}d; undated markers are kept": "Antigüedad máxima: {days} días; se conservan los marcadores sin fecha", + "Maximum event age: {days}d; undated markers are kept": "Antigüedad máxima: {days} d; se conservan los marcadores sin fecha", "event age filter|All": "Todos", "Event age": "Antigüedad", - "Event age: {days}d · undated kept": "Antigüedad: {days} días · se conservan los marcadores sin fecha", + "Event age: {days}d · undated kept": "Antigüedad: {days} d · se conservan los marcadores sin fecha", "Close camera preview": "Cerrar vista previa de la cámara", "Refresh map still": "Actualizar imagen fija del mapa", "The map still uses a separate request and may show a different moment.": "La imagen del mapa usa una solicitud independiente y puede mostrar otro momento.", "Video plays from Québec 511, which sees your IP address.": "El vídeo se reproduce desde Québec 511, que ve tu dirección IP.", - "Press OPEN to load the photo": "Pulse ABRIR para cargar la foto", + "Press OPEN to load the photo": "Pulsa ABRIR para cargar la foto", "Captured {month}": "Capturada en {month}", "this spot": "este punto", "the view centre": "el centro de la vista", @@ -3537,24 +3537,24 @@ "Zoom out (−)": "Alejar (−)", "ORBITAL VIEW · OFF-NADIR": "VISTA ORBITAL · FUERA DEL NADIR", "World time": "Hora mundial", - "Back to live": "Volver al directo", - "Live": "En directo", + "Back to live": "Volver al modo en vivo", + "Live": "En vivo", "1 h/s": "1 h/s", "1 d/s": "1 d/s", "1 wk/s": "1 sem/s", "Play speed": "Velocidad de reproducción", - "Scroll or pinch to zoom, drag to move along · double-click to see it all": "Desplaza o pellizca para ampliar, arrastra para moverte · doble clic para verlo todo", + "Scroll or pinch to zoom, drag to move along · double-click to see it all": "Usa la rueda o pellizca para hacer zoom, arrastra para desplazarte · doble clic para verlo todo", "1 min/s": "1 min/s", "Rail": "Ferrocarril", "Urban rail": "Urbano", - "Local rail": "Cercanías", + "Local rail": "Suburbano", "Regional rail": "Regional", "National rail": "Larga distancia", "International rail": "Internacional", "Hide {category}": "Ocultar {category}", "Show {category}": "Mostrar {category}", "Live GPS": "GPS en vivo", - "Estimated from station reports": "Estimada a partir de avisos de estaciones", + "Estimated from station reports": "Posición estimada a partir de avisos de estaciones", "To {headsign}": "Hacia {headsign}", "Next {stop} {time} {relative}": "Próxima parada {stop} {time} {relative}", "Prev {stop} {time} {relative}": "Parada anterior {stop} {time} {relative}", @@ -3564,13 +3564,13 @@ "Photo: {photographer} · {licence}": "Foto: {photographer} · {licence}", "Photo · {licence}": "Foto · {licence}", "The shared flight isn't reporting now — showing where it was last seen": "El vuelo compartido no está transmitiendo ahora; se muestra dónde se vio por última vez", - "The shared flight isn't reporting now — showing where it was at {time}": "El vuelo compartido no está transmitiendo ahora; se muestra dónde estaba a las {time}", + "The shared flight isn't reporting now — showing where it was at {time}": "El vuelo compartido no está transmitiendo ahora; se muestra dónde estaba ({time})", "The shared military flight isn't reporting now — showing where it was last seen": "El vuelo militar compartido no está transmitiendo ahora; se muestra dónde se vio por última vez", - "The shared military flight isn't reporting now — showing where it was at {time}": "El vuelo militar compartido no está transmitiendo ahora; se muestra dónde estaba a las {time}", + "The shared military flight isn't reporting now — showing where it was at {time}": "El vuelo militar compartido no está transmitiendo ahora; se muestra dónde estaba ({time})", "The shared vessel isn't reporting now — showing where it was last seen": "El buque compartido no está transmitiendo ahora; se muestra dónde se vio por última vez", - "The shared vessel isn't reporting now — showing where it was at {time}": "El buque compartido no está transmitiendo ahora; se muestra dónde estaba a las {time}", + "The shared vessel isn't reporting now — showing where it was at {time}": "El buque compartido no está transmitiendo ahora; se muestra dónde estaba ({time})", "The shared satellite isn't reporting now — showing where it was last seen": "El satélite compartido no está transmitiendo ahora; se muestra dónde se vio por última vez", - "The shared satellite isn't reporting now — showing where it was at {time}": "El satélite compartido no está transmitiendo ahora; se muestra dónde estaba a las {time}", + "The shared satellite isn't reporting now — showing where it was at {time}": "El satélite compartido no está transmitiendo ahora; se muestra dónde estaba ({time})", "ICAO: {id}": "ICAO: {id}", "NORAD: {id}": "NORAD: {id}", "MMSI: {id}": "MMSI: {id}", @@ -3686,7 +3686,7 @@ "Resolution": "Resolución", "Flown": "Vuelo", "Updates": "Actualización", - "Ortho quality": "Calidad del orto", + "Ortho quality": "Calidad de la ortofoto", "Below 250 m": "Menos de 250 m", "250–500 m": "250–500 m", "500–1,000 m": "500–1000 m", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 161ce716a..98633538b 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1,32 +1,32 @@ { - "News feed unavailable": "Fil d’actualités indisponible", - "Developing: {name}": "En cours : {name}", + "News feed unavailable": "Fil d'actualités indisponible", + "Developing: {name}": "En cours : {name}", "{count} events today": { - "one": "{count} événement aujourd’hui", - "many": "{count} événements aujourd’hui", - "other": "{count} événements aujourd’hui" + "one": "{count} événement aujourd'hui", + "many": "{count} d'événements aujourd'hui", + "other": "{count} événements aujourd'hui" }, "{count} reports today": { - "one": "{count} article aujourd’hui", - "many": "{count} articles aujourd’hui", - "other": "{count} articles aujourd’hui" + "one": "{count} article aujourd'hui", + "many": "{count} d'articles aujourd'hui", + "other": "{count} articles aujourd'hui" }, - "Disputed: {name}": "Contesté : {name}", + "Disputed: {name}": "Zone contestée : {name}", "Video": "Vidéo", "Photos": "Photos", "Claimed by {who}": "Selon {who}", "Claimed": "Affirmation", - "Not yet confirmed by others": "Pas encore confirmé par d’autres", - "Reported by {side} sources": "Rapporté par des sources {side}", - "Reported by {sides}": "Rapporté par {sides}", - "{side} sources": "sources {side}", + "Not yet confirmed by others": "Pas encore confirmé par d'autres", + "Reported by {side} sources": "Rapporté par des sources : {side}", + "Reported by {sides}": "Rapporté par des sources : {sides}", + "{side} sources": "{side}", "Official": "Officiel", "Reported by both sides": "Rapporté par les deux camps", "Independently reported": "Rapporté de façon indépendante", "Single source": "Source unique", "International": "Internationale", "Independent": "Indépendante", - "Unattributed": "Non attribué", + "Unattributed": "Non attribuée", "Outside party": "Partie extérieure", "Ukraine–Russia": "Ukraine–Russie", "Israel–Gaza–Lebanon": "Israël–Gaza–Liban", @@ -48,19 +48,19 @@ "Minor incident": "Incident mineur", "Significant": "Important", "Major": "Majeur", - "age|now": "maintenant", + "age|now": "à l'instant", "{minutes} min": "{minutes} min", "{hours} h": "{hours} h", "{count} sources": { "one": "{count} source", - "many": "{count} sources", + "many": "{count} de sources", "other": "{count} sources" }, - "News feed not configured on this server": "Fil d’actualités non configuré sur ce serveur", - "Malformed news hotspots response": "Réponse des foyers d’actualité mal formée", - "Malformed news feed response": "Réponse du fil d’actualités mal formée", + "News feed not configured on this server": "Fil d'actualités non configuré sur ce serveur", + "Malformed news hotspots response": "Réponse des foyers d'actualité mal formée", + "Malformed news feed response": "Réponse du fil d'actualités mal formée", "{place} (placed by model)": "{place} (placé par le modèle)", - "English headline by the model": "Titre anglais du modèle", + "English headline by the model": "Titre traduit en anglais par le modèle", "Not placed on the map": "Non placé sur la carte", "Close": "Fermer", "Any severity": "Toute gravité", @@ -68,26 +68,26 @@ "{severity} only": "{severity} uniquement", "All categories": "Toutes les catégories", "Reading the news…": "Lecture des actualités…", - "News, last {hours} hours": "Actualités des dernières {hours} heures", - "Last {hours} h": "Dernières {hours} h", + "News, last {hours} hours": "Actualités sur {hours} h", + "Last {hours} h": "Sur {hours} h", "Time window": "Période", "Conflicts": "Conflits", "Local": "Local", - "News sections": "Rubriques d’actualité", - "Zoom in to see local news": "Zoomez pour voir l’actualité locale", + "News sections": "Rubriques d'actualité", + "Zoom in to see local news": "Zoomez pour voir l'actualité locale", "Hotspots": "Foyers", "Severity": "Gravité", "Category": "Catégorie", - "Nothing reported near here in the last {hours} h": "Rien de signalé près d’ici ces dernières {hours} h", + "Nothing reported near here in the last {hours} h": "Rien de signalé près d'ici en {hours} h", "Latest news": "Dernières actualités", - "No news matches these filters in the last {hours} h": "Aucune actualité des dernières {hours} h ne correspond à ces filtres", + "No news matches these filters in the last {hours} h": "Aucune actualité ne correspond à ces filtres sur {hours} h", "Load older news": "Charger des actualités plus anciennes", "How to read the map": "Comment lire la carte", - "Colour: severity · icon: type of event": "Couleur : gravité · icône : type d’événement", - "Solid rim: reported by both sides or independently. Dashed rim: a claim. Thin rim: one source, or one side’s own press.": "Bord plein : rapporté par les deux camps ou de façon indépendante. Bord en tirets : une affirmation. Bord fin : une source, ou la presse d’un camp.", - "Larger: more sources · fainter: older. Picture card: a major or fast-growing event with two or more sources.": "Plus grand : plus de sources · plus pâle : plus ancien. Carte illustrée : un événement majeur ou en forte croissance, avec au moins deux sources.", - "Side labels describe the source, not whether a report is true.": "Les étiquettes de camp décrivent la source, pas la véracité de l’article.", - "Exit cockpit to fly to a news report": "Quittez le cockpit pour aller à un article", + "Colour: severity · icon: type of event": "Couleur : gravité · icône : type d'événement", + "Solid rim: reported by both sides or independently. Dashed rim: a claim. Thin rim: one source, or one side’s own press.": "Bord plein : rapporté par les deux camps ou de façon indépendante. Bord en tirets : une affirmation. Bord fin : une source, ou la presse d'un camp.", + "Larger: more sources · fainter: older. Picture card: a major or fast-growing event with two or more sources.": "Plus grand : plus de sources · plus pâle : plus ancien. Fiche illustrée : un événement majeur ou en forte croissance, avec au moins deux sources.", + "Side labels describe the source, not whether a report is true.": "Les étiquettes de camp décrivent la source, pas la véracité de l'article.", + "Exit cockpit to fly to a news report": "Quittez le cockpit pour rejoindre un article", "News items in the feed": "Articles dans le fil", "Pick a shape, then click the map.": "Choisissez une forme, puis cliquez sur la carte.", "Enter to place the pin, Esc to cancel.": "Enter pour poser l'épingle, Esc pour annuler.", @@ -951,7 +951,7 @@ "Hide the two-line element set": "Masquer le jeu d'éléments à deux lignes", "Two-line element set": "Jeu d'éléments à deux lignes", "Return the view to nadir": "Ramener la vue au nadir", - "Return to nadir (Home or 0)": "Retour au nadir (Home ou 0)", + "Return to nadir (Home or 0)": "Revenir au nadir (Home ou 0)", "NADIR": "AU NADIR", "Selected Space Mission": "Mission spatiale sélectionnée", "SELECTED SPACE MISSION": "MISSION SPATIALE SÉLECTIONNÉE", @@ -1754,9 +1754,9 @@ "other": "{count} caméras chargées · activer CCTV pour les utiliser" }, "{count} more cameras outside this view · pan or zoom out": { - "one": "{count} autre caméra hors de cette vue · déplacer ou dézoomer", - "many": "{count} d'autres caméras hors de cette vue · déplacer ou dézoomer", - "other": "{count} autres caméras hors de cette vue · déplacer ou dézoomer" + "one": "{count} autre caméra hors de cette vue · déplacez la carte ou dézoomez", + "many": "{count} d'autres caméras hors de cette vue · déplacez la carte ou dézoomez", + "other": "{count} autres caméras hors de cette vue · déplacez la carte ou dézoomez" }, "Enable CCTV to load camera intersections": "Activez CCTV pour charger les carrefours équipés de caméras", "Enable CCTV to start camera-linked intelligence summaries.": "Activez CCTV pour lancer les résumés de renseignement liés aux caméras.", @@ -1782,7 +1782,7 @@ "Cycle briefing pages automatically every 9 seconds (Signals → News → Local). Pauses while you hover or focus the panel. Live signal data refreshes continuously either way.": "Faire défiler automatiquement les pages du briefing toutes les 9 secondes (Signaux → Actus → Local). Pause pendant le survol ou quand le panneau a le focus. Les signaux en direct s'actualisent en continu dans les deux cas.", "ACQUIRING REGIONAL NEWS": "ACQUISITION DES ACTUS RÉGIONALES", "REGIONAL NEWS UNAVAILABLE": "ACTUS RÉGIONALES INDISPONIBLES", - "CACHED · REFRESH FAILED": "EN CACHE · ÉCHEC DE L’ACTUALISATION", + "CACHED · REFRESH FAILED": "EN CACHE · ÉCHEC DE L'ACTUALISATION", "RESOLVING REGION": "IDENTIFICATION DE LA RÉGION", "REGION UNAVAILABLE": "RÉGION INDISPONIBLE", "NO NEWS IN THE LAST {hours} H": "AUCUNE ACTU CES {hours} DERNIÈRES H", @@ -1794,9 +1794,9 @@ "REGIONAL NEWS": "ACTUS RÉGIONALES", "{label} · {radius} AIR/SEA WINDOW": "{label} · AIR/MER {radius}", "{scope} · INSTALLATIONS {coverage}": "{scope} · INSTALLATIONS : {coverage}", - "MILITARY FEED OFF · PARTIAL PICTURE": "FLUX MILITAIRE COUPÉ · SITUATION PARTIELLE", - "FLIGHTS FEED OFF · PARTIAL PICTURE": "FLUX DES VOLS COUPÉ · SITUATION PARTIELLE", - "NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "AÉRONEFS PROCHES INCOMPLETS · PAS UN FEU VERT", + "MILITARY FEED OFF · PARTIAL PICTURE": "FLUX MILITAIRE DÉSACTIVÉ · SITUATION PARTIELLE", + "FLIGHTS FEED OFF · PARTIAL PICTURE": "FLUX DES VOLS DÉSACTIVÉ · SITUATION PARTIELLE", + "NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "AÉRONEFS PROCHES : DONNÉES INCOMPLÈTES · PAS UN FEU VERT", "CONTEXT STANDBY": "CONTEXTE EN VEILLE", "ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "ACTIVER LE CONTEXTE GLOBAL POUR LES PINGS DE PROXIMITÉ", "CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "CONTACT PERDU · DERNIER RELEVÉ CONNU · PAS UN FEU VERT", @@ -1832,7 +1832,7 @@ "ROUTE DATA UNAVAILABLE": "DONNÉES DE ROUTE INDISPONIBLES", "CONTACT NAVIGATION OFF": "NAVIGATION DES CONTACTS DÉSACTIVÉE", "THE BRIDGE STAYS WITH THE SELECTED VESSEL": "LA PASSERELLE RESTE SUR LE NAVIRE SÉLECTIONNÉ", - "{range} NM": "{range} MN", + "{range} NM": "{range} NM", "AIS DESTINATION · CREW REPORTED": "DESTINATION AIS · DÉCLARÉE PAR L'ÉQUIPAGE", "DEST {bearing}": "DEST {bearing}", "Disable cockpit weather effects": "Désactiver les effets météo du cockpit", @@ -2867,7 +2867,7 @@ "Next cockpit vision style": "Style de vision suivant du cockpit", "Next vision style": "Style de vision suivant", "Current aircraft heading": "Cap actuel de l'aéronef", - "Look ahead: turn the bridge view back to the bow": "Vue avant : ramener la vue passerelle vers l'étrave", + "Look ahead: turn the bridge view back to the bow": "Vue avant : ramener la vue passerelle vers l'étrave", "Look ahead (Home or 0)": "Vue avant (Home ou 0)", "LOOK AHEAD": "VUE AVANT", "Current vessel heading": "Cap actuel du navire", @@ -2880,7 +2880,7 @@ "Contact cockpit summary": "Résumé cockpit du contact", "CONTACT": "CONTACT", "CONTACTS · 250 KM": "CONTACTS · 250 KM", - "RINGS {spacing} NM": "CERCLES {spacing} MN", + "RINGS {spacing} NM": "CERCLES {spacing} NM", "{count} CONTACTS": { "one": "{count} CONTACT", "many": "{count} DE CONTACTS", @@ -3195,7 +3195,7 @@ "Add the current view, style and layers as the next shot": "Ajouter la vue, le style et les couches actuels comme plan suivant", "Replace the selected shot with the current view": "Remplacer le plan sélectionné par la vue actuelle", "Click a shot to select it, double-click to rename.": "Cliquez sur un plan pour le sélectionner, double-cliquez pour le renommer.", - "Play from this scene through the rest of the project": "Lire de cette scène jusqu'à la fin du projet", + "Play from this scene through the rest of the project": "Lire à partir de cette scène jusqu'à la fin du projet", "Stop playback (Esc)": "Arrêter la lecture (Esc)", "Play the shot after the selected one, moving on to the next scene when needed": "Lire le plan qui suit la sélection, en passant à la scène suivante si besoin", "SAVE & SHARE": "ENREGISTRER ET PARTAGER", @@ -3372,40 +3372,40 @@ "Too many chat requests. Try again in {seconds} s.": "Trop de requêtes de discussion. Réessayez dans {seconds} s.", "The provider refused access for this key. Check the key's project, region or permissions.": "Le fournisseur a refusé l'accès pour cette clé. Vérifiez le projet, la région ou les autorisations de la clé.", "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "Le compte du fournisseur n'a plus de crédit. Vérifiez la facturation ou choisissez un autre fournisseur d'IA dans les paramètres.", - "Last 24 h of news. Each report links to its publisher; places and categories are read from the text and data automatically and can be wrong. Side labels, where shown, describe the source, not whether a report is true.": "Dernières 24 h d’actualité. Chaque article renvoie à son éditeur ; lieux et catégories sont lus automatiquement dans le texte et les données et peuvent être faux. Les étiquettes de camp, là où elles figurent, décrivent la source, pas la véracité de l’article.", + "Last 24 h of news. Each report links to its publisher; places and categories are read from the text and data automatically and can be wrong. Side labels, where shown, describe the source, not whether a report is true.": "Dernières 24 h d'actualité. Chaque article renvoie à son éditeur ; lieux et catégories sont lus automatiquement dans le texte et les données et peuvent être faux. Les étiquettes de camp, là où elles figurent, décrivent la source, pas la véracité de l'article.", "Economy": "Économie", - "News from {area}": "Actualités : {area}", + "News from {area}": "Actualités : {area}", "Within {km} km of the view centre": "À moins de {km} km du centre de la vue", "{region}, {country}": "{region}, {country}", "via {source}": "via {source}", - "Local in the News layer: the day’s news from the outlets of the country in view": "Local dans la couche Actualités : l’actualité du jour des médias du pays affiché", + "Local in the News layer: the day’s news from the outlets of the country in view": "Local dans la couche Actualités : l'actualité du jour des médias du pays affiché", "{provider} refused its key": "{provider} a refusé sa clé", "Check {envVars} in Provider Settings": "Vérifiez {envVars} dans les paramètres des fournisseurs", "{provider} key required": "Clé {provider} requise", "{provider} limit reached for today": "{provider} a atteint sa limite du jour", "News resumes tomorrow, or with a paid plan": "Les actualités reprennent demain, ou avec une offre payante", - "News key required": "Clé d’actualités requise", + "News key required": "Clé d'actualités requise", "Precise conflict points in the News layer, beside GDELT’s articles": "Points de conflit précis dans la couche Actualités, à côté des articles de GDELT", "Worldwide": "Monde entier", "Shipwrecks": "Épaves", "Before 1900": "Avant 1900", "After 1945": "Après 1945", "Date unknown": "Date inconnue", - "Position: centre of the protected area": "Position : centre de la zone protégée", + "Position: centre of the protected area": "Position : centre de la zone protégée", "Position approximate (within about {km} km)": "Position approximative (à environ {km} km près)", "Position surveyed (within about {m} m)": "Position relevée (à environ {m} m près)", - "Vessel: {value}": "Navire : {value}", - "What happened: {value}": "Ce qui s'est passé : {value}", - "Depth: {m} m ({quality})": "Profondeur : {m} m ({quality})", - "Depth: {m} m": "Profondeur : {m} m", - "Notes: {value}": "Notes : {value}", - "Cargo or finds: {value}": "Cargaison ou découvertes : {value}", + "Vessel: {value}": "Navire : {value}", + "What happened: {value}": "Ce qui s'est passé : {value}", + "Depth: {m} m ({quality})": "Profondeur : {m} m ({quality})", + "Depth: {m} m": "Profondeur : {m} m", + "Notes: {value}": "Notes : {value}", + "Cargo or finds: {value}": "Cargaison ou découvertes : {value}", "Protected wreck": "Épave protégée", - "Source: {value}": "Source : {value}", + "Source: {value}": "Source : {value}", "Click the card to open EMODnet ↗": "Cliquez sur la fiche pour ouvrir EMODnet ↗", "Click the card to open the source ↗": "Cliquez sur la fiche pour ouvrir la source ↗", - "Wikidata unavailable; showing EMODnet only": "Wikidata indisponible ; seul EMODnet est affiché", - "EMODnet unavailable; showing Wikidata only": "EMODnet indisponible ; seul Wikidata est affiché", + "Wikidata unavailable; showing EMODnet only": "Wikidata indisponible ; seul EMODnet est affiché", + "EMODnet unavailable; showing Wikidata only": "EMODnet indisponible ; seul Wikidata est affiché", "Shipwrecks unavailable": "Épaves indisponibles", "EMODnet (European waters) · Wikidata (worldwide)": "EMODnet (eaux européennes) · Wikidata (monde)", "{count} wrecks": { @@ -3415,14 +3415,14 @@ }, "1900–1945": "1900–1945", "Shipwreck": "Naufrage", - "Fatalities: {count}": "Morts : {count}", - "Survivors: {count}": "Survivants : {count}", + "Fatalities: {count}": "Morts : {count}", + "Survivors: {count}": "Survivants : {count}", "Click the card to open the article ↗": "Cliquez sur la fiche pour ouvrir l'article ↗", - "European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "Les épaves patrimoniales européennes viennent d'EMODnet Human Activities ; les épaves notables du monde entier viennent de Wikidata. La couleur indique quand le navire a coulé ; le repère grossit avec le nombre de morts. Cliquez sur un repère pour voir sa fiche.", + "European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "Les épaves patrimoniales européennes viennent d'EMODnet Human Activities ; les épaves notables du monde entier viennent de Wikidata. La couleur indique quand le navire a coulé ; le marqueur grossit avec le nombre de morts. Cliquez sur un marqueur pour voir sa fiche.", "All years": "Toutes les années", "Period": "Période", - "From": "Du", - "To": "Au", + "From": "De", + "To": "À", "First year of the shipwreck period": "Première année de la période des naufrages", "Last year of the shipwreck period": "Dernière année de la période des naufrages", "SIGHTING": "OBSERVATION", @@ -3470,10 +3470,10 @@ "Loading photo...": "Chargement de la photo…", "Photo unavailable": "Photo indisponible", "Location obscured to a 0.2° cell": "Localisation masquée dans une cellule de 0,2°", - "Photo of {name}": "Photo de {name}", + "Photo of {name}": "Photo : {name}", "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", - "{count} sightings in view": "{count} observations dans la vue", + "{count} sightings in view": "Observations dans la vue : {count}", "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 en mer des Salish provenant d'Acartia sont arrondis à 0,01°. Pas un recensement.", "Open {name} on iNaturalist": "Ouvrir {name} sur iNaturalist", @@ -3496,17 +3496,17 @@ "Captured {month}": "Prise en {month}", "this spot": "cet endroit", "the view centre": "le centre de la vue", - "{source} at {place}": "{source} à {place}", - "Street level at {place}": "Niveau rue à {place}", + "{source} at {place}": "{source} : {place}", + "Street level at {place}": "Niveau rue : {place}", "Switching map": "Changement de carte", "Opening imagery": "Ouverture des images", - "Street View is ready: press {open} to load it": "Street View est prêt : appuyez sur {open} pour le charger", + "Street View is ready: press {open} to load it": "Street View est prêt : appuyez sur {open} pour le charger", "{source} cannot open this link's street-level photo without a working key": "{source} ne peut pas ouvrir la photo au niveau de la rue de ce lien sans clé valide", - "Street-level photos are hidden in the current view. Leave it first.": "Les photos au niveau de la rue sont masquées dans la vue actuelle. Quittez-la d’abord.", + "Street-level photos are hidden in the current view. Leave it first.": "Les photos au niveau de la rue sont masquées dans la vue actuelle. Quittez-la d'abord.", "STATIONS": "STATIONS", "Crewed stations and the craft that visit them": "Stations habitées et les vaisseaux qui s'y rendent", "IMAGING": "IMAGERIE", - "Earth observation: optical, radar, weather and science": "Observation de la Terre : optique, radar, météo et science", + "Earth observation: optical, radar, weather and science": "Observation de la Terre : optique, radar, météo et science", "TELECOM": "TÉLÉCOM", "Communications and navigation satellites, Starlink excepted": "Satellites de communication et de navigation, hors Starlink", "STARLINK": "STARLINK", @@ -3515,9 +3515,9 @@ "Space science, technology demonstration and other payloads": "Sciences spatiales, démonstrations technologiques et autres charges utiles", "DEBRIS": "DÉBRIS", "Rocket bodies, fragments and dead satellites": "Étages de fusée, fragments et satellites hors service", - "Classes may lag: SATCAT is a cached copy": "Classes peut-être en retard : SATCAT est une copie en cache", - "Classes may lag: SATCAT unavailable": "Classes peut-être en retard : SATCAT indisponible", - "CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "SATCAT de CelesTrak donne le type et l'état de chaque objet. Sans copie à jour, les classes reposent sur la base UCS, les noms et les groupes CelesTrak : un satellite hors service depuis peut encore apparaître comme actif.", + "Classes may lag: SATCAT is a cached copy": "Classes peut-être pas à jour : SATCAT est une copie en cache", + "Classes may lag: SATCAT unavailable": "Classes peut-être pas à jour : SATCAT indisponible", + "CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "SATCAT de CelesTrak donne le type et l'état de chaque objet. Sans copie à jour, les classes reposent sur la base UCS, les noms et les groupes CelesTrak : un satellite hors service depuis peut encore apparaître comme actif.", "STARLINK ···": "STARLINK ···", "STARLINK ✕": "STARLINK ✕", "SENSOR SWATH {value} KM": "FAUCHÉE CAPTEUR {value} KM", @@ -3531,7 +3531,7 @@ "AFT": "ARRIÈRE", "RIGHT": "DROITE", "LEFT": "GAUCHE", - "TRACK UP": "TRACE EN HAUT", + "TRACK UP": "ROUTE EN HAUT", "COURSE HOLD · AIS": "VERROU ROUTE · AIS", "Zoom in (+)": "Zoomer (+)", "Zoom out (−)": "Dézoomer (−)", @@ -3543,39 +3543,39 @@ "1 d/s": "1 j/s", "1 wk/s": "1 sem/s", "Play speed": "Vitesse de lecture", - "Scroll or pinch to zoom, drag to move along · double-click to see it all": "Faites défiler ou pincez pour zoomer, glissez pour vous déplacer · double-clic pour tout voir", + "Scroll or pinch to zoom, drag to move along · double-click to see it all": "Faites défiler ou pincez pour zoomer, glissez pour vous déplacer · double-cliquez pour tout voir", "1 min/s": "1 min/s", "Rail": "Trains", "Urban rail": "Urbain", - "Local rail": "Trains locaux", + "Local rail": "Périurbain", "Regional rail": "Régional", "National rail": "Grandes lignes", "International rail": "International", - "Hide {category}": "Masquer {category}", - "Show {category}": "Afficher {category}", + "Hide {category}": "Masquer la catégorie {category}", + "Show {category}": "Afficher la catégorie {category}", "Live GPS": "GPS en direct", - "Estimated from station reports": "Estimée d’après les signalements en gare", + "Estimated from station reports": "Position estimée d'après les passages en gare", "To {headsign}": "Vers {headsign}", "Next {stop} {time} {relative}": "Prochain arrêt {stop} {time} {relative}", "Prev {stop} {time} {relative}": "Arrêt précédent {stop} {time} {relative}", "(+{minutes} min late)": "(+{minutes} min de retard)", "Exit cockpit to fly to a train": "Quittez le cockpit pour rejoindre un train", "Photo © {photographer}": "Photo © {photographer}", - "Photo: {photographer} · {licence}": "Photo : {photographer} · {licence}", + "Photo: {photographer} · {licence}": "Photo : {photographer} · {licence}", "Photo · {licence}": "Photo · {licence}", "The shared flight isn't reporting now — showing where it was last seen": "Le vol partagé n'émet pas actuellement — affichage de sa dernière position connue", - "The shared flight isn't reporting now — showing where it was at {time}": "Le vol partagé n'émet pas actuellement — affichage de sa position à {time}", + "The shared flight isn't reporting now — showing where it was at {time}": "Le vol partagé n'émet pas actuellement — affichage de sa position ({time})", "The shared military flight isn't reporting now — showing where it was last seen": "Le vol militaire partagé n'émet pas actuellement — affichage de sa dernière position connue", - "The shared military flight isn't reporting now — showing where it was at {time}": "Le vol militaire partagé n'émet pas actuellement — affichage de sa position à {time}", + "The shared military flight isn't reporting now — showing where it was at {time}": "Le vol militaire partagé n'émet pas actuellement — affichage de sa position ({time})", "The shared vessel isn't reporting now — showing where it was last seen": "Le navire partagé n'émet pas actuellement — affichage de sa dernière position connue", - "The shared vessel isn't reporting now — showing where it was at {time}": "Le navire partagé n'émet pas actuellement — affichage de sa position à {time}", + "The shared vessel isn't reporting now — showing where it was at {time}": "Le navire partagé n'émet pas actuellement — affichage de sa position ({time})", "The shared satellite isn't reporting now — showing where it was last seen": "Le satellite partagé n'émet pas actuellement — affichage de sa dernière position connue", - "The shared satellite isn't reporting now — showing where it was at {time}": "Le satellite partagé n'émet pas actuellement — affichage de sa position à {time}", - "ICAO: {id}": "ICAO : {id}", - "NORAD: {id}": "NORAD : {id}", - "MMSI: {id}": "MMSI : {id}", - "Vessel UUID: {id}": "UUID du navire : {id}", - "Latitude must be within ±90° and longitude within ±180°": "La latitude doit être comprise dans ±90° et la longitude dans ±180°", + "The shared satellite isn't reporting now — showing where it was at {time}": "Le satellite partagé n'émet pas actuellement — affichage de sa position ({time})", + "ICAO: {id}": "OACI : {id}", + "NORAD: {id}": "NORAD : {id}", + "MMSI: {id}": "MMSI : {id}", + "Vessel UUID: {id}": "UUID du navire : {id}", + "Latitude must be within ±90° and longitude within ±180°": "La latitude doit être comprise entre −90° et 90°, et la longitude entre −180° et 180°", "Street Level is unavailable": "Niveau rue n'est pas disponible", "No street-level imagery source is configured": "Aucune source d'images au niveau de la rue n'est configurée", "Street Level could not be enabled": "Impossible d'activer Niveau rue", @@ -3589,22 +3589,22 @@ "{source} imagery could not be opened": "Impossible d'ouvrir les images de {source}", "{place} has no position to look for imagery at": "{place} n'a pas de position où chercher des images", "No {source} imagery near {place}": "Aucune image {source} près de {place}", - "{reason} (filters on: {filters})": "{reason} (filtres actifs : {filters})", + "{reason} (filters on: {filters})": "{reason} (filtres actifs : {filters})", "tiles unavailable": "tuiles indisponibles", "zoom in to load this layer": "zoomez pour charger cette couche", "Zoom to layer": "Zoomer sur la couche", - "Zoom to layer · fly to where this layer has data": "Zoomer sur la couche · voler là où cette couche a des données", - "Remove from Data Layers": "Retirer des Couches de données", - "Remove from Data Layers · add it again from the library": "Retirer des Couches de données · ajoutez-la à nouveau depuis la bibliothèque", + "Zoom to layer · fly to where this layer has data": "Zoomer sur la couche · rejoindre la zone où cette couche a des données", + "Remove from Data Layers": "Retirer du panneau Couches", + "Remove from Data Layers · add it again from the library": "Retirer du panneau Couches · ajoutez-la à nouveau depuis la bibliothèque", "Coverage outline": "Contour de couverture", "DRAPED": "DRAPÉ", "Below 250 m": "Moins de 250 m", "250–500 m": "250–500 m", - "500–1,000 m": "500–1 000 m", - "1,000–2,000 m": "1 000–2 000 m", - "2,000–3,000 m": "2 000–3 000 m", - "3,000 m and above": "3 000 m et plus", - "Exit cockpit to fly to a layer": "Quittez le cockpit pour voler vers une couche", + "500–1,000 m": "500–1 000 m", + "1,000–2,000 m": "1 000–2 000 m", + "2,000–3,000 m": "2 000–3 000 m", + "3,000 m and above": "3 000 m et plus", + "Exit cockpit to fly to a layer": "Quittez le cockpit pour rejoindre une couche", "Library": "Bibliothèque", "Environment": "Environnement", "Imagery": "Imagerie", @@ -3649,11 +3649,11 @@ }, "switch on a dataset to load features": "activez un jeu de données pour charger des entités", "Switched on by this instance · part of the CCTV layer": "Activé par cette instance · fait partie de la couche CCTV", - "Operator opt-in: set {env}=1 on the server": "Activation par l'opérateur : définir {env}=1 sur le serveur", - "Operator opt-in: add {id} to GEV_LIBRARY_ENABLE on the server": "Activation par l'opérateur : ajouter {id} à GEV_LIBRARY_ENABLE sur le serveur", + "Operator opt-in: set {env}=1 on the server": "Activation par l'opérateur : définir {env}=1 sur le serveur", + "Operator opt-in: add {id} to GEV_LIBRARY_ENABLE on the server": "Activation par l'opérateur : ajouter {id} à GEV_LIBRARY_ENABLE sur le serveur", "Turned off by this instance's operator": "Désactivé par l'opérateur de cette instance", "Needs a provider key on the server": "Nécessite une clé du fournisseur sur le serveur", - "Needs this app's server · not available in a static build": "Nécessite le serveur de cette app · indisponible dans une version statique", + "Needs this app's server · not available in a static build": "Nécessite le serveur de cette application · indisponible dans une version statique", "Checking what this instance serves…": "Vérification de ce que propose cette instance…", "Regional": "Régional", "Orthophoto": "Orthophoto", @@ -3663,13 +3663,13 @@ "True orthophoto": "Orthophoto vraie", "Type": "Type", "Resolution": "Résolution", - "Flown": "Vol", + "Flown": "Prise de vue", "Updates": "Mise à jour", "Ortho quality": "Qualité ortho", "DATA LIBRARY": "BIBLIOTHÈQUE DE DONNÉES", "Close data library": "Fermer la bibliothèque de données", "Data Library at a glance": "La bibliothèque de données en un coup d'œil", - "Live sources beyond the core layers. Each one is fetched from its provider while it is on; nothing is stored in this app. Add one and it appears in Data Layers under Library.": "Sources en direct au-delà des couches principales. Chacune est récupérée auprès de son fournisseur tant qu'elle est active ; rien n'est stocké dans cette app. Ajoutez-en une et elle apparaît dans Couches de données, sous Bibliothèque.", + "Live sources beyond the core layers. Each one is fetched from its provider while it is on; nothing is stored in this app. Add one and it appears in Data Layers under Library.": "Sources en direct au-delà des couches principales. Chacune est récupérée auprès de son fournisseur tant qu'elle est active ; rien n'est stocké dans cette application. Ajoutez-en une et elle apparaît dans le panneau Couches, sous Bibliothèque.", "Name, provider or country": "Nom, fournisseur ou pays", "Theme": "Thème", "All themes": "Tous les thèmes", @@ -3681,16 +3681,16 @@ "Zoom to {name}": "Zoomer sur {name}", "WORKING…": "EN COURS…", "ADD": "AJOUTER", - "Remove {name} from Data Layers": "Retirer {name} des Couches de données", - "Add {name} to Data Layers": "Ajouter {name} aux Couches de données", + "Remove {name} from Data Layers": "Retirer {name} du panneau Couches", + "Add {name} to Data Layers": "Ajouter {name} au panneau Couches", "CONFIRM · REMOVE ALL {count}": { "one": "CONFIRMER · RETIRER {count}", "many": "CONFIRMER · RETIRER LES {count}", "other": "CONFIRMER · RETIRER LES {count}" }, "REMOVE ALL": "TOUT RETIRER", - "Confirm: remove every added entry from Data Layers": "Confirmer : retirer des Couches de données toutes les entrées ajoutées", - "Remove every added entry from Data Layers": "Retirer des Couches de données toutes les entrées ajoutées", - "Showing {shown} of {total}": "{shown} sur {total} affichés", + "Confirm: remove every added entry from Data Layers": "Confirmer : retirer du panneau Couches toutes les entrées ajoutées", + "Remove every added entry from Data Layers": "Retirer du panneau Couches toutes les entrées ajoutées", + "Showing {shown} of {total}": "Affichées : {shown} sur {total}", "BROWSE DATA LIBRARY": "PARCOURIR LA BIBLIOTHÈQUE DE DONNÉES" } diff --git a/src/i18n/locales/he.json b/src/i18n/locales/he.json index 1e6066ab9..335515fb4 100644 --- a/src/i18n/locales/he.json +++ b/src/i18n/locales/he.json @@ -3293,17 +3293,17 @@ "Refresh map still": "רענון תמונת המפה", "The map still uses a separate request and may show a different moment.": "תמונת המפה נשלפת בבקשה נפרדת ועשויה להראות רגע שונה.", "Video plays from Québec 511, which sees your IP address.": "הווידאו זורם מ-Québec 511, שרואה את כתובת ה-IP שלך.", - "Press OPEN to load the photo": "לחץ על פתח כדי לטעון את התמונה", + "Press OPEN to load the photo": "לחץ על \"פתח\" כדי לטעון את התמונה", "Captured {month}": "צולם ב-{month}", "this spot": "המקום הזה", "the view centre": "מרכז התצוגה", - "{source} at {place}": "{source} ב-{place}", - "Street level at {place}": "רמת רחוב ב-{place}", + "{source} at {place}": "{source}: {place}", + "Street level at {place}": "צילומי רחוב: {place}", "Switching map": "מחליף מפה", "Opening imagery": "פותח צילומים", - "Street View is ready: press {open} to load it": "Street View מוכן: לחץ על {open} כדי לטעון אותו", - "{source} cannot open this link's street-level photo without a working key": "{source} לא יכול לפתוח את תמונת רמת הרחוב של הקישור הזה בלי מפתח תקף", - "Street-level photos are hidden in the current view. Leave it first.": "תמונות ברמת הרחוב מוסתרות בתצוגה הנוכחית. צא ממנה קודם.", + "Street View is ready: press {open} to load it": "Street View מוכן: לחץ על \"{open}\" כדי לטעון אותו", + "{source} cannot open this link's street-level photo without a working key": "{source} לא יכול לפתוח את תמונת הרחוב שבקישור הזה בלי מפתח תקף", + "Street-level photos are hidden in the current view. Leave it first.": "תמונות הרחוב מוסתרות בתצוגה הנוכחית. צא ממנה קודם.", "Ship bridge view": "תצוגת גשר האונייה", "Satellite orbital view": "התצוגה המסלולית של הלוויין", "Exit bridge view": "יציאה מתצוגת הגשר", @@ -3383,7 +3383,7 @@ "Next {stop} {time} {relative}": "התחנה הבאה {stop} {time} {relative}", "Prev {stop} {time} {relative}": "התחנה הקודמת {stop} {time} {relative}", "(+{minutes} min late)": "(+{minutes} דק׳ איחור)", - "Exit cockpit to fly to a train": "צא מתא הטייס כדי לטוס לרכבת", + "Exit cockpit to fly to a train": "צא מתא הטייס כדי לטוס אל רכבת", "Photo © {photographer}": "צילום © {photographer}", "Photo: {photographer} · {licence}": "צילום: {photographer} · {licence}", "Photo · {licence}": "צילום · {licence}", @@ -3422,16 +3422,16 @@ "{provider} limit reached for today": "המכסה היומית של {provider} נוצלה", "News resumes tomorrow, or with a paid plan": "החדשות יחזרו מחר, או עם תוכנית בתשלום", "{provider} refused its key": "{provider} דחה את המפתח", - "Check {envVars} in Provider Settings": "בדקו את {envVars} בהגדרות הספקים", + "Check {envVars} in Provider Settings": "בדוק את {envVars} בהגדרות הספקים", "{provider} key required": "נדרש מפתח של {provider}", "Developing: {name}": "מתפתח: {name}", "{count} events today": { - "one": "אירוע {count} היום", + "one": "{count} אירוע היום", "two": "{count} אירועים היום", "other": "{count} אירועים היום" }, "{count} reports today": { - "one": "דיווח {count} היום", + "one": "{count} דיווח היום", "two": "{count} דיווחים היום", "other": "{count} דיווחים היום" }, @@ -3440,12 +3440,12 @@ "via {source}": "דרך {source}", "Video": "וידאו", "Photos": "תמונות", - "Claimed by {who}": "נטען על ידי {who}", - "Claimed": "נטען", + "Claimed by {who}": "לטענת {who}", + "Claimed": "טענה", "Not yet confirmed by others": "טרם אושר על ידי אחרים", - "Reported by {side} sources": "דווח על ידי מקורות {side}", - "Reported by {sides}": "דווח על ידי {sides}", - "{side} sources": "מקורות {side}", + "Reported by {side} sources": "דווח על ידי מקורות: {side}", + "Reported by {sides}": "דווח על ידי מקורות: {sides}", + "{side} sources": "{side}", "Official": "רשמי", "Reported by both sides": "דווח על ידי שני הצדדים", "Independently reported": "דווח באופן עצמאי", @@ -3481,7 +3481,7 @@ "{minutes} min": "{minutes} דק׳", "{hours} h": "{hours} שע׳", "{count} sources": { - "one": "מקור {count}", + "one": "{count} מקור", "two": "{count} מקורות", "other": "{count} מקורות" }, @@ -3489,35 +3489,35 @@ "News feed not configured on this server": "פיד החדשות אינו מוגדר בשרת זה", "Malformed news hotspots response": "תגובת מוקדי החדשות פגומה", "Malformed news feed response": "תגובת פיד החדשות פגומה", - "Exit cockpit to fly to a news report": "צאו מתא הטייס כדי לטוס אל דיווח חדשותי", + "Exit cockpit to fly to a news report": "צא מתא הטייס כדי לטוס אל דיווח חדשותי", "{source} · LAST {hours} H": "{source} · {hours} השעות האחרונות", "CACHED · REFRESH FAILED": "מהמטמון · הרענון נכשל", "{source} · CACHED {age}": "{source} · מהמטמון {age}", - "NO NEWS IN THE LAST {hours} H": "אין חדשות ב־{hours} השעות האחרונות", - "{place} (placed by model)": "{place} (מוקם על ידי המודל)", + "NO NEWS IN THE LAST {hours} H": "אין חדשות ב-{hours} השעות האחרונות", + "{place} (placed by model)": "{place} (מיקום לפי המודל)", "English headline by the model": "כותרת באנגלית מאת המודל", - "Not placed on the map": "לא מוקם על המפה", + "Not placed on the map": "ללא מיקום על המפה", "Close": "סגירה", "Conflicts": "עימותים", "Local": "מקומי", - "Any severity": "כל חומרה", + "Any severity": "כל רמות החומרה", "{severity} and above": "{severity} ומעלה", "{severity} only": "{severity} בלבד", "All categories": "כל הקטגוריות", - "Last {hours} h": "{hours} שע׳ אחרונות", - "News from {area}": "חדשות מ{area}", - "Within {km} km of the view centre": "בטווח {km} ק״מ ממרכז התצוגה", + "Last {hours} h": "{hours} שע׳ אחורה", + "News from {area}": "חדשות מ-{area}", + "Within {km} km of the view centre": "בטווח {km} ק\"מ ממרכז התצוגה", "Reading the news…": "קורא את החדשות…", - "News, last {hours} hours": "חדשות, {hours} השעות האחרונות", + "News, last {hours} hours": "חדשות, {hours} שע׳ אחורה", "Time window": "חלון זמן", "News sections": "מדורי חדשות", "Hotspots": "מוקדים", "Severity": "חומרה", "Category": "קטגוריה", - "Zoom in to see local news": "התקרבו כדי לראות חדשות מקומיות", - "Nothing reported near here in the last {hours} h": "לא דווח דבר בסביבה ב־{hours} השעות האחרונות", + "Zoom in to see local news": "התקרב כדי לראות חדשות מקומיות", + "Nothing reported near here in the last {hours} h": "לא דווח דבר בסביבה בחלון של {hours} שע׳", "Latest news": "חדשות אחרונות", - "No news matches these filters in the last {hours} h": "אין חדשות התואמות למסננים אלה ב־{hours} השעות האחרונות", + "No news matches these filters in the last {hours} h": "אין חדשות התואמות למסננים אלה בחלון של {hours} שע׳", "Load older news": "טעינת חדשות ישנות יותר", "How to read the map": "איך לקרוא את המפה", "Colour: severity · icon: type of event": "צבע: חומרה · סמל: סוג האירוע", @@ -3531,8 +3531,8 @@ "After 1945": "אחרי 1945", "Date unknown": "תאריך לא ידוע", "Position: centre of the protected area": "מיקום: מרכז השטח המוגן", - "Position approximate (within about {km} km)": "מיקום משוער (בטווח של כ־{km} ק״מ)", - "Position surveyed (within about {m} m)": "מיקום שנמדד (בטווח של כ־{m} מ׳)", + "Position approximate (within about {km} km)": "מיקום משוער (בטווח של כ-{km} ק\"מ)", + "Position surveyed (within about {m} m)": "מיקום שנמדד (בטווח של כ-{m} מ׳)", "Shipwreck": "ספינה טרופה", "Fatalities: {count}": "הרוגים: {count}", "Survivors: {count}": "ניצולים: {count}", @@ -3549,32 +3549,32 @@ "Click the card to open the source ↗": "לחץ על הכרטיס כדי לפתוח את המקור ↗", "All years": "כל השנים", "Period": "תקופה", - "From": "מ־", + "From": "מ-", "First year of the shipwreck period": "השנה הראשונה בתקופת הטביעות", "To": "עד", "Last year of the shipwreck period": "השנה האחרונה בתקופת הטביעות", - "Wikidata unavailable; showing EMODnet only": "ויקינתונים אינו זמין; מוצג EMODnet בלבד", - "EMODnet unavailable; showing Wikidata only": "EMODnet אינו זמין; מוצג ויקינתונים בלבד", + "Wikidata unavailable; showing EMODnet only": "Wikidata אינו זמין; מוצג EMODnet בלבד", + "EMODnet unavailable; showing Wikidata only": "EMODnet אינו זמין; מוצג Wikidata בלבד", "Shipwrecks unavailable": "ספינות טרופות לא זמינות", "{count} wrecks": { "one": "{count} ספינה טרופה", "two": "{count} ספינות טרופות", "other": "{count} ספינות טרופות" }, - "EMODnet (European waters) · Wikidata (worldwide)": "EMODnet (מימי אירופה) · ויקינתונים (כל העולם)", - "European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "ספינות טרופות אירופיות בעלות ערך מורשת מגיעות מ־EMODnet Human Activities; ספינות טרופות בולטות מכל העולם מגיעות מוויקינתונים. הצבע מראה מתי הספינה טבעה; הסמן גדל ככל שמספר ההרוגים עולה. לחץ על סמן כדי לפתוח את הכרטיס שלו.", + "EMODnet (European waters) · Wikidata (worldwide)": "EMODnet (מימי אירופה) · Wikidata (כל העולם)", + "European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "ספינות טרופות אירופיות בעלות ערך מורשת מגיעות מ-EMODnet Human Activities; ספינות טרופות בולטות מכל העולם מגיעות מ-Wikidata. הצבע מראה מתי הספינה טבעה; הסמן גדל ככל שמספר ההרוגים עולה. לחץ על סמן כדי לפתוח את הכרטיס שלו.", "The shared flight isn't reporting now — showing where it was last seen": "הטיסה המשותפת לא מדווחת עכשיו — מוצג המיקום האחרון הידוע", - "The shared flight isn't reporting now — showing where it was at {time}": "הטיסה המשותפת לא מדווחת עכשיו — מוצג המיקום בשעה {time}", + "The shared flight isn't reporting now — showing where it was at {time}": "הטיסה המשותפת לא מדווחת עכשיו — מוצג המיקום נכון ל-{time}", "The shared military flight isn't reporting now — showing where it was last seen": "הטיסה הצבאית המשותפת לא מדווחת עכשיו — מוצג המיקום האחרון הידוע", - "The shared military flight isn't reporting now — showing where it was at {time}": "הטיסה הצבאית המשותפת לא מדווחת עכשיו — מוצג המיקום בשעה {time}", - "The shared vessel isn't reporting now — showing where it was last seen": "כלי השיט המשותף לא מדווח עכשיו — מוצג המיקום האחרון הידוע", - "The shared vessel isn't reporting now — showing where it was at {time}": "כלי השיט המשותף לא מדווח עכשיו — מוצג המיקום בשעה {time}", + "The shared military flight isn't reporting now — showing where it was at {time}": "הטיסה הצבאית המשותפת לא מדווחת עכשיו — מוצג המיקום נכון ל-{time}", + "The shared vessel isn't reporting now — showing where it was last seen": "האונייה המשותפת לא מדווחת עכשיו — מוצג המיקום האחרון הידוע", + "The shared vessel isn't reporting now — showing where it was at {time}": "האונייה המשותפת לא מדווחת עכשיו — מוצג המיקום נכון ל-{time}", "The shared satellite isn't reporting now — showing where it was last seen": "הלוויין המשותף לא מדווח עכשיו — מוצג המיקום האחרון הידוע", - "The shared satellite isn't reporting now — showing where it was at {time}": "הלוויין המשותף לא מדווח עכשיו — מוצג המיקום בשעה {time}", + "The shared satellite isn't reporting now — showing where it was at {time}": "הלוויין המשותף לא מדווח עכשיו — מוצג המיקום נכון ל-{time}", "ICAO: {id}": "ICAO: {id}", "NORAD: {id}": "NORAD: {id}", "MMSI: {id}": "MMSI: {id}", - "Vessel UUID: {id}": "UUID של כלי השיט: {id}", + "Vessel UUID: {id}": "UUID של האונייה: {id}", "Latitude must be within ±90° and longitude within ±180°": "קו הרוחב חייב להיות בטווח ±90° וקו האורך בטווח ±180°", "Street Level is unavailable": "Street Level אינו זמין", "No street-level imagery source is configured": "לא הוגדר מקור לצילומי רחוב", @@ -3641,13 +3641,13 @@ "other": "{count} על המפה" }, "none yet": "אין עדיין", - "Map features": "ישויות במפה", + "Map features": "אובייקטים במפה", "from {count} datasets": { "one": "מ-{count} מערך נתונים", "two": "מ-{count} מערכי נתונים", "other": "מ-{count} מערכי נתונים" }, - "switch on a dataset to load features": "הפעל מערך נתונים כדי לטעון ישויות", + "switch on a dataset to load features": "הפעל מערך נתונים כדי לטעון אובייקטים", "Switched on by this instance · part of the CCTV layer": "מופעל במופע הזה · חלק משכבת CCTV", "Operator opt-in: set {env}=1 on the server": "הפעלה ביוזמת המפעיל: הגדר {env}=1 בשרת", "Operator opt-in: add {id} to GEV_LIBRARY_ENABLE on the server": "הפעלה ביוזמת המפעיל: הוסף את {id} ל-GEV_LIBRARY_ENABLE בשרת", @@ -3657,7 +3657,7 @@ "Checking what this instance serves…": "בודק מה המופע הזה מספק…", "Regional": "אזורי", "Orthophoto": "אורתופוטו", - "Satellite mosaic": "פסיפס לוויני", + "Satellite mosaic": "פסיפס לווייני", "Oblique": "צילום אלכסוני", "Historic imagery": "צילומים היסטוריים", "True orthophoto": "אורתופוטו אמיתי", @@ -3684,9 +3684,9 @@ "Remove {name} from Data Layers": "הסרת {name} משכבות נתונים", "Add {name} to Data Layers": "הוספת {name} לשכבות נתונים", "CONFIRM · REMOVE ALL {count}": { - "one": "אישור · הסרת {count}", - "two": "אישור · הסרת כל {count}", - "other": "אישור · הסרת כל {count}" + "one": "אישור · הסרה ({count})", + "two": "אישור · הסרת הכול ({count})", + "other": "אישור · הסרת הכול ({count})" }, "REMOVE ALL": "הסרת הכול", "Confirm: remove every added entry from Data Layers": "אישור: הסרת כל הפריטים שנוספו משכבות נתונים", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 2864e1d17..c6127b3fb 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -1,25 +1,25 @@ { "News feed unavailable": "Feed delle notizie non disponibile", - "Developing: {name}": "In sviluppo: {name}", + "Developing: {name}": "In evoluzione: {name}", "{count} events today": { "one": "{count} evento oggi", - "many": "{count} eventi oggi", + "many": "{count} di eventi oggi", "other": "{count} eventi oggi" }, "{count} reports today": { "one": "{count} notizia oggi", - "many": "{count} notizie oggi", + "many": "{count} di notizie oggi", "other": "{count} notizie oggi" }, - "Disputed: {name}": "Conteso: {name}", + "Disputed: {name}": "Area contesa: {name}", "Video": "Video", "Photos": "Foto", "Claimed by {who}": "Secondo {who}", "Claimed": "Affermazione", "Not yet confirmed by others": "Non ancora confermato da altri", - "Reported by {side} sources": "Riportato da fonti {side}", - "Reported by {sides}": "Riportato da {sides}", - "{side} sources": "fonti {side}", + "Reported by {side} sources": "Riportato da fonti: {side}", + "Reported by {sides}": "Riportato da fonti: {sides}", + "{side} sources": "{side}", "Official": "Ufficiale", "Reported by both sides": "Riportato da entrambe le parti", "Independently reported": "Riportato in modo indipendente", @@ -53,14 +53,14 @@ "{hours} h": "{hours} h", "{count} sources": { "one": "{count} fonte", - "many": "{count} fonti", + "many": "{count} di fonti", "other": "{count} fonti" }, "News feed not configured on this server": "Feed delle notizie non configurato su questo server", "Malformed news hotspots response": "Risposta dei focolai di notizie non valida", "Malformed news feed response": "Risposta del feed delle notizie non valida", - "{place} (placed by model)": "{place} (collocato dal modello)", - "English headline by the model": "Titolo inglese del modello", + "{place} (placed by model)": "{place} (posizione stimata dal modello)", + "English headline by the model": "Titolo tradotto in inglese dal modello", "Not placed on the map": "Non collocato sulla mappa", "Close": "Chiudi", "Any severity": "Qualsiasi gravità", @@ -68,8 +68,8 @@ "{severity} only": "Solo {severity}", "All categories": "Tutte le categorie", "Reading the news…": "Lettura delle notizie…", - "News, last {hours} hours": "Notizie delle ultime {hours} ore", - "Last {hours} h": "Ultime {hours} h", + "News, last {hours} hours": "Notizie, periodo in ore: {hours}", + "Last {hours} h": "Periodo: {hours} h", "Time window": "Periodo", "Conflicts": "Conflitti", "Local": "Locale", @@ -78,16 +78,16 @@ "Hotspots": "Focolai", "Severity": "Gravità", "Category": "Categoria", - "Nothing reported near here in the last {hours} h": "Nulla segnalato qui vicino nelle ultime {hours} h", + "Nothing reported near here in the last {hours} h": "Nessuna segnalazione qui vicino · periodo: {hours} h", "Latest news": "Ultime notizie", - "No news matches these filters in the last {hours} h": "Nessuna notizia delle ultime {hours} h corrisponde a questi filtri", + "No news matches these filters in the last {hours} h": "Nessuna notizia corrisponde a questi filtri · periodo: {hours} h", "Load older news": "Carica notizie precedenti", "How to read the map": "Come leggere la mappa", "Colour: severity · icon: type of event": "Colore: gravità · icona: tipo di evento", - "Solid rim: reported by both sides or independently. Dashed rim: a claim. Thin rim: one source, or one side’s own press.": "Bordo pieno: riportato da entrambe le parti o in modo indipendente. Bordo tratteggiato: un’affermazione. Bordo sottile: una fonte, o la stampa di una parte.", + "Solid rim: reported by both sides or independently. Dashed rim: a claim. Thin rim: one source, or one side’s own press.": "Bordo pieno: riportato da entrambe le parti o in modo indipendente. Bordo tratteggiato: un'affermazione. Bordo sottile: una fonte, o la stampa di una parte.", "Larger: more sources · fainter: older. Picture card: a major or fast-growing event with two or more sources.": "Più grande: più fonti · più tenue: più vecchio. Scheda con immagine: un evento grave o in rapida crescita con due o più fonti.", - "Side labels describe the source, not whether a report is true.": "Le etichette di parte descrivono la fonte, non se la notizia è vera.", - "Exit cockpit to fly to a news report": "Esci dalla cabina per volare a una notizia", + "Side labels describe the source, not whether a report is true.": "Le etichette delle parti descrivono la fonte, non se la notizia è vera.", + "Exit cockpit to fly to a news report": "Esci dal cockpit per volare a una notizia", "News items in the feed": "Notizie nel feed", "Pick a shape, then click the map.": "Scegli una forma, poi fai clic sulla mappa.", "Enter to place the pin, Esc to cancel.": "Premi Enter per posizionare il segnaposto, Esc per annullare.", @@ -1230,8 +1230,8 @@ "Hybrid": "Ibrido", "Lines": "Linee", "Dots": "Punti", - "Road lines colored by live flow: green to red": "Strade colorate in base al traffico in tempo reale: dal verde al rosso", - "Moving dots colored by live flow": "Punti in movimento colorati in base al traffico in tempo reale", + "Road lines colored by live flow: green to red": "Strade colorate in base al flusso live: dal verde al rosso", + "Moving dots colored by live flow": "Punti in movimento colorati in base al flusso live", "Moving dots": "Punti in movimento", "TomTom key unavailable": "Chiave TomTom non disponibile", "TomTom daily budget reached": "Budget giornaliero TomTom esaurito", @@ -1754,9 +1754,9 @@ "other": "{count} telecamere caricate · attiva CCTV per attivarle" }, "{count} more cameras outside this view · pan or zoom out": { - "one": "Altra {count} telecamera fuori da questa vista · sposta o riduci lo zoom", - "many": "Altre {count} di telecamere fuori da questa vista · sposta o riduci lo zoom", - "other": "Altre {count} telecamere fuori da questa vista · sposta o riduci lo zoom" + "one": "{count} altra telecamera fuori da questa vista · sposta la mappa o riduci lo zoom", + "many": "Altre {count} di telecamere fuori da questa vista · sposta la mappa o riduci lo zoom", + "other": "Altre {count} telecamere fuori da questa vista · sposta la mappa o riduci lo zoom" }, "Enable CCTV to load camera intersections": "Attiva CCTV per caricare le telecamere agli incroci", "Enable CCTV to start camera-linked intelligence summaries.": "Attiva CCTV per avviare i riepiloghi intelligence collegati alle telecamere.", @@ -1796,7 +1796,7 @@ "{scope} · INSTALLATIONS {coverage}": "{scope} · SITI {coverage}", "MILITARY FEED OFF · PARTIAL PICTURE": "FEED MILITARE DISATTIVATO · QUADRO PARZIALE", "FLIGHTS FEED OFF · PARTIAL PICTURE": "FEED VOLI DISATTIVATO · QUADRO PARZIALE", - "NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "AEREI VICINI INCOMPLETI · NON È UN VIA LIBERA", + "NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "DATI AEREI VICINI INCOMPLETI · NON È UN VIA LIBERA", "CONTEXT STANDBY": "CONTESTO IN ATTESA", "ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "ATTIVA IL CONTESTO GLOBALE PER I PING DI PROSSIMITÀ", "CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "CONTATTO PERSO · ULTIMA LETTURA NOTA · NON È UN VIA LIBERA", @@ -1881,7 +1881,7 @@ "TRACK ACQUIRED": "TRACCIA ACQUISITA", "{label} · COURSE {heading}°": "{label} · ROTTA {heading}°", "{label} · HEADING {heading}°": "{label} · PRUA {heading}°", - "{label} · NO HEADING": "{label} · NESSUNA PRUA", + "{label} · NO HEADING": "{label} · PRUA N/D", "{label} · NADIR VIEW": "{label} · VISTA AL NADIR", "Context could not restore every layer; try again": "Il Contesto non ha potuto ripristinare tutti i livelli; riprova", "No selected data layers": "Nessun livello dati selezionato", @@ -2839,8 +2839,8 @@ "Aircraft cockpit view": "Vista cockpit dell'aereo", "Ship bridge view": "Vista dalla plancia della nave", "Satellite orbital view": "Vista orbitale dal satellite", - "LOOK STBD {bearing}°": "VISTA DRITTA {bearing}°", - "LOOK PORT {bearing}°": "VISTA SINISTRA {bearing}°", + "LOOK STBD {bearing}°": "VISTA A DRITTA {bearing}°", + "LOOK PORT {bearing}°": "VISTA A SINISTRA {bearing}°", "LOOK ASTERN": "VISTA A POPPA", "UP {angle}°": "SU {angle}°", "DOWN {angle}°": "GIÙ {angle}°", @@ -2852,7 +2852,7 @@ "BRIDGE WINDOW · FWD": "FINESTRA PLANCIA · AVANTI", "OPTICAL PLANE · 01": "PIANO OTTICO · 01", "HEADING HOLD · AIS": "BLOCCO PRUA · AIS", - "NO HEADING · AIS": "NESSUNA PRUA · AIS", + "NO HEADING · AIS": "PRUA N/D · AIS", "VISOR LOCK · ACTIVE": "BLOCCO VISIERA · ATTIVO", "GROUND SPEED · KTS": "VEL. AL SUOLO · KTS", "SPEED OVER GROUND · KTS": "VEL. SUL FONDO · KTS", @@ -2989,7 +2989,7 @@ "AVAILABLE MISSIONS": "MISSIONI DISPONIBILI", "SELECT A MISSION TO INSPECT": "SELEZIONA UNA MISSIONE DA ESAMINARE", "LOADING 30-DAY MISSION INDEX": "CARICAMENTO INDICE MISSIONI A 30 GIORNI", - "Space Missions unavailable: the mission index could not load. Try again later.": "Missioni spaziali non disponibili: impossibile caricare l’indice delle missioni. Riprova più tardi.", + "Space Missions unavailable: the mission index could not load. Try again later.": "Missioni spaziali non disponibili: impossibile caricare l'indice delle missioni. Riprova più tardi.", "30-DAY MISSION INDEX UNAVAILABLE": "INDICE MISSIONI A 30 GIORNI NON DISPONIBILE", "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB: ANTEPRIMA · ENTER / SPACE: SELEZIONA", "Internet radio and local SDR": "Radio internet e SDR locale", @@ -3010,7 +3010,7 @@ "Radio volume": "Volume radio", "STATION SITE": "SITO STAZIONE", "DIRECTORY: RADIO BROWSER": "ELENCO: 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.": "L'audio si collega direttamente all'emittente dopo aver premuto play, scelto una stazione sul globo o chiesto la riproduzione a voce. Il tuo IP è visibile a quell'emittente.", + "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.": "L'audio si collega direttamente all'emittente quando premi play, scegli una stazione sul globo o chiedi la riproduzione con un comando vocale. Il tuo IP è visibile a quell'emittente.", "Local RTL-SDR receiver": "Ricevitore RTL-SDR locale", "LOCAL RTL-SDR": "RTL-SDR LOCALE", "Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · un sintonizzatore locale condiviso tra FM e il livello ADS-B locale.", @@ -3207,7 +3207,7 @@ "Left panels": "Pannelli a sinistra", "Right panels": "Pannelli a destra", "CONFIRMED": "CONFERMATA", - "PAD / RTLS": "SITO DI LANCIO / RTLS", + "PAD / RTLS": "RAMPA / RTLS", "fire confidence filter|All": "Tutti", "fire confidence filter|Nominal+": "Nominale+", "fire confidence filter|High": "Alta", @@ -3372,7 +3372,7 @@ "Too many chat requests. Try again in {seconds} s.": "Troppe richieste di chat. Riprova tra {seconds} s.", "The provider refused access for this key. Check the key's project, region or permissions.": "Il provider ha negato l'accesso per questa chiave. Controlla progetto, regione o autorizzazioni della chiave.", "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "L'account del provider ha esaurito il credito. Controlla la fatturazione o scegli un altro provider IA in Impostazioni.", - "Last 24 h of news. Each report links to its publisher; places and categories are read from the text and data automatically and can be wrong. Side labels, where shown, describe the source, not whether a report is true.": "Ultime 24 h di notizie. Ogni notizia rimanda al suo editore; luoghi e categorie sono letti automaticamente dal testo e dai dati e possono essere errati. Le etichette di parte, dove presenti, descrivono la fonte, non se la notizia è vera.", + "Last 24 h of news. Each report links to its publisher; places and categories are read from the text and data automatically and can be wrong. Side labels, where shown, describe the source, not whether a report is true.": "Ultime 24 h di notizie. Ogni notizia rimanda al suo editore; luoghi e categorie sono letti automaticamente dal testo e dai dati e possono essere errati. Le etichette delle parti, dove presenti, descrivono la fonte, non se la notizia è vera.", "Economy": "Economia", "News from {area}": "Notizie: {area}", "Within {km} km of the view centre": "Entro {km} km dal centro della vista", @@ -3394,7 +3394,7 @@ "Position: centre of the protected area": "Posizione: centro dell'area protetta", "Position approximate (within about {km} km)": "Posizione approssimativa (entro circa {km} km)", "Position surveyed (within about {m} m)": "Posizione rilevata (entro circa {m} m)", - "Vessel: {value}": "Imbarcazione: {value}", + "Vessel: {value}": "Nave: {value}", "What happened: {value}": "Cosa è successo: {value}", "Depth: {m} m ({quality})": "Profondità: {m} m ({quality})", "Depth: {m} m": "Profondità: {m} m", @@ -3414,11 +3414,11 @@ "other": "{count} relitti" }, "1900–1945": "1900–1945", - "Shipwreck": "Naufragio", + "Shipwreck": "Relitto", "Fatalities: {count}": "Vittime: {count}", "Survivors: {count}": "Sopravvissuti: {count}", "Click the card to open the article ↗": "Fai clic sulla scheda per aprire l'articolo ↗", - "European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "I relitti storici europei provengono da EMODnet Human Activities; i relitti noti di tutto il mondo da Wikidata. Il colore indica quando la nave è affondata; l'indicatore cresce con il numero di vittime. Fai clic su un indicatore per la sua scheda.", + "European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "I relitti storici europei provengono da EMODnet Human Activities; i relitti noti di tutto il mondo da Wikidata. Il colore indica quando la nave è affondata; il marcatore cresce con il numero di vittime. Fai clic su un marcatore per la sua scheda.", "All years": "Tutti gli anni", "Period": "Periodo", "From": "Dal", @@ -3444,7 +3444,7 @@ "Fungi": "Funghi", "Other animals": "Altri animali", "Other or unknown": "Altri o sconosciuti", - "{label} {detail} — click to retry": "{label} {detail} — fai clic per riprovare", + "{label} {detail} — click to retry": "{label} {detail}: clic per riprovare", "Wildlife sightings source unavailable": "Fonte degli avvistamenti non disponibile", "Show only community-confirmed (research-grade) sightings": "Mostra solo osservazioni confermate dalla comunità (grado di ricerca)", "No recent sightings in view": "Nessun avvistamento recente nella vista", @@ -3463,8 +3463,8 @@ "All rights reserved": "Tutti i diritti riservati", "Cetacean": "Cetaceo", "Wildlife Sightings": "Avvistamenti naturalistici", - "Exit cockpit to fly to a sighting": "Esci dalla cabina per volare a un avvistamento", - "Exit cockpit to fly to a sighting area": "Esci dalla cabina per volare a un’area di avvistamento", + "Exit cockpit to fly to a sighting": "Esci dal cockpit per volare a un avvistamento", + "Exit cockpit to fly to a sighting area": "Esci dal cockpit per volare a un'area di avvistamento", "Wildlife": "Natura", "Zoom in for local sightings": "Ingrandisci per gli avvistamenti locali", "Loading photo...": "Caricamento della foto…", @@ -3473,7 +3473,7 @@ "Photo of {name}": "Foto di {name}", "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", - "{count} sightings in view": "{count} avvistamenti nella vista", + "{count} sightings in view": "Avvistamenti nella vista: {count}", "Close sighting": "Chiudi avvistamento", "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.": "Fauna osservata nelle ultime 48 h (cetacei 7 giorni). Colore e simbolo del contrassegno indicano il gruppo tassonomico. Le specie sensibili sono mostrate solo in celle di 0,2°; le segnalazioni di mammiferi marini di Acartia nel mar dei Salish sono arrotondate a 0,01°. Non è un censimento.", "Open {name} on iNaturalist": "Apri {name} su iNaturalist", @@ -3481,23 +3481,23 @@ "iNaturalist": "iNaturalist", "iNaturalist contributors · licences per record": "Contributori di iNaturalist · licenze per osservazione", "iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Contributori di iNaturalist · Acartia {count} (Mare dei Salish) · licenze per osservazione", - "{days}d": "{days}g", - "All markers": "Tutti gli indicatori", - "Show markers of every age": "Mostra gli indicatori di qualsiasi età", - "Maximum event age: {days}d; undated markers are kept": "Età massima degli eventi: {days} giorni; indicatori senza data mantenuti", + "{days}d": "{days} g", + "All markers": "Tutti i marcatori", + "Show markers of every age": "Mostra i marcatori di qualsiasi età", + "Maximum event age: {days}d; undated markers are kept": "Età massima degli eventi: {days} g; marcatori senza data mantenuti", "event age filter|All": "Tutti", "Event age": "Età degli eventi", - "Event age: {days}d · undated kept": "Età degli eventi: {days} giorni · senza data mantenuti", + "Event age: {days}d · undated kept": "Età degli eventi: {days} g · senza data mantenuti", "Close camera preview": "Chiudi anteprima della telecamera", "Refresh map still": "Aggiorna immagine fissa sulla mappa", - "The map still uses a separate request and may show a different moment.": "L’immagine sulla mappa usa una richiesta separata e può mostrare un momento diverso.", + "The map still uses a separate request and may show a different moment.": "L'immagine sulla mappa usa una richiesta separata e può mostrare un momento diverso.", "Video plays from Québec 511, which sees your IP address.": "Il video viene riprodotto da Québec 511, che vede il tuo indirizzo IP.", "Press OPEN to load the photo": "Premi APRI per caricare la foto", - "Captured {month}": "Scattata a {month}", + "Captured {month}": "Scattata: {month}", "this spot": "questo punto", "the view centre": "il centro della vista", - "{source} at {place}": "{source} a {place}", - "Street level at {place}": "Vista stradale a {place}", + "{source} at {place}": "{source}: {place}", + "Street level at {place}": "Vista stradale: {place}", "Switching map": "Cambio mappa", "Opening imagery": "Apertura delle immagini", "Street View is ready: press {open} to load it": "Street View è pronto: premi {open} per caricarlo", @@ -3512,11 +3512,11 @@ "STARLINK": "STARLINK", "The Starlink constellation (thousands of extra points)": "La costellazione Starlink (migliaia di punti in più)", "PROBES": "SONDE", - "Space science, technology demonstration and other payloads": "Scienza spaziale, dimostrazioni tecnologiche e altri carichi utili", + "Space science, technology demonstration and other payloads": "Scienza spaziale, dimostrazioni tecnologiche e altri payload", "DEBRIS": "DETRITI", "Rocket bodies, fragments and dead satellites": "Stadi di razzi, frammenti e satelliti non operativi", - "Classes may lag: SATCAT is a cached copy": "Le classi potrebbero essere in ritardo: SATCAT è una copia in cache", - "Classes may lag: SATCAT unavailable": "Le classi potrebbero essere in ritardo: SATCAT non disponibile", + "Classes may lag: SATCAT is a cached copy": "Le classi potrebbero non essere aggiornate: SATCAT è una copia in cache", + "Classes may lag: SATCAT unavailable": "Le classi potrebbero non essere aggiornate: SATCAT non disponibile", "CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "SATCAT di CelesTrak fornisce tipo e stato di ogni oggetto. Senza una copia aggiornata le classi si basano sul database UCS, sui nomi e sui gruppi CelesTrak, quindi un satellite che nel frattempo ha cessato di funzionare può ancora risultare operativo.", "STARLINK ···": "STARLINK ···", "STARLINK ✕": "STARLINK ✕", @@ -3554,7 +3554,7 @@ "Hide {category}": "Nascondi {category}", "Show {category}": "Mostra {category}", "Live GPS": "GPS in tempo reale", - "Estimated from station reports": "Stimata dalle segnalazioni delle stazioni", + "Estimated from station reports": "Posizione stimata dalle segnalazioni delle stazioni", "To {headsign}": "Per {headsign}", "Next {stop} {time} {relative}": "Prossima fermata {stop} {time} {relative}", "Prev {stop} {time} {relative}": "Fermata precedente {stop} {time} {relative}", @@ -3564,22 +3564,22 @@ "Photo: {photographer} · {licence}": "Foto: {photographer} · {licence}", "Photo · {licence}": "Foto · {licence}", "The shared flight isn't reporting now — showing where it was last seen": "Il volo condiviso non sta trasmettendo ora: viene mostrata l'ultima posizione nota", - "The shared flight isn't reporting now — showing where it was at {time}": "Il volo condiviso non sta trasmettendo ora: viene mostrata la posizione alle {time}", + "The shared flight isn't reporting now — showing where it was at {time}": "Il volo condiviso non sta trasmettendo ora: viene mostrata la posizione rilevata ({time})", "The shared military flight isn't reporting now — showing where it was last seen": "Il volo militare condiviso non sta trasmettendo ora: viene mostrata l'ultima posizione nota", - "The shared military flight isn't reporting now — showing where it was at {time}": "Il volo militare condiviso non sta trasmettendo ora: viene mostrata la posizione alle {time}", + "The shared military flight isn't reporting now — showing where it was at {time}": "Il volo militare condiviso non sta trasmettendo ora: viene mostrata la posizione rilevata ({time})", "The shared vessel isn't reporting now — showing where it was last seen": "La nave condivisa non sta trasmettendo ora: viene mostrata l'ultima posizione nota", - "The shared vessel isn't reporting now — showing where it was at {time}": "La nave condivisa non sta trasmettendo ora: viene mostrata la posizione alle {time}", + "The shared vessel isn't reporting now — showing where it was at {time}": "La nave condivisa non sta trasmettendo ora: viene mostrata la posizione rilevata ({time})", "The shared satellite isn't reporting now — showing where it was last seen": "Il satellite condiviso non sta trasmettendo ora: viene mostrata l'ultima posizione nota", - "The shared satellite isn't reporting now — showing where it was at {time}": "Il satellite condiviso non sta trasmettendo ora: viene mostrata la posizione alle {time}", + "The shared satellite isn't reporting now — showing where it was at {time}": "Il satellite condiviso non sta trasmettendo ora: viene mostrata la posizione rilevata ({time})", "ICAO: {id}": "ICAO: {id}", "NORAD: {id}": "NORAD: {id}", "MMSI: {id}": "MMSI: {id}", "Vessel UUID: {id}": "UUID della nave: {id}", "Latitude must be within ±90° and longitude within ±180°": "La latitudine deve essere entro ±90° e la longitudine entro ±180°", - "Street Level is unavailable": "Vista stradale non è disponibile", + "Street Level is unavailable": "Il livello Vista stradale non è disponibile", "No street-level imagery source is configured": "Nessuna fonte di immagini a livello stradale configurata", "Street Level could not be enabled": "Impossibile attivare Vista stradale", - "Street Level changed while it looked for imagery": "Vista stradale è cambiata durante la ricerca delle immagini", + "Street Level changed while it looked for imagery": "Il livello Vista stradale è cambiato durante la ricerca delle immagini", "Aim the view at a street": "Punta la vista su una strada", "{source} is not a street-level imagery source here": "{source} non è una fonte di immagini a livello stradale qui", "{source} needs its key": "{source} richiede la sua chiave", @@ -3591,7 +3591,7 @@ "No {source} imagery near {place}": "Nessuna immagine di {source} vicino a {place}", "{reason} (filters on: {filters})": "{reason} (filtri attivi: {filters})", "tiles unavailable": "tile non disponibili", - "zoom in to load this layer": "avvicinati per caricare questo livello", + "zoom in to load this layer": "ingrandisci per caricare questo livello", "Zoom to layer": "Zoom sul livello", "Zoom to layer · fly to where this layer has data": "Zoom sul livello · vola dove questo livello ha dati", "Remove from Data Layers": "Rimuovi da Livelli dati", @@ -3604,7 +3604,7 @@ "1,000–2,000 m": "1.000–2.000 m", "2,000–3,000 m": "2.000–3.000 m", "3,000 m and above": "3.000 m e oltre", - "Exit cockpit to fly to a layer": "Esci dalla cabina di pilotaggio per volare a un livello", + "Exit cockpit to fly to a layer": "Esci dal cockpit per volare a un livello", "Library": "Libreria", "Environment": "Ambiente", "Imagery": "Immagini", @@ -3652,7 +3652,7 @@ "Operator opt-in: set {env}=1 on the server": "Attivazione dell'operatore: imposta {env}=1 sul server", "Operator opt-in: add {id} to GEV_LIBRARY_ENABLE on the server": "Attivazione dell'operatore: aggiungi {id} a GEV_LIBRARY_ENABLE sul server", "Turned off by this instance's operator": "Disattivato dall'operatore di questa istanza", - "Needs a provider key on the server": "Richiede una chiave del fornitore sul server", + "Needs a provider key on the server": "Richiede una chiave del provider sul server", "Needs this app's server · not available in a static build": "Richiede il server di questa app · non disponibile in una build statica", "Checking what this instance serves…": "Verifica di ciò che offre questa istanza…", "Regional": "Regionale", @@ -3669,8 +3669,8 @@ "DATA LIBRARY": "LIBRERIA DATI", "Close data library": "Chiudi la libreria dati", "Data Library at a glance": "La libreria dati in sintesi", - "Live sources beyond the core layers. Each one is fetched from its provider while it is on; nothing is stored in this app. Add one and it appears in Data Layers under Library.": "Fonti in tempo reale oltre ai livelli principali. Ognuna viene caricata dal suo fornitore finché è attiva; questa app non memorizza nulla. Aggiungine una e comparirà in Livelli dati, sotto Libreria.", - "Name, provider or country": "Nome, fornitore o paese", + "Live sources beyond the core layers. Each one is fetched from its provider while it is on; nothing is stored in this app. Add one and it appears in Data Layers under Library.": "Fonti live oltre ai livelli principali. Ognuna viene caricata dal suo provider finché è attiva; questa app non memorizza nulla. Aggiungine una e comparirà in Livelli dati, sotto Libreria.", + "Name, provider or country": "Nome, provider o paese", "Theme": "Tema", "All themes": "Tutti i temi", "Only what this instance serves": "Solo ciò che offre questa istanza", @@ -3685,12 +3685,12 @@ "Add {name} to Data Layers": "Aggiungi {name} a Livelli dati", "CONFIRM · REMOVE ALL {count}": { "one": "CONFERMA · RIMUOVI {count}", - "many": "CONFERMA · RIMUOVI TUTTI I {count}", - "other": "CONFERMA · RIMUOVI TUTTI I {count}" + "many": "CONFERMA · RIMUOVI TUTTE LE {count}", + "other": "CONFERMA · RIMUOVI TUTTE LE {count}" }, "REMOVE ALL": "RIMUOVI TUTTO", "Confirm: remove every added entry from Data Layers": "Conferma: rimuovi da Livelli dati tutte le voci aggiunte", "Remove every added entry from Data Layers": "Rimuovi da Livelli dati tutte le voci aggiunte", - "Showing {shown} of {total}": "{shown} di {total} mostrati", + "Showing {shown} of {total}": "Mostrati: {shown} su {total}", "BROWSE DATA LIBRARY": "ESPLORA LIBRERIA DATI" } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 20a65fcb4..62c517f21 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1,21 +1,21 @@ { "News feed unavailable": "ニュースフィードを利用できません", - "Developing: {name}": "進行中: {name}", + "Developing: {name}": "進行中:{name}", "{count} events today": { "other": "本日の出来事 {count}件" }, "{count} reports today": { "other": "本日の報道 {count}件" }, - "Disputed: {name}": "係争地: {name}", + "Disputed: {name}": "係争地:{name}", "Video": "動画", "Photos": "写真", "Claimed by {who}": "{who}の主張", "Claimed": "主張", - "Not yet confirmed by others": "他からの確認はまだない", - "Reported by {side} sources": "{side}側の情報源が報道", - "Reported by {sides}": "報道: {sides}", - "{side} sources": "{side}側の情報源", + "Not yet confirmed by others": "他の情報源による確認なし", + "Reported by {side} sources": "{side}の情報源が報道", + "Reported by {sides}": "報道:{sides}", + "{side} sources": "{side}の情報源", "Official": "公式", "Reported by both sides": "双方が報道", "Independently reported": "独立した報道あり", @@ -36,13 +36,13 @@ "Air defence": "防空", "Ground fighting": "地上戦", "Casualties": "死傷者", - "Humanitarian": "人道", + "Humanitarian": "人道問題", "Disaster": "災害", "Protest": "抗議", "Political": "政治", "No physical event": "物理的な事象なし", "Minor incident": "小規模な事案", - "Significant": "重大", + "Significant": "中程度", "Major": "深刻", "age|now": "今", "{minutes} min": "{minutes}分", @@ -68,7 +68,7 @@ "Conflicts": "紛争", "Local": "ローカル", "News sections": "ニュースの区分", - "Zoom in to see local news": "拡大するとローカルニュースが表示されます", + "Zoom in to see local news": "ズームインするとローカルニュースが表示されます", "Hotspots": "ホットスポット", "Severity": "深刻度", "Category": "カテゴリ", @@ -77,10 +77,10 @@ "No news matches these filters in the last {hours} h": "直近{hours}時間で条件に合うニュースはありません", "Load older news": "以前のニュースを読み込む", "How to read the map": "地図の見方", - "Colour: severity · icon: type of event": "色: 深刻度 · アイコン: 出来事の種類", - "Solid rim: reported by both sides or independently. Dashed rim: a claim. Thin rim: one source, or one side’s own press.": "実線の縁: 双方または独立した情報源が報道。破線の縁: 主張。細い縁: 単一の情報源、または一方の陣営のメディア。", - "Larger: more sources · fainter: older. Picture card: a major or fast-growing event with two or more sources.": "大きい: 情報源が多い · 薄い: 古い。画像カード: 情報源が2件以上ある深刻な、または急拡大中の出来事。", - "Side labels describe the source, not whether a report is true.": "陣営ラベルは情報源を示すもので、報道の真偽ではありません。", + "Colour: severity · icon: type of event": "色:深刻度 · アイコン:出来事の種類", + "Solid rim: reported by both sides or independently. Dashed rim: a claim. Thin rim: one source, or one side’s own press.": "実線の縁:双方または独立した情報源が報道。破線の縁:主張。細い縁:単一の情報源、または一方の陣営のメディア。", + "Larger: more sources · fainter: older. Picture card: a major or fast-growing event with two or more sources.": "大きい:情報源が多い · 薄い:古い。画像カード:情報源が2件以上ある深刻な、または急拡大中の出来事。", + "Side labels describe the source, not whether a report is true.": "陣営ラベルは情報源を示すもので、報道の真偽を示すものではありません。", "Exit cockpit to fly to a news report": "ニュースへ移動するにはコックピットを終了してください", "News items in the feed": "フィード内のニュース数", "Pick a shape, then click the map.": "図形を選んでから、地図をクリックしてください。", @@ -479,7 +479,7 @@ "Remove {title} from this app's saved keys": "{title}をこのアプリの保存済みキーから削除", "Project keys: {status}": "プロジェクトのキー:{status}", "your app configuration": "アプリの設定", - "your local .env": "ローカルの .env", + "your local .env": "ローカルの.env", "Saving…": "保存中…", "Save failed: {detail}": "保存に失敗しました:{detail}", "Paste at least one key first.": "まず、キーを1つ以上貼り付けてください。", @@ -801,7 +801,7 @@ "loading...": "読込中…", "KEY REQUIRED": "キーが必要", "LIVE · updated {age}": "ライブ · 更新 {age}", - "24 h to {day} {time} UTC": "{day} {time} UTC までの24時間", + "24 h to {day} {time} UTC": "{day} {time} UTCまでの24時間", "Source snapshot time unavailable": "ソースのスナップショット時刻を取得できません", "Live data unavailable": "ライブデータを利用できません", "Intel Picture": "情勢図", @@ -831,7 +831,7 @@ "Low-accuracy score 2–10%": "低精度スコア 2–10%", "Low-accuracy score > 10%": "低精度スコア > 10%", "GNSS integrity source unavailable": "GNSSインテグリティのソースを利用できません", - "Regional sample · 250 nm radius · cells from visited views over {minutes} min": "地域サンプル · 半径250海里 · {minutes}分以内に表示したビューのセル", + "Regional sample · 250 nm radius · cells from visited views over {minutes} min": "地域サンプル · 半径250海里 · 過去{minutes}分間に表示したビューのセル", "Retrying automatically at {time} UTC": "{time} UTCに自動で再試行します", "Retrying automatically after provider cooldown": "プロバイダーのクールダウン後に自動で再試行します", "Updates automatically after pan or zoom and once a minute. The public feed covers a 250-nautical-mile circle around the view centre; zooming out does not make it a worldwide snapshot. Blank cells are outside sampled coverage or have insufficient observations.": "パンやズームの後、および1分ごとに自動で更新されます。公開フィードは表示中心から半径250海里の円をカバーします。ズームアウトしても全世界のスナップショットにはなりません。空白のセルは、サンプリング範囲外か観測が不十分です。", @@ -889,18 +889,18 @@ "{value} KM/S": "{value} km/s", "{direction} {angle}°": "{direction} {angle}°", "OFF-NADIR {angle}° · {offset}": "オフナディア {angle}° · {offset}", - "SITES STANDBY": "地点待機", - "ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "マップ済み施設を有効にすると地点ピングを表示", - "Release the camera from {name}": "{name} からカメラを解放", + "SITES STANDBY": "施設待機", + "ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "マップ済み施設を有効にすると施設ピングを表示", + "Release the camera from {name}": "{name}からカメラを解放", "RETURN · {name}": "戻る · {name}", "PREVIOUS SATELLITE": "前の衛星", - "Return to {name}": "{name} に戻る", + "Return to {name}": "{name}に戻る", "SATELLITE": "衛星", - "Ride {name}": "{name} に乗る", - "Unnamed site": "名称のない地点", + "Ride {name}": "{name}に乗る", + "Unnamed site": "名称不明の施設", "OFF-NADIR {angle}°": "オフナディア {angle}°", "FOCUS · {name}": "フォーカス · {name}", - "Keep the camera on {name}": "カメラを {name} に向け続ける", + "Keep the camera on {name}": "カメラを{name}に向け続ける", "ZOOM {zoom}×": "ズーム {zoom}×", "SWATH {value} KM": "観測幅 {value} km", "SWATH ≈ {value} KM": "観測幅 ≈ {value} km", @@ -909,8 +909,8 @@ "ORBITAL VELOCITY": "軌道速度", "SUB-SATELLITE POINT": "衛星直下点", "NORTH UP": "ノースアップ", - "CURRENTLY OVER": "現在の上空", - "{division}, {country}": "{division}、{country}", + "CURRENTLY OVER": "現在の直下", + "{division}, {country}": "{country}・{division}", "{region} · near {place}": "{region} · {place}付近", "NORAD {id}": "NORAD {id}", "EPOCH {time}": "元期 {time}", @@ -1702,7 +1702,7 @@ "other": "カメラ {count}台を読込済み · CCTVを有効にして使用" }, "{count} more cameras outside this view · pan or zoom out": { - "other": "この表示範囲外にさらに {count}台のカメラ · 移動またはズームアウト" + "other": "表示範囲外にあと{count}台のカメラ · パンまたはズームアウト" }, "Enable CCTV to load camera intersections": "CCTVを有効にすると、カメラのある交差点を読み込みます", "Enable CCTV to start camera-linked intelligence summaries.": "CCTVを有効にすると、カメラ連動のインテリジェンス概要を開始します。", @@ -1740,8 +1740,8 @@ "REGIONAL NEWS": "地域ニュース", "{label} · {radius} AIR/SEA WINDOW": "{label} · 航空機/船舶の範囲 {radius}", "{scope} · INSTALLATIONS {coverage}": "{scope} · 施設 {coverage}", - "MILITARY FEED OFF · PARTIAL PICTURE": "軍用フィードオフ · 部分的な状況", - "FLIGHTS FEED OFF · PARTIAL PICTURE": "フライトフィードオフ · 部分的な状況", + "MILITARY FEED OFF · PARTIAL PICTURE": "軍用フィード オフ · 部分的な状況", + "FLIGHTS FEED OFF · PARTIAL PICTURE": "フライトフィード オフ · 部分的な状況", "NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "付近の航空機情報は不完全 · 安全の保証ではありません", "CONTEXT STANDBY": "コンテキスト待機", "ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "グローバルコンテキストを有効にすると近接ピングを表示", @@ -1876,7 +1876,7 @@ "Earthquakes (24h)": "地震(24時間)", "Satellites": "衛星", "Place names": "地名", - "Space Missions (30d)": "宇宙ミッション 30日", + "Space Missions (30d)": "宇宙ミッション(30日)", "Street Traffic": "道路交通", "CCTV": "CCTV", "Radio": "ラジオ", @@ -2858,7 +2858,7 @@ "Radio volume": "ラジオの音量", "STATION SITE": "放送局サイト", "DIRECTORY: RADIO BROWSER": "ディレクトリ: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.": "再生ボタンを押す、地球儀で局を選ぶ、または音声コントロールで再生を指示すると、音声は放送局に直接接続されます。お使いのIPアドレスは放送局側から見えます。", + "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.": "再生ボタンを押す、地球儀で局を選ぶ、または音声アシスタントに再生を指示すると、ブラウザーが放送局に直接接続します。お使いのIPアドレスは放送局側から見えます。", "Local RTL-SDR receiver": "ローカルRTL-SDR受信機", "LOCAL RTL-SDR": "ローカルRTL-SDR", "Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · FMとローカルADS-Bレイヤーで1台のローカルチューナーを共有します。", @@ -3032,7 +3032,7 @@ "Restricted": "飛行制限区域", "Prohibited & Restricted Airspace": "飛行禁止・飛行制限区域", "scene|PLAY": "再生", - "AUTHORING": "作成", + "AUTHORING": "シーン作成", "Create and edit scenes": "シーンを作成・編集", "SCENE": "シーン", "Scene": "シーン", @@ -3047,7 +3047,7 @@ "Click a shot to select it, double-click to rename.": "ショットをクリックして選択、ダブルクリックで名前を変更します。", "Play from this scene through the rest of the project": "このシーンからプロジェクトの最後まで再生", "Stop playback (Esc)": "再生を停止(Esc)", - "Play the shot after the selected one, moving on to the next scene when needed": "選択中の次のショットを再生(必要に応じて次のシーンへ進む)", + "Play the shot after the selected one, moving on to the next scene when needed": "選択中のショットの次を再生(必要に応じて次のシーンへ進む)", "SAVE & SHARE": "保存と共有", "Download every scene as a JSON file you can import later": "すべてのシーンを、後でインポートできるJSONファイルとしてダウンロード", "Load scenes from an exported JSON file": "書き出したJSONファイルからシーンを読み込む", @@ -3078,7 +3078,7 @@ "Drawn shapes": "描いた図形", "Audio": "オーディオ", "UTILITIES": "ユーティリティ", - "Exit cockpit to fly to a drawing": "描画の位置へ移動するにはコックピットを終了してください", + "Exit cockpit to fly to a drawing": "描いた図形へ移動するにはコックピットを終了してください", "Directions, drawing and your own files": "ルート案内、描画、自分のファイル", "Voice place search (needs an Overpass server)": "音声による場所検索(Overpassサーバーが必要)", "OSM Places": "OSMの場所", @@ -3212,7 +3212,7 @@ "Too many chat requests. Try again in {seconds} s.": "チャットのリクエストが多すぎます。{seconds}秒後にもう一度お試しください。", "The provider refused access for this key. Check the key's project, region or permissions.": "プロバイダーがこのキーでのアクセスを拒否しました。キーのプロジェクト、リージョン、権限を確認してください。", "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "プロバイダーアカウントのクレジットが不足しています。課金状況を確認するか、設定で別のAIプロバイダーを選んでください。", - "Last 24 h of news. Each report links to its publisher; places and categories are read from the text and data automatically and can be wrong. Side labels, where shown, describe the source, not whether a report is true.": "直近24時間のニュース。各報道は発信元にリンクします。場所とカテゴリは本文とデータから自動で読み取るため誤りがあり得ます。陣営ラベルは、表示される場合、情報源を示すもので、報道の真偽ではありません。", + "Last 24 h of news. Each report links to its publisher; places and categories are read from the text and data automatically and can be wrong. Side labels, where shown, describe the source, not whether a report is true.": "直近24時間のニュース。各報道は発信元にリンクします。場所とカテゴリは本文とデータから自動で読み取るため誤りがあり得ます。陣営ラベルは、表示される場合、情報源を示すもので、報道の真偽を示すものではありません。", "Economy": "経済", "News from {area}": "{area}のニュース", "Within {km} km of the view centre": "表示中心から{km} km以内", @@ -3228,33 +3228,33 @@ "Precise conflict points in the News layer, beside GDELT’s articles": "ニュースレイヤーの正確な紛争地点(GDELTの記事に加えて)", "Worldwide": "世界", "Shipwrecks": "難破船", - "Before 1900": "1900年以前", - "After 1945": "1945年以降", + "Before 1900": "1900年より前", + "After 1945": "1946年以降", "Date unknown": "日付不明", - "Position: centre of the protected area": "位置: 保護区域の中心", + "Position: centre of the protected area": "位置:保護区域の中心", "Position approximate (within about {km} km)": "おおよその位置(誤差約{km} km)", "Position surveyed (within about {m} m)": "測量された位置(誤差約{m} m)", - "Vessel: {value}": "船種: {value}", - "What happened: {value}": "経緯: {value}", - "Depth: {m} m ({quality})": "水深: {m} m({quality})", - "Depth: {m} m": "水深: {m} m", - "Notes: {value}": "備考: {value}", - "Cargo or finds: {value}": "積荷・遺物: {value}", + "Vessel: {value}": "船種:{value}", + "What happened: {value}": "経緯:{value}", + "Depth: {m} m ({quality})": "水深:{m} m({quality})", + "Depth: {m} m": "水深:{m} m", + "Notes: {value}": "備考:{value}", + "Cargo or finds: {value}": "積荷・遺物:{value}", "Protected wreck": "保護対象の難破船", - "Source: {value}": "出典: {value}", + "Source: {value}": "出典:{value}", "Click the card to open EMODnet ↗": "カードをクリックしてEMODnetを開く ↗", "Click the card to open the source ↗": "カードをクリックして出典を開く ↗", "Wikidata unavailable; showing EMODnet only": "Wikidataを利用できません。EMODnetのみ表示しています", "EMODnet unavailable; showing Wikidata only": "EMODnetを利用できません。Wikidataのみ表示しています", "Shipwrecks unavailable": "難破船データを利用できません", - "EMODnet (European waters) · Wikidata (worldwide)": "EMODnet(欧州海域)· Wikidata(世界)", + "EMODnet (European waters) · Wikidata (worldwide)": "EMODnet(欧州海域) · Wikidata(世界)", "{count} wrecks": { - "other": "難破船 {count}件" + "other": "難破船 {count}隻" }, "1900–1945": "1900–1945", - "Shipwreck": "難破", - "Fatalities: {count}": "死者: {count}", - "Survivors: {count}": "生存者: {count}", + "Shipwreck": "難破船", + "Fatalities: {count}": "死者:{count}人", + "Survivors: {count}": "生存者:{count}人", "Click the card to open the article ↗": "カードをクリックして記事を開く ↗", "European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "欧州の歴史的な難破船はEMODnet Human Activities、世界の著名な難破船はWikidataによります。色は沈没した時期を示し、マーカーは死者数に応じて大きくなります。マーカーをクリックするとカードが開きます。", "All years": "全期間", @@ -3277,13 +3277,13 @@ "Amphibians": "両生類", "Ray-finned fishes": "条鰭類", "Insects": "昆虫", - "Arachnids": "クモ類", + "Arachnids": "クモガタ類", "Plants": "植物", "Fungi": "菌類", "Other animals": "その他の動物", "Other or unknown": "その他・不明", - "{label} {detail} — click to retry": "{label} {detail} — クリックして再試行", - "Wildlife sightings source unavailable": "野生生物の目撃情報源を利用できません", + "{label} {detail} — click to retry": "{label} {detail}:クリックで再試行", + "Wildlife sightings source unavailable": "野生生物の目撃情報のソースを利用できません", "Show only community-confirmed (research-grade) sightings": "コミュニティが確認した研究グレードの目撃情報のみ表示", "No recent sightings in view": "表示範囲に最近の目撃情報はありません", "Acartia feed unavailable": "Acartiaのフィードを利用できません", @@ -3301,10 +3301,10 @@ "All rights reserved": "無断転載禁止", "Cetacean": "クジラ類", "Wildlife Sightings": "野生生物の目撃情報", - "Exit cockpit to fly to a sighting": "目撃地点に移動するにはコックピットを終了", - "Exit cockpit to fly to a sighting area": "目撃範囲に移動するにはコックピットを終了", + "Exit cockpit to fly to a sighting": "目撃地点へ移動するにはコックピットを終了してください", + "Exit cockpit to fly to a sighting area": "目撃範囲へ移動するにはコックピットを終了してください", "Wildlife": "野生生物", - "Zoom in for local sightings": "拡大して地域の目撃情報を表示", + "Zoom in for local sightings": "ズームインして地域の目撃情報を表示", "Loading photo...": "写真を読み込み中…", "Photo unavailable": "写真を表示できません", "Location obscured to a 0.2° cell": "位置は0.2°のセル内に秘匿されています", @@ -3313,12 +3313,12 @@ "CC BY 4.0": "CC BY 4.0", "{count} sightings in view": "表示範囲に{count}件の目撃情報", "Close sighting": "目撃情報を閉じる", - "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.": "過去48時間以内に観察された野生生物(クジラ類は7日間)。色とバッジのアイコンは分類群を表します。保護が必要な種は0.2°の区画のみ表示し、セイリッシュ海のAcartia海生哺乳類目撃情報は0.01°に丸めます。個体数調査ではありません。", + "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.": "過去48時間以内に観察された野生生物(クジラ類は7日間)。色とバッジのアイコンは分類群を表します。保護が必要な種は0.2°のセルとしてのみ表示し、セイリッシュ海のAcartiaによる海生哺乳類の目撃情報は0.01°に丸めます。個体数調査ではありません。", "Open {name} on iNaturalist": "iNaturalistで{name}を開く", "Open {name} on Acartia": "Acartiaで{name}を開く", "iNaturalist": "iNaturalist", - "iNaturalist contributors · licences per record": "iNaturalist の投稿者 · ライセンスは記録ごと", - "iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "iNaturalist の投稿者 · Acartia {count}(セイリッシュ海) · ライセンスは記録ごと", + "iNaturalist contributors · licences per record": "iNaturalistの投稿者 · ライセンスは記録ごと", + "iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "iNaturalistの投稿者 · Acartia {count}(セイリッシュ海) · ライセンスは記録ごと", "{days}d": "{days}日", "All markers": "すべてのマーカー", "Show markers of every age": "日付にかかわらずすべてのマーカーを表示", @@ -3329,8 +3329,8 @@ "Close camera preview": "カメラのプレビューを閉じる", "Refresh map still": "地図上の静止画を更新", "The map still uses a separate request and may show a different moment.": "地図上の静止画は別のリクエストで取得されるため、異なる時点を表示する場合があります。", - "Video plays from Québec 511, which sees your IP address.": "動画は Québec 511 から再生され、Québec 511 にはあなたの IP アドレスが見えます。", - "Press OPEN to load the photo": "「開く」を押して写真を読み込む", + "Video plays from Québec 511, which sees your IP address.": "動画はQuébec 511から再生され、お使いのIPアドレスはQuébec 511側から見えます。", + "Press OPEN to load the photo": "[開く]を押して写真を読み込む", "Captured {month}": "{month}撮影", "this spot": "この地点", "the view centre": "表示中心", @@ -3338,8 +3338,8 @@ "Street level at {place}": "{place}のストリートレベル", "Switching map": "地図を切り替え中", "Opening imagery": "画像を開いています", - "Street View is ready: press {open} to load it": "ストリートビューの準備ができました:{open} を押して読み込みます", - "{source} cannot open this link's street-level photo without a working key": "{source} は有効なキーがないため、このリンクのストリートレベル写真を開けません", + "Street View is ready: press {open} to load it": "ストリートビューの準備ができました:[{open}]を押して読み込んでください", + "{source} cannot open this link's street-level photo without a working key": "{source}は有効なキーがないため、このリンクのストリートレベル写真を開けません", "Street-level photos are hidden in the current view. Leave it first.": "現在の表示ではストリートレベルの写真は非表示です。先にこの表示を終了してください。", "STATIONS": "ステーション", "Crewed stations and the craft that visit them": "有人宇宙ステーションと、そこを訪れる宇宙機", @@ -3353,16 +3353,16 @@ "Space science, technology demonstration and other payloads": "宇宙科学、技術実証、その他のペイロード", "DEBRIS": "デブリ", "Rocket bodies, fragments and dead satellites": "ロケット機体、破片、運用を終えた衛星", - "Classes may lag: SATCAT is a cached copy": "分類が古い可能性: SATCAT はキャッシュのコピーです", - "Classes may lag: SATCAT unavailable": "分類が古い可能性: SATCAT を取得できません", - "CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "CelesTrak SATCAT は各物体の種別と運用状態を示します。最新のコピーがない間、分類は UCS データベース、名称、CelesTrak のグループのみに基づくため、その後機能を失った衛星がまだ稼働中と表示されることがあります。", + "Classes may lag: SATCAT is a cached copy": "分類が古い可能性:SATCATはキャッシュのコピーです", + "Classes may lag: SATCAT unavailable": "分類が古い可能性:SATCATを取得できません", + "CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "CelesTrak SATCATは各物体の種別と運用状態を示します。最新のコピーがない間、分類はUCSデータベース、名称、CelesTrakのグループのみに基づくため、その後機能を失った衛星がまだ稼働中と表示されることがあります。", "STARLINK ···": "STARLINK ···", "STARLINK ✕": "STARLINK ✕", "SENSOR SWATH {value} KM": "センサー観測幅 {value} km", "SENSOR SWATH ≈ {value} KM": "センサー観測幅 ≈ {value} km", "SENSOR": "センサー", "Zoom to the imaging sensor swath": "撮像センサーの観測幅までズーム", - "<1 H OLD": "1時間未満前", + "<1 H OLD": "1時間以内", "{value} H OLD": "{value}時間前", "{value} D OLD": "{value}日前", "FWD": "前方", @@ -3371,8 +3371,8 @@ "LEFT": "左", "TRACK UP": "トラックアップ", "COURSE HOLD · AIS": "対地針路保持 · AIS", - "Zoom in (+)": "ズームイン (+)", - "Zoom out (−)": "ズームアウト (−)", + "Zoom in (+)": "ズームイン(+)", + "Zoom out (−)": "ズームアウト(−)", "ORBITAL VIEW · OFF-NADIR": "軌道ビュー · オフナディア", "World time": "世界時刻", "Back to live": "ライブに戻る", @@ -3401,14 +3401,14 @@ "Photo © {photographer}": "写真 © {photographer}", "Photo: {photographer} · {licence}": "写真:{photographer} · {licence}", "Photo · {licence}": "写真 · {licence}", - "The shared flight isn't reporting now — showing where it was last seen": "共有されたフライトは現在報告していません。最後に確認された位置を表示しています", - "The shared flight isn't reporting now — showing where it was at {time}": "共有されたフライトは現在報告していません。{time}時点の位置を表示しています", - "The shared military flight isn't reporting now — showing where it was last seen": "共有された軍用機は現在報告していません。最後に確認された位置を表示しています", - "The shared military flight isn't reporting now — showing where it was at {time}": "共有された軍用機は現在報告していません。{time}時点の位置を表示しています", - "The shared vessel isn't reporting now — showing where it was last seen": "共有された船舶は現在報告していません。最後に確認された位置を表示しています", - "The shared vessel isn't reporting now — showing where it was at {time}": "共有された船舶は現在報告していません。{time}時点の位置を表示しています", - "The shared satellite isn't reporting now — showing where it was last seen": "共有された衛星は現在報告していません。最後に確認された位置を表示しています", - "The shared satellite isn't reporting now — showing where it was at {time}": "共有された衛星は現在報告していません。{time}時点の位置を表示しています", + "The shared flight isn't reporting now — showing where it was last seen": "共有されたフライトの位置情報は現在届いていません。最後に確認された位置を表示しています", + "The shared flight isn't reporting now — showing where it was at {time}": "共有されたフライトの位置情報は現在届いていません。{time}時点の位置を表示しています", + "The shared military flight isn't reporting now — showing where it was last seen": "共有された軍用機の位置情報は現在届いていません。最後に確認された位置を表示しています", + "The shared military flight isn't reporting now — showing where it was at {time}": "共有された軍用機の位置情報は現在届いていません。{time}時点の位置を表示しています", + "The shared vessel isn't reporting now — showing where it was last seen": "共有された船舶の位置情報は現在届いていません。最後に確認された位置を表示しています", + "The shared vessel isn't reporting now — showing where it was at {time}": "共有された船舶の位置情報は現在届いていません。{time}時点の位置を表示しています", + "The shared satellite isn't reporting now — showing where it was last seen": "共有された衛星の位置情報は現在届いていません。最後に確認された位置を表示しています", + "The shared satellite isn't reporting now — showing where it was at {time}": "共有された衛星の位置情報は現在届いていません。{time}時点の位置を表示しています", "ICAO: {id}": "ICAO: {id}", "NORAD: {id}": "NORAD: {id}", "MMSI: {id}": "MMSI: {id}", @@ -3427,7 +3427,7 @@ "{source} imagery could not be opened": "{source}の画像を開けませんでした", "{place} has no position to look for imagery at": "{place}には画像を探す位置がありません", "No {source} imagery near {place}": "{place}の近くに{source}の画像はありません", - "{reason} (filters on: {filters})": "{reason}(有効なフィルター: {filters})", + "{reason} (filters on: {filters})": "{reason}(有効なフィルター:{filters})", "tiles unavailable": "タイルを利用できません", "zoom in to load this layer": "ズームインしてこのレイヤーを読み込む", "Zoom to layer": "レイヤーにズーム", @@ -3436,12 +3436,12 @@ "Remove from Data Layers · add it again from the library": "データレイヤーから削除 · ライブラリから再度追加できます", "Coverage outline": "カバー範囲の輪郭", "DRAPED": "地形に投影", - "Below 250 m": "250 m 未満", + "Below 250 m": "250 m未満", "250–500 m": "250–500 m", "500–1,000 m": "500–1,000 m", "1,000–2,000 m": "1,000–2,000 m", "2,000–3,000 m": "2,000–3,000 m", - "3,000 m and above": "3,000 m 以上", + "3,000 m and above": "3,000 m以上", "Exit cockpit to fly to a layer": "レイヤーへ移動するにはコックピットを終了してください", "Library": "ライブラリ", "Environment": "環境", @@ -3457,28 +3457,28 @@ "Datasets": "データセット", "{datasets} · {tiles} · {packs}": "{datasets} · {tiles} · {packs}", "{count} datasets": { - "other": "{count} 件のデータセット" + "other": "{count}件のデータセット" }, "{count} tile layers": { - "other": "{count} 件のタイルレイヤー" + "other": "{count}件のタイルレイヤー" }, "{count} packs": { - "other": "{count} 件のパック" + "other": "{count}件のパック" }, "Themes": "テーマ", "Added": "追加済み", "{count} on the map": { - "other": "地図上に {count} 件" + "other": "地図上に{count}件" }, "none yet": "まだありません", "Map features": "地図上の地物", "from {count} datasets": { - "other": "{count} 件のデータセットから" + "other": "{count}件のデータセットから" }, "switch on a dataset to load features": "データセットをオンにすると地物を読み込みます", - "Switched on by this instance · part of the CCTV layer": "このインスタンスで有効 · CCTV レイヤーの一部", - "Operator opt-in: set {env}=1 on the server": "運用者による有効化:サーバーで {env}=1 を設定", - "Operator opt-in: add {id} to GEV_LIBRARY_ENABLE on the server": "運用者による有効化:サーバーの GEV_LIBRARY_ENABLE に {id} を追加", + "Switched on by this instance · part of the CCTV layer": "このインスタンスで有効 · CCTVレイヤーの一部", + "Operator opt-in: set {env}=1 on the server": "運用者による有効化:サーバーで{env}=1を設定", + "Operator opt-in: add {id} to GEV_LIBRARY_ENABLE on the server": "運用者による有効化:サーバーのGEV_LIBRARY_ENABLEに{id}を追加", "Turned off by this instance's operator": "このインスタンスの運用者により無効化されています", "Needs a provider key on the server": "サーバーにプロバイダーのキーが必要です", "Needs this app's server · not available in a static build": "このアプリのサーバーが必要 · 静的ビルドでは利用できません", @@ -3506,17 +3506,17 @@ "KEY": "キー必須", "KEYLESS": "キー不要", "ZOOM": "ズーム", - "Zoom to {name}": "{name} にズーム", + "Zoom to {name}": "{name}にズーム", "WORKING…": "処理中…", "ADD": "追加", - "Remove {name} from Data Layers": "{name} をデータレイヤーから削除", - "Add {name} to Data Layers": "{name} をデータレイヤーに追加", + "Remove {name} from Data Layers": "{name}をデータレイヤーから削除", + "Add {name} to Data Layers": "{name}をデータレイヤーに追加", "CONFIRM · REMOVE ALL {count}": { - "other": "確認 · {count} 件をすべて削除" + "other": "確認 · {count}件をすべて削除" }, "REMOVE ALL": "すべて削除", "Confirm: remove every added entry from Data Layers": "確認:追加したすべての項目をデータレイヤーから削除", "Remove every added entry from Data Layers": "追加したすべての項目をデータレイヤーから削除", - "Showing {shown} of {total}": "{total} 件中 {shown} 件を表示", + "Showing {shown} of {total}": "{total}件中{shown}件を表示", "BROWSE DATA LIBRARY": "データライブラリを見る" } diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 8b1e5e7ae..5d3cfb253 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -3,12 +3,12 @@ "Developing: {name}": "Em desenvolvimento: {name}", "{count} events today": { "one": "{count} evento hoje", - "many": "{count} eventos hoje", + "many": "{count} de eventos hoje", "other": "{count} eventos hoje" }, "{count} reports today": { "one": "{count} notícia hoje", - "many": "{count} notícias hoje", + "many": "{count} de notícias hoje", "other": "{count} notícias hoje" }, "Disputed: {name}": "Em disputa: {name}", @@ -17,9 +17,9 @@ "Claimed by {who}": "Segundo {who}", "Claimed": "Alegação", "Not yet confirmed by others": "Ainda não confirmado por outros", - "Reported by {side} sources": "Noticiado por fontes de {side}", + "Reported by {side} sources": "Noticiado por fontes: {side}", "Reported by {sides}": "Noticiado por {sides}", - "{side} sources": "fontes de {side}", + "{side} sources": "fontes: {side}", "Official": "Oficial", "Reported by both sides": "Noticiado por ambos os lados", "Independently reported": "Noticiado de forma independente", @@ -27,7 +27,7 @@ "International": "Internacional", "Independent": "Independente", "Unattributed": "Sem atribuição", - "Outside party": "Parte externa", + "Outside party": "Terceiros", "Ukraine–Russia": "Ucrânia–Rússia", "Israel–Gaza–Lebanon": "Israel–Gaza–Líbano", "Iran–Hormuz": "Irã–Ormuz", @@ -43,7 +43,7 @@ "Humanitarian": "Humanitário", "Disaster": "Desastre", "Protest": "Protesto", - "Political": "Político", + "Political": "Política", "No physical event": "Sem evento físico", "Minor incident": "Incidente menor", "Significant": "Significativo", @@ -53,7 +53,7 @@ "{hours} h": "{hours} h", "{count} sources": { "one": "{count} fonte", - "many": "{count} fontes", + "many": "{count} de fontes", "other": "{count} fontes" }, "News feed not configured on this server": "Feed de notícias não configurado neste servidor", @@ -64,23 +64,23 @@ "Not placed on the map": "Não localizado no mapa", "Close": "Fechar", "Any severity": "Qualquer gravidade", - "{severity} and above": "{severity} ou mais", + "{severity} and above": "{severity} ou superior", "{severity} only": "Apenas {severity}", "All categories": "Todas as categorias", "Reading the news…": "Lendo as notícias…", - "News, last {hours} hours": "Notícias das últimas {hours} horas", - "Last {hours} h": "Últimas {hours} h", + "News, last {hours} hours": "Notícias de até {hours} h atrás", + "Last {hours} h": "Até {hours} h atrás", "Time window": "Período", "Conflicts": "Conflitos", "Local": "Local", - "News sections": "Secções de notícias", - "Zoom in to see local news": "Aproxime para ver notícias locais", + "News sections": "Seções de notícias", + "Zoom in to see local news": "Aproxime o zoom para ver notícias locais", "Hotspots": "Focos", "Severity": "Gravidade", "Category": "Categoria", - "Nothing reported near here in the last {hours} h": "Nada noticiado aqui perto nas últimas {hours} h", + "Nothing reported near here in the last {hours} h": "Nada noticiado aqui perto desde {hours} h atrás", "Latest news": "Últimas notícias", - "No news matches these filters in the last {hours} h": "Nenhuma notícia das últimas {hours} h corresponde a estes filtros", + "No news matches these filters in the last {hours} h": "Nenhuma notícia de até {hours} h atrás corresponde a estes filtros", "Load older news": "Carregar notícias anteriores", "How to read the map": "Como ler o mapa", "Colour: severity · icon: type of event": "Cor: gravidade · ícone: tipo de evento", @@ -857,7 +857,7 @@ "Low-accuracy score 2–10%": "Índice de baixa precisão 2–10%", "Low-accuracy score > 10%": "Índice de baixa precisão > 10%", "GNSS integrity source unavailable": "Fonte de integridade GNSS indisponível", - "Regional sample · 250 nm radius · cells from visited views over {minutes} min": "Amostra regional · raio de 250 NM · células das vistas visitadas em {minutes} min", + "Regional sample · 250 nm radius · cells from visited views over {minutes} min": "Amostra regional · raio de 250 NM · células das áreas visualizadas nos últimos {minutes} min", "Retrying automatically at {time} UTC": "Nova tentativa automática às {time} UTC", "Retrying automatically after provider cooldown": "Nova tentativa automática após a pausa do provedor", "Updates automatically after pan or zoom and once a minute. The public feed covers a 250-nautical-mile circle around the view centre; zooming out does not make it a worldwide snapshot. Blank cells are outside sampled coverage or have insufficient observations.": "Atualiza automaticamente após mover ou dar zoom e uma vez por minuto. O feed público cobre um círculo de 250 milhas náuticas ao redor do centro da vista; afastar o zoom não o transforma em um snapshot mundial. Células vazias estão fora da cobertura amostrada ou não têm observações suficientes.", @@ -926,14 +926,14 @@ "PREVIOUS SATELLITE": "SATÉLITE ANTERIOR", "Return to {name}": "Voltar para {name}", "SATELLITE": "SATÉLITE", - "Ride {name}": "Acompanhar {name}", + "Ride {name}": "Ir a bordo de {name}", "Unnamed site": "Local sem nome", "OFF-NADIR {angle}°": "FORA DO NADIR {angle}°", "FOCUS · {name}": "FOCO · {name}", "Keep the camera on {name}": "Manter a câmera em {name}", "ZOOM {zoom}×": "ZOOM {zoom}×", - "SWATH {value} KM": "FAIXA IMAGEADA {value} KM", - "SWATH ≈ {value} KM": "FAIXA IMAGEADA ≈ {value} KM", + "SWATH {value} KM": "FAIXA {value} KM", + "SWATH ≈ {value} KM": "FAIXA ≈ {value} KM", "Orbital view": "Vista orbital", "ORBITAL VIEW · NADIR": "VISTA ORBITAL · NADIR", "ORBITAL VELOCITY": "VELOCIDADE ORBITAL", @@ -1234,7 +1234,7 @@ "TomTom key unavailable": "Chave do TomTom indisponível", "TomTom daily budget reached": "Limite diário do TomTom atingido", "Monthly traffic allowance used": "Cota mensal de trânsito esgotada", - "Traffic rate limit reached": "Limite de solicitações de trânsito atingido", + "Traffic rate limit reached": "Trânsito: limite de requisições atingido", "Traffic service unavailable": "Serviço de trânsito indisponível", "TomTom upstream unreachable": "Servidor do TomTom inacessível", "TomTom flow unavailable": "Fluxo do TomTom indisponível", @@ -1754,9 +1754,9 @@ "other": "{count} câmeras carregadas · ative o CCTV para usar" }, "{count} more cameras outside this view · pan or zoom out": { - "one": "Mais {count} câmera fora desta vista · mova ou afaste o zoom", - "many": "Mais {count} de câmeras fora desta vista · mova ou afaste o zoom", - "other": "Mais {count} câmeras fora desta vista · mova ou afaste o zoom" + "one": "Mais {count} câmera fora desta vista · mova o mapa ou afaste o zoom", + "many": "Mais {count} de câmeras fora desta vista · mova o mapa ou afaste o zoom", + "other": "Mais {count} câmeras fora desta vista · mova o mapa ou afaste o zoom" }, "Enable CCTV to load camera intersections": "Ative o CCTV para carregar os cruzamentos com câmeras", "Enable CCTV to start camera-linked intelligence summaries.": "Ative o CCTV para iniciar resumos de inteligência vinculados às câmeras.", @@ -1796,7 +1796,7 @@ "{scope} · INSTALLATIONS {coverage}": "{scope} · INSTALAÇÕES: {coverage}", "MILITARY FEED OFF · PARTIAL PICTURE": "FEED MILITAR DESLIGADO · QUADRO PARCIAL", "FLIGHTS FEED OFF · PARTIAL PICTURE": "FEED DE VOOS DESLIGADO · QUADRO PARCIAL", - "NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "AERONAVES PRÓXIMAS INCOMPLETAS · NÃO GARANTE SEGURANÇA", + "NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "LISTA DE AERONAVES PRÓXIMAS INCOMPLETA · NÃO GARANTE SEGURANÇA", "CONTEXT STANDBY": "CONTEXTO EM ESPERA", "ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "ATIVE O CONTEXTO GLOBAL PARA PINGS DE PROXIMIDADE", "CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "CONTATO PERDIDO · ÚLTIMA LEITURA CONHECIDA · NÃO GARANTE SEGURANÇA", @@ -2848,7 +2848,7 @@ "DOWN {angle}°": "ABAIXO {angle}°", "LEVEL": "NIVELADO", "{direction} · {elevation}": "{direction} · {elevation}", - "LOOK {elevation}": "VISTA {elevation}", + "LOOK {elevation}": "VISTA · {elevation}", "Estimated destination direction": "Direção estimada do destino", "DEST ---°": "DEST ---°", "BRIDGE WINDOW · FWD": "JANELA DO PASSADIÇO · VANTE", @@ -3012,7 +3012,7 @@ "Radio volume": "Volume do rádio", "STATION SITE": "SITE DA ESTAÇÃO", "DIRECTORY: RADIO BROWSER": "DIRETÓRIO: 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.": "O áudio conecta direto à emissora quando você aperta \"Ouvir\", escolhe uma estação no globo ou pede por voz. Seu IP fica visível para essa emissora.", + "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.": "O áudio se conecta diretamente à emissora quando você pressiona OUVIR, escolhe uma estação no globo ou pede por voz para tocar. Seu IP fica visível para essa emissora.", "Local RTL-SDR receiver": "Receptor 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 · um sintonizador local compartilhado entre o FM e a camada ADS-B local.", @@ -3372,13 +3372,13 @@ "Too many chat requests. Try again in {seconds} s.": "Chat: limite de requisições atingido. Tente novamente em {seconds} s.", "The provider refused access for this key. Check the key's project, region or permissions.": "O provedor negou o acesso para esta chave. Verifique o projeto, a região ou as permissões da chave.", "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "A conta do provedor está sem crédito. Verifique o faturamento ou escolha outro provedor de IA em \"Configurações\".", - "Last 24 h of news. Each report links to its publisher; places and categories are read from the text and data automatically and can be wrong. Side labels, where shown, describe the source, not whether a report is true.": "Últimas 24 h de notícias. Cada notícia leva ao seu editor; locais e categorias são lidos do texto e dos dados automaticamente e podem estar errados. Os rótulos de lado, quando exibidos, descrevem a fonte, não se a notícia é verdadeira.", + "Last 24 h of news. Each report links to its publisher; places and categories are read from the text and data automatically and can be wrong. Side labels, where shown, describe the source, not whether a report is true.": "Últimas 24 h de notícias. Cada notícia tem um link para o veículo que a publicou; locais e categorias são lidos do texto e dos dados automaticamente e podem estar errados. Os rótulos de lado, quando exibidos, descrevem a fonte, não se a notícia é verdadeira.", "Economy": "Economia", "News from {area}": "Notícias: {area}", "Within {km} km of the view centre": "Até {km} km do centro da vista", "{region}, {country}": "{region}, {country}", "via {source}": "via {source}", - "Local in the News layer: the day’s news from the outlets of the country in view": "Local na camada de notícias: as notícias do dia dos veículos do país em vista", + "Local in the News layer: the day’s news from the outlets of the country in view": "\"Local\" na camada de notícias: as notícias do dia dos veículos de imprensa do país na vista", "{provider} refused its key": "{provider} recusou a chave", "Check {envVars} in Provider Settings": "Verifique {envVars} em \"Configurações de provedores\"", "{provider} key required": "Chave do {provider} necessária", @@ -3402,8 +3402,8 @@ "Cargo or finds: {value}": "Carga ou achados: {value}", "Protected wreck": "Naufrágio protegido", "Source: {value}": "Fonte: {value}", - "Click the card to open EMODnet ↗": "Clique na ficha para abrir o EMODnet ↗", - "Click the card to open the source ↗": "Clique na ficha para abrir a fonte ↗", + "Click the card to open EMODnet ↗": "Clique no cartão para abrir o EMODnet ↗", + "Click the card to open the source ↗": "Clique no cartão para abrir a fonte ↗", "Wikidata unavailable; showing EMODnet only": "Wikidata indisponível; mostrando só o EMODnet", "EMODnet unavailable; showing Wikidata only": "EMODnet indisponível; mostrando só o Wikidata", "Shipwrecks unavailable": "Naufrágios indisponíveis", @@ -3417,8 +3417,8 @@ "Shipwreck": "Naufrágio", "Fatalities: {count}": "Mortos: {count}", "Survivors: {count}": "Sobreviventes: {count}", - "Click the card to open the article ↗": "Clique na ficha para abrir o artigo ↗", - "European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "Os naufrágios históricos europeus vêm do EMODnet Human Activities; os naufrágios notáveis do mundo vêm do Wikidata. A cor mostra quando o navio afundou; o marcador cresce com o número de mortos. Clique num marcador para ver a ficha.", + "Click the card to open the article ↗": "Clique no cartão para abrir o artigo ↗", + "European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "Os naufrágios históricos europeus vêm do EMODnet Human Activities; os naufrágios notáveis do mundo vêm do Wikidata. A cor mostra quando o navio afundou; o marcador cresce com o número de mortos. Clique em um marcador para ver o cartão.", "All years": "Todos os anos", "Period": "Período", "From": "Início", @@ -3446,7 +3446,7 @@ "Other or unknown": "Outros ou desconhecidos", "{label} {detail} — click to retry": "{label} {detail} — clique para tentar novamente", "Wildlife sightings source unavailable": "Fonte de avistamentos indisponível", - "Show only community-confirmed (research-grade) sightings": "Mostrar apenas observações confirmadas pela comunidade (grau de pesquisa)", + "Show only community-confirmed (research-grade) sightings": "Mostrar apenas avistamentos confirmados pela comunidade (grau de pesquisa)", "No recent sightings in view": "Nenhum avistamento recente na vista", "Acartia feed unavailable": "Feed Acartia indisponível", "Show all taxa": "Mostrar todos os grupos taxonômicos", @@ -3466,28 +3466,28 @@ "Exit cockpit to fly to a sighting": "Saia da cabine para voar até um avistamento", "Exit cockpit to fly to a sighting area": "Saia da cabine para voar até uma área de avistamento", "Wildlife": "Vida selvagem", - "Zoom in for local sightings": "Amplie para ver avistamentos locais", + "Zoom in for local sightings": "Aproxime o zoom para ver avistamentos locais", "Loading photo...": "Carregando foto…", "Photo unavailable": "Foto indisponível", "Location obscured to a 0.2° cell": "Localização ocultada em uma célula de 0,2°", "Photo of {name}": "Foto de {name}", "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", - "{count} sightings in view": "{count} avistamentos na vista", + "{count} sightings in view": "Avistamentos na vista: {count}", "Close sighting": "Fechar avistamento", - "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.": "Fauna observada nas últimas 48 h (cetáceos 7 dias). A cor e o símbolo do emblema indicam o grupo taxonômico. Espécies sensíveis aparecem apenas em células de 0,2°; os relatos de mamíferos marinhos da Acartia no mar de Salish são arredondados para 0,01°. Não é um censo.", + "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.": "Vida selvagem observada nas últimas 48 h (cetáceos 7 dias). A cor e o símbolo do emblema indicam o grupo taxonômico. Espécies sensíveis aparecem apenas em células de 0,2°; os relatos de mamíferos marinhos do mar de Salish via Acartia são arredondados para 0,01°. Não é um censo.", "Open {name} on iNaturalist": "Abrir {name} no iNaturalist", "Open {name} on Acartia": "Abrir {name} no Acartia", "iNaturalist": "iNaturalist", "iNaturalist contributors · licences per record": "Colaboradores do iNaturalist · licenças por registro", "iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Colaboradores do iNaturalist · Acartia {count} (mar de Salish) · licenças por registro", - "{days}d": "{days}d", + "{days}d": "{days} d", "All markers": "Todos os marcadores", "Show markers of every age": "Mostrar marcadores de qualquer idade", - "Maximum event age: {days}d; undated markers are kept": "Idade máxima do evento: {days} dias; marcadores sem data mantidos", + "Maximum event age: {days}d; undated markers are kept": "Idade máxima do evento: {days} d; marcadores sem data são mantidos", "event age filter|All": "Todos", "Event age": "Idade do evento", - "Event age: {days}d · undated kept": "Idade do evento: {days} dias · sem data mantidos", + "Event age: {days}d · undated kept": "Idade do evento: {days} d · sem data mantidos", "Close camera preview": "Fechar prévia da câmera", "Refresh map still": "Atualizar imagem fixa no mapa", "The map still uses a separate request and may show a different moment.": "A imagem no mapa usa uma solicitação separada e pode mostrar outro momento.", @@ -3496,16 +3496,16 @@ "Captured {month}": "Capturada em {month}", "this spot": "este ponto", "the view centre": "o centro da vista", - "{source} at {place}": "{source} em {place}", - "Street level at {place}": "Nível da rua em {place}", + "{source} at {place}": "{source}: {place}", + "Street level at {place}": "Nível da rua: {place}", "Switching map": "Mudando de mapa", "Opening imagery": "Abrindo imagens", "Street View is ready: press {open} to load it": "O Street View está pronto: clique em {open} para carregar", - "{source} cannot open this link's street-level photo without a working key": "{source} não pode abrir a foto ao nível da rua deste link sem uma chave válida", - "Street-level photos are hidden in the current view. Leave it first.": "As fotos ao nível da rua estão ocultas na vista atual. Saia dela primeiro.", + "{source} cannot open this link's street-level photo without a working key": "{source} não pode abrir a foto no nível da rua deste link sem uma chave válida", + "Street-level photos are hidden in the current view. Leave it first.": "As fotos no nível da rua estão ocultas na vista atual. Saia dela primeiro.", "STATIONS": "ESTAÇÕES", "Crewed stations and the craft that visit them": "Estações tripuladas e as naves que as visitam", - "IMAGING": "IMAGENS", + "IMAGING": "IMAGEAMENTO", "Earth observation: optical, radar, weather and science": "Observação da Terra: óptica, radar, meteorologia e ciência", "TELECOM": "TELECOM", "Communications and navigation satellites, Starlink excepted": "Satélites de comunicações e navegação, exceto Starlink", @@ -3517,7 +3517,7 @@ "Rocket bodies, fragments and dead satellites": "Corpos de foguetes, fragmentos e satélites inativos", "Classes may lag: SATCAT is a cached copy": "As classes podem estar desatualizadas: SATCAT é uma cópia em cache", "Classes may lag: SATCAT unavailable": "As classes podem estar desatualizadas: SATCAT indisponível", - "CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "O SATCAT da CelesTrak dá o tipo e o estado de cada objeto. Sem uma cópia atual, as classes se baseiam no banco de dados UCS, nos nomes e nos grupos da CelesTrak, então um satélite que deixou de funcionar pode ainda aparecer como operacional.", + "CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "O SATCAT do CelesTrak dá o tipo e o estado de cada objeto. Sem uma cópia atual, as classes se baseiam no banco de dados UCS, nos nomes e nos grupos do CelesTrak, então um satélite que deixou de funcionar pode ainda aparecer como operacional.", "STARLINK ···": "STARLINK ···", "STARLINK ✕": "STARLINK ✕", "SENSOR SWATH {value} KM": "FAIXA DO SENSOR {value} KM", @@ -3543,9 +3543,9 @@ "1 d/s": "1 d/s", "1 wk/s": "1 sem/s", "Play speed": "Velocidade de reprodução", - "Scroll or pinch to zoom, drag to move along · double-click to see it all": "Role ou faça pinça para ampliar, arraste para se mover · clique duplo para ver tudo", + "Scroll or pinch to zoom, drag to move along · double-click to see it all": "Role ou use o gesto de pinça para dar zoom, arraste para percorrer · clique duplo para ver tudo", "1 min/s": "1 min/s", - "Rail": "Ferroviário", + "Rail": "Trens", "Urban rail": "Urbano", "Local rail": "Suburbano", "Regional rail": "Regional", @@ -3554,7 +3554,7 @@ "Hide {category}": "Ocultar {category}", "Show {category}": "Mostrar {category}", "Live GPS": "GPS ao vivo", - "Estimated from station reports": "Estimada a partir dos registros das estações", + "Estimated from station reports": "Posição estimada pelos registros das estações", "To {headsign}": "Para {headsign}", "Next {stop} {time} {relative}": "Próxima parada {stop} {time} {relative}", "Prev {stop} {time} {relative}": "Parada anterior {stop} {time} {relative}", @@ -3564,22 +3564,22 @@ "Photo: {photographer} · {licence}": "Foto: {photographer} · {licence}", "Photo · {licence}": "Foto · {licence}", "The shared flight isn't reporting now — showing where it was last seen": "O voo compartilhado não está transmitindo agora — mostrando a última posição conhecida", - "The shared flight isn't reporting now — showing where it was at {time}": "O voo compartilhado não está transmitindo agora — mostrando onde estava às {time}", + "The shared flight isn't reporting now — showing where it was at {time}": "O voo compartilhado não está transmitindo agora — mostrando onde estava em {time}", "The shared military flight isn't reporting now — showing where it was last seen": "O voo militar compartilhado não está transmitindo agora — mostrando a última posição conhecida", - "The shared military flight isn't reporting now — showing where it was at {time}": "O voo militar compartilhado não está transmitindo agora — mostrando onde estava às {time}", + "The shared military flight isn't reporting now — showing where it was at {time}": "O voo militar compartilhado não está transmitindo agora — mostrando onde estava em {time}", "The shared vessel isn't reporting now — showing where it was last seen": "A embarcação compartilhada não está transmitindo agora — mostrando a última posição conhecida", - "The shared vessel isn't reporting now — showing where it was at {time}": "A embarcação compartilhada não está transmitindo agora — mostrando onde estava às {time}", + "The shared vessel isn't reporting now — showing where it was at {time}": "A embarcação compartilhada não está transmitindo agora — mostrando onde estava em {time}", "The shared satellite isn't reporting now — showing where it was last seen": "O satélite compartilhado não está transmitindo agora — mostrando a última posição conhecida", - "The shared satellite isn't reporting now — showing where it was at {time}": "O satélite compartilhado não está transmitindo agora — mostrando onde estava às {time}", + "The shared satellite isn't reporting now — showing where it was at {time}": "O satélite compartilhado não está transmitindo agora — mostrando onde estava em {time}", "ICAO: {id}": "ICAO: {id}", "NORAD: {id}": "NORAD: {id}", "MMSI: {id}": "MMSI: {id}", "Vessel UUID: {id}": "UUID da embarcação: {id}", "Latitude must be within ±90° and longitude within ±180°": "A latitude deve estar dentro de ±90° e a longitude dentro de ±180°", - "Street Level is unavailable": "Nível da rua não está disponível", + "Street Level is unavailable": "A camada \"Nível da rua\" não está disponível", "No street-level imagery source is configured": "Nenhuma fonte de imagens no nível da rua configurada", - "Street Level could not be enabled": "Não foi possível ativar Nível da rua", - "Street Level changed while it looked for imagery": "Nível da rua mudou enquanto procurava imagens", + "Street Level could not be enabled": "Não foi possível ativar a camada \"Nível da rua\"", + "Street Level changed while it looked for imagery": "A camada \"Nível da rua\" mudou enquanto procurava imagens", "Aim the view at a street": "Aponte a vista para uma rua", "{source} is not a street-level imagery source here": "{source} não é uma fonte de imagens no nível da rua aqui", "{source} needs its key": "{source} precisa da sua chave", @@ -3591,11 +3591,11 @@ "No {source} imagery near {place}": "Nenhuma imagem de {source} perto de {place}", "{reason} (filters on: {filters})": "{reason} (filtros ativos: {filters})", "tiles unavailable": "blocos indisponíveis", - "zoom in to load this layer": "aproxime para carregar esta camada", + "zoom in to load this layer": "aproxime o zoom para carregar esta camada", "Zoom to layer": "Ir para a camada", "Zoom to layer · fly to where this layer has data": "Ir para a camada · voe até onde esta camada tem dados", - "Remove from Data Layers": "Remover de Camadas de dados", - "Remove from Data Layers · add it again from the library": "Remover de Camadas de dados · adicione de novo pela biblioteca", + "Remove from Data Layers": "Remover de \"Camadas de dados\"", + "Remove from Data Layers · add it again from the library": "Remover de \"Camadas de dados\" · adicione de novo pela biblioteca", "Coverage outline": "Contorno de cobertura", "DRAPED": "SOBREPOSTO", "Below 250 m": "Abaixo de 250 m", @@ -3669,28 +3669,28 @@ "DATA LIBRARY": "BIBLIOTECA DE DADOS", "Close data library": "Fechar a biblioteca de dados", "Data Library at a glance": "A biblioteca de dados em resumo", - "Live sources beyond the core layers. Each one is fetched from its provider while it is on; nothing is stored in this app. Add one and it appears in Data Layers under Library.": "Fontes ao vivo além das camadas principais. Cada uma é obtida do seu provedor enquanto está ativa; nada é armazenado neste app. Adicione uma e ela aparecerá em Camadas de dados, em Biblioteca.", + "Live sources beyond the core layers. Each one is fetched from its provider while it is on; nothing is stored in this app. Add one and it appears in Data Layers under Library.": "Fontes ao vivo além das camadas principais. Cada uma é obtida do seu provedor enquanto está ativa; nada é armazenado neste app. Adicione uma e ela aparecerá em \"Camadas de dados\", em \"Biblioteca\".", "Name, provider or country": "Nome, provedor ou país", "Theme": "Tema", "All themes": "Todos os temas", "Only what this instance serves": "Só o que esta instância oferece", - "OPT-IN": "OPCIONAL", + "OPT-IN": "ATIV. PELO OPERADOR", "KEY": "CHAVE", "KEYLESS": "SEM CHAVE", "ZOOM": "APROXIMAR", "Zoom to {name}": "Ir para {name}", "WORKING…": "PROCESSANDO…", "ADD": "ADICIONAR", - "Remove {name} from Data Layers": "Remover {name} de Camadas de dados", - "Add {name} to Data Layers": "Adicionar {name} a Camadas de dados", + "Remove {name} from Data Layers": "Remover {name} de \"Camadas de dados\"", + "Add {name} to Data Layers": "Adicionar {name} a \"Camadas de dados\"", "CONFIRM · REMOVE ALL {count}": { "one": "CONFIRMAR · REMOVER {count}", "many": "CONFIRMAR · REMOVER TODAS AS {count}", "other": "CONFIRMAR · REMOVER TODAS AS {count}" }, "REMOVE ALL": "REMOVER TUDO", - "Confirm: remove every added entry from Data Layers": "Confirmar: remover de Camadas de dados todas as entradas adicionadas", - "Remove every added entry from Data Layers": "Remover de Camadas de dados todas as entradas adicionadas", + "Confirm: remove every added entry from Data Layers": "Confirmar: remover de \"Camadas de dados\" todas as entradas adicionadas", + "Remove every added entry from Data Layers": "Remover de \"Camadas de dados\" todas as entradas adicionadas", "Showing {shown} of {total}": "Mostrando {shown} de {total}", "BROWSE DATA LIBRARY": "EXPLORAR BIBLIOTECA DE DADOS" } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 6a366e4b8..ef8b144e5 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -1,6 +1,6 @@ { "News feed unavailable": "Лента новостей недоступна", - "Developing: {name}": "Развивается: {name}", + "Developing: {name}": "Развитие событий: {name}", "{count} events today": { "one": "{count} событие сегодня", "few": "{count} события сегодня", @@ -19,12 +19,12 @@ "Claimed by {who}": "Заявление: {who}", "Claimed": "Заявление", "Not yet confirmed by others": "Пока не подтверждено другими", - "Reported by {side} sources": "Сообщают источники {side}", - "Reported by {sides}": "Сообщают: {sides}", - "{side} sources": "источники {side}", + "Reported by {side} sources": "Сообщают источники: {side}", + "Reported by {sides}": "Сообщают источники: {sides}", + "{side} sources": "{side}", "Official": "Официально", "Reported by both sides": "Сообщают обе стороны", - "Independently reported": "Подтверждено независимыми источниками", + "Independently reported": "Сообщают независимые источники", "Single source": "Один источник", "International": "Международные", "Independent": "Независимые", @@ -42,7 +42,7 @@ "Air defence": "ПВО", "Ground fighting": "Наземные бои", "Casualties": "Жертвы", - "Humanitarian": "Гуманитарное", + "Humanitarian": "Гуманитарная ситуация", "Disaster": "Бедствие", "Protest": "Протест", "Political": "Политика", @@ -50,7 +50,7 @@ "Minor incident": "Незначительный инцидент", "Significant": "Значительный", "Major": "Крупный", - "age|now": "сейчас", + "age|now": "только что", "{minutes} min": "{minutes} мин", "{hours} h": "{hours} ч", "{count} sources": { @@ -60,7 +60,7 @@ "other": "{count} источника" }, "News feed not configured on this server": "Лента новостей не настроена на этом сервере", - "Malformed news hotspots response": "Некорректный ответ горячих точек новостей", + "Malformed news hotspots response": "Некорректный ответ с горячими точками новостей", "Malformed news feed response": "Некорректный ответ ленты новостей", "{place} (placed by model)": "{place} (место определила модель)", "English headline by the model": "Английский заголовок от модели", @@ -68,7 +68,7 @@ "Close": "Закрыть", "Any severity": "Любая тяжесть", "{severity} and above": "{severity} и выше", - "{severity} only": "Только {severity}", + "{severity} only": "Только: {severity}", "All categories": "Все категории", "Reading the news…": "Читаем новости…", "News, last {hours} hours": "Новости за последние {hours} ч", @@ -87,7 +87,7 @@ "Load older news": "Загрузить более ранние новости", "How to read the map": "Как читать карту", "Colour: severity · icon: type of event": "Цвет: тяжесть · значок: тип события", - "Solid rim: reported by both sides or independently. Dashed rim: a claim. Thin rim: one source, or one side’s own press.": "Сплошной обод: сообщают обе стороны или независимые источники. Пунктирный обод: заявление. Тонкий обод: один источник или пресса одной стороны.", + "Solid rim: reported by both sides or independently. Dashed rim: a claim. Thin rim: one source, or one side’s own press.": "Сплошной контур: сообщают обе стороны или независимые источники. Пунктирный контур: заявление. Тонкий контур: один источник или пресса одной стороны.", "Larger: more sources · fainter: older. Picture card: a major or fast-growing event with two or more sources.": "Крупнее: больше источников · бледнее: старше. Карточка с фото: крупное или быстро растущее событие от двух и более источников.", "Side labels describe the source, not whether a report is true.": "Метки сторон описывают источник, а не достоверность сообщения.", "Exit cockpit to fly to a news report": "Выйдите из кабины, чтобы перелететь к сообщению", @@ -268,7 +268,7 @@ "PAUSED · {title}": "ПАУЗА · {title}", "UNSPECIFIED": "НЕ УКАЗАНО", "UNAVAILABLE": "НЕДОСТУПНО", - "+{count} additional payload records": "+{count} доп. записи о полезной нагрузке", + "+{count} additional payload records": "Ещё записей о полезной нагрузке: {count}", "PAYLOAD DATA UNAVAILABLE": "ДАННЫЕ ПОЛЕЗНОЙ НАГРУЗКИ НЕДОСТУПНЫ", "{value} KM DOWNRANGE": "{value} КМ ПО ТРАССЕ", "POSITION UNAVAILABLE": "ПОЗИЦИЯ НЕДОСТУПНА", @@ -285,8 +285,8 @@ "{direction} {angle}°": "{direction} {angle}°", "OFF-NADIR {angle}° · {offset}": "ОТКЛОНЕНИЕ ОТ НАДИРА {angle}° · {offset}", "SITES STANDBY": "ОБЪЕКТЫ В ОЖИДАНИИ", - "ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "ВКЛЮЧИТЕ ОТМЕЧЕННЫЕ ОБЪЕКТЫ ДЛЯ ПИНГОВ МЕСТ", - "Release the camera from {name}": "Освободить камеру от {name}", + "ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "ВКЛЮЧИТЕ «ОБЪЕКТЫ НА КАРТЕ» ДЛЯ ПИНГОВ ОБЪЕКТОВ", + "Release the camera from {name}": "Отпустить камеру с объекта: {name}", "RETURN · {name}": "НАЗАД · {name}", "PREVIOUS SATELLITE": "ПРЕДЫДУЩИЙ СПУТНИК", "Return to {name}": "Вернуться к {name}", @@ -295,8 +295,8 @@ "Unnamed site": "Безымянный объект", "OFF-NADIR {angle}°": "ОТКЛОНЕНИЕ ОТ НАДИРА {angle}°", "FOCUS · {name}": "ФОКУС · {name}", - "Keep the camera on {name}": "Удерживать камеру на {name}", - "ZOOM {zoom}×": "ПРИБЛИЖЕНИЕ {zoom}×", + "Keep the camera on {name}": "Удерживать камеру на объекте: {name}", + "ZOOM {zoom}×": "МАСШТАБ {zoom}×", "SWATH {value} KM": "ПОЛОСА ОБЗОРА {value} КМ", "SWATH ≈ {value} KM": "ПОЛОСА ОБЗОРА ≈ {value} КМ", "Orbital view": "Орбитальный вид", @@ -304,7 +304,7 @@ "ORBITAL VELOCITY": "ОРБИТАЛЬНАЯ СКОРОСТЬ", "SUB-SATELLITE POINT": "ПОДСПУТНИКОВАЯ ТОЧКА", "NORTH UP": "СЕВЕР ВВЕРХУ", - "CURRENTLY OVER": "СЕЙЧАС НАД", + "CURRENTLY OVER": "ПОД СПУТНИКОМ", "{division}, {country}": "{division}, {country}", "{region} · near {place}": "{region} · поблизости: {place}", "NORAD {id}": "NORAD {id}", @@ -346,7 +346,7 @@ "Cancel replay": "Отменить повтор", "EST. DOWNRANGE": "РАСЧЁТ ПО ТРАССЕ", "CONFIRMED": "ПОДТВЕРЖДЕНО", - "PAD / RTLS": "СТАРТОВАЯ ПЛОЩАДКА / RTLS", + "PAD / RTLS": "ПЛОЩАДКА / RTLS", "PLANNED · ": "ПЛАН · ", "SUPPLIED TRAJECTORY POINTS": "ПРЕДОСТАВЛЕННЫЕ ТОЧКИ ТРАЕКТОРИИ", "RECONSTRUCTED ESTIMATE": "РЕКОНСТРУИРОВАННАЯ ОЦЕНКА", @@ -376,7 +376,7 @@ "Starlink {detail} — click to retry": "Starlink: {detail} — нажмите, чтобы повторить", "load failed": "ошибка загрузки", "Showing the full Starlink shell — click for the core catalog only": "Показана полная оболочка Starlink — нажмите, чтобы оставить только основной каталог", - "DENSE": "ПЛОТНЫЙ", + "DENSE": "ПЛОТНО", "Syncing flow · Roads: {source}": "Синхронизация потока · Дороги: {source}", "LIVE · Roads: {source} · Roads without flow hidden": "ОНЛАЙН · Дороги: {source} · Дороги без потока скрыты", "LIVE · Roads: {source} · No flow roads in view": "ОНЛАЙН · Дороги: {source} · В кадре нет дорог с потоком", @@ -469,10 +469,10 @@ "other": "Загружено {count} камеры · включите CCTV, чтобы активировать" }, "{count} more cameras outside this view · pan or zoom out": { - "one": "Ещё {count} камера вне этого вида · сдвиньте или отдалите", - "few": "Ещё {count} камеры вне этого вида · сдвиньте или отдалите", - "many": "Ещё {count} камер вне этого вида · сдвиньте или отдалите", - "other": "Ещё {count} камеры вне этого вида · сдвиньте или отдалите" + "one": "Ещё {count} камера вне поля зрения · сдвиньте или отдалите карту", + "few": "Ещё {count} камеры вне поля зрения · сдвиньте или отдалите карту", + "many": "Ещё {count} камер вне поля зрения · сдвиньте или отдалите карту", + "other": "Ещё {count} камеры вне поля зрения · сдвиньте или отдалите карту" }, "Enable CCTV to load camera intersections": "Включите CCTV, чтобы загрузить камеры на перекрёстках", "Enable CCTV to start camera-linked intelligence summaries.": "Включите CCTV, чтобы запускать сводки по камерам.", @@ -545,7 +545,7 @@ "ROUTE DATA UNAVAILABLE": "ДАННЫЕ МАРШРУТА НЕДОСТУПНЫ", "CONTACT NAVIGATION OFF": "НАВИГАЦИЯ ПО КОНТАКТАМ ВЫКЛЮЧЕНА", "THE BRIDGE STAYS WITH THE SELECTED VESSEL": "МОСТИК ОСТАЁТСЯ НА ВЫБРАННОМ СУДНЕ", - "{range} NM": "{range} МОР. МИЛЬ", + "{range} NM": "{range} МОР. МИЛ.", "AIS DESTINATION · CREW REPORTED": "ПУНКТ НАЗНАЧЕНИЯ AIS · УКАЗАН ЭКИПАЖЕМ", "DEST {bearing}": "НАЗН. {bearing}", "Disable cockpit weather effects": "Выключить погодные эффекты кабины", @@ -742,7 +742,7 @@ "ACQUIRING": "ЗАХВАТ", "SHARED {subject}": "ПО ССЫЛКЕ: {subject}", "entity": "объект", - "Shared {subject} follow expired": "Срок отслеживания {subject} по ссылке истёк", + "Shared {subject} follow expired": "Срок отслеживания по ссылке истёк: {subject}", "Shared {subject} could not be restored — feed unavailable": "Не удалось восстановить {subject} по ссылке — поток данных недоступен", "Shared {subject} is unavailable": "{subject} по ссылке недоступен", "LOADING LIVE DATA": "ЗАГРУЗКА ОНЛАЙН-ДАННЫХ", @@ -804,8 +804,8 @@ "Live signals": "Сигналы в реальном времени", "Latest regional news": "Последние региональные новости", "Location-based information": "Информация по текущему месту", - "LOOK STBD {bearing}°": "ВЗГЛЯД ПР. БОРТ {bearing}°", - "LOOK PORT {bearing}°": "ВЗГЛЯД ЛЕВ. БОРТ {bearing}°", + "LOOK STBD {bearing}°": "ВЗГЛЯД НА ПР. БОРТ {bearing}°", + "LOOK PORT {bearing}°": "ВЗГЛЯД НА ЛЕВ. БОРТ {bearing}°", "LOOK ASTERN": "ВЗГЛЯД ЗА КОРМУ", "UP {angle}°": "ВВЕРХ {angle}°", "DOWN {angle}°": "ВНИЗ {angle}°", @@ -823,7 +823,7 @@ "LIVE TRACK · COURSE ALIGNED": "ТРЕК ОНЛАЙН · КУРС СОВМЕЩЁН", "GROUND SPEED": "ПУТЕВАЯ СКОРОСТЬ", "SPEED OVER GROUND": "СКОРОСТЬ ПО ГРУНТУ", - "COURSE OVER GROUND": "КУРС ПО ГРУНТУ", + "COURSE OVER GROUND": "ПУТЕВОЙ УГОЛ", "ALTITUDE": "ВЫСОТА", "CONTEXT ONLY": "ТОЛЬКО КОНТЕКСТ", "AVAILABLE INPUTS ONLY · NOT AN ALL-CLEAR": "ТОЛЬКО ДОСТУПНЫЕ ДАННЫЕ · ОТСУТСТВИЕ УГРОЗ НЕ ПОДТВЕРЖДЕНО", @@ -842,14 +842,14 @@ "EXIT BRIDGE": "ВЫЙТИ С МОСТИКА", "EXIT ORBIT": "ВЫЙТИ С ОРБИТЫ", "CONTACTS · 250 KM": "КОНТАКТЫ · 250 КМ", - "RINGS {spacing} NM": "КОЛЬЦА {spacing} МОР. МИЛЬ", + "RINGS {spacing} NM": "КОЛЬЦА {spacing} МОР. МИЛ.", "{count} CONTACTS": { "one": "{count} КОНТАКТ", "few": "{count} КОНТАКТА", "many": "{count} КОНТАКТОВ", "other": "{count} КОНТАКТА" }, - "Radar range {range}. Select for the next range.": "Шкала дальности радара {range}. Выберите, чтобы перейти к следующей шкале.", + "Radar range {range}. Select for the next range.": "Шкала дальности радара {range} — выберите, чтобы перейти к следующей шкале.", "Bridge radar": "Радар ходового мостика", "Expand Visual Presets": "Развернуть визуальные пресеты", "Pin visual presets": "Закрепить визуальные пресеты", @@ -970,7 +970,7 @@ "DEL": "УДАЛ.", "UPDATE SHOT": "ОБНОВИТЬ КАДР", "IMPORT": "ИМПОРТ", - "RUN LOG": "ЖУРНАЛ ЗАПУСКОВ", + "RUN LOG": "ЖУРНАЛ ТАЙМИНГОВ", "CALIBRATION": "КАЛИБРОВКА", "Camera pose — click a value to type": "Поза камеры — щёлкните по значению, чтобы ввести", "SCENE SUMMARY": "СВОДКА ПО СЦЕНЕ", @@ -2131,9 +2131,9 @@ "N/A": "Н/Д", "{label} · {radius} AIR/SEA WINDOW": "{label} · ОКНО ВОЗДУХ/МОРЕ {radius}", "{scope} · INSTALLATIONS {coverage}": "{scope} · ОБЪЕКТЫ: {coverage}", - "MILITARY FEED OFF · PARTIAL PICTURE": "ВОЕННЫЙ ПОТОК ВЫКЛ · КАРТИНА НЕПОЛНАЯ", + "MILITARY FEED OFF · PARTIAL PICTURE": "ПОТОК ВОЕННЫХ РЕЙСОВ ВЫКЛ · КАРТИНА НЕПОЛНАЯ", "FLIGHTS FEED OFF · PARTIAL PICTURE": "ПОТОК РЕЙСОВ ВЫКЛ · КАРТИНА НЕПОЛНАЯ", - "NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "БЛИЖНИЕ ВОЗДУШНЫЕ СУДА: ДАННЫЕ НЕПОЛНЫЕ · ОТСУТСТВИЕ УГРОЗ НЕ ПОДТВЕРЖДЕНО", + "NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "БЛИЖАЙШИЕ САМОЛЁТЫ: ДАННЫЕ НЕПОЛНЫЕ · ОТСУТСТВИЕ УГРОЗ НЕ ПОДТВЕРЖДЕНО", "{source} · LAST {hours} H": "{source} · ЗА {hours} Ч", "ft": "фт", "kts": "уз", @@ -2416,8 +2416,8 @@ "Import": "Импорт", "Choose GeoJSON, KML, KMZ or GPX files": "Выберите файлы GeoJSON, KML, KMZ или GPX", "Imported files": "Импортированные файлы", - "DOCKED · {name}": "СТЫКОВКА · {name}", - "DOCKED · {name} · +{extra}": "СТЫКОВКА · {name} · +{extra}", + "DOCKED · {name}": "ПРИСТЫКОВАН · {name}", + "DOCKED · {name} · +{extra}": "ПРИСТЫКОВАНЫ · {name} · +{extra}", "loading coverage...": "загрузка покрытия...", "live feed unavailable": "поток в реальном времени недоступен", "GBFS fetch error": "ошибка загрузки GBFS", @@ -2936,7 +2936,7 @@ "Hybrid": "Гибрид", "Lines": "Линии", "Dots": "Точки", - "Road lines colored by live flow: green to red": "Линии дорог по потоку онлайн: от зелёного к красному", + "Road lines colored by live flow: green to red": "Линии дорог, окрашенные по потоку онлайн: от зелёного к красному", "Moving dots colored by live flow": "Движущиеся точки, окрашенные по потоку онлайн", "Moving dots": "Движущиеся точки", "TomTom roads only, each with its live flow": "Только дороги TomTom, у каждой — свой поток онлайн", @@ -3183,11 +3183,11 @@ "Gemini Live connection is expiring. Start voice again for a new conversation.": "Соединение с Gemini Live истекает. Запустите голосовой режим заново для нового разговора.", "Gemini voice sessions have a time limit. When a session expires, start voice again for a new conversation.": "Время голосовых сеансов Gemini ограничено. Когда сеанс истечёт, запустите голосовой режим заново для нового разговора.", "Gemini Live voice control": "Голосовое управление через Gemini Live", - "Voice provider": "Поставщик голосового сервиса", - "Changing provider reconnects voice if it is on.": "Смена поставщика переподключает голосовой режим, если он включён.", + "Voice provider": "Голосовой провайдер", + "Changing provider reconnects voice if it is on.": "Смена провайдера переподключает голосовой режим, если он включён.", "Gemini voice could not connect. Try again.": "Не удалось подключить голосовой режим Gemini. Повторите попытку.", "Gemini voice requires a same-origin app request": "Голосовой режим Gemini требует запрос из того же источника, что и приложение", - "Add a Gemini API key in Provider Settings": "Добавьте ключ API Gemini в настройках поставщиков", + "Add a Gemini API key in Provider Settings": "Добавьте ключ API Gemini в «Настройках провайдеров»", "The Gemini Live model configuration is invalid": "Недопустимая конфигурация модели Gemini Live", "Gemini voice is starting too often. Try again shortly.": "Голосовой режим Gemini запускается слишком часто. Повторите попытку чуть позже.", "Gemini quota is unavailable. Try again later.": "Квота Gemini недоступна. Повторите попытку позже.", @@ -3215,7 +3215,7 @@ "S2 · {name}": "S2 · {name}", "Report": "Сводка", "Open the source for {title}": "Открыть источник: {title}", - "LIVE · GitHub {commit} · {message} · checked every {seconds}s": "В ЭФИРЕ · GitHub {commit} · {message} · проверка каждые {seconds} с", + "LIVE · GitHub {commit} · {message} · checked every {seconds}s": "ОНЛАЙН · GitHub {commit} · {message} · проверка каждые {seconds} с", "GitHub {commit} · {message} · reconnecting for live updates": "GitHub {commit} · {message} · переподключение для обновлений в реальном времени", "Connecting to the S2 GhostMaps repository…": "Подключение к репозиторию S2 GhostMaps…", "{count} placemarks": { @@ -3292,7 +3292,7 @@ "Click a shot to select it, double-click to rename.": "Щёлкните кадр, чтобы выбрать его, дважды — чтобы переименовать.", "scene|PLAY": "ПУСК", "Stop playback (Esc)": "Остановить воспроизведение (Esc)", - "SAVE & SHARE": "СОХРАНИТЬ И ПОДЕЛИТЬСЯ", + "SAVE & SHARE": "СОХРАНЕНИЕ И ОБМЕН", "Download every scene as a JSON file you can import later": "Скачать все сцены файлом JSON для последующего импорта", "Load scenes from an exported JSON file": "Загрузить сцены из экспортированного файла JSON", "Download the timing log of the last play": "Скачать журнал таймингов последнего воспроизведения", @@ -3303,7 +3303,7 @@ "Left panels": "Левые панели", "Right panels": "Правые панели", "UTILITIES": "ИНСТРУМЕНТЫ", - "Exit cockpit to fly to a drawing": "Выйдите из кабины, чтобы перелететь к рисунку", + "Exit cockpit to fly to a drawing": "Выйдите из кабины, чтобы перелететь к фигуре", "Directions, drawing and your own files": "Маршруты, рисование и ваши файлы", "Voice place search (needs an Overpass server)": "Голосовой поиск мест (нужен сервер Overpass)", "OSM Places": "Места OSM", @@ -3458,14 +3458,14 @@ "Within {km} km of the view centre": "В радиусе {km} км от центра карты", "{region}, {country}": "{region}, {country}", "via {source}": "через {source}", - "Local in the News layer: the day’s news from the outlets of the country in view": "Местное в слое новостей: новости дня от СМИ страны в кадре", + "Local in the News layer: the day’s news from the outlets of the country in view": "Раздел «Местные» в слое «Новости»: новости дня от СМИ страны в кадре", "{provider} refused its key": "{provider} отклонил ключ", "Check {envVars} in Provider Settings": "Проверьте {envVars} в «Настройках провайдеров»", "{provider} key required": "Нужен ключ {provider}", "{provider} limit reached for today": "{provider}: дневной лимит исчерпан", "News resumes tomorrow, or with a paid plan": "Новости вернутся завтра или с платным тарифом", "News key required": "Нужен ключ новостей", - "Precise conflict points in the News layer, beside GDELT’s articles": "Точные точки конфликтов в слое новостей, вдобавок к статьям GDELT", + "Precise conflict points in the News layer, beside GDELT’s articles": "Точные координаты конфликтов в слое «Новости», в дополнение к статьям GDELT", "Worldwide": "Весь мир", "Shipwrecks": "Кораблекрушения", "Before 1900": "До 1900", @@ -3480,7 +3480,7 @@ "Depth: {m} m": "Глубина: {m} м", "Notes: {value}": "Примечания: {value}", "Cargo or finds: {value}": "Груз или находки: {value}", - "Protected wreck": "Охраняемый объект", + "Protected wreck": "Затонувшее судно под охраной", "Source: {value}": "Источник: {value}", "Click the card to open EMODnet ↗": "Нажмите на карточку, чтобы открыть EMODnet ↗", "Click the card to open the source ↗": "Нажмите на карточку, чтобы открыть источник ↗", @@ -3511,9 +3511,9 @@ "seen {age} ago": "замечено {age} назад", "observed {age} ago": "наблюдалось {age} назад", "Be Whale Wise": "Наблюдайте китов бережно", - "Reported by {name} ({entity})": "Сообщил: {name} ({entity})", - "Reported by {name}": "Сообщил: {name}", - "Salish Sea": "Селишское море", + "Reported by {name} ({entity})": "Источник: {name} ({entity})", + "Reported by {name}": "Источник: {name}", + "Salish Sea": "Море Селиш", "Birds": "Птицы", "Mammals": "Млекопитающие", "Reptiles": "Пресмыкающиеся", @@ -3528,8 +3528,8 @@ "{label} {detail} — click to retry": "{label} {detail} — нажмите, чтобы повторить", "Wildlife sightings source unavailable": "Источник наблюдений живой природы недоступен", "Show only community-confirmed (research-grade) sightings": "Показать только подтверждённые сообществом наблюдения исследовательского уровня", - "No recent sightings in view": "Нет недавних наблюдений в области обзора", - "Acartia feed unavailable": "Лента Acartia недоступна", + "No recent sightings in view": "В кадре нет недавних наблюдений", + "Acartia feed unavailable": "Поток Acartia недоступен", "Show all taxa": "Показать все группы организмов", "BIRDS": "ПТИЦЫ", "Show only birds": "Показать только птиц", @@ -3544,31 +3544,31 @@ "All rights reserved": "Все права защищены", "Cetacean": "Китообразное", "Wildlife Sightings": "Наблюдения живой природы", - "Exit cockpit to fly to a sighting": "Выйдите из кабины, чтобы перейти к наблюдению", - "Exit cockpit to fly to a sighting area": "Выйдите из кабины, чтобы перейти к области наблюдения", + "Exit cockpit to fly to a sighting": "Выйдите из кабины, чтобы перелететь к наблюдению", + "Exit cockpit to fly to a sighting area": "Выйдите из кабины, чтобы перелететь к области наблюдения", "Wildlife": "Живая природа", - "Zoom in for local sightings": "Приблизьте для местных наблюдений", + "Zoom in for local sightings": "Приблизьте карту, чтобы увидеть местные наблюдения", "Loading photo...": "Загрузка фото…", "Photo unavailable": "Фото недоступно", "Location obscured to a 0.2° cell": "Местоположение скрыто в ячейке 0,2°", - "Photo of {name}": "Фото {name}", + "Photo of {name}": "Фото: {name}", "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", - "{count} sightings in view": "{count} наблюдений в области просмотра", + "{count} sightings in view": "Наблюдений в кадре: {count}", "Close sighting": "Закрыть наблюдение", - "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.": "Наблюдения дикой природы за 48 ч (китообразные — 7 дней). Цвет и символ значка обозначают таксономическую группу. Уязвимые виды показаны только ячейками 0,2°; сообщения Acartia о морских млекопитающих моря Селиш округлены до 0,01°. Не перепись.", + "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.": "Наблюдения живой природы за 48 ч (китообразные — 7 дней). Цвет и символ значка обозначают таксономическую группу. Уязвимые виды показаны только ячейками 0,2°; сообщения Acartia о морских млекопитающих моря Селиш округлены до 0,01°. Это не учёт численности.", "Open {name} on iNaturalist": "Открыть {name} в iNaturalist", "Open {name} on Acartia": "Открыть {name} в Acartia", "iNaturalist": "iNaturalist", "iNaturalist contributors · licences per record": "Участники iNaturalist · лицензия у каждой записи", - "iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Участники iNaturalist · Acartia {count} (море Сэлиш) · лицензия у каждой записи", - "{days}d": "{days}д", + "iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Участники iNaturalist · Acartia {count} (море Селиш) · лицензия у каждой записи", + "{days}d": "{days} дн.", "All markers": "Все метки", "Show markers of every age": "Показать метки любого возраста", - "Maximum event age: {days}d; undated markers are kept": "Максимальный возраст события: {days} дн.; метки без даты сохранены", + "Maximum event age: {days}d; undated markers are kept": "Максимальный возраст события: {days} дн.; метки без даты остаются", "event age filter|All": "Все", "Event age": "Возраст события", - "Event age: {days}d · undated kept": "Возраст события: {days} дн. · метки без даты сохранены", + "Event age: {days}d · undated kept": "Возраст события: {days} дн. · метки без даты остаются", "Close camera preview": "Закрыть просмотр камеры", "Refresh map still": "Обновить кадр на карте", "The map still uses a separate request and may show a different moment.": "Кадр на карте запрашивается отдельно и может показывать другой момент.", @@ -3596,18 +3596,18 @@ "Space science, technology demonstration and other payloads": "Космическая наука, демонстрация технологий и другие полезные нагрузки", "DEBRIS": "МУСОР", "Rocket bodies, fragments and dead satellites": "Ступени ракет, обломки и неработающие спутники", - "Classes may lag: SATCAT is a cached copy": "Классы могут отставать: SATCAT — кэшированная копия", - "Classes may lag: SATCAT unavailable": "Классы могут отставать: SATCAT недоступен", + "Classes may lag: SATCAT is a cached copy": "Классы могут быть неактуальны: SATCAT — кэшированная копия", + "Classes may lag: SATCAT unavailable": "Классы могут быть неактуальны: SATCAT недоступен", "CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "CelesTrak SATCAT сообщает тип и состояние каждого объекта. Без актуальной копии классы опираются на базу UCS, названия и группы CelesTrak, поэтому вышедший с тех пор из строя спутник может по-прежнему показываться работающим.", "STARLINK ···": "STARLINK ···", "STARLINK ✕": "STARLINK ✕", - "SENSOR SWATH {value} KM": "ПОЛОСА СЕНСОРА {value} КМ", - "SENSOR SWATH ≈ {value} KM": "ПОЛОСА СЕНСОРА ≈ {value} КМ", + "SENSOR SWATH {value} KM": "ПОЛОСА ЗАХВАТА {value} КМ", + "SENSOR SWATH ≈ {value} KM": "ПОЛОСА ЗАХВАТА ≈ {value} КМ", "SENSOR": "СЕНСОР", - "Zoom to the imaging sensor swath": "Приблизить до полосы съёмочного сенсора", + "Zoom to the imaging sensor swath": "Приблизить до полосы захвата сенсора", "<1 H OLD": "<1 Ч НАЗАД", "{value} H OLD": "{value} Ч НАЗАД", - "{value} D OLD": "{value} Д НАЗАД", + "{value} D OLD": "{value} ДН. НАЗАД", "FWD": "ВПЕРЁД", "AFT": "НАЗАД", "RIGHT": "ВПРАВО", @@ -3618,11 +3618,11 @@ "Zoom out (−)": "Отдалить (−)", "ORBITAL VIEW · OFF-NADIR": "ОРБИТАЛЬНЫЙ ВИД · ОТКЛОНЕНИЕ ОТ НАДИРА", "World time": "Мировое время", - "Back to live": "Вернуться к прямому эфиру", + "Back to live": "Вернуться к текущему времени", "Live": "Сейчас", "1 h/s": "1 ч/с", - "1 d/s": "1 дн/с", - "1 wk/s": "1 нед/с", + "1 d/s": "1 дн./с", + "1 wk/s": "1 нед./с", "Play speed": "Скорость воспроизведения", "Scroll or pinch to zoom, drag to move along · double-click to see it all": "Прокрутите или сведите пальцы для масштаба, перетащите для перемещения · двойной щелчок — показать всё", "1 min/s": "1 мин/с", @@ -3637,31 +3637,31 @@ "Live GPS": "GPS в реальном времени", "Estimated from station reports": "Оценка по отметкам станций", "To {headsign}": "До {headsign}", - "Next {stop} {time} {relative}": "Следующая {stop} {time} {relative}", - "Prev {stop} {time} {relative}": "Предыдущая {stop} {time} {relative}", + "Next {stop} {time} {relative}": "Следующая: {stop} {time} {relative}", + "Prev {stop} {time} {relative}": "Предыдущая: {stop} {time} {relative}", "(+{minutes} min late)": "(+{minutes} мин опоздания)", "Exit cockpit to fly to a train": "Выйдите из кабины, чтобы перелететь к поезду", "Photo © {photographer}": "Фото © {photographer}", "Photo: {photographer} · {licence}": "Фото: {photographer} · {licence}", "Photo · {licence}": "Фото · {licence}", - "The shared flight isn't reporting now — showing where it was last seen": "Общий рейс сейчас не передаёт данные — показано последнее известное место", - "The shared flight isn't reporting now — showing where it was at {time}": "Общий рейс сейчас не передаёт данные — показано место в {time}", - "The shared military flight isn't reporting now — showing where it was last seen": "Общий военный рейс сейчас не передаёт данные — показано последнее известное место", - "The shared military flight isn't reporting now — showing where it was at {time}": "Общий военный рейс сейчас не передаёт данные — показано место в {time}", - "The shared vessel isn't reporting now — showing where it was last seen": "Общее судно сейчас не передаёт данные — показано последнее известное место", - "The shared vessel isn't reporting now — showing where it was at {time}": "Общее судно сейчас не передаёт данные — показано место в {time}", - "The shared satellite isn't reporting now — showing where it was last seen": "Общий спутник сейчас не передаёт данные — показано последнее известное место", - "The shared satellite isn't reporting now — showing where it was at {time}": "Общий спутник сейчас не передаёт данные — показано место в {time}", + "The shared flight isn't reporting now — showing where it was last seen": "Рейс по ссылке сейчас не передаёт данные — показано последнее известное положение", + "The shared flight isn't reporting now — showing where it was at {time}": "Рейс по ссылке сейчас не передаёт данные — показано положение на {time}", + "The shared military flight isn't reporting now — showing where it was last seen": "Военный рейс по ссылке сейчас не передаёт данные — показано последнее известное положение", + "The shared military flight isn't reporting now — showing where it was at {time}": "Военный рейс по ссылке сейчас не передаёт данные — показано положение на {time}", + "The shared vessel isn't reporting now — showing where it was last seen": "Судно по ссылке сейчас не передаёт данные — показано последнее известное положение", + "The shared vessel isn't reporting now — showing where it was at {time}": "Судно по ссылке сейчас не передаёт данные — показано положение на {time}", + "The shared satellite isn't reporting now — showing where it was last seen": "Спутник по ссылке сейчас не передаёт данные — показано последнее известное положение", + "The shared satellite isn't reporting now — showing where it was at {time}": "Спутник по ссылке сейчас не передаёт данные — показано положение на {time}", "ICAO: {id}": "ICAO: {id}", "NORAD: {id}": "NORAD: {id}", "MMSI: {id}": "MMSI: {id}", "Vessel UUID: {id}": "UUID судна: {id}", "Latitude must be within ±90° and longitude within ±180°": "Широта должна быть в пределах ±90°, а долгота — в пределах ±180°", - "Street Level is unavailable": "Уличные снимки недоступны", + "Street Level is unavailable": "Слой «Уличные снимки» недоступен", "No street-level imagery source is configured": "Источник уличных снимков не настроен", - "Street Level could not be enabled": "Не удалось включить уличные снимки", - "Street Level changed while it looked for imagery": "Уличные снимки изменились во время поиска", - "Aim the view at a street": "Наведите вид на улицу", + "Street Level could not be enabled": "Не удалось включить «Уличные снимки»", + "Street Level changed while it looked for imagery": "Состояние слоя «Уличные снимки» изменилось во время поиска", + "Aim the view at a street": "Наведите центр вида на улицу", "{source} is not a street-level imagery source here": "{source} здесь не является источником уличных снимков", "{source} needs its key": "{source}: нужен ключ", "{source} needs the {map} map, which is not available": "{source}: нужна карта {map}, но она недоступна", @@ -3669,7 +3669,7 @@ "{source} is not available on this map": "{source}: недоступно на этой карте", "{source} imagery could not be opened": "Не удалось открыть снимки {source}", "{place} has no position to look for imagery at": "У места «{place}» нет координат для поиска снимков", - "No {source} imagery near {place}": "Нет снимков {source} рядом с {place}", + "No {source} imagery near {place}": "Нет снимков {source} рядом с местом «{place}»", "{reason} (filters on: {filters})": "{reason} (включены фильтры: {filters})", "tiles unavailable": "тайлы недоступны", "Environment": "Окружающая среда", @@ -3688,15 +3688,15 @@ "Needs a provider key on the server": "Нужен ключ провайдера на сервере", "Needs this app's server · not available in a static build": "Нужен сервер приложения · недоступно в статической сборке", "Checking what this instance serves…": "Проверяем, что доступно на этом экземпляре…", - "Regional": "Региональный", + "Regional": "Регионально", "DATA LIBRARY": "БИБЛИОТЕКА ДАННЫХ", "Close data library": "Закрыть библиотеку данных", - "Live sources beyond the core layers. Each one is fetched from its provider while it is on; nothing is stored in this app. Add one and it appears in Data Layers under Library.": "Живые источники помимо основных слоёв. Каждый загружается у своего провайдера, пока он включён; приложение ничего не хранит. Добавьте источник, и он появится в «Слоях данных» в разделе «Библиотека».", + "Live sources beyond the core layers. Each one is fetched from its provider while it is on; nothing is stored in this app. Add one and it appears in Data Layers under Library.": "Источники в реальном времени помимо основных слоёв. Каждый загружается у своего провайдера, пока он включён; приложение ничего не хранит. Добавьте источник, и он появится в «Слоях данных» в разделе «Библиотека».", "Name, provider or country": "Название, провайдер или страна", "Theme": "Тема", "All themes": "Все темы", "Only what this instance serves": "Только то, что доступно на этом экземпляре", - "OPT-IN": "ПО ВЫБОРУ", + "OPT-IN": "ВКЛЮЧАЕТ ОПЕРАТОР", "KEY": "КЛЮЧ", "KEYLESS": "БЕЗ КЛЮЧА", "WORKING…": "ВЫПОЛНЯЕТСЯ…", diff --git a/src/i18n/locales/sv.json b/src/i18n/locales/sv.json index 6aa87a8d4..87c66ef3f 100644 --- a/src/i18n/locales/sv.json +++ b/src/i18n/locales/sv.json @@ -3328,7 +3328,7 @@ "Refresh map still": "Uppdatera stillbilden på kartan", "The map still uses a separate request and may show a different moment.": "Stillbilden på kartan hämtas separat och kan visa ett annat ögonblick.", "Video plays from Québec 511, which sees your IP address.": "Videon spelas upp från Québec 511, som ser din IP-adress.", - "Press OPEN to load the photo": "Tryck på ÖPPNA för att läsa in bilden", + "Press OPEN to load the photo": "Tryck på ÖPPNA för att ladda fotot", "Captured {month}": "Tagen {month}", "this spot": "den här platsen", "the view centre": "vyns mitt", @@ -3336,7 +3336,7 @@ "Street level at {place}": "Gatunivå vid {place}", "Switching map": "Byter karta", "Opening imagery": "Öppnar bilder", - "Street View is ready: press {open} to load it": "Street View är redo: tryck på {open} för att läsa in den", + "Street View is ready: press {open} to load it": "Street View är redo: tryck på {open} för att ladda den", "{source} cannot open this link's street-level photo without a working key": "{source} kan inte öppna länkens bild på gatunivå utan en fungerande nyckel", "Street-level photos are hidden in the current view. Leave it first.": "Bilder på gatunivå är dolda i den aktuella vyn. Lämna den först.", "STATIONS": "STATIONER", @@ -3457,7 +3457,7 @@ "REPORTED VOYAGE": "ANGIVEN RESA", "1 min/s": "1 min/s", "Rail": "Järnväg", - "Urban rail": "Stadstrafik", + "Urban rail": "Tunnelbana/spårväg", "Local rail": "Lokaltåg", "Regional rail": "Regionaltåg", "National rail": "Fjärrtåg", @@ -3465,11 +3465,11 @@ "Hide {category}": "Dölj {category}", "Show {category}": "Visa {category}", "Live GPS": "Live-GPS", - "Estimated from station reports": "Uppskattad från stationsrapporter", + "Estimated from station reports": "Uppskattad utifrån stationsrapporter", "To {headsign}": "Mot {headsign}", "Next {stop} {time} {relative}": "Nästa stopp {stop} {time} {relative}", "Prev {stop} {time} {relative}": "Föregående stopp {stop} {time} {relative}", - "(+{minutes} min late)": "(+{minutes} min försenad)", + "(+{minutes} min late)": "(+{minutes} min försening)", "Exit cockpit to fly to a train": "Lämna cockpiten för att flyga till ett tåg", "Photo © {photographer}": "Foto © {photographer}", "Photo: {photographer} · {licence}": "Foto: {photographer} · {licence}", @@ -3532,7 +3532,7 @@ "TILES": "KARTRUTOR", "PACK": "PAKET", "INCUBATING": "INKUBERAS", - "COMMUNITY": "GEMENSKAP", + "COMMUNITY": "ANVÄNDARBIDRAG", "VERIFIED": "VERIFIERAD", "ARCHIVED": "ARKIVERAD", "Datasets": "Datamängder", @@ -3563,11 +3563,11 @@ }, "switch on a dataset to load features": "slå på en datamängd för att ladda objekt", "Switched on by this instance · part of the CCTV layer": "Påslaget av den här instansen · del av CCTV-lagret", - "Operator opt-in: set {env}=1 on the server": "Aktiveras av operatören: sätt {env}=1 på servern", - "Operator opt-in: add {id} to GEV_LIBRARY_ENABLE on the server": "Aktiveras av operatören: lägg till {id} i GEV_LIBRARY_ENABLE på servern", - "Turned off by this instance's operator": "Avstängt av instansens operatör", + "Operator opt-in: set {env}=1 on the server": "Aktiveras av administratören: sätt {env}=1 på servern", + "Operator opt-in: add {id} to GEV_LIBRARY_ENABLE on the server": "Aktiveras av administratören: lägg till {id} i GEV_LIBRARY_ENABLE på servern", + "Turned off by this instance's operator": "Avstängt av instansens administratör", "Needs a provider key on the server": "Kräver en leverantörsnyckel på servern", - "Needs this app's server · not available in a static build": "Kräver appens server · inte tillgängligt i en statisk build", + "Needs this app's server · not available in a static build": "Kräver appens server · inte tillgängligt i en statisk version", "Checking what this instance serves…": "Kontrollerar vad instansen tillhandahåller …", "Regional": "Regionalt", "Orthophoto": "Ortofoto", @@ -3577,7 +3577,7 @@ "True orthophoto": "Sant ortofoto", "Type": "Typ", "Resolution": "Upplösning", - "Flown": "Flugen", + "Flown": "Fotograferat", "Updates": "Uppdateras", "Ortho quality": "Ortokvalitet", "DATA LIBRARY": "DATABIBLIOTEK", @@ -3588,7 +3588,7 @@ "Theme": "Tema", "All themes": "Alla teman", "Only what this instance serves": "Bara det instansen tillhandahåller", - "OPT-IN": "VALFRITT", + "OPT-IN": "TILLVAL", "KEY": "NYCKEL", "KEYLESS": "UTAN NYCKEL", "ZOOM": "ZOOMA", diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index acb9e92a8..4b079ac0a 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -1,6 +1,6 @@ { "News feed unavailable": "Стрічка новин недоступна", - "Developing: {name}": "Розвивається: {name}", + "Developing: {name}": "Розвиток подій: {name}", "{count} events today": { "one": "{count} подія сьогодні", "few": "{count} події сьогодні", @@ -19,17 +19,17 @@ "Claimed by {who}": "Заява: {who}", "Claimed": "Заява", "Not yet confirmed by others": "Поки не підтверджено іншими", - "Reported by {side} sources": "Повідомляють джерела {side}", - "Reported by {sides}": "Повідомляють: {sides}", - "{side} sources": "джерела {side}", + "Reported by {side} sources": "Повідомляють джерела: {side}", + "Reported by {sides}": "Повідомляють джерела: {sides}", + "{side} sources": "{side}", "Official": "Офіційно", "Reported by both sides": "Повідомляють обидві сторони", - "Independently reported": "Підтверджено незалежними джерелами", + "Independently reported": "Повідомляють незалежні джерела", "Single source": "Одне джерело", "International": "Міжнародні", "Independent": "Незалежні", "Unattributed": "Без атрибуції", - "Outside party": "Зовнішня сторона", + "Outside party": "Третя сторона", "Ukraine–Russia": "Україна–Росія", "Israel–Gaza–Lebanon": "Ізраїль–Газа–Ліван", "Iran–Hormuz": "Іран–Ормуз", @@ -42,7 +42,7 @@ "Air defence": "ППО", "Ground fighting": "Наземні бої", "Casualties": "Жертви", - "Humanitarian": "Гуманітарне", + "Humanitarian": "Гуманітарна ситуація", "Disaster": "Лихо", "Protest": "Протест", "Political": "Політика", @@ -60,38 +60,38 @@ "other": "{count} джерела" }, "News feed not configured on this server": "Стрічку новин не налаштовано на цьому сервері", - "Malformed news hotspots response": "Некоректна відповідь гарячих точок новин", + "Malformed news hotspots response": "Некоректна відповідь із гарячими точками новин", "Malformed news feed response": "Некоректна відповідь стрічки новин", "{place} (placed by model)": "{place} (місце визначила модель)", "English headline by the model": "Англійський заголовок від моделі", - "Not placed on the map": "Не позначено на мапі", + "Not placed on the map": "Не позначено на карті", "Close": "Закрити", "Any severity": "Будь-яка тяжкість", "{severity} and above": "{severity} і вище", "{severity} only": "Лише {severity}", "All categories": "Усі категорії", "Reading the news…": "Читаємо новини…", - "News, last {hours} hours": "Новини за останні {hours} год", + "News, last {hours} hours": "Новини за {hours} год", "Last {hours} h": "За {hours} год", "Time window": "Період", "Conflicts": "Конфлікти", "Local": "Місцеві", "News sections": "Розділи новин", - "Zoom in to see local news": "Наблизьте мапу, щоб побачити місцеві новини", + "Zoom in to see local news": "Наблизьте карту, щоб побачити місцеві новини", "Hotspots": "Гарячі точки", "Severity": "Тяжкість", "Category": "Категорія", - "Nothing reported near here in the last {hours} h": "За останні {hours} год поблизу нічого не повідомлялося", + "Nothing reported near here in the last {hours} h": "За {hours} год поблизу нічого не повідомлялося", "Latest news": "Останні новини", - "No news matches these filters in the last {hours} h": "За останні {hours} год немає новин за цими фільтрами", + "No news matches these filters in the last {hours} h": "За {hours} год немає новин за цими фільтрами", "Load older news": "Завантажити давніші новини", - "How to read the map": "Як читати мапу", + "How to read the map": "Як читати карту", "Colour: severity · icon: type of event": "Колір: тяжкість · значок: тип події", "Solid rim: reported by both sides or independently. Dashed rim: a claim. Thin rim: one source, or one side’s own press.": "Суцільний обідок: повідомляють обидві сторони або незалежні джерела. Пунктирний обідок: заява. Тонкий обідок: одне джерело або преса однієї сторони.", - "Larger: more sources · fainter: older. Picture card: a major or fast-growing event with two or more sources.": "Більше: більше джерел · блідіше: давніше. Картка з фото: велика або швидко зростаюча подія з двох і більше джерел.", + "Larger: more sources · fainter: older. Picture card: a major or fast-growing event with two or more sources.": "Більший маркер: більше джерел · блідіший: давніша подія. Картка з фото: велика подія або подія, що швидко розвивається, з двома чи більше джерелами.", "Side labels describe the source, not whether a report is true.": "Позначки сторін описують джерело, а не правдивість повідомлення.", "Exit cockpit to fly to a news report": "Вийдіть із кабіни, щоб перелетіти до повідомлення", - "News items in the feed": "Новин у стрічці", + "News items in the feed": "Кількість новин у стрічці", "Pick a shape, then click the map.": "Виберіть фігуру й клацніть на карті.", "Enter to place the pin, Esc to cancel.": "Enter — поставити мітку, Esc — скасувати.", "Click where the pin goes.": "Клацніть там, де має бути мітка.", @@ -936,15 +936,15 @@ "{value} KM/S": "{value} КМ/С", "{direction} {angle}°": "{direction} {angle}°", "OFF-NADIR {angle}° · {offset}": "ВІДХИЛЕННЯ ВІД НАДИРА {angle}° · {offset}", - "SITES STANDBY": "ОБ'ЄКТИ: ОЧІКУВАННЯ", - "ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "УВІМКНІТЬ ПОЗНАЧЕНІ ОБ'ЄКТИ, ЩОБ БАЧИТИ МІСЦЯ", + "SITES STANDBY": "ОБʼЄКТИ: ОЧІКУВАННЯ", + "ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "УВІМКНІТЬ «НАНЕСЕНІ ОБʼЄКТИ», ЩОБ БАЧИТИ КОНТАКТИ", "Release the camera from {name}": "Звільнити камеру від {name}", "RETURN · {name}": "НАЗАД · {name}", "PREVIOUS SATELLITE": "ПОПЕРЕДНІЙ СУПУТНИК", "Return to {name}": "Повернутися до {name}", "SATELLITE": "СУПУТНИК", "Ride {name}": "Перейти на {name}", - "Unnamed site": "Безіменний об'єкт", + "Unnamed site": "Безіменний обʼєкт", "OFF-NADIR {angle}°": "ВІДХИЛЕННЯ ВІД НАДИРА {angle}°", "FOCUS · {name}": "ФОКУС · {name}", "Keep the camera on {name}": "Утримувати камеру на {name}", @@ -1253,9 +1253,9 @@ "Moving dots": "Рухомі точки", "TomTom key unavailable": "Ключ TomTom недоступний", "TomTom daily budget reached": "Вичерпано денний ліміт TomTom", - "Monthly traffic allowance used": "Місячний ліміт даних про трафік вичерпано", - "Traffic rate limit reached": "Досягнуто ліміту запитів даних про трафік", - "Traffic service unavailable": "Сервіс даних про трафік недоступний", + "Monthly traffic allowance used": "Місячний ліміт даних про дорожній рух вичерпано", + "Traffic rate limit reached": "Перевищено ліміт запитів до сервісу руху", + "Traffic service unavailable": "Сервіс руху недоступний", "TomTom upstream unreachable": "Сервер TomTom не відповідає", "TomTom flow unavailable": "Потік TomTom недоступний", "SIMULATED — {reason}": "ІМІТАЦІЯ — {reason}", @@ -1779,10 +1779,10 @@ "other": "{count} камери завантажено · увімкніть CCTV, щоб активувати" }, "{count} more cameras outside this view · pan or zoom out": { - "one": "Ще {count} камера поза цим видом · посуньте або віддаліть", - "few": "Ще {count} камери поза цим видом · посуньте або віддаліть", - "many": "Ще {count} камер поза цим видом · посуньте або віддаліть", - "other": "Ще {count} камери поза цим видом · посуньте або віддаліть" + "one": "Ще {count} камера поза кадром · посуньте або віддаліть карту", + "few": "Ще {count} камери поза кадром · посуньте або віддаліть карту", + "many": "Ще {count} камер поза кадром · посуньте або віддаліть карту", + "other": "Ще {count} камери поза кадром · посуньте або віддаліть карту" }, "Enable CCTV to load camera intersections": "Увімкніть CCTV, щоб завантажити камери на перехрестях", "Enable CCTV to start camera-linked intelligence summaries.": "Увімкніть CCTV, щоб отримувати розвідувальні зведення за даними камер.", @@ -1822,7 +1822,7 @@ "{scope} · INSTALLATIONS {coverage}": "{scope} · ОБʼЄКТИ: {coverage}", "MILITARY FEED OFF · PARTIAL PICTURE": "ВІЙСЬКОВИЙ ПОТІК ВИМК. · КАРТИНА НЕПОВНА", "FLIGHTS FEED OFF · PARTIAL PICTURE": "ПОТІК РЕЙСІВ ВИМК. · КАРТИНА НЕПОВНА", - "NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "БЛИЗЬКІ ПОВІТРЯНІ СУДНА: ДАНІ НЕПОВНІ · НЕ ОЗНАЧАЄ «УСЕ ЧИСТО»", + "NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "БОРТИ ПОБЛИЗУ: ДАНІ НЕПОВНІ · НЕ ОЗНАЧАЄ «УСЕ ЧИСТО»", "CONTEXT STANDBY": "КОНТЕКСТ: ОЧІКУВАННЯ", "ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "УВІМКНІТЬ ГЛОБАЛЬНИЙ КОНТЕКСТ, ЩОБ БАЧИТИ КОНТАКТИ ПОБЛИЗУ", "CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "КОНТАКТ ВТРАЧЕНО · ОСТАННІ ВІДОМІ ПОКАЗНИКИ · НЕ ОЗНАЧАЄ «УСЕ ЧИСТО»", @@ -1859,7 +1859,7 @@ "ROUTE DATA UNAVAILABLE": "ДАНІ МАРШРУТУ НЕДОСТУПНІ", "CONTACT NAVIGATION OFF": "НАВІГАЦІЮ КОНТАКТАМИ ВИМКНЕНО", "THE BRIDGE STAYS WITH THE SELECTED VESSEL": "МІСТОК ЗАЛИШАЄТЬСЯ НА ВИБРАНОМУ СУДНІ", - "{range} NM": "{range} МОР. МИЛЬ", + "{range} NM": "{range} МОР. МИЛ.", "AIS DESTINATION · CREW REPORTED": "ПРИЗНАЧЕННЯ AIS · ВКАЗАНО ЕКІПАЖЕМ", "DEST {bearing}": "ПРИЗН. {bearing}", "Disable cockpit weather effects": "Вимкнути погодні ефекти кабіни", @@ -2929,7 +2929,7 @@ "NO HEADING · AIS": "НЕМАЄ КУРСУ · AIS", "VISOR LOCK · ACTIVE": "ФІКСАЦІЯ ВІЗОРА · АКТИВНА", "GROUND SPEED · KTS": "ШЛЯХОВА ШВИДКІСТЬ · ВУЗ", - "SPEED OVER GROUND · KTS": "ШВИДКІСТЬ ЩОДО ҐРУНТУ · ВУЗ", + "SPEED OVER GROUND · KTS": "ШЛЯХОВА ШВИДКІСТЬ · ВУЗ", "ALTITUDE · FT": "ВИСОТА · ФТ", "FIRST PERSON": "ВІД ПЕРШОЇ ОСОБИ", "BRIDGE VIEW": "ВИГЛЯД З МІСТКА", @@ -2946,22 +2946,22 @@ "LOOK AHEAD": "ПОГЛЯД ПО НОСІ", "Current vessel heading": "Поточний курс судна", "GROUND SPEED": "ШЛЯХОВА ШВИДКІСТЬ", - "SPEED OVER GROUND": "ШВИДКІСТЬ ЩОДО ҐРУНТУ", - "COURSE OVER GROUND": "КУРС ЩОДО ҐРУНТУ", + "SPEED OVER GROUND": "ШЛЯХОВА ШВИДКІСТЬ", + "COURSE OVER GROUND": "ШЛЯХОВИЙ КУТ", "KTS": "ВУЗ", "ALTITUDE": "ВИСОТА", "FT": "ФТ", "Contact cockpit summary": "Зведення контакту в кабіні", "CONTACT": "КОНТАКТ", "CONTACTS · 250 KM": "КОНТАКТИ · 250 КМ", - "RINGS {spacing} NM": "КІЛЬЦЯ {spacing} МОР. МИЛЬ", + "RINGS {spacing} NM": "КІЛЬЦЯ {spacing} МОР. МИЛ.", "{count} CONTACTS": { "one": "{count} КОНТАКТ", "few": "{count} КОНТАКТИ", "many": "{count} КОНТАКТІВ", "other": "{count} КОНТАКТУ" }, - "Radar range {range}. Select for the next range.": "Шкала дальності радара {range}. Виберіть, щоб перейти до наступної шкали.", + "Radar range {range}. Select for the next range.": "Шкала дальності радара {range} — виберіть, щоб перейти до наступної шкали.", "Bridge radar": "Радар ходового містка", "Contact navigation": "Навігація контактами", "CONTEXT ONLY": "ЛИШЕ КОНТЕКСТ", @@ -3064,7 +3064,7 @@ "AVAILABLE MISSIONS": "ДОСТУПНІ МІСІЇ", "SELECT A MISSION TO INSPECT": "ВИБЕРІТЬ МІСІЮ ДЛЯ ПЕРЕГЛЯДУ", "LOADING 30-DAY MISSION INDEX": "ЗАВАНТАЖЕННЯ ІНДЕКСУ МІСІЙ ЗА 30 ДНІВ", - "Space Missions unavailable: the mission index could not load. Try again later.": "Космічні місії недоступні: не вдалося завантажити список місій. Спробуйте пізніше.", + "Space Missions unavailable: the mission index could not load. Try again later.": "Режим «Космічні місії» недоступний: не вдалося завантажити індекс місій. Спробуйте пізніше.", "30-DAY MISSION INDEX UNAVAILABLE": "ІНДЕКС МІСІЙ ЗА 30 ДНІВ НЕДОСТУПНИЙ", "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB — ОГЛЯД · ENTER / SPACE — ВИБІР", "Internet radio and local SDR": "Інтернет-радіо та локальний SDR", @@ -3213,7 +3213,7 @@ "FLAT": "ПЛОСКІ", "SINCE": "З ДАТИ", "UPDATE SHOT": "ОНОВИТИ КАДР", - "IMPORT": "ІМПОРТУВАТИ", + "IMPORT": "ІМПОРТ", "RUN LOG": "ЖУРНАЛ ВІДТВОРЕННЯ", "POWER UP": "ЗАПУСК", "GROUND STATION · PROVIDER SETTINGS": "НАЗЕМНА СТАНЦІЯ · НАЛАШТУВАННЯ ПОСТАЧАЛЬНИКІВ", @@ -3282,13 +3282,13 @@ "+ CAPTURE SHOT": "+ ЗНЯТИ КАДР", "Add the current view, style and layers as the next shot": "Додати поточний вигляд, стиль і шари як наступний кадр", "Replace the selected shot with the current view": "Замінити вибраний кадр поточним виглядом", - "Click a shot to select it, double-click to rename.": "Натисніть кадр, щоб вибрати його, двічі — щоб перейменувати.", + "Click a shot to select it, double-click to rename.": "Клацніть кадр, щоб вибрати його, двічі — щоб перейменувати.", "Play from this scene through the rest of the project": "Відтворити від цієї сцени до кінця проєкту", "Stop playback (Esc)": "Зупинити відтворення (Esc)", "Play the shot after the selected one, moving on to the next scene when needed": "Відтворити кадр після вибраного, за потреби перейшовши до наступної сцени", "SAVE & SHARE": "ЗБЕРЕГТИ Й ПОДІЛИТИСЯ", "Download every scene as a JSON file you can import later": "Завантажити всі сцени файлом JSON для подальшого імпорту", - "Load scenes from an exported JSON file": "Завантажити сцени з експортованого файлу JSON", + "Load scenes from an exported JSON file": "Імпортувати сцени з експортованого файлу JSON", "Download the timing log of the last play": "Завантажити журнал таймінгів останнього відтворення", "Edit anchors, data packs and shot details": "Змінити якорі, пакети даних і параметри кадрів", "Download this scene as JSON or an asset bundle": "Завантажити цю сцену у форматі JSON або архівом ресурсів", @@ -3303,7 +3303,7 @@ "Drawn shapes": "Намальовані фігури", "Audio": "Аудіо", "UTILITIES": "ІНСТРУМЕНТИ", - "Exit cockpit to fly to a drawing": "Вийдіть із кабіни, щоб перелетіти до малюнка", + "Exit cockpit to fly to a drawing": "Вийдіть із кабіни, щоб перелетіти до фігури", "Directions, drawing and your own files": "Маршрути, малювання та ваші файли", "Voice place search (needs an Overpass server)": "Голосовий пошук місць (потрібен сервер Overpass)", "OSM Places": "Місця OSM", @@ -3455,19 +3455,19 @@ "Last 24 h of news. Each report links to its publisher; places and categories are read from the text and data automatically and can be wrong. Side labels, where shown, describe the source, not whether a report is true.": "Новини за останні 24 год. Кожне повідомлення веде до свого видання; місця й категорії визначаються з тексту й даних автоматично і можуть бути хибними. Позначки сторін, де вони є, описують джерело, а не правдивість повідомлення.", "Economy": "Економіка", "News from {area}": "Новини: {area}", - "Within {km} km of the view centre": "У радіусі {km} км від центру мапи", + "Within {km} km of the view centre": "У радіусі {km} км від центру карти", "{region}, {country}": "{region}, {country}", "via {source}": "через {source}", - "Local in the News layer: the day’s news from the outlets of the country in view": "Місцеве в шарі новин: новини дня від медіа країни в кадрі", + "Local in the News layer: the day’s news from the outlets of the country in view": "Розділ «Місцеві» в шарі «Новини»: новини дня від медіа країни в кадрі", "{provider} refused its key": "{provider} відхилив ключ", "Check {envVars} in Provider Settings": "Перевірте {envVars} у «Налаштуваннях постачальників»", "{provider} key required": "Потрібен ключ {provider}", "{provider} limit reached for today": "{provider}: денний ліміт вичерпано", "News resumes tomorrow, or with a paid plan": "Новини повернуться завтра або з платним тарифом", "News key required": "Потрібен ключ новин", - "Precise conflict points in the News layer, beside GDELT’s articles": "Точні точки конфліктів у шарі новин, на додачу до статей GDELT", + "Precise conflict points in the News layer, beside GDELT’s articles": "Точні місця конфліктів у шарі «Новини», на додачу до статей GDELT", "Worldwide": "Увесь світ", - "Shipwrecks": "Корабельні аварії", + "Shipwrecks": "Затонулі судна", "Before 1900": "До 1900", "After 1945": "Після 1945", "Date unknown": "Дата невідома", @@ -3480,13 +3480,13 @@ "Depth: {m} m": "Глибина: {m} м", "Notes: {value}": "Примітки: {value}", "Cargo or finds: {value}": "Вантаж або знахідки: {value}", - "Protected wreck": "Охоронюваний об'єкт", + "Protected wreck": "Охоронюваний обʼєкт", "Source: {value}": "Джерело: {value}", - "Click the card to open EMODnet ↗": "Натисніть картку, щоб відкрити EMODnet ↗", - "Click the card to open the source ↗": "Натисніть картку, щоб відкрити джерело ↗", + "Click the card to open EMODnet ↗": "Клацніть картку, щоб відкрити EMODnet ↗", + "Click the card to open the source ↗": "Клацніть картку, щоб відкрити джерело ↗", "Wikidata unavailable; showing EMODnet only": "Wikidata недоступна; показано лише EMODnet", "EMODnet unavailable; showing Wikidata only": "EMODnet недоступний; показано лише Wikidata", - "Shipwrecks unavailable": "Дані про корабельні аварії недоступні", + "Shipwrecks unavailable": "Дані про затонулі судна недоступні", "EMODnet (European waters) · Wikidata (worldwide)": "EMODnet (води Європи) · Wikidata (увесь світ)", "{count} wrecks": { "one": "{count} затонуле судно", @@ -3495,25 +3495,25 @@ "other": "{count} затонулого судна" }, "1900–1945": "1900–1945", - "Shipwreck": "Корабельна аварія", + "Shipwreck": "Затонуле судно", "Fatalities: {count}": "Загиблих: {count}", - "Survivors: {count}": "Тих, хто вижив: {count}", - "Click the card to open the article ↗": "Натисніть картку, щоб відкрити статтю ↗", - "European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "Історичні затонулі судна Європи — з EMODnet Human Activities, відомі корабельні аварії світу — з Wikidata. Колір показує, коли судно затонуло; маркер збільшується з кількістю загиблих. Натисніть маркер, щоб відкрити картку.", + "Survivors: {count}": "Уцілілих: {count}", + "Click the card to open the article ↗": "Клацніть картку, щоб відкрити статтю ↗", + "European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "Історичні затонулі судна Європи — з EMODnet Human Activities, відомі затонулі судна з усього світу — з Wikidata. Колір показує, коли судно затонуло; маркер збільшується з кількістю загиблих. Клацніть маркер, щоб відкрити картку.", "All years": "Усі роки", "Period": "Період", "From": "З", "To": "По", - "First year of the shipwreck period": "Перший рік періоду корабельних аварій", - "Last year of the shipwreck period": "Останній рік періоду корабельних аварій", + "First year of the shipwreck period": "Перший рік періоду загибелі суден", + "Last year of the shipwreck period": "Останній рік періоду загибелі суден", "SIGHTING": "СПОСТЕРЕЖЕННЯ", "Show {name} sighting": "Показати спостереження: {name}", "seen {age} ago": "помічено {age} тому", "observed {age} ago": "спостерігалося {age} тому", "Be Whale Wise": "Спостерігайте за китами дбайливо", - "Reported by {name} ({entity})": "Повідомив: {name} ({entity})", - "Reported by {name}": "Повідомив: {name}", - "Salish Sea": "Салішське море", + "Reported by {name} ({entity})": "Повідомляє: {name} ({entity})", + "Reported by {name}": "Повідомляє: {name}", + "Salish Sea": "море Селіш", "Birds": "Птахи", "Mammals": "Ссавці", "Reptiles": "Плазуни", @@ -3525,11 +3525,11 @@ "Fungi": "Гриби", "Other animals": "Інші тварини", "Other or unknown": "Інші або невідомі", - "{label} {detail} — click to retry": "{label} {detail} — натисніть, щоб повторити", + "{label} {detail} — click to retry": "{label}: {detail} — клацніть, щоб повторити", "Wildlife sightings source unavailable": "Джерело спостережень живої природи недоступне", "Show only community-confirmed (research-grade) sightings": "Показати лише підтверджені спільнотою спостереження дослідницького рівня", - "No recent sightings in view": "Немає недавніх спостережень в області огляду", - "Acartia feed unavailable": "Стрічка Acartia недоступна", + "No recent sightings in view": "У кадрі немає недавніх спостережень", + "Acartia feed unavailable": "Потік Acartia недоступний", "Show all taxa": "Показати всі групи організмів", "BIRDS": "ПТАХИ", "Show only birds": "Показати лише птахів", @@ -3544,31 +3544,31 @@ "All rights reserved": "Усі права захищено", "Cetacean": "Китоподібне", "Wildlife Sightings": "Спостереження живої природи", - "Exit cockpit to fly to a sighting": "Вийдіть із кабіни, щоб перейти до спостереження", - "Exit cockpit to fly to a sighting area": "Вийдіть із кабіни, щоб перейти до області спостереження", + "Exit cockpit to fly to a sighting": "Вийдіть із кабіни, щоб перелетіти до спостереження", + "Exit cockpit to fly to a sighting area": "Вийдіть із кабіни, щоб перелетіти до області спостереження", "Wildlife": "Жива природа", - "Zoom in for local sightings": "Наблизьте для місцевих спостережень", + "Zoom in for local sightings": "Наблизьте карту, щоб побачити місцеві спостереження", "Loading photo...": "Завантаження фото…", "Photo unavailable": "Фото недоступне", "Location obscured to a 0.2° cell": "Місцеположення приховано в комірці 0,2°", "Photo of {name}": "Фото {name}", "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", - "{count} sightings in view": "{count} спостережень у полі зору", + "{count} sightings in view": "Спостережень у кадрі: {count}", "Close sighting": "Закрити спостереження", - "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.": "Спостереження дикої природи за 48 год (китоподібні — 7 днів). Колір і символ значка позначають таксономічну групу. Вразливі види показано лише клітинками 0,2°; повідомлення Acartia про морських ссавців моря Селіш округлено до 0,01°. Не перепис.", + "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.": "Спостереження живої природи за 48 год (китоподібні — 7 днів). Колір і символ значка позначають таксономічну групу. Вразливі види показано лише комірками 0,2°; повідомлення Acartia про морських ссавців моря Селіш округлено до 0,01°. Не перепис.", "Open {name} on iNaturalist": "Відкрити {name} в iNaturalist", "Open {name} on Acartia": "Відкрити {name} в Acartia", "iNaturalist": "iNaturalist", "iNaturalist contributors · licences per record": "Учасники iNaturalist · ліцензія в кожного запису", "iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Учасники iNaturalist · Acartia {count} (море Селіш) · ліцензія в кожного запису", - "{days}d": "{days}д", - "All markers": "Усі позначки", - "Show markers of every age": "Показати позначки будь-якого віку", - "Maximum event age: {days}d; undated markers are kept": "Максимальний вік події: {days} дн.; позначки без дати збережено", + "{days}d": "{days} дн", + "All markers": "Усі мітки", + "Show markers of every age": "Показати мітки будь-якого віку", + "Maximum event age: {days}d; undated markers are kept": "Максимальний вік події: {days} дн; мітки без дати залишаються", "event age filter|All": "Усі", "Event age": "Вік події", - "Event age: {days}d · undated kept": "Вік події: {days} дн. · позначки без дати збережено", + "Event age: {days}d · undated kept": "Вік події: {days} дн · мітки без дати залишаються", "Close camera preview": "Закрити перегляд камери", "Refresh map still": "Оновити кадр на карті", "The map still uses a separate request and may show a different moment.": "Кадр на карті запитується окремо та може показувати інший момент.", @@ -3581,24 +3581,24 @@ "Street level at {place}": "Вуличні знімки: {place}", "Switching map": "Перемикання карти", "Opening imagery": "Відкриття знімків", - "Street View is ready: press {open} to load it": "Перегляд вулиць готовий: натисніть {open}, щоб завантажити", + "Street View is ready: press {open} to load it": "Перегляд вулиць готовий: натисніть «{open}», щоб завантажити", "{source} cannot open this link's street-level photo without a working key": "{source} не може відкрити вуличне фото за цим посиланням без робочого ключа", "Street-level photos are hidden in the current view. Leave it first.": "Вуличні знімки приховані в поточному режимі. Спершу вийдіть із нього.", "STATIONS": "СТАНЦІЇ", "Crewed stations and the craft that visit them": "Пілотовані станції та кораблі, що до них прилітають", "IMAGING": "ЗЙОМКА", "Earth observation: optical, radar, weather and science": "Спостереження Землі: оптика, радар, погода й наука", - "TELECOM": "ЗВ’ЯЗОК", - "Communications and navigation satellites, Starlink excepted": "Супутники зв’язку та навігації, крім Starlink", + "TELECOM": "ЗВʼЯЗОК", + "Communications and navigation satellites, Starlink excepted": "Супутники звʼязку та навігації, крім Starlink", "STARLINK": "STARLINK", "The Starlink constellation (thousands of extra points)": "Угруповання Starlink (тисячі додаткових точок)", "PROBES": "ЗОНДИ", "Space science, technology demonstration and other payloads": "Космічна наука, демонстрація технологій та інші корисні навантаження", "DEBRIS": "УЛАМКИ", - "Rocket bodies, fragments and dead satellites": "Ступені ракет, уламки й непрацюючі супутники", + "Rocket bodies, fragments and dead satellites": "Ступені ракет, уламки й недіючі супутники", "Classes may lag: SATCAT is a cached copy": "Класи можуть відставати: SATCAT — кешована копія", "Classes may lag: SATCAT unavailable": "Класи можуть відставати: SATCAT недоступний", - "CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "CelesTrak SATCAT повідомляє тип і стан кожного об’єкта. Без актуальної копії класи спираються на базу UCS, назви та групи CelesTrak, тож супутник, що відтоді вийшов з ладу, може й далі показуватися робочим.", + "CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "CelesTrak SATCAT повідомляє тип і стан кожного обʼєкта. Без актуальної копії класи спираються на базу UCS, назви та групи CelesTrak, тож супутник, що відтоді вийшов з ладу, може й далі показуватися робочим.", "STARLINK ···": "STARLINK ···", "STARLINK ✕": "STARLINK ✕", "SENSOR SWATH {value} KM": "СМУГА СЕНСОРА {value} КМ", @@ -3618,7 +3618,7 @@ "Zoom out (−)": "Віддалити (−)", "ORBITAL VIEW · OFF-NADIR": "ОРБІТАЛЬНИЙ ВИГЛЯД · ВІДХИЛЕННЯ ВІД НАДИРА", "World time": "Світовий час", - "Back to live": "Повернутися до прямого ефіру", + "Back to live": "Повернутися до поточного часу", "Live": "Зараз", "1 h/s": "1 год/с", "1 d/s": "1 дн/с", @@ -3636,22 +3636,22 @@ "Show {category}": "Показати: {category}", "Live GPS": "GPS у реальному часі", "Estimated from station reports": "Оцінка за відмітками станцій", - "To {headsign}": "До {headsign}", - "Next {stop} {time} {relative}": "Наступна {stop} {time} {relative}", - "Prev {stop} {time} {relative}": "Попередня {stop} {time} {relative}", + "To {headsign}": "Куди: {headsign}", + "Next {stop} {time} {relative}": "Наступна: {stop} {time} {relative}", + "Prev {stop} {time} {relative}": "Попередня: {stop} {time} {relative}", "(+{minutes} min late)": "(+{minutes} хв запізнення)", "Exit cockpit to fly to a train": "Вийдіть із кабіни, щоб перелетіти до поїзда", "Photo © {photographer}": "Фото © {photographer}", "Photo: {photographer} · {licence}": "Фото: {photographer} · {licence}", "Photo · {licence}": "Фото · {licence}", - "The shared flight isn't reporting now — showing where it was last seen": "Спільний рейс зараз не передає даних — показано останнє відоме місце", - "The shared flight isn't reporting now — showing where it was at {time}": "Спільний рейс зараз не передає даних — показано місце о {time}", - "The shared military flight isn't reporting now — showing where it was last seen": "Спільний військовий рейс зараз не передає даних — показано останнє відоме місце", - "The shared military flight isn't reporting now — showing where it was at {time}": "Спільний військовий рейс зараз не передає даних — показано місце о {time}", - "The shared vessel isn't reporting now — showing where it was last seen": "Спільне судно зараз не передає даних — показано останнє відоме місце", - "The shared vessel isn't reporting now — showing where it was at {time}": "Спільне судно зараз не передає даних — показано місце о {time}", - "The shared satellite isn't reporting now — showing where it was last seen": "Спільний супутник зараз не передає даних — показано останнє відоме місце", - "The shared satellite isn't reporting now — showing where it was at {time}": "Спільний супутник зараз не передає даних — показано місце о {time}", + "The shared flight isn't reporting now — showing where it was last seen": "Рейс за посиланням зараз не передає даних — показано останнє відоме місце", + "The shared flight isn't reporting now — showing where it was at {time}": "Рейс за посиланням зараз не передає даних — показано місце станом на {time}", + "The shared military flight isn't reporting now — showing where it was last seen": "Військовий рейс за посиланням зараз не передає даних — показано останнє відоме місце", + "The shared military flight isn't reporting now — showing where it was at {time}": "Військовий рейс за посиланням зараз не передає даних — показано місце станом на {time}", + "The shared vessel isn't reporting now — showing where it was last seen": "Судно за посиланням зараз не передає даних — показано останнє відоме місце", + "The shared vessel isn't reporting now — showing where it was at {time}": "Судно за посиланням зараз не передає даних — показано місце станом на {time}", + "The shared satellite isn't reporting now — showing where it was last seen": "Супутник за посиланням зараз не передає даних — показано останнє відоме місце", + "The shared satellite isn't reporting now — showing where it was at {time}": "Супутник за посиланням зараз не передає даних — показано місце станом на {time}", "ICAO: {id}": "ICAO: {id}", "NORAD: {id}": "NORAD: {id}", "MMSI: {id}": "MMSI: {id}", @@ -3659,9 +3659,9 @@ "Latitude must be within ±90° and longitude within ±180°": "Широта має бути в межах ±90°, а довгота — у межах ±180°", "Street Level is unavailable": "Вуличні знімки недоступні", "No street-level imagery source is configured": "Джерело вуличних знімків не налаштовано", - "Street Level could not be enabled": "Не вдалося увімкнути вуличні знімки", - "Street Level changed while it looked for imagery": "Вуличні знімки змінилися під час пошуку", - "Aim the view at a street": "Наведіть вигляд на вулицю", + "Street Level could not be enabled": "Не вдалося увімкнути «Вуличні знімки»", + "Street Level changed while it looked for imagery": "Стан шару «Вуличні знімки» змінився під час пошуку", + "Aim the view at a street": "Спрямуйте камеру на вулицю", "{source} is not a street-level imagery source here": "{source} тут не є джерелом вуличних знімків", "{source} needs its key": "{source}: потрібен ключ", "{source} needs the {map} map, which is not available": "{source}: потрібна карта {map}, але вона недоступна", @@ -3669,14 +3669,14 @@ "{source} is not available on this map": "{source}: недоступно на цій карті", "{source} imagery could not be opened": "Не вдалося відкрити знімки {source}", "{place} has no position to look for imagery at": "Для місця «{place}» немає координат для пошуку знімків", - "No {source} imagery near {place}": "Немає знімків {source} поблизу {place}", + "No {source} imagery near {place}": "Немає знімків {source} поблизу: {place}", "{reason} (filters on: {filters})": "{reason} (увімкнено фільтри: {filters})", "tiles unavailable": "тайли недоступні", - "zoom in to load this layer": "наблизьте, щоб завантажити цей шар", + "zoom in to load this layer": "наблизьте карту, щоб завантажити цей шар", "Zoom to layer": "Наблизити до шару", "Zoom to layer · fly to where this layer has data": "Наблизити до шару · перелетіти туди, де цей шар має дані", - "Remove from Data Layers": "Вилучити з «Шарів даних»", - "Remove from Data Layers · add it again from the library": "Вилучити з «Шарів даних» · можна знову додати з бібліотеки", + "Remove from Data Layers": "Видалити з «Шарів даних»", + "Remove from Data Layers · add it again from the library": "Видалити з «Шарів даних» · можна знову додати з бібліотеки", "Coverage outline": "Контур покриття", "DRAPED": "НАКЛАДЕНО", "Below 250 m": "Нижче 250 м", @@ -3693,7 +3693,7 @@ "DATASET": "НАБІР ДАНИХ", "TILES": "ТАЙЛИ", "PACK": "ПАКЕТ", - "INCUBATING": "НА ІНКУБАЦІЇ", + "INCUBATING": "НА ВИПРОБУВАННІ", "COMMUNITY": "СПІЛЬНОТА", "VERIFIED": "ПЕРЕВІРЕНО", "ARCHIVED": "В АРХІВІ", @@ -3726,58 +3726,58 @@ "other": "{count} на карті" }, "none yet": "поки жодного", - "Map features": "Об’єкти карти", + "Map features": "Обʼєкти карти", "from {count} datasets": { "one": "з {count} набору даних", "few": "з {count} наборів даних", "many": "з {count} наборів даних", "other": "з {count} набору даних" }, - "switch on a dataset to load features": "увімкніть набір даних, щоб завантажити об’єкти", + "switch on a dataset to load features": "увімкніть набір даних, щоб завантажити обʼєкти", "Switched on by this instance · part of the CCTV layer": "Увімкнено цим екземпляром · частина шару CCTV", "Operator opt-in: set {env}=1 on the server": "Увімкнення оператором: задайте {env}=1 на сервері", "Operator opt-in: add {id} to GEV_LIBRARY_ENABLE on the server": "Увімкнення оператором: додайте {id} до GEV_LIBRARY_ENABLE на сервері", "Turned off by this instance's operator": "Вимкнено оператором цього екземпляра", "Needs a provider key on the server": "Потрібен ключ постачальника на сервері", "Needs this app's server · not available in a static build": "Потрібен сервер цього застосунку · недоступно в статичній збірці", - "Checking what this instance serves…": "Перевірка, що надає цей екземпляр…", - "Regional": "Регіональні", + "Checking what this instance serves…": "Перевірка того, що надає цей екземпляр…", + "Regional": "Регіонально", "Orthophoto": "Ортофото", "Satellite mosaic": "Супутникова мозаїка", "Oblique": "Перспективна зйомка", "Historic imagery": "Історичні знімки", - "True orthophoto": "Справжнє ортофото", + "True orthophoto": "Істинне ортофото", "Type": "Тип", - "Resolution": "Роздільність", + "Resolution": "Роздільна здатність", "Flown": "Зйомка", "Updates": "Оновлення", "Ortho quality": "Якість орто", "DATA LIBRARY": "БІБЛІОТЕКА ДАНИХ", "Close data library": "Закрити бібліотеку даних", - "Data Library at a glance": "Бібліотека даних коротко", - "Live sources beyond the core layers. Each one is fetched from its provider while it is on; nothing is stored in this app. Add one and it appears in Data Layers under Library.": "Живі джерела поза основними шарами. Кожне завантажується від свого постачальника, поки увімкнене; цей застосунок нічого не зберігає. Додайте джерело, і воно з’явиться в «Шарах даних» у розділі «Бібліотека».", + "Data Library at a glance": "Огляд бібліотеки даних", + "Live sources beyond the core layers. Each one is fetched from its provider while it is on; nothing is stored in this app. Add one and it appears in Data Layers under Library.": "Джерела даних у реальному часі поза основними шарами. Кожне завантажується від свого постачальника, поки увімкнене; цей застосунок нічого не зберігає. Додайте джерело, і воно зʼявиться в «Шарах даних» у розділі «Бібліотека».", "Name, provider or country": "Назва, постачальник або країна", "Theme": "Тема", "All themes": "Усі теми", "Only what this instance serves": "Лише те, що надає цей екземпляр", - "OPT-IN": "ЗА ЗГОДОЮ", + "OPT-IN": "ВМИКАЄ ОПЕРАТОР", "KEY": "КЛЮЧ", "KEYLESS": "БЕЗ КЛЮЧА", "ZOOM": "НАБЛИЗИТИ", "Zoom to {name}": "Наблизити до {name}", "WORKING…": "ОБРОБКА…", "ADD": "ДОДАТИ", - "Remove {name} from Data Layers": "Вилучити {name} з «Шарів даних»", + "Remove {name} from Data Layers": "Видалити {name} з «Шарів даних»", "Add {name} to Data Layers": "Додати {name} до «Шарів даних»", "CONFIRM · REMOVE ALL {count}": { - "one": "ПІДТВЕРДИТИ · ВИЛУЧИТИ {count}", - "few": "ПІДТВЕРДИТИ · ВИЛУЧИТИ ВСІ {count}", - "many": "ПІДТВЕРДИТИ · ВИЛУЧИТИ ВСІ {count}", - "other": "ПІДТВЕРДИТИ · ВИЛУЧИТИ ВСІ {count}" + "one": "ПІДТВЕРДИТИ · ВИДАЛИТИ {count}", + "few": "ПІДТВЕРДИТИ · ВИДАЛИТИ ВСІ {count}", + "many": "ПІДТВЕРДИТИ · ВИДАЛИТИ ВСІ {count}", + "other": "ПІДТВЕРДИТИ · ВИДАЛИТИ ВСІ {count}" }, - "REMOVE ALL": "ВИЛУЧИТИ ВСЕ", - "Confirm: remove every added entry from Data Layers": "Підтвердити: вилучити всі додані записи з «Шарів даних»", - "Remove every added entry from Data Layers": "Вилучити всі додані записи з «Шарів даних»", + "REMOVE ALL": "ВИДАЛИТИ ВСЕ", + "Confirm: remove every added entry from Data Layers": "Підтвердити: видалити всі додані записи з «Шарів даних»", + "Remove every added entry from Data Layers": "Видалити всі додані записи з «Шарів даних»", "Showing {shown} of {total}": "Показано {shown} з {total}", "BROWSE DATA LIBRARY": "ВІДКРИТИ БІБЛІОТЕКУ ДАНИХ" } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 86ecd4f40..8311259cd 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -10,12 +10,12 @@ "Disputed: {name}": "争议地区:{name}", "Video": "视频", "Photos": "照片", - "Claimed by {who}": "{who}声称", + "Claimed by {who}": "{who} 声称", "Claimed": "声称", "Not yet confirmed by others": "尚未获他方证实", - "Reported by {side} sources": "{side}方来源报道", + "Reported by {side} sources": "报道来源:{side}", "Reported by {sides}": "报道来源:{sides}", - "{side} sources": "{side}方来源", + "{side} sources": "{side}", "Official": "官方", "Reported by both sides": "双方均有报道", "Independently reported": "有独立来源报道", @@ -23,7 +23,7 @@ "International": "国际", "Independent": "独立", "Unattributed": "未注明来源", - "Outside party": "外部方", + "Outside party": "第三方", "Ukraine–Russia": "乌克兰–俄罗斯", "Israel–Gaza–Lebanon": "以色列–加沙–黎巴嫩", "Iran–Hormuz": "伊朗–霍尔木兹", @@ -36,17 +36,17 @@ "Air defence": "防空", "Ground fighting": "地面战斗", "Casualties": "伤亡", - "Humanitarian": "人道", + "Humanitarian": "人道主义", "Disaster": "灾害", "Protest": "抗议", "Political": "政治", "No physical event": "无实际事件", "Minor incident": "轻微事件", - "Significant": "重大", - "Major": "严重", + "Significant": "较大", + "Major": "重大", "age|now": "刚刚", - "{minutes} min": "{minutes} 分钟", - "{hours} h": "{hours} 小时", + "{minutes} min": "{minutes} 分钟前", + "{hours} h": "{hours} 小时前", "{count} sources": { "other": "{count} 个来源" }, @@ -78,10 +78,10 @@ "Load older news": "加载更早的新闻", "How to read the map": "如何解读地图", "Colour: severity · icon: type of event": "颜色:严重程度 · 图标:事件类型", - "Solid rim: reported by both sides or independently. Dashed rim: a claim. Thin rim: one source, or one side’s own press.": "实线边框:双方或独立来源均有报道。虚线边框:一方声称。细边框:单一来源,或一方自身的媒体。", - "Larger: more sources · fainter: older. Picture card: a major or fast-growing event with two or more sources.": "越大:来源越多 · 越淡:越早。图片卡片:有两个或以上来源的严重或快速发展的事件。", + "Solid rim: reported by both sides or independently. Dashed rim: a claim. Thin rim: one source, or one side’s own press.": "实线边框:双方均有报道,或有独立来源报道。虚线边框:一方声称。细边框:单一来源,或一方自身的媒体。", + "Larger: more sources · fainter: older. Picture card: a major or fast-growing event with two or more sources.": "越大:来源越多 · 越淡:越早。图片卡片:有两个或以上来源的重大或快速发展的事件。", "Side labels describe the source, not whether a report is true.": "阵营标签描述的是来源,而非报道是否属实。", - "Exit cockpit to fly to a news report": "退出驾驶舱以飞往新闻报道", + "Exit cockpit to fly to a news report": "请先退出座舱,再飞往新闻报道地点", "News items in the feed": "列表中的新闻数", "Pick a shape, then click the map.": "先选择形状,再在地图上点击。", "Enter to place the pin, Esc to cancel.": "按 Enter 放置图钉,按 Esc 取消。", @@ -598,7 +598,7 @@ "FOV {value}° (DATASHEET)": "FOV {value}°(规格书)", "FOV {value}°": "FOV {value}°", "SPEC FOV {span}° (CAPABILITY)": "规格 FOV {span}°(可调范围)", - "COVERAGE {value}km²": "覆盖 {value}km²", + "COVERAGE {value}km²": "覆盖 {value} km²", "OVERLAP {count} cams": "重叠 {count} 个摄像头", "ISOLATED VIEW": "独立视角", "PROJ MONITOR": "投影:监视器", @@ -616,8 +616,8 @@ "Hurricane": "飓风", "Tropical storm": "热带风暴", "Tropical depression": "热带低压", - "Subtropical storm": "亚热带风暴", - "Subtropical depression": "亚热带低压", + "Subtropical storm": "副热带风暴", + "Subtropical depression": "副热带低压", "Track and cone match this advisory": "路径和预报不确定范围与本期公告一致", "Track/cone awaiting advisory {number}": "路径/预报不确定范围等待第 {number} 号公告", "Track/cone unavailable": "路径/预报不确定范围不可用", @@ -808,7 +808,7 @@ "Low-accuracy score 2–10%": "低精度评分 2–10%", "Low-accuracy score > 10%": "低精度评分 > 10%", "GNSS integrity source unavailable": "GNSS 完好性数据源不可用", - "Regional sample · 250 nm radius · cells from visited views over {minutes} min": "区域采样 · 半径 250 海里 · {minutes} 分钟内已访问视图中的网格", + "Regional sample · 250 nm radius · cells from visited views over {minutes} min": "区域采样 · 半径 250 海里 · {minutes} 分钟内浏览过的视野中的网格", "Retrying automatically at {time} UTC": "将于 {time} UTC 自动重试", "Retrying automatically after provider cooldown": "服务商冷却结束后自动重试", "Updates automatically after pan or zoom and once a minute. The public feed covers a 250-nautical-mile circle around the view centre; zooming out does not make it a worldwide snapshot. Blank cells are outside sampled coverage or have insufficient observations.": "平移或缩放后以及每分钟自动更新。公共数据源覆盖以视野中心为圆心、半径 250 海里的范围;缩小地图并不会使其成为全球快照。空白网格位于采样覆盖范围之外,或观测数据不足。", @@ -866,18 +866,18 @@ "{value} KM/S": "{value} km/s", "{direction} {angle}°": "{direction} {angle}°", "OFF-NADIR {angle}° · {offset}": "偏离天底 {angle}° · {offset}", - "SITES STANDBY": "地点待机", - "ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "启用已测绘设施以接收地点提醒", - "Release the camera from {name}": "让镜头离开{name}", + "SITES STANDBY": "设施待机", + "ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "启用地图标注设施以接收设施提醒", + "Release the camera from {name}": "让镜头离开 {name}", "RETURN · {name}": "返回 · {name}", "PREVIOUS SATELLITE": "上一颗卫星", - "Return to {name}": "返回{name}", + "Return to {name}": "返回 {name}", "SATELLITE": "卫星", - "Ride {name}": "乘坐{name}", - "Unnamed site": "未命名地点", + "Ride {name}": "乘坐 {name}", + "Unnamed site": "未命名设施", "OFF-NADIR {angle}°": "偏离天底 {angle}°", "FOCUS · {name}": "聚焦 · {name}", - "Keep the camera on {name}": "让镜头持续对准{name}", + "Keep the camera on {name}": "让镜头持续对准 {name}", "ZOOM {zoom}×": "变焦 {zoom}×", "SWATH {value} KM": "幅宽 {value} km", "SWATH ≈ {value} KM": "幅宽 ≈ {value} km", @@ -885,10 +885,10 @@ "ORBITAL VIEW · NADIR": "轨道视图 · 天底", "ORBITAL VELOCITY": "轨道速度", "SUB-SATELLITE POINT": "星下点", - "NORTH UP": "北向上", - "CURRENTLY OVER": "当前上空", - "{division}, {country}": "{division},{country}", - "{region} · near {place}": "{region} · {place}附近", + "NORTH UP": "正北朝上", + "CURRENTLY OVER": "当前飞越", + "{division}, {country}": "{country} {division}", + "{region} · near {place}": "{region} · {place} 附近", "NORAD {id}": "NORAD {id}", "EPOCH {time}": "历元 {time}", "TLE": "TLE", @@ -1173,7 +1173,7 @@ "Moving dots": "移动圆点", "TomTom key unavailable": "TomTom 密钥不可用", "TomTom daily budget reached": "已达 TomTom 每日配额", - "Monthly traffic allowance used": "本月交通流量额度已用完", + "Monthly traffic allowance used": "本月交通数据配额已用完", "Traffic rate limit reached": "已达到交通数据请求频率上限", "Traffic service unavailable": "交通服务不可用", "TomTom upstream unreachable": "TomTom 上游服务无法连接", @@ -1606,7 +1606,7 @@ "Link copied!": "链接已复制!", "Link shared": "链接已分享", "This share link can't be opened": "无法打开此分享链接", - "Shared vessel is unavailable": "分享的船只不可用", + "Shared vessel is unavailable": "分享的船舶不可用", "Copy failed": "复制失败", "Return map to straight-down view": "将地图恢复为垂直俯视", "Tilt map to oblique view": "将地图倾斜至斜视角", @@ -1676,7 +1676,7 @@ "other": "已加载 {count} 个摄像头 · 启用 CCTV 以激活" }, "{count} more cameras outside this view · pan or zoom out": { - "other": "此视图外还有 {count} 个摄像头 · 平移或缩小" + "other": "视野外还有 {count} 个摄像头 · 请平移或缩小地图" }, "Enable CCTV to load camera intersections": "启用 CCTV 以加载路口摄像头", "Enable CCTV to start camera-linked intelligence summaries.": "启用 CCTV 以开始生成与摄像头关联的情报摘要。", @@ -1716,7 +1716,7 @@ "{scope} · INSTALLATIONS {coverage}": "{scope} · 设施 {coverage}", "MILITARY FEED OFF · PARTIAL PICTURE": "军用数据源已关闭 · 态势不完整", "FLIGHTS FEED OFF · PARTIAL PICTURE": "航班数据源已关闭 · 态势不完整", - "NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "附近航空器信息不完整 · 不代表安全无虞", + "NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "附近飞机信息不完整 · 不代表安全无虞", "CONTEXT STANDBY": "态势待机", "ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "启用全局态势以接收邻近目标提醒", "CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "目标丢失 · 最后已知读数 · 不代表安全无虞", @@ -1750,7 +1750,7 @@ "ROUTE DATA UNAVAILABLE": "航线数据不可用", "CONTACT NAVIGATION OFF": "目标导航已关闭", "THE BRIDGE STAYS WITH THE SELECTED VESSEL": "驾驶台保持在所选船舶上", - "{range} NM": "{range}海里", + "{range} NM": "{range} 海里", "AIS DESTINATION · CREW REPORTED": "AIS 目的地 · 船员申报", "DEST {bearing}": "目的地 {bearing}", "Disable cockpit weather effects": "停用座舱天气效果", @@ -1870,7 +1870,7 @@ "GNSS Integrity": "GNSS 完好性", "Rain radar": "降雨雷达", "Lightning density": "闪电密度", - "Cyclone advisories": "气旋预警", + "Cyclone advisories": "气旋公告", "ALPR Cameras": "ALPR 摄像头", "Live Vessels": "实时船舶", "Bike Share": "共享单车", @@ -2073,8 +2073,8 @@ "coverage|partial": "部分", "unknown": "未知", "no imagery": "无影像", - "{percent}% cloud": "{percent}% 云量", - "{min}–{max}% cloud": "{min}–{max}% 云量", + "{percent}% cloud": "云量 {percent}%", + "{min}–{max}% cloud": "云量 {min}–{max}%", "NO BOX": "无框选", "SEARCHING": "搜索中", "{count} DAYS": { @@ -2629,7 +2629,7 @@ "Contact cockpit summary": "目标座舱摘要", "CONTACT": "目标", "CONTACTS · 250 KM": "目标 · 250 km", - "RINGS {spacing} NM": "距标圈 {spacing}海里", + "RINGS {spacing} NM": "距标圈 {spacing} 海里", "{count} CONTACTS": { "other": "{count} 个目标" }, @@ -2718,7 +2718,7 @@ "WEATHER": "天气", "Active weather products": "已启用的天气产品", "RECENT IMAGERY": "近期影像", - "Imagery days in the box": "视野内的影像天数", + "Imagery days in the box": "框选区域内的影像天数", "CONTEXT": "态势", "Context mode": "态势模式", "CONTACTS": "目标", @@ -3078,7 +3078,7 @@ "Left panels": "左侧面板", "Right panels": "右侧面板", "UTILITIES": "工具", - "Exit cockpit to fly to a drawing": "请先退出座舱,再飞往绘制位置", + "Exit cockpit to fly to a drawing": "请先退出座舱,再飞往已绘制的形状", "Directions, drawing and your own files": "路线导航、绘制和你自己的文件", "Voice place search (needs an Overpass server)": "语音地点搜索(需要 Overpass 服务器)", "OSM Places": "OSM 地点", @@ -3214,11 +3214,11 @@ "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "服务商账户余额不足。请检查账户的计费信息,或在设置中选择其他 AI 服务商。", "Last 24 h of news. Each report links to its publisher; places and categories are read from the text and data automatically and can be wrong. Side labels, where shown, describe the source, not whether a report is true.": "最近 24 小时的新闻。每条报道都链接到其发布者;地点和类别由文本和数据自动读取,可能有误。阵营标签(如有显示)描述的是来源,而非报道是否属实。", "Economy": "经济", - "News from {area}": "{area}新闻", - "Within {km} km of the view centre": "视图中心 {km} 公里范围内", - "{region}, {country}": "{country}{region}", + "News from {area}": "{area} 新闻", + "Within {km} km of the view centre": "视野中心 {km} km 范围内", + "{region}, {country}": "{country} {region}", "via {source}": "来自 {source}", - "Local in the News layer: the day’s news from the outlets of the country in view": "新闻图层的本地:视野中国家的媒体当天的新闻", + "Local in the News layer: the day’s news from the outlets of the country in view": "新闻图层的“本地”分区:当前视野所在国家的媒体当天的新闻", "{provider} refused its key": "{provider} 拒绝了密钥", "Check {envVars} in Provider Settings": "请在服务商设置中检查 {envVars}", "{provider} key required": "需要 {provider} 密钥", @@ -3228,16 +3228,16 @@ "Precise conflict points in the News layer, beside GDELT’s articles": "新闻图层中精确的冲突地点,与 GDELT 的文章并列", "Worldwide": "全球", "Shipwrecks": "沉船", - "Before 1900": "1900年以前", - "After 1945": "1945年以后", + "Before 1900": "1900 年以前", + "After 1945": "1945 年以后", "Date unknown": "日期不详", "Position: centre of the protected area": "位置:保护区中心", - "Position approximate (within about {km} km)": "大致位置(误差约 {km} 公里)", - "Position surveyed (within about {m} m)": "实测位置(误差约 {m} 米)", - "Vessel: {value}": "船只:{value}", + "Position approximate (within about {km} km)": "大致位置(误差约 {km} km)", + "Position surveyed (within about {m} m)": "实测位置(误差约 {m} m)", + "Vessel: {value}": "船型:{value}", "What happened: {value}": "经过:{value}", - "Depth: {m} m ({quality})": "深度:{m} 米({quality})", - "Depth: {m} m": "深度:{m} 米", + "Depth: {m} m ({quality})": "深度:{m} m({quality})", + "Depth: {m} m": "深度:{m} m", "Notes: {value}": "备注:{value}", "Cargo or finds: {value}": "货物或出水文物:{value}", "Protected wreck": "受保护沉船", @@ -3253,8 +3253,8 @@ }, "1900–1945": "1900–1945", "Shipwreck": "沉船", - "Fatalities: {count}": "遇难:{count}", - "Survivors: {count}": "幸存者:{count}", + "Fatalities: {count}": "遇难人数:{count}", + "Survivors: {count}": "幸存人数:{count}", "Click the card to open the article ↗": "点击卡片打开文章 ↗", "European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "欧洲历史沉船来自 EMODnet Human Activities;全球知名沉船来自 Wikidata。颜色表示船只沉没的时期,标记大小随遇难人数增加。点击标记查看卡片。", "All years": "所有年份", @@ -3264,12 +3264,12 @@ "First year of the shipwreck period": "沉船时段的起始年份", "Last year of the shipwreck period": "沉船时段的结束年份", "SIGHTING": "目击", - "Show {name} sighting": "显示{name}目击", + "Show {name} sighting": "显示 {name} 的目击记录", "seen {age} ago": "{age}前目击", - "observed {age} ago": "{age}前观察", + "observed {age} ago": "{age}前观察到", "Be Whale Wise": "负责任地观鲸", - "Reported by {name} ({entity})": "报告方 {name}({entity})", - "Reported by {name}": "报告方 {name}", + "Reported by {name} ({entity})": "报告方:{name}({entity})", + "Reported by {name}": "报告方:{name}", "Salish Sea": "萨利希海", "Birds": "鸟类", "Mammals": "哺乳类", @@ -3282,18 +3282,18 @@ "Fungi": "真菌", "Other animals": "其他动物", "Other or unknown": "其他或未知", - "{label} {detail} — click to retry": "{label} {detail} — 点击重试", + "{label} {detail} — click to retry": "{label} {detail}——点击重试", "Wildlife sightings source unavailable": "野生生物目击来源不可用", "Show only community-confirmed (research-grade) sightings": "仅显示社区确认(研究级)的目击", "No recent sightings in view": "视野内无近期目击", - "Acartia feed unavailable": "Acartia数据源不可用", + "Acartia feed unavailable": "Acartia 数据源不可用", "Show all taxa": "显示所有分类群", "BIRDS": "鸟类", "Show only birds": "仅显示鸟类", "MAMMALS": "哺乳类", "Show only mammals": "仅显示哺乳类", "WHALES": "鲸类", - "Show only whales and dolphins (7 days)": "仅显示鲸和海豚(7天)", + "Show only whales and dolphins (7 days)": "仅显示鲸和海豚(7 天)", "INSECTS": "昆虫", "Show only insects": "仅显示昆虫", "PLANTS": "植物", @@ -3301,31 +3301,31 @@ "All rights reserved": "保留所有权利", "Cetacean": "鲸类", "Wildlife Sightings": "野生生物目击", - "Exit cockpit to fly to a sighting": "退出驾驶舱以飞往目击地点", - "Exit cockpit to fly to a sighting area": "退出驾驶舱以飞往目击区域", + "Exit cockpit to fly to a sighting": "请先退出座舱,再飞往目击地点", + "Exit cockpit to fly to a sighting area": "请先退出座舱,再飞往目击区域", "Wildlife": "野生生物", "Zoom in for local sightings": "放大查看当地目击", "Loading photo...": "正在加载照片…", "Photo unavailable": "照片不可用", - "Location obscured to a 0.2° cell": "位置已模糊至0.2°网格", - "Photo of {name}": "{name}的照片", + "Location obscured to a 0.2° cell": "位置已模糊至 0.2° 网格", + "Photo of {name}": "{name} 的照片", "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", "{count} sightings in view": "视野内有 {count} 次目击", "Close sighting": "关闭目击记录", - "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.": "近48小时内观察到的野生生物(鲸类为7天)。颜色与徽章图标表示分类群。敏感物种仅以0.2°网格显示;Acartia提供的萨利希海海洋哺乳动物目击位置四舍五入到0.01°。并非种群普查。", + "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.": "近 48 小时内观察到的野生生物(鲸类为 7 天)。颜色与徽章图标表示分类群。敏感物种仅以 0.2° 网格显示;Acartia 提供的萨利希海海洋哺乳动物目击位置四舍五入到 0.01°。并非种群普查。", "Open {name} on iNaturalist": "在 iNaturalist 上打开 {name}", "Open {name} on Acartia": "在 Acartia 上打开 {name}", "iNaturalist": "iNaturalist", "iNaturalist contributors · licences per record": "iNaturalist 贡献者 · 各记录单独授权", - "iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "iNaturalist 贡献者 · Acartia {count}(萨利希海) · 各记录单独授权", - "{days}d": "{days}天", + "iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "iNaturalist 贡献者 · Acartia {count}(萨利希海)· 各记录单独授权", + "{days}d": "{days} 天", "All markers": "所有标记", "Show markers of every age": "显示所有时间的标记", - "Maximum event age: {days}d; undated markers are kept": "事件最长保留时间:{days}天;保留无日期标记", + "Maximum event age: {days}d; undated markers are kept": "仅显示 {days} 天内的事件;保留无日期标记", "event age filter|All": "全部", "Event age": "事件时间", - "Event age: {days}d · undated kept": "事件时间:{days}天内 · 保留无日期标记", + "Event age: {days}d · undated kept": "事件时间:{days} 天内 · 保留无日期标记", "Close camera preview": "关闭摄像头预览", "Refresh map still": "刷新地图静止画面", "The map still uses a separate request and may show a different moment.": "地图静止画面通过单独的请求获取,可能显示不同的时刻。", @@ -3333,12 +3333,12 @@ "Press OPEN to load the photo": "点击“打开”加载照片", "Captured {month}": "拍摄于 {month}", "this spot": "此处", - "the view centre": "视图中心", + "the view centre": "视野中心", "{source} at {place}": "{place} 的 {source}", "Street level at {place}": "{place} 的街景", "Switching map": "正在切换地图", "Opening imagery": "正在打开影像", - "Street View is ready: press {open} to load it": "街景已就绪:按 {open} 加载", + "Street View is ready: press {open} to load it": "街景已就绪:点击“{open}”加载", "{source} cannot open this link's street-level photo without a working key": "没有有效密钥,{source} 无法打开此链接的街景照片", "Street-level photos are hidden in the current view. Leave it first.": "当前视图隐藏了街景照片。请先退出该视图。", "STATIONS": "空间站", @@ -3352,7 +3352,7 @@ "PROBES": "探测器", "Space science, technology demonstration and other payloads": "空间科学、技术验证及其他载荷", "DEBRIS": "碎片", - "Rocket bodies, fragments and dead satellites": "火箭残骸、碎片和失效卫星", + "Rocket bodies, fragments and dead satellites": "火箭箭体、碎片和失效卫星", "Classes may lag: SATCAT is a cached copy": "分类可能滞后:SATCAT 为缓存副本", "Classes may lag: SATCAT unavailable": "分类可能滞后:SATCAT 不可用", "CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "CelesTrak SATCAT 给出每个物体的类型和运行状态。没有最新副本时,分类仅依据 UCS 数据库、名称和 CelesTrak 分组,因此此后已失效的卫星可能仍显示为在轨工作。", @@ -3362,17 +3362,17 @@ "SENSOR SWATH ≈ {value} KM": "传感器幅宽 ≈ {value} km", "SENSOR": "传感器", "Zoom to the imaging sensor swath": "缩放到成像传感器幅宽", - "<1 H OLD": "不到1小时前", - "{value} H OLD": "{value}小时前", - "{value} D OLD": "{value}天前", + "<1 H OLD": "不到 1 小时前", + "{value} H OLD": "{value} 小时前", + "{value} D OLD": "{value} 天前", "FWD": "前方", "AFT": "后方", "RIGHT": "右", "LEFT": "左", - "TRACK UP": "航迹向上", + "TRACK UP": "航迹朝上", "COURSE HOLD · AIS": "对地航向保持 · AIS", - "Zoom in (+)": "放大 (+)", - "Zoom out (−)": "缩小 (−)", + "Zoom in (+)": "放大(+)", + "Zoom out (−)": "缩小(−)", "ORBITAL VIEW · OFF-NADIR": "轨道视图 · 偏离天底", "World time": "世界时间", "Back to live": "返回实时", @@ -3401,14 +3401,14 @@ "Photo © {photographer}": "照片 © {photographer}", "Photo: {photographer} · {licence}": "照片:{photographer} · {licence}", "Photo · {licence}": "照片 · {licence}", - "The shared flight isn't reporting now — showing where it was last seen": "共享的航班目前没有发送信号——显示其最后出现的位置", - "The shared flight isn't reporting now — showing where it was at {time}": "共享的航班目前没有发送信号——显示其在 {time} 的位置", - "The shared military flight isn't reporting now — showing where it was last seen": "共享的军用航班目前没有发送信号——显示其最后出现的位置", - "The shared military flight isn't reporting now — showing where it was at {time}": "共享的军用航班目前没有发送信号——显示其在 {time} 的位置", - "The shared vessel isn't reporting now — showing where it was last seen": "共享的船舶目前没有发送信号——显示其最后出现的位置", - "The shared vessel isn't reporting now — showing where it was at {time}": "共享的船舶目前没有发送信号——显示其在 {time} 的位置", - "The shared satellite isn't reporting now — showing where it was last seen": "共享的卫星目前没有发送信号——显示其最后出现的位置", - "The shared satellite isn't reporting now — showing where it was at {time}": "共享的卫星目前没有发送信号——显示其在 {time} 的位置", + "The shared flight isn't reporting now — showing where it was last seen": "分享的航班目前无位置更新——显示其最后出现的位置", + "The shared flight isn't reporting now — showing where it was at {time}": "分享的航班目前无位置更新——显示其在 {time} 的位置", + "The shared military flight isn't reporting now — showing where it was last seen": "分享的军用航班目前无位置更新——显示其最后出现的位置", + "The shared military flight isn't reporting now — showing where it was at {time}": "分享的军用航班目前无位置更新——显示其在 {time} 的位置", + "The shared vessel isn't reporting now — showing where it was last seen": "分享的船舶目前无位置更新——显示其最后出现的位置", + "The shared vessel isn't reporting now — showing where it was at {time}": "分享的船舶目前无位置更新——显示其在 {time} 的位置", + "The shared satellite isn't reporting now — showing where it was last seen": "分享的卫星目前无位置更新——显示其最后出现的位置", + "The shared satellite isn't reporting now — showing where it was at {time}": "分享的卫星目前无位置更新——显示其在 {time} 的位置", "ICAO: {id}": "ICAO:{id}", "NORAD: {id}": "NORAD:{id}", "MMSI: {id}": "MMSI:{id}", @@ -3442,7 +3442,7 @@ "1,000–2,000 m": "1,000–2,000 m", "2,000–3,000 m": "2,000–3,000 m", "3,000 m and above": "3,000 m 及以上", - "Exit cockpit to fly to a layer": "退出驾驶舱后才能飞往图层", + "Exit cockpit to fly to a layer": "请先退出座舱,再飞往图层", "Library": "资源库", "Environment": "环境", "Imagery": "影像", @@ -3477,8 +3477,8 @@ }, "switch on a dataset to load features": "开启一个数据集以加载要素", "Switched on by this instance · part of the CCTV layer": "已由此实例开启 · 属于 CCTV 图层", - "Operator opt-in: set {env}=1 on the server": "运营者启用:在服务器上设置 {env}=1", - "Operator opt-in: add {id} to GEV_LIBRARY_ENABLE on the server": "运营者启用:在服务器上将 {id} 添加到 GEV_LIBRARY_ENABLE", + "Operator opt-in: set {env}=1 on the server": "需运营者启用:在服务器上设置 {env}=1", + "Operator opt-in: add {id} to GEV_LIBRARY_ENABLE on the server": "需运营者启用:在服务器上将 {id} 添加到 GEV_LIBRARY_ENABLE", "Turned off by this instance's operator": "已被此实例的运营者关闭", "Needs a provider key on the server": "需要在服务器上配置提供方密钥", "Needs this app's server · not available in a static build": "需要此应用的服务器 · 静态构建中不可用", diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index 6f7bc4801..fd201af36 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -1,60 +1,60 @@ { - "News feed unavailable": "新聞來源無法使用", + "News feed unavailable": "新聞源不可用", "Developing: {name}": "事態發展:{name}", "{count} events today": { "other": "今日 {count} 起事件" }, "{count} reports today": { - "other": "今日 {count} 則報導" + "other": "今日 {count} 條報道" }, "Disputed: {name}": "爭議地區:{name}", - "Video": "影片", + "Video": "視頻", "Photos": "照片", - "Claimed by {who}": "{who}聲稱", + "Claimed by {who}": "{who} 聲稱", "Claimed": "聲稱", "Not yet confirmed by others": "尚未獲他方證實", - "Reported by {side} sources": "{side}方來源報導", - "Reported by {sides}": "報導來源:{sides}", - "{side} sources": "{side}方來源", + "Reported by {side} sources": "報道來源:{side}", + "Reported by {sides}": "報道來源:{sides}", + "{side} sources": "{side}", "Official": "官方", - "Reported by both sides": "雙方均有報導", - "Independently reported": "有獨立來源報導", + "Reported by both sides": "雙方均有報道", + "Independently reported": "有獨立來源報道", "Single source": "單一來源", "International": "國際", "Independent": "獨立", "Unattributed": "未註明來源", - "Outside party": "外部方", + "Outside party": "第三方", "Ukraine–Russia": "烏克蘭–俄羅斯", - "Israel–Gaza–Lebanon": "以色列–加薩–黎巴嫩", - "Iran–Hormuz": "伊朗–荷莫茲", - "Yemen–Red Sea": "葉門–紅海", + "Israel–Gaza–Lebanon": "以色列–加沙–黎巴嫩", + "Iran–Hormuz": "伊朗–霍爾木茲", + "Yemen–Red Sea": "也門–紅海", "Sudan": "蘇丹", - "Sahel": "薩赫爾", + "Sahel": "薩赫勒", "Elsewhere": "其他地區", "Strike": "打擊", "Alert": "警報", "Air defence": "防空", "Ground fighting": "地面戰鬥", "Casualties": "傷亡", - "Humanitarian": "人道", + "Humanitarian": "人道主義", "Disaster": "災害", "Protest": "抗議", "Political": "政治", "No physical event": "無實際事件", "Minor incident": "輕微事件", - "Significant": "重大", - "Major": "嚴重", + "Significant": "較大", + "Major": "重大", "age|now": "剛剛", - "{minutes} min": "{minutes} 分鐘", - "{hours} h": "{hours} 小時", + "{minutes} min": "{minutes} 分鐘前", + "{hours} h": "{hours} 小時前", "{count} sources": { "other": "{count} 個來源" }, - "News feed not configured on this server": "此伺服器未設定新聞來源", - "Malformed news hotspots response": "新聞熱點回應格式錯誤", - "Malformed news feed response": "新聞來源回應格式錯誤", + "News feed not configured on this server": "此服務器未配置新聞源", + "Malformed news hotspots response": "新聞熱點響應格式錯誤", + "Malformed news feed response": "新聞源響應格式錯誤", "{place} (placed by model)": "{place}(由模型定位)", - "English headline by the model": "模型產生的英文標題", + "English headline by the model": "模型生成的英文標題", "Not placed on the map": "未在地圖上標出", "Close": "關閉", "Any severity": "任意嚴重程度", @@ -72,16 +72,16 @@ "Hotspots": "熱點", "Severity": "嚴重程度", "Category": "類別", - "Nothing reported near here in the last {hours} h": "最近 {hours} 小時附近無報導", + "Nothing reported near here in the last {hours} h": "最近 {hours} 小時附近無報道", "Latest news": "最新新聞", "No news matches these filters in the last {hours} h": "最近 {hours} 小時沒有符合這些篩選條件的新聞", - "Load older news": "載入更早的新聞", + "Load older news": "加載更早的新聞", "How to read the map": "如何解讀地圖", - "Colour: severity · icon: type of event": "顏色:嚴重程度 · 圖示:事件類型", - "Solid rim: reported by both sides or independently. Dashed rim: a claim. Thin rim: one source, or one side’s own press.": "實線邊框:雙方或獨立來源均有報導。虛線邊框:一方聲稱。細邊框:單一來源,或一方自身的媒體。", - "Larger: more sources · fainter: older. Picture card: a major or fast-growing event with two or more sources.": "越大:來源越多 · 越淡:越早。圖片卡片:有兩個或以上來源的嚴重或快速發展的事件。", - "Side labels describe the source, not whether a report is true.": "陣營標籤描述的是來源,而非報導是否屬實。", - "Exit cockpit to fly to a news report": "退出駕駛艙以飛往新聞報導", + "Colour: severity · icon: type of event": "顏色:嚴重程度 · 圖標:事件類型", + "Solid rim: reported by both sides or independently. Dashed rim: a claim. Thin rim: one source, or one side’s own press.": "實線邊框:雙方均有報道,或有獨立來源報道。虛線邊框:一方聲稱。細邊框:單一來源,或一方自身的媒體。", + "Larger: more sources · fainter: older. Picture card: a major or fast-growing event with two or more sources.": "越大:來源越多 · 越淡:越早。圖片卡片:有兩個或以上來源的重大或快速發展的事件。", + "Side labels describe the source, not whether a report is true.": "陣營標籤描述的是來源,而非報道是否屬實。", + "Exit cockpit to fly to a news report": "請先退出座艙,再飛往新聞報道地點", "News items in the feed": "列表中的新聞數", "Pick a shape, then click the map.": "先選擇形狀,再在地圖上點擊。", "Enter to place the pin, Esc to cancel.": "按 Enter 放置圖釘,按 Esc 取消。", @@ -407,7 +407,7 @@ "LOADING": "加載中", "DEGRADED": "降級", "STALE": "過期", - "SIGNAL LOST · {time}": "訊號遺失 · {time}", + "SIGNAL LOST · {time}": "信號丟失 · {time}", "PARTIAL": "部分", "FALLBACK": "備用", "view band|STREET": "街道", @@ -504,7 +504,7 @@ "Real live traffic (keyless runs a simulation)": "真實實時路況(無密鑰時為模擬)", "Bing imagery map stacks + world terrain": "Bing 影像地圖組合 + 全球地形", "More flight-polling credits (anonymous works without)": "更多航班輪詢額度(無密鑰也可匿名使用)", - "Street-level imagery and coverage in the Street Level layer. Free: register an app in the Mapillary developer dashboard and paste its Client Token": "街景影像與街景圖層中的覆蓋範圍。免費:在 Mapillary 開發者控制檯註冊一個應用,並粘貼其 Client Token", + "Street-level imagery and coverage in the Street Level layer. Free: register an app in the Mapillary developer dashboard and paste its Client Token": "街景影像與街景圖層中的覆蓋範圍。免費:在 Mapillary 開發者控制台註冊一個應用,並粘貼其 Client Token", "Public space-missions access needs no key. Leave blank for standard use; paid tokens only raise request limits.": "公開的太空任務數據無需密鑰。常規使用請留空;付費令牌只會提高請求上限。", "Air quality request failed": "空氣質量請求失敗", "No readings available": "暫無讀數", @@ -598,7 +598,7 @@ "FOV {value}° (DATASHEET)": "FOV {value}°(規格書)", "FOV {value}°": "FOV {value}°", "SPEC FOV {span}° (CAPABILITY)": "規格 FOV {span}°(可調範圍)", - "COVERAGE {value}km²": "覆蓋 {value}km²", + "COVERAGE {value}km²": "覆蓋 {value} km²", "OVERLAP {count} cams": "重疊 {count} 個攝像頭", "ISOLATED VIEW": "獨立視角", "PROJ MONITOR": "投影:監視器", @@ -615,8 +615,8 @@ "Hurricane": "颶風", "Tropical storm": "熱帶風暴", "Tropical depression": "熱帶低壓", - "Subtropical storm": "亞熱帶風暴", - "Subtropical depression": "亞熱帶低壓", + "Subtropical storm": "副熱帶風暴", + "Subtropical depression": "副熱帶低壓", "Track and cone match this advisory": "路徑和預報不確定範圍與本期公告一致", "Track/cone awaiting advisory {number}": "路徑/預報不確定範圍等待第 {number} 號公告", "Track/cone unavailable": "路徑/預報不確定範圍不可用", @@ -803,7 +803,7 @@ "Low-accuracy score 2–10%": "低精度評分 2–10%", "Low-accuracy score > 10%": "低精度評分 > 10%", "GNSS integrity source unavailable": "GNSS 完好性數據源不可用", - "Regional sample · 250 nm radius · cells from visited views over {minutes} min": "區域採樣 · 半徑 250 海里 · {minutes} 分鐘內已訪問視圖中的網格", + "Regional sample · 250 nm radius · cells from visited views over {minutes} min": "區域採樣 · 半徑 250 海里 · {minutes} 分鐘內瀏覽過的視野中的網格", "Retrying automatically at {time} UTC": "將於 {time} UTC 自動重試", "Retrying automatically after provider cooldown": "服務商冷卻結束後自動重試", "Updates automatically after pan or zoom and once a minute. The public feed covers a 250-nautical-mile circle around the view centre; zooming out does not make it a worldwide snapshot. Blank cells are outside sampled coverage or have insufficient observations.": "平移或縮放後以及每分鐘自動更新。公共數據源覆蓋以視野中心為圓心、半徑 250 海里的範圍;縮小地圖並不會使其成為全球快照。空白網格位於採樣覆蓋範圍之外,或觀測數據不足。", @@ -861,18 +861,18 @@ "{value} KM/S": "{value} km/s", "{direction} {angle}°": "{direction} {angle}°", "OFF-NADIR {angle}° · {offset}": "偏離天底 {angle}° · {offset}", - "SITES STANDBY": "地點待機", - "ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "啟用已測繪設施以接收地點提醒", - "Release the camera from {name}": "讓鏡頭離開{name}", + "SITES STANDBY": "設施待機", + "ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "啟用地圖標註設施以接收設施提醒", + "Release the camera from {name}": "讓鏡頭離開 {name}", "RETURN · {name}": "返回 · {name}", "PREVIOUS SATELLITE": "上一顆衛星", - "Return to {name}": "返回{name}", + "Return to {name}": "返回 {name}", "SATELLITE": "衛星", - "Ride {name}": "乘坐{name}", - "Unnamed site": "未命名地點", + "Ride {name}": "乘坐 {name}", + "Unnamed site": "未命名設施", "OFF-NADIR {angle}°": "偏離天底 {angle}°", "FOCUS · {name}": "聚焦 · {name}", - "Keep the camera on {name}": "讓鏡頭持續對準{name}", + "Keep the camera on {name}": "讓鏡頭持續對準 {name}", "ZOOM {zoom}×": "變焦 {zoom}×", "SWATH {value} KM": "幅寬 {value} km", "SWATH ≈ {value} KM": "幅寬 ≈ {value} km", @@ -880,10 +880,10 @@ "ORBITAL VIEW · NADIR": "軌道視圖 · 天底", "ORBITAL VELOCITY": "軌道速度", "SUB-SATELLITE POINT": "星下點", - "NORTH UP": "北向上", - "CURRENTLY OVER": "目前上空", - "{division}, {country}": "{division},{country}", - "{region} · near {place}": "{region} · {place}附近", + "NORTH UP": "正北朝上", + "CURRENTLY OVER": "當前飛越", + "{division}, {country}": "{country} {division}", + "{region} · near {place}": "{region} · {place} 附近", "NORAD {id}": "NORAD {id}", "EPOCH {time}": "曆元 {time}", "TLE": "TLE", @@ -1163,14 +1163,14 @@ "Hybrid": "混合", "Lines": "線條", "Dots": "圓點", - "Road lines colored by live flow: green to red": "依即時路況著色的道路線條:由綠到紅", - "Moving dots colored by live flow": "依即時路況著色的移動圓點", + "Road lines colored by live flow: green to red": "按實時路況著色的道路線條:從綠到紅", + "Moving dots colored by live flow": "按實時路況著色的移動圓點", "Moving dots": "移動圓點", "TomTom key unavailable": "TomTom 密鑰不可用", "TomTom daily budget reached": "已達 TomTom 每日配額", - "Monthly traffic allowance used": "本月交通流量額度已用完", - "Traffic rate limit reached": "已達交通資料請求頻率上限", - "Traffic service unavailable": "交通服務無法使用", + "Monthly traffic allowance used": "本月交通數據配額已用完", + "Traffic rate limit reached": "已達到交通數據請求頻率上限", + "Traffic service unavailable": "交通服務不可用", "TomTom upstream unreachable": "TomTom 上游服務無法連接", "TomTom flow unavailable": "TomTom 路況不可用", "SIMULATED — {reason}": "模擬——{reason}", @@ -1668,7 +1668,7 @@ "other": "已加載 {count} 個攝像頭 · 啟用 CCTV 以激活" }, "{count} more cameras outside this view · pan or zoom out": { - "other": "此視圖外還有 {count} 個攝像頭 · 平移或縮小" + "other": "視野外還有 {count} 個攝像頭 · 請平移或縮小地圖" }, "Enable CCTV to load camera intersections": "啟用 CCTV 以加載路口攝像頭", "Enable CCTV to start camera-linked intelligence summaries.": "啟用 CCTV 以開始生成與攝像頭關聯的情報摘要。", @@ -1694,7 +1694,7 @@ "Cycle briefing pages automatically every 9 seconds (Signals → News → Local). Pauses while you hover or focus the panel. Live signal data refreshes continuously either way.": "每 9 秒自動輪換簡報頁面(信號 → 新聞 → 本地)。懸停或聚焦面板時暫停。無論是否輪換,實時信號數據都會持續刷新。", "ACQUIRING REGIONAL NEWS": "正在獲取地區新聞", "REGIONAL NEWS UNAVAILABLE": "地區新聞不可用", - "CACHED · REFRESH FAILED": "快取 · 重新整理失敗", + "CACHED · REFRESH FAILED": "緩存 · 刷新失敗", "RESOLVING REGION": "正在解析區域", "REGION UNAVAILABLE": "區域不可用", "NO NEWS IN THE LAST {hours} H": "過去 {hours} 小時無新聞", @@ -1702,13 +1702,13 @@ "{value} KM/H": "{value} km/h", "CLOUD {pct}%": "雲量 {pct}%", "CLOUD UNKNOWN": "雲量未知", - "{source} · CACHED {age}": "{source} · 快取 {age}", + "{source} · CACHED {age}": "{source} · 緩存 {age}", "REGIONAL NEWS": "地區新聞", "{label} · {radius} AIR/SEA WINDOW": "{label} · {radius} 空/海監視窗口", "{scope} · INSTALLATIONS {coverage}": "{scope} · 設施 {coverage}", "MILITARY FEED OFF · PARTIAL PICTURE": "軍用數據源已關閉 · 態勢不完整", "FLIGHTS FEED OFF · PARTIAL PICTURE": "航班數據源已關閉 · 態勢不完整", - "NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "附近航空器資訊不完整 · 不代表安全無虞", + "NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "附近飛機信息不完整 · 不代表安全無虞", "CONTEXT STANDBY": "態勢待機", "ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "啟用全局態勢以接收鄰近目標提醒", "CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "目標丟失 · 最後已知讀數 · 不代表安全無虞", @@ -1741,8 +1741,8 @@ "ARROW · ESTIMATED DIRECTION": "箭頭 · 預估方向", "ROUTE DATA UNAVAILABLE": "航線數據不可用", "CONTACT NAVIGATION OFF": "目標導航已關閉", - "THE BRIDGE STAYS WITH THE SELECTED VESSEL": "駕駛台保持在所選船舶上", - "{range} NM": "{range}海里", + "THE BRIDGE STAYS WITH THE SELECTED VESSEL": "駕駛臺保持在所選船舶上", + "{range} NM": "{range} 海里", "AIS DESTINATION · CREW REPORTED": "AIS 目的地 · 船員申報", "DEST {bearing}": "目的地 {bearing}", "Disable cockpit weather effects": "停用座艙天氣效果", @@ -1862,7 +1862,7 @@ "GNSS Integrity": "GNSS 完好性", "Rain radar": "降雨雷達", "Lightning density": "閃電密度", - "Cyclone advisories": "氣旋預警", + "Cyclone advisories": "氣旋公告", "ALPR Cameras": "ALPR 攝像頭", "Live Vessels": "實時船舶", "Bike Share": "共享單車", @@ -1887,7 +1887,7 @@ "Events": "事件", "Weather": "天氣", "Utilities": "工具", - "Audio": "音訊", + "Audio": "音頻", "Other layers": "其他圖層", "{state} · {source}": "{state} · {source}", "UNCERTAIN · {source} · lifecycle state requires reconciliation": "不確定 · {source} · 生命週期狀態需要核對", @@ -2063,8 +2063,8 @@ "coverage|partial": "部分", "unknown": "未知", "no imagery": "無影像", - "{percent}% cloud": "{percent}% 雲量", - "{min}–{max}% cloud": "{min}–{max}% 雲量", + "{percent}% cloud": "雲量 {percent}%", + "{min}–{max}% cloud": "雲量 {min}–{max}%", "NO BOX": "無框選", "SEARCHING": "搜索中", "{count} DAYS": { @@ -2576,7 +2576,7 @@ "DONE": "完成", "HERE": "此處", "Aircraft cockpit view": "飛機座艙視圖", - "Ship bridge view": "船舶駕駛台視圖", + "Ship bridge view": "船舶駕駛臺視圖", "Satellite orbital view": "衛星軌道視圖", "LOOK STBD {bearing}°": "視線 右舷 {bearing}°", "LOOK PORT {bearing}°": "視線 左舷 {bearing}°", @@ -2588,7 +2588,7 @@ "LOOK {elevation}": "視線 {elevation}", "Estimated destination direction": "預估目的地方向", "DEST ---°": "目的地 ---°", - "BRIDGE WINDOW · FWD": "駕駛台窗口 · 前方", + "BRIDGE WINDOW · FWD": "駕駛臺窗口 · 前方", "OPTICAL PLANE · 01": "光學平面 · 01", "HEADING HOLD · AIS": "船首向保持 · AIS", "NO HEADING · AIS": "無船首向 · AIS", @@ -2597,7 +2597,7 @@ "SPEED OVER GROUND · KTS": "對地航速 · kn", "ALTITUDE · FT": "高度 · ft", "FIRST PERSON": "第一人稱", - "BRIDGE VIEW": "駕駛台視圖", + "BRIDGE VIEW": "駕駛臺視圖", "LIVE TRACK · COURSE ALIGNED": "實時航跡 · 航向對準", "Cockpit vision style": "座艙視覺樣式", "Previous cockpit vision style": "上一個座艙視覺樣式", @@ -2606,7 +2606,7 @@ "Next cockpit vision style": "下一個座艙視覺樣式", "Next vision style": "下一個視覺樣式", "Current aircraft heading": "飛機當前航向", - "Look ahead: turn the bridge view back to the bow": "看向前方:將駕駛台視圖轉回船首", + "Look ahead: turn the bridge view back to the bow": "看向前方:將駕駛臺視圖轉回船首", "Look ahead (Home or 0)": "看向前方(Home 或 0)", "LOOK AHEAD": "看向前方", "Current vessel heading": "船舶當前船首向", @@ -2619,12 +2619,12 @@ "Contact cockpit summary": "目標座艙摘要", "CONTACT": "目標", "CONTACTS · 250 KM": "目標 · 250 km", - "RINGS {spacing} NM": "距標圈 {spacing}海里", + "RINGS {spacing} NM": "距標圈 {spacing} 海里", "{count} CONTACTS": { "other": "{count} 個目標" }, "Radar range {range}. Select for the next range.": "雷達量程 {range}。選擇以切換到下一量程。", - "Bridge radar": "駕駛台雷達", + "Bridge radar": "駕駛臺雷達", "Contact navigation": "目標導航", "CONTEXT ONLY": "僅供參考", "Nearby cohort counts": "附近各類目標數量", @@ -2680,10 +2680,10 @@ "Exit cockpit and return to full globe view": "退出座艙並返回完整地球視圖", "RESET": "重置", "Exit cockpit view": "退出座艙視圖", - "Exit bridge view": "退出駕駛台視圖", + "Exit bridge view": "退出駕駛臺視圖", "Exit orbital view": "退出軌道視圖", "EXIT COCKPIT": "退出座艙", - "EXIT BRIDGE": "退出駕駛台", + "EXIT BRIDGE": "退出駕駛臺", "EXIT ORBIT": "退出軌道視圖", "Navigation, voice, and visual preset controls": "導航、語音和視覺預設控件", "Expand Visual Presets": "展開視覺預設", @@ -2708,7 +2708,7 @@ "WEATHER": "天氣", "Active weather products": "已啟用的天氣產品", "RECENT IMAGERY": "近期影像", - "Imagery days in the box": "視野內的影像天數", + "Imagery days in the box": "框選區域內的影像天數", "CONTEXT": "態勢", "Context mode": "態勢模式", "CONTACTS": "目標", @@ -2717,7 +2717,7 @@ "SELECT CONTEXT": "選擇態勢", "Contact Context actions": "目標態勢操作", "COCKPIT": "座艙", - "BRIDGE": "駕駛台", + "BRIDGE": "駕駛臺", "SEARCH NEARBY SITES": "搜索附近設施", "Reclassify tracked contact as TR-3B": "將跟蹤目標重新歸類為 TR-3B", "CONTACTS CONTEXT OFF": "目標態勢已關閉", @@ -2726,7 +2726,7 @@ "AVAILABLE MISSIONS": "可用任務", "SELECT A MISSION TO INSPECT": "選擇一項任務以查看", "LOADING 30-DAY MISSION INDEX": "正在加載 30 天任務索引", - "Space Missions unavailable: the mission index could not load. Try again later.": "太空任務無法使用:無法載入任務索引。請稍後再試。", + "Space Missions unavailable: the mission index could not load. Try again later.": "太空任務不可用:無法加載任務索引。請稍後重試。", "30-DAY MISSION INDEX UNAVAILABLE": "30 天任務索引不可用", "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB 預覽 · ENTER / 空格鍵選擇", "Internet radio and local SDR": "網絡電臺與本地 SDR", @@ -2984,7 +2984,7 @@ "Take the exit {direction}": "從出口{direction}駛出", "Link shared": "鏈接已分享", "This share link can't be opened": "無法打開此分享鏈接", - "Shared vessel is unavailable": "分享的船隻不可用", + "Shared vessel is unavailable": "分享的船舶不可用", "Check the spelling and press Enter to search again.": "請檢查拼寫,然後按 Enter 重新搜索。", "Press Enter to try again.": "按 Enter 重試。", "Recent speech": "最近語音", @@ -3078,7 +3078,7 @@ "Detection confidence": "探測置信度", "{shown} of {total} shown": "顯示 {shown} / {total}", "UTILITIES": "工具", - "Exit cockpit to fly to a drawing": "請先退出座艙,再飛往繪製位置", + "Exit cockpit to fly to a drawing": "請先退出座艙,再飛往已繪製的形狀", "Directions, drawing and your own files": "路線導航、繪製和你自己的文件", "Voice place search (needs an Overpass server)": "語音地點搜索(需要 Overpass 服務器)", "OSM Places": "OSM 地點", @@ -3212,64 +3212,64 @@ "Too many chat requests. Try again in {seconds} s.": "聊天請求過多。請在 {seconds} 秒後重試。", "The provider refused access for this key. Check the key's project, region or permissions.": "服務商拒絕了此密鑰的訪問請求。請檢查該密鑰所屬的項目、區域或權限。", "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "服務商賬戶餘額不足。請檢查賬戶的計費信息,或在設置中選擇其他 AI 服務商。", - "Last 24 h of news. Each report links to its publisher; places and categories are read from the text and data automatically and can be wrong. Side labels, where shown, describe the source, not whether a report is true.": "最近 24 小時的新聞。每則報導都連結至其發布者;地點與類別由文字與資料自動判讀,可能有誤。陣營標籤(如有顯示)描述的是來源,而非報導是否屬實。", + "Last 24 h of news. Each report links to its publisher; places and categories are read from the text and data automatically and can be wrong. Side labels, where shown, describe the source, not whether a report is true.": "最近 24 小時的新聞。每條報道都鏈接到其發布者;地點和類別由文本和數據自動讀取,可能有誤。陣營標籤(如有顯示)描述的是來源,而非報道是否屬實。", "Economy": "經濟", - "News from {area}": "{area}新聞", - "Within {km} km of the view centre": "視圖中心 {km} 公里範圍內", - "{region}, {country}": "{country}{region}", + "News from {area}": "{area} 新聞", + "Within {km} km of the view centre": "視野中心 {km} km 範圍內", + "{region}, {country}": "{country} {region}", "via {source}": "來自 {source}", - "Local in the News layer: the day’s news from the outlets of the country in view": "新聞圖層的本地:視野中國家的媒體當天的新聞", - "{provider} refused its key": "{provider} 拒絕了金鑰", + "Local in the News layer: the day’s news from the outlets of the country in view": "新聞圖層的“本地”分區:當前視野所在國家的媒體當天的新聞", + "{provider} refused its key": "{provider} 拒絕了密鑰", "Check {envVars} in Provider Settings": "請在服務商設置中檢查 {envVars}", - "{provider} key required": "需要 {provider} 金鑰", + "{provider} key required": "需要 {provider} 密鑰", "{provider} limit reached for today": "{provider} 已達今日上限", - "News resumes tomorrow, or with a paid plan": "新聞將於明天恢復,或升級付費方案", - "News key required": "需要新聞金鑰", + "News resumes tomorrow, or with a paid plan": "新聞將於明天恢復,或升級付費套餐", + "News key required": "需要新聞密鑰", "Precise conflict points in the News layer, beside GDELT’s articles": "新聞圖層中精確的衝突地點,與 GDELT 的文章並列", "Worldwide": "全球", "Shipwrecks": "沉船", - "Before 1900": "1900年以前", - "After 1945": "1945年以後", + "Before 1900": "1900 年以前", + "After 1945": "1945 年以後", "Date unknown": "日期不詳", "Position: centre of the protected area": "位置:保護區中心", - "Position approximate (within about {km} km)": "大致位置(誤差約 {km} 公里)", - "Position surveyed (within about {m} m)": "實測位置(誤差約 {m} 公尺)", - "Vessel: {value}": "船隻:{value}", + "Position approximate (within about {km} km)": "大致位置(誤差約 {km} km)", + "Position surveyed (within about {m} m)": "實測位置(誤差約 {m} m)", + "Vessel: {value}": "船型:{value}", "What happened: {value}": "經過:{value}", - "Depth: {m} m ({quality})": "深度:{m} 公尺({quality})", - "Depth: {m} m": "深度:{m} 公尺", + "Depth: {m} m ({quality})": "深度:{m} m({quality})", + "Depth: {m} m": "深度:{m} m", "Notes: {value}": "備註:{value}", "Cargo or finds: {value}": "貨物或出水文物:{value}", "Protected wreck": "受保護沉船", "Source: {value}": "來源:{value}", - "Click the card to open EMODnet ↗": "點擊卡片開啟 EMODnet ↗", - "Click the card to open the source ↗": "點擊卡片開啟來源 ↗", - "Wikidata unavailable; showing EMODnet only": "Wikidata 無法使用;僅顯示 EMODnet", - "EMODnet unavailable; showing Wikidata only": "EMODnet 無法使用;僅顯示 Wikidata", - "Shipwrecks unavailable": "沉船資料無法使用", + "Click the card to open EMODnet ↗": "點擊卡片打開 EMODnet ↗", + "Click the card to open the source ↗": "點擊卡片打開來源 ↗", + "Wikidata unavailable; showing EMODnet only": "Wikidata 不可用;僅顯示 EMODnet", + "EMODnet unavailable; showing Wikidata only": "EMODnet 不可用;僅顯示 Wikidata", + "Shipwrecks unavailable": "沉船數據不可用", "EMODnet (European waters) · Wikidata (worldwide)": "EMODnet(歐洲海域)· Wikidata(全球)", "{count} wrecks": { "other": "{count} 艘沉船" }, "1900–1945": "1900–1945", "Shipwreck": "沉船", - "Fatalities: {count}": "罹難:{count}", - "Survivors: {count}": "倖存者:{count}", - "Click the card to open the article ↗": "點擊卡片開啟文章 ↗", - "European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "歐洲歷史沉船來自 EMODnet Human Activities;全球知名沉船來自 Wikidata。顏色表示船隻沉沒的時期,標記大小隨罹難人數增加。點擊標記查看卡片。", + "Fatalities: {count}": "遇難人數:{count}", + "Survivors: {count}": "倖存人數:{count}", + "Click the card to open the article ↗": "點擊卡片打開文章 ↗", + "European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "歐洲歷史沉船來自 EMODnet Human Activities;全球知名沉船來自 Wikidata。顏色表示船隻沉沒的時期,標記大小隨遇難人數增加。點擊標記查看卡片。", "All years": "所有年份", "Period": "時段", "From": "起", - "To": "迄", + "To": "止", "First year of the shipwreck period": "沉船時段的起始年份", "Last year of the shipwreck period": "沉船時段的結束年份", "SIGHTING": "目擊", - "Show {name} sighting": "顯示{name}目擊", + "Show {name} sighting": "顯示 {name} 的目擊記錄", "seen {age} ago": "{age}前目擊", - "observed {age} ago": "{age}前觀察", - "Be Whale Wise": "負責任地賞鯨", - "Reported by {name} ({entity})": "通報方 {name}({entity})", - "Reported by {name}": "通報方 {name}", + "observed {age} ago": "{age}前觀察到", + "Be Whale Wise": "負責任地觀鯨", + "Reported by {name} ({entity})": "報告方:{name}({entity})", + "Reported by {name}": "報告方:{name}", "Salish Sea": "薩利希海", "Birds": "鳥類", "Mammals": "哺乳類", @@ -3282,18 +3282,18 @@ "Fungi": "真菌", "Other animals": "其他動物", "Other or unknown": "其他或未知", - "{label} {detail} — click to retry": "{label} {detail} — 點擊重試", - "Wildlife sightings source unavailable": "野生生物目擊來源無法使用", - "Show only community-confirmed (research-grade) sightings": "僅顯示社群確認(研究級)的目擊", + "{label} {detail} — click to retry": "{label} {detail}——點擊重試", + "Wildlife sightings source unavailable": "野生生物目擊來源不可用", + "Show only community-confirmed (research-grade) sightings": "僅顯示社區確認(研究級)的目擊", "No recent sightings in view": "視野內無近期目擊", - "Acartia feed unavailable": "Acartia資料來源無法使用", + "Acartia feed unavailable": "Acartia 數據源不可用", "Show all taxa": "顯示所有分類群", "BIRDS": "鳥類", "Show only birds": "僅顯示鳥類", "MAMMALS": "哺乳類", "Show only mammals": "僅顯示哺乳類", "WHALES": "鯨類", - "Show only whales and dolphins (7 days)": "僅顯示鯨和海豚(7天)", + "Show only whales and dolphins (7 days)": "僅顯示鯨和海豚(7 天)", "INSECTS": "昆蟲", "Show only insects": "僅顯示昆蟲", "PLANTS": "植物", @@ -3301,87 +3301,87 @@ "All rights reserved": "保留所有權利", "Cetacean": "鯨類", "Wildlife Sightings": "野生生物目擊", - "Exit cockpit to fly to a sighting": "退出駕駛艙以飛往目擊地點", - "Exit cockpit to fly to a sighting area": "退出駕駛艙以飛往目擊區域", + "Exit cockpit to fly to a sighting": "請先退出座艙,再飛往目擊地點", + "Exit cockpit to fly to a sighting area": "請先退出座艙,再飛往目擊區域", "Wildlife": "野生生物", "Zoom in for local sightings": "放大查看當地目擊", - "Loading photo...": "正在載入照片…", - "Photo unavailable": "照片無法使用", - "Location obscured to a 0.2° cell": "位置已模糊至0.2°網格", - "Photo of {name}": "{name}的照片", + "Loading photo...": "正在加載照片…", + "Photo unavailable": "照片不可用", + "Location obscured to a 0.2° cell": "位置已模糊至 0.2° 網格", + "Photo of {name}": "{name} 的照片", "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", "{count} sightings in view": "視野內有 {count} 次目擊", - "Close sighting": "關閉目擊紀錄", - "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.": "近48小時內觀察到的野生生物(鯨類為7天)。顏色與徽章圖示表示分類群。敏感物種僅以0.2°網格顯示;Acartia提供的薩利希海海洋哺乳動物目擊位置四捨五入到0.01°。並非族群普查。", + "Close sighting": "關閉目擊記錄", + "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.": "近 48 小時內觀察到的野生生物(鯨類為 7 天)。顏色與徽章圖標表示分類群。敏感物種僅以 0.2° 網格顯示;Acartia 提供的薩利希海海洋哺乳動物目擊位置四捨五入到 0.01°。並非種群普查。", "Open {name} on iNaturalist": "在 iNaturalist 上打開 {name}", "Open {name} on Acartia": "在 Acartia 上打開 {name}", "iNaturalist": "iNaturalist", - "iNaturalist contributors · licences per record": "iNaturalist 貢獻者 · 各紀錄個別授權", - "iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "iNaturalist 貢獻者 · Acartia {count}(薩利希海) · 各紀錄個別授權", - "{days}d": "{days}天", + "iNaturalist contributors · licences per record": "iNaturalist 貢獻者 · 各記錄單獨授權", + "iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "iNaturalist 貢獻者 · Acartia {count}(薩利希海)· 各記錄單獨授權", + "{days}d": "{days} 天", "All markers": "所有標記", "Show markers of every age": "顯示所有時間的標記", - "Maximum event age: {days}d; undated markers are kept": "事件最長保留時間:{days}天;保留無日期標記", + "Maximum event age: {days}d; undated markers are kept": "僅顯示 {days} 天內的事件;保留無日期標記", "event age filter|All": "全部", "Event age": "事件時間", - "Event age: {days}d · undated kept": "事件時間:{days}天內 · 保留無日期標記", - "Close camera preview": "關閉攝影機預覽", - "Refresh map still": "重新整理地圖靜態畫面", - "The map still uses a separate request and may show a different moment.": "地圖靜態畫面使用獨立請求,顯示的時間可能不同。", - "Video plays from Québec 511, which sees your IP address.": "影片從 Québec 511 播放,Québec 511 會看到你的 IP 位址。", + "Event age: {days}d · undated kept": "事件時間:{days} 天內 · 保留無日期標記", + "Close camera preview": "關閉攝像頭預覽", + "Refresh map still": "刷新地圖靜止畫面", + "The map still uses a separate request and may show a different moment.": "地圖靜止畫面通過單獨的請求獲取,可能顯示不同的時刻。", + "Video plays from Québec 511, which sees your IP address.": "視頻從 Québec 511 播放,Québec 511 會看到你的 IP 地址。", "Press OPEN to load the photo": "點擊“打開”加載照片", "Captured {month}": "拍攝於 {month}", "this spot": "此處", - "the view centre": "視圖中心", + "the view centre": "視野中心", "{source} at {place}": "{place} 的 {source}", "Street level at {place}": "{place} 的街景", "Switching map": "正在切換地圖", "Opening imagery": "正在打開影像", - "Street View is ready: press {open} to load it": "街景已就緒:按 {open} 加載", + "Street View is ready: press {open} to load it": "街景已就緒:點擊“{open}”加載", "{source} cannot open this link's street-level photo without a working key": "沒有有效密鑰,{source} 無法打開此鏈接的街景照片", "Street-level photos are hidden in the current view. Leave it first.": "當前視圖隱藏了街景照片。請先退出該視圖。", - "STATIONS": "太空站", - "Crewed stations and the craft that visit them": "載人太空站及來訪飛行器", + "STATIONS": "空間站", + "Crewed stations and the craft that visit them": "載人空間站及來訪飛行器", "IMAGING": "成像", "Earth observation: optical, radar, weather and science": "對地觀測:光學、雷達、氣象與科學", - "TELECOM": "通訊", - "Communications and navigation satellites, Starlink excepted": "通訊與導航衛星(不含 Starlink)", + "TELECOM": "通信", + "Communications and navigation satellites, Starlink excepted": "通信與導航衛星(不含 Starlink)", "STARLINK": "STARLINK", "The Starlink constellation (thousands of extra points)": "Starlink 星座(數千個額外點位)", "PROBES": "探測器", - "Space science, technology demonstration and other payloads": "太空科學、技術驗證及其他酬載", + "Space science, technology demonstration and other payloads": "空間科學、技術驗證及其他載荷", "DEBRIS": "碎片", - "Rocket bodies, fragments and dead satellites": "火箭殘骸、碎片與失效衛星", - "Classes may lag: SATCAT is a cached copy": "分類可能落後:SATCAT 為快取副本", - "Classes may lag: SATCAT unavailable": "分類可能落後:SATCAT 無法取得", - "CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "CelesTrak SATCAT 提供每個物體的類型與運作狀態。沒有最新副本時,分類僅依據 UCS 資料庫、名稱與 CelesTrak 群組,因此此後已失效的衛星可能仍顯示為運作中。", + "Rocket bodies, fragments and dead satellites": "火箭箭體、碎片和失效衛星", + "Classes may lag: SATCAT is a cached copy": "分類可能滯後:SATCAT 為緩存副本", + "Classes may lag: SATCAT unavailable": "分類可能滯後:SATCAT 不可用", + "CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "CelesTrak SATCAT 給出每個物體的類型和運行狀態。沒有最新副本時,分類僅依據 UCS 數據庫、名稱和 CelesTrak 分組,因此此後已失效的衛星可能仍顯示為在軌工作。", "STARLINK ···": "STARLINK ···", "STARLINK ✕": "STARLINK ✕", - "SENSOR SWATH {value} KM": "感測器幅寬 {value} km", - "SENSOR SWATH ≈ {value} KM": "感測器幅寬 ≈ {value} km", - "SENSOR": "感測器", - "Zoom to the imaging sensor swath": "縮放到成像感測器幅寬", - "<1 H OLD": "不到1小時前", - "{value} H OLD": "{value}小時前", - "{value} D OLD": "{value}天前", + "SENSOR SWATH {value} KM": "傳感器幅寬 {value} km", + "SENSOR SWATH ≈ {value} KM": "傳感器幅寬 ≈ {value} km", + "SENSOR": "傳感器", + "Zoom to the imaging sensor swath": "縮放到成像傳感器幅寬", + "<1 H OLD": "不到 1 小時前", + "{value} H OLD": "{value} 小時前", + "{value} D OLD": "{value} 天前", "FWD": "前方", "AFT": "後方", "RIGHT": "右", "LEFT": "左", - "TRACK UP": "航跡向上", + "TRACK UP": "航跡朝上", "COURSE HOLD · AIS": "對地航向保持 · AIS", - "Zoom in (+)": "放大 (+)", - "Zoom out (−)": "縮小 (−)", + "Zoom in (+)": "放大(+)", + "Zoom out (−)": "縮小(−)", "ORBITAL VIEW · OFF-NADIR": "軌道視圖 · 偏離天底", "World time": "世界時間", - "Back to live": "返回即時", - "Live": "即時", + "Back to live": "返回實時", + "Live": "實時", "1 h/s": "1 小時/秒", "1 d/s": "1 天/秒", "1 wk/s": "1 週/秒", "Play speed": "播放速度", - "Scroll or pinch to zoom, drag to move along · double-click to see it all": "捲動或雙指縮放,拖曳以移動 · 按兩下查看全部", + "Scroll or pinch to zoom, drag to move along · double-click to see it all": "滾動或雙指縮放,拖動以移動 · 雙擊查看全部", "1 min/s": "1 分鐘/秒", "Rail": "鐵路", "Urban rail": "城市軌道", @@ -3391,24 +3391,24 @@ "International rail": "國際鐵路", "Hide {category}": "隱藏{category}", "Show {category}": "顯示{category}", - "Live GPS": "即時 GPS", + "Live GPS": "實時 GPS", "Estimated from station reports": "根據車站報告估算", "To {headsign}": "開往 {headsign}", "Next {stop} {time} {relative}": "下一站 {stop} {time} {relative}", "Prev {stop} {time} {relative}": "上一站 {stop} {time} {relative}", - "(+{minutes} min late)": "(誤點 {minutes} 分鐘)", + "(+{minutes} min late)": "(晚點 {minutes} 分鐘)", "Exit cockpit to fly to a train": "請先退出座艙,再飛往列車", "Photo © {photographer}": "照片 © {photographer}", "Photo: {photographer} · {licence}": "照片:{photographer} · {licence}", "Photo · {licence}": "照片 · {licence}", - "The shared flight isn't reporting now — showing where it was last seen": "共享的航班目前沒有發送信號——顯示其最後出現的位置", - "The shared flight isn't reporting now — showing where it was at {time}": "共享的航班目前沒有發送信號——顯示其在 {time} 的位置", - "The shared military flight isn't reporting now — showing where it was last seen": "共享的軍用航班目前沒有發送信號——顯示其最後出現的位置", - "The shared military flight isn't reporting now — showing where it was at {time}": "共享的軍用航班目前沒有發送信號——顯示其在 {time} 的位置", - "The shared vessel isn't reporting now — showing where it was last seen": "共享的船舶目前沒有發送信號——顯示其最後出現的位置", - "The shared vessel isn't reporting now — showing where it was at {time}": "共享的船舶目前沒有發送信號——顯示其在 {time} 的位置", - "The shared satellite isn't reporting now — showing where it was last seen": "共享的衛星目前沒有發送信號——顯示其最後出現的位置", - "The shared satellite isn't reporting now — showing where it was at {time}": "共享的衛星目前沒有發送信號——顯示其在 {time} 的位置", + "The shared flight isn't reporting now — showing where it was last seen": "分享的航班目前無位置更新——顯示其最後出現的位置", + "The shared flight isn't reporting now — showing where it was at {time}": "分享的航班目前無位置更新——顯示其在 {time} 的位置", + "The shared military flight isn't reporting now — showing where it was last seen": "分享的軍用航班目前無位置更新——顯示其最後出現的位置", + "The shared military flight isn't reporting now — showing where it was at {time}": "分享的軍用航班目前無位置更新——顯示其在 {time} 的位置", + "The shared vessel isn't reporting now — showing where it was last seen": "分享的船舶目前無位置更新——顯示其最後出現的位置", + "The shared vessel isn't reporting now — showing where it was at {time}": "分享的船舶目前無位置更新——顯示其在 {time} 的位置", + "The shared satellite isn't reporting now — showing where it was last seen": "分享的衛星目前無位置更新——顯示其最後出現的位置", + "The shared satellite isn't reporting now — showing where it was at {time}": "分享的衛星目前無位置更新——顯示其在 {time} 的位置", "ICAO: {id}": "ICAO:{id}", "NORAD: {id}": "NORAD:{id}", "MMSI: {id}": "MMSI:{id}", @@ -3428,61 +3428,61 @@ "{place} has no position to look for imagery at": "{place} 沒有可查找影像的位置", "No {source} imagery near {place}": "{place} 附近沒有 {source} 影像", "{reason} (filters on: {filters})": "{reason}(已開啟篩選:{filters})", - "tiles unavailable": "圖磚無法使用", - "zoom in to load this layer": "放大以載入此圖層", + "tiles unavailable": "瓦片不可用", + "zoom in to load this layer": "放大以加載此圖層", "Zoom to layer": "縮放至圖層", - "Zoom to layer · fly to where this layer has data": "縮放至圖層 · 飛往此圖層有資料的區域", + "Zoom to layer · fly to where this layer has data": "縮放至圖層 · 飛往此圖層有數據的區域", "Remove from Data Layers": "從數據圖層中移除", - "Remove from Data Layers · add it again from the library": "從數據圖層中移除 · 可從數據資源庫重新加入", - "Coverage outline": "涵蓋範圍輪廓", + "Remove from Data Layers · add it again from the library": "從數據圖層中移除 · 可從數據資源庫重新添加", + "Coverage outline": "覆蓋範圍輪廓", "DRAPED": "貼地", "Below 250 m": "低於 250 m", "250–500 m": "250–500 m", "500–1,000 m": "500–1,000 m", "1,000–2,000 m": "1,000–2,000 m", "2,000–3,000 m": "2,000–3,000 m", - "3,000 m and above": "3,000 m 以上", - "Exit cockpit to fly to a layer": "離開駕駛艙後才能飛往圖層", + "3,000 m and above": "3,000 m 及以上", + "Exit cockpit to fly to a layer": "請先退出座艙,再飛往圖層", "Library": "資源庫", "Environment": "環境", "Imagery": "影像", "Demographics": "人口統計", - "DATASET": "資料集", - "TILES": "圖磚", - "PACK": "資料包", + "DATASET": "數據集", + "TILES": "瓦片", + "PACK": "數據包", "INCUBATING": "孵化中", - "COMMUNITY": "社群", + "COMMUNITY": "社區", "VERIFIED": "已驗證", - "ARCHIVED": "已封存", - "Datasets": "資料集", + "ARCHIVED": "已歸檔", + "Datasets": "數據集", "{datasets} · {tiles} · {packs}": "{datasets} · {tiles} · {packs}", "{count} datasets": { - "other": "{count} 個資料集" + "other": "{count} 個數據集" }, "{count} tile layers": { - "other": "{count} 個圖磚圖層" + "other": "{count} 個瓦片圖層" }, "{count} packs": { - "other": "{count} 個資料包" + "other": "{count} 個數據包" }, "Themes": "主題", - "Added": "已加入", + "Added": "已添加", "{count} on the map": { "other": "地圖上 {count} 個" }, - "none yet": "尚無", - "Map features": "地圖圖徵", + "none yet": "暫無", + "Map features": "地圖要素", "from {count} datasets": { - "other": "來自 {count} 個資料集" + "other": "來自 {count} 個數據集" }, - "switch on a dataset to load features": "開啟一個資料集以載入圖徵", - "Switched on by this instance · part of the CCTV layer": "已由此執行個體開啟 · 屬於 CCTV 圖層", - "Operator opt-in: set {env}=1 on the server": "營運者啟用:在伺服器上設定 {env}=1", - "Operator opt-in: add {id} to GEV_LIBRARY_ENABLE on the server": "營運者啟用:在伺服器上將 {id} 加入 GEV_LIBRARY_ENABLE", - "Turned off by this instance's operator": "已被此執行個體的營運者關閉", - "Needs a provider key on the server": "需要在伺服器上設定提供者金鑰", - "Needs this app's server · not available in a static build": "需要此應用程式的伺服器 · 靜態建置中無法使用", - "Checking what this instance serves…": "正在檢查此執行個體提供的內容…", + "switch on a dataset to load features": "開啟一個數據集以加載要素", + "Switched on by this instance · part of the CCTV layer": "已由此實例開啟 · 屬於 CCTV 圖層", + "Operator opt-in: set {env}=1 on the server": "需運營者啟用:在服務器上設置 {env}=1", + "Operator opt-in: add {id} to GEV_LIBRARY_ENABLE on the server": "需運營者啟用:在服務器上將 {id} 添加到 GEV_LIBRARY_ENABLE", + "Turned off by this instance's operator": "已被此實例的運營者關閉", + "Needs a provider key on the server": "需要在服務器上配置提供方密鑰", + "Needs this app's server · not available in a static build": "需要此應用的服務器 · 靜態構建中不可用", + "Checking what this instance serves…": "正在檢查此實例提供的內容…", "Regional": "區域", "Orthophoto": "正射影像", "Satellite mosaic": "衛星鑲嵌圖", @@ -3490,33 +3490,33 @@ "Historic imagery": "歷史影像", "True orthophoto": "真正射影像", "Type": "類型", - "Resolution": "解析度", - "Flown": "航拍時間", + "Resolution": "分辨率", + "Flown": "航攝時間", "Updates": "更新頻率", - "Ortho quality": "正射品質", + "Ortho quality": "正射質量", "DATA LIBRARY": "數據資源庫", "Close data library": "關閉數據資源庫", "Data Library at a glance": "數據資源庫概覽", - "Live sources beyond the core layers. Each one is fetched from its provider while it is on; nothing is stored in this app. Add one and it appears in Data Layers under Library.": "核心圖層以外的即時資料來源。每個來源開啟時直接從其提供者取得;本應用程式不儲存任何資料。加入後,它會出現在數據圖層的「資源庫」分組中。", - "Name, provider or country": "名稱、提供者或國家", + "Live sources beyond the core layers. Each one is fetched from its provider while it is on; nothing is stored in this app. Add one and it appears in Data Layers under Library.": "核心圖層之外的實時數據源。每個數據源開啟時直接從其提供方獲取;本應用不存儲任何數據。添加後,它會出現在數據圖層的“資源庫”分組中。", + "Name, provider or country": "名稱、提供方或國家", "Theme": "主題", - "All themes": "所有主題", - "Only what this instance serves": "僅顯示此執行個體提供的內容", + "All themes": "全部主題", + "Only what this instance serves": "僅顯示此實例提供的內容", "OPT-IN": "需啟用", - "KEY": "需金鑰", - "KEYLESS": "免金鑰", + "KEY": "需密鑰", + "KEYLESS": "免密鑰", "ZOOM": "縮放", "Zoom to {name}": "縮放至 {name}", "WORKING…": "處理中…", - "ADD": "加入", + "ADD": "添加", "Remove {name} from Data Layers": "從數據圖層中移除 {name}", - "Add {name} to Data Layers": "將 {name} 加入數據圖層", + "Add {name} to Data Layers": "將 {name} 添加到數據圖層", "CONFIRM · REMOVE ALL {count}": { "other": "確認 · 全部移除 {count} 項" }, "REMOVE ALL": "全部移除", - "Confirm: remove every added entry from Data Layers": "確認:從數據圖層中移除所有已加入項目", - "Remove every added entry from Data Layers": "從數據圖層中移除所有已加入項目", + "Confirm: remove every added entry from Data Layers": "確認:從數據圖層中移除所有已添加條目", + "Remove every added entry from Data Layers": "從數據圖層中移除所有已添加條目", "Showing {shown} of {total}": "顯示 {shown} / {total}", "BROWSE DATA LIBRARY": "瀏覽數據資源庫" } diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index e9735a668..1ab9b364f 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -247,7 +247,9 @@ "NORAD: {id}", "COMMUNITY", "Regional", - "OPT-IN" + "OPT-IN", + "{minutes} min", + "1 min/s" ], "es": [ "1 min/s", @@ -630,7 +632,8 @@ "{count} packs", "Orthophoto", "Oblique", - "Type" + "Type", + "{range} NM" ], "it": [ "1 min/s", diff --git a/src/layers/firms/windowLabel.test.mjs b/src/layers/firms/windowLabel.test.mjs index 8113db14d..c668f8bb4 100644 --- a/src/layers/firms/windowLabel.test.mjs +++ b/src/layers/firms/windowLabel.test.mjs @@ -32,10 +32,7 @@ test('a FIRMS snapshot that ends at a set time names it instead of LIVE', (conte assert.equal(methods.getStats().at, at); assert.equal(methods.getStats().source, 'NASA FIRMS', 'the row drops LIVE'); setLocale('de'); - assert.equal( - methods.getStats().loadingLabel, - '24 Std. bis 21.10. 15:00 UTC', - ); + assert.equal(methods.getStats().loadingLabel, '24 h bis 21.10. 15:00 UTC'); layerState._at = null; setLocale('en'); assert.equal(methods.getStats().loadingLabel, 'LIVE · updated 2m ago'); diff --git a/src/ui/sceneControls.test.mjs b/src/ui/sceneControls.test.mjs index b0aea6cfd..b8505cfdb 100644 --- a/src/ui/sceneControls.test.mjs +++ b/src/ui/sceneControls.test.mjs @@ -627,7 +627,7 @@ test('Authoring PLAY uses the scene context, not the radio PLAY', async (context const f = fixture(); try { await nextTick(); - assert.equal(f.elements.start.textContent, 'REPROD.'); + assert.equal(f.elements.start.textContent, 'REPRODUCIR'); } finally { f.restore(); } diff --git a/src/voice/speech.test.mjs b/src/voice/speech.test.mjs index e1aa0c3ec..3a30849fc 100644 --- a/src/voice/speech.test.mjs +++ b/src/voice/speech.test.mjs @@ -570,7 +570,7 @@ test('street-level failure cards are in the interface language; speech stays Eng ]); assert.deepEqual( lines('Google Street View needs the photoreal map, which is not available', { stage: 'map' }), - ['Google Street View braucht die Google 3D-Karte, die nicht verfügbar ist'], + ['Google Street View braucht die Karte „Google 3D“, die nicht verfügbar ist'], ); assert.deepEqual(lines('Mapillary could not be switched on', { stage: 'provider' }), [ 'Mapillary konnte nicht eingeschaltet werden', From bc8aadf55f99d6f7ae30b73ced561e04bf5af5d1 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Fri, 13 Nov 2026 17:50:58 +0400 Subject: [PATCH 55/55] Changelog fragment for #1698 --- changelog.d/1698-new-strings-translations.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 changelog.d/1698-new-strings-translations.md diff --git a/changelog.d/1698-new-strings-translations.md b/changelog.d/1698-new-strings-translations.md new file mode 100644 index 000000000..ef67e482f --- /dev/null +++ b/changelog.d/1698-new-strings-translations.md @@ -0,0 +1,5 @@ +- Fix the translations of the strings added since the last review (News, + Data Library, Wildlife Sightings, Shipwrecks, Rail, the vessel and orbital + cockpits) in all twelve languages: neutral side labels and counts no longer + break the sentence, terms follow each glossary, and Traditional Chinese + follows Simplified Chinese again (German, @gekh, #1698).