diff --git a/changelog.d/1524-tools-heading-wrap.md b/changelog.d/1524-tools-heading-wrap.md new file mode 100644 index 000000000..3647bf9a8 --- /dev/null +++ b/changelog.d/1524-tools-heading-wrap.md @@ -0,0 +1,3 @@ +- The agent and voice query tools wrap whole-degree directions into 0–359, so + a vessel course or heading, aircraft course, transit bearing, camera heading + or satellite azimuth that rounds to 360 reads 0 (@quanticsoul4772, #1524). diff --git a/changelog.d/1547-nominatim-cache-mode-windows.md b/changelog.d/1547-nominatim-cache-mode-windows.md new file mode 100644 index 000000000..9a48b1ee6 --- /dev/null +++ b/changelog.d/1547-nominatim-cache-mode-windows.md @@ -0,0 +1,3 @@ +- The Nominatim disk-cache test checks the cache directory and file modes + only where POSIX modes exist, so it no longer always fails on Windows + (quanticsoul4772, #1547). diff --git a/changelog.d/1569-perimeters-sub-acre-size.md b/changelog.d/1569-perimeters-sub-acre-size.md new file mode 100644 index 000000000..5ee4ffa98 --- /dev/null +++ b/changelog.d/1569-perimeters-sub-acre-size.md @@ -0,0 +1,3 @@ +- Show a fire perimeter under one acre at its reported size. The incident card + rounded WFIGS sizes to whole acres, so a 0.25-acre fire read "0 ac" + (Rudy Celekli, @rudycelekli, #1569). diff --git a/changelog.d/1611-fr-catalog-review.md b/changelog.d/1611-fr-catalog-review.md new file mode 100644 index 000000000..dad302092 --- /dev/null +++ b/changelog.d/1611-fr-catalog-review.md @@ -0,0 +1,2 @@ +- French catalog review: missing narrow no-break spaces, apostrophes, one + mistranslated message and the Starlink shell wording (abiyeenzo, #1611). diff --git a/changelog.d/795-opensky-compress-cached-body.md b/changelog.d/795-opensky-compress-cached-body.md new file mode 100644 index 000000000..3dff6a908 --- /dev/null +++ b/changelog.d/795-opensky-compress-cached-body.md @@ -0,0 +1,5 @@ +- Compress the live flight snapshot on the wire. `/api/flights` gzips its + cached global snapshot and its regional adsb.lol fallback once per refresh + and sends that frame to clients that offer gzip: about 860 KB becomes about + 250 KB. Other clients get the same bytes uncompressed, and both answers carry + `Vary: Accept-Encoding` (daikaginza, #795). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index e1f7555f4..f00f50fad 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4707,6 +4707,10 @@ by `scripts/qa-emergency-squawk.mjs`. Both accept `--timeout-ms` for slow boot. Fire Perimeters uses capped, timed server reads with stale-on-error caching and a per-client limit. A stale snapshot is served for at most 24 hours; the layer row then reads STALE, dated by that snapshot's fetch, and `get_fire_perimeters` returns `stale` and `retrieved_at` with "(data may be stale)" in its summary. Unchanged snapshots retain geometry; link checks cancel on disable or selection change, and the row legend shows reported containment. +The incident card rounds the WFIGS size to whole acres, except under one acre, +where it keeps the reported hundredths: a 0.25-acre fire reads `0.25 ac`, not +`0 ac`. + Prohibited & Restricted Airspace is off by default (share-link token `8`) and sits in Infrastructure. The proxy pages `TYPE_CODE IN ('P','R')` from the FAA SUA FeatureServer, caps the body, and reuses a one-hour snapshot, serving the last @@ -6487,6 +6491,7 @@ apply to `public/` assets or built-preview serving. - `/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/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). - 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. @@ -6588,7 +6593,7 @@ apply to `public/` assets or built-preview serving. - **Cockpit camera anchor:** first-person mode does not write feed-boundary corrections directly into the camera. A cockpit-only inertial anchor advances from the selected aircraft's displayed course and speed, then converges toward the authoritative delayed track with correction capped below forward motion. The displayed kinematics are derived from the same consecutive fix segment as the rendered position, with raw feed speed/course used only as fallback; a transient zero/missing feed speed therefore cannot freeze a visibly moving aircraft after layer enable or a map/cockpit handoff. Rendered altitude continues to come from that interpolated track position. Late ADS-B fixes and short render stalls can remove drift without accelerating or reversing the view. Camera placement runs before scene update/culling at a bounded 20 Hz so a moving cockpit does not force Photoreal 3D Tiles to retraverse on every display frame; textual instruments update at 10 Hz and context/layout work at 4 Hz. Every far Cockpit contact pip shares one stable Cesium texture-atlas entry and skips unused screen-projected course calculations, while only in-range 2D aircraft silhouettes pay the screen-projected rotation cost; ambient glTF collections are hidden/retained rather than synchronously destroyed at cockpit entry, and context rails lay out only on explicit content/state changes and viewport resize. The deliberate 15/30-second layer interpolation delays and per-Cesium-frame position caches remain unchanged. - **Cockpit route, vision, and view controls:** visible on-screen `COCKPIT`, `RESET`, and `EXIT COCKPIT` controls are the way in and out; Cockpit claims no `C` shortcut, so `C` keeps toggling CCTV, also inside a first-person view. RESET uses the same canonical globe route as the map and voice actions, exits Cockpit, and releases its camera ownership rather than exposing the hidden map-style top action. When the tracked commercial flight has a plausible ADSBDB route, the top of the right briefing rail shows a compact `FROM → TO` airport strip and the visor shows a centered estimated-destination chevron with its relative bearing; its destination label rounds to whole degrees in 000–359, wrapping rounded north to 000° while the chevron continues to use the relative bearing independently of label rounding; absent or implausible route data hides the strip and cue rather than guessing. The cockpit-local vision control is an interactive `PREV / CURRENT / NEXT` carousel over `NORMAL`, `CRT`, `NVG`, `FLIR`, `ANIME`, and `NOIR`; its previous/next actions wrap, and activating the current value advances to the next style. Cockpit enters on the active map preset, so a Cyber FLIR map opens Cockpit on FLIR while Normal remains a distinct option instead of producing a second FLIR entry. Cockpit choices temporarily activate the existing Cesium post-process stages, while exiting Cockpit through either EXIT or RESET restores the pre-entry map style. The regional-news page uses a free Google News RSS locality query first, with the existing GDELT query retained only as a fail-soft fallback; linked headlines remain reporting, not verified incidents or risk intelligence. - Cockpit wind-direction labels wrap after rounding to whole degrees, so a direction of 359.5° displays as `N · 0°` rather than `N · 360°`. -- The transit, ALPR, vessel, CCTV, Local ADS-B, flight and military context, awareness, Cockpit and Street Level heading readouts round through `roundHeadingDegrees` in `src/data/headingDegrees.js` (whole degrees 0–359, `null` when not finite, so each caller keeps its own placeholder); `normalizeHeading` lives there too and `cockpitMath.js` re-exports it. The Cockpit's `TRACK ACQUIRED` signal uses `formatHeadingReadout`, so a 359.6° track reads `COURSE 000°`. +- The transit, ALPR, vessel, CCTV, Local ADS-B, flight and military context, awareness, Cockpit and Street Level heading readouts, and the query tools' vessel `course_deg` and `heading_deg`, aircraft `course_deg`, transit `bearing_deg`, camera `heading_deg` and satellite `rise_azimuth_deg` and `azimuth_deg`, round through `roundHeadingDegrees` in `src/data/headingDegrees.js` (whole degrees 0–359, `null` when not finite, so each caller keeps its own placeholder); `normalizeHeading` lives there too and `cockpitMath.js` re-exports it. The Cockpit's `TRACK ACQUIRED` signal uses `formatHeadingReadout`, so a 359.6° track reads `COURSE 000°`. - **Cockpit missing observations:** missing local weather observations remain unknown rather than being converted to zero or clear skies, in the briefing and in the cloud effects; a missing temperature drops the whole local weather record, including its wind and condition. - **Cockpit weather status:** the earlier multi-canvas atmospheric compositor remains fail-closed and is not attached to the live viewer. Cockpit clouds are a separate transparent WebGL pass with a capped 520×320 framebuffer, 24 ray steps, three FBM octaves, and a 12 FPS ceiling. It defaults off and starts only when local storage explicitly contains the persisted `WX ON` opt-in (`'1'`). When opted in, observations refresh after five minutes or 25 km of aircraft movement, fail transparent when unavailable or clear, and stop on exit or disable. `WX OFF` governs atmospheric rendering only: the briefing still fetches source-backed Natural Earth region, headline, and Open-Meteo local-information data, aborting and replacing any in-flight request when the selected aircraft changes. No weather effect runs in map mode and no synthetic fallback is shown. - **Cockpit trail visibility:** entering cockpit hides the selected aircraft's trail body and head so they cannot cross the first-person view; exit restores them. This cockpit-only presentation change does not alter the normal map-mode invariant that aircraft trails render through terrain using their depth-fail material. diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 7b09e8077..2bb7a20ae 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1126,6 +1126,7 @@ "src/sky/starrySky.test.mjs", "src/tooling/skyAssets.test.mjs", "src/tooling/skyAssets.paintOut.test.mjs", + "src/tooling/responseCompression.test.mjs", "scripts/qa-anime-sky.mjs", "scripts/qa-sky-model-lighting.mjs", "server/providers/overpass/requestAdmission.test.mjs", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index b8cc521d7..fed59de8b 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -206,6 +206,7 @@ "modules": [ "src/sources/adsbFields.js", "server/providers/live.js", + "server/providers/common/compress.js", "server/providers/common/http.js", "server/providers/common/query.js", "server/providers/aircraft/opensky.js", diff --git a/server/providers/aircraft/opensky.js b/server/providers/aircraft/opensky.js index f7019d752..b1b07c9d2 100644 --- a/server/providers/aircraft/opensky.js +++ b/server/providers/aircraft/opensky.js @@ -1,6 +1,7 @@ import fs from 'node:fs'; import os from 'node:os'; import { normalizeAdsbLolPointResponse } from '../../../src/data/adsbLolFallback.js'; +import { encodedBody, gzipBody } from '../common/compress.js'; import { coalesceProxyRequest, readResponseJsonCapped, @@ -18,6 +19,11 @@ let _openskyTokenExpiry = 0; let _openskyTokenPromise = null; /** @type {string|null} Cached upstream response body (JSON text). */ let _openskyCacheBody = null; +/** + * @type {Buffer|null} Gzip of `_openskyCacheBody`, written together with it. + * Null when the body is not worth compressing. + */ +let _openskyCacheGzip = null; /** @type {number} HTTP status of the cached response. */ let _openskyCacheStatus = 0; /** @type {number} Epoch-ms when the response was cached. */ @@ -399,8 +405,12 @@ async function fetchAdsbLolPointFallback(req) { ADSBLOL_POINT_MAX_RESPONSE_BYTES, ); const normalized = normalizeAdsbLolPointResponse(payload); + const body = JSON.stringify(normalized); const record = { - body: JSON.stringify(normalized), + body, + // Compressed once per entry; ADSBLOL_POINT_CACHE_MAX bounds the + // extra memory. + gzip: await gzipBody(body), cachedAt: Date.now(), count: normalized.states.length, }; @@ -429,6 +439,7 @@ async function fetchAdsbLolPointFallback(req) { async function serveAdsbLolPointFallback(req, res, requestedMode, reason) { const fallback = await fetchAdsbLolPointFallback(req); if (!fallback) return false; + const encoded = encodedBody(req, fallback.body, fallback.gzip); res.writeHead(200, { ...buildOpenSkyHeaders({ cacheStatus: fallback.cacheStatus, @@ -439,8 +450,9 @@ async function serveAdsbLolPointFallback(req, res, requestedMode, reason) { 'X-Flight-Source': 'adsb.lol', 'X-Flight-Coverage': `${ADSBLOL_POINT_RADIUS_NM}nm regional fallback`, 'X-Flight-Count': String(fallback.count), + ...encoded.headers, }); - res.end(fallback.body); + res.end(encoded.body); return true; } @@ -514,9 +526,13 @@ export function openSkyProxy() { reason: 'cached', }; const isStale = now - _openskyCacheTime >= _openskyTtlMs; - res.writeHead( - _openskyCacheStatus || 200, - buildOpenSkyHeaders({ + const encoded = encodedBody( + req, + _openskyCacheBody, + _openskyCacheGzip, + ); + res.writeHead(_openskyCacheStatus || 200, { + ...buildOpenSkyHeaders({ cacheStatus: isStale ? 'STALE' : 'HIT', requestedMode: cachedMeta.requestedMode || requestedMode, usedMode: cachedMeta.usedMode || 'unknown', @@ -532,8 +548,9 @@ export function openSkyProxy() { ? (_openskyCooldownUntil - now) / 1000 : undefined, }), - ); - res.end(_openskyCacheBody); + ...encoded.headers, + }); + res.end(encoded.body); return; } // Cooling down with nothing cached (cold start into a rate limit): @@ -628,7 +645,11 @@ export function openSkyProxy() { // Only cache successful responses — error responses (401/403/429/5xx) // should not be served from cache on subsequent requests if (upstream.ok) { + // Compress before writing either slot, so a request arriving + // mid-refresh still reads a body with its own frame. + const frame = await gzipBody(body); _openskyCacheBody = body; + _openskyCacheGzip = frame; _openskyCacheStatus = upstream.status; _openskyCacheTime = now; _openskyCacheSourceEpochMs = sourceEpochMs; @@ -681,9 +702,13 @@ export function openSkyProxy() { // Serve the last-good body instead of the 429 when we have one — // the layer keeps rendering (STALE-cued) instead of dying. if (_openskyCacheBody && _openskyCacheStatus === 200) { - res.writeHead( - 200, - buildOpenSkyHeaders({ + const encoded = encodedBody( + req, + _openskyCacheBody, + _openskyCacheGzip, + ); + res.writeHead(200, { + ...buildOpenSkyHeaders({ cacheStatus: 'STALE', requestedMode, usedMode, @@ -691,8 +716,9 @@ export function openSkyProxy() { staleSeconds: (now - _openskyCacheTime) / 1000, retryAfterSeconds: cooldownMs / 1000, }), - ); - res.end(_openskyCacheBody); + ...encoded.headers, + }); + res.end(encoded.body); return; } } @@ -734,16 +760,23 @@ export function openSkyProxy() { } } - res.writeHead( - upstream.status, - buildOpenSkyHeaders({ + // The frame belongs to this body only if this body was cached. An + // uncached error must not borrow the previous snapshot's frame. + const encoded = encodedBody( + req, + body, + _openskyCacheBody === body ? _openskyCacheGzip : null, + ); + res.writeHead(upstream.status, { + ...buildOpenSkyHeaders({ cacheStatus: 'MISS', requestedMode, usedMode, reason, }), - ); - res.end(body); + ...encoded.headers, + }); + res.end(encoded.body); } catch (e) { console.error('[OpenSky Proxy]', e.message); if (_openskyCacheBody) { @@ -754,17 +787,22 @@ export function openSkyProxy() { usedMode: 'unknown', reason: 'cached_stale', }; - res.writeHead( - _openskyCacheStatus || 200, - buildOpenSkyHeaders({ + const encoded = encodedBody( + req, + _openskyCacheBody, + _openskyCacheGzip, + ); + res.writeHead(_openskyCacheStatus || 200, { + ...buildOpenSkyHeaders({ cacheStatus: 'STALE', requestedMode: cachedMeta.requestedMode || OPENSKY_AUTH_MODE_DEFAULT, usedMode: cachedMeta.usedMode || 'unknown', reason: cachedMeta.reason || 'cached_stale', }), - ); - res.end(_openskyCacheBody); + ...encoded.headers, + }); + res.end(encoded.body); return; } const requestedMode = normalizeOpenSkyAuthMode( diff --git a/server/providers/common/compress.js b/server/providers/common/compress.js new file mode 100644 index 000000000..cbce21c6e --- /dev/null +++ b/server/providers/common/compress.js @@ -0,0 +1,81 @@ +import { gzip } from 'node:zlib'; +import { promisify } from 'node:util'; + +const gzipAsync = promisify(gzip); + +/** + * Opt-in gzip for text bodies a proxy already holds in memory. + * + * Not a middleware: `cctv/media.js` streams bodies and answers range requests, + * and several providers forward binary upstream types that gzip only grows. + * `encodedBody` returns the body with its headers so the two cannot disagree. + */ + +/** Skip compression for bodies smaller than 1 KiB. */ +const MIN_COMPRESSED_BYTES = 1024; + +/** + * RFC 9110 section 12.4.2 `qvalue`. Matching the whole token keeps `q=1oops` + * and `q=2` unreadable instead of rounding them into acceptance. + */ +const QVALUE = /^(?:0(?:\.\d{0,3})?|1(?:\.0{0,3})?)$/; + +/** + * Whether `Accept-Encoding` offers gzip. Anything unreadable (no header, a bad + * q value, `x-gzip`) reads as no: every client can decode identity. + * + * @param {import('node:http').IncomingMessage} request + * @returns {boolean} + */ +export function acceptsGzip(request) { + const header = request?.headers?.['accept-encoding']; + if (typeof header !== 'string') return false; + return header.split(',').some((part) => { + const [token, ...parameters] = part.split(';'); + if (token.trim().toLowerCase() !== 'gzip') return false; + const quality = parameters + .map((parameter) => parameter.trim().toLowerCase()) + .find((parameter) => parameter.startsWith('q=')); + if (quality === undefined) return true; + // `q=0` names gzip and refuses it. + const value = quality.slice(2); + return QVALUE.test(value) && Number(value) > 0; + }); +} + +/** + * Gzip a body once for a caller that caches it. Async, so the ~860 KB OpenSky + * snapshot does not block other requests while it compresses. + * + * Returns null, meaning "serve identity", when the body is under 1 KiB or gzip + * does not shrink it. + * + * @param {string|Buffer} body - the exact bytes the identity response sends. + * @returns {Promise} + */ +export async function gzipBody(body) { + const raw = Buffer.isBuffer(body) ? body : Buffer.from(body); + if (raw.byteLength < MIN_COMPRESSED_BYTES) return null; + const compressed = await gzipAsync(raw); + return compressed.byteLength < raw.byteLength ? compressed : null; +} + +/** + * Pick the body and the headers that describe it. `Vary` is on both branches + * so a shared cache never serves one encoding to a client that asked for the + * other. + * + * @param {import('node:http').IncomingMessage} request + * @param {string|Buffer} identity - the uncompressed body. + * @param {Buffer|null} compressed - `gzipBody(identity)`, or null. + * @returns {{body: string|Buffer, headers: Record}} + */ +export function encodedBody(request, identity, compressed) { + if (compressed && acceptsGzip(request)) { + return { + body: compressed, + headers: { 'Content-Encoding': 'gzip', Vary: 'Accept-Encoding' }, + }; + } + return { body: identity, headers: { Vary: 'Accept-Encoding' } }; +} diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 44edfae1c..24cd94528 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -820,7 +820,7 @@ "FLIGHT {count}": "VOL {count}", "REUSED": "RÉUTILISÉ", "NO STAGE RE-ENTRY / RECOVERY DATA": "AUCUNE DONNÉE DE RENTRÉE / RÉCUPÉRATION D'ÉTAGE", - "{count} / 30D": "{count} / 30J", + "{count} / 30D": "{count} / 30 J", "NO MISSIONS AVAILABLE IN THE CURRENT 30-DAY WINDOW": "AUCUNE MISSION DISPONIBLE DANS LA FENÊTRE ACTUELLE DE 30 JOURS", "DATE UNAVAILABLE": "DATE INDISPONIBLE", "UNSPECIFIED OPERATOR": "OPÉRATEUR NON PRÉCISÉ", @@ -1052,11 +1052,11 @@ "Longitudes must be within ±180°": "Les longitudes doivent être comprises entre −180° et +180°", "Select one side of the dateline": "Ne traversez pas la ligne de changement de date", "Box must enclose an area": "La zone doit délimiter une surface", - "Add the full Starlink broadband shell (thousands of extra points)": "Ajouter toute la constellation Starlink (des milliers de points en plus)", - "Loading the Starlink shell…": "Chargement de la constellation Starlink…", + "Add the full Starlink broadband shell (thousands of extra points)": "Ajouter la couche complète de la constellation Starlink (des milliers de points en plus)", + "Loading the Starlink shell…": "Chargement de la couche de la constellation Starlink…", "Starlink {detail} — click to retry": "Starlink {detail} — cliquer pour réessayer", "load failed": "échec du chargement", - "Showing the full Starlink shell — click for the core catalog only": "Constellation Starlink complète affichée — cliquer pour le catalogue de base seulement", + "Showing the full Starlink shell — click for the core catalog only": "Couche complète de la constellation Starlink affichée — cliquer pour le catalogue de base seulement", "DENSE": "DENSE", "CelesTrak unreachable": "CelesTrak injoignable", "DOCKED · {name} · +{extra}": "AMARRÉ · {name} · +{extra}", @@ -1137,7 +1137,7 @@ "Hybrid": "Hybride", "Lines": "Lignes", "Dots": "Points", - "Road lines colored by live flow: green to red": "Routes colorées selon le trafic en direct : du vert au rouge", + "Road lines colored by live flow: green to red": "Routes colorées selon le trafic en direct : du vert au rouge", "Moving dots colored by live flow": "Points mobiles colorés selon le trafic en direct", "Moving dots": "Points mobiles", "TomTom key unavailable": "Clé TomTom indisponible", @@ -1153,7 +1153,7 @@ "LIVE · Roads: {source} · No flow roads in view": "DIRECT · Routes : {source} · Aucune route avec flux dans la vue", "LIVE · Roads: {source} · Flow {percent}%": "DIRECT · Routes : {source} · Flux {percent} %", "SIMULATED · Roads: {source} · Flow {percent}%": "SIMULÉ · Routes : {source} · Flux {percent} %", - "STALE · Roads: {source} · Flow: cached TomTom tiles": "OBSOLÈTE · Routes : {source} · Flux : tuiles TomTom en cache", + "STALE · Roads: {source} · Flow: cached TomTom tiles": "OBSOLÈTE · Routes : {source} · Flux : tuiles TomTom en cache", "Syncing flow · Roads: OpenStreetMap · Flow: TomTom · Unmatched: simulated": "Synchro du flux · Routes : OpenStreetMap · Flux : TomTom · Non appariées : simulées", "LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov · Unmatched: simulated": "DIRECT · Routes : OpenStreetMap · Flux : TomTom · couv. {percent} % · Non appariées : simulées", "LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov": "DIRECT · Routes : OpenStreetMap · Flux : TomTom · couv. {percent} %", @@ -1250,7 +1250,7 @@ "Contiguous US · gaps ≠ no rain": "États-Unis contigus · lacunes ≠ absence de pluie", "Americas + Pacific · not individual strikes": "Amériques + Pacifique · pas des impacts individuels", "Color: strikes/km²/min ×10³": "Couleur : impacts/km²/min ×10³", - "Color: {units}": "Couleur : {units}", + "Color: {units}": "Couleur : {units}", "RADAR · {units}": "RADAR · {units}", "60°S–60°N · typically 2–3 h delayed": "60°S–60°N · retard habituel de 2 à 3 h", "North America · infrared imagery": "Amérique du Nord · imagerie infrarouge", @@ -1382,7 +1382,7 @@ "{label} is unavailable": "{label} est indisponible", "This map stack": "Ce fond de carte", "{label} unavailable: {hint}": "{label} indisponible : {hint}", - "Google 3D tiles unavailable — check the key's API restrictions, quota, or network": "Tuiles Google 3D indisponibles — vérifiez les restrictions d'API de la clé, le quota ou le réseau", + "Google 3D tiles unavailable — check the key's API restrictions, quota, or network": "Google 3D Tiles indisponible — vérifiez les restrictions d'API de la clé, le quota ou le réseau", "Needs {envVars} — add it in Provider Settings — or a Cesium ion token for the ion-hosted route": "Nécessite {envVars} — à ajouter dans les paramètres des fournisseurs — ou un jeton Cesium ion pour la variante hébergée par ion", "Exit cockpit to fly to a camera": "Quittez le cockpit pour rejoindre une caméra", "Exit cockpit to fly to a location": "Quittez le cockpit pour rejoindre un lieu", @@ -1521,7 +1521,7 @@ "Apply details": "Appliquer les détails", "Select a scene first": "Sélectionnez d'abord une scène", "Share selected scene": "Partager la scène sélectionnée", - "Scene JSON preserves authored settings and attribution. It does not include asset files. Bundles include only pack files you choose here or previously imported bundle files.": "Le JSON de scène conserve les réglages de l'auteur et l'attribution. Il n'inclut pas les fichiers de ressources. Les archives n'incluent que les fichiers de paquets choisis ici ou ceux d'archives déjà importées.", + "Scene JSON preserves authored settings and attribution. It does not include asset files. Bundles include only pack files you choose here or previously imported bundle files.": "Le JSON de scène conserve les paramètres de l'auteur et l'attribution. Il n'inclut pas les fichiers de ressources. Les archives n'incluent que les fichiers de paquets choisis ici ou ceux d'archives déjà importées.", "Download scene JSON": "Télécharger le JSON de scène", "Choose data-pack files": "Choisir des fichiers de paquet de données", "Or choose a data-pack folder": "Ou choisir un dossier de paquet de données", @@ -2891,7 +2891,7 @@ "AVAILABLE MISSIONS": "MISSIONS DISPONIBLES", "SELECT A MISSION TO INSPECT": "CHOISIR UNE MISSION À EXAMINER", "LOADING 30-DAY MISSION INDEX": "CHARGEMENT DE L'INDEX DES MISSIONS À 30 JOURS", - "Space Missions unavailable: the mission index could not load. Try again later.": "Missions spatiales indisponibles : impossible de charger l’index des missions. Réessayez plus tard.", + "Space Missions unavailable: the mission index could not load. Try again later.": "Missions spatiales indisponibles : impossible de charger l'index des missions. Réessayez plus tard.", "30-DAY MISSION INDEX UNAVAILABLE": "INDEX DES MISSIONS À 30 JOURS INDISPONIBLE", "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB APERÇU · ENTER / SPACE SÉLECTIONNE", "Internet radio and local SDR": "Radio Internet et SDR local", @@ -2912,7 +2912,7 @@ "Radio volume": "Volume de la radio", "STATION SITE": "SITE DE LA STATION", "DIRECTORY: RADIO BROWSER": "ANNUAIRE : RADIO BROWSER", - "Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "Le flux audio se connecte directement au diffuseur dès que vous lancez la lecture, choisissez une station sur le globe ou la demandez par la voix. Votre IP est visible par ce diffuseur.", + "Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "Le flux audio se connecte directement au diffuseur lorsque vous lancez la lecture, que vous choisissez une station sur le globe ou que vous la demandez par la voix. Votre adresse IP est visible par ce diffuseur.", "Local RTL-SDR receiver": "Récepteur RTL-SDR local", "LOCAL RTL-SDR": "RTL-SDR LOCAL", "Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · un seul tuner local partagé entre la FM et la couche ADS-B local.", @@ -3275,7 +3275,7 @@ "The provider refused access for this key. Check the key's project, region or permissions.": "Le fournisseur a refusé l'accès pour cette clé. Vérifiez le projet, la région ou les autorisations de la clé.", "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "Le compte du fournisseur n'a plus de crédit. Vérifiez la facturation ou choisissez un autre fournisseur d'IA dans les paramètres.", "SIGHTING": "OBSERVATION", - "Show {name} sighting": "Afficher l’observation de {name}", + "Show {name} sighting": "Afficher l'observation : {name}", "seen {age} ago": "vu il y a {age}", "observed {age} ago": "observé il y a {age}", "Be Whale Wise": "Respectez les cétacés", @@ -3313,7 +3313,7 @@ "Cetacean": "Cétacé", "Wildlife Sightings": "Observations naturalistes", "Exit cockpit to fly to a sighting": "Quittez le cockpit pour rejoindre une observation", - "Exit cockpit to fly to a sighting area": "Quittez le cockpit pour rejoindre une zone d’observation", + "Exit cockpit to fly to a sighting area": "Quittez le cockpit pour rejoindre une zone d'observation", "Wildlife": "Faune et flore", "Zoom in for local sightings": "Zoomez pour les observations locales", "Loading photo...": "Chargement de la photo…", @@ -3323,26 +3323,26 @@ "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", "{count} sightings in view": "{count} observations dans la vue", - "Close sighting": "Fermer l’observation", - "Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "Faune observée au cours des dernières 48 h (cétacés 7 jours). La couleur et le pictogramme du badge indiquent le groupe taxonomique. Les espèces sensibles sont limitées à des cellules de 0,2° ; les signalements de mammifères marins Acartia en mer des Salish sont arrondis à 0,01°. Pas un recensement.", + "Close sighting": "Fermer l'observation", + "Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "Faune observée au cours des dernières 48 h (cétacés 7 jours). La couleur et le pictogramme du badge indiquent le groupe taxonomique. Les espèces sensibles sont limitées à des cellules de 0,2° ; les signalements de mammifères marins en mer des Salish provenant d'Acartia sont arrondis à 0,01°. Pas un recensement.", "Open {name} on iNaturalist": "Ouvrir {name} sur iNaturalist", "Open {name} on Acartia": "Ouvrir {name} sur Acartia", "iNaturalist": "iNaturalist", "iNaturalist contributors · licences per record": "Contributeurs iNaturalist · licences par observation", "iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Contributeurs iNaturalist · Acartia {count} (mer des Salish) · licences par observation", - "{days}d": "{days}j", + "{days}d": "{days} j", "All markers": "Tous les marqueurs", "Show markers of every age": "Afficher les marqueurs de tout âge", - "Maximum event age: {days}d; undated markers are kept": "Ancienneté maximale : {days} j ; marqueurs sans date conservés", + "Maximum event age: {days}d; undated markers are kept": "Ancienneté maximale : {days} j ; marqueurs sans date conservés", "event age filter|All": "Tous", "Event age": "Ancienneté", - "Event age: {days}d · undated kept": "Ancienneté : {days} j · marqueurs sans date conservés", - "Close camera preview": "Fermer l’aperçu de la caméra", - "Refresh map still": "Actualiser l’image fixe sur la carte", - "The map still uses a separate request and may show a different moment.": "L’image fixe de la carte utilise une requête distincte et peut montrer un autre moment.", + "Event age: {days}d · undated kept": "Ancienneté : {days} j · marqueurs sans date conservés", + "Close camera preview": "Fermer l'aperçu de la caméra", + "Refresh map still": "Actualiser l'image fixe sur la carte", + "The map still uses a separate request and may show a different moment.": "L'image fixe de la carte utilise une requête distincte et peut montrer un autre moment.", "Video plays from Québec 511, which sees your IP address.": "La vidéo est lue depuis Québec 511, qui voit votre adresse IP.", "STATIONS": "STATIONS", - "Crewed stations and the craft that visit them": "Stations habitées et les vaisseaux qui s’y rendent", + "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", "TELECOM": "TÉLÉCOM", @@ -3355,7 +3355,7 @@ "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.", + "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", diff --git a/src/layers/perimeters/cards.js b/src/layers/perimeters/cards.js index aef5913ce..4c73d5733 100644 --- a/src/layers/perimeters/cards.js +++ b/src/layers/perimeters/cards.js @@ -159,7 +159,8 @@ export function buildIncidentCard(row, nowMs, { link = null } = {}) { // the known cause, behavior and complexity values and the wording around them. const facts = []; if (Number.isFinite(row.acres)) { - const acres = Math.round(row.acres); + const acres = + row.acres < 1 ? Math.round(row.acres * 100) / 100 : Math.round(row.acres); facts.push( tn(acres, '{acres} ac', '{acres} ac', { acres: formatNumber(acres) }), ); diff --git a/src/layers/perimeters/cards.test.mjs b/src/layers/perimeters/cards.test.mjs index 0e064bff0..fe4aac06b 100644 --- a/src/layers/perimeters/cards.test.mjs +++ b/src/layers/perimeters/cards.test.mjs @@ -302,3 +302,34 @@ test('partial incident details render only the facts that exist', () => { 'Catron County · $85K to date', ]); }); + +test('a fire under one acre keeps its WFIGS size instead of reading 0 ac', () => { + const row = { + stableId: 'x', + name: 'Grey', + acres: 0.25, + containedPct: null, + state: 'US-WA', + category: 'WF', + discoveredTime: null, + updatedTime: null, + cause: null, + behavior: null, + personnel: null, + county: null, + costToDate: null, + complexity: null, + }; + assert.equal( + buildIncidentCard(row, 1758000000000).details[0], + '0.25 ac · containment unknown · US-WA', + ); + assert.equal( + buildIncidentCard({ ...row, acres: 0.01 }, 1758000000000).details[0], + '0.01 ac · containment unknown · US-WA', + ); + assert.equal( + buildIncidentCard({ ...row, acres: 1.4 }, 1758000000000).details[0], + '1 ac · containment unknown · US-WA', + ); +}); diff --git a/src/tooling/liveProviders.test.mjs b/src/tooling/liveProviders.test.mjs index 1f1befc95..3198ee752 100644 --- a/src/tooling/liveProviders.test.mjs +++ b/src/tooling/liveProviders.test.mjs @@ -1,6 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { once } from 'node:events'; +import { gunzipSync } from 'node:zlib'; import { WebSocketServer } from 'ws'; import { setTimeout as delay } from 'node:timers/promises'; import * as providers from '../../server/providers/live.js'; @@ -15,7 +16,7 @@ function install(plugin, preview = false) { }, }, }); - return async (route, url = '/', method = 'GET') => { + return async (route, url = '/', method = 'GET', headers = {}) => { assert.ok(routes.has(route), `registered route: ${route}`); const response = { statusCode: 200, @@ -32,7 +33,7 @@ function install(plugin, preview = false) { this.body = body; }, }; - await routes.get(route)({ url, method }, response); + await routes.get(route)({ url, method, headers }, response); return response; }; } @@ -584,6 +585,111 @@ test('track backfill proxy returns 502 on an oversized upstream body and caches assert.equal(callCount, 1); }); +test('the OpenSky snapshot is compressed for clients that ask, and an error body never inherits the cached frame', async (t) => { + environment(t, { + OPENSKY_AUTH_MODE: 'anon', + OPENSKY_CLIENT_ID: undefined, + OPENSKY_CLIENT_SECRET: undefined, + OPENSKY_USERNAME: undefined, + OPENSKY_PASSWORD: undefined, + }); + let now = Date.now(); + t.mock.method(Date, 'now', () => now); + t.mock.method(console, 'log', () => {}); + t.mock.method(console, 'error', () => {}); + + let upstreamStatus = 200; + let snapshotAgeMs = 0; + t.mock.method(globalThis, 'fetch', async (url) => { + if (url.includes('/lat/')) + return Response.json({ + now: now / 1000, + ac: Array.from({ length: 200 }, (_, index) => ({ + hex: `abc${index}`, + lat: 30, + lon: -97, + alt_baro: 10000, + })), + }); + assert.ok(url.includes('/states/'), `unexpected upstream call: ${url}`); + if (upstreamStatus !== 200) + return new Response('{"error":"upstream"}', { status: upstreamStatus }); + const sourceSeconds = Math.floor((now - snapshotAgeMs) / 1000); + // Healthy credits keep the 9 s base TTL, so the clock jumps below clear it. + return Response.json( + { + time: sourceSeconds, + states: Array.from({ length: 200 }, (_, index) => [ + `abc${index}`, + 'CALLSIGN', + 'United States', + sourceSeconds, + -97.7, + 30.1, + 10000, + ]), + }, + { headers: { 'x-rate-limit-remaining': '4000' } }, + ); + }); + + // A fresh module: the snapshot cache, its frame and the TTL are module state. + const isolated = await import( + `../../server/providers/aircraft/opensky.js?compression=${now}` + ); + const states = install(isolated.openSkyProxy()); + const compressed = await states('/api/flights', '/', 'GET', { + 'accept-encoding': 'gzip, deflate, br', + }); + assert.equal(compressed.statusCode, 200); + assert.equal(compressed.headers['content-encoding'], 'gzip'); + assert.equal(compressed.headers.vary, 'Accept-Encoding'); + assert.ok(Buffer.isBuffer(compressed.body)); + + // Same cached snapshot, no Accept-Encoding: identity bytes, which the frame + // above must decode to. + const identity = await states('/api/flights'); + assert.equal(identity.headers['x-opensky-cache'], 'HIT'); + assert.equal(identity.headers['content-encoding'], undefined); + assert.equal(identity.headers.vary, 'Accept-Encoding'); + assert.equal(gunzipSync(compressed.body).toString(), identity.body); + + // `gzip;q=0` refuses gzip. + const refused = await states('/api/flights', '/', 'GET', { + 'accept-encoding': 'gzip;q=0', + }); + assert.equal(refused.headers['content-encoding'], undefined); + assert.equal(refused.body, identity.body); + + // An upstream rejection is not cached, so it must not carry the previous + // snapshot's Content-Encoding. + upstreamStatus = 403; + now += 60000; // past the 9 s base TTL + const rejected = await states('/api/flights', '/', 'GET', { + 'accept-encoding': 'gzip', + }); + assert.equal(rejected.statusCode, 403); + assert.equal(rejected.headers['content-encoding'], undefined); + assert.ok(JSON.parse(rejected.body).error, 'a readable error body'); + + // A snapshot too old to serve falls back to adsb.lol, which negotiates the + // same way. + upstreamStatus = 200; + snapshotAgeMs = 3600000; + now += 60000; + const regional = await states('/api/flights', '?lat=30&lon=-97', 'GET', { + 'accept-encoding': 'gzip', + }); + assert.equal(regional.statusCode, 200); + assert.equal(regional.headers['x-flight-source'], 'adsb.lol'); + assert.equal(regional.headers['content-encoding'], 'gzip'); + assert.equal(regional.headers.vary, 'Accept-Encoding'); + assert.equal( + JSON.parse(gunzipSync(regional.body).toString()).states.length, + 200, + ); +}); + test('OpenSky Basic mode no longer sends username and password: it asks anonymously and warns once', async (t) => { environment(t, { OPENSKY_CLIENT_ID: undefined, diff --git a/src/tooling/nominatimOutlineRoute.test.mjs b/src/tooling/nominatimOutlineRoute.test.mjs index c29b41f21..74f0b2d6a 100644 --- a/src/tooling/nominatimOutlineRoute.test.mjs +++ b/src/tooling/nominatimOutlineRoute.test.mjs @@ -684,25 +684,34 @@ test('the disk cache survives a restart and stays within its bounds', async () = await createNominatimCache({ dir, now: time.now }).get('k0'), null, ); - assert.equal( - fs.statSync(dir).mode & 0o777, - 0o700, - 'the query cache directory is private', - ); + // The cache is private only through POSIX mode bits. Windows has none + // (directories stat as 0o666) and the cache keeps the checkout's ACLs, + // so these checks run, and privacy holds, only on POSIX systems. + const posixModes = process.platform !== 'win32'; + if (posixModes) { + assert.equal( + fs.statSync(dir).mode & 0o777, + 0o700, + 'the query cache directory is private (POSIX modes)', + ); + } const text = fs .readdirSync(dir) .filter((name) => name.endsWith('.json')) .map((name) => fs.readFileSync(path.join(dir, name), 'utf8')) .join('\n'); assert.equal(text.includes('k4'), false, 'disk entries never store raw keys'); - for (const name of fs - .readdirSync(dir) - .filter((entry) => entry.endsWith('.json'))) - assert.equal( - fs.statSync(path.join(dir, name)).mode & 0o777, - 0o600, - `${name} is private`, - ); + if (posixModes) { + for (const name of fs + .readdirSync(dir) + .filter((entry) => entry.endsWith('.json'))) { + assert.equal( + fs.statSync(path.join(dir, name)).mode & 0o777, + 0o600, + `${name} is private (POSIX modes)`, + ); + } + } fs.rmSync(dir, { recursive: true, force: true }); }); diff --git a/src/tooling/responseCompression.test.mjs b/src/tooling/responseCompression.test.mjs new file mode 100644 index 000000000..011d767c3 --- /dev/null +++ b/src/tooling/responseCompression.test.mjs @@ -0,0 +1,131 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { gunzipSync } from 'node:zlib'; +import { + acceptsGzip, + encodedBody, + gzipBody, +} from '../../server/providers/common/compress.js'; + +/** A request shaped like the one Node hands a middleware. */ +function request(acceptEncoding) { + return { + url: '/api/flights', + method: 'GET', + headers: + acceptEncoding === undefined ? {} : { 'accept-encoding': acceptEncoding }, + }; +} + +/** Repetitive JSON shaped like a snapshot body. */ +function snapshotBody(states = 200) { + return JSON.stringify({ + time: 1700000000, + states: Array.from({ length: states }, (_, index) => [ + `abc${index}`, + 'CALLSIGN', + 'United States', + 1700000000, + 30.1, + -97.7, + 10000, + ]), + }); +} + +/** Seeded high-entropy bytes, so the "gzip made it bigger" check is stable. */ +function incompressibleBytes(length) { + const bytes = Buffer.alloc(length); + let state = 123456789; + for (let index = 0; index < length; index += 1) { + state = (state * 1103515245 + 12345) % 2147483648; + bytes[index] = (state >>> 16) & 0xff; + } + return bytes; +} + +test('accept-encoding is read the way clients actually write it', () => { + assert.equal(acceptsGzip(request('gzip')), true); + assert.equal(acceptsGzip(request('gzip, deflate, br')), true); + assert.equal(acceptsGzip(request('br;q=1.0, gzip;q=0.8')), true); + assert.equal(acceptsGzip(request('GZIP')), true); + assert.equal(acceptsGzip(request(' gzip ')), true); + + assert.equal(acceptsGzip(request('br, deflate')), false); + assert.equal(acceptsGzip(request('identity')), false); + assert.equal(acceptsGzip(request('')), false); + assert.equal(acceptsGzip(request(undefined)), false); + assert.equal(acceptsGzip({}), false); + assert.equal(acceptsGzip(undefined), false); + + // `q=0` names gzip and refuses it (RFC 9110 section 12.4.2). + assert.equal(acceptsGzip(request('gzip;q=0')), false); + assert.equal(acceptsGzip(request('gzip;q=0.000')), false); + assert.equal(acceptsGzip(request('gzip;q=0.001')), true); + assert.equal(acceptsGzip(request('gzip;q=1')), true); + assert.equal(acceptsGzip(request('gzip;q=1.000')), true); + + // A q that is not a whole `qvalue` is unreadable and must not round into + // acceptance. + assert.equal(acceptsGzip(request('gzip;q=bogus')), false); + assert.equal(acceptsGzip(request('gzip;q=1oops')), false); + assert.equal(acceptsGzip(request('gzip;q=0.5abc')), false); + assert.equal(acceptsGzip(request('gzip;q=2')), false); + assert.equal(acceptsGzip(request('gzip;q=1.5')), false); + assert.equal(acceptsGzip(request('gzip;q=0.0001')), false); + assert.equal(acceptsGzip(request('gzip;q=')), false); + + // `gzip` is not matched as a substring of a neighbouring token. + assert.equal(acceptsGzip(request('x-gzip')), false); + assert.equal(acceptsGzip(request('gzipped')), false); +}); + +test('a body is only compressed when compression pays for itself', async () => { + assert.equal(await gzipBody(''), null); + assert.equal( + await gzipBody('{"states":[]}'), + null, + 'below the frame threshold', + ); + assert.equal(await gzipBody(Buffer.alloc(1023, 'a')), null, 'one byte short'); + + const identity = snapshotBody(); + assert.ok(identity.length > 1024, 'fixture is worth compressing'); + const compressed = await gzipBody(identity); + assert.ok(Buffer.isBuffer(compressed)); + assert.ok(compressed.byteLength < Buffer.byteLength(identity)); + assert.equal(gunzipSync(compressed).toString(), identity); + + // Buffers are accepted as-is; data gzip cannot shrink stays uncompressed. + assert.ok((await gzipBody(Buffer.from(identity))) !== null); + assert.equal(await gzipBody(incompressibleBytes(4096)), null); +}); + +test('the encoded body and the header that names it are chosen together', async () => { + const identity = snapshotBody(); + const compressed = await gzipBody(identity); + + const negotiated = encodedBody(request('gzip'), identity, compressed); + assert.equal(negotiated.body, compressed); + assert.equal(negotiated.headers['Content-Encoding'], 'gzip'); + assert.equal(negotiated.headers.Vary, 'Accept-Encoding'); + assert.equal(gunzipSync(negotiated.body).toString(), identity); + + // Offered but refused: identity bytes and no Content-Encoding. + const refused = encodedBody(request('br'), identity, compressed); + assert.equal(refused.body, identity); + assert.equal(refused.headers['Content-Encoding'], undefined); + assert.equal(refused.headers.Vary, 'Accept-Encoding'); + + // Wanted but not available: same answer. + const unavailable = encodedBody(request('gzip'), identity, null); + assert.equal(unavailable.body, identity); + assert.equal(unavailable.headers['Content-Encoding'], undefined); + + // Vary is on both branches, so a shared cache cannot serve the wrong encoding. + assert.equal(unavailable.headers.Vary, 'Accept-Encoding'); + assert.equal( + encodedBody(request(undefined), identity, compressed).headers.Vary, + 'Accept-Encoding', + ); +}); diff --git a/src/tools/queries/aviation.js b/src/tools/queries/aviation.js index 153e36e03..7c988c5ea 100644 --- a/src/tools/queries/aviation.js +++ b/src/tools/queries/aviation.js @@ -19,6 +19,7 @@ import { numericSummary, thinEvenly, } from '../results.js'; +import { roundHeadingDegrees } from '../../data/headingDegrees.js'; const ICAO24_SCHEMA = Object.freeze({ type: 'string', @@ -75,7 +76,7 @@ function aircraftRow(record, from) { altitude_m: round(record.baroAltitudeM ?? record.ellipsoidAltitudeM, 0), on_ground: record.onGround === true, speed_mps: round(record.speedMps, 1), - course_deg: round(record.courseDeg, 0), + course_deg: roundHeadingDegrees(record.courseDeg), vertical_rate_mps: round(record.verticalRateMps, 1), type_code: record.typeCode || null, registration: record.registration || null, diff --git a/src/tools/queries/aviation.test.mjs b/src/tools/queries/aviation.test.mjs index 484719790..59a928672 100644 --- a/src/tools/queries/aviation.test.mjs +++ b/src/tools/queries/aviation.test.mjs @@ -146,6 +146,19 @@ test('the military feed is used on request and reported when missing', async () ); }); +test('an aircraft course that rounds to 360 reads as 0', async () => { + const catalog = composeCatalog({ + tools: coreTools, + services: { + aircraft: feed([ + record('ddd444', 37.6, -122.3, { callsign: 'N360', courseDeg: 359.6 }), + ]), + }, + }); + const result = await catalog.call('find_aircraft', { callsign: 'N360' }); + assert.equal(result.data.rows[0].course_deg, 0); +}); + test('aircraft are found by exactly one identifier across feeds', async () => { const catalog = composeCatalog({ tools: coreTools, diff --git a/src/tools/queries/environment.js b/src/tools/queries/environment.js index 08db732fc..1f5800d53 100644 --- a/src/tools/queries/environment.js +++ b/src/tools/queries/environment.js @@ -71,6 +71,7 @@ function weatherRow(weather) { precipitation_mm: weather.precipitationMm ?? null, cloud_cover_pct: weather.cloudCoverPct ?? null, wind_kph: weather.windKph ?? null, + // Raw Open-Meteo value, so north reads 360. wind_direction_deg: weather.windDirectionDeg ?? null, visibility_m: weather.visibilityM ?? null, }; diff --git a/src/tools/queries/maritime.js b/src/tools/queries/maritime.js index 5798a0936..0e9d80373 100644 --- a/src/tools/queries/maritime.js +++ b/src/tools/queries/maritime.js @@ -20,6 +20,7 @@ import { numericSummary, thinEvenly, } from '../results.js'; +import { roundHeadingDegrees } from '../../data/headingDegrees.js'; import { normalizeVesselType } from '../../data/vesselTypes.js'; const MMSI_SCHEMA = Object.freeze({ @@ -57,8 +58,8 @@ function vesselRow(record, from) { lat: round(record.latitude, 5), lon: round(record.longitude, 5), speed_mps: round(record.speedMps, 1), - course_deg: round(record.courseDeg, 0), - heading_deg: round(record.headingDeg, 0), + course_deg: roundHeadingDegrees(record.courseDeg), + heading_deg: roundHeadingDegrees(record.headingDeg), observed_at: isoTime(record.observedAtMs), ...(from ? { distance_km: round(distanceKm(from, point), 1) } : {}), }; diff --git a/src/tools/queries/maritime.test.mjs b/src/tools/queries/maritime.test.mjs index d86bdf352..f89e6bce3 100644 --- a/src/tools/queries/maritime.test.mjs +++ b/src/tools/queries/maritime.test.mjs @@ -351,6 +351,25 @@ test('find_vessel answers carry normalized types and destinations', async () => ); }); +test('a vessel course or heading that rounds to 360 reads as 0', async () => { + const catalog = composeCatalog({ + tools: coreTools, + services: { + vessels: vessels({ + records: [ + vessel('366000003', 37.78, -122.4, { + courseDeg: 359.7, + headingDeg: 359.6, + }), + ], + }), + }, + }); + const [row] = (await catalog.call('find_vessel', { mmsi: '366000003' })).data + .rows; + assert.deepEqual([row.course_deg, row.heading_deg], [0, 0]); +}); + test('a destination with no letters counts as unknown', async () => { const catalog = composeCatalog({ tools: coreTools, diff --git a/src/tools/queries/media.js b/src/tools/queries/media.js index a03e88bb4..d154619db 100644 --- a/src/tools/queries/media.js +++ b/src/tools/queries/media.js @@ -10,6 +10,7 @@ import { resolveArea, } from '../area.js'; import { LIMIT_SCHEMA, capRows, countNoun, toBase64 } from '../results.js'; +import { roundHeadingDegrees } from '../../data/headingDegrees.js'; const FRAME_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']); const MAX_FRAME_BYTES = 3 * 1024 * 1024; @@ -85,7 +86,7 @@ export const findCctvCameras = defineTool({ provider: text(camera.provider), lat: round(camera.lat, 6), lon: round(camera.lon, 6), - heading_deg: round(camera.headingDeg, 0), + heading_deg: roundHeadingDegrees(camera.headingDeg), feed_type: text(camera.feedType), credit: text(camera.credit), distance_km: round(distanceKm(center, camera), 2), diff --git a/src/tools/queries/media.test.mjs b/src/tools/queries/media.test.mjs index 28c037a57..f690966c5 100644 --- a/src/tools/queries/media.test.mjs +++ b/src/tools/queries/media.test.mjs @@ -77,6 +77,20 @@ test('cameras in an area are listed nearest first', async () => { assert.equal(result.data.rows[1].credit, null); }); +test('a camera heading that rounds to 360 reads as 0', async () => { + const source = { + async getCatalog() { + return { sources: [{ ...cameras[0], headingDeg: 359.6 }] }; + }, + }; + const catalog = composeCatalog({ + tools: coreTools, + services: { cctv: source }, + }); + const result = await catalog.call('find_cctv_cameras', { area: downtown }); + assert.equal(result.data.rows[0].heading_deg, 0); +}); + test('a snapshot returns the frame as an image with its credit', async () => { const source = cctv(); const catalog = composeCatalog({ diff --git a/src/tools/queries/mobility.js b/src/tools/queries/mobility.js index 0a57ae3ae..c31f03863 100644 --- a/src/tools/queries/mobility.js +++ b/src/tools/queries/mobility.js @@ -19,6 +19,7 @@ import { resolveArea, } from '../area.js'; import { LIMIT_SCHEMA, capRows, countNoun, isoTime } from '../results.js'; +import { roundHeadingDegrees } from '../../data/headingDegrees.js'; const MAX_SYSTEMS = 3; @@ -244,7 +245,7 @@ export const getTransitVehicles = defineTool({ trip_id: vehicle.tripId ?? null, lat: vehicle.lat, lon: vehicle.lon, - bearing_deg: round(vehicle.bearing, 0), + bearing_deg: roundHeadingDegrees(vehicle.bearing), speed_mps: round(vehicle.speedMps, 1), status: vehicle.status ?? null, occupancy: vehicle.occupancy ?? null, diff --git a/src/tools/queries/mobility.test.mjs b/src/tools/queries/mobility.test.mjs index 0ea882727..aaaa97bad 100644 --- a/src/tools/queries/mobility.test.mjs +++ b/src/tools/queries/mobility.test.mjs @@ -311,6 +311,23 @@ test('transit vehicles come from nearby feeds and can be limited to a route', as assert.equal(rapid.data.rows[0].id, 'v2'); }); +test('a transit bearing that rounds to 360 reads as 0', async () => { + const north = [{ ...vehicles[0], bearing: 359.6 }]; + const catalog = composeCatalog({ + tools: coreTools, + services: { + transit: transit({ + ok: true, + status: 200, + json: async () => ({ vehicles: north }), + }), + clock, + }, + }); + const result = await catalog.call('get_transit_vehicles', { area: downtown }); + assert.equal(result.data.rows[0].bearing_deg, 0); +}); + test('transit reports uncovered areas and unavailable feeds', async () => { const catalog = composeCatalog({ tools: coreTools, diff --git a/src/tools/queries/space.js b/src/tools/queries/space.js index ef59c0be5..a589e801c 100644 --- a/src/tools/queries/space.js +++ b/src/tools/queries/space.js @@ -10,6 +10,7 @@ import { parseTleText, tleCatalogNumber } from '../../sources/tle.js'; import { POINT_SCHEMA, resolvePoint } from '../area.js'; import { defineTool, ToolError } from '../catalog.js'; import { LIMIT_SCHEMA, capRows, countNoun, isoTime } from '../results.js'; +import { roundHeadingDegrees } from '../../data/headingDegrees.js'; const text = (value) => (typeof value === 'string' && value ? value : null); @@ -241,7 +242,7 @@ export const nextSatellitePass = defineTool({ peak: isoTime(pass.maxElevMs), set: isoTime(pass.setMs), max_elevation_deg: round(pass.maxElevDeg, 1), - rise_azimuth_deg: round(pass.riseAzDeg, 0), + rise_azimuth_deg: roundHeadingDegrees(pass.riseAzDeg), rise_direction: compass(pass.riseAzDeg), visible: pass.visible, }, @@ -283,7 +284,7 @@ export const satellitesOverhead = defineTool({ name: entry.name, norad: entry.norad, elevation_deg: round(look.elevDeg, 1), - azimuth_deg: round(look.azDeg, 0), + azimuth_deg: roundHeadingDegrees(look.azDeg), direction: compass(look.azDeg), }, ] diff --git a/src/tools/queries/space.test.mjs b/src/tools/queries/space.test.mjs index 0eb5ce346..cb12dfcbe 100644 --- a/src/tools/queries/space.test.mjs +++ b/src/tools/queries/space.test.mjs @@ -2,6 +2,10 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { twoline2satrec } from 'satellite.js'; import { composeCatalog, coreTools } from '../index.js'; +import { + findNextSatellitePass, + lookAnglesAt, +} from '../../data/satellitePass.js'; import { noradIdFromSatnum, parseTleText, @@ -296,6 +300,37 @@ test('satellites overhead are those above the elevation, highest first', async ( ); }); +test('satellite azimuths that round to 360 read as 0', async () => { + const catalog = composeCatalog({ + tools: coreTools, + services: { satellites: satellites(), clock: CLOCK }, + }); + // At these points the ISS's raw azimuths fall just below 360°; check that + // first so the test cannot pass on a raw value near 0. + const [, line1, line2] = TLE.split('\n'); + const iss = twoline2satrec(line1, line2); + const nearNorth = (deg) => deg > 359.5 && deg < 360; + const rawPass = findNextSatellitePass({ + satrec: iss, + latDeg: 10, + lonDeg: 30, + fromMs: CLOCK.now(), + }); + assert.ok(nearNorth(rawPass.riseAzDeg), `rise ${rawPass.riseAzDeg}°`); + const rawLook = lookAnglesAt(iss, CLOCK.now(), 39.25, 65.75); + assert.ok(nearNorth(rawLook.azDeg), `azimuth ${rawLook.azDeg}°`); + const pass = await catalog.call('next_satellite_pass', { + location: { lat: 10, lon: 30 }, + }); + assert.equal(pass.data.pass.rise_azimuth_deg, 0); + assert.equal(pass.data.pass.rise_direction, 'N'); + const overhead = await catalog.call('satellites_overhead', { + location: { lat: 39.25, lon: 65.75 }, + }); + assert.equal(overhead.data.rows[0].name, 'ISS (ZARYA)'); + assert.equal(overhead.data.rows[0].azimuth_deg, 0); +}); + test('stale orbit and launch data are reported', async () => { const stale = { readGroup: async () => ({ ok: true, status: 200, text: TLE, stale: true }),