diff --git a/changelog.d/1057-windows-npm-dep0190.md b/changelog.d/1057-windows-npm-dep0190.md new file mode 100644 index 000000000..1e8e1da55 --- /dev/null +++ b/changelog.d/1057-windows-npm-dep0190.md @@ -0,0 +1,4 @@ +- On Windows, `npm run doctor` and the Pinokio install no longer print Node's + DEP0190 warning ("Passing args to a child process with shell option true can + lead to security vulnerabilities"): npm.cmd's fixed subcommand is now part of + the shell command string instead of an argument array (quanticsoul4772, #1057). diff --git a/changelog.d/1117-google-viewer-lifecycle.md b/changelog.d/1117-google-viewer-lifecycle.md new file mode 100644 index 000000000..66d73d4a2 --- /dev/null +++ b/changelog.d/1117-google-viewer-lifecycle.md @@ -0,0 +1,3 @@ +- Release superseded Google Street View open waits and suppress metadata + requests whose viewer was unmounted while the service loaded (rudycelekli, + #1117). diff --git a/changelog.d/1172-cctv-missing-numbers.md b/changelog.d/1172-cctv-missing-numbers.md new file mode 100644 index 000000000..d401021e9 --- /dev/null +++ b/changelog.d/1172-cctv-missing-numbers.md @@ -0,0 +1,13 @@ +- Keep missing CCTV numbers unknown instead of reading them as zero. `Number` + turns null, blank strings and booleans into 0 or 1, and the camera source + list sends an unknown number as JSON null, so a source-pack camera that left + out its pose reached the browser as zeros clamped to each field's floor — + facing north, pitch −2°, FOV 20°, range 120 m, mount 6 m and ground 0 m — + instead of the defaults (id-hash heading, −17°, 74°, 700 m, 24 m and the + city's ground); a null or blank position placed the camera at 0,0. The + server's `toFiniteNumber` and the browser's `safeNumber` now treat absent, + blank and boolean values as missing. The same conversion also stops a + Tallinn row with a null heading reading as a confident north-facing camera, + a Caltrans camera with a blank elevation losing its 150 m ground prior, and + a TfL or Catalonia camera with a null or blank coordinate being kept at 0,0 + or on the zero meridian (omerbberk, #1172). diff --git a/changelog.d/1173-antimeridian-ground-floor.md b/changelog.d/1173-antimeridian-ground-floor.md new file mode 100644 index 000000000..939559bb5 --- /dev/null +++ b/changelog.d/1173-antimeridian-ground-floor.md @@ -0,0 +1,10 @@ +- Flights' ground floor works across the antimeridian. The viewer-distance + check behind the 150 km floor radius and its copy in the photoreal mesh-floor + sampler subtracted longitudes without wrapping, so two points 21 km apart + across 180° measured about 38,000 km and low or grounded aircraft just across + the line from the viewer were never floored. A contact's display corridor + walked a segment across 180° the long way round, spending its cell budget on + ground behind the contact, and a contact on the seam lost its cell hysteresis. + SEARCH NEARBY SITES on a view across 180° searched Google Places around + longitude 0, on the far side of the globe, with a radius sized from the view's + height alone; it now searches the view (Aditya, @Rohilalala, #1173). diff --git a/changelog.d/970-recent-imagery-details-scroll.md b/changelog.d/970-recent-imagery-details-scroll.md new file mode 100644 index 000000000..761e265c0 --- /dev/null +++ b/changelog.d/970-recent-imagery-details-scroll.md @@ -0,0 +1,3 @@ +- Recent Imagery: opening DETAILS scrolls its card into view when it opens below + the fold. The panel used to restore the previous scroll position right after + the reveal, so the card stayed out of sight (Ian Blenke, #970). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index b437f0010..385e8c78b 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1364,6 +1364,19 @@ Node-only package entries expose both provider factories. Routes, payloads, fallback behavior and existing dev/preview hook registration are preserved; browser rendering is unchanged. +## CCTV missing numeric fields + +CCTV numbers are read as missing when they are absent, null, blank or boolean, +on the server (`toFiniteNumber`, every pack and the file/env source packs) and +in the browser (`safeNumber`). `/api/cctv/sources` sends an unknown number as +JSON null; the browser then uses the bundled seed camera's value when one +shares the id, else the field's default — id-hash heading, pitch −17°, FOV 74° +(which a matched datasheet can still replace), range 700 m, mount 24 m, the +city's ground elevation — rather than reading zero and clamping it to the +field's floor. A camera with a missing latitude or longitude and no seed +position is skipped by the browser catalog instead of being placed at 0,0. +Real zeros, including `"0"`, are kept. + ## Terrain, traffic, fire and bike-share provider modules Local composition now imports separate Node modules for Re:Earth heights, @@ -3220,7 +3233,11 @@ test:track` 43 tracking invariants · headless QA harnesses under > metres low. `qa-floor-verify.mjs` now **exits non-zero on FAIL** (1 = FAIL, > 2 = INCONCLUSIVE); it previously exited 0 on every verdict, which is how the > burial stayed invisible. It also honours `QA_BASE_URL` and puppeteer's -> pinned Chrome-for-Testing, like the other harnesses. +> pinned Chrome-for-Testing, like the other harnesses. The viewer-distance +> gate, the corridor walk and cell hysteresis take the short way across 180° +> (`src/data/approxDistance.js` is the one distance both the flights records +> and the mesh-floor sampler use), so contacts just across the antimeridian +> are floored like any other. > - **2026-07-16:** the FIRMS Active Fires layer is **LIVE** — > the bundled 2026-05-25 snapshot (58 MB) is deleted; a new `/api/firms` proxy > (vite.config.js) merges VIIRS NOAA-20/NOAA-21/Suomi-NPP NRT world CSVs @@ -3477,7 +3494,10 @@ controls (the address goes in the caption); a pose goes out at once and again when the panorama's month, photographer and address arrive (one successful lookup per panorama per session, seeded by the nearest lookup; a details lookup that fails or takes over ten seconds waits five seconds -before a view event can retry, and an unmount drops lookups in flight). A key +before a view event can retry, and an unmount drops lookups in flight). Opening +another panorama settles the previous open before registering its wait; closing +then releases the current wait immediately. A details lookup released while its +service loads makes no panorama request after that service becomes available. A key Google refuses (`gm_authFailure`: the API not enabled, a referrer it does not allow, billing off) reads KEY REJECTED and fails a pending open at once; a lookup or open Google never answers fails after 10 or 20 s. In CI @@ -4245,7 +4265,7 @@ silently demoting every later lookup for the session. - The internal Context coordinator is available in every visual style. Its dedicated right-side `CONTEXT` chooser exposes the neutral shell; the coordinator is not duplicated in Data Layers and does not enable a live-data dependency until a mode is selected. - The expanded `CONTEXT` view offers mutually exclusive `CONTACTS` and `SPACE MISSIONS` modes. Selecting `CONTACTS` enables the context-owned Flights, Military Flights, AIS Vessels, and Mapped Installations dependencies only when they are not already user-enabled; selecting `SPACE MISSIONS` enables the recent-launch layer and its Satellite dependency. `CONTACTS` cycles the nearest supported contact of whatever type is selected. Satellites are deliberately excluded from those Awareness cohorts and keep their own tracking UX. Selecting the active mode again returns to the neutral chooser and releases only mode-owned dependencies. - If a civilian or military aircraft is already tracked when `CONTACTS` becomes operational, that source-owned track is adopted as the Context subject before nearest-contact autofocus. Context rechecks the tracker after its dependencies settle, so a newer selection wins, while an explicit clear during activation prevents fallback from silently selecting a replacement. Cockpit entry remains unavailable until that Context transaction has settled, so its camera takeover cannot clear Cesium tracking before adoption. Adoption does not recreate tracking or transfer camera ownership; it initializes the normal 250 km ring, history, proximity results, and Cockpit Previous/Next state for the original aircraft. -- `SEARCH NEARBY SITES` retains the bounded OSM results and makes one user-initiated, view-biased Google Maps Places text search for “military installation.” Google results are source-stamped, deduplicated against OSM by rounded location/name, and remain mapped context rather than operational claims. If Places is unavailable or the API is not enabled for the supplied key, OSM context remains available. +- `SEARCH NEARBY SITES` retains the bounded OSM results and makes one user-initiated, view-biased Google Maps Places text search for “military installation.” Google results are source-stamped, deduplicated against OSM by rounded location/name, and remain mapped context rather than operational claims. If Places is unavailable or the API is not enabled for the supplied key, OSM context remains available. A view across the antimeridian is searched around its own centre, with a radius from its real width. - The expanded desktop header omits the redundant `ON` label; the active mode button carries state. Expanded Contact results also omit the duplicate `GLOBAL CONTEXT` / `CONTEXT ONLY` status row and begin with the selected subject and its 250 km scope. Global Context does not fabricate a selected-entity model preview: the provisional hand-authored aircraft wireframe was removed because it was not geometry extracted from the selected entity's actual asset. - Dependency ownership is reversible: disabling Global Context releases only dependencies it enabled, while user-enabled layers remain on. This also removes the Military-layer suppression handoff when Global Context owned Military, allowing an already-enabled civilian Flights layer to resume its normal mixed rendering. If OpenSky is unavailable and has no last-good cache, Flights requests a capped 250 nm adsb.lol point snapshot around the current view anchor and labels that provenance explicitly; it never relabels military-feed rows as civilian data. If both inputs fail, Flights remains `UNKNOWN`. - Space Missions is replay-isolated: Rocket Launches and Satellites are the only Data Layers permitted while the mode is active. Direct UI and voice entry capture the same pre-entry snapshot; internal dependency and restoration enables do not create a user-owned Context session. Entry waits for incompatible layers to shut down, direct incompatible enables are blocked before lifecycle work, and the entry gate remains active through the complete Rocket Launches enable. A newer same-target ON request takes ownership of the pending entry without releasing its isolation snapshot, including when it arrives while the prior request is awaiting the adoption guard. A caller abort, resource cancellation, newer OFF, or layer teardown waits for exact manager settlement and restores that snapshot without resurrecting Rocket Launches. If an abort lands after only part of a restore settles, Context completes the same exact target without the stale caller signal and then replays newer explicit layer intent. Dedicated Voice Context cancellation reports a stable cancelled result plus the current Context state; generic layer visibility additionally exposes manager phase, reason, successor, and lifecycle details. Once an exact voice visibility intent commits, a newer voice turn cannot relabel it as cancelled while Context settlement completes; pre-commit aborts remain cancellable and final lifecycle mismatches remain failures. Clear Selected Layers reserves its complete captured OFF set before sequential teardown; a newer absolute request of any origin supersedes only its layer reservation and remains authoritative. A rejected layer teardown retains the truthful enabled state, rolls already-stopped siblings back to the captured pre-entry set, and aborts replay; rapid exit/re-entry serializes the full Satellite enabled-state and parameter restore before a new snapshot is taken. Contacts remains additive and restores user-enabled layers normally. @@ -4838,7 +4858,7 @@ pins and share links already supply complete boxes. - **Modes, pins and the preview.** The mode is explicit: `image` (one pinned day, slot `a`), `basemap` (that day on the left of a swipe, the basemap on the right: slot `a` splits LEFT and there is no second layer) and `ab` (day `a` left, day `b` right). `setAssignment(slot, key | null)` / `toggleAssignment(slot, key)` pin and unpin; a day is in one slot at most (pinning it in the other slot moves it) and an empty day cannot be pinned. `setMode()` keeps both pins; `b` only drapes in `ab`. While the mode's first empty slot exists the focused day previews in it: a click at once, arrow keys after 250 ms of quiet (`FOCUS_DEBOUNCE_MS`, one timer, last day wins); an unprobed day is followed and previews when its probe says present, an empty or unknown day never drapes and the previous preview stays. A pin change drops the preview until focus moves again, and unpinning takes that layer off at once. `rankLatest` picks START HERE coverage first (a present HLS day whose footprints cover the box, or have none, beats a newer sliver), then newest with all granules ≤ 20% known cloud, then newest; slivers only as `partial`; else the newest non-empty VIIRS day; it is focused and previewed after each search when nothing is pinned. Escape clears the preview (`clearPreview()`), then cancels the box tool, then blurs — with SELECT BOX armed too, through the tool's `onEscape` interceptor wired in `createApplicationTools`. Pins, mode, box and split changes made in the panel are published with `adoptLayerParams`, never while a share link is being applied. - **Rendering.** `rendering.js` owns at most two GIBS layers on the host `src/maps/imageryHost.js` resolves: `viewer.imageryLayers` while the globe shows, the photoreal tileset's `imageryLayers` while it is hidden, or none (the panel says so). Layers are rebuilt on a new host, never re-parented. `MapSourceController.subscribe()` fires after every settled stack switch (silent switches and fallbacks included) and the layer rebinds on it. Tile requests pass a six-slot semaphore of our own (Cesium's global limits are untouched). Under `requestRenderMode` a deferred tile needs a later frame, but a deferral must never request one itself or a stalled tile keeps the scene rendering every frame: a request that finds the semaphore full asks for nothing (the settling request asks for one frame), and a tile Cesium itself deferred goes through one coalesced 250 ms retry. Thumbnails: three fetches at once, sixteen decoded images, least-recently-used where a `get` read is never a use; eviction keeps the probe result. The strip's visible window is probed around the focused card; the pinned and previewed days are probed wherever they sit, at the focused card's priority, so a pin restored or assigned off-screen still drapes. - **Esri surface and the swipe.** While any image is on the map (a preview included) the layer holds the map lease (`acquireImageryComparison`, owner `recent-imagery`, `esri-if-photoreal`): on Google 3D the map switches to Esri, because 30 m pixels over 3D buildings do not read, and Cesium's split only works on the globe. The lease is handed back, and Google 3D restored unless the operator changed maps meanwhile, when the last image leaves the map or the layer is disabled; the notice line says so while it holds. While a day is shown the imagery keeps Esri: a manual switch that lands on Google 3D is answered at once with a new lease under the same policy (`keepEsri`), the notice reads `Imagery stays on Esri · CLEAR to use Google 3D`, and the divider never suspends because of a map switch. It re-leases once per switch generation and only for a manual one (`MapSourceController#getSwitchOrigin()` is `manual` for an outside `setStack`, `automatic` for the controller's own fallback or recovery), never for the lease's own switch and never while releasing, so CLEAR and disable still return to Google 3D; a lease another owner holds stays refused. When Esri fails and the controller falls back to Google 3D on its own, the layer lets the lease go without restoring anything, both days drape on the tileset without a swipe and the guidance reads `Esri map unavailable · no swipe`; only the next manual switch to Google 3D takes Esri again, so a failing Esri map is never retried in a loop. Switching between globe stacks (Esri, OSM, Bing) only rebinds. On a tileset host both images drape without a swipe. The split control (`createImagerySplit`, labels A / B, or IMAGE / BASEMAP) exists only while a swipe is live, recentres on every new comparison (not while scrubbing the second day), except once for a split restored from a link. There is no keyboard shortcut (Space is the app's push-to-talk): SWAP (`#ri-swap`, in the controls row, its slot reserved and the button disabled while no swipe is live) trades the two sides, A to the right and B to the left, or the image to the right of the basemap, and a second click restores; the hint reads `Drag the divider · SWAP trades sides`. The swap is not in the share link, and every new comparison starts unswapped. One opacity applies to both drapes. Another owner holding the lease is reported and the images still drape. -- **Panel.** `#recent-imagery-panel` is static rail markup in `src/ui/templates/context.html` beside `#weather-panel`, registered in `PanelLayoutController`, `PanelChrome` and the share panel state (`ui` token `i`), hidden while the layer is off and opened on first appearance unless a stored or shared collapse choice exists (`dataset.collapsedPreference`). `LayerPanel.attachRecentImagery(factory)` mounts the readout (`createRecentImageryPanel` in `src/ui/recentImagery.js`) into its `data-rail-scroller` body, as the weather readout is mounted. The body is a fixed stack in which no block changes height and no control moves in any state: `#ri-actions` (SELECT BOX · USE VIEW · CLEAR, rail-card actions), `#ri-notice` (one line: `#ri-notice-text` with the refusal, error, CLEAR notice or the Esri note, and `#ri-zoom-in` in a fixed slot at its right end, reserved unless an oversized box or view can be fitted), `#ri-strip` (day cards newest first: thumbnail, date, sensor, cloud, START HERE, PREVIEW, and a SHOW chip or A and B chips; ← → Home End move, S / A / B pin, Enter previews), `#ri-hint` (one line: the next step), `#ri-selection` (`#ri-mode` IMAGE · VS BASEMAP · A / B, then `#ri-slot-a` and `#ri-slot-b` with `#ri-unpin-a` / `#ri-unpin-b` kept in place when empty), `#ri-controls` (`#ri-opacity`, EXPORT / EXPORT A and EXPORT B) and `#ri-details`, a collapsed rail card holding the focused day's acquisition, coverage and granules, catalog notes, the empty-days toggle and the imagery credit. Absent controls are reserved (invisible), never removed. Every render preserves the body's `scrollTop`. +- **Panel.** `#recent-imagery-panel` is static rail markup in `src/ui/templates/context.html` beside `#weather-panel`, registered in `PanelLayoutController`, `PanelChrome` and the share panel state (`ui` token `i`), hidden while the layer is off and opened on first appearance unless a stored or shared collapse choice exists (`dataset.collapsedPreference`). `LayerPanel.attachRecentImagery(factory)` mounts the readout (`createRecentImageryPanel` in `src/ui/recentImagery.js`) into its `data-rail-scroller` body, as the weather readout is mounted. The body is a fixed stack in which no block changes height and no control moves in any state: `#ri-actions` (SELECT BOX · USE VIEW · CLEAR, rail-card actions), `#ri-notice` (one line: `#ri-notice-text` with the refusal, error, CLEAR notice or the Esri note, and `#ri-zoom-in` in a fixed slot at its right end, reserved unless an oversized box or view can be fitted), `#ri-strip` (day cards newest first: thumbnail, date, sensor, cloud, START HERE, PREVIEW, and a SHOW chip or A and B chips; ← → Home End move, S / A / B pin, Enter previews), `#ri-hint` (one line: the next step), `#ri-selection` (`#ri-mode` IMAGE · VS BASEMAP · A / B, then `#ri-slot-a` and `#ri-slot-b` with `#ri-unpin-a` / `#ri-unpin-b` kept in place when empty), `#ri-controls` (`#ri-opacity`, EXPORT / EXPORT A and EXPORT B) and `#ri-details`, a collapsed rail card holding the focused day's acquisition, coverage and granules, catalog notes, the empty-days toggle and the imagery credit. Absent controls are reserved (invisible), never removed. Every render preserves the body's `scrollTop`; opening DETAILS then scrolls its card into view once. - **Share links.** Layer options (token `1`): box edges as integers at degrees × 100000, pins `a` and `b` as compact days (`S20260918`), `m` mode (0 image, 1 basemap, 2 A / B), `p` split percent (share-link only: never stored locally) and `v` for the daily overview. Row chips: `MORE DETAIL · 30 m` (on) and `DAILY OVERVIEW · 250 m` (off). - Regression surface: the colocated `*.test.mjs` beside each module (shared doubles in `src/layers/recentImagery/testDoubles.mjs`; `src/ui/recentImagery.test.mjs` includes a DOM-structure guard: every control keeps its place in the tree, nothing is hidden, and every fixed-height block keeps the class the stylesheet fixes its height by; it has no layout, so it cannot see movement), `src/ui/layerPanel.test.mjs` (mount), `src/maps/controller.test.mjs` (`subscribe`), `src/data/layerState.test.mjs` (codec), `src/sharelink.celestial.test.mjs` (panel token), and the live gate `scripts/qa-recent-imagery.mjs`, which also measures every control's rect, relative to the panel, across the transitions within 1 px; that measurement is the real layout-shift check. `QA_RECENT_IMAGERY_SLOW_RENDER=1` gives loaded software renderers a 4 s zoom deadline and an 8-sample stability tail; the default stays 2 s and 12 samples. diff --git a/scripts/format-scope.json b/scripts/format-scope.json index a2b3e01f8..8bcc4c04d 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -117,6 +117,7 @@ "server/providers/cctv/constants.js", "server/providers/cctv/media.js", "server/providers/cctv/normalize.js", + "src/data/cctvMissingNumbers.test.mjs", "server/providers/cctv/sources.js", "server/providers/radio.js", "server/providers/radio/catalog.js", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index a9c49f6c3..75ed60f18 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -901,6 +901,7 @@ "src/data/aircraftIcons.js", "src/data/aircraftMeta.js", "src/data/aircraftNearbyPolicy.js", + "src/data/approxDistance.js", "src/data/cockpitAirLod.js", "src/data/cockpitContactDot.js", "src/data/contactMatch.js", @@ -1649,6 +1650,7 @@ "src/ui/radioPanelPlacement.js", "src/cyberSonarGpu.js", "src/scenes/nepalEvidencePack.js", + "src/data/approxDistance.js", "src/data/bhoteKoshiEvent.js", "src/ui/imagerySplit.js", "src/data/bhoteKoshiEmbeddedMedia.js", @@ -2500,6 +2502,7 @@ "exports": ["./application/layers"], "modules": [ "src/cyberSonar.js", + "src/data/approxDistance.js", "src/data/bhoteKoshiEvent.js", "src/ui/imagerySplit.js", "src/maps/imageryComparison.js", @@ -3067,6 +3070,7 @@ "src/app/operations.js", "src/sources/sourceSlot.js", "src/app/surfaceServices.js", + "src/data/approxDistance.js", "src/data/dataCredits.js", "src/data/geoid.js", "src/data/local_data/natural_earth/marine.json", @@ -3226,6 +3230,7 @@ "src/layers/flights/records.js", "src/layers/flights/recordPolicy.js", "src/data/aircraftMeta.js", + "src/data/approxDistance.js", "src/data/renderAltitude.js", "src/data/aircraftClass.js", "src/data/aircraftEmergency.js" @@ -3236,7 +3241,8 @@ "exports": ["./layers/flights/ingestion"], "modules": [ "src/layers/flights/ingestion.js", - "src/layers/flights/recordPolicy.js" + "src/layers/flights/recordPolicy.js", + "src/data/approxDistance.js" ], "external": [] }, @@ -3547,6 +3553,7 @@ "src/cyberSonar.js", "src/app/sourceSlot.js", "src/celestialRing.js", + "src/data/approxDistance.js", "src/data/cameraSensitivity.js", "src/data/contactPlayback.js", "src/data/contactTrailRenderer.js", diff --git a/scripts/pinokio-install.mjs b/scripts/pinokio-install.mjs index 01a26ccce..4e52bf3af 100644 --- a/scripts/pinokio-install.mjs +++ b/scripts/pinokio-install.mjs @@ -24,8 +24,8 @@ export function runChecked(command, args, { shell = false } = {}) { export function installPinokioDependencies() { applyPinokioEnvironment(); rmSync(READY_FILE, { force: true }); - const npm = npmProcessSpec(); - runChecked(npm.command, ['ci'], { shell: npm.shell }); + const npm = npmProcessSpec(['ci']); + runChecked(npm.command, npm.args, { shell: npm.shell }); // Pinokio starts Vite directly and loads only its ENVIRONMENT file plus the // normal dotenv ladder. Unlike dev-fresh.sh, it does not import macOS diff --git a/scripts/setup-doctor.mjs b/scripts/setup-doctor.mjs index 49c6722c2..01e048091 100644 --- a/scripts/setup-doctor.mjs +++ b/scripts/setup-doctor.mjs @@ -76,10 +76,15 @@ export function hasRequiredDependencies(rootDir = ROOT) { } } -/** Return the npm command and spawn mode required by the target platform. */ -export function npmProcessSpec(platform = process.platform) { - const windows = platform === 'win32'; - return { command: windows ? 'npm.cmd' : 'npm', shell: windows }; +/** + * Return the npm command, arguments and spawn mode for the target platform. + * Windows must run npm.cmd through a shell, and Node 24 deprecates passing an + * argument array with `shell: true` (DEP0190), so there the arguments, which + * are fixed npm subcommands, join the command string. + */ +export function npmProcessSpec(args, platform = process.platform) { + if (platform !== 'win32') return { command: 'npm', args, shell: false }; + return { command: ['npm.cmd', ...args].join(' '), args: [], shell: true }; } /** Read one key from Vite's dotenv file ladder without depending on Vite. */ @@ -190,8 +195,8 @@ export function buildCapabilitySummary( export function inspectSetup({ includeKeychain = true, authoritativeEnvironment = false, rootDir = ROOT } = {}) { const node = classifyNodeVersion(); - const npm = npmProcessSpec(); - const npmResult = spawnSync(npm.command, ['--version'], { + const npm = npmProcessSpec(['--version']); + const npmResult = spawnSync(npm.command, npm.args, { encoding: 'utf8', shell: npm.shell, }); diff --git a/server/providers/cctv/normalize.js b/server/providers/cctv/normalize.js index 6c54622e9..2d4e16e11 100644 --- a/server/providers/cctv/normalize.js +++ b/server/providers/cctv/normalize.js @@ -39,11 +39,21 @@ export function escapeXml(text) { /** * Coerce a value to a finite number, returning fallback if NaN/Infinity. * + * Absent, blank and boolean values take the fallback too: `Number` reads + * null, '' and false as 0, which would turn a missing coordinate, heading or + * elevation into a real zero. + * * @param {*} value * @param {number} [fallback=NaN] * @returns {number} */ export function toFiniteNumber(value, fallback = NaN) { + if ( + value == null || + typeof value === 'boolean' || + (typeof value === 'string' && !value.trim()) + ) + return fallback; const num = Number(value); return Number.isFinite(num) ? num : fallback; } diff --git a/src/data/approxDistance.js b/src/data/approxDistance.js new file mode 100644 index 000000000..d581589a5 --- /dev/null +++ b/src/data/approxDistance.js @@ -0,0 +1,12 @@ +/** + * Local equirectangular distance in km, for viewer-proximity gates. Takes the + * short way round, so points either side of 180° stay close. + */ +export function approxDistanceKm(lat1, lon1, lat2, lon2) { + const dLat = (lat2 - lat1) * 111.32; + let dLonDeg = lon2 - lon1; + dLonDeg -= 360 * Math.round(dLonDeg / 360); + const dLon = + dLonDeg * 111.32 * Math.cos((((lat1 + lat2) / 2) * Math.PI) / 180); + return Math.hypot(dLat, dLon); +} diff --git a/src/data/cctvMissingNumbers.test.mjs b/src/data/cctvMissingNumbers.test.mjs new file mode 100644 index 000000000..68911110f --- /dev/null +++ b/src/data/cctvMissingNumbers.test.mjs @@ -0,0 +1,252 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { + fallbackHeadingFromId, + isLikelyCataloniaCoordinate, + toFiniteNumber, +} from '../../server/providers/cctv/normalize.js'; +import { + loadCaltransSourcesFromOpenData, + loadTallinnSourcesFromCatalog, + loadTflSourcesFromOpenData, + parseCataloniaXml, +} from '../../server/providers/cctv/sources.js'; +import { cctvProxy } from '../../server/providers/cctv.js'; +import { createCatalog } from '../layers/cctv/catalog.js'; +import { createModel } from '../layers/cctv/model.js'; + +/** Values `Number` reads as 0 (or 1) although they carry no number. */ +const ABSENT = [null, undefined, '', ' ', false, true]; + +/** Set env vars for one test and restore them afterwards. */ +function withEnv(t, env) { + for (const [name, value] of Object.entries(env)) { + const previous = process.env[name]; + t.after(() => { + if (previous === undefined) delete process.env[name]; + else process.env[name] = previous; + }); + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } +} + +/** Silence the loaders' progress and failure logging. */ +function quiet(t) { + t.mock.method(console, 'log', () => {}); + t.mock.method(console, 'warn', () => {}); +} + +/** The production browser model; only the catalog's pose pass is stubbed. */ +function browserModel() { + const parts = {}; + parts.model = createModel({ + state: {}, + services: { focus: {} }, + parts, + source: {}, + }); + return parts.model; +} + +/** The browser catalog over the production model. */ +function browserCatalog(model, cityPois = {}) { + return createCatalog({ + state: {}, + services: { locations: { CITY_POIS: cityPois } }, + parts: { model: { ...model, ensureCameraPose: () => {} } }, + source: {}, + }); +} + +/** GET /api/cctv/sources through the real proxy middleware. */ +async function requestSources(plugin) { + let handler; + plugin.configureServer({ + middlewares: { + use(_route, fn) { + handler = fn; + }, + }, + }); + const res = { + writeHead(status, headers) { + Object.assign(this, { status, headers }); + }, + end(body) { + this.body = body; + }, + }; + await handler({ url: '/sources', method: 'GET' }, res); + assert.equal(res.status, 200); + return JSON.parse(res.body); +} + +test('absent, blank and boolean values take the fallback on both sides of the source list', () => { + const { safeNumber } = browserModel(); + for (const value of ABSENT) { + assert.ok(Number.isNaN(toFiniteNumber(value)), `server ${String(value)}`); + assert.equal(toFiniteNumber(value, 15), 15, `server ${String(value)}`); + assert.ok(Number.isNaN(safeNumber(value)), `browser ${String(value)}`); + assert.equal(safeNumber(value, 74), 74, `browser ${String(value)}`); + } + for (const [value, expected] of [ + [0, 0], + ['0', 0], + [-17, -17], + [' 12.5 ', 12.5], + ]) { + assert.equal(toFiniteNumber(value, 99), expected); + assert.equal(safeNumber(value, 99), expected); + } +}); + +test('a source-pack camera that omits or nulls its pose gets the browser defaults, not the clamp floors', async (t) => { + quiet(t); + const pose = { + headingDeg: null, + pitchDeg: null, + fovDeg: null, + rangeM: null, + mountHeightM: null, + groundElevationM: null, + }; + const at = { cityId: 'austin', lat: 30.2672, lon: -97.7431 }; + withEnv(t, { + CCTV_SOURCES_FILE: path.join(tmpdir(), 'gev-no-such-pack.json'), + CCTV_SOURCES_JSON: JSON.stringify([ + { id: 'pack-pose-omitted', ...at, feedType: 'image' }, + { id: 'pack-pose-null', ...at, ...pose, feedType: 'image' }, + { id: 'pack-coords-null', lat: null, lon: null, feedType: 'image' }, + { id: 'pack-coords-blank', lat: '', lon: ' ', feedType: 'image' }, + ]), + }); + + const body = await requestSources(cctvProxy({ sourceRoot: tmpdir() })); + const model = browserModel(); + const cameras = browserCatalog(model, { + austin: { name: 'Austin', groundElevation: 150 }, + }).buildCatalogFromSources(body.sources); + const byId = new Map(cameras.map((camera) => [camera.id, camera])); + + for (const id of ['pack-pose-omitted', 'pack-pose-null']) { + const camera = byId.get(id); + assert.ok(camera, id); + assert.equal(camera.headingDeg, model.headingFromId(id), id); + assert.equal(camera.pitchDeg, -17, id); + assert.equal(camera.fovDeg, 74, id); + assert.equal(camera.rangeM, 700, id); + assert.equal(camera.mountHeightM, 24, id); + assert.equal(camera.groundElevationM, 150, id); + } + // No position is not a camera in the Gulf of Guinea. + assert.equal(byId.has('pack-coords-null'), false); + assert.equal(byId.has('pack-coords-blank'), false); +}); + +test('Tallinn rows with a null heading or ground keep the low-confidence prior', (t) => { + quiet(t); + const root = mkdtempSync(path.join(tmpdir(), 'gev-tallinn-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + const file = path.join(root, 'tallinn.json'); + writeFileSync( + file, + JSON.stringify([ + { + id: 'tln-null-heading', + lat: 59.437, + lon: 24.753, + headingDeg: null, + groundElevationM: null, + url: 'https://ristmikud.tallinn.ee/last/cam001.jpg', + }, + ]), + ); + withEnv(t, { CCTV_TALLINN_SOURCES_FILE: file }); + + const [camera] = loadTallinnSourcesFromCatalog({ sourceRoot: root }); + assert.equal(camera.headingConfidence, 'low'); + assert.equal(camera.headingDeg, fallbackHeadingFromId('tln-null-heading')); + assert.equal(camera.groundElevationM, 15); +}); + +test('TfL JamCams with null coordinates are dropped instead of placed at 0,0', async (t) => { + quiet(t); + const jamCam = (id, lat, lon) => ({ + id: `JamCams_${id}`, + commonName: `JamCam ${id}`, + lat, + lon, + additionalProperties: [ + { key: 'available', value: 'true' }, + { + key: 'imageUrl', + value: `https://s3-eu-west-1.amazonaws.com/jamcams.tfl.gov.uk/${id}.jpg`, + }, + ], + }); + t.mock.method(globalThis, 'fetch', async () => + Response.json([ + jamCam('00001.00001', 51.5072, -0.1276), + jamCam('00001.00002', null, null), + jamCam('00001.00003', 51.5072, ''), + ]), + ); + + const cameras = await loadTflSourcesFromOpenData(); + assert.deepEqual( + cameras.map((camera) => camera.id), + ['tfl-00001.00001'], + ); +}); + +test('a Catalonia feature with a blank longitude is not read as the zero meridian', () => { + const feature = (coordinates) => ` + + ${coordinates} + https://example.org/cam.jpg + + `; + const [blank, valid] = parseCataloniaXml( + feature(',41.5') + feature('2.17,41.39'), + ); + // 0 °E at 41.5 °N is inside the Catalonia box, so a zero would pass it. + assert.ok(Number.isNaN(blank.lon)); + assert.equal(isLikelyCataloniaCoordinate(blank.lat, blank.lon), false); + assert.equal(isLikelyCataloniaCoordinate(valid.lat, valid.lon), true); +}); + +test('a Caltrans camera with a blank elevation keeps the 150 m ground prior', async (t) => { + quiet(t); + withEnv(t, { CCTV_CALTRANS_DISTRICTS: '4' }); + t.mock.method(globalThis, 'fetch', async () => + Response.json({ + data: [ + { + cctv: { + inService: 'true', + location: { + latitude: '37.8044', + longitude: '-122.2712', + elevation: '', + locationName: 'TV101 -- I-880 : Test Ave', + }, + imageData: { + static: { + currentImageURL: + 'https://cwwp2.dot.ca.gov/data/d4/cctv/image/tv101/tv101.jpg', + }, + }, + }, + }, + ], + }), + ); + + const [camera] = await loadCaltransSourcesFromOpenData(); + assert.ok(camera); + assert.equal(camera.groundElevationM, 150); +}); diff --git a/src/data/groundFloor.test.mjs b/src/data/groundFloor.test.mjs index e118549c3..4644f57ca 100644 --- a/src/data/groundFloor.test.mjs +++ b/src/data/groundFloor.test.mjs @@ -257,6 +257,32 @@ test('stickyFloorCell does not stick to a far-away stale cell', () => { assert.deepEqual(stickyFloorCell(30.2004, -97.6604, prev), { lat: 30.2, lon: -97.66 }); }); +// --- Antimeridian ------------------------------------------------------------ +// Raw longitude arithmetic walks a segment across 180° the long way round and +// reads a contact on the seam as half a world from its previous cell. + +test('corridorFloorCells walks across the antimeridian, not the long way round', () => { + for (const [from, to, across, behind] of [ + [179.999, -179.998, -179.999, 179.998], + [-179.999, 179.998, 179.999, -179.998], + ]) { + const cells = corridorFloorCells([{ lat: -16.69, lon: from }, { lat: -16.69, lon: to }]); + const lons = cells.map((c) => c.lon); + assert.equal(lons[0], from); + assert.equal(lons.at(-1), to); + assert.ok(lons.includes(across), `crosses through ${across}: ${lons}`); + assert.ok(!lons.includes(behind), `never walks back through ${behind}: ${lons}`); + assert.ok(lons.every((lon) => Math.abs(lon) <= 180), `normalised: ${lons}`); + } +}); + +test('stickyFloorCell holds across the antimeridian', () => { + for (const [prevLon, lon] of [[180, -179.9998], [-180, 179.9998]]) { + const prev = { lat: -16.69, lon: prevLon }; + assert.deepEqual(stickyFloorCell(-16.69, lon, prev), prev); + } +}); + // --- F2: corridor budget fairness ------------------------------------------ // The first cut spent the budget in Map insertion order and decremented it // BEFORE deduping, so parked contacts (whose corridor is the fix cell the poll diff --git a/src/data/meshFloorSampler.test.mjs b/src/data/meshFloorSampler.test.mjs index 8c629e50f..f651d72f8 100644 --- a/src/data/meshFloorSampler.test.mjs +++ b/src/data/meshFloorSampler.test.mjs @@ -128,3 +128,12 @@ test('sampler: outside the google-3d regime nothing is sampled at all', () => { setMeshFloorPreferred(true); assert.equal(cachedMeshFloor(cell.lat, cell.lon), null); }); + +test('sampler: a cell just across the antimeridian is still near the viewer', () => { + reset(); + let calls = 0; + const scene = fakeScene(() => { calls += 1; return undefined; }); + sampleMeshFloorCells(scene, [{ lat: -16.8, lon: -179.999 }], { viewerLat: -16.8, viewerLon: 179.999 }); + sampleMeshFloorCells(scene, [{ lat: -16.81, lon: 179.999 }], { viewerLat: -16.81, viewerLon: -179.999 }); + assert.equal(calls, 2, 'about 200 m apart, so both cells are probed'); +}); diff --git a/src/data/rocketLaunches.test.mjs b/src/data/rocketLaunches.test.mjs index a1a3fab44..ccfd280e2 100644 --- a/src/data/rocketLaunches.test.mjs +++ b/src/data/rocketLaunches.test.mjs @@ -2,6 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; import { gstime } from 'satellite.js'; +import { createModel } from '../layers/launches/model.js'; import rocketLaunchesLayer, { _setRocketMissionOverlayHostForTest, _setSelectedRocketMissionForTest, @@ -728,6 +729,126 @@ test('normalizes detailed payload and stage recovery records', () => { assert.equal(launches[0].recoveryStages[0].lon, -76.2); }); +test('finiteCoordinate normalizes finite numbers and rejects non-numeric or non-finite inputs', () => { + const model = createModel({}); + assert.equal(model.finiteCoordinate(42.5), 42.5); + assert.equal(model.finiteCoordinate('28.5619'), 28.5619); + assert.equal(model.finiteCoordinate('-80.5772'), -80.5772); + assert.equal(model.finiteCoordinate(0), 0); + assert.equal(model.finiteCoordinate('0'), 0); + assert.equal(model.finiteCoordinate(undefined), null); + assert.equal(model.finiteCoordinate('invalid'), null); + assert.equal(model.finiteCoordinate('TBD'), null); + assert.equal(model.finiteCoordinate(Number.NaN), null); + assert.equal(model.finiteCoordinate(Number.POSITIVE_INFINITY), null); + assert.equal(model.finiteCoordinate(Number.NEGATIVE_INFINITY), null); +}); + +test('normalizes recovery stage coordinates, downrange distance, and flight numbers with non-finite fallbacks', () => { + const launches = normalizeRocketLaunches( + { + results: [ + { + id: 'recovery-fallbacks', + name: 'Falcon 9 | Edge Case Stage Normalization', + net: '2026-07-20T10:00:00Z', + pad: { latitude: '28.608', longitude: '-80.604' }, + rocket: { + launcher_stage: [ + { + id: 88, + type: { name: 'Booster' }, + launcher_flight_number: 'invalid-flight', + landing: { + attempt: true, + success: false, + downrange_distance: 'TBD', + landing_location: { + name: 'Ocean Impact', + latitude: 'N/A', + longitude: undefined, + }, + }, + }, + { + id: 89, + type: { name: 'Expended Stage' }, + landing: { + attempt: false, + type: { name: 'Expendable' }, + }, + }, + ], + }, + }, + ], + }, + NOW, + ); + + assert.equal(launches[0].recoveryStages.length, 2); + + const booster = launches[0].recoveryStages[0]; + assert.equal(booster.status, 'LOST'); + assert.equal(booster.success, false); + assert.equal(booster.attempted, true); + assert.equal(booster.flightNumber, null); + assert.equal(booster.downrangeKm, null); + assert.equal(booster.lat, null); + assert.equal(booster.lon, null); + + const expendable = launches[0].recoveryStages[1]; + assert.equal(expendable.status, 'EXPENDABLE'); + assert.equal(expendable.attempted, false); + assert.equal(expendable.success, null); +}); + +test('normalizes payload amounts and fractional string masses', () => { + const launches = normalizeRocketLaunches( + { + results: [ + { + id: 'payload-edge-cases', + name: 'Electron | Mixed Payloads', + net: '2026-07-20T10:00:00Z', + pad: { latitude: '-39.26', longitude: '177.86' }, + rocket: { + payloads: [ + { + id: 601, + amount: 'not-a-number', + payload: { id: 11, name: 'Unknown Amount Payload' }, + }, + { + id: 602, + amount: 3, + payload: { id: 12, name: 'Triple Payload' }, + }, + { + id: 603, + payload: { + id: 13, + name: 'Valid Fractional Mass Payload', + mass: '12.75', + }, + }, + ], + }, + }, + ], + }, + NOW, + ); + + // Blank, negative and non-numeric masses are pinned by "missing payload + // details stay unknown without inventing mass or classification". + const payloads = launches[0].payloads; + assert.equal(payloads.length, 3); + assert.equal(payloads[0].amount, 1); + assert.equal(payloads[1].amount, 3); + assert.equal(payloads[2].massKg, 12.75); +}); + test('keeps payload and recovery collections empty when LL2 does not disclose them', () => { const launches = normalizeRocketLaunches({ results: [{ id: 'undisclosed', diff --git a/src/layers/cctv/model.js b/src/layers/cctv/model.js index 48c8cddd4..bdf350b4e 100644 --- a/src/layers/cctv/model.js +++ b/src/layers/cctv/model.js @@ -97,12 +97,23 @@ export function createModel({ state: layerState, services, parts, source }) { /** * Coerces a value to a finite number or returns the fallback. + * + * Absent, blank and boolean values return the fallback as well. The source + * list serializes an unknown number (NaN) as JSON null, and `Number(null)` + * is 0, so without this an unknown pose field arrived as zero and was + * clamped to the field's floor instead of taking its default. * @param {*} value * @param {number} [fallback=NaN] * @returns {number} */ function safeNumber(value, fallback = NaN) { + if ( + value == null || + typeof value === 'boolean' || + (typeof value === 'string' && !value.trim()) + ) + return fallback; const n = Number(value); return Number.isFinite(n) ? n : fallback; } diff --git a/src/layers/flights/recordPolicy.js b/src/layers/flights/recordPolicy.js index ea284cb11..e269639d0 100644 --- a/src/layers/flights/recordPolicy.js +++ b/src/layers/flights/recordPolicy.js @@ -13,9 +13,4 @@ export const MISSING_POLL_LIMIT = 3; export const ERROR_BACKOFF_INTERVAL = 20000; /** Local equirectangular distance used for viewer-proximate floor sampling. */ -export function approxDistanceKm(lat1, lon1, lat2, lon2) { - const dLat = (lat2 - lat1) * 111.32; - const dLon = - (lon2 - lon1) * 111.32 * Math.cos((((lat1 + lat2) / 2) * Math.PI) / 180); - return Math.hypot(dLat, dLon); -} +export { approxDistanceKm } from '../../data/approxDistance.js'; diff --git a/src/layers/flights/recordPolicy.test.mjs b/src/layers/flights/recordPolicy.test.mjs new file mode 100644 index 000000000..4cf74d3cf --- /dev/null +++ b/src/layers/flights/recordPolicy.test.mjs @@ -0,0 +1,18 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + approxDistanceKm, + GROUND_FLOOR_CLAMP_RADIUS_KM, +} from './recordPolicy.js'; + +test('approximate distance takes the short way across the antimeridian', () => { + // 0.2° of longitude at Taveuni, Fiji (16.8° S) is about 21 km. + for (const [lon1, lon2] of [ + [179.9, -179.9], + [-179.9, 179.9], + ]) { + const km = approxDistanceKm(-16.8, lon1, -16.8, lon2); + assert.ok(Math.abs(km - 21.31) < 0.01, `${lon1} → ${lon2}: ${km} km`); + assert.ok(km <= GROUND_FLOOR_CLAMP_RADIUS_KM); + } +}); diff --git a/src/layers/installations/ingestion.js b/src/layers/installations/ingestion.js index 664d81943..63862ddff 100644 --- a/src/layers/installations/ingestion.js +++ b/src/layers/installations/ingestion.js @@ -134,15 +134,16 @@ export function createIngestion({ let placesError = null; if (layerState.googleSearchRequested) { layerState.googleSearchRequested = false; + // A view across 180° has west > east; its width runs through 180°. + const lonSpan = box.east - box.west + (box.east < box.west ? 360 : 0); const latitude = (box.south + box.north) / 2; - const longitude = (box.west + box.east) / 2; + let longitude = box.west + lonSpan / 2; + if (longitude > 180) longitude -= 360; const radiusM = Math.min( 50000, Math.max( 1000, - Math.round( - Math.max(box.north - box.south, box.east - box.west) * 55_000, - ), + Math.round(Math.max(box.north - box.south, lonSpan) * 55_000), ), ); try { diff --git a/src/layers/installations/ingestion.test.mjs b/src/layers/installations/ingestion.test.mjs new file mode 100644 index 000000000..fbb90534c --- /dev/null +++ b/src/layers/installations/ingestion.test.mjs @@ -0,0 +1,51 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createIngestion } from './ingestion.js'; + +// Taveuni, Fiji: Cesium reports a view across 180° with west > east. Each view +// is 0.4° wide and 0.2° tall; the last one does not cross 180°. +for (const [west, east, centre] of [ + [179.7, -179.9, 179.9], + [179.9, -179.7, -179.9], + [179.5, 179.9, 179.7], +]) + test(`a nearby search is centred on the view at ${centre}`, async () => { + const box = { south: -17, west, north: -16.8, east }; + const searches = []; + const state = { + enabled: true, + viewer: {}, + records: [], + googleSearchRequested: true, + }; + const ingestion = createIngestion({ + state, + source: { + getMappedSites: async () => ({ records: [], wide: true }), + searchNearby: async (query) => { + searches.push(query); + return { places: [] }; + }, + }, + services: { + render: { governorRequestRender() {} }, + ground: { resolveGroundFloorCellsBounded: async () => {} }, + }, + parts: { + viewport: { + loadArea: () => ({ box, coverage: {} }), + clearUnavailableRetry() {}, + scheduleUnavailableRetry() {}, + }, + model: { installationWithinViewport: () => true }, + rendering: { renderRecords() {}, warmInstallationFloors() {} }, + }, + }); + await ingestion.loadInstallations(); + assert.equal(searches.length, 1); + const { latitude, longitude, radiusM } = searches[0]; + assert.ok(Math.abs(latitude + 16.9) < 1e-9); + assert.ok(Math.abs(longitude - centre) < 1e-9, `longitude ${longitude}`); + // The width, not the height, sets the radius. + assert.equal(radiusM, 22000); + }); diff --git a/src/layers/streetLevel/providers/google/viewer.js b/src/layers/streetLevel/providers/google/viewer.js index 422f52c0c..25e0b08fe 100644 --- a/src/layers/streetLevel/providers/google/viewer.js +++ b/src/layers/streetLevel/providers/google/viewer.js @@ -97,6 +97,8 @@ export function createGoogleViewer({ loader, getService, render } = {}) { service ||= getService ? await getService() : new (await ensureLibrary()).StreetViewService(); + if (!current()) + throw new Error('Street View details lookup was released'); return service.getPanorama({ pano: panoId }); })().then(resolve, reject); }); @@ -236,6 +238,7 @@ export function createGoogleViewer({ loader, getService, render } = {}) { const panoId = String(imageId); if (!host) throw new Error('Street View is not mounted'); if (loader.authFailed()) throw new Error(KEY_REJECTED_MESSAGE); + cancelShown?.(); pendingOpen = panoId; const instance = await ensurePanorama(host); if (pendingOpen !== panoId) return; diff --git a/src/layers/streetLevel/providers/google/viewer.test.mjs b/src/layers/streetLevel/providers/google/viewer.test.mjs index a3d746a34..e1d4a187c 100644 --- a/src/layers/streetLevel/providers/google/viewer.test.mjs +++ b/src/layers/streetLevel/providers/google/viewer.test.mjs @@ -403,3 +403,69 @@ test('a remount looks up again a panorama whose lookup hung', async () => { await settle(); assert.equal(poses.length, count); }); + +test('superseded panorama waits cannot steal the current close cleanup', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const { OPEN_TIMEOUT_MS } = await import('./policy.js'); + const flush = async () => { + for (let i = 0; i < 10; i++) await Promise.resolve(); + }; + const { fake, loader, viewer, host } = setup(); + await viewer.mount(host); + const [panorama] = fake.built; + panorama.setPano = (id) => { + panorama.pano = id; + }; + const first = viewer.open('pano-a'); + await flush(); + const second = viewer.open('pano-b'); + await flush(); + panorama.fire('position_changed'); // new panorama has not reached OK + await flush(); + viewer.close(); + const remaining = loader.listenerCount(); + t.mock.timers.tick(OPEN_TIMEOUT_MS); + await Promise.allSettled([first, second]); + assert.equal( + remaining, + 0, + 'close disposes every pending open without waiting 20 seconds', + ); + assert.equal(panorama.visible, false); +}); + +test('unmount during details-service acquisition makes no later billed lookup', async () => { + const fake = fakeStreetViewLibrary({ panoramas: PANORAMAS }); + const loader = fakeMapsLoader(fake.library); + const { createGoogleProvider } = await import('./index.js'); + let release; + const acquiring = new Promise((resolve) => { + release = resolve; + }); + const importLibrary = loader.importLibrary.bind(loader); + let imports = 0; + loader.importLibrary = async (name) => { + if (++imports > 1) await acquiring; + return importLibrary(name); + }; + const provider = createGoogleProvider({ + getApiKey: () => 'fixture-key', + loader, + }); + const instance = provider.create({ + notify() {}, + actions: { reportError() {} }, + }); + const viewer = instance.viewer; + await viewer.mount(fakeHost()); + await viewer.open('pano-a'); + viewer.unmount(); + release(); + for (let i = 0; i < 10; i++) await Promise.resolve(); + assert.equal( + fake.lookups.length, + 0, + 'teardown invalidates the request before calling getPanorama', + ); + instance.destroy(); +}); diff --git a/src/services/groundFloor.js b/src/services/groundFloor.js index 950c1b94e..27b7dc5e4 100644 --- a/src/services/groundFloor.js +++ b/src/services/groundFloor.js @@ -204,12 +204,16 @@ export function createGroundFloor({ terrain, signal }) { * @returns {{lat: number, lon: number}} The cell to read. */ function stickyFloorCell(lat, lon, previousCell) { - if ( - previousCell && - Math.abs(lat - previousCell.lat) <= 0.0005 + CELL_HYSTERESIS_DEG && - Math.abs(lon - previousCell.lon) <= 0.0005 + CELL_HYSTERESIS_DEG - ) { - return previousCell; + if (previousCell) { + // The short way round, so a contact on the 180° seam keeps its cell. + let dLon = lon - previousCell.lon; + dLon -= 360 * Math.round(dLon / 360); + if ( + Math.abs(lat - previousCell.lat) <= 0.0005 + CELL_HYSTERESIS_DEG && + Math.abs(dLon) <= 0.0005 + CELL_HYSTERESIS_DEG + ) { + return previousCell; + } } return coarseFloorCoord(lat, lon); } @@ -269,17 +273,20 @@ export function createGroundFloor({ terrain, signal }) { const a = points[s]; const b = points[s + 1]; if (![a?.lat, a?.lon, b?.lat, b?.lon].every(Number.isFinite)) continue; + // The short way round, so a segment across 180° walks across it. + let dLon = b.lon - a.lon; + dLon -= 360 * Math.round(dLon / 360); const steps = Math.ceil( - Math.max(Math.abs(b.lat - a.lat), Math.abs(b.lon - a.lon)) / + Math.max(Math.abs(b.lat - a.lat), Math.abs(dLon)) / CORRIDOR_WALK_STEP_DEG, ); for (let i = 1; i <= steps; i++) { if (out.length >= prefixLimit) break walk; const t = i / steps; - const cell = coarseFloorCoord( - a.lat + (b.lat - a.lat) * t, - a.lon + (b.lon - a.lon) * t, - ); + let lon = a.lon + dLon * t; + if (lon > 180) lon -= 360; + else if (lon < -180) lon += 360; + const cell = coarseFloorCoord(a.lat + (b.lat - a.lat) * t, lon); const key = `${cell.lat},${cell.lon}`; if (seen.has(key) || key === destKey) continue; seen.add(key); diff --git a/src/services/meshFloorSampler.js b/src/services/meshFloorSampler.js index b1cf65f29..e1416d288 100644 --- a/src/services/meshFloorSampler.js +++ b/src/services/meshFloorSampler.js @@ -1,4 +1,5 @@ import * as Cesium from 'cesium'; +import { approxDistanceKm } from '../data/approxDistance.js'; /** Construct an instance-owned meshFloorSampler service with explicit dependencies. */ export function createMeshFloorSampler({ @@ -79,16 +80,6 @@ export function createMeshFloorSampler({ return false; } - /** - * Equirectangular distance (km) — same approximation the flights clamp uses. - */ - function _approxKm(lat1, lon1, lat2, lon2) { - const dLat = (lat2 - lat1) * 111.32; - const dLon = - (lon2 - lon1) * 111.32 * Math.cos((((lat1 + lat2) / 2) * Math.PI) / 180); - return Math.hypot(dLat, dLon); - } - /** * Samples the rendered mesh height for the coarse cells containing `points`, * one-shot per cell, budget-capped, viewer-proximity-gated. Call ONCE per @@ -133,7 +124,8 @@ export function createMeshFloorSampler({ if ( Number.isFinite(viewerLat) && Number.isFinite(viewerLon) && - _approxKm(viewerLat, viewerLon, cell.lat, cell.lon) > MAX_SAMPLE_DIST_KM + approxDistanceKm(viewerLat, viewerLon, cell.lat, cell.lon) > + MAX_SAMPLE_DIST_KM ) { continue; // too far: tiles not streamed there, probe would be a guaranteed miss } diff --git a/src/setupDoctor.test.mjs b/src/setupDoctor.test.mjs index 71c6e44af..26d695ba1 100644 --- a/src/setupDoctor.test.mjs +++ b/src/setupDoctor.test.mjs @@ -69,12 +69,22 @@ test('placeholder values are never counted as configured credentials', () => { }); test('doctor selects a Windows-safe npm process without changing Unix behavior', () => { - assert.deepEqual(npmProcessSpec('win32'), { - command: 'npm.cmd', + // Windows passes no argument array with shell: true (Node DEP0190). + assert.deepEqual(npmProcessSpec(['ci'], 'win32'), { + command: 'npm.cmd ci', + args: [], shell: true, }); - assert.deepEqual(npmProcessSpec('darwin'), { command: 'npm', shell: false }); - assert.deepEqual(npmProcessSpec('linux'), { command: 'npm', shell: false }); + assert.deepEqual(npmProcessSpec(['--version'], 'darwin'), { + command: 'npm', + args: ['--version'], + shell: false, + }); + assert.deepEqual(npmProcessSpec(['ci'], 'linux'), { + command: 'npm', + args: ['ci'], + shell: false, + }); }); test('doctor recognizes every OpenSky OAuth keychain alias used by dev-fresh', () => { diff --git a/src/ui/recentImagery.js b/src/ui/recentImagery.js index 300e2536b..857271c8b 100644 --- a/src/ui/recentImagery.js +++ b/src/ui/recentImagery.js @@ -344,7 +344,6 @@ export function createRecentImageryPanel({ const detailsHost = el('div', 'ri-details', root); detailsHost.id = 'ri-details'; let detailsOpen = false; - let detailsShownOpen = false; const details = createRailCards({ container: detailsHost, document, @@ -352,6 +351,8 @@ export function createRecentImageryPanel({ onOpen: () => { detailsOpen = !detailsOpen; render(); + // After the render: preserveScroll() puts the old position back. + if (detailsOpen) revealCard(container, detailsHost.children[0]); }, }); @@ -875,9 +876,6 @@ export function createRecentImageryPanel({ ], }, ]); - if (detailsOpen && !detailsShownOpen) - revealCard(container, detailsHost.children[0]); - detailsShownOpen = detailsOpen; } // ---- divider ---------------------------------------------------------- diff --git a/src/ui/recentImagery.test.mjs b/src/ui/recentImagery.test.mjs index af1758166..a77556797 100644 --- a/src/ui/recentImagery.test.mjs +++ b/src/ui/recentImagery.test.mjs @@ -1147,6 +1147,29 @@ test('the body scroll position survives renders even when DOM mutations reset it assert.equal(f.body.scrollTop, 120); }); +test('opening DETAILS scrolls its card into view, and closing it does not scroll', async () => { + const f = fixture(); + await f.ready(); + // The body is 100 px high and scrolled 40 px; the card opens 150 px down. + f.body.scrollTop = 40; + f.body.clientHeight = 100; + f.body.getBoundingClientRect = () => ({ top: 0 }); + const header = f + .byId('ri-details') + .find((node) => node.classList.contains('rail-card-header')); + const card = header.parentNode; + card.getBoundingClientRect = () => ({ + top: 150, + height: card.dataset.open === 'true' ? 60 : 0, + }); + header.click(); + assert.equal(card.dataset.open, 'true'); + assert.equal(f.body.scrollTop, 150); + header.click(); + assert.equal(card.dataset.open, 'false'); + assert.equal(f.body.scrollTop, 150); +}); + test('destroy returns every listener, the divider and the panel state', async () => { const f = fixture(); await f.ready();