diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 016576bf8..b753f8fef 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -40,7 +40,7 @@ body: attributes: label: OS, Node version, browser description: Include the versions you can find. Write "unknown" for anything unavailable. - placeholder: "Windows 11, Node 24.14.0, Chrome 151" + placeholder: "Windows 11, Node 24.22.2, Chrome 151" validations: required: true - type: input diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ce3563685..63d1eb29b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,10 +20,10 @@ jobs: strategy: fail-fast: false matrix: - node: [24.14.0, 26.x] + node: [24.22.2, 26.x] env: PUPPETEER_SKIP_DOWNLOAD: '1' - GEV_REQUIRE_ALLOCATION_GATE: ${{ matrix.node == '24.14.0' && '1' || '0' }} + GEV_REQUIRE_ALLOCATION_GATE: ${{ matrix.node == '24.22.2' && '1' || '0' }} steps: - name: Check out source uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 @@ -76,7 +76,7 @@ jobs: - name: Use Node.js uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: - node-version: 24.14.0 + node-version: 24.22.2 cache: npm - name: Install locked dependencies and headless Chrome @@ -131,7 +131,7 @@ jobs: - name: Use Node.js uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: - node-version: 24.14.0 + node-version: 24.22.2 cache: npm - name: Run the Pinokio install path diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d8e569d3a..f2c78f4b4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,13 +4,15 @@ Thanks for being here. God's Eye View is an open foundation for live spatial int ## Getting set up -Use Node.js 24.14.x or 26.x (also enforced by `package.json`). +Use Node.js 24 (24.14.0 or later) or 26.x (also enforced by `package.json`). +Prefer 24.22.1 or later: earlier releases lack the October 21, 2026 security +fixes, including CVE-2026-111497 (a Brotli decoder flaw). ```bash git clone https://github.com/bilawalsidhu/gods-eye-view.git cd gods-eye-view -nvm install 24.14.0 -nvm use 24.14.0 +nvm install 24.22.2 +nvm use 24.22.2 npm install npm run doctor ./scripts/dev-fresh.sh # or: npm run dev (keys are optional) @@ -27,6 +29,25 @@ through Pinokio; the terminal path above remains the contributor path. Open `http://localhost:4173`. Before sending a PR run `npm run build`, `npm test`, and `npm run test:track` (dev server must be up) — **all three must stay green.** +### Test prerequisites + +- **`npm test`** — unit tests. Runs standalone; no dev server or credentials + required. Exit code is the gate. +- **`npm run test:track`** — the 3D flight-tracking regression harness. It + drives the **real app** in headless Chromium, so it **requires a running + dev server** (default `http://localhost:4173`). To target another instance, + run `npm run test:track -- --url ` (or + `node scripts/track-regression.mjs --url `). It does not depend on + live OpenSky / adsb.lol / AISStream (those are optional or rate-limited + locally); it installs a persistent `fetch` shim that returns synthetic + aircraft in the exact upstream payload shapes the layers parse. +- **`npm run build`** — production Vite build. No server or credentials + required; it validates that the client bundle compiles cleanly. + +Each feature also has its own headless gate; see +[Feature regression gates](#feature-regression-gates) below. Docs and changelog +rules are under [Pull requests](#pull-requests). + ## Checking a built app locally Run `npm run build` followed by `npm run preview`. Preview serves the built diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 85fd7cabb..8bed93755 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -307,6 +307,15 @@ the retrieval date (2026-07-30), exact download URL, license evidence, and the deterministic transform (`scripts/build-sf-neighborhoods.mjs`: `nhood` → `name`, ~2 m Douglas-Peucker simplification, 6-decimal rounding). +### Local CCTV source packs (`config/`) + +The server can read a local camera list from `CCTV_SOURCES_FILE` (default +`config/cctv_sources.austin.json`, which ships empty) and inline JSON from +`CCTV_SOURCES_JSON`; the two are merged, and inline entries win on a duplicate +id. A non-empty local pack replaces the live open-data camera packs unless +`CCTV_FORCE_AUSTIN=1` is set. The packs under `config/` show the schema, and a +pack's cameras keep their own source's license and attribution. + --- ## Weather sources diff --git a/Dockerfile b/Dockerfile index 433d170a8..a9b8aebd9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,7 @@ # proxies (ADS-B, AIS, Overpass, OpenAI Realtime, ...) as middleware, so there # is nothing else to containerise. See compose.yaml for the usual entry point. # Same Node as CI (package.json engines, .github/workflows), pinned by digest. -FROM node:24.14.0-slim@sha256:d8e448a56fc63242f70026718378bd4b00f8c82e78d20eefb199224a4d8e33d8 +FROM node:24.22.2-slim@sha256:c86ecead9e55a5210ac1727de38a4ca840bf03dcaf34dc9749addbf383b1920c # Puppeteer is only used by the QA scripts; skip its ~150MB Chromium download. ENV PUPPETEER_SKIP_DOWNLOAD=1 diff --git a/README.md b/README.md index 3a4c5fc43..5baba4ad4 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ Start with the included data sources, then add your own. Each layer is a separat ## ⚡ Quick Start -**Start without an account or API keys.** Both paths open the same app with +**Start without an account or API keys.** Every path opens the same app with Esri satellite imagery and keyless terrain. OSM is the fallback if Esri is unreachable. Flights, military traffic, satellites, earthquakes, public cameras, radio, and launches are available without keys. @@ -129,8 +129,9 @@ hardware; it does not establish a minimum GPU requirement. ### Path 2 — Terminal / coding agent -Use **Node.js 24.x (24.14.0 or later) or 26.x**. The setup doctor warns about -Node 25, which is end-of-life. +Use **Node.js 24.x (24.14.0 or later) or 26.x**; 24.22.1 or later (26.12.1 or +later on 26) includes the October 21, 2026 security fixes, among them +CVE-2026-111497. The setup doctor warns about Node 25, which is end-of-life. ```bash git clone https://github.com/bilawalsidhu/gods-eye-view.git @@ -233,6 +234,41 @@ the container. +### Path 4 — GitHub Codespaces (cloud, no local install) + +Runs the same dev server in a cloud workspace, so Node and the dependencies +never touch your machine. The globe still renders in your own browser, so it +needs the same graphics there as any other path. GitHub Free includes 120 +core-hours a month with no card on file — about 60 hours on the default 2-core +machine. Org and Enterprise plans include no free quota; current terms and +quotas apply. + +1. Fork this repository, then open **your fork** on GitHub. +2. Click the green **Code** button, the **Codespaces** tab, then **Create codespace on main**. +3. In the codespace terminal: + ```bash + nvm install 24 && nvm use 24 + npm ci + npm run doctor + GEV_ALLOWED_HOSTS="$CODESPACE_NAME-4173.$GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN" npm run dev + ``` +4. Open the **Ports** tab, find `4173`, and click the globe icon to open it in + the browser. The port is private by default, so the URL asks for your GitHub + login. Keep it private: a public port lets anyone use the app and the keys + in your `.env`. + +`GEV_ALLOWED_HOSTS` there is required, not optional. The dev server answers only +the hostnames it trusts, and a forwarded port arrives on +`$CODESPACE_NAME-4173.$GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN`. Without it the +page is `Blocked request. This host is not allowed.` Leading-dot wildcards are +ignored on purpose, so pass that exact name. + +⚠️ **A codespace is reachable from the internet.** Treat it as the +wider-publish case in [Sharing an instance](#-sharing-an-instance). Provider +Settings does not open on the forwarded URL, so put keys in the workspace +`.env` by hand. `.env` is gitignored — never commit it. [SECURITY.md](SECURITY.md) has the +threat model. + ### Then power it up — in the app, not in a file Keys are upgrades, not prerequisites. When you want one, click the **POWER UP** diff --git a/changelog.d/1093-multipart-cable-feature-ownership.md b/changelog.d/1093-multipart-cable-feature-ownership.md new file mode 100644 index 000000000..777a159d0 --- /dev/null +++ b/changelog.d/1093-multipart-cable-feature-ownership.md @@ -0,0 +1,4 @@ +- Keep every submarine-cable segment associated with its source feature after + Cesium expands multipart geometry, correcting segment colors, click destinations + and horizon culling while retaining one reference stem per feature + (@Aliciawque, #1093). diff --git a/changelog.d/1427-military-installations-cross-site-gate.md b/changelog.d/1427-military-installations-cross-site-gate.md new file mode 100644 index 000000000..35d9e73e7 --- /dev/null +++ b/changelog.d/1427-military-installations-cross-site-gate.md @@ -0,0 +1,4 @@ +- The military-installations proxy (`/api/military-installations`) now refuses + cross-site and reverse-proxy-forwarded browser requests with 403 before any + Overpass work, through the same shared gate as `/api/overpass`. Same-origin + app requests and local tools such as curl still work (@Sunil56224972, #1427). diff --git a/changelog.d/1433-awareness-marker-kilometre.md b/changelog.d/1433-awareness-marker-kilometre.md new file mode 100644 index 000000000..a62cdd90c --- /dev/null +++ b/changelog.d/1433-awareness-marker-kilometre.md @@ -0,0 +1,6 @@ +- Show a Contacts marker 999.5 m or more away as 1.0 km. The on-globe marker + chose metres before rounding, so 999.6 m read "1000 m" while the panel read + "1.0 km" (Aditya, @Rohilalala, #1433). +- Distance labels share one formatter that takes each unit cut after rounding, + so the Contacts panel and markers, the drawn-line measure and the Cockpit + show 9.96 km as 10 km rather than 10.0 km (#1433). diff --git a/changelog.d/1434-voice-unknown-altitude.md b/changelog.d/1434-voice-unknown-altitude.md new file mode 100644 index 000000000..229d31800 --- /dev/null +++ b/changelog.d/1434-voice-unknown-altitude.md @@ -0,0 +1,3 @@ +- Selecting the nearest aircraft by voice no longer says "0 feet" for a contact + whose feed reported no altitude; the reply and card leave the altitude out + (Aditya, @Rohilalala, #1434). diff --git a/changelog.d/1436-military-geom-vertical-rate.md b/changelog.d/1436-military-geom-vertical-rate.md new file mode 100644 index 000000000..ddeb5b4aa --- /dev/null +++ b/changelog.d/1436-military-geom-vertical-rate.md @@ -0,0 +1,3 @@ +- Military aircraft whose transponders report a GNSS vertical rate now carry it + in analyst and tracked-aircraft data instead of null (Aditya, @Rohilalala, + #1436). diff --git a/changelog.d/1442-traffic-coverage-edges.md b/changelog.d/1442-traffic-coverage-edges.md new file mode 100644 index 000000000..63890cc35 --- /dev/null +++ b/changelog.d/1442-traffic-coverage-edges.md @@ -0,0 +1,4 @@ +- Keep Street Traffic's flow status honest at the edges. A few TomTom-matched + dots among hundreds rounded to 0% and read "(no matches)", and a few + simulated dots rounded to "100% cov"; coverage now stays within 1–99% while + both are shown (Aditya, @Rohilalala, #1442). diff --git a/changelog.d/1443-replay-countdown-ten.md b/changelog.d/1443-replay-countdown-ten.md new file mode 100644 index 000000000..4780f26bf --- /dev/null +++ b/changelog.d/1443-replay-countdown-ten.md @@ -0,0 +1,3 @@ +- Run Space Missions' Replay Ascent countdown from T−10 after the five-second + tile hold; the hold ran 10 s and the countdown started at T−05 (Aditya, + @Rohilalala, #1443). diff --git a/changelog.d/1444-replay-speed-countdown.md b/changelog.d/1444-replay-speed-countdown.md new file mode 100644 index 000000000..fb35d008b --- /dev/null +++ b/changelog.d/1444-replay-speed-countdown.md @@ -0,0 +1,3 @@ +- Keep Space Missions' replay countdown in real time when the replay speed + changes before liftoff; 0.25× had stretched T−8 to T−32 (Aditya, + @Rohilalala, #1444). diff --git a/changelog.d/1445-annotation-label-units.md b/changelog.d/1445-annotation-label-units.md new file mode 100644 index 000000000..494c663db --- /dev/null +++ b/changelog.d/1445-annotation-label-units.md @@ -0,0 +1,3 @@ +- Label a route, arrow or drawn line of 995 m or more as 1.0 km, and of + 9,950 m or more as 10 km. The placed label picked the unit before rounding, + so it read "1000 m" and "10.0 km" (Aditya, @Rohilalala, #1445). diff --git a/changelog.d/1447-bikeshare-derived-capacity.md b/changelog.d/1447-bikeshare-derived-capacity.md new file mode 100644 index 000000000..c241b58f7 --- /dev/null +++ b/changelog.d/1447-bikeshare-derived-capacity.md @@ -0,0 +1,3 @@ +- Bike Share stations whose feed publishes no capacity now take it from each + status poll's bikes and docks instead of keeping the first poll's value or + the placeholder 15 (Aditya, @Rohilalala, #1447). diff --git a/changelog.d/1448-local-adsb-geom-rate.md b/changelog.d/1448-local-adsb-geom-rate.md new file mode 100644 index 000000000..a4a0c54f8 --- /dev/null +++ b/changelog.d/1448-local-adsb-geom-rate.md @@ -0,0 +1,6 @@ +- Local ADS-B decoder feeds now carry a GNSS-sourced vertical rate + (`geom_rate`) when no barometric rate is sent, so the card shows V/S + instead of a dash (Aditya, @Rohilalala, #1448). +- Local ADS-B, military and the adsb.lol flight fallback read the vertical + rate through one shared readsb field reader, so all three prefer the + barometric rate and fall back to the GNSS rate the same way (#1448). diff --git a/changelog.d/1449-cable-tool-duplicates.md b/changelog.d/1449-cable-tool-duplicates.md new file mode 100644 index 000000000..e3cc8df96 --- /dev/null +++ b/changelog.d/1449-cable-tool-duplicates.md @@ -0,0 +1,3 @@ +- List each submarine cable once in find_submarine_cables. TeleGeography + splits 17 cables (2Africa, Echo, Hawaiki) into several features, so a + search for Echo read "3 submarine cables" (Aditya, @Rohilalala, #1449). diff --git a/changelog.d/1450-directions-ten-km.md b/changelog.d/1450-directions-ten-km.md new file mode 100644 index 000000000..1b348493e --- /dev/null +++ b/changelog.d/1450-directions-ten-km.md @@ -0,0 +1,3 @@ +- Show a route of 9.95 km or more as 10 km. The Directions row and step + cards kept one decimal until the raw distance reached 10 km, so 9.96 km + read "10.0 km" (Aditya, @Rohilalala, #1450). diff --git a/changelog.d/1453-satellite-launch-year.md b/changelog.d/1453-satellite-launch-year.md new file mode 100644 index 000000000..6b51770c5 --- /dev/null +++ b/changelog.d/1453-satellite-launch-year.md @@ -0,0 +1,6 @@ +- Space Missions no longer shows an older satellite with a similar name and a + different known launch year as a new payload's orbit: the launch-year check + on loaded satellites now runs (Aditya, @Rohilalala, #1453). +- Satellite launch years come from one TLE designator parser for both the + loaded catalog and the active-TLE fallback; a blank designator is an + unknown year instead of 2000 (#1453). diff --git a/changelog.d/1454-voice-context-heading-north.md b/changelog.d/1454-voice-context-heading-north.md new file mode 100644 index 000000000..5653c4785 --- /dev/null +++ b/changelog.d/1454-voice-context-heading-north.md @@ -0,0 +1,5 @@ +- Describe a selected flight or military contact on 359.5° or more to the + voice assistant as heading 0°, not 360° (Aditya, @Rohilalala, #1454). +- Heading and bearing readouts share one rounding helper, so the Cockpit's + TRACK ACQUIRED course no longer reads 360°, and a negative bearing wraps + instead of printing a minus sign (#1454). diff --git a/changelog.d/1457-dock-icon-only-wings.md b/changelog.d/1457-dock-icon-only-wings.md new file mode 100644 index 000000000..3d82cb934 --- /dev/null +++ b/changelog.d/1457-dock-icon-only-wings.md @@ -0,0 +1,5 @@ +- Between 721px and 900px wide (tablet portrait, phone landscape), the + Location and Visual Presets dock wings show only their icons, so their + labels no longer spill over the voice control or take taps meant for the + mic button. The full name stays in the tooltip and accessible name, and the + Cyber layout keeps its labels (Denis Groshev, @ZloyS, #1457). diff --git a/changelog.d/1458-node-24-22-1-security-release.md b/changelog.d/1458-node-24-22-1-security-release.md new file mode 100644 index 000000000..d379a26f1 --- /dev/null +++ b/changelog.d/1458-node-24-22-1-security-release.md @@ -0,0 +1,4 @@ +- The Docker image and CI now pin Node 24.22.2, the current 24 LTS patch. It + carries the October 21, 2026 security fixes first released in 24.22.1, + including CVE-2026-111497 in the Brotli decoder (Luis Ortiz, @anobjectn, + #1458). diff --git a/changelog.d/1459-hud-place-lookups.md b/changelog.d/1459-hud-place-lookups.md index b0ff71f09..023e2f2e6 100644 --- a/changelog.d/1459-hud-place-lookups.md +++ b/changelog.d/1459-hud-place-lookups.md @@ -2,4 +2,4 @@ looks up place names when no OpenAI key is configured, reverse-geocodes at most two points instead of up to four, and looks up again only once the view shows a different area, not after every small pan, so a small pan no longer - asks OpenAI for a new summary either (samehkhamis, #0000). + asks OpenAI for a new summary either (samehkhamis, #1459). diff --git a/changelog.d/1462-voice-alias-hint-budget.md b/changelog.d/1462-voice-alias-hint-budget.md new file mode 100644 index 000000000..ae0b1be38 --- /dev/null +++ b/changelog.d/1462-voice-alias-hint-budget.md @@ -0,0 +1,5 @@ +- Leave layer aliases the layer id already spells, such as "dams" for + `local-dams`, out of the voice layer hint. The Realtime prompt is 305 + characters shorter, leaving room under the 52,000 gate for new + voice-toggleable layers; every alias is still accepted (@quanticsoul4772, + #1462). diff --git a/changelog.d/1501-rail-pills.md b/changelog.d/1501-rail-pills.md new file mode 100644 index 000000000..61d23a38b --- /dev/null +++ b/changelog.d/1501-rail-pills.md @@ -0,0 +1,3 @@ +- The side panel rails show each panel as a labelled pill while no panel on + that side is open, and fold to icons once one opens. `UiRail` takes + `collapsible` for this (samehkhamis, #1501). diff --git a/changelog.d/210-contributor-docs-refresh.md b/changelog.d/210-contributor-docs-refresh.md new file mode 100644 index 000000000..ed6dbe354 --- /dev/null +++ b/changelog.d/210-contributor-docs-refresh.md @@ -0,0 +1,4 @@ +- Contributor docs: CONTRIBUTING lists what each of the three baseline checks + needs to run, DATA_SOURCES documents loading a local CCTV source pack, and + KNOWN-ISSUES points the floor-placement note at its verification script + instead of a missing report (Gaurav Patidar, @gaurav7902, #210). diff --git a/changelog.d/980-codespaces-setup.md b/changelog.d/980-codespaces-setup.md new file mode 100644 index 000000000..57acd7094 --- /dev/null +++ b/changelog.d/980-codespaces-setup.md @@ -0,0 +1,3 @@ +- Document GitHub Codespaces as a fourth setup path in the README: a cloud + workspace runs the dev server while the globe renders in your own browser + (John Leshan, @johnleshan, #980). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 438c69c43..0583990a7 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -197,6 +197,9 @@ catalog, loaded on first use. See [tools and the MCP server](TOOLS.md). Aircraft altitude-band queries require a reported barometric or geometric altitude for each match, so a grounded contact that reports none is left out of a band too. Queries without an altitude band retain unknown-altitude contacts. +`find_submarine_cables` lists and counts each cable once: TeleGeography splits +a few cables (2Africa, Echo) into several features under one id, and such a +cable matches when any of its parts crosses the area. Catalog calls check their cancellation signal immediately before executing a tool. Already-cancelled queries do not start; cancellation after execution starts @@ -1135,7 +1138,8 @@ The tracked-aircraft descriptor both flight layers hand to the cockpit carries `verticalRate` and military records as `verticalRateMps`; the query seam maps both to one name, so `resolveTrackedAircraftInfo` returns a single shape for either layer. The rate is reported, never derived: a blank poll keeps the last transmitted value and an -absent rate stays null rather than reading as level flight. +absent rate stays null rather than reading as level flight. A military readsb row +without `baro_rate` reports its GNSS rate as `geom_rate`, which is used instead. ## Transponder emergency highlighting @@ -1582,6 +1586,10 @@ Imagery (while shown), Street Level and Context on the right. Selecting an item opens its panel through `setPanelCollapsed(id, false, {explicit: true})`; selecting the open panel's item closes it. The item of the open panel is pressed (`aria-pressed`), and its tooltip is suppressed while the panel is open. +While no panel on its side is open, the rail shows each item as a labelled pill +(icon and title, `UiRail`'s `collapsible`); opening a panel folds the labels away +to the icon rail. The rail is placed by its screen-side edge, so the pills grow +toward the middle of the screen, over the closed side's empty corridor. One panel is open per desktop side, in every HUD layout. Opening a side panel by any route (rail, header, keyboard, voice) collapses its peers; restored or @@ -1890,6 +1898,8 @@ limit, as well as when a tile decodes or the source resets. GBFS null or empty availability counts retain an unknown value through station parsing and rendering; they use the neutral station color rather than reporting an empty dock. Explicit zero and caller-supplied numeric fallbacks remain valid. +A station whose `station_information` publishes no capacity takes bikes plus +docks from each status poll (15 until its status arrives), not from the first. ## Terrain height cache bound @@ -2786,6 +2796,20 @@ isCurrent })` gates BOTH halves of the map-stack switch, which is its only > name the panel (`#location-bar` / `#control-panel`) to reach (2,5,0). Adding > a new tray rule means checking it against the pinned variants, not just > against the base rule. +> - **Dock wings are icon-only from 900px down to 721px (2026-10-28):** at +> ≤900px `--dock-side-compact` shrinks the Location and Visual Presets wings +> to 4.25rem, open or closed (an open tray pops out above the dock), which is +> too narrow for their text labels. The labels used to spill over the voice +> control, and the LOCATION label captured taps on the left half of the mic +> button. `src/ui/styles/command-dock-trays.css` now sets the +> labels to `font-size: 0` and drops their `›`/`‹` chevrons in that band, so +> each wing shows only its icon. `CommandDockView.vue` still gives the toggles +> their "Expand/Collapse …" `aria-label` and `title` (`trayWords`), so the +> browser tooltip is the only visible name on hover. +> The Cyber HUD layout is excluded: it sets `--dock-side-compact` to +> 9–9.75rem, which fits its labels. The former ≤620px ellipsis clip is gone: +> the phone shell takes over at 720px, so it never applied to a visible +> dock. > - **LOCATION mini-status is data-only today (2026-08-20):** the collapsed > readout now follows a free-text geocode search as well as preset pills > (`src/locationStatus.js` owns the copy for both), and every other camera @@ -3425,7 +3449,8 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with > style-switch persistence state. Browser/GPU performance comparison remains > operator-side; the final accounting records only comparable population > reductions and deterministic Node allocation/solve measurements. Those -> allocation gates are calibrated on Node.js 24.14.x; `package.json` permits +> allocation gates were calibrated on Node.js 24.14.x and run in CI on the +> pinned Node.js 24.22.2 with the same ceilings; `package.json` permits > supported product runtimes on Node 24 or 26, while the allocation runner > separately enforces Node 24 for these two calibrated probes. Each probe > compiles synchronously and discards explicit-GC @@ -4315,7 +4340,9 @@ animate). Disable clears all state. At the metre/kilometre boundary, route distance labels use the rounded ten-metre value to select the unit, switching to kilometres at 995 metres; -a 996-metre leg displays as `1.0 km` rather than `1000 m`. +a 996-metre leg displays as `1.0 km` rather than `1000 m`. Whole kilometres +start where the label would round to 10.0 km, so a 9.96 km route displays as +`10 km` rather than `10.0 km`. **Keys needed: none.** Directions works on a keyless boot, and the layer is off until the operator turns it on. @@ -4608,8 +4635,21 @@ silent for five minutes are removed. Credits appear when the feed is used. coordinator, but it is not a user-visible Data Layers entry. Its visible entry point is the right-side `CONTEXT` chooser's `CONTACTS` mode. -Contacts distance labels select metres or kilometres after rounding to the -nearest metre, so a 999.5-metre contact displays as `1.0 km`, not `1000 m`. +Contacts distance labels, in the panel and on the globe's contact markers, +select metres or kilometres after rounding to the nearest metre, so a +999.5-metre contact displays as `1.0 km`, not `1000 m`, and drop the +kilometre decimal from 9.95 km, so 9,960 m displays as `10 km`. + +Distance labels that switch between metres and kilometres share +`distanceParts` / `formatDistanceText` in `src/data/distanceFormat.js`: the +Contacts panel and markers, Directions rows and step cards, placed annotation +labels, the drawn-line measure and `formatRouteDistance`. Each cut is taken on +the value as it prints, and kilometres come back already rounded, so the +plain and localized labels agree. Callers choose the metre step (whole metres; +tens, as annotation labels use; or tens with whole metres under 100, as +Directions uses) and keep their own rendering; the Cockpit's kilometre-only +distance uses `kilometreDigits`. The HUD altitude tag and the spoken view +height still choose their own unit. At global scale, ambient Radio cluster badges are hard-opacity shared-host entries: count/category updates and identity replacement do not run keyhole or @@ -5036,7 +5076,7 @@ silently demoting every later lookup for the session. protected-card path. Disable tears the tier down completely. Coverage/viewshed semantics unchanged. - **Satellites**: orbit rings rotate about Earth's Z by ΔGMST every ~1s (exact inertial→ECEF compensation; no SGP4 re-runs); the tracked satellite propagates per frame with one shared epoch for dot/label/camera. Verified: ISS holds <1km perpendicular to its ring while tracked. Pass prediction (`src/data/satellitePass.js`; `getNextSatellitePass`, `getNextIssPass`) scans at 20s, bisects rise/set to ~0.2s, fits the peak with a parabola, and marks a pass visible when the satellite is outside a cylindrical Earth shadow while the observer's Sun is at or below −6°. `next_iss_pass` preserves its geometric-pass ordering and appends visibility metadata. General `next_satellite_pass` accepts a loaded NORAD ID or unambiguous name, searches 24 hours and optionally requires estimated visibility. These estimates do not account for weather or satellite brightness. -- **Space Missions (30d)**: recent launches render as bounded shared-host, horizon-occluded mission markers using Launch Library 2 v2.3 detailed records. Enabling the layer selects the unified right-side Context panel's Space Missions mode and enables the required satellite layer. Before applying its temporary dense/hidden Satellite mode, Space Missions snapshots the complete standalone Satellite parameter set and exact enabled-layer set. Disabling Space Missions from either Context or the left Data Layers rail restores those parameters and the exact prior enabled state, so a Satellite layer that was already on stays on while a mode-owned dependency returns off; enabling Satellites by itself remains independent. Selecting a mission isolates its launch-to-orbit transfer and dashed satellite orbit, fills that same panel with navigation/details, and animates a small phase-colored marker along the exact displayed Cartesian samples. Marker hit testing drill-picks through photorealistic tiles so the depth-test-free tactical dot remains reliably selectable; its text is non-interactive shared-host presentation. The selected pad is the camera's zoom pivot: the overview remains centered on its launch site, wheel zoom approaches that site instead of drifting elsewhere, and camera pitch progressively changes from global nadir to an oblique close 3D view. Its protected shared-host callout remains visible and expands to include both mission and launch-site names. `FOCUS` flies directly to a 12 km oblique frame around the selected launch site and retains the same anchored zoom/orbit behavior. `REPLAY ASCENT` resets the selected marker at the pad, frames it from an oblique third-person angle, and follows it through the mission-specific compressed ascent directly into one orbital lap at the default `1×` rate. A live `0.25×`–`4×` slider changes ascent and orbit playback speed; adjusting it mid-replay preserves the current path position and historical mission timestamp. Re-entry/recovery cannot be inserted into replay. At orbit insertion the camera smoothly pulls back over the first fifth of the orbital replay and pitches to a globe-scale nadir view while continuing to target the moving replay point. Replay Cancel, mission navigation, deselection, layer disable, and data refresh all release camera ownership. Reconstructed paths are one continuous 128-sample geodetic curve: horizontal departure begins near zero while altitude rises quickly, then the climb progressively bends toward insertion without the former hard 120 km corner. Unmatched orbit fallbacks are smooth planar inclined rings rather than longitude/latitude ground-track curves. The panel lists disclosed payload names, types, operators/manufacturers, mass, multiplicity, and destination when supplied; an empty LL2 payload collection is shown as `CLASSIFIED / MULTI-PAYLOAD`. Launcher, spacecraft, and recoverable payload stages appear in a compact stage table with serial/flight/reuse details, recovery outcome/type, destination, and final coordinates when those records exist; an empty recovery collection omits the section. Stage recoveries with confirmed coordinates use those coordinates; return-to-launch-site records use the pad; downrange-only records receive an explicitly labeled estimated endpoint along the ascent azimuth. Available endpoints render as static 2 px dashed descent/recovery paths with a fixed final-position dot and an estimated atmospheric-interface segment when applicable. The ascent is geodetically densified above the ellipsoid toward the orbit's nearest insertion point, then rendered with `ArcType.NONE`, so it neither cuts through Earth nor separates from the marker. Because LL2 does not normally supply continuous ascent telemetry, pad-to-insertion paths without upstream trajectory samples are labeled `RECONSTRUCTED ESTIMATE` / `ASCENT ESTIMATE`; only supplied trajectory samples receive the replay wording. Selected-orbit framing fits the complete ring, rear-side linework uses normal scene depth occlusion, and current distance, speed, and callout data come only from a launch-year-validated satellite match. Speed is the magnitude of the SGP4 inertial velocity vector at the same propagation epoch as position, displayed in km/s with km/h available as hover detail. Unavailable operator, site, launch-time, orbit, current-altitude, and speed values omit their detail rows instead of reserving panel space with placeholders. Newly launched payloads absent from the core operational groups use CelesTrak's cached active TLE feed as a lookup-only fallback; weak constellation-name matches are rejected. The replay callout maps compressed animation progress onto Launch Library's mission-relative timeline, showing the historical UTC date/time at the marker's current path position; unavailable timelines remain explicit. Matched live satellite positions propagate at one-second cadence and use a distinct green dot/callout with the current UTC date/time. +- **Space Missions (30d)**: recent launches render as bounded shared-host, horizon-occluded mission markers using Launch Library 2 v2.3 detailed records. Enabling the layer selects the unified right-side Context panel's Space Missions mode and enables the required satellite layer. Before applying its temporary dense/hidden Satellite mode, Space Missions snapshots the complete standalone Satellite parameter set and exact enabled-layer set. Disabling Space Missions from either Context or the left Data Layers rail restores those parameters and the exact prior enabled state, so a Satellite layer that was already on stays on while a mode-owned dependency returns off; enabling Satellites by itself remains independent. Selecting a mission isolates its launch-to-orbit transfer and dashed satellite orbit, fills that same panel with navigation/details, and animates a small phase-colored marker along the exact displayed Cartesian samples. Marker hit testing drill-picks through photorealistic tiles so the depth-test-free tactical dot remains reliably selectable; its text is non-interactive shared-host presentation. The selected pad is the camera's zoom pivot: the overview remains centered on its launch site, wheel zoom approaches that site instead of drifting elsewhere, and camera pitch progressively changes from global nadir to an oblique close 3D view. Its protected shared-host callout remains visible and expands to include both mission and launch-site names. `FOCUS` flies directly to a 12 km oblique frame around the selected launch site and retains the same anchored zoom/orbit behavior. `REPLAY ASCENT` resets the selected marker at the pad, frames it from an oblique third-person angle, and follows it through the mission-specific compressed ascent directly into one orbital lap at the default `1×` rate. A live `0.25×`–`4×` slider changes ascent and orbit playback speed; adjusting it mid-replay preserves the current path position and historical mission timestamp. Re-entry/recovery cannot be inserted into replay. At orbit insertion the camera smoothly pulls back over the first fifth of the orbital replay and pitches to a globe-scale nadir view while continuing to target the moving replay point. Replay Cancel, mission navigation, deselection, layer disable, and data refresh all release camera ownership. Reconstructed paths are one continuous 128-sample geodetic curve: horizontal departure begins near zero while altitude rises quickly, then the climb progressively bends toward insertion without the former hard 120 km corner. Unmatched orbit fallbacks are smooth planar inclined rings rather than longitude/latitude ground-track curves. The panel lists disclosed payload names, types, operators/manufacturers, mass, multiplicity, and destination when supplied; an empty LL2 payload collection is shown as `CLASSIFIED / MULTI-PAYLOAD`. Launcher, spacecraft, and recoverable payload stages appear in a compact stage table with serial/flight/reuse details, recovery outcome/type, destination, and final coordinates when those records exist; an empty recovery collection omits the section. Stage recoveries with confirmed coordinates use those coordinates; return-to-launch-site records use the pad; downrange-only records receive an explicitly labeled estimated endpoint along the ascent azimuth. Available endpoints render as static 2 px dashed descent/recovery paths with a fixed final-position dot and an estimated atmospheric-interface segment when applicable. The ascent is geodetically densified above the ellipsoid toward the orbit's nearest insertion point, then rendered with `ArcType.NONE`, so it neither cuts through Earth nor separates from the marker. Because LL2 does not normally supply continuous ascent telemetry, pad-to-insertion paths without upstream trajectory samples are labeled `RECONSTRUCTED ESTIMATE` / `ASCENT ESTIMATE`; only supplied trajectory samples receive the replay wording. Selected-orbit framing fits the complete ring, rear-side linework uses normal scene depth occlusion, and current distance, speed, and callout data come only from a launch-year-validated satellite match. Both the loaded-catalog and active-TLE lookups read the launch year from the TLE international designator through one parser (`src/sources/tle.js`); a blank or malformed designator is an unknown year, which skips the year check rather than rejecting the match. Speed is the magnitude of the SGP4 inertial velocity vector at the same propagation epoch as position, displayed in km/s with km/h available as hover detail. Unavailable operator, site, launch-time, orbit, current-altitude, and speed values omit their detail rows instead of reserving panel space with placeholders. Newly launched payloads absent from the core operational groups use CelesTrak's cached active TLE feed as a lookup-only fallback; weak constellation-name matches are rejected. The replay callout maps compressed animation progress onto Launch Library's mission-relative timeline, showing the historical UTC date/time at the marker's current path position; unavailable timelines remain explicit. Matched live satellite positions propagate at one-second cadence and use a distinct green dot/callout with the current UTC date/time. When no mission is selected, the Context panel presents a scrollable newest-first roster of every launch in the rolling window, including the smaller 5 px operator-colored marker, provider, and launch date. Hovering or keyboard-focusing a row shows four compact cyan corner brackets on both that roster row and its corresponding globe dot, rotates the globe at the current zoom to center it, and gives its label declutter priority without selecting it; the globe label remains unbracketed. Selecting a roster row invokes the same mission isolation and full-orbit framing as clicking its globe marker. The replay vehicle is one screen-space SVG/CSS HUD overlay rather than separate Cesium billboard, label, and reticle graphics. It is hidden during ordinary Focus and manual close views, where the standard selected launch-site label remains visible, and exists only while ascent replay is active. Its fixed pixel scale is shared by ascent and insertion, so camera range never resizes the rocket on screen before the phase boundary. Generic Launch Library pad names are reduced to their identifying suffix, and replay timestamps use a cyan state title over unprefixed white UTC date/time values. `REPLAY ASCENT` holds the unframed cyan/white rocket at the pad for a real-time `T−10` countdown, transitions through `LIFTOFF`, and attaches six tapered cyan/white ellipse waves directly below it from liftoff through insertion to convey thrust without adding scene geometry. While replay is active, the single start button is replaced by compact Play, Pause, and Cancel icon controls. Pause freezes countdown or mission time, vehicle/stage positions, camera target, labels, and thrust-wave animation; Play resumes from that exact frame, and Cancel releases replay camera ownership. The rocket and thrust group rotates from the path's live screen-space tangent, so its nose follows the visible ascent curve while the adjacent text remains upright. The camera begins as a close oblique launch chase, then smoothly widens between roughly 120 and 420 km vehicle altitude into a higher oblique context view that keeps the moving rocket targeted while exposing the ascent bend and orbit connection. At insertion the rocket/thrust glyph is replaced by the fixed-size cyan orbit dot, which the camera follows through the existing globe-scale orbit pullback. The chase camera limits per-frame yaw changes across heading wraps so it cannot abruptly cross in front of the vehicle and make ascent read in reverse; the replay-speed slider affects mission playback but not countdown duration. Mission world text has no native `LabelGraphics`: overview launch markers publish at most 48 ambient candidates for a 24-winner budget, while selecting a mission clears that overview source and publishes its launch-site callout, stage re-entry annotations, live/estimated payload-position readout, and orbit annotation as protected selected-lane entries. The source retains the exact former strings and colors. Static anchors reuse the Cartesian values used to build their mission geometry; the moving payload entry reads the layer's per-frame live-position cache; catalog-backed orbit annotation positions are cached in the same one-second matrix update that realigns the orbit primitive. Keyhole edge fade, horizon culling, final collision placement, and UI exclusion are owned by the shared host. Deselect restores the bounded overview, and refresh, disable, and destroy replace or clear both mission sources. A selected mission renders its orbit as four repeating tactical sectors, each containing one prominent cyan dot followed by one hundred thin translucent dashes. The bright dots act as orbit anchors while the subdued dash field remains depth-tested against the globe and is shown only for the selected mission. @@ -5059,6 +5099,8 @@ repointing `OVERPASS_UPSTREAMS` refetches instead of serving another instance's answers; untagged entries stop serving once an instance is configured. The shared transport guards `/api/overpass` and `/api/military-installations`. +Both query handlers refuse cross-site and forwarded browser requests through +the shared local-request gate, before any rate-limit, cache or upstream work. With no configured upstream, each route serves existing cache data (including expired entries, marked stale with the original cache/retrieval date) without refresh. An annotation miss returns HTTP 503, `OVERPASS_NOT_CONFIGURED`, @@ -5259,6 +5301,8 @@ and unreachable upstream (502/504) separately from road geometry. cannot race the nearest-aircraft query ahead of layer enablement. A healthy fallback feed is queried normally and its source is returned with the selection; fallback with no airborne records remains an honest no-data result, not an enable failure. + The spoken reply and the card leave out an altitude the feed did not report + rather than reading it as 0 feet. - Voice treats the parent Context panel and Contacts as separate intents. An explicit request to open Context expands only `global-context-panel`; it does not choose a mode. An explicit request to open Contacts expands that parent @@ -5306,7 +5350,7 @@ and unreachable upstream (502/504) separately from road geometry. - Pins: `src/routeCinematics.test.mjs` (26 tests), with `scripts/qa-flyroute-mutations.mjs` reverting each fix individually to prove they are load-bearing (19 named defects); rendered proof `scripts/qa-flyroute-cinema.mjs` (drives the real voice runner, measures the real Cesium camera every frame, writes a labelled contact sheet). - **Public control facade** on StyleManager (`setHudVisible/setHudLayout/setDetection/setMapStack/setBloom/setSharpen/setOrbit/setCleanView/getControlState` plus `runImmediateNavigation`): every setter syncs DOM sliders + share links + scene snapshots and returns `{ok, ...state}` — voice confirms only what actually happened. Validated `move_camera`, `fly_route`, `frame_overhead`, strongest-fire focus, and entity tracking use the shared navigation transaction, which refuses Cockpit before mutation, advances authority, releases follow owners, cancels stale work, and only then starts the requested action. All four entity layers expose `findByQuery/getNearby/getAllPositions/trackById|selectById/stopTracking/getTrackedInfo`. - **Scene context** (`get_entity_context`): selected entity from the context store, or visible entities ranked by distance to the view target (≤100km altitude); plus basemap context — view-target picking, 7-point viewport sampling, view-scale classification (global/continental/regional/metro/city/local), reverse geocoding (center ≤750km, viewport samples ≤3,000km), Google Places Nearby via `/api/google/nearby-places` (≤25km), known-landmark matching against `CITY_POIS`, coarse country inference fallback. Context assembly is capped at 1.5s with cache fallbacks; caches are deduped in-flight. - - **Every selectable layer writes that one slot** (2026-08-21). The tracking layers — flights, military, satellites — publish selection on their own awareness lane (`gev:awareness-subject-selected`, consumed by the readout card and the Contacts panel) and for the life of the voice tools never wrote the shared slot, so `scope:'selected'` silently answered `in_view` with a contact plainly selected on screen. They now call `selectTrackedSubjectContext` / `refreshTrackedSubjectContext` / `clearTrackedSubjectContext` (`src/data/contextStore.js`) on select / poll / deselect. **Invariants: (a)** that write path must NOT dispatch `gev:entity-selected` — tracking layers already own an event lane and a second one makes two surfaces fight over one subject; **(b)** exactly one record per tracking layer, because a frozen snapshot of a moved contact must never reach the visible-entity scan; **(c)** precedence is recency, not layer — one slot, last selection wins, so clicking an overlay entity supersedes a tracked plane as the voice subject while the plane stays tracked. Satellites refresh on the 1 s propagation beat, not per frame. + - **Every selectable layer writes that one slot** (2026-08-21). The tracking layers — flights, military, satellites — publish selection on their own awareness lane (`gev:awareness-subject-selected`, consumed by the readout card and the Contacts panel) and for the life of the voice tools never wrote the shared slot, so `scope:'selected'` silently answered `in_view` with a contact plainly selected on screen. They now call `selectTrackedSubjectContext` / `refreshTrackedSubjectContext` / `clearTrackedSubjectContext` (`src/data/contextStore.js`) on select / poll / deselect. **Invariants: (a)** that write path must NOT dispatch `gev:entity-selected` — tracking layers already own an event lane and a second one makes two surfaces fight over one subject; **(b)** exactly one record per tracking layer, because a frozen snapshot of a moved contact must never reach the visible-entity scan; **(c)** precedence is recency, not layer — one slot, last selection wins, so clicking an overlay entity supersedes a tracked plane as the voice subject while the plane stays tracked. Satellites refresh on the 1 s propagation beat, not per frame. The flights and military context records wrap the track after rounding to whole degrees, so a contact on 359.5° or more reads `0°` rather than `360°`. - **Context-mode vocabulary is symmetric** (2026-08-21). `set_context_mode` accepts `contacts`; the internal id is `flights`. Every model-readable field (`mode`, `entering`, `priorMode`, nested `context`/`contextRollback`, and the transition diagnostic text) is reported in the accepted vocabulary, with the internal id preserved as `Internal`; an absent secondary mode stays `null` rather than claiming to be `off`. Mapping lives in `src/contextModePolicy.js` so UI text and voice payloads cannot drift. Reporting the internal id made the model read `mode:'flights'` as "Contacts is off" and refuse to answer from the `contactsWindow` counts in the same payload. - **Analyst → track handoff carries a key, not just a label** (2026-08-21). `analyst_query` items include `icao24`/`mmsi` alongside the display `id`, and contact lookup uses the shared tiered ranking in `src/data/contactMatch.js`: hex exact → callsign exact → registration exact → callsign prefix → registration prefix → callsign substring → registration substring. The tiers keep an exact callsign ahead of a colliding registration regardless of feed order, and registrations compare separator-insensitively (`G-ABCD`/`GABCD`, `05-8152`/`058152`, `N123AB`/`N-123AB`). - **A typed command supersedes the turn it interrupts** (2026-08-21; hardened 2026-10-17). `sendTextCommand` defers its `response.create` behind an active response instead of colliding with it, marks that response superseded so a late function call from it is refused rather than dispatched, and drops the old turn's queued follow-up. A refused call is still ANSWERED — a terminal `{ok:false, superseded:true}` `function_call_output` — because an unanswered `function_call` strands a pending call and deadlocks the model; the refusal creates no response of its own. A burst of typed commands coalesces into one response while keeping both conversation items. If the local channel rejects the user item, that command requests no response and adds no deferred answer; earlier accepted deferred input remains queued. Interruption and pointer preparation still happen before the send attempt. @@ -5540,7 +5584,7 @@ request viewbox and continues searching without that geographic preference. - **A vertex is a lon/lat, and the mark is DRAPED.** Clicks are picked through `pickWorldFromScreen` — one of two scene helpers the annotation resolver factory (`src/annotations/resolver.js`) exposes alongside `sampleGroundHeight`, so the app has ONE depth-aware pick cascade rather than a second written beside it — which returns the height the pick landed on as well as the coordinate, so the live preview hangs on the roof or hillside under the pointer instead of sinking to sea level. The world renderer draws areas and routes with `clampToGround`, so the height is dropped at finish in one place (`finishSpec`) rather than being carried downstream and discarded silently: what you get is the outline you drew, lying on the surface beneath it. - **Draped marks classify onto `ClassificationType.BOTH`** — terrain AND 3D tiles — not onto 3D tiles alone. With only `CESIUM_3D_TILE`, every area fill, area outline, route and arrow rendered to nothing on a keyless boot, where Cesium's own globe carries Esri/OSM imagery and there are no tiles to classify against; the labels appeared and the geometry did not. That hit spoken annotations exactly as hard as hand-drawn ones. `scripts/qa-draw-tool.mjs` counts the mark's own colour in the rendered frame before and after a shape is finished, on a keyed AND a keyless server, so "the board says there is a mark" can no longer pass for "the mark is on screen". - **A finished area is a CLOSED ring.** The fill polygon closes itself but the outline beside it is a polyline, so an open ring drew every edge except the one back to the first vertex — a triangle with two sides. `finishSpec` repeats the first position at the end. A line is never closed: joining its ends would invent a segment nobody drew. -- Drawn-line distance labels select metres or kilometres after rounding to the nearest metre, so a 999.5-metre line displays as `1.0 km` rather than `1000 m`. +- Drawn-line distance labels select metres or kilometres after rounding to the nearest metre, so a 999.5-metre line displays as `1.0 km` rather than `1000 m`, and drop the kilometre decimal from 9.95 km. The placed mark's label (shared with spoken routes and arrows) rounds before each unit cut, so a 996-metre line is labelled `1.0 km`, not `1000 m`, and a 9,960-metre one `10 km`, not `10.0 km`. - **Measurements and centres are computed on unwrapped longitudes** (`unwrapLongitudes` / `wrapLongitude` in `drawMode.js`, used by `ringAreaM2`, `ringCentroid` and the engine's manual-area anchor). A ~50,000 m² rectangle straddling the antimeridian reads its 0.002° width as 359.998° under raw arithmetic: it measured thousands of square kilometres and anchored on the Greenwich meridian, half a world from the shape. Path length was already safe — haversine is periodic in the longitude delta — and a test pins that so a "fix" cannot break it. - **Bounds.** One shape holds at most `MAX_VERTICES` = 512 points; two clicks within 0.5 m are one vertex (that is what makes a double-click finish without leaving a stray point); a vertex that is not a finite on-globe coordinate is refused. A shape that encloses nothing is refused rather than placed: three collinear points are not an area and a line under a metre long is not a line, and the hint says which. - Drawn-area measures round before choosing m², ha or km², so 9,999.6 m² displays as `1.0 ha`, not `10000 m²`, and 999,960 m² as `1.00 km²`, not `100.0 ha`. @@ -5799,6 +5843,7 @@ apply to `public/` assets or built-preview serving. - **Cockpit camera anchor:** first-person mode does not write feed-boundary corrections directly into the camera. A cockpit-only inertial anchor advances from the selected aircraft's displayed course and speed, then converges toward the authoritative delayed track with correction capped below forward motion. The displayed kinematics are derived from the same consecutive fix segment as the rendered position, with raw feed speed/course used only as fallback; a transient zero/missing feed speed therefore cannot freeze a visibly moving aircraft after layer enable or a map/cockpit handoff. Rendered altitude continues to come from that interpolated track position. Late ADS-B fixes and short render stalls can remove drift without accelerating or reversing the view. Camera placement runs before scene update/culling at a bounded 20 Hz so a moving cockpit does not force Photoreal 3D Tiles to retraverse on every display frame; textual instruments update at 10 Hz and context/layout work at 4 Hz. Every far Cockpit contact pip shares one stable Cesium texture-atlas entry and skips unused screen-projected course calculations, while only in-range 2D aircraft silhouettes pay the screen-projected rotation cost; ambient glTF collections are hidden/retained rather than synchronously destroyed at cockpit entry, and context rails lay out only on explicit content/state changes and viewport resize. The deliberate 15/30-second layer interpolation delays and per-Cesium-frame position caches remain unchanged. - **Cockpit route, vision, and view controls:** visible on-screen `COCKPIT`, `RESET`, and `EXIT COCKPIT` controls replace reliance on the `C` shortcut. RESET uses the same canonical globe route as the map and voice actions, exits Cockpit, and releases its camera ownership rather than exposing the hidden map-style top action. When the tracked commercial flight has a plausible ADSBDB route, the top of the right briefing rail shows a compact `FROM → TO` airport strip and the visor shows a centered estimated-destination chevron with its relative bearing; its destination label rounds to whole degrees in 000–359, wrapping rounded north to 000° while the chevron continues to use the relative bearing independently of label rounding; absent or implausible route data hides the strip and cue rather than guessing. The cockpit-local vision control is an interactive `PREV / CURRENT / NEXT` carousel over `NORMAL`, `CRT`, `NVG`, `FLIR`, `ANIME`, and `NOIR`; its previous/next actions wrap, and activating the current value advances to the next style. Cockpit enters on the active map preset, so a Cyber FLIR map opens Cockpit on FLIR while Normal remains a distinct option instead of producing a second FLIR entry. Cockpit choices temporarily activate the existing Cesium post-process stages, while exiting Cockpit through either EXIT or RESET restores the pre-entry map style. The regional-news page uses a free Google News RSS locality query first, with the existing GDELT query retained only as a fail-soft fallback; linked headlines remain reporting, not verified incidents or risk intelligence. - Cockpit wind-direction labels wrap after rounding to whole degrees, so a direction of 359.5° displays as `N · 0°` rather than `N · 360°`. +- The transit, ALPR, vessel, CCTV, Local ADS-B, flight and military context, awareness, Cockpit and Street Level heading readouts round through `roundHeadingDegrees` in `src/data/headingDegrees.js` (whole degrees 0–359, `null` when not finite, so each caller keeps its own placeholder); `normalizeHeading` lives there too and `cockpitMath.js` re-exports it. The Cockpit's `TRACK ACQUIRED` signal uses `formatHeadingReadout`, so a 359.6° track reads `COURSE 000°`. - **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. @@ -6007,9 +6052,11 @@ easier to meet (detection is now on more often), but does not create it. OSM choice), so Hybrid fill is never replayed as OSM roads. Failed flow refreshes clear old matches and report simulation. `flowCoveragePct` is the percentage of shown dots on roads with flow, including all unmatched dots in - the denominator and excluding dots hidden by closures. The OSM keyed status - reads `LIVE · Roads: OpenStreetMap · Flow: TomTom · N% cov`. Road fetch - coverage follows a 5×5 ellipsoid sample of the central screen square. + the denominator and excluding dots hidden by closures. It reads 1–99 whenever + both matched and simulated dots are shown, so rounding never turns a few + matches into `(no matches)` or a few simulated dots into full coverage. The + OSM keyed status reads `LIVE · Roads: OpenStreetMap · Flow: TomTom · N% cov`. + Road fetch coverage follows a 5×5 ellipsoid sample of the central screen square. Horizon rays are capped at 10 km from nadir (reduced if necessary to fit 16 z12 tiles); polygon-intersecting tiles load nearest first. Up to 16 z14 tiles provide near detail, with z12 roads beyond them. Cached tiles serve @@ -6200,7 +6247,10 @@ The lockfile uses DOMPurify 3.4.17, protobufjs 8.8.0, PostCSS 8.5.28, source-map-js 1.2.2 and nanoid 3.3.19. Cesium remains on 1.138.0. Browser QA uses Puppeteer 25.10.0; image-processing tools use Sharp 0.35.5. QA scripts await Puppeteer's asynchronous executable-path lookup before testing or passing the path to Chrome. Supported Node versions remain -24.14.x and 26.x. Use `npm ci` to reproduce the checked-in dependency tree. +Node 24 (24.14.0 or later) and 26.x; the Docker image and CI pin 24.22.2, +the current 24 LTS patch, which carries the October 21, 2026 Node.js security +fixes first released in 24.22.1 (including CVE-2026-111497). Use +`npm ci` to reproduce the checked-in dependency tree. ### Traffic city navigation @@ -6342,7 +6392,9 @@ Receivers publish one record shape from `src/sources/adsbRecords.js` `lastMessageAt`, `messageCount`, `rssiDbfs`, `band`, `source`). `band` is `1090` or `978`; `source` is `webusb` (browser SDR) or `feed` (decoder feed). `normalizeDump1090Aircraft(json, nowMs, { band })` maps a dump1090/readsb/ -skyaware978 `aircraft.json` document into the same records. Voice +skyaware978 `aircraft.json` document into the same records; a row without +`baro_rate` reads its vertical rate from `geom_rate`, where a GNSS-sourced rate +is sent. Voice `set_layer_visibility` accepts `local-adsb` ("local ADS-B", "my receiver", "my antenna"). @@ -6389,6 +6441,12 @@ three Cesium data sources; enabling rebuilds from the accepted parsed cache, and destroying clears it. Load ownership prevents cancelled work from adding entities after teardown. +Every Cesium entity from multipart cable or landing-point geometry stays tied +to its source feature, including generated IDs and IDs that collide with +Cesium's multipart suffixes. Each segment gets its own feature's styling, click +destination and horizon-culling reference, and each source feature has one +reference stem. The source collections are not modified. + Natural Earth regions and neighborhood polygon lookup are package exports. Their existing lazy loaders, retry behavior, bundled datasets and attribution are unchanged. The cable dataset remains CC BY-NC-SA 3.0 and is not covered by diff --git a/docs/KNOWN-ISSUES.md b/docs/KNOWN-ISSUES.md index 6afd37839..53c7949c6 100644 --- a/docs/KNOWN-ISSUES.md +++ b/docs/KNOWN-ISSUES.md @@ -71,9 +71,9 @@ Status: Open (owner-accepted 2026-07-08, documented) a few stragglers take one more poll. - **Born-grounded first poll:** a contact first seen on the ground with no altitude data renders at the geoid for ≤1 poll until its floor cell warms. -- Full context, improvement ideas, and the verification oracle - (`scripts/qa-floor-verify.mjs`): - `docs/superpowers/reports/2026-07-08-height-datum-handover.md`. +- Verify floor-placement behavior with `scripts/qa-floor-verify.mjs` against a + running local app; set `QA_BASE_URL` when it is not on the default port + (`http://localhost:4173`). --- diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 613e55dc5..49d2fcc1a 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1198,5 +1198,8 @@ "src/standalone/layerSources.test.mjs", "src/tooling/nominatimGuardLifecycle.test.mjs", "src/tooling/nominatimLockOwnership.test.mjs", - "src/tooling/nominatimOutlineRoute.test.mjs" + "src/tooling/nominatimOutlineRoute.test.mjs", + "src/data/headingDegrees.test.mjs", + "src/data/distanceFormat.test.mjs", + "src/sources/adsbFields.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 2b2a70cd0..86b68eb4b 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -179,6 +179,7 @@ "runtime": "node", "exports": ["./server/providers/live"], "modules": [ + "src/sources/adsbFields.js", "server/providers/live.js", "server/providers/common/http.js", "server/providers/common/query.js", @@ -198,13 +199,14 @@ }, "adsb-lol-source": { "exports": ["./sources/adsb-lol"], - "modules": ["src/data/adsbLolFallback.js"], + "modules": ["src/data/adsbLolFallback.js", "src/sources/adsbFields.js"], "external": [] }, "place-providers": { "runtime": "node", "exports": ["./server/providers/places"], "modules": [ + "src/data/distanceFormat.js", "server/providers/places.js", "server/providers/places/google.js", "server/providers/places/google-key.js", @@ -391,6 +393,7 @@ "runtime": "node", "exports": ["./server/providers/overpass"], "modules": [ + "src/data/distanceFormat.js", "server/providers/common/geo.js", "server/providers/common/http.js", "server/providers/common/rate-limit.js", @@ -429,7 +432,9 @@ "server/providers/overpass/transport.js", "src/sources/httpBody.js", "src/i18n/marker.js", - "src/sources/rateLimit.js" + "src/sources/rateLimit.js", + "server/providers/common/same-site.js", + "src/localRequestGate.mjs" ], "external": [] }, @@ -755,6 +760,7 @@ "cctv-controls": { "exports": ["./ui/cctv"], "modules": [ + "src/data/headingDegrees.js", "src/ui/cctv.js", "src/ui/cctvBindings.js", "src/ui/cctvCalibration.js", @@ -824,6 +830,8 @@ "./ui/cockpit/vision" ], "modules": [ + "src/data/headingDegrees.js", + "src/data/distanceFormat.js", "src/ui/cockpit.js", "src/ui/cockpitBriefing.js", "src/ui/cockpitBriefPages.js", @@ -893,6 +901,8 @@ "ui-shell": { "exports": ["./ui/shell"], "modules": [ + "src/data/headingDegrees.js", + "src/data/distanceFormat.js", "src/i18n/marker.js", "src/ui/mobileShell.js", "src/exclusiveSurfaces.js", @@ -1250,6 +1260,7 @@ "live-observations": { "exports": ["./sources/live"], "modules": [ + "src/sources/adsbFields.js", "src/i18n/marker.js", "src/data/aircraftEmergency.js", "src/sources/live/aircraft.js", @@ -1263,6 +1274,7 @@ "civil-flights": { "exports": ["./layers/flights"], "modules": [ + "src/data/headingDegrees.js", "src/i18n/marker.js", "src/cyberSonar.js", "src/data/aircraftClass.js", @@ -1325,6 +1337,7 @@ "military-flights": { "exports": ["./layers/military"], "modules": [ + "src/data/headingDegrees.js", "src/i18n/marker.js", "src/cyberSonar.js", "src/data/aircraftClass.js", @@ -1410,6 +1423,7 @@ "vessel-layer": { "exports": ["./layers/vessels"], "modules": [ + "src/data/headingDegrees.js", "src/layers/vessels/cards.js", "src/layers/vessels/evidence.js", "src/layers/vessels/index.js", @@ -1655,6 +1669,8 @@ "awareness-layer": { "exports": ["./layers/awareness"], "modules": [ + "src/data/headingDegrees.js", + "src/data/distanceFormat.js", "src/i18n/marker.js", "src/cyberSonar.js", "src/cockpitMath.js", @@ -1793,6 +1809,7 @@ "cctv-layer": { "exports": ["./layers/cctv"], "modules": [ + "src/data/headingDegrees.js", "src/data/cctvCards.js", "src/data/cctvGizmo.js", "src/data/cctvLod.js", @@ -1921,6 +1938,7 @@ "alpr-cameras": { "exports": ["./layers/alpr"], "modules": [ + "src/data/headingDegrees.js", "src/layers/alpr/index.js", "src/layers/alpr/model.js", "src/layers/alpr/policy.js", @@ -2199,6 +2217,9 @@ "./standalone/catalog" ], "modules": [ + "src/sources/adsbFields.js", + "src/data/headingDegrees.js", + "src/data/distanceFormat.js", "src/ui/placeNames.js", "src/cyberSonarScene.js", "src/cyberSonar.js", @@ -3181,6 +3202,9 @@ "application-layer-construction": { "exports": ["./application/layers"], "modules": [ + "src/sources/adsbFields.js", + "src/data/headingDegrees.js", + "src/data/distanceFormat.js", "src/cyberSonar.js", "src/data/approxDistance.js", "src/data/bhoteKoshiEvent.js", @@ -4083,6 +4107,7 @@ "directions-layer": { "exports": ["./layers/directions"], "modules": [ + "src/data/distanceFormat.js", "src/i18n/marker.js", "src/layers/directions/index.js", "src/data/pointerOwnerNames.js", @@ -4359,6 +4384,7 @@ "transit-standalone": { "exports": ["./layers/transit/standalone"], "modules": [ + "src/data/headingDegrees.js", "src/cyberSonar.js", "src/celestialRing.js", "src/data/approxDistance.js", @@ -4436,6 +4462,7 @@ "transit-layer": { "exports": ["./layers/transit"], "modules": [ + "src/data/headingDegrees.js", "src/data/iconOrientation.js", "src/data/inputOwnership.js", "src/data/transitFeeds.js", @@ -4663,6 +4690,7 @@ "tools": { "exports": ["./tools"], "modules": [ + "src/data/headingDegrees.js", "src/tools/index.js", "src/tools/catalog.js", "src/tools/schema.js", @@ -4739,6 +4767,7 @@ "tools-services": { "exports": ["./tools/services"], "modules": [ + "src/sources/adsbFields.js", "src/tools/services.js", "src/layers/bikeshare/registry.js", "src/layers/bikeshare/source.js", diff --git a/scripts/setup-doctor.mjs b/scripts/setup-doctor.mjs index 7075be2c1..600e96b09 100644 --- a/scripts/setup-doctor.mjs +++ b/scripts/setup-doctor.mjs @@ -57,7 +57,7 @@ export function classifyNodeVersion(version = process.versions.node) { // NEWER than this release has verified is a warning, never a refusal: a // future Node must not brick a no-terminal install with advice its user // cannot follow. Too-old stays an error above — old runtimes genuinely fail. - return { level: 'warn', summary: 'newer than this release has verified; Node 24.14.x or 26.x is the tested path' }; + return { level: 'warn', summary: 'newer than this release has verified; Node 24.22.x or 26.x is the tested path' }; } /** Verify that every direct package declared by this checkout is present. */ diff --git a/server/providers/military-installations.js b/server/providers/military-installations.js index 7d252b8ed..1b6c82107 100644 --- a/server/providers/military-installations.js +++ b/server/providers/military-installations.js @@ -1,4 +1,5 @@ import { makeRateLimiter, clientKey } from './common/rate-limit.js'; +import { admitSameSite } from './common/same-site.js'; import { MILITARY_INSTALLATION_ELEMENT_CAP, MILITARY_INSTALLATION_MAX_RESPONSE_BYTES, @@ -97,6 +98,7 @@ function militaryInstallationsProxy() { res.end(JSON.stringify({ error: 'Method Not Allowed' })); return; } + if (admitSameSite(req, res)) return; const url = new URL(req.url, 'http://localhost'); const requested = validMilitaryInstallationBox(url.searchParams); if (!requested) { diff --git a/src/annotations/annotationEngine.js b/src/annotations/annotationEngine.js index 68a2e310a..73c952d23 100644 --- a/src/annotations/annotationEngine.js +++ b/src/annotations/annotationEngine.js @@ -17,6 +17,7 @@ import { phaseTiming, recordWorkSlice, } from '../sources/phaseTiming.js'; +import { formatDistanceText } from '../data/distanceFormat.js'; // Dev convenience: expose the app's Cesium instance for console/preview probing // (single shared module instance — avoids dual-Cesium state bugs when testing). @@ -2177,9 +2178,7 @@ function greatCircleM(a, b) { } function formatDistance(m) { - if (!Number.isFinite(m)) return null; - if (m >= 1000) return `${(m / 1000).toFixed(m >= 10000 ? 0 : 1)} km`; - return `${Math.round(m / 10) * 10} m`; + return formatDistanceText(m, { metreStep: 10 }); } function composeRouteLabel(baseLabel, distM, durS, mode, fallback) { diff --git a/src/annotations/annotationEngine.test.mjs b/src/annotations/annotationEngine.test.mjs index f6dbb4d3e..98337d25d 100644 --- a/src/annotations/annotationEngine.test.mjs +++ b/src/annotations/annotationEngine.test.mjs @@ -1230,7 +1230,6 @@ test('only an explicit navigation request permits resolving distant annotation t engine.destroy(); }); - test('recaption inherits outline credit from borrowed geometry rather than its new anchor', async (t) => { installAnimationFrameStubs(t); const { renderer } = fakeRenderer(); @@ -1302,3 +1301,18 @@ test('plain recaption keeps the old mark addressable when its removal throws', a ); assert.equal(engine.count(), 0); }); + +test('a drawn line that rounds up to the next unit is labelled in it, not as 1000 m or 10.0 km', async (t) => { + installAnimationFrameStubs(t); + const labels = []; + const renderer = { add(anno) { labels.push(anno.label); }, update() {}, remove() {}, sync() {}, destroy() {} }; + const engine = createAnnotationEngine({ viewer: {}, renderer }); + const metresPerDegree = (6371.0088 * 1000 * Math.PI) / 180; + for (const metres of [994, 996, 9940, 9960]) { + await engine.annotate([ + { type: 'route', manual: true, path: [[0, 0], [0, metres / metresPerDegree]] }, + ]); + } + assert.deepEqual(labels, ['990 m', '1.0 km', '9.9 km', '10 km']); + engine.destroy(); +}); diff --git a/src/annotations/drawMode.js b/src/annotations/drawMode.js index 93f75dbdc..f22a39696 100644 --- a/src/annotations/drawMode.js +++ b/src/annotations/drawMode.js @@ -21,6 +21,7 @@ import { t, tn } from '../i18n/index.js'; */ import { greatCircleMeters } from '../geoDistance.js'; +import { formatDistanceText } from '../data/distanceFormat.js'; export const DRAW_SHAPES = Object.freeze(['area', 'line', 'pin']); export const MIN_VERTICES = Object.freeze({ area: 3, line: 2, pin: 1 }); @@ -207,11 +208,7 @@ export function ringCentroid(vertices) { export function formatMeasure(session) { if (!session) return ''; if (session.shape === 'line') { - const m = pathLengthM(session.vertices); - // Round before the kilometre cut. 999.6 m was printed as "1000 m". - const whole = Math.round(m); - if (whole < 1000) return `${whole} m`; - return `${(m / 1000).toFixed(m >= 10000 ? 0 : 1)} km`; + return formatDistanceText(pathLengthM(session.vertices)) ?? ''; } if (session.shape === 'area') { const m2 = ringAreaM2(session.vertices); diff --git a/src/annotations/drawMode.test.mjs b/src/annotations/drawMode.test.mjs index 8018b4527..57b89751b 100644 --- a/src/annotations/drawMode.test.mjs +++ b/src/annotations/drawMode.test.mjs @@ -346,6 +346,14 @@ test('a line that rounds up to a kilometre reads 1.0 km, not 1000 m', () => { assert.match(drawHint(line), /^1\.0 km · /); }); +test('a line that rounds to 10.0 km reads in whole kilometres', () => { + const metresPerDegree = (6371.0088 * 1000 * Math.PI) / 180; + const line = createDrawSession('line'); + addVertex(line, { lon: 0, lat: 0 }); + addVertex(line, { lon: 0, lat: 9960 / metresPerDegree }); + assert.equal(formatMeasure(line), '10 km'); +}); + test('an area that rounds up to the next unit reads in that unit', () => { // A square on the equator whose shoelace area is about `m2`. const square = (m2) => { diff --git a/src/cockpitMath.js b/src/cockpitMath.js index 888c33ead..3186b1f3c 100644 --- a/src/cockpitMath.js +++ b/src/cockpitMath.js @@ -1,10 +1,10 @@ -export { resolveHudRailLayout } from './ui/panelRailGeometry.js'; +import { + normalizeHeading, + roundHeadingDegrees, +} from './data/headingDegrees.js'; -/** Normalize a heading into the [0, 360) range. */ -export function normalizeHeading(value) { - if (!Number.isFinite(value)) return 0; - return ((value % 360) + 360) % 360; -} +export { resolveHudRailLayout } from './ui/panelRailGeometry.js'; +export { normalizeHeading }; /** * Three-digit heading readout, 000–359. @@ -12,7 +12,7 @@ export function normalizeHeading(value) { */ export function formatHeadingReadout(degrees) { if (!Number.isFinite(degrees)) return '---'; - return String(Math.round(normalizeHeading(degrees)) % 360).padStart(3, '0'); + return String(roundHeadingDegrees(degrees)).padStart(3, '0'); } /** Advance a displayed heading along the shortest arc without exceeding a slew rate. */ diff --git a/src/cockpitMath.test.mjs b/src/cockpitMath.test.mjs index e49b91a57..5a7f029c0 100644 --- a/src/cockpitMath.test.mjs +++ b/src/cockpitMath.test.mjs @@ -161,6 +161,7 @@ test('a heading that rounds to north reads 000, not 360', () => { assert.equal(formatHeadingReadout(359.4), '359'); assert.equal(formatHeadingReadout(0), '000'); assert.equal(formatHeadingReadout(10.2), '010'); + assert.equal(formatHeadingReadout(-0.6), '359'); assert.equal(formatHeadingReadout(Number.NaN), '---'); }); diff --git a/src/data/adsbLolFallback.js b/src/data/adsbLolFallback.js index 40dd2bbc0..61d07387f 100644 --- a/src/data/adsbLolFallback.js +++ b/src/data/adsbLolFallback.js @@ -1,3 +1,5 @@ +import { readAdsbVerticalRateFpm } from '../sources/adsbFields.js'; + const KNOT_TO_MPS = 0.514444; const FOOT_TO_M = 0.3048; const FPM_TO_MPS = 0.00508; @@ -61,8 +63,7 @@ export function normalizeAdsbLolAircraftState(aircraft, nowSeconds) { const barometricFeet = onGround ? null : finiteNumber(aircraft?.alt_baro); const geometricFeet = finiteNumber(aircraft?.alt_geom); const groundSpeedKnots = finiteNumber(aircraft?.gs); - const verticalRateFpm = - finiteNumber(aircraft?.baro_rate) ?? finiteNumber(aircraft?.geom_rate); + const verticalRateFpm = readAdsbVerticalRateFpm(aircraft); const track = finiteNumber(aircraft?.track); return [ diff --git a/src/data/directions.test.mjs b/src/data/directions.test.mjs index 0522226d2..ae3ac51ff 100644 --- a/src/data/directions.test.mjs +++ b/src/data/directions.test.mjs @@ -849,6 +849,8 @@ test('route numbers follow the page language and never break from their unit', ( assert.equal(routeDistanceText(994), '990\u00a0m'); assert.equal(routeDistanceText(996), '1.0\u00a0km'); assert.equal(routeDistanceText(1500), '1.5\u00a0km'); + assert.equal(routeDistanceText(9940), '9.9\u00a0km'); + assert.equal(routeDistanceText(9960), '10\u00a0km'); assert.equal(routeDistanceText(2_345_000), '2345\u00a0km'); assert.equal(routeDurationText(39), '39\u00a0s'); assert.equal(routeDurationText(59.4), '59\u00a0s'); diff --git a/src/data/distanceFormat.js b/src/data/distanceFormat.js new file mode 100644 index 000000000..efd9d78f9 --- /dev/null +++ b/src/data/distanceFormat.js @@ -0,0 +1,43 @@ +/** + * Kilometre decimals for a distance: one below 10 km, none from there on. + * The cut is taken on the tenth that prints, so 9.96 km reads 10 km, not 10.0. + * @param {number} meters + * @returns {0|1} + */ +export function kilometreDigits(meters) { + return Math.round(meters / 100) < 100 ? 1 : 0; +} + +/** + * The unit and precision a distance prints with: metres below 1 km, then + * kilometres with `kilometreDigits`. Metres round to `metreStep` (10 for tens), + * except below `exactBelowM`, where they stay whole. Each cut is taken after + * rounding, so 999.6 m reads 1.0 km rather than 1000 m. + * @param {number} meters + * @param {{metreStep?: number, exactBelowM?: number}} [options] + * @returns {{value: number, unit: 'm'|'km', digits: 0|1}|null} null for a + * distance that is negative or not finite. The value is already rounded to + * `digits`, so toFixed and Intl render it the same way. + */ +export function distanceParts(meters, { metreStep = 1, exactBelowM = 0 } = {}) { + if (!Number.isFinite(meters) || meters < 0) return null; + const step = meters < exactBelowM ? 1 : metreStep; + const shownMeters = Math.round(meters / step) * step; + if (shownMeters < 1000) return { value: shownMeters, unit: 'm', digits: 0 }; + const digits = kilometreDigits(meters); + const value = + digits === 1 ? Math.round(meters / 100) / 10 : Math.round(meters / 1000); + return { value, unit: 'km', digits }; +} + +/** + * Plain `850 m` / `4.6 km` / `12 km` text for labels that are not localized. + * @param {number} meters + * @param {{metreStep?: number, exactBelowM?: number}} [options] + * @returns {string|null} + */ +export function formatDistanceText(meters, options) { + const parts = distanceParts(meters, options); + if (!parts) return null; + return `${parts.value.toFixed(parts.digits)} ${parts.unit}`; +} diff --git a/src/data/distanceFormat.test.mjs b/src/data/distanceFormat.test.mjs new file mode 100644 index 000000000..92a027662 --- /dev/null +++ b/src/data/distanceFormat.test.mjs @@ -0,0 +1,50 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + distanceParts, + formatDistanceText, + kilometreDigits, +} from './distanceFormat.js'; + +test('a distance switches to kilometres where its metres round to 1,000', () => { + assert.equal(formatDistanceText(999.4), '999 m'); + assert.equal(formatDistanceText(999.5), '1.0 km'); + assert.equal(formatDistanceText(999.6), '1.0 km'); + assert.equal(formatDistanceText(994, { metreStep: 10 }), '990 m'); + assert.equal(formatDistanceText(995, { metreStep: 10 }), '1.0 km'); +}); + +test('kilometres drop their decimal where the tenth rounds to 10.0', () => { + assert.equal(kilometreDigits(9949), 1); + assert.equal(kilometreDigits(9950), 0); + assert.equal(formatDistanceText(9949), '9.9 km'); + assert.equal(formatDistanceText(9950), '10 km'); + assert.equal(formatDistanceText(9960), '10 km'); + assert.equal(formatDistanceText(99950), '100 km'); +}); + +test('metres below exactBelowM stay whole while the rest round to the step', () => { + const options = { metreStep: 10, exactBelowM: 100 }; + assert.equal(formatDistanceText(8, options), '8 m'); + assert.equal(formatDistanceText(99.4, options), '99 m'); + assert.equal(formatDistanceText(846, options), '850 m'); + assert.deepEqual(distanceParts(846, options), { + value: 850, + unit: 'm', + digits: 0, + }); +}); + +test('a negative or non-finite distance has no parts', () => { + for (const value of [-1, Number.NaN, Infinity, -Infinity, null, undefined]) { + assert.equal(distanceParts(value), null); + assert.equal(formatDistanceText(value), null); + } + assert.equal(formatDistanceText(0), '0 m'); +}); + +test('kilometres come back rounded, so toFixed and Intl agree', () => { + assert.deepEqual(distanceParts(1450), { value: 1.5, unit: 'km', digits: 1 }); + assert.equal(formatDistanceText(1450), '1.5 km'); + assert.equal(formatDistanceText(1150), '1.2 km'); +}); diff --git a/src/data/headingDegrees.js b/src/data/headingDegrees.js new file mode 100644 index 000000000..af2c1203a --- /dev/null +++ b/src/data/headingDegrees.js @@ -0,0 +1,19 @@ +/** Normalize a heading into the [0, 360) range; 0 when it is not finite. */ +export function normalizeHeading(value) { + if (!Number.isFinite(value)) return 0; + // Only a negative remainder takes the extra turn, so a positive bearing + // keeps its precision (0.49999999999999 must not become 360.5). + const remainder = value % 360; + const wrapped = remainder < 0 ? remainder + 360 : remainder; + return wrapped === 360 ? 0 : wrapped; +} + +/** + * Whole degrees for display, 0–359. 359.5° and above rounds to north (0), + * and negative or multi-turn values wrap. Null when the value is not finite, + * so each caller keeps its own placeholder. + */ +export function roundHeadingDegrees(value) { + if (!Number.isFinite(value)) return null; + return Math.round(normalizeHeading(value)) % 360; +} diff --git a/src/data/headingDegrees.test.mjs b/src/data/headingDegrees.test.mjs new file mode 100644 index 000000000..021813eff --- /dev/null +++ b/src/data/headingDegrees.test.mjs @@ -0,0 +1,47 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { normalizeHeading, roundHeadingDegrees } from './headingDegrees.js'; + +test('normalizeHeading wraps into [0, 360) and reads a non-finite value as 0', () => { + assert.equal(normalizeHeading(370), 10); + assert.equal(normalizeHeading(-10), 350); + assert.equal(normalizeHeading(720), 0); + assert.equal(normalizeHeading(Number.NaN), 0); + assert.equal(normalizeHeading(Infinity), 0); +}); + +test('roundHeadingDegrees reads 359.5° and above as north, never 360', () => { + assert.equal(roundHeadingDegrees(359.4), 359); + assert.equal(roundHeadingDegrees(359.5), 0); + assert.equal(roundHeadingDegrees(359.6), 0); + assert.equal(roundHeadingDegrees(360), 0); + assert.equal(roundHeadingDegrees(0), 0); + assert.equal(roundHeadingDegrees(194.2), 194); +}); + +test('roundHeadingDegrees wraps negative and multi-turn bearings', () => { + assert.equal(Object.is(roundHeadingDegrees(-0.4), 0), true); + assert.equal(roundHeadingDegrees(-0.6), 359); + assert.equal(roundHeadingDegrees(-1), 359); + assert.equal(roundHeadingDegrees(719.6), 0); + assert.equal(roundHeadingDegrees(-360.6), 359); +}); + +test('roundHeadingDegrees returns null for a value that is not finite', () => { + for (const value of [ + Number.NaN, + Infinity, + -Infinity, + null, + undefined, + '12', + ]) { + assert.equal(roundHeadingDegrees(value), null); + } +}); + +test('normalizeHeading keeps a positive bearing just under a half degree', () => { + assert.equal(roundHeadingDegrees(0.49999999999999), 0); + assert.equal(roundHeadingDegrees(359.49999999999994), 359); + assert.equal(normalizeHeading(-1e-15) < 360, true); +}); diff --git a/src/data/militaryAwareness.test.mjs b/src/data/militaryAwareness.test.mjs index ea63280dc..05fd97dc0 100644 --- a/src/data/militaryAwareness.test.mjs +++ b/src/data/militaryAwareness.test.mjs @@ -79,6 +79,9 @@ test('Contacts moves a distance that rounds to 1,000 m into kilometres', () => { assert.equal(presentAwarenessDistance(999.4), '999\u00a0m'); assert.equal(presentAwarenessDistance(999.6), '1.0\u00a0km'); assert.equal(presentAwarenessDistance(4600), '4.6\u00a0km'); + assert.equal(presentAwarenessDistance(9949), '9.9\u00a0km'); + assert.equal(presentAwarenessDistance(9950), '10\u00a0km'); + assert.equal(presentAwarenessDistance(9960), '10\u00a0km'); }); function installAwarenessRuntime({ diff --git a/src/data/militaryAwarenessEngine.js b/src/data/militaryAwarenessEngine.js index 5f359b757..136e9d2fa 100644 --- a/src/data/militaryAwarenessEngine.js +++ b/src/data/militaryAwarenessEngine.js @@ -2,6 +2,7 @@ import { msg, t } from '../i18n/index.js'; import { UNNAMED_PLACE_NAMES } from './installationNames.js'; +import { formatDistanceText } from './distanceFormat.js'; const UNNAMED_NAMES = new Set(UNNAMED_PLACE_NAMES); @@ -46,10 +47,7 @@ export function findByDoublingRadius( /** @param {number} meters @returns {string} */ export function formatAwarenessDistance(meters) { - if (!Number.isFinite(meters) || meters < 0) return '—'; - return meters < 1000 - ? `${Math.round(meters)} m` - : `${(meters / 1000).toFixed(meters < 10000 ? 1 : 0)} km`; + return formatDistanceText(meters) ?? '—'; } /** diff --git a/src/data/militaryAwarenessEngine.test.mjs b/src/data/militaryAwarenessEngine.test.mjs index 8b8771f14..92e89355b 100644 --- a/src/data/militaryAwarenessEngine.test.mjs +++ b/src/data/militaryAwarenessEngine.test.mjs @@ -25,6 +25,15 @@ test('awareness formatters preserve valid zeroes and replace missing or invalid assert.equal(formatAwarenessLabel(undefined), '—'); }); +test('a contact marker moves a distance that rounds to 1,000 m into kilometres', () => { + assert.equal(formatAwarenessDistance(999.4), '999 m'); + assert.equal(formatAwarenessDistance(999.6), '1.0 km'); + assert.equal(formatAwarenessDistance(4600), '4.6 km'); + assert.equal(formatAwarenessDistance(9949), '9.9 km'); + assert.equal(formatAwarenessDistance(9950), '10 km'); + assert.equal(formatAwarenessDistance(9960), '10 km'); +}); + test('awareness cohorts sort nearest-first and do not turn empty data into out of range', () => { const result = summarizeAwarenessCohort([{ id: 'far', distance: 9000 }, { id: 'near', distanceM: 1000 }]); assert.equal(result.relationship, 'NEARBY'); diff --git a/src/data/rocketLaunches.test.mjs b/src/data/rocketLaunches.test.mjs index f94273d4e..38ff4d24c 100644 --- a/src/data/rocketLaunches.test.mjs +++ b/src/data/rocketLaunches.test.mjs @@ -445,6 +445,18 @@ test('holds replay at the pad for a real-time countdown independent of replay sp assert.ok(liftoff.phaseProgress > 0); }); +test('runs the five-second tile hold before a full T-10 countdown', () => { + const launch = { launchTime: '2026-07-20T10:00:00Z', timeline: [] }; + // REPLAY ASCENT puts liftoff 5 s (tile hold) + 10 s (countdown) ahead. + const hold = replayState(launch, 115000, 12, 28, 5400, 1, 104500, 5); + assert.equal(hold.preCountdownActive, true); + assert.equal(hold.countdownActive, false); + const countdown = replayState(launch, 115000, 12, 28, 5400, 1, 105000, 5); + assert.equal(countdown.preCountdownActive, false); + assert.equal(countdown.countdownActive, true); + assert.equal(countdown.countdownSeconds, 10); +}); + test('hands replay directly from ascent into orbit', () => { const launch = { launchTime: '2026-07-20T10:00:00Z', timeline: [] }; const orbit = replayState(launch, 0, 12, 28, 5400, 1, 16_000); diff --git a/src/data/routeSteps.js b/src/data/routeSteps.js index 736d812ee..119a599df 100644 --- a/src/data/routeSteps.js +++ b/src/data/routeSteps.js @@ -13,6 +13,8 @@ * roundabout turn, exit roundabout, exit rotary, notification, use lane. */ +import { formatDistanceText } from './distanceFormat.js'; + /** Cap on steps returned for one route — a 2,500 km drive rarely needs more. */ export const ROUTE_STEPS_MAX = 200; @@ -216,11 +218,7 @@ export function normalizeOsrmSteps(route) { * @returns {string} */ export function formatRouteDistance(meters) { - if (!Number.isFinite(meters) || meters < 0) return ''; - if (meters < 100) return `${Math.round(meters)} m`; - if (meters < 1000) return `${Math.round(meters / 10) * 10} m`; - const km = meters / 1000; - return `${km < 10 ? km.toFixed(1) : Math.round(km)} km`; + return formatDistanceText(meters, { metreStep: 10, exactBelowM: 100 }) ?? ''; } /** diff --git a/src/data/routeSteps.test.mjs b/src/data/routeSteps.test.mjs index c0eb445dc..dea5256da 100644 --- a/src/data/routeSteps.test.mjs +++ b/src/data/routeSteps.test.mjs @@ -236,6 +236,8 @@ test('distance and duration formatting', () => { assert.equal(formatRouteDistance(8), '8 m'); assert.equal(formatRouteDistance(846), '850 m'); assert.equal(formatRouteDistance(1234), '1.2 km'); + assert.equal(formatRouteDistance(996), '1.0 km'); + assert.equal(formatRouteDistance(9960), '10 km'); assert.equal(formatRouteDistance(21479), '21 km'); assert.equal(formatRouteDistance(-1), ''); assert.equal(formatRouteDuration(40), '40 s'); diff --git a/src/data/telegeographySubmarineCables.test.mjs b/src/data/telegeographySubmarineCables.test.mjs index 3850748dc..110f65f18 100644 --- a/src/data/telegeographySubmarineCables.test.mjs +++ b/src/data/telegeographySubmarineCables.test.mjs @@ -1754,3 +1754,252 @@ test('another GeoJSON source renders with its own label and rebuilds from accept env.layer.destroy(env.viewer); } }); + +function multipartCableFixture() { + return [ + { + type: 'Feature', + properties: { + id: 'alpha', + name: 'Alpha cable', + color: '#ff0000', + coordinates: [-40, 35], + }, + geometry: { + type: 'MultiLineString', + coordinates: [ + [ + [-40, 35], + [-39, 36], + ], + [ + [-38, 37], + [-37, 38], + ], + [ + [-36, 39], + [-35, 40], + ], + ], + }, + }, + { + type: 'Feature', + properties: { + id: 'beta', + name: 'Beta cable', + color: '#0000ff', + coordinates: [140, -35], + }, + geometry: { + type: 'LineString', + coordinates: [ + [140, -35], + [141, -34], + ], + }, + }, + ]; +} + +function geometrySource(cables, landingPoints = []) { + return { + label: 'Multipart test source', + async fetch() { + return { + cables: { type: 'FeatureCollection', features: cables }, + landingPoints: { type: 'FeatureCollection', features: landingPoints }, + }; + }, + }; +} + +test('multipart association accepts frozen provider collections without modifying properties', async () => { + const payload = { + cables: { type: 'FeatureCollection', features: multipartCableFixture() }, + landingPoints: { + type: 'FeatureCollection', + features: multipartCableFixture(), + }, + }; + payload.cables.features[0].properties.__gevSourceFeatureIndex = + 'provider value'; + const before = structuredClone(payload); + const freeze = (value) => { + if (value && typeof value === 'object') { + for (const child of Object.values(value)) freeze(child); + Object.freeze(value); + } + return value; + }; + freeze(payload); + const env = await createRealCableLayerHarness({ + source: { label: 'Frozen multipart source', fetch: async () => payload }, + }); + try { + for (const dataSource of env.dataSources.slice(0, 2)) { + assert.deepEqual( + dataSource.entities.values.map( + (entity) => entity.__gevTeleGeography?.label, + ), + ['Alpha cable', 'Alpha cable', 'Alpha cable', 'Beta cable'], + ); + } + assert.equal(env.dataSources[2].entities.values.length, 4); + assert.deepEqual(payload, before); + const entity = env.dataSources[0].entities.values[0]; + assert.equal(entity.properties.__gevSourceFeatureIndex.getValue(), 0); + assert.match(entity.description.getValue(), /__gevSourceFeatureIndex/); + } finally { + env.layer.destroy(env.viewer); + } +}); + +test('multipart cables retain their own styling, picking and culling on every segment', async () => { + const features = multipartCableFixture(); + const before = JSON.stringify(features); + const env = await createRealCableLayerHarness({ + source: geometrySource(features), + }); + try { + const entities = env.dataSources[0].entities.values; + assert.equal(entities.length, 4); + for (const [index, entity] of entities.entries()) { + const feature = features[index < 3 ? 0 : 1]; + const info = entity.__gevTeleGeography; + assert.equal(info?.label, feature.properties.name, entity.id); + assert.deepEqual(info.reference, { + lon: feature.properties.coordinates[0], + lat: feature.properties.coordinates[1], + }); + assert.deepEqual( + entity.polyline.material.color.getValue(), + Cesium.Color.fromCssColorString(feature.properties.color).withAlpha( + 0.92, + ), + ); + assert.equal(entity.polyline.width.getValue(), 2.5); + } + env.raiseSweep(); + assert.deepEqual( + entities.map((entity) => entity.show), + [true, true, true, false], + ); + assert.equal( + env.dataSources[2].entities.values.length, + 2, + 'one reference per feature', + ); + assert.equal( + JSON.stringify(features), + before, + 'source features stay unchanged', + ); + } finally { + env.layer.destroy(env.viewer); + } +}); + +test('multipart association supports missing and repeated IDs without guessing Cesium suffixes', async () => { + const features = multipartCableFixture(); + features[1].id = 'alpha_2'; + delete features[1].properties.id; + const generated = structuredClone(features[1]); + delete generated.id; + generated.properties.name = 'Generated cable'; + features.push(generated); + const repeated = structuredClone(features[1]); + repeated.properties.id = 'alpha'; + repeated.properties.name = 'Repeated ID cable'; + features.push(repeated); + const env = await createRealCableLayerHarness({ + source: geometrySource(features), + }); + try { + const entities = env.dataSources[0].entities.values; + assert.deepEqual( + entities.map((entity) => entity.id), + ['alpha', 'alpha_2', 'alpha_3', 'alpha_2_2', 'cable-2', 'alpha_4'], + ); + assert.deepEqual( + entities.map((entity) => entity.__gevTeleGeography?.label), + [ + 'Alpha cable', + 'Alpha cable', + 'Alpha cable', + 'Beta cable', + 'Generated cable', + 'Repeated ID cable', + ], + ); + assert.deepEqual( + env.dataSources[2].entities.values.map( + (entity) => entity.__gevTeleGeography.featureId, + ), + ['alpha', 'alpha_2', 'cable-2', 'alpha'], + ); + } finally { + env.layer.destroy(env.viewer); + } +}); + +test('multipart landing geometry keeps every pick with one reference per feature', async () => { + const features = multipartCableFixture(); + const env = await createRealCableLayerHarness({ + source: geometrySource([], features), + }); + try { + const entities = env.dataSources[1].entities.values; + assert.deepEqual( + entities.map((entity) => entity.__gevTeleGeography?.label), + ['Alpha cable', 'Alpha cable', 'Alpha cable', 'Beta cable'], + ); + assert.ok( + entities.every( + (entity) => entity.__gevTeleGeography.kind === 'landing-point', + ), + ); + assert.equal(env.dataSources[2].entities.values.length, 2); + } finally { + env.layer.destroy(env.viewer); + } +}); + +test('every bundled cable segment is associated with its source feature', async () => { + const { features } = JSON.parse( + readFileSync( + new URL( + './local_data/telegeography_submarine_cables/cable-geo.json', + import.meta.url, + ), + 'utf8', + ), + ); + // Some bundled features share a cable ID but have different references. + const expandedFeatures = features.flatMap((feature) => + feature.geometry.coordinates.map(() => feature), + ); + assert.ok(expandedFeatures.length > features.length); + const env = await createRealCableLayerHarness({ + source: geometrySource(features), + }); + try { + const entities = env.dataSources[0].entities.values; + assert.equal(entities.length, expandedFeatures.length); + for (const [index, entity] of entities.entries()) { + const feature = expandedFeatures[index]; + assert.equal( + entity.__gevTeleGeography?.label, + feature.properties.name, + entity.id, + ); + assert.deepEqual(entity.__gevTeleGeography.reference, { + lon: feature.properties.coordinates[0], + lat: feature.properties.coordinates[1], + }); + } + assert.equal(env.dataSources[2].entities.values.length, features.length); + } finally { + env.layer.destroy(env.viewer); + } +}); diff --git a/src/data/trafficTiming.test.mjs b/src/data/trafficTiming.test.mjs index 3cadbfb81..808fb1300 100644 --- a/src/data/trafficTiming.test.mjs +++ b/src/data/trafficTiming.test.mjs @@ -1,5 +1,8 @@ import assert from 'node:assert/strict'; import { readLayerSource } from '../testSupport/readLayerSource.mjs'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; import test from 'node:test'; import { fileURLToPath } from 'node:url'; @@ -136,6 +139,7 @@ test('traffic timing pairs real ordering to the scheduling change and guards re- const timeouts = new Map(); let timerId = 0; let server; + const cacheDir = mkdtempSync(path.join(tmpdir(), 'gev-traffic-timing-vite-')); let trafficLayer; let viewer; @@ -159,6 +163,12 @@ test('traffic timing pairs real ordering to the scheduling change and guards re- }; server = await createServer({ root: fileURLToPath(new URL('../..', import.meta.url)), + // Its own optimizer cache, not node_modules/.vite in the package tree: + // a host running Core's suite refuses files inside Core. + cacheDir, + // The test loads modules over SSR; the browser dependency optimizer would + // keep writing its cache after the server closes. + optimizeDeps: { noDiscovery: true, include: [] }, configFile: false, appType: 'custom', logLevel: 'silent', @@ -328,6 +338,7 @@ test('traffic timing pairs real ordering to the scheduling change and guards re- console.warn = originalWarn; performance.clearMarks(); performance.clearMeasures(); + rmSync(cacheDir, { recursive: true, force: true, maxRetries: 3 }); } }); diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs index 56548c1da..81c1cfda5 100644 --- a/src/firstRunExperience.test.mjs +++ b/src/firstRunExperience.test.mjs @@ -1029,10 +1029,11 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping // Panel adds timeout and named voice/provider targets; consolidated guidance // preserves the mission mapping and globe-rotation arguments. // S2 GhostMaps extends the generated layer enums; both feature contracts remain. - assert.equal(block.length, 31206, 'serialized tool schema length drifted'); + // The layer alias hint leaves out aliases the layer id already spells. + assert.equal(block.length, 30901, 'serialized tool schema length drifted'); assert.equal( crypto.createHash('sha256').update(block).digest('hex'), - 'f772df95404494bf492e02fde1beabffdfe61986940bffd0cf9ad5a38d768746', + '9e1bd8fde648953c47718ba248758415a95c1484b0da6369df6b89b0505c0de2', 'the first-run missions must ride EXISTING tools: no schema edit, no cache bust', ); const instructions = fs.readFileSync( diff --git a/src/layers/alpr/visuals.js b/src/layers/alpr/visuals.js index 2a9202d92..9f4d9899a 100644 --- a/src/layers/alpr/visuals.js +++ b/src/layers/alpr/visuals.js @@ -5,6 +5,7 @@ import { DIRECTION_CONE_HALF_ANGLE_DEG, MARKER_SCALE_BY_DISTANCE, } from './policy.js'; +import { roundHeadingDegrees } from '../../data/headingDegrees.js'; /** * Badge scale at a camera distance, matching Cesium's NearFarScalar for the @@ -49,9 +50,8 @@ export function alprLabelDetails(record, source) { const details = [ sourceName === 'OpenStreetMap' ? 'OSM MAPPED' : `Source: ${sourceName}`, ]; - // Wrap after rounding: 359.6° rounds to 360°, which is north. if (Number.isFinite(record.directionDeg)) - details.push(`DIRECTION ${Math.round(record.directionDeg) % 360}°`); + details.push(`DIRECTION ${roundHeadingDegrees(record.directionDeg)}°`); const equipment = [ ...new Set( [record.manufacturer, record.operator, record.cameraType] diff --git a/src/layers/awareness/panel.js b/src/layers/awareness/panel.js index af8f8bd84..c0c71ed01 100644 --- a/src/layers/awareness/panel.js +++ b/src/layers/awareness/panel.js @@ -10,6 +10,7 @@ import { } from '../../i18n/index.js'; import { formatQuantity } from '../../ui/unitLabels.js'; import { AWARENESS_PAGE_SIZE, AWARENESS_PAGE_ROTATE_MS } from './policy.js'; +import { distanceParts } from '../../data/distanceFormat.js'; /** Format a cohort total without presenting a retained cap as exact. */ export function formatAwarenessCount(summary = {}) { @@ -31,13 +32,11 @@ function presentAwarenessCount(summary = {}) { /** A distance as the panel shows it: 850 m, 4.6 km, 120 km, in the UI language. */ export function presentAwarenessDistance(meters) { - if (!Number.isFinite(meters) || meters < 0) return '—'; - const wholeMeters = Math.round(meters); - if (wholeMeters < 1000) return formatQuantity(wholeMeters, 'm'); - const digits = meters < 10000 ? 1 : 0; - return formatQuantity(meters / 1000, 'km', { - minimumFractionDigits: digits, - maximumFractionDigits: digits, + const parts = distanceParts(meters); + if (!parts) return '—'; + return formatQuantity(parts.value, parts.unit, { + minimumFractionDigits: parts.digits, + maximumFractionDigits: parts.digits, }); } diff --git a/src/layers/awareness/presentation.js b/src/layers/awareness/presentation.js index 7439907d9..854fee3b8 100644 --- a/src/layers/awareness/presentation.js +++ b/src/layers/awareness/presentation.js @@ -1,3 +1,5 @@ +import { roundHeadingDegrees } from '../../data/headingDegrees.js'; + /** * Format a compass direction for the awareness overlay. * @param {number} degrees @@ -5,6 +7,5 @@ */ export function formatAwarenessDirection(degrees) { if (!Number.isFinite(degrees)) return '—'; - const normalized = ((degrees % 360) + 360) % 360; - return String(Math.round(normalized) % 360).padStart(3, '0'); + return String(roundHeadingDegrees(degrees)).padStart(3, '0'); } diff --git a/src/layers/bikeshare/proxyRoute.test.mjs b/src/layers/bikeshare/proxyRoute.test.mjs index 01dd389c4..05e31fc03 100644 --- a/src/layers/bikeshare/proxyRoute.test.mjs +++ b/src/layers/bikeshare/proxyRoute.test.mjs @@ -451,3 +451,62 @@ test('unknown GBFS counts stay unknown through the source, model and station col assert.equal(model.toNonNegativeInteger('3'), 3); } }); + +test('a station without a published capacity re-derives it from every status poll', async () => { + const { createRendering } = await import('./rendering.js'); + const { COLOR_YELLOW } = await import('./policy.js'); + const model = createModel({}); + const state = { + _cityRuntime: new Map(), + _stationRenderMap: new Map(), + _pointCollection: { add: (options) => ({ ...options }) }, + }; + const rendering = createRendering({ + state, + services: { render: { governorRequestRender() {} } }, + parts: { model }, + }); + const info = model.parseStationInformation({ + data: { + stations: [ + { station_id: 'open', name: 'Open', lat: 30.26, lon: -97.74 }, + { + station_id: 'fixed', + name: 'Fixed', + lat: 30.27, + lon: -97.75, + capacity: 13, + }, + ], + }, + }); + rendering.ensureCityPoints('austin', info); + const poll = (stations) => + rendering.applyStatusToPoints( + 'austin', + model.parseStationStatus({ data: { stations } }), + ); + const open = state._stationRenderMap.get('austin:open'); + const fixed = state._stationRenderMap.get('austin:fixed'); + + // The first poll has no row for the station: capacity is a placeholder. + poll([ + { + station_id: 'fixed', + num_bikes_available: 4, + num_docks_available: 9, + }, + ]); + poll([ + { station_id: 'open', num_bikes_available: 10, num_docks_available: 20 }, + { station_id: 'fixed', num_bikes_available: 2, num_docks_available: 3 }, + ]); + assert.equal(open.capacity, 30, 'the first poll fixed the capacity'); + assert.equal(open.point.color, COLOR_YELLOW, '10 of 30 is not plenty'); + assert.equal(fixed.capacity, 13, 'a published capacity is kept'); + + poll([ + { station_id: 'open', num_bikes_available: 4, num_docks_available: 2 }, + ]); + assert.equal(open.capacity, 6); +}); diff --git a/src/layers/bikeshare/rendering.js b/src/layers/bikeshare/rendering.js index f0c737d1d..c75189d4a 100644 --- a/src/layers/bikeshare/rendering.js +++ b/src/layers/bikeshare/rendering.js @@ -185,6 +185,7 @@ export function createRendering({ stationName: station.name, point, capacity: parts.model.toNonNegativeInteger(station.capacity), + publishedCapacity: parts.model.toNonNegativeInteger(station.capacity), bikesAvailable: null, docksAvailable: null, isInstalled: station.isInstalled, @@ -245,7 +246,8 @@ export function createRendering({ if (!record) continue; const status = statusMap.get(record.stationId) || null; - const capacity = resolveCapacity(record.capacity, status); + // Resolve from the published capacity, not last poll's derived value. + const capacity = resolveCapacity(record.publishedCapacity, status); record.capacity = capacity; record.bikesAvailable = parts.model.toNonNegativeInteger( diff --git a/src/layers/cctv/headingConfidence.js b/src/layers/cctv/headingConfidence.js index fad0867de..1c9be36fe 100644 --- a/src/layers/cctv/headingConfidence.js +++ b/src/layers/cctv/headingConfidence.js @@ -1,4 +1,5 @@ import { t } from '../../i18n/index.js'; +import { roundHeadingDegrees } from '../../data/headingDegrees.js'; /** * @module headingConfidence @@ -44,8 +45,7 @@ export function isHeadingEstimated(camera) { * @returns {string} */ export function headingHudToken(camera) { - // 359.5° and above rounds to north; modulo keeps it from printing 360°. - const hdg = Math.round(Number(camera?.headingDeg) || 0) % 360; + const hdg = roundHeadingDegrees(Number(camera?.headingDeg)) ?? 0; return isHeadingEstimated(camera) ? t('HDG {value}° (ESTIMATED)', { value: hdg }) : t('HDG {value}°', { value: hdg }); diff --git a/src/layers/directions/index.js b/src/layers/directions/index.js index ffc1497f1..1dcb9e347 100644 --- a/src/layers/directions/index.js +++ b/src/layers/directions/index.js @@ -31,6 +31,7 @@ import { quantitySeparator, unitLabel, } from '../../ui/unitLabels.js'; +import { distanceParts } from '../../data/distanceFormat.js'; export const DIRECTIONS_STEP_OVERLAY_SOURCE_ID = 'directions-step'; export const DIRECTIONS_STEP_OVERLAY_SOURCE_OPTIONS = Object.freeze({ @@ -108,17 +109,12 @@ export const FLIGHT_PROGRESS_MS = 250; * @returns {string} */ export function routeDistanceText(meters) { - if (!Number.isFinite(meters) || meters < 0) return ''; - if (meters < 100) return formatQuantity(Math.round(meters), 'm'); - const tens = Math.round(meters / 10) * 10; - if (tens < 1000) return formatQuantity(tens, 'm'); - const km = meters / 1000; - return km < 10 - ? formatQuantity(km, 'km', { - minimumFractionDigits: 1, - maximumFractionDigits: 1, - }) - : formatQuantity(Math.round(km), 'km'); + const parts = distanceParts(meters, { metreStep: 10, exactBelowM: 100 }); + if (!parts) return ''; + return formatQuantity(parts.value, parts.unit, { + minimumFractionDigits: parts.digits, + maximumFractionDigits: parts.digits, + }); } /** diff --git a/src/layers/flights/ownership.test.mjs b/src/layers/flights/ownership.test.mjs index 813df5641..16c1a9cc6 100644 --- a/src/layers/flights/ownership.test.mjs +++ b/src/layers/flights/ownership.test.mjs @@ -3,6 +3,7 @@ import assert from 'node:assert/strict'; import { createCivilFlightLayer } from './index.js'; import { createFlightState } from './state.js'; import { createEnrichment } from './enrichment.js'; +import { createTracking } from './tracking.js'; function services() { const names = [ @@ -124,3 +125,24 @@ test('a synchronous enrichment failure stays bounded and releases its slot', asy await new Promise((done) => setImmediate(done)); assert.equal(flightState._enrichActive, 0); }); + +test('voice context reads a track that rounds to north as 0°, not 360°', () => { + const tracking = createTracking({ + flightState: { + feed: { _lastSource: 'Fixture aircraft' }, + records: { data: new Map() }, + }, + services: services(), + parts: { + queries: { + _describeFlight: () => ({ track: 359.6, altitudeM: 9000 }), + _contactLabel: () => 'AAL1', + }, + }, + layer: {}, + }); + assert.equal( + tracking._contextSubjectMetadata('abc123').properties.heading, + '0°', + ); +}); diff --git a/src/layers/flights/tracking.js b/src/layers/flights/tracking.js index dde8f4f95..59270892e 100644 --- a/src/layers/flights/tracking.js +++ b/src/layers/flights/tracking.js @@ -24,6 +24,7 @@ import { TRAIL_COLOR, CYAN_TRANSPARENT, } from './policy.js'; +import { roundHeadingDegrees } from '../../data/headingDegrees.js'; export function createTracking({ flightState, @@ -126,7 +127,7 @@ export function createTracking({ ? `${Math.round(described.velocityMps * 1.944)} kt` : '', heading: Number.isFinite(described.track) - ? `${Math.round(described.track)}°` + ? `${roundHeadingDegrees(described.track)}°` : '', route: route || '', icao24, diff --git a/src/layers/launches/replay.js b/src/layers/launches/replay.js index 037eff225..86b6a08f0 100644 --- a/src/layers/launches/replay.js +++ b/src/layers/launches/replay.js @@ -139,8 +139,10 @@ export function createReplay({ state: layerState, services, parts, source }) { 0, Number(preCountdownDurationSec) || 0, ); + // The hold precedes the whole countdown; ending it at -hold cut T-10 to T-5. const preCountdownActive = - preCountdownDuration > 0 && realSecondsSinceStart < -preCountdownDuration; + preCountdownDuration > 0 && + realSecondsSinceStart < -REPLAY_COUNTDOWN_DURATION_SEC; const countdownActive = realSecondsSinceStart < 0 && !preCountdownActive; const countdownSeconds = countdownActive ? Math.ceil(-realSecondsSinceStart) @@ -247,7 +249,8 @@ export function createReplay({ state: layerState, services, parts, source }) { ? layerState._replayPausedAtMs : Date.now(); for (const [launchId, startedAt] of layerState._animationStarts) { - if (!Number.isFinite(startedAt)) continue; + // A countdown runs in real time; rescaling it turned T-8 into T-32 at 0.25x. + if (!Number.isFinite(startedAt) || startedAt > now) continue; const elapsedMissionMs = (now - startedAt) * previousSpeed; layerState._animationStarts.set( launchId, diff --git a/src/layers/launches/replay.test.mjs b/src/layers/launches/replay.test.mjs new file mode 100644 index 000000000..b0700d0f3 --- /dev/null +++ b/src/layers/launches/replay.test.mjs @@ -0,0 +1,47 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createState } from './state.js'; +import { createReplay } from './replay.js'; +import { createPaths } from './paths.js'; + +const noop = () => {}; + +function replayRig() { + const state = createState({ + services: { + overlays: { + setOverlayEntries: noop, + setOverlaySourceVisible: noop, + clearOverlaySource: noop, + }, + }, + }); + const parts = { panel: { publishPanel: noop } }; + parts.paths = createPaths({ state, parts }); + return { state, replay: createReplay({ state, parts }) }; +} + +test('changing replay speed during the countdown keeps its real-time liftoff', (t) => { + t.mock.timers.enable({ apis: ['Date'], now: 1_000_000 }); + const { state, replay } = replayRig(); + const launch = { launchTime: '2026-07-20T10:00:00Z', timeline: [] }; + state._animationStarts.set('countdown', 1_008_000); // T-8 + state._animationStarts.set('flying', 990_000); // 10 s into the mission + + replay.setReplaySpeed(0.25); + + const startedAt = state._animationStarts.get('countdown'); + assert.equal(startedAt, 1_008_000); + const countdown = replay.replayState( + launch, + startedAt, + 12, + 28, + 5400, + state._replaySpeed, + Date.now(), + ); + assert.equal(countdown.countdownSeconds, 8); + // Mission time already flown is still preserved across the change. + assert.equal(state._animationStarts.get('flying'), 960_000); +}); diff --git a/src/layers/localAdsb/card.js b/src/layers/localAdsb/card.js index c1c923a8c..a17ba93c1 100644 --- a/src/layers/localAdsb/card.js +++ b/src/layers/localAdsb/card.js @@ -1,6 +1,7 @@ import { AIRCRAFT_CLASS_LABELS } from '../../data/aircraftClass.js'; import { formatNumber, t, tn } from '../../i18n/index.js'; import { BAND_LABELS, LOCAL_ADSB_COLOR, SOURCE_LABELS } from './policy.js'; +import { roundHeadingDegrees } from '../../data/headingDegrees.js'; const DASH = '—'; @@ -152,7 +153,7 @@ export function localAdsbCardModel( t('ALT {altitude} FT · GS {speed} KT · TRK {track}', { altitude: grouped(altitude), speed: speed === null ? DASH : speed, - track: track === null ? DASH : `${track % 360}°`, + track: track === null ? DASH : `${roundHeadingDegrees(track)}°`, }), vertical === null ? t('V/S {rate}', { rate: DASH }) diff --git a/src/layers/military/ownership.test.mjs b/src/layers/military/ownership.test.mjs index 41081c137..49aebee76 100644 --- a/src/layers/military/ownership.test.mjs +++ b/src/layers/military/ownership.test.mjs @@ -2,6 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { createMilitaryFlightLayer } from './index.js'; import { createFlightState } from './state.js'; +import { createTracking } from './tracking.js'; function services() { const names = [ @@ -211,3 +212,27 @@ test('a newly seen military contact appears at its delayed position, not ahead o `expected about ${Math.round(expectedM)} m behind the fix, got ${Math.round(behindM)} m`, ); }); + +test('voice context reads a track that rounds to north as 0°, not 360°', () => { + const stubs = services(); + stubs.aircraftPresentation = { tr3bTypeLabel: (id, type) => type }; + const tracking = createTracking({ + flightState: { + feed: { _lastSource: 'Fixture aircraft' }, + records: { data: new Map() }, + }, + services: stubs, + parts: { + queries: { + _describeFlight: () => ({ track: 359.6, callsign: 'RCH123' }), + _toCleanText: (value) => value || '', + _formatAltitude: () => '', + }, + }, + layer: {}, + }); + assert.equal( + tracking._contextSubjectMetadata('ae1234').properties.heading, + '0°', + ); +}); diff --git a/src/layers/military/tracking.js b/src/layers/military/tracking.js index 8c9723176..5ad60adf9 100644 --- a/src/layers/military/tracking.js +++ b/src/layers/military/tracking.js @@ -25,6 +25,7 @@ import { AMBER_TRANSPARENT, TRACKED_ICON_COLOR, } from './policy.js'; +import { roundHeadingDegrees } from '../../data/headingDegrees.js'; export function createTracking({ flightState, @@ -120,7 +121,7 @@ export function createTracking({ ? `${Math.round(described.velocityMps * 1.944)} kt` : '', heading: Number.isFinite(described.track) - ? `${Math.round(described.track)}°` + ? `${roundHeadingDegrees(described.track)}°` : '', icao24, // Honesty cue: the contact is coasting on dead reckoning, so the diff --git a/src/layers/satellites/catalog.js b/src/layers/satellites/catalog.js index 097d9e3be..f4a347af2 100644 --- a/src/layers/satellites/catalog.js +++ b/src/layers/satellites/catalog.js @@ -1,6 +1,6 @@ import * as Cesium from 'cesium'; import { twoline2satrec } from 'satellite.js'; -import { noradIdFromSatnum } from '../../sources/tle.js'; +import { noradIdFromSatnum, tleLineLaunchYear } from '../../sources/tle.js'; import { DENSE_REFRESH_FRAMES, DENSE_GROUP_PATH, @@ -123,6 +123,7 @@ export function createCatalog({ state: layerState, services, parts, source }) { name: entry.name, satrec, group: 'dense', + launchYear: tleLineLaunchYear(entry.line1), }); const point = layerState._pointCollection.add({ position: Cesium.Cartesian3.fromDegrees( diff --git a/src/layers/satellites/ingestion.js b/src/layers/satellites/ingestion.js index 01653611c..d2cc16e15 100644 --- a/src/layers/satellites/ingestion.js +++ b/src/layers/satellites/ingestion.js @@ -1,7 +1,7 @@ import { twoline2satrec } from 'satellite.js'; import * as Cesium from 'cesium'; import { msg } from '../../i18n/index.js'; -import { noradIdFromSatnum } from '../../sources/tle.js'; +import { noradIdFromSatnum, tleLineLaunchYear } from '../../sources/tle.js'; import { CATALOG_GROUPS, ISS_NORAD, POINT_STYLES } from './policy.js'; export function createIngestion({ @@ -129,6 +129,8 @@ export function createIngestion({ name: entry.name, satrec, group: entry.group, + // satellite.js does not keep the designator the year match reads. + launchYear: tleLineLaunchYear(entry.line1), }); coreStale ||= entry.stale; diff --git a/src/layers/satellites/ingestion.test.mjs b/src/layers/satellites/ingestion.test.mjs index 313d999ee..beea1a518 100644 --- a/src/layers/satellites/ingestion.test.mjs +++ b/src/layers/satellites/ingestion.test.mjs @@ -1,7 +1,8 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { parseTleText } from '../../sources/tle.js'; +import { parseTleText, tleLineLaunchYear } from '../../sources/tle.js'; import { createIngestion } from './ingestion.js'; +import { createOrbits } from './orbits.js'; // The ISS set (1 January 2024) with its catalog field swapped per satellite. const ISS_LINE1 = @@ -78,3 +79,43 @@ test('every Alpha-5 satellite keeps its own catalog entry through dedupe', async assert.deepEqual([...state._points.keys()], [100000, 110001, 339999]); assert.equal(state._count, 3); }); + +test('a mission payload does not match a loaded satellite from another launch year', async (t) => { + t.mock.method(console, 'log', () => {}); + t.mock.method(console, 'warn', () => {}); + // SXM-11, international designator 2026-148A. + const { state, update } = ingestionFor( + [ + 'SXM-11', + '1 69728U 26148A 26208.65166678 .00000015 00000+0 00000+0 0 9990', + '2 69728 0.0784 264.9826 0001797 240.4588 273.9506 1.00271527 440', + ].join('\n'), + ); + await update(); + const { getSatelliteOrbitTrack } = createOrbits({ state }); + const track = (launchTime) => + getSatelliteOrbitTrack('Sirius SXM-11', { launchTime }); + assert.equal(track('2026-06-29T02:25:00Z')?.noradId, 69728); + assert.equal(track('2030-06-29T02:25:00Z'), null); +}); + +test('a satellite with a blank designator keeps matching by name', async (t) => { + t.mock.method(console, 'log', () => {}); + t.mock.method(console, 'warn', () => {}); + const { state, update } = ingestionFor( + [ + 'SXM-11', + '1 69728U 26208.65166678 .00000015 00000+0 00000+0 0 9990', + '2 69728 0.0784 264.9826 0001797 240.4588 273.9506 1.00271527 440', + ].join('\n'), + ); + await update(); + const { getSatelliteOrbitTrack } = createOrbits({ state }); + assert.equal(tleLineLaunchYear('1 69728U 26208.65166678'), null); + assert.equal( + getSatelliteOrbitTrack('Sirius SXM-11', { + launchTime: '2030-06-29T02:25:00Z', + })?.noradId, + 69728, + ); +}); diff --git a/src/layers/satellites/orbits.js b/src/layers/satellites/orbits.js index 0e4461e85..a2bfbd939 100644 --- a/src/layers/satellites/orbits.js +++ b/src/layers/satellites/orbits.js @@ -8,7 +8,11 @@ import { twoline2satrec, } from 'satellite.js'; import { findNextSatellitePass } from '../../data/satellitePass.js'; -import { noradIdFromSatnum, parseTleText } from '../../sources/tle.js'; +import { + noradIdFromSatnum, + parseTleText, + tleLineLaunchYear, +} from '../../sources/tle.js'; import { ORBIT_PATH_STEPS, ISS_NORAD } from './policy.js'; export function createOrbits({ state: layerState, services, parts, source }) { @@ -258,13 +262,6 @@ export function createOrbits({ state: layerState, services, parts, source }) { return score; } - function internationalDesignatorYear(satrec) { - const match = String(satrec?.intldesg || '').match(/^(\d{2})/); - if (!match) return null; - const shortYear = Number(match[1]); - return shortYear >= 57 ? 1900 + shortYear : 2000 + shortYear; - } - function lookupTleEntries(tleText) { if (tleText !== layerState._lookupTleText) { layerState._lookupTleText = tleText; @@ -273,12 +270,6 @@ export function createOrbits({ state: layerState, services, parts, source }) { return layerState._lookupTleEntries; } - function tleLineLaunchYear(line1) { - const shortYear = Number(String(line1 || '').slice(9, 11)); - if (!Number.isFinite(shortYear)) return null; - return shortYear >= 57 ? 1900 + shortYear : 2000 + shortYear; - } - function orbitTrackFromRecord(name, satrec) { const referenceDate = new Date(); const current = propagatePosition(satrec, referenceDate); @@ -350,7 +341,7 @@ export function createOrbits({ state: layerState, services, parts, source }) { : null; let bestScore = 0; for (const [id, sat] of layerState._catalog) { - const designatorYear = internationalDesignatorYear(sat.satrec); + const designatorYear = sat.launchYear ?? null; if ( launchYear !== null && designatorYear !== null && @@ -379,7 +370,6 @@ export function createOrbits({ state: layerState, services, parts, source }) { getNextSatellitePass, resolveSatelliteForPass, scoreSatelliteNameMatch, - internationalDesignatorYear, lookupTleEntries, tleLineLaunchYear, orbitTrackFromRecord, diff --git a/src/layers/satellites/stale.test.mjs b/src/layers/satellites/stale.test.mjs index 89b4d2dbf..9cca23610 100644 --- a/src/layers/satellites/stale.test.mjs +++ b/src/layers/satellites/stale.test.mjs @@ -235,6 +235,20 @@ test('dense provenance belongs to its admitted records and clears on removal', a ); }); +test('dense extras keep their launch year for mission orbit matching', async (t) => { + const f = await fixture(t); + await f.update(); + f.state._params.catalog = 'dense'; + assert.equal((await f.parts.catalog._loadDenseCatalog()).status, 'ready'); + // Every fixture set carries the 1998-067A designator. + const track = (launchTime) => + f.parts.orbits.getSatelliteOrbitTrack('OWNED ORBIT FIXTURE 25551', { + launchTime, + }); + assert.equal(track('1998-11-20T06:40:00Z')?.noradId, 25551); + assert.equal(track('2026-06-29T02:25:00Z'), null); +}); + test('cancelling dense replacement preserves admitted stale extras until rebuild', async (t) => { const f = await fixture(t); await f.update(); diff --git a/src/layers/submarineCables/ingestion.js b/src/layers/submarineCables/ingestion.js index 5468996d5..714b8753b 100644 --- a/src/layers/submarineCables/ingestion.js +++ b/src/layers/submarineCables/ingestion.js @@ -6,6 +6,21 @@ import { } from './geometry.js'; import * as Cesium from 'cesium'; +const SOURCE_FEATURE_INDEX = '__gevSourceFeatureIndex'; + +function indexedCollection(features) { + return { + type: 'FeatureCollection', + features: features.map((feature, index) => ({ + ...feature, + properties: { + ...feature.properties, + [SOURCE_FEATURE_INDEX]: index, + }, + })), + }; +} + export function createIngestion({ state, parts, source }) { async function load(viewer) { if (state._loading || state._loaded) return; @@ -49,7 +64,7 @@ export function createIngestion({ state, parts, source }) { const landingFeatures = normalizeFeatures(landingJson, 'landing'); const cableDataSource = await Cesium.GeoJsonDataSource.load( - { type: 'FeatureCollection', features: cableFeatures }, + indexedCollection(cableFeatures), { clampToGround: true, stroke: state.cableColor.withAlpha(0.95), @@ -61,7 +76,7 @@ export function createIngestion({ state, parts, source }) { ); if (!owns()) return; const landingDataSource = await Cesium.GeoJsonDataSource.load( - { type: 'FeatureCollection', features: landingFeatures }, + indexedCollection(landingFeatures), { clampToGround: true, stroke: state.landingColor.withAlpha(0.9), @@ -131,7 +146,12 @@ export function createIngestion({ state, parts, source }) { state._referenceRecords = []; state._surfaceRecords = []; - cableEntities.forEach((entity, index) => { + // Cesium expands multipart geometry into several entities and suffixes + // colliding IDs. Its copied properties retain this source index even + // when a provider has no ID, so every segment keeps the right owner. + const cableReferences = new Set(); + cableEntities.forEach((entity) => { + const index = entity.properties?.[SOURCE_FEATURE_INDEX]?.getValue(); const feature = cableFeatures[index]; const reference = featureReference(feature); if (!reference) return; @@ -146,6 +166,8 @@ export function createIngestion({ state, parts, source }) { entity, base: Cesium.Cartesian3.fromDegrees(reference.lon, reference.lat, 0), }); + if (cableReferences.has(index)) return; + cableReferences.add(index); parts.rendering.addReferenceStem({ reference, label: featureLabel(feature), @@ -155,7 +177,9 @@ export function createIngestion({ state, parts, source }) { }); }); - landingEntities.forEach((entity, index) => { + const landingReferences = new Set(); + landingEntities.forEach((entity) => { + const index = entity.properties?.[SOURCE_FEATURE_INDEX]?.getValue(); const feature = landingFeatures[index]; const reference = featureReference(feature); if (!reference) return; @@ -170,6 +194,8 @@ export function createIngestion({ state, parts, source }) { entity, base: Cesium.Cartesian3.fromDegrees(reference.lon, reference.lat, 0), }); + if (landingReferences.has(index)) return; + landingReferences.add(index); parts.rendering.addReferenceStem({ reference, label: featureLabel(feature), diff --git a/src/layers/traffic/controls.js b/src/layers/traffic/controls.js index 01af66633..6570c7958 100644 --- a/src/layers/traffic/controls.js +++ b/src/layers/traffic/controls.js @@ -259,7 +259,8 @@ export function createControls({ state: layerState, services, parts, source }) { * `error` carries this instant's health, so a live-configured layer whose * flow feed went down reads DEGRADED with the reason instead of a stale * LIVE coverage number. `flowCoveragePct` is shown dots on matched roads / - * all shown dots (0–100 int), excluding closures; `tilesFetched` counts flow-tile requests + * all shown dots (0–100 int, kept within 1–99 while both kinds are shown), + * excluding closures; `tilesFetched` counts flow-tile requests * issued to the proxy this session (decode-cache hits excluded). * @returns {{count:number, lastUpdate:number|null, loading:boolean, * mode:'live'|'sim', error:string|null, flowCoveragePct:number, @@ -304,8 +305,18 @@ export function createControls({ state: layerState, services, parts, source }) { Boolean(layerState._heightPending); const { free, slow, jam, sim } = layerState._bucketCounts; const matched = free + slow + jam; + // The label reads 0 as "no matches" and 100 as nothing simulated, so + // rounding must not reach either while both kinds of dot are shown. const flowCoveragePct = - matched + sim > 0 ? Math.round((100 * matched) / (matched + sim)) : 0; + matched + sim > 0 + ? Math.min( + sim > 0 ? 99 : 100, + Math.max( + matched > 0 ? 1 : 0, + Math.round((100 * matched) / (matched + sim)), + ), + ) + : 0; const feed = parts.model.trafficFeedPresentation({ liveMode: layerState._liveMode, fetching: loading, diff --git a/src/layers/traffic/source.test.mjs b/src/layers/traffic/source.test.mjs index 028d8f642..779164b7c 100644 --- a/src/layers/traffic/source.test.mjs +++ b/src/layers/traffic/source.test.mjs @@ -519,6 +519,26 @@ test('late closures hide and stop dots; coverage counts shown matched dots, and assert.equal(controls.getStats().flowCoveragePct, 80); }); +test('coverage never rounds to "no matches" or full coverage while both kinds of dot are shown', async () => { + const { state, controls } = await matchingContext(async () => []); + state._bucketCounts = { free: 1, slow: 0, jam: 0, sim: 300 }; + assert.equal(controls.getStats().flowCoveragePct, 1); + assert.equal( + controls.getStats().loadingLabel, + 'LIVE · Roads: OpenStreetMap · Flow: TomTom · 1% cov · Unmatched: simulated', + ); + state._bucketCounts = { free: 300, slow: 0, jam: 0, sim: 1 }; + assert.equal(controls.getStats().flowCoveragePct, 99); + assert.equal( + controls.getStats().loadingLabel, + 'LIVE · Roads: OpenStreetMap · Flow: TomTom · 99% cov · Unmatched: simulated', + ); + state._bucketCounts = { free: 300, slow: 0, jam: 0, sim: 0 }; + assert.equal(controls.getStats().flowCoveragePct, 100); + state._bucketCounts = { free: 0, slow: 0, jam: 0, sim: 300 }; + assert.equal(controls.getStats().flowCoveragePct, 0); +}); + test('refresh failure removes cached matches and reports simulated flow', async () => { const { flow, state, model, controls } = await matchingContext(async () => { throw new Error('HTTP 429'); diff --git a/src/layers/transit/policy.js b/src/layers/transit/policy.js index 33e03aab4..eabe5df4a 100644 --- a/src/layers/transit/policy.js +++ b/src/layers/transit/policy.js @@ -9,6 +9,7 @@ import { transitRouteLabel, } from '../../data/transitFeeds.js'; import { displayMotion, playbackPosition } from './movement.js'; +import { roundHeadingDegrees } from '../../data/headingDegrees.js'; export const TRANSIT_SELECTED_OVERLAY_SOURCE_ID = 'transit-selected'; export const TRANSIT_SELECTED_OVERLAY_SOURCE_OPTIONS = Object.freeze({ @@ -405,11 +406,10 @@ export function buildTransitSelectionCopy( reportedMotion.push( t('reported {speed} km/h', { speed: Math.round(record.speedMps * 3.6) }), ); - // 359.5° and above rounds to north; modulo keeps it from printing 360°. if (Number.isFinite(record.bearing)) reportedMotion.push( t('reported hdg {heading}°', { - heading: Math.round(record.bearing) % 360, + heading: roundHeadingDegrees(record.bearing), }), ); // The operator's own claim, kept as a REPORT rather than as the live state. diff --git a/src/layers/vessels/cards.js b/src/layers/vessels/cards.js index 2cab53ed1..bcf3459f1 100644 --- a/src/layers/vessels/cards.js +++ b/src/layers/vessels/cards.js @@ -4,6 +4,7 @@ import { normalizeVesselType, } from '../../data/vesselLabels.js'; import { formatNumber, t } from '../../i18n/index.js'; +import { roundHeadingDegrees } from '../../data/headingDegrees.js'; export function createCards({ vesselState, @@ -55,10 +56,8 @@ export function createCards({ if (record.speed !== null && record.speed !== undefined) parts.push(formatSpeed(record.speed)); const direction = record.heading ?? record.course; - // A course of 359.5° and above rounds to north; modulo keeps it from - // printing 360°. The selected card and the HUD wrap the same way. if (Number.isFinite(direction)) - parts.push(`${Math.round(direction) % 360}°`); + parts.push(`${roundHeadingDegrees(direction)}°`); return { id: vesselOverlayEntryId(record), actionable: Boolean(record?.mmsi), @@ -89,7 +88,9 @@ export function createCards({ [ vesselTypeShort(record) || t('VESSEL'), formatSpeed(record.speed), - Number.isFinite(direction) ? `${Math.round(direction) % 360}°` : '--°', + Number.isFinite(direction) + ? `${roundHeadingDegrees(direction)}°` + : '--°', ].join(' · '), ]; const destination = String(record.destination || '').trim(); @@ -179,7 +180,7 @@ export function createCards({ function formatHeading(heading) { return t('{heading}DEG', { - heading: Number.isFinite(heading) ? Math.round(heading) % 360 : '--', + heading: roundHeadingDegrees(heading) ?? '--', }); } diff --git a/src/overpassOffload.test.mjs b/src/overpassOffload.test.mjs index 602bab8e4..7ef6b0c2b 100644 --- a/src/overpassOffload.test.mjs +++ b/src/overpassOffload.test.mjs @@ -54,14 +54,22 @@ async function call(handlers, url, init = {}) { Object.assign(req, { method: init.method || 'GET', url: parsed.pathname.slice(mount.length) + parsed.search || '/', - headers: {}, + headers: init.headers || {}, socket: { remoteAddress: '127.0.0.1' }, }); - let status, headers, body; + let status, body; + let headers = {}; await handlers.get(mount)(req, { + // The cross-site gate answers through statusCode/setHeader, not writeHead. + set statusCode(s) { + status = s; + }, + setHeader(name, value) { + headers[name] = value; + }, writeHead(s, h) { status = s; - headers = h; + headers = { ...headers, ...h }; }, end(b) { body = b; @@ -166,6 +174,36 @@ test('zero egress: every Overpass consumer reaches real default handlers, and re ); }); +test('the installations proxy refuses cross-site and forwarded requests before any Overpass work', async (t) => { + env(t); + t.mock.method(globalThis, 'fetch', () => + assert.fail('unexpected upstream fetch'), + ); + const handlers = routes(militaryInstallationsProxy()); + const url = + '/api/military-installations?south=48.8&west=2.2&north=48.9&east=2.4'; + for (const headers of [ + { 'sec-fetch-site': 'cross-site' }, + { 'sec-fetch-site': 'same-site' }, + { origin: 'https://foreign.example' }, + { origin: 'null' }, + { 'sec-fetch-site': 'same-origin', 'x-forwarded-for': '203.0.113.9' }, + ]) { + const response = await call(handlers, url, { headers }); + assert.equal(response.status, 403, JSON.stringify(headers)); + assert.equal(response.headers.get('cache-control'), 'no-store'); + } + for (const headers of [ + {}, + { origin: 'http://localhost:4173', 'sec-fetch-site': 'same-origin' }, + ]) { + const response = await call(handlers, url, { + headers: { host: 'localhost:4173', ...headers }, + }); + assert.notEqual(response.status, 403, JSON.stringify(headers)); + } +}); + test('an unconfigured server is probed once and never sent a feature query', async (t) => { env(t); t.mock.method(globalThis, 'fetch', () => diff --git a/src/sources/adsbFields.js b/src/sources/adsbFields.js new file mode 100644 index 000000000..4d4822e2a --- /dev/null +++ b/src/sources/adsbFields.js @@ -0,0 +1,16 @@ +/** A readsb rate field: a finite JSON number, else null. */ +function rateField(value) { + return typeof value === 'number' && Number.isFinite(value) ? value : null; +} + +/** + * Vertical rate in ft/min from a readsb-style aircraft row (readsb, tar1090, + * adsb.lol): the barometric `baro_rate`, else the GNSS-sourced `geom_rate` + * readsb sends instead when it has no barometric rate. A barometric 0 is kept. + * Only a finite number counts; null when neither field has one. + * @param {object|null|undefined} row + * @returns {number|null} + */ +export function readAdsbVerticalRateFpm(row) { + return rateField(row?.baro_rate) ?? rateField(row?.geom_rate); +} diff --git a/src/sources/adsbFields.test.mjs b/src/sources/adsbFields.test.mjs new file mode 100644 index 000000000..68fc52f79 --- /dev/null +++ b/src/sources/adsbFields.test.mjs @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { readAdsbVerticalRateFpm } from './adsbFields.js'; + +test('the barometric rate wins, a barometric 0 included', () => { + assert.equal( + readAdsbVerticalRateFpm({ baro_rate: -640, geom_rate: -832 }), + -640, + ); + assert.equal(readAdsbVerticalRateFpm({ baro_rate: 0, geom_rate: 600 }), 0); +}); + +test('a GNSS rate stands in when no barometric rate is usable', () => { + for (const baro_rate of [ + undefined, + null, + Number.NaN, + Infinity, + '300', + false, + ]) { + assert.equal(readAdsbVerticalRateFpm({ baro_rate, geom_rate: -832 }), -832); + } +}); + +test('no usable rate reads as null', () => { + assert.equal(readAdsbVerticalRateFpm({}), null); + assert.equal(readAdsbVerticalRateFpm(null), null); + assert.equal( + readAdsbVerticalRateFpm({ baro_rate: '300', geom_rate: true }), + null, + ); +}); diff --git a/src/sources/adsbRecords.js b/src/sources/adsbRecords.js index 814da60b2..afa952503 100644 --- a/src/sources/adsbRecords.js +++ b/src/sources/adsbRecords.js @@ -30,6 +30,8 @@ * This module is portable: no Cesium, DOM, network or storage access. */ +import { readAdsbVerticalRateFpm } from './adsbFields.js'; + /** A marker is dropped once its newest position is this old. */ export const LOCAL_ADSB_POSITION_STALE_MS = 60_000; /** An aircraft is forgotten once no message has arrived for this long. */ @@ -247,7 +249,7 @@ export function normalizeDump1090Aircraft(json, nowMs, { band = '1090' } = {}) { altitudeFt: altitude, groundSpeedKt: finiteOrNull(entry.gs), trackDeg: normalizeTrack(entry.track), - verticalRateFpm: finiteOrNull(entry.baro_rate), + verticalRateFpm: readAdsbVerticalRateFpm(entry), lastPositionAt: hasPosition ? receivedAt - Math.max(0, seenPosS) * 1000 : null, diff --git a/src/sources/adsbRecords.test.mjs b/src/sources/adsbRecords.test.mjs index 12070d47b..aa14133b6 100644 --- a/src/sources/adsbRecords.test.mjs +++ b/src/sources/adsbRecords.test.mjs @@ -169,6 +169,20 @@ test('a feed document names its band on every record', () => { assert.ok(uat.every((record) => record.source === 'feed')); }); +test('a decoder feed reads a GNSS-sourced vertical rate when no baro rate is sent', () => { + // dump1090-fa and readsb file a velocity message's rate under geom_rate + // instead of baro_rate when its source bit says GNSS. + const rate = (fields) => + normalizeDump1090Aircraft( + { aircraft: [{ hex: 'abc123', seen: 1, ...fields }] }, + 10_000, + )[0].verticalRateFpm; + assert.equal(rate({ geom_rate: -832 }), -832); + assert.equal(rate({ baro_rate: -768, geom_rate: -832 }), -768); + assert.equal(rate({ baro_rate: 0, geom_rate: -832 }), 0); + assert.equal(rate({}), null); +}); + function heard(overrides = {}) { return { icao: 'abc123', diff --git a/src/sources/live/aircraft.js b/src/sources/live/aircraft.js index 1af1f6b47..19472a92a 100644 --- a/src/sources/live/aircraft.js +++ b/src/sources/live/aircraft.js @@ -6,6 +6,7 @@ import { epoch, finite, } from './contract.js'; +import { readAdsbVerticalRateFpm } from '../adsbFields.js'; const openSkyId = (row) => Array.isArray(row) ? cleanText(row[0]).toLowerCase() : ''; @@ -48,7 +49,7 @@ export function normalizeReadsbAircraft(row, snapshotTimeMs) { const baroFt = finite(row.alt_baro), geoFt = finite(row.alt_geom); const speedKts = finite(row.gs), - rateFtMin = finite(row.baro_rate); + rateFtMin = readAdsbVerticalRateFpm(row); const seen = finite(row.seen), seenPos = finite(row.seen_pos); return { diff --git a/src/sources/live/contract.test.mjs b/src/sources/live/contract.test.mjs index b8b1787da..79626f6b3 100644 --- a/src/sources/live/contract.test.mjs +++ b/src/sources/live/contract.test.mjs @@ -145,6 +145,20 @@ test('readsb units and position/contact ages are normalized once against the sou ); }); +test('readsb vertical rate falls back to the GNSS rate when no baro rate is sent', () => { + // readsb reports the rate under geom_rate when the aircraft's source is + // GNSS; 26 of 157 positioned rows in a live /v2/mil fetch had only that. + const row = { hex: 'ae638d', lat: 20.3, lon: -158.5, geom_rate: -2432 }; + assert.equal( + normalizeReadsbAircraft(row, now).verticalRateMps, + -2432 * 0.00508, + ); + assert.equal( + normalizeReadsbAircraft({ ...row, baro_rate: -2400 }, now).verticalRateMps, + -2400 * 0.00508, + ); +}); + test('vessel identity, opaque reference and heading/course remain distinct, unknowns stay null', () => { const row = normalizeVesselObservation( { diff --git a/src/sources/tle.js b/src/sources/tle.js index 61c409a2b..b34280787 100644 --- a/src/sources/tle.js +++ b/src/sources/tle.js @@ -72,3 +72,16 @@ export function tleCatalogNumber(line) { const field = String(line).slice(2, 7); return decodeCatalogField(field.replace(/^ +/, '')); } + +/** + * Launch year from TLE line 1's international designator (columns 10-11); + * null when the designator is blank. + * @param {string} line1 + * @returns {number|null} + */ +export function tleLineLaunchYear(line1) { + const digits = String(line1 || '').slice(9, 11); + if (!/^\d{2}$/.test(digits)) return null; + const shortYear = Number(digits); + return shortYear >= 57 ? 1900 + shortYear : 2000 + shortYear; +} diff --git a/src/tools/queries/cables.js b/src/tools/queries/cables.js index 8d8c0cecf..a4f020150 100644 --- a/src/tools/queries/cables.js +++ b/src/tools/queries/cables.js @@ -57,6 +57,7 @@ export const findSubmarineCables = defineTool({ Number.isFinite(point.lat) && (!area || areaContains(area, point)), ); + const seen = new Set(); const rows = (cables?.features || []) .filter((feature) => { const name = String(feature.properties?.name || ''); @@ -71,6 +72,11 @@ export const findSubmarineCables = defineTool({ id: feature.properties.id ?? null, name: feature.properties.name, })) + // TeleGeography splits some cables into several features under one id. + .filter((row) => { + const key = row.id ?? row.name; + return !seen.has(key) && seen.add(key); + }) .sort((a, b) => a.name.localeCompare(b.name)); const where = area ? ` in ${area.label}` : ''; const what = wanted ? ` matching "${args.name}"` : ''; diff --git a/src/tools/queries/cables.test.mjs b/src/tools/queries/cables.test.mjs index f74457087..37c18fa61 100644 --- a/src/tools/queries/cables.test.mjs +++ b/src/tools/queries/cables.test.mjs @@ -89,6 +89,59 @@ test('cables are found by area and name, with landing points and attribution', a ); }); +test('a cable split into several features is listed once', async () => { + // TeleGeography splits a few cables (2Africa, Echo, Hawaiki) into several + // features under one id. + const cable = (id, name, coordinates) => ({ + properties: { id, name }, + geometry: { type: 'MultiLineString', coordinates: [coordinates] }, + }); + const split = { + async fetch() { + return { + cables: { + features: [ + cable('2africa', '2Africa', [ + [60, 18], + [57, 22], + ]), + cable('equiano', 'Equiano', [ + [-10, 5], + [3, 6], + ]), + cable('2africa', '2Africa', [ + [-21, 16], + [-17, 14], + ]), + ], + }, + landingPoints: { features: [] }, + }; + }, + }; + const catalog = composeCatalog({ + tools: coreTools, + services: { cables: split }, + }); + const named = await catalog.call('find_submarine_cables', { + name: '2africa', + }); + assert.equal(named.summary, '1 submarine cable matching "2africa".'); + assert.deepEqual(named.data.rows, [{ id: '2africa', name: '2Africa' }]); + const both = await catalog.call('find_submarine_cables', { + area: { bbox: [-30, 0, 70, 30] }, + }); + assert.deepEqual( + both.data.rows.map((row) => row.id), + ['2africa', 'equiano'], + ); + // Only the second part crosses this box; the cable is still found. + const west = await catalog.call('find_submarine_cables', { + area: { bbox: [-25, 10, -10, 20] }, + }); + assert.deepEqual(west.data.rows, [{ id: '2africa', name: '2Africa' }]); +}); + test('the local fetch serves bundled data files and nothing outside them', async () => { const fetchImpl = createApiFetch({ apiBase: 'http://127.0.0.1:1', diff --git a/src/ui/PanelRailView.vue b/src/ui/PanelRailView.vue index 8f43dabb2..8ac9fb256 100644 --- a/src/ui/PanelRailView.vue +++ b/src/ui/PanelRailView.vue @@ -42,6 +42,7 @@ const label = computed(() => :side="side" :label="label" :panel-open="active !== null" + collapsible @select="onSelect" /> diff --git a/src/ui/cctvPresentation.js b/src/ui/cctvPresentation.js index cd298e108..2d5b398cc 100644 --- a/src/ui/cctvPresentation.js +++ b/src/ui/cctvPresentation.js @@ -3,6 +3,7 @@ import { cctvSourceLabelText, cctvSourceMessageText } from './cctvFrames.js'; import { createCctvVideoSurface } from './cctvVideo.js'; import { presentCctvCalibration } from './cctvCalibration.js'; +import { roundHeadingDegrees } from '../data/headingDegrees.js'; export function _calBadgeLabel(badge) { switch (badge) { case 'calibrated': @@ -107,7 +108,7 @@ function metaText(state, enabled, activeCamera, catalogCount) { '{city} · HDG {heading} · FOV {fov} · RANGE {range}m · {projection}{calBadge} · {provider}{status}', { city: activeCamera.city, - heading: `${Math.round(activeCamera.headingDeg) % 360}°`, + heading: `${roundHeadingDegrees(Number(activeCamera.headingDeg)) ?? 0}°`, fov: `${Math.round(activeCamera.fovDeg)}°`, range: Math.round(activeCamera.rangeM), projection: projLabel, diff --git a/src/ui/cockpitInstruments.js b/src/ui/cockpitInstruments.js index 484125db1..dc9964ff8 100644 --- a/src/ui/cockpitInstruments.js +++ b/src/ui/cockpitInstruments.js @@ -120,7 +120,7 @@ export function updateHud( (tick) => formatAltitudeRulerTick(tick.valueFt), ); view.heading = { - text: String(Math.round(heading) % 360).padStart(3, '0'), + text: formatHeadingReadout(heading), value: heading, immediate: forceContext, }; diff --git a/src/ui/cockpitPresentation.js b/src/ui/cockpitPresentation.js index e787b79e0..fc62ceaad 100644 --- a/src/ui/cockpitPresentation.js +++ b/src/ui/cockpitPresentation.js @@ -1,6 +1,11 @@ /** Pure formatting and fixed presentation constants used by Cockpit components. */ import { formatNumber, getLocale, isolate, t, msg } from '../i18n/index.js'; import { formatQuantity } from './unitLabels.js'; +import { + normalizeHeading, + roundHeadingDegrees, +} from '../data/headingDegrees.js'; +import { kilometreDigits } from '../data/distanceFormat.js'; export const COCKPIT_HEADING_SLEW_DPS = 28; @@ -116,7 +121,7 @@ export function formatCockpitCoordinate(value, positive, negative) { * (`4,2 КМ`): one decimal under 10 km, whole kilometres beyond. */ export function formatCockpitDistance(distanceM) { - const digits = distanceM < 10000 ? 1 : 0; + const digits = kilometreDigits(distanceM); const distance = formatQuantity(distanceM / 1000, 'km', { minimumFractionDigits: digits, maximumFractionDigits: digits, @@ -135,8 +140,7 @@ export function cockpitUnitCase(text) { export function formatCockpitWindDirection(value) { if (!Number.isFinite(value)) return t('DIR UNKNOWN'); const labels = Object.keys(COMPASS_POINT_LABELS); - const normalized = ((value % 360) + 360) % 360; - // 359.5° and above is north. Modulo keeps that from printing 360°. - const degrees = Math.round(normalized) % 360; + const normalized = normalizeHeading(value); + const degrees = roundHeadingDegrees(value); return `${compassPointLabel(labels[Math.round(normalized / 45) % labels.length])} · ${degrees}°`; } diff --git a/src/ui/cockpitPresentation.test.mjs b/src/ui/cockpitPresentation.test.mjs index da5c8e53b..d94209f0d 100644 --- a/src/ui/cockpitPresentation.test.mjs +++ b/src/ui/cockpitPresentation.test.mjs @@ -1,6 +1,9 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { formatCockpitWindDirection } from './cockpitPresentation.js'; +import { + formatCockpitDistance, + formatCockpitWindDirection, +} from './cockpitPresentation.js'; test('wind just west of north reads 0°, not 360°', () => { assert.equal(formatCockpitWindDirection(90), 'E · 90°'); @@ -8,3 +11,8 @@ test('wind just west of north reads 0°, not 360°', () => { assert.equal(formatCockpitWindDirection(359.4), 'N · 359°'); assert.equal(formatCockpitWindDirection(359.6), 'N · 0°'); }); + +test('a cockpit distance that rounds to 10.0 km drops the decimal', () => { + assert.match(formatCockpitDistance(9949), /^9\.9\s?KM$/); + assert.match(formatCockpitDistance(9960), /^10\s?KM$/); +}); diff --git a/src/ui/cockpitTrackingController.js b/src/ui/cockpitTrackingController.js index b945da4de..f308b0d2f 100644 --- a/src/ui/cockpitTrackingController.js +++ b/src/ui/cockpitTrackingController.js @@ -1,6 +1,7 @@ /** Tracking identity, entry, exit and contact navigation for the Cockpit controller. */ import { t } from '../i18n/index.js'; import { + formatHeadingReadout, normalizeHeading, resolveTrackedAircraftInfo, } from '../cockpitMath.js'; @@ -186,9 +187,7 @@ export function enter() { this.startBriefRotation(); const trackLabel = info.callsign || info.registration || info.icao24 || t('AIRCRAFT'); - const trackHeading = String( - Math.round(normalizeHeading(info.track ?? 0)), - ).padStart(3, '0'); + const trackHeading = formatHeadingReadout(info.track ?? 0); this.pushCockpitSignal( 'track', 'track', diff --git a/src/ui/kit/UiRail.test.mjs b/src/ui/kit/UiRail.test.mjs index 6c443286a..57a0ff09d 100644 --- a/src/ui/kit/UiRail.test.mjs +++ b/src/ui/kit/UiRail.test.mjs @@ -71,3 +71,75 @@ test('tips open toward the screen, and not for the open panel', async () => { await other.trigger('focusout'); assert.equal(wrapper.find('[role="tooltip"]').exists(), false); }); + +test('a collapsible rail shows labelled pills until a panel opens', async () => { + const wrapper = mountRail({ collapsible: true }); + const nav = wrapper.get('nav'); + assert.ok(nav.classes().includes('ui-rail--collapsible')); + assert.ok(nav.classes().includes('ui-rail--expanded')); + assert.deepEqual( + wrapper.findAll('.ui-rail__label').map((label) => label.text()), + ['DATA LAYERS', 'AUTHORING', 'CCTV'], + ); + // The label is on screen, so its tip would only repeat it. + await wrapper.findAll('.ui-tooltip')[0].trigger('focusin'); + assert.equal(wrapper.find('[role="tooltip"]').exists(), false); + // The button keeps its own accessible name; the visible label is hidden + // from assistive technology so the name is not read twice. + const button = wrapper.get('[data-rail-item="layers"]'); + assert.equal(button.attributes('aria-label'), 'DATA LAYERS'); + assert.equal(button.get('.ui-rail__label').attributes('aria-hidden'), 'true'); + + await wrapper.setProps({ active: 'layers', panelOpen: true }); + assert.ok(nav.classes().includes('ui-rail--collapsible')); + assert.equal(nav.classes().includes('ui-rail--expanded'), false); + // Collapsed, the other items' tips come back. + await wrapper.findAll('.ui-tooltip')[1].trigger('focusin'); + await new Promise((resolve) => setTimeout(resolve, 450)); + assert.equal(wrapper.get('[role="tooltip"]').text(), 'AUTHORING'); +}); + +test('a label cut short by the pill keeps its tip', async (t) => { + const observers = []; + const original = globalThis.ResizeObserver; + globalThis.ResizeObserver = class { + constructor(callback) { + this.callback = callback; + this.targets = []; + observers.push(this); + } + observe(target) { + this.targets.push(target); + } + unobserve() {} + disconnect() {} + }; + t.after(() => (globalThis.ResizeObserver = original)); + const wrapper = mountRail({ collapsible: true }); + const [observer] = observers; + const [layers, scenes] = observer.targets; + Object.defineProperties(layers, { + scrollWidth: { value: 260 }, + clientWidth: { value: 220 }, + }); + Object.defineProperties(scenes, { + scrollWidth: { value: 90 }, + clientWidth: { value: 90 }, + }); + observer.callback([{ target: layers }, { target: scenes }]); + await wrapper.vm.$nextTick(); + await wrapper.findAll('.ui-tooltip')[0].trigger('focusin'); + assert.equal(wrapper.get('[role="tooltip"]').text(), 'DATA LAYERS'); + await wrapper.findAll('.ui-tooltip')[0].trigger('focusout'); + await wrapper.findAll('.ui-tooltip')[1].trigger('focusin'); + assert.equal(wrapper.find('[role="tooltip"]').exists(), false); +}); + +test('a plain rail has no labels', () => { + const wrapper = mountRail(); + assert.equal(wrapper.find('.ui-rail__label').exists(), false); + assert.equal( + wrapper.get('nav').classes().includes('ui-rail--expanded'), + false, + ); +}); diff --git a/src/ui/kit/UiRail.vue b/src/ui/kit/UiRail.vue index af740aae3..4e2bf6836 100644 --- a/src/ui/kit/UiRail.vue +++ b/src/ui/kit/UiRail.vue @@ -1,9 +1,10 @@