diff --git a/build/registerVue.js b/build/registerVue.js index eb8dc4736..6db7d017c 100644 --- a/build/registerVue.js +++ b/build/registerVue.js @@ -1,5 +1,8 @@ -import { register } from 'node:module'; +import { registerHooks } from 'node:module'; +import { load } from './vueLoader.js'; // Lets Node import single-file components: `node --import=`. -// Hosts reach it as `/build/register-vue`. -register('./vueLoader.js', import.meta.url); +// Hosts reach it as `/build/register-vue`. The hooks run on the +// loading thread: off-thread hooks (module.register) would add a round trip +// to every module a test imports, not only the components. +registerHooks({ load }); diff --git a/build/vueLoader.js b/build/vueLoader.js index a549fca47..13026dc04 100644 --- a/build/vueLoader.js +++ b/build/vueLoader.js @@ -1,14 +1,32 @@ -import { readFile } from 'node:fs/promises'; +import { createHash, randomBytes } from 'node:crypto'; +import { + lstatSync, + mkdirSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { compileScript, compileTemplate, parse } from 'vue/compiler-sfc'; import { VUE_TEMPLATE_OPTIONS } from './vueOptions.js'; +const require = createRequire(import.meta.url); + +// The compiler loads on the first component that is not cached, so a test +// process that imports no views, or only cached ones, never pays for it. +let compiler = null; +const sfcCompiler = () => (compiler ??= require('vue/compiler-sfc')); + /** * Compile a single-file component to an ES module for Node, so unit tests (the * application's and a host's) can import components without a bundler. * Styles are dropped: tests read the global stylesheet separately. */ export function compileVueModule(source, filename) { + const { compileScript, compileTemplate, parse } = sfcCompiler(); // The template is parsed with the SFC; parse it as the browser build does. const { descriptor, errors } = parse(source, { filename, @@ -40,14 +58,113 @@ export function compileVueModule(source, filename) { return `${script}\n${template}\nif (typeof render === 'function') __sfc__.render = render;\nexport default __sfc__;\n`; } -/** Node module hook: load `.vue` files as compiled modules. */ -export async function load(url, context, nextLoad) { +// The loader's own code is part of every key, so a change to how components +// compile never reuses output from before it. +let loaderFingerprint = null; +const fingerprint = () => + (loaderFingerprint ??= createHash('sha256') + .update(readFileSync(fileURLToPath(import.meta.url))) + .update(readFileSync(new URL('./vueOptions.js', import.meta.url))) + .update(require('vue/package.json').version) + .digest('hex')); + +/** + * Where compiled components are kept between test processes, never a + * package's own tree (a host's checks refuse files inside Core): the system + * temp directory, or on Windows the per-user local app data directory, since + * a temp directory there may be shared and its ownership is not checked. + * GEV_VUE_CACHE=0 compiles every time. + */ +export function vueCacheDirectory( + env = process.env, + platform = process.platform, +) { + if (env.GEV_VUE_CACHE === '0') return null; + if (platform === 'win32') { + return env.LOCALAPPDATA ? path.join(env.LOCALAPPDATA, 'gev-vue-sfc') : null; + } + return path.join(tmpdir(), `gev-vue-sfc-${process.getuid()}`); +} + +/** The cache entry for one component: its path, its text and the loader. */ +export function vueCacheKey(source, filename) { + return createHash('sha256') + .update(JSON.stringify([fingerprint(), filename])) + .update('\0') + .update(source) + .digest('hex'); +} + +/** + * Compile a component, reusing a previous process's result for the same text. + * A cache that cannot be read or written only costs the compile. + */ +export function cachedVueModule( + source, + filename, + directory = vueCacheDirectory(), +) { + if (!directory || !ownCacheDirectory(directory)) { + return compileVueModule(source, filename); + } + const entry = path.join(directory, `${vueCacheKey(source, filename)}.mjs`); + try { + return readFileSync(entry, 'utf8'); + } catch { + // Not compiled yet. + } + const compiled = compileVueModule(source, filename); + // Concurrent test processes and threads may compile the same component; + // each writes its own new file and the rename makes the entry appear whole. + const partial = `${entry}.${randomBytes(6).toString('hex')}.tmp`; + try { + writeFileSync(partial, compiled, { flag: 'wx', mode: 0o600 }); + renameSync(partial, entry); + } catch { + // An unwritable directory, or a rename another process holds open, + // leaves the cache without this entry. + try { + rmSync(partial, { force: true }); + } catch { + // A partial file that cannot be removed is only clutter. + } + } + return compiled; +} + +/** + * Cached modules run as code, so the directory must be this user's own: a + * real directory (not a link), owned by the current user and writable by no + * one else. In a shared temp directory another user could otherwise create it + * first and plant modules. On Windows it sits in the user's own profile. + */ +const checkedDirectories = new Map(); +export function ownCacheDirectory(directory) { + if (!checkedDirectories.has(directory)) { + checkedDirectories.set(directory, isOwnDirectory(directory)); + } + return checkedDirectories.get(directory); +} + +function isOwnDirectory(directory) { + try { + mkdirSync(directory, { recursive: true, mode: 0o700 }); + const stats = lstatSync(directory); + if (!stats.isDirectory()) return false; + if (process.platform === 'win32') return true; + return stats.uid === process.getuid() && (stats.mode & 0o022) === 0; + } catch { + return false; + } +} + +/** Synchronous module hook (module.registerHooks): `.vue` loads compiled. */ +export function load(url, context, nextLoad) { if (!url.endsWith('.vue')) return nextLoad(url, context); const filename = fileURLToPath(url); - const source = await readFile(filename, 'utf8'); return { format: 'module', - source: compileVueModule(source, filename), + source: cachedVueModule(readFileSync(filename, 'utf8'), filename), shortCircuit: true, }; } diff --git a/changelog.d/1432-vessel-course-north.md b/changelog.d/1432-vessel-course-north.md new file mode 100644 index 000000000..45d9e5845 --- /dev/null +++ b/changelog.d/1432-vessel-course-north.md @@ -0,0 +1,3 @@ +- Show a vessel course of 359.5° or more as 0°. Vessel cards and the AIS HUD + rounded the course without wrapping, so a ship with no reported heading on + a 359.7° course read "360°" and "HDG: 360DEG" (Aditya, @Rohilalala, #1432). diff --git a/changelog.d/1441-cctv-heading-north.md b/changelog.d/1441-cctv-heading-north.md new file mode 100644 index 000000000..d65e5c2d8 --- /dev/null +++ b/changelog.d/1441-cctv-heading-north.md @@ -0,0 +1,3 @@ +- Show a CCTV camera facing 359.5° or more as HDG 0°. The HUD heading token + and the camera panel rounded the bearing without wrapping, so a camera + facing 359.6° read "HDG 360°" (Aditya, @Rohilalala, #1441). 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/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/1451-alpr-direction-north.md b/changelog.d/1451-alpr-direction-north.md new file mode 100644 index 000000000..74f04b7ab --- /dev/null +++ b/changelog.d/1451-alpr-direction-north.md @@ -0,0 +1,2 @@ +- Show an ALPR camera's mapped bearing of 359.5° or more as DIRECTION 0° + rather than DIRECTION 360° (Aditya, @Rohilalala, #1451). 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/1500-vue-loader-cache.md b/changelog.d/1500-vue-loader-cache.md new file mode 100644 index 000000000..da2ff7c13 --- /dev/null +++ b/changelog.d/1500-vue-loader-cache.md @@ -0,0 +1,4 @@ +- `build/register-vue` loads components on the test thread and keeps compiled + components between test processes in a per-user cache directory, so Node + tests that import views run about twice as fast. `GEV_VUE_CACHE=0` compiles + every time (samehkhamis, #1500). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index ee23c96fb..86ee39dc3 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -557,7 +557,9 @@ those reports arrive as 102.3 knots and 360 degrees. Both are stored as unknown, and vessel cards, the HUD and analyst queries show a missing measurement rather than a reading. Heading keeps its existing separate check for its own sentinel. Genuine readings are unchanged, including a stopped vessel's zero and the -highest speed and course the fields can encode. +highest speed and course the fields can encode. Vessel cards and the HUD wrap +the direction after rounding to whole degrees, so a course of 359.5° or more +reads `0°` (`0DEG` in the HUD) rather than `360°`. For an already tracked vessel, AISStream static messages update name, type, destination and IMO without changing its position, motion, fix timestamps, @@ -958,7 +960,9 @@ change. Once the camera is still and tiles have settled, each overlay anchor is replaced once by the rendered surface under it (two depth samples per paint, accepted only within -30/+40 m of the floor cell), so badges do not float or sink on slopes; wedge corners follow their own floor cells instead of staying -level with the apex. Unknown bearings never acquire a wedge. The row reads +level with the apex. Unknown bearings never acquire a wedge. The selected +card's `DIRECTION` wraps after rounding to whole degrees, so a mapped bearing +of 359.5° or more reads `0°` rather than `360°`. The row reads `N nearby · M on screen`; when every loaded camera is outside the view it adds "None on screen — nearby cameras are outside the view" instead of looking unloaded. `scripts/qa-alpr-journey.mjs` drives an Austin journey with ALPR off, @@ -4313,7 +4317,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. @@ -4927,7 +4933,9 @@ silently demoting every later lookup for the session. wireframe dashed; colours, widths and active/idle emphasis are unchanged. A manual calibration (`calSource: 'manual'`) or curated pose (`poseSource: 'curated'`) is never presented as estimated, matching the CAL badge. Public camera state carries `headingConfidence` and - `headingEstimated` (`src/layers/cctv/headingConfidence.js`, #639). + `headingEstimated` (`src/layers/cctv/headingConfidence.js`, #639). The HUD token and the + camera panel's meta line wrap the bearing after rounding to whole degrees, so a camera + facing 359.5° or more reads `HDG 0°` rather than `HDG 360°`. - **CCTV identified hardware:** a camera whose source or seed names a hardware `model` that resolves in `src/layers/cctv/modelSpecs.js` (vendored CC0 records from the CCTV Camera Database) gets the model's specs appended to the HUD meta line. Only fixed single-lens @@ -5303,7 +5311,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. @@ -5492,7 +5500,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`. 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`. @@ -5752,6 +5760,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. diff --git a/scripts/format-scope.json b/scripts/format-scope.json index c6ae4b493..f78a6a70c 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -68,6 +68,7 @@ "server/standalone/vite.config.js", "vite.config.js", "src/tooling/viteBuild.test.mjs", + "src/tooling/vueLoader.test.mjs", "src/tooling/allowedHosts.test.mjs", "server/providers/common/http.js", "server/providers/common/query.js", @@ -1180,5 +1181,6 @@ "src/testSupport/hudReadouts.mjs", "src/ui/stores/firstRun.js", "src/firstRunLauncherView.test.mjs", - "src/firstRunCopy.js" + "src/firstRunCopy.js", + "src/data/headingDegrees.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index c8783bde9..bf1eb1a3c 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -745,6 +745,7 @@ "cctv-controls": { "exports": ["./ui/cctv"], "modules": [ + "src/data/headingDegrees.js", "src/ui/cctv.js", "src/ui/cctvBindings.js", "src/ui/cctvCalibration.js", @@ -814,6 +815,7 @@ "./ui/cockpit/vision" ], "modules": [ + "src/data/headingDegrees.js", "src/ui/cockpit.js", "src/ui/cockpitBriefing.js", "src/ui/cockpitBriefPages.js", @@ -883,6 +885,7 @@ "ui-shell": { "exports": ["./ui/shell"], "modules": [ + "src/data/headingDegrees.js", "src/i18n/marker.js", "src/ui/mobileShell.js", "src/exclusiveSurfaces.js", @@ -1253,6 +1256,7 @@ "civil-flights": { "exports": ["./layers/flights"], "modules": [ + "src/data/headingDegrees.js", "src/i18n/marker.js", "src/cyberSonar.js", "src/data/aircraftClass.js", @@ -1315,6 +1319,7 @@ "military-flights": { "exports": ["./layers/military"], "modules": [ + "src/data/headingDegrees.js", "src/i18n/marker.js", "src/cyberSonar.js", "src/data/aircraftClass.js", @@ -1400,6 +1405,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", @@ -1645,6 +1651,7 @@ "awareness-layer": { "exports": ["./layers/awareness"], "modules": [ + "src/data/headingDegrees.js", "src/i18n/marker.js", "src/cyberSonar.js", "src/cockpitMath.js", @@ -1783,6 +1790,7 @@ "cctv-layer": { "exports": ["./layers/cctv"], "modules": [ + "src/data/headingDegrees.js", "src/data/cctvCards.js", "src/data/cctvGizmo.js", "src/data/cctvLod.js", @@ -1911,6 +1919,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", @@ -2189,6 +2198,7 @@ "./standalone/catalog" ], "modules": [ + "src/data/headingDegrees.js", "src/ui/placeNames.js", "src/cyberSonarScene.js", "src/cyberSonar.js", @@ -3163,6 +3173,7 @@ "application-layer-construction": { "exports": ["./application/layers"], "modules": [ + "src/data/headingDegrees.js", "src/cyberSonar.js", "src/data/approxDistance.js", "src/data/bhoteKoshiEvent.js", @@ -4319,6 +4330,7 @@ "transit-standalone": { "exports": ["./layers/transit/standalone"], "modules": [ + "src/data/headingDegrees.js", "src/cyberSonar.js", "src/celestialRing.js", "src/data/approxDistance.js", @@ -4396,6 +4408,7 @@ "transit-layer": { "exports": ["./layers/transit"], "modules": [ + "src/data/headingDegrees.js", "src/data/iconOrientation.js", "src/data/inputOwnership.js", "src/data/transitFeeds.js", @@ -4623,6 +4636,7 @@ "tools": { "exports": ["./tools"], "modules": [ + "src/data/headingDegrees.js", "src/tools/index.js", "src/tools/catalog.js", "src/tools/schema.js", diff --git a/src/annotations/annotationEngine.js b/src/annotations/annotationEngine.js index b0622579d..c867782c7 100644 --- a/src/annotations/annotationEngine.js +++ b/src/annotations/annotationEngine.js @@ -1573,8 +1573,11 @@ 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`; + // Round before each unit cut: 996 m was printed as "1000 m" and + // 9,960 m as "10.0 km". + const tens = Math.round(m / 10) * 10; + if (tens < 1000) return `${tens} m`; + return `${(m / 1000).toFixed(Math.round(m / 100) >= 100 ? 0 : 1)} km`; } function composeRouteLabel(baseLabel, distM, durS, mode, fallback) { diff --git a/src/annotations/annotationEngine.test.mjs b/src/annotations/annotationEngine.test.mjs index 5f39a077a..4f5804290 100644 --- a/src/annotations/annotationEngine.test.mjs +++ b/src/annotations/annotationEngine.test.mjs @@ -730,3 +730,18 @@ test('only an explicit navigation request permits resolving distant annotation t assert.deepEqual(received.map(options => options.allowDistant), [false, true]); engine.destroy(); }); + +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/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/aisLiveVessels.test.mjs b/src/data/aisLiveVessels.test.mjs index 050bb0323..8e12bff94 100644 --- a/src/data/aisLiveVessels.test.mjs +++ b/src/data/aisLiveVessels.test.mjs @@ -1321,6 +1321,27 @@ test('buildVesselCard: heading falls back to course; missing parts are omitted', assert.deepEqual(bare.details, []); }); +test('vessel cards read a course that rounds to north as 0°, not 360°', () => { + const card = (course) => buildVesselCard(makeRecord({ heading: null, course })).details[0]; + assert.equal(card(359.4), 'CONTAINER SHIP · 14.5KT · 359°'); + assert.equal(card(359.7), 'CONTAINER SHIP · 14.5KT · 0°'); + assert.equal( + buildSelectedVesselCard(makeRecord({ heading: null, course: 359.7 })).details[0], + 'CONTAINER SHIP · 14.5KT · 0°', + ); +}); + +test('the vessel HUD reads a course that rounds to north as HDG 0DEG', () => { + const harness = installWireHarness(undefined, { selectedRecord: null }); + try { + Object.assign(harness.record, { heading: null, course: 359.7 }); + assert.equal(aisLiveVesselsLayer.selectById(harness.record.mmsi), true); + assert.match(harness.hud.readout.text, /HDG: 0DEG/); + } finally { + harness.cleanup(); + } +}); + test('buildVesselCard: unnamed vessels title as MMSI; long names truncate', () => { const unnamed = buildVesselCard(makeRecord({ name: 'VESSEL' })); assert.equal(unnamed.title, 'MMSI 353136000'); 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/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/layers/alpr/overlay.test.mjs b/src/layers/alpr/overlay.test.mjs index 1bb9985af..c9aa8e5f5 100644 --- a/src/layers/alpr/overlay.test.mjs +++ b/src/layers/alpr/overlay.test.mjs @@ -2,6 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; import { createAlprOverlay } from './overlay.js'; +import { normalizeAlprNode } from './records.js'; import { directionWedgePositions, alprLabelDetails, @@ -218,6 +219,23 @@ test('adapter labels never claim a custom source is public OSM data', () => { ); }); +test('a mapped bearing that rounds up to a whole turn reads 0°, not 360°', () => { + const direction = (tag) => + alprLabelDetails( + normalizeAlprNode({ + type: 'node', + id: 1, + lat: 30, + lon: -97, + tags: { 'surveillance:type': 'ALPR', 'camera:direction': tag }, + }), + { label: 'Custom directory' }, + ).find((line) => line.startsWith('DIRECTION')); + assert.equal(direction('359.6'), 'DIRECTION 0°'); + assert.equal(direction('359.4'), 'DIRECTION 359°'); + assert.equal(direction('0.4'), 'DIRECTION 0°'); +}); + test('overlay glyphs and native badges shrink alike with distance', () => { assert.equal(markerScale(100), 1.15); assert.equal(markerScale(40_000), 0.45); diff --git a/src/layers/alpr/visuals.js b/src/layers/alpr/visuals.js index 3a3d3fc5f..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 @@ -50,7 +51,7 @@ export function alprLabelDetails(record, source) { sourceName === 'OpenStreetMap' ? 'OSM MAPPED' : `Source: ${sourceName}`, ]; if (Number.isFinite(record.directionDeg)) - details.push(`DIRECTION ${Math.round(record.directionDeg)}°`); + details.push(`DIRECTION ${roundHeadingDegrees(record.directionDeg)}°`); const equipment = [ ...new Set( [record.manufacturer, record.operator, record.cameraType] 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/cctv/headingConfidence.js b/src/layers/cctv/headingConfidence.js index 26a877558..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,7 +45,7 @@ export function isHeadingEstimated(camera) { * @returns {string} */ export function headingHudToken(camera) { - const hdg = Math.round(Number(camera?.headingDeg) || 0); + 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/cctv/headingConfidence.test.mjs b/src/layers/cctv/headingConfidence.test.mjs index 513bf0d1b..3cf673861 100644 --- a/src/layers/cctv/headingConfidence.test.mjs +++ b/src/layers/cctv/headingConfidence.test.mjs @@ -44,6 +44,15 @@ test('HUD token tags a synthetic bearing and rounds the degrees', () => { ); }); +test('HUD token reads a bearing that rounds to north as 0°, not 360°', () => { + assert.equal(headingHudToken({ headingDeg: 359.4 }), 'HDG 359°'); + assert.equal(headingHudToken({ headingDeg: 359.6 }), 'HDG 0°'); + assert.equal( + headingHudToken({ headingDeg: 359.5, headingConfidence: 'low' }), + 'HDG 0° (ESTIMATED)', + ); +}); + test('HUD token stays untagged for surveyed and calibrated bearings', () => { assert.equal( headingHudToken({ headingDeg: 67.5, headingConfidence: 'high' }), diff --git a/src/layers/directions/index.js b/src/layers/directions/index.js index ffc1497f1..cad09e4e9 100644 --- a/src/layers/directions/index.js +++ b/src/layers/directions/index.js @@ -113,7 +113,8 @@ export function routeDistanceText(meters) { const tens = Math.round(meters / 10) * 10; if (tens < 1000) return formatQuantity(tens, 'm'); const km = meters / 1000; - return km < 10 + // Pick the unit from the tenth it prints: 9.96 km read "10.0 km". + return Math.round(meters / 100) < 100 ? formatQuantity(km, 'km', { minimumFractionDigits: 1, maximumFractionDigits: 1, 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/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/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 aa6f06717..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,7 +56,8 @@ export function createCards({ if (record.speed !== null && record.speed !== undefined) parts.push(formatSpeed(record.speed)); const direction = record.heading ?? record.course; - if (Number.isFinite(direction)) parts.push(`${Math.round(direction)}°`); + if (Number.isFinite(direction)) + parts.push(`${roundHeadingDegrees(direction)}°`); return { id: vesselOverlayEntryId(record), actionable: Boolean(record?.mmsi), @@ -86,7 +88,9 @@ export function createCards({ [ vesselTypeShort(record) || t('VESSEL'), formatSpeed(record.speed), - Number.isFinite(direction) ? `${Math.round(direction)}°` : '--°', + Number.isFinite(direction) + ? `${roundHeadingDegrees(direction)}°` + : '--°', ].join(' · '), ]; const destination = String(record.destination || '').trim(); @@ -176,7 +180,7 @@ export function createCards({ function formatHeading(heading) { return t('{heading}DEG', { - heading: Number.isFinite(heading) ? Math.round(heading) : '--', + heading: roundHeadingDegrees(heading) ?? '--', }); } diff --git a/src/tooling/vueLoader.test.mjs b/src/tooling/vueLoader.test.mjs new file mode 100644 index 000000000..772299c0b --- /dev/null +++ b/src/tooling/vueLoader.test.mjs @@ -0,0 +1,111 @@ +import assert from 'node:assert/strict'; +import { + chmodSync, + mkdirSync, + mkdtempSync, + readdirSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { + cachedVueModule, + compileVueModule, + ownCacheDirectory, + vueCacheDirectory, + vueCacheKey, +} from '../../build/vueLoader.js'; + +const SOURCE = + '\n\n'; +const FILE = '/virtual/Greeting.vue'; + +test('a component compiles once and later processes read the cached module', (t) => { + const directory = mkdtempSync(path.join(tmpdir(), 'gev-vue-cache-test-')); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const compiled = cachedVueModule(SOURCE, FILE, directory); + assert.equal(compiled, compileVueModule(SOURCE, FILE)); + const entries = readdirSync(directory); + assert.deepEqual(entries, [`${vueCacheKey(SOURCE, FILE)}.mjs`]); + // A cached entry is returned as stored, without compiling again. + writeFileSync(path.join(directory, entries[0]), 'export default "cached";'); + assert.equal( + cachedVueModule(SOURCE, FILE, directory), + 'export default "cached";', + ); +}); + +test('an edited component or another path is a new cache entry', () => { + const key = vueCacheKey(SOURCE, FILE); + assert.notEqual(vueCacheKey(`${SOURCE}\n`, FILE), key); + assert.notEqual(vueCacheKey(SOURCE, '/virtual/Other.vue'), key); + assert.equal(vueCacheKey(SOURCE, FILE), key); +}); + +test('GEV_VUE_CACHE=0 compiles every time; otherwise the cache sits in a per-user directory', () => { + assert.equal(vueCacheDirectory({ GEV_VUE_CACHE: '0' }), null); + if (process.platform !== 'win32') { + const directory = vueCacheDirectory({}); + assert.equal(path.dirname(directory), tmpdir()); + assert.match(path.basename(directory), /^gev-vue-sfc-/); + } + // Windows temp directories may be shared, so the cache lives in the + // user's profile, or not at all. + assert.equal( + vueCacheDirectory( + { LOCALAPPDATA: 'C:\\Users\\a\\AppData\\Local' }, + 'win32', + ), + path.join('C:\\Users\\a\\AppData\\Local', 'gev-vue-sfc'), + ); + assert.equal(vueCacheDirectory({}, 'win32'), null); + assert.equal( + cachedVueModule(SOURCE, FILE, null), + compileVueModule(SOURCE, FILE), + ); +}); + +test('a cache directory that cannot be written still returns the compiled module', (t) => { + const directory = mkdtempSync(path.join(tmpdir(), 'gev-vue-cache-test-')); + t.after(() => rmSync(directory, { recursive: true, force: true })); + // A file where the cache directory should be makes every write fail. + const blocked = path.join(directory, 'blocked'); + writeFileSync(blocked, ''); + assert.equal( + cachedVueModule(SOURCE, FILE, blocked), + compileVueModule(SOURCE, FILE), + ); +}); + +test( + 'a cache directory others can write to, or a link to one, is never read', + { + skip: process.platform === 'win32', + }, + (t) => { + const root = mkdtempSync(path.join(tmpdir(), 'gev-vue-cache-test-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + const shared = path.join(root, 'shared'); + mkdirSync(shared); + chmodSync(shared, 0o777); + // A module planted where the cache would look must not run. + writeFileSync( + path.join(shared, `${vueCacheKey(SOURCE, FILE)}.mjs`), + 'export default "planted";', + ); + assert.equal(ownCacheDirectory(shared), false); + assert.equal( + cachedVueModule(SOURCE, FILE, shared), + compileVueModule(SOURCE, FILE), + ); + const own = path.join(root, 'own'); + mkdirSync(own, { mode: 0o700 }); + const link = path.join(root, 'link'); + symlinkSync(own, link); + assert.equal(ownCacheDirectory(link), false); + assert.equal(ownCacheDirectory(own), true); + }, +); diff --git a/src/ui/cctvPanel.test.mjs b/src/ui/cctvPanel.test.mjs index 1675d6119..a3dbef786 100644 --- a/src/ui/cctvPanel.test.mjs +++ b/src/ui/cctvPanel.test.mjs @@ -490,3 +490,26 @@ test('the scene summary types in, then holds the whole text', async (t) => { await nextTick(); assert.equal(byId('cctv-summary').textContent, text); }); + +test('the panel meta line reads a bearing that rounds to north as HDG 0°', () => { + const meta = (headingDeg) => { + const camera = { + id: 'a', + city: 'Austin', + headingDeg, + fovDeg: 74, + rangeM: 700, + }; + return presentCctvPanel( + { + enabled: true, + activeCameraId: camera.id, + activeCamera: camera, + cameras: [camera], + }, + true, + ).meta; + }; + assert.match(meta(359.4), /· HDG 359° ·/); + assert.match(meta(359.6), /· HDG 0° ·/); +}); diff --git a/src/ui/cctvPresentation.js b/src/ui/cctvPresentation.js index c5daf7163..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)}°`, + 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..6b557de7d 100644 --- a/src/ui/cockpitPresentation.js +++ b/src/ui/cockpitPresentation.js @@ -1,6 +1,10 @@ /** 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'; export const COCKPIT_HEADING_SLEW_DPS = 28; @@ -135,8 +139,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/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/streetLevelPresentation.js b/src/ui/streetLevelPresentation.js index d7359dfec..95af86b1a 100644 --- a/src/ui/streetLevelPresentation.js +++ b/src/ui/streetLevelPresentation.js @@ -7,6 +7,7 @@ import { translateKnown, } from '../i18n/index.js'; import { keyGuidanceText, keyRequirementText } from './keyGuidance.js'; +import { roundHeadingDegrees } from '../data/headingDegrees.js'; // Street Level UI state to panel strings and flags. Pure, so it is testable. @@ -175,9 +176,8 @@ function presentViewer(state) { const { street } = state; const right = []; if (street.isPano) right.push('360°'); - // 359.5° and above rounds to north; modulo keeps it from printing 360°. if (Number.isFinite(street.bearing)) - right.push(`${Math.round(street.bearing) % 360}°`); + right.push(`${roundHeadingDegrees(street.bearing)}°`); if (street.capturedAt) right.push(formatDate(street.capturedAt, street.capturedAtPrecision)); const left = [];