From 81f49406b80f2f4c8076ed739360f79ab2e2c34f Mon Sep 17 00:00:00 2001 From: Wilmer Alzate Date: Wed, 30 Sep 2026 12:31:59 -0500 Subject: [PATCH 01/71] perf(flights): skip dead-reckoning contacts that stay behind the horizon The fleet tick dead-reckoned, floored and distance-tested every contact on every ~80 ms tick, then hid the ones behind the horizon. The worldwide feed carries ~13k contacts; zoomed in, well under 1% are on screen, so nearly all of that work produced a hidden billboard. A contact that is already hidden and still behind the horizon at its last position now re-reckons only on its turn in a staggered one-in-12-ticks rotation (about once a second, HIDDEN_CONTACT_DR_TICKS). Anything that position puts in view, and any contact with a fleet model, takes the full pass immediately, so on-screen behaviour is unchanged. Hidden-inclusive proximity queries (getNearby includeHidden, getAllPositions) read positions at most about a second old, well inside the 30 s poll's uncertainty. Measured in Chrome on a Radeon 780M, ~12.9k contacts, 56 on screen over Colombia at 4 km, same view before and after: fleet tick 7.66 -> 2.03 ms/frame, longest tick 50.9 -> ~16 ms frame 20.6 -> 14.0 ms, 42.9 -> 57.8 FPS Teleporting the camera to Madrid showed 975 contacts within 100 ms. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 ++++ docs/CURRENT-STATE.md | 2 +- scripts/format-scope.json | 1 + .../flights/hiddenContactRotation.test.mjs | 42 +++++++++++++++++++ src/layers/flights/policy.js | 31 ++++++++++++++ src/layers/flights/rendering.js | 24 +++++++++++ src/layers/flights/state.js | 4 ++ 7 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 src/layers/flights/hiddenContactRotation.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 58103d04d..4cc44d40c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +- Stop dead-reckoning every flight hidden behind the horizon on every fleet + tick. With the worldwide feed (~13k contacts, ~56 on screen over Colombia at + 4 km), the fleet pass drops from 7.7 ms to 2.0 ms per frame and its longest + tick from 51 ms to about 16 ms; the scene goes from 43 to 58 FPS. Hidden + contacts re-reckon about once a second, staggered, and appear as soon as + their position is in view. + - Public Overpass instances are no longer used by default. Street Traffic roads come from TomTom flow tiles, OpenFreeMap vector tiles, or both, chosen on the layer row (TomTom / OSM / Hybrid) or with `?trafficRoads=`. With a diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 22ce1eb3b..ee4269a63 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3469,7 +3469,7 @@ silently demoting every later lookup for the session. ### Motion & Symbology Correctness (June 10, 2026) -- **Flights (commercial + military)** render one poll interval behind real time (30s/15s) and interpolate between two known feed-stamped fixes (OpenSky `time_position`; adsb.lol `receipt − seen_pos`). The display latency is an intentional product decision — do not "fix" it away. The whole fleet dead-reckons at ~12Hz (1m² write gating); aircraft get a 3-poll grace period (faded icon) before removal. When a position epoch pauses, both layers coast for at least 60 seconds of contact grace with an absolute five-minute ceiling. Source backoff marks each contact and the cockpit `STALE`; the cockpit then holds the exact layer position instead of continuing inertial flight. Repeated-position kinematic changes create a forward-only synthetic fix rather than mutating history, and grounded history is lifted only when no owned 3D model already controls its datum. A nominally successful worldwide OpenSky response whose own snapshot epoch is more than two minutes old prefers the existing 250-nm viewport-scoped adsb.lol fallback and labels the source/coverage accordingly when that upstream is available. If the fallback is also unavailable, the layer's freshness/error fields use the source epoch—never the cache receipt time—so the UI reports an old snapshot rather than “just now.” +- **Flights (commercial + military)** render one poll interval behind real time (30s/15s) and interpolate between two known feed-stamped fixes (OpenSky `time_position`; adsb.lol `receipt − seen_pos`). The display latency is an intentional product decision — do not "fix" it away. The fleet dead-reckons at ~12Hz (1m² write gating); a contact already hidden behind the horizon whose last position still is re-reckons on a staggered one-in-12-ticks rotation (about once a second) instead, so the worldwide feed's ~13k contacts no longer cost a full pass when well under 1% are on screen. Anything that position puts in view, and any contact with a fleet model, takes the full pass immediately, and hidden-inclusive proximity queries read positions at most about a second old; aircraft get a 3-poll grace period (faded icon) before removal. When a position epoch pauses, both layers coast for at least 60 seconds of contact grace with an absolute five-minute ceiling. Source backoff marks each contact and the cockpit `STALE`; the cockpit then holds the exact layer position instead of continuing inertial flight. Repeated-position kinematic changes create a forward-only synthetic fix rather than mutating history, and grounded history is lifted only when no owned 3D model already controls its datum. A nominally successful worldwide OpenSky response whose own snapshot epoch is more than two minutes old prefers the existing 250-nm viewport-scoped adsb.lol fallback and labels the source/coverage accordingly when that upstream is available. If the fallback is also unavailable, the layer's freshness/error fields use the source epoch—never the cache receipt time—so the UI reports an old snapshot rather than “just now.” - **World-space headings at every angle** (`src/data/iconOrientation.js`): aircraft/vessel icon rotation uses the camera right/up basis per tick (alignedAxis always ZERO), which is exact at screen center and for orthographic/nadir views and remains stable through >180° tracked orbits. Perspective rays vary across the viewport, so off-center contacts at oblique pitch can diverge from an exact finite-difference window projection; a regression test pins that known regime, and field evidence decides whether to adopt exact projection with the basis method as fallback. Fleet rotations refresh on camera-pose change; tracked entities per frame. Billboards are horizon-culled via a shared EllipsoidalOccluder. - **Military/OpenSky reconciliation** (`src/data/militaryRegistry.js`): known-military ICAOs render amber in the flights layer (60s self-poll of the cached mil endpoint when the military layer is off) and are suppressed there while the military layer renders them. - **Satellites**: 838-sat core catalog (stations/visual/GPS/GLONASS/Galileo/GEO), tracking lands ~726km out via `viewFrom` (tracked entity owns a point graphic so the tracking camera engages), rings realign via primitive modelMatrix (no per-second rebuild flicker), optional `setParams({catalog:'dense'})` Starlink mode. diff --git a/scripts/format-scope.json b/scripts/format-scope.json index cd836a5cb..a12d5124f 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -308,6 +308,7 @@ "src/layers/flights/tracking.js", "src/layers/flights/rendering.js", "src/layers/flights/testing.js", + "src/layers/flights/hiddenContactRotation.test.mjs", "src/layers/flights/evidence.js", "src/layers/flights/ingestion.js", "src/layers/flights/queries.js", diff --git a/src/layers/flights/hiddenContactRotation.test.mjs b/src/layers/flights/hiddenContactRotation.test.mjs new file mode 100644 index 000000000..23e81c45b --- /dev/null +++ b/src/layers/flights/hiddenContactRotation.test.mjs @@ -0,0 +1,42 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + FLEET_DR_INTERVAL_MS, + HIDDEN_CONTACT_DR_INTERVAL_MS, + HIDDEN_CONTACT_DR_TICKS, + hiddenContactDrPhase, +} from './policy.js'; + +// Contacts hidden behind the horizon re-reckon on one fleet tick per rotation +// instead of every tick. Their positions still feed hidden-inclusive proximity +// queries, so the rotation must reach every contact about once a second, and +// it must spread them across ticks so no single tick carries the whole fleet. + +const icao = (n) => n.toString(16).padStart(6, '0'); + +test('the rotation revisits each hidden contact about once a second', () => { + const periodMs = HIDDEN_CONTACT_DR_TICKS * FLEET_DR_INTERVAL_MS; + assert.ok(HIDDEN_CONTACT_DR_TICKS > 1); + assert.ok( + Math.abs(periodMs - HIDDEN_CONTACT_DR_INTERVAL_MS) <= FLEET_DR_INTERVAL_MS, + ); +}); + +test('a contact keeps the same slot, inside the rotation', () => { + for (let n = 0; n < 500; n++) { + const phase = hiddenContactDrPhase(icao(n * 7919)); + assert.ok(Number.isInteger(phase)); + assert.ok(phase >= 0 && phase < HIDDEN_CONTACT_DR_TICKS); + assert.equal(hiddenContactDrPhase(icao(n * 7919)), phase); + } +}); + +test('a worldwide fleet is spread evenly enough that no tick carries a burst', () => { + const fleet = 13000; + const perSlot = new Array(HIDDEN_CONTACT_DR_TICKS).fill(0); + // Real ICAO24 addresses cluster by national block; sample several blocks. + for (let n = 0; n < fleet; n++) + perSlot[hiddenContactDrPhase(icao(0x0a0000 + n * 53))]++; + const fair = fleet / HIDDEN_CONTACT_DR_TICKS; + for (const count of perSlot) assert.ok(count < fair * 1.5, `${count} > 1.5×`); +}); diff --git a/src/layers/flights/policy.js b/src/layers/flights/policy.js index f8358c98f..04313c367 100644 --- a/src/layers/flights/policy.js +++ b/src/layers/flights/policy.js @@ -200,6 +200,37 @@ export const FLEET_DR_INTERVAL_MS = 80; export const ROTATION_REFRESH_MS = 1000; +/** + * @constant {number} Dead-reckoning cadence (ms) for contacts hidden behind the + * horizon. The worldwide feed carries ~13k contacts and a zoomed-in view shows + * well under 1% of them; the rest still need positions for hidden-inclusive + * proximity queries, but ~1 s old (≈250 m for an airliner) is well inside the + * 30 s poll's own uncertainty. Each hidden contact is re-reckoned on one tick in + * every HIDDEN_CONTACT_DR_TICKS, staggered by id so no tick carries them all. + */ + +export const HIDDEN_CONTACT_DR_INTERVAL_MS = 1000; + +export const HIDDEN_CONTACT_DR_TICKS = Math.max( + 1, + Math.round(HIDDEN_CONTACT_DR_INTERVAL_MS / FLEET_DR_INTERVAL_MS), +); + +/** + * Stable slot in [0, HIDDEN_CONTACT_DR_TICKS) for a contact id, so each hidden + * contact re-reckons on the same tick of every rotation. + * @param {string} id Contact id (ICAO24). + * @returns {number} + */ + +export function hiddenContactDrPhase(id) { + const text = String(id); + let hash = 0; + for (let i = 0; i < text.length; i++) + hash = (hash * 31 + text.charCodeAt(i)) >>> 0; + return hash % HIDDEN_CONTACT_DR_TICKS; +} + /** Max course slew (deg/s) — well above real turns (≤4°/s), hides fix-boundary * steps. Scaled down toward COURSE_MIN_DPS at low speed (courseSlewCapDps). */ diff --git a/src/layers/flights/rendering.js b/src/layers/flights/rendering.js index 44e1b02c3..2048128dd 100644 --- a/src/layers/flights/rendering.js +++ b/src/layers/flights/rendering.js @@ -33,6 +33,8 @@ import { COURSE_SLEW_DT_MAX_SEC, ROTATION_REFRESH_MS, COURSE_MAX_DPS, + HIDDEN_CONTACT_DR_TICKS, + hiddenContactDrPhase, } from './policy.js'; export function createRendering({ @@ -811,6 +813,12 @@ export function createRendering({ } } + /** The contact's hidden-rotation slot, cached on its billboard. */ + function _hiddenDrPhase(bb, icao24) { + bb._gevHiddenDrPhase ??= hiddenContactDrPhase(icao24); + return bb._gevHiddenDrPhase; + } + function _fleetTick() { if ( !flightState._viewer || @@ -931,9 +939,25 @@ export function createRendering({ for (const icao of toRelease) _releaseModel(icao); } + const hiddenDrPhase = flightState._fleetTickSeq++ % HIDDEN_CONTACT_DR_TICKS; + for (const [icao24, bb] of flightState._billboards) { if (icao24 === flightState._trackedIcao) continue; // tracked entity owns its own motion + // A contact still behind the horizon at its last position would only be + // re-reckoned to be hidden again, so off its turn it keeps that position + // (see HIDDEN_CONTACT_DR_TICKS). Anything the stale position puts in view, + // and any contact with a fleet model, takes the full pass below at once. + if ( + !bb.show && + _hiddenDrPhase(bb, icao24) !== hiddenDrPhase && + !flightState._models.has(icao24) && + !occluder.isPointVisible( + flightState._cullPositions.get(icao24) || bb.position, + ) + ) + continue; + const info = flightState.records.data.get(icao24); const dr = parts.motion._deadReckon(icao24, flightState._scratchFleetPos); diff --git a/src/layers/flights/state.js b/src/layers/flights/state.js index 2978ceba4..d1adec064 100644 --- a/src/layers/flights/state.js +++ b/src/layers/flights/state.js @@ -204,6 +204,10 @@ export function createFlightState({ source, services }) { flightState._lastFleetTickMs = 0; + /** @type {number} Fleet ticks run; selects which hidden contacts re-reckon */ + + flightState._fleetTickSeq = 0; + /** @type {string} Camera pose signature at the last rotation pass */ flightState._lastCamPoseSig = ''; From aa745bcee5c4f76353c9426fb9055cb5737f0fc0 Mon Sep 17 00:00:00 2001 From: Trygve Hemminghytt <274629477+trygvehhh@users.noreply.github.com> Date: Wed, 7 Oct 2026 04:02:05 +0200 Subject: [PATCH 02/71] data: refresh Norway datacenters from Geofabrik extract Add a maintainer script that merges a pinned, MD5-verified Geofabrik Norway extract (osmium tags-filter telecom/building=data_center) into the bundled datacenter layer. Only features inside the Geofabrik Norway boundary are touched; unchanged features keep their original line. norway-261005 (OSM 2026-10-05) adds Troll Housing (w1551029155) and updates tags on four existing features. Provenance is recorded in the dataset README and the bundled count moves from 4,351 to 4,352. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 + DATA_SOURCES.md | 2 +- README.md | 2 +- docs/INFRASTRUCTURE-LAYERS.md | 2 +- scripts/build-datacenters-norway.py | 196 ++++++++++++++++++ src/data/infrastructure.test.mjs | 2 +- src/data/local_data/datacenters/README.md | 16 +- .../datacenters/datacenters.geojsonl | 9 +- 8 files changed, 226 insertions(+), 9 deletions(-) create mode 100644 scripts/build-datacenters-norway.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b433f8c06..031e36751 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +- Refresh Norway's datacenters from a Geofabrik OpenStreetMap extract + (2026-10-05). Troll Housing joins the layer (4,352 features) and four + Norwegian sites pick up current OSM tags; no site moved or was removed. + `scripts/build-datacenters-norway.py` reproduces or `--verify`s the merge, + touching only features inside Geofabrik's Norway boundary, and the dataset + README records the extract, checksum and filter. - Add Norway's road cameras from Statens vegvesen as a CCTV pack. The camera list is one keyless GeoJSON request to the agency's OGC API view of its DATEX 3.1 CCTV table, so no DATEX account is needed. Every working camera diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 734e35a69..b4f0dc0cc 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -112,7 +112,7 @@ The [Bhote Koshi event pack](public/events/bhote-koshi-2026/README.md), under `p | Dataset | Folder | License | Commercial use? | Attribution | | --------------------------------------------------------------------------- | --------------------------------- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | --------------------------------------------------------------------------- | -| **Datacenters** (4,351) | `datacenters/` | **ODbL 1.0** (OpenStreetMap extract) | ✅ (attribution + share-alike on data) | © OpenStreetMap contributors (shared credit) | +| **Datacenters** (4,352) | `datacenters/` | **ODbL 1.0** (OpenStreetMap extract) | ✅ (attribution + share-alike on data) | © OpenStreetMap contributors (shared credit) | | **Dams** (704) | `dams/` | **ODbL 1.0** (OpenInfraMap / OSM extract) | ✅ (attribution + share-alike on data) | © OpenStreetMap contributors (shared credit) + Open Infrastructure Map | | **Military area names** (36,466) | `osm_military_names/` | **ODbL 1.0** (OpenStreetMap, via Overture Maps) | ✅ (attribution + share-alike on data) | © OpenStreetMap contributors (shared credit) + Overture Maps Foundation | | **TeleGeography Submarine Cable Map** (712 cables + 1,917 landing points) | `telegeography_submarine_cables/` | **CC BY-NC-SA 3.0** | ❌ **NonCommercial — remove for commercial use** | "© TeleGeography — submarinecablemap.com" | diff --git a/README.md b/README.md index 724ea18cb..0500645af 100644 --- a/README.md +++ b/README.md @@ -323,7 +323,7 @@ In the app, **Data Layers** groups them as Movement, Cameras, Infrastructure, Ev _The Space Missions layer replaying a Falcon 9 ascent — labeled `RECONSTRUCTED ESTIMATE`, scrubbable 0.25×–4×._ -**Also on the globe:** neighborhood overlays · an optional cockpit WX cloud effect. **Bundled static infrastructure:** Datacenters (4,351), Dams (704), and Submarine Cables (712). +**Also on the globe:** neighborhood overlays · an optional cockpit WX cloud effect. **Bundled static infrastructure:** Datacenters (4,352), Dams (704), and Submarine Cables (712). ![Diving into the Bahamas and revealing labeled submarine cable routes beneath the globe](docs/media/09-undersea-cables.gif) diff --git a/docs/INFRASTRUCTURE-LAYERS.md b/docs/INFRASTRUCTURE-LAYERS.md index 76567e605..1166a9e69 100644 --- a/docs/INFRASTRUCTURE-LAYERS.md +++ b/docs/INFRASTRUCTURE-LAYERS.md @@ -58,6 +58,6 @@ factory and supplies its existing functions. Other consumers should use the pack exports, which do not import standalone application globals. Dataset files and source/license notices remain under `src/data/local_data/`. -Their input counts are 4,351 datacenter features and 704 dam features. Cesium may +Their input counts are 4,352 datacenter features and 704 dam features. Cesium may expand multipart geometries into multiple entities; entity and stem counts are different measurements. This change does not refresh or relicense the datasets. diff --git a/scripts/build-datacenters-norway.py b/scripts/build-datacenters-norway.py new file mode 100644 index 000000000..01a891a8b --- /dev/null +++ b/scripts/build-datacenters-norway.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 +"""Maintainer-only Norway refresh of the bundled datacenter extract (osmium-tool). + +Downloads a pinned Geofabrik Norway extract, verifies its MD5, filters +telecom=data_center / building=data_center with osmium, and merges the result +into datacenters.geojsonl: features inside the Geofabrik Norway boundary are +added, updated or removed; every other line is kept byte-for-byte. +""" +import argparse +import hashlib +import json +import math +from pathlib import Path +import shutil +import subprocess +import urllib.request + +ROOT = Path(__file__).resolve().parent.parent +EXTRACT = 'norway-261005' +GEOFABRIK = 'https://download.geofabrik.de/europe/' +DATASET = ROOT / 'src/data/local_data/datacenters/datacenters.geojsonl' +CONTACT_TAGS = {'email', 'phone', 'fax', 'mobile', 'whatsapp', 'contact:email', 'contact:phone', + 'contact:fax', 'contact:mobile', 'contact:whatsapp'} +SAME_GEOMETRY_METERS = 0.05 + + +def fetch(url, target): + if not target.exists(): + with urllib.request.urlopen(url) as response, open(str(target) + '.part', 'wb') as out: + shutil.copyfileobj(response, out) + Path(str(target) + '.part').rename(target) + return target + + +def md5(path): + digest = hashlib.md5() + with open(path, 'rb') as handle: + for block in iter(lambda: handle.read(1 << 20), b''): + digest.update(block) + return digest.hexdigest() + + +def read_poly(path): + """Geofabrik .poly -> list of (ring, is_hole).""" + rings, ring, hole = [], None, False + for raw in path.read_text().splitlines()[1:]: + line = raw.strip() + if ring is None: + if line and line != 'END': + ring, hole = [], line.startswith('!') + elif line == 'END': + rings.append((ring, hole)) + ring = None + elif line: + lon, lat = (float(v) for v in line.split()) + ring.append((lon, lat)) + return rings + + +def inside(rings, lon, lat): + def in_ring(ring): + hit = False + for (x1, y1), (x2, y2) in zip(ring, ring[1:] + ring[:1]): + if (y1 > lat) != (y2 > lat) and lon < x1 + (lat - y1) * (x2 - x1) / (y2 - y1): + hit = not hit + return hit + return any(in_ring(r) for r, h in rings if not h) and not any(in_ring(r) for r, h in rings if h) + + +def points(coordinates): + if isinstance(coordinates[0], (int, float)): + return [coordinates] + return [p for part in coordinates for p in points(part)] + + +def osm_key(feature): + osm_id = feature['properties']['osm_id'] + if osm_id < 0: + return 'r%d' % -osm_id + return ('n' if feature['geometry']['type'] == 'Point' else 'w') + str(osm_id) + + +def export(osmium, pbf, cache): + filtered = cache / (EXTRACT + '-datacenters.osm.pbf') + exported = cache / (EXTRACT + '-datacenters.geojsonseq') + subprocess.run([osmium, 'tags-filter', '-O', '-o', str(filtered), str(pbf), + 'nwr/telecom=data_center', 'nwr/building=data_center'], check=True) + subprocess.run([osmium, 'export', '-O', '-f', 'geojsonseq', '--add-unique-id=type_id', + '-o', str(exported), str(filtered)], check=True) + fresh = {} + for line in exported.read_text().splitlines(): + line = line.lstrip('\x1e').strip() + if not line: + continue + feature = json.loads(line) + tags = {k: v for k, v in feature['properties'].items() if k not in CONTACT_TAGS} + if tags.get('telecom') != 'data_center' and tags.get('building') != 'data_center': + continue # untagged member ways exported only for geometry + key = feature['id'] + geometry = feature['geometry'] + if key[0] == 'a': # osmium area id: way * 2 or relation * 2 + 1 + number = int(key[1:]) + key = ('w%d' % (number // 2)) if number % 2 == 0 else ('r%d' % (number // 2)) + if key[0] == 'r': + tags = {**tags, 'type': 'multipolygon'} + if geometry['type'] == 'MultiPolygon' and len(geometry['coordinates']) == 1: + geometry = {'type': 'Polygon', 'coordinates': geometry['coordinates'][0]} + elif key in fresh or geometry['type'] == 'LineString': + continue # closed ways are taken from their area export + fresh[key] = {'geometry': geometry, 'tags': tags} + return fresh + + +def same_geometry(old, new): + """Same type and vertex set within tolerance; ring start and winding may differ.""" + if old['type'] != new['type']: + return False + a, b = points(old['coordinates']), points(new['coordinates']) + if len(a) != len(b): + return False + + def meters(p, q): + dx = (p[0] - q[0]) * 111320 * math.cos(math.radians(p[1])) + return math.hypot(dx, (p[1] - q[1]) * 110540) + return all(min(meters(p, q) for q in b) <= SAME_GEOMETRY_METERS for p in a) and \ + all(min(meters(q, p) for p in a) <= SAME_GEOMETRY_METERS for q in b) + + +def feature_line(key, sequence, fresh): + osm_id = int(key[1:]) * (-1 if key[0] == 'r' else 1) + feature = {'id': osm_id, 'type': 'Feature', 'geometry': fresh['geometry'], + 'properties': {'id': sequence, 'tags': fresh['tags'], 'type': 'data_center', + 'osm_id': osm_id}} + return json.dumps(feature, separators=(',', ':'), ensure_ascii=False) + + +def merge(lines, fresh, rings): + out, seen, sequence = [], set(), 0 + report = {'kept': 0, 'updated': [], 'removed': [], 'added': []} + for line in lines: + feature = json.loads(line) + sequence = max(sequence, feature['properties']['id']) + key = osm_key(feature) + if key in fresh: + seen.add(key) + old_tags = feature['properties']['tags'] + if old_tags == fresh[key]['tags'] and same_geometry(feature['geometry'], fresh[key]['geometry']): + out.append(line) + report['kept'] += 1 + else: + out.append(feature_line(key, feature['properties']['id'], fresh[key])) + report['updated'].append(key) + continue + lon, lat = points(feature['geometry']['coordinates'])[0][:2] + if inside(rings, lon, lat): + report['removed'].append(key) + continue + out.append(line) + for key in sorted(set(fresh) - seen, key=lambda k: (k[0], int(k[1:]))): + sequence += 1 + out.append(feature_line(key, sequence, fresh[key])) + report['added'].append(key) + if len({osm_key(json.loads(line)) for line in out}) != len(out): + raise ValueError('Duplicate OSM identity') + return out, report + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--cache', type=Path, required=True) + parser.add_argument('--osmium', default=shutil.which('osmium') or 'osmium') + parser.add_argument('--verify', action='store_true') + args = parser.parse_args() + args.cache.mkdir(parents=True, exist_ok=True) + pbf = fetch(GEOFABRIK + EXTRACT + '.osm.pbf', args.cache / (EXTRACT + '.osm.pbf')) + expected = fetch(GEOFABRIK + EXTRACT + '.osm.pbf.md5', + args.cache / (EXTRACT + '.osm.pbf.md5')).read_text().split()[0] + actual = md5(pbf) + if actual != expected: + raise RuntimeError('MD5 mismatch for %s: %s != %s' % (pbf.name, actual, expected)) + rings = read_poly(fetch(GEOFABRIK + 'norway.poly', args.cache / 'norway.poly')) + fresh = export(args.osmium, pbf, args.cache) + current = [line for line in DATASET.read_text().splitlines() if line.strip()] + merged, report = merge(current, fresh, rings) + payload = '\n'.join(merged) + '\n' + if args.verify: + if payload != DATASET.read_text(): + raise SystemExit('datacenters.geojsonl is not the %s merge' % EXTRACT) + else: + DATASET.write_text(payload) + print(json.dumps({'extract': EXTRACT, 'md5': actual, 'norway': len(fresh), + 'features': len(merged), **report})) + + +if __name__ == '__main__': + main() diff --git a/src/data/infrastructure.test.mjs b/src/data/infrastructure.test.mjs index 45fb3d317..c7f1b5a23 100644 --- a/src/data/infrastructure.test.mjs +++ b/src/data/infrastructure.test.mjs @@ -82,7 +82,7 @@ test('infrastructure factory preserves identity and creates independent state wi test('dataset URLs still name the complete bundled sources', () => { for (const [file, count] of [ - ['datacenters', 4351], + ['datacenters', 4352], ['dams', 704], ]) { const lines = readFileSync( diff --git a/src/data/local_data/datacenters/README.md b/src/data/local_data/datacenters/README.md index 454a15007..ef0720ae4 100644 --- a/src/data/local_data/datacenters/README.md +++ b/src/data/local_data/datacenters/README.md @@ -5,7 +5,7 @@ layer. - Source: OpenStreetMap contributors - License: Open Database License (ODbL) 1.0 -- Feature count: 4,351 +- Feature count: 4,352 - Runtime file: `datacenters.geojsonl` The public-release snapshot removes contact-oriented tags such as email, phone, @@ -16,3 +16,17 @@ ODbL 1.0 with the required OpenStreetMap attribution. The original extraction date and query were not recorded alongside this snapshot. Future refreshes should record both before replacing the file. + +## Norway refresh + +Features inside the Geofabrik Norway boundary (`norway.poly`, which includes +Svalbard) were refreshed separately, so this file mixes two vintages: Norway +reflects OSM as of the extract below, everything else the original snapshot. + +- Extract: Geofabrik `norway-261005.osm.pbf`, OSM data + 2026-10-05T20:21:35Z, MD5 `1d2cfdeb98980fce11b8891d0f9146c0` +- Filter: `osmium tags-filter nwr/telecom=data_center nwr/building=data_center` +- Contact tags are dropped as above; unchanged features keep their original + line, so geometry is only rewritten when tags or vertices actually changed. +- Rebuild or check: `python3 scripts/build-datacenters-norway.py --cache ` + (add `--verify` to check without writing; requires `osmium-tool`) diff --git a/src/data/local_data/datacenters/datacenters.geojsonl b/src/data/local_data/datacenters/datacenters.geojsonl index f7f5fc89f..b02bd57b3 100644 --- a/src/data/local_data/datacenters/datacenters.geojsonl +++ b/src/data/local_data/datacenters/datacenters.geojsonl @@ -3770,15 +3770,15 @@ {"id":939340882,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[21.021364183,52.315373019],[21.02232433,52.315435883],[21.022363641,52.315209656],[21.021403578,52.315146875],[21.021364183,52.315373019]]]},"properties":{"id":11407,"tags":{"name":"T-Mobile Warszawa-Szlachecka","telecom":"data_center","building":"office","building:levels":"3"},"type":"data_center","osm_id":939340882}} {"id":1005973518,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[21.021325375,52.315595978],[21.022285857,52.315658423],[21.02232433,52.315435883],[21.021364183,52.315373019],[21.021325375,52.315595978]]]},"properties":{"id":11976,"tags":{"telecom":"data_center","building":"office","building:levels":"3"},"type":"data_center","osm_id":1005973518}} {"id":1156823776,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[21.115130857,52.235385942],[21.115189866,52.236349777],[21.115232027,52.236667954],[21.115818174,52.237174556],[21.116175578,52.237468677],[21.117262124,52.236985628],[21.11728157,52.236976576],[21.117262795,52.236960985],[21.117340495,52.236926033],[21.117360276,52.2369433],[21.117447532,52.236904491],[21.11804097,52.236654459],[21.118030996,52.236645742],[21.118041054,52.236641719],[21.116603055,52.235624072],[21.116518984,52.235676375],[21.115986231,52.235696659],[21.115954966,52.235361551],[21.115130857,52.235385942]]]},"properties":{"id":15342,"tags":{"name":"Atman Data Center Warsaw-1","telecom":"data_center","website":"https://www.atman.pl"},"type":"data_center","osm_id":1156823776}} -{"id":7405586142,"type":"Feature","geometry":{"type":"Point","coordinates":[5.598350034,58.860111293]},"properties":{"id":24849,"tags":{"name":"Maan Group","telecom":"data_center","operator":"Privat"},"type":"data_center","osm_id":7405586142}} +{"id":7405586142,"type":"Feature","geometry":{"type":"Point","coordinates":[5.5983501,58.8601113]},"properties":{"id":24849,"tags":{"name":"Maan Group","opening_hours":"24/7","operator":"Privat","telecom":"data_center"},"type":"data_center","osm_id":7405586142}} {"id":481615983,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[9.572510149,56.481777951],[9.574069938,56.484921751],[9.57465357,56.48483349],[9.574639572,56.484805243],[9.574700676,56.484795939],[9.574714925,56.484824857],[9.575298557,56.484736595],[9.57454888,56.483225422],[9.574609229,56.483216369],[9.574510155,56.483016629],[9.574449722,56.483025849],[9.573738769,56.481592795],[9.573155137,56.481680972],[9.573170643,56.481712321],[9.573109539,56.481721625],[9.573093781,56.48168969],[9.572510149,56.481777951]]]},"properties":{"id":7881,"tags":{"telecom":"data_center","building":"data_center","operator":"Apple","wikidata":"Q19721053","wikipedia":"en:Foulum Data Center","description":"Apple Datacenter"},"type":"data_center","osm_id":481615983}} {"id":618756435,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[10.500360092,57.318668738],[10.500399236,57.318671672],[10.500382891,57.318734871],[10.500552038,57.318744175],[10.500545249,57.318787677],[10.500501495,57.318785666],[10.500458999,57.318957746],[10.500970295,57.31899647],[10.501009187,57.318847189],[10.501084959,57.318852888],[10.50111966,57.318719197],[10.500843896,57.318698159],[10.500865521,57.318615178],[10.500859235,57.318614759],[10.500869461,57.318575699],[10.500954537,57.318582153],[10.501022347,57.31832223],[10.500736691,57.318300437],[10.500669133,57.318559689],[10.500674162,57.318560025],[10.500664272,57.318598498],[10.500383981,57.318577124],[10.500360092,57.318668738]]]},"properties":{"id":8842,"tags":{"ref":"AAL01-01","name":"Prime Data Centers - Saeby Campus AAL01","telecom":"data_center","website":"https://primedatacenters.com/locations/saeby/","building":"industrial","operator":"Prime Data Centers"},"type":"data_center","osm_id":618756435}} -{"id":-14367602,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[11.002243225,59.633471864],[11.003903261,59.633446551],[11.007046726,59.633399696],[11.006983443,59.632420941],[11.006982772,59.63227057],[11.006911023,59.632265289],[11.006852936,59.632253973],[11.006765848,59.632228576],[11.00671782,59.632205275],[11.006119687,59.632213573],[11.006119352,59.632202592],[11.006118933,59.63219245],[11.005930424,59.632194378],[11.005923635,59.632120869],[11.005980799,59.632120031],[11.005979542,59.632104524],[11.005977027,59.63206865],[11.00597225,59.631998242],[11.005412171,59.632035541],[11.005286945,59.632043839],[11.00527848,59.632044342],[11.003707962,59.632147272],[11.003107483,59.63219178],[11.002741864,59.632235533],[11.002729375,59.63223788],[11.002345987,59.632309294],[11.002308352,59.632316251],[11.002302066,59.632477351],[11.002277088,59.632913881],[11.002243225,59.633471864]]]},"properties":{"id":107,"tags":{"name":"STACK Infrastructure OSL04","type":"multipolygon","telecom":"data_center","website":"https://www.stackinfra.com","operator":"Stack Infrastructure","operator:wikidata":"Q115477173"},"type":"data_center","osm_id":-14367602}} +{"id":-14367602,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[11.0022433,59.6334719],[11.0022771,59.6329139],[11.0023021,59.6324774],[11.0023084,59.6323163],[11.002346,59.6323093],[11.0027294,59.6322379],[11.0027419,59.6322356],[11.0031075,59.6321918],[11.003708,59.6321473],[11.0052785,59.6320444],[11.005287,59.6320439],[11.0054122,59.6320356],[11.0059723,59.6319983],[11.0059771,59.6320687],[11.0059796,59.6321046],[11.0059808,59.6321201],[11.0059237,59.6321209],[11.0059305,59.6321944],[11.006119,59.6321925],[11.0061194,59.6322026],[11.0061197,59.6322136],[11.0067179,59.6322053],[11.0067659,59.6322286],[11.006853,59.632254],[11.0069111,59.6322653],[11.0069828,59.6322706],[11.0069835,59.632421],[11.0070468,59.6333997],[11.0039033,59.6334466],[11.0022433,59.6334719]]]},"properties":{"id":107,"tags":{"landuse":"industrial","name":"STACK Infrastructure OSL04","operator":"Stack Infrastructure","operator:wikidata":"Q115477173","telecom":"data_center","website":"https://www.stackinfra.com","type":"multipolygon"},"type":"data_center","osm_id":-14367602}} {"id":1028687251,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[11.004111886,59.632578437],[11.004142983,59.633037262],[11.004275166,59.633034999],[11.004274998,59.63303173],[11.00505133,59.633019157],[11.005056443,59.633099372],[11.005224416,59.63309669],[11.005190637,59.632562344],[11.005060885,59.632564439],[11.005060969,59.632567457],[11.004587978,59.632575419],[11.00458538,59.632536527],[11.004459819,59.632538623],[11.004462418,59.632576844],[11.004242476,59.6325807],[11.004242141,59.632576174],[11.004111886,59.632578437]]]},"properties":{"id":12226,"tags":{"building":"data_center","ref:bygningsnr":"300783262"},"type":"data_center","osm_id":1028687251}} {"id":1164840308,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[11.002600126,59.632599392],[11.002631223,59.633058217],[11.00276349,59.633055954],[11.002763238,59.633052685],[11.00353957,59.633040196],[11.003544683,59.633120327],[11.00371274,59.633117645],[11.003678961,59.632583382],[11.003549042,59.632585478],[11.003549293,59.632588495],[11.003076135,59.632596458],[11.00307362,59.632557566],[11.002948143,59.632559661],[11.002950657,59.632597883],[11.002730716,59.632601571],[11.002730465,59.632597129],[11.002600126,59.632599392]]]},"properties":{"id":15459,"tags":{"building":"data_center","ref:bygningsnr":"300783262"},"type":"data_center","osm_id":1164840308}} {"id":1164840309,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[11.005613672,59.632723444],[11.005644769,59.633182269],[11.005776951,59.633179922],[11.005776784,59.633176737],[11.006553199,59.633164164],[11.006558229,59.633244463],[11.006726286,59.633241781],[11.006692423,59.632707351],[11.006562671,59.632709446],[11.006562839,59.632712464],[11.006089764,59.632720426],[11.006087166,59.632681534],[11.005961772,59.632683797],[11.005964287,59.632721851],[11.005744262,59.632725623],[11.005743927,59.632721181],[11.005613672,59.632723444]]]},"properties":{"id":15460,"tags":{"building":"data_center","ref:bygningsnr":"300783262"},"type":"data_center","osm_id":1164840309}} -{"id":935092287,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[10.991467366,59.759024056],[10.992780978,59.759406942],[10.992773937,59.759423957],[10.992948197,59.759474248],[10.993073842,59.759363859],[10.993022293,59.759348939],[10.993291688,59.759112653],[10.993128827,59.759065547],[10.993181885,59.759019027],[10.993083062,59.758990445],[10.992976947,59.759083484],[10.992845519,59.759045346],[10.992813584,59.759073342],[10.9927876,59.759065798],[10.993409789,59.758524243],[10.99263002,59.758297094],[10.991938848,59.758898663],[10.991693594,59.758827165],[10.991467366,59.759024056]]]},"properties":{"id":11377,"tags":{"name":"Green Mountain Ytre Enebakk","telecom":"data_center","website":"https://greenmountain.no/","building":"warehouse","ref:bygningsnr":"300752944"},"type":"data_center","osm_id":935092287}} -{"id":1161764012,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[10.992826324,59.757448761],[10.994530281,59.757601396],[10.994634888,59.757303168],[10.992928332,59.757152545],[10.992826324,59.757448761]]]},"properties":{"id":15426,"tags":{"name":"Green Mountain Ytre Enebakk","telecom":"data_center","website":"https://greenmountain.no/","building":"industrial"},"type":"data_center","osm_id":1161764012}} +{"id":935092287,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[10.9914674,59.7590241],[10.9916936,59.7588272],[10.9919389,59.7588987],[10.9926301,59.7582971],[10.9934098,59.7585243],[10.9927876,59.7590658],[10.9928136,59.7590734],[10.9928456,59.7590454],[10.992977,59.7590835],[10.9930831,59.7589905],[10.9931819,59.7590191],[10.9931289,59.7590656],[10.9932917,59.7591127],[10.9930223,59.759349],[10.9930739,59.7593639],[10.9929482,59.7594743],[10.992774,59.759424],[10.992781,59.759407],[10.9914674,59.7590241]]]},"properties":{"id":11377,"tags":{"building":"warehouse","name":"Green Mountain Ytre Enebakk","ref:bygningsnr":"300752944","source":"Norway Orthophoto 2024;https://greenmountain.no/data-center/osl1-enebakk/","telecom":"data_center","website":"https://greenmountain.no/"},"type":"data_center","osm_id":935092287}} +{"id":1161764012,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[10.9928264,59.7574488],[10.9929284,59.7571526],[10.9946349,59.7573032],[10.9945303,59.7576014],[10.9928264,59.7574488]]]},"properties":{"id":15426,"tags":{"building":"industrial","name":"Green Mountain Ytre Enebakk","source":"Norway Orthophoto 2024;https://greenmountain.no/data-center/osl1-enebakk/","telecom":"data_center","website":"https://greenmountain.no/"},"type":"data_center","osm_id":1161764012}} {"id":30724580,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[10.808790995,59.924984985],[10.808893925,59.925031923],[10.80893793,59.92500594],[10.808982941,59.925025972],[10.809042955,59.92501298],[10.809083943,59.925031923],[10.809012948,59.925077856],[10.80926583,59.925189755],[10.80955115,59.925025972],[10.809676543,59.925081293],[10.81032899,59.924701928],[10.809890365,59.924508138],[10.80982155,59.924515347],[10.809691631,59.924457847],[10.808820667,59.924557592],[10.809099365,59.924699581],[10.808806418,59.924865291],[10.808918149,59.924914828],[10.808875988,59.924937962],[10.808790995,59.924984985]]]},"properties":{"id":261,"tags":{"name":"Stack Infrastructure OSLO1A","telecom":"data_center","building":"office","operator":"Stack Infrastructure","ref:bygningsnr":"80877293","building:levels":"4","operator:wikidata":"Q115477173"},"type":"data_center","osm_id":30724580}} {"id":80871917,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[10.83408892,59.938588981],[10.834305927,59.938720996],[10.834575992,59.938720996],[10.834574987,59.938736],[10.834581943,59.938736922],[10.834579932,59.938771958],[10.834736925,59.938771958],[10.834734997,59.93893792],[10.836717988,59.938944961],[10.836717988,59.938983937],[10.836817984,59.938983937],[10.83681899,59.938944961],[10.836844974,59.938944961],[10.836846985,59.938774976],[10.836841956,59.938774976],[10.836842962,59.938744968],[10.836849919,59.938744968],[10.836851931,59.938653941],[10.836683957,59.938652935],[10.836684125,59.938640278],[10.836686975,59.938414973],[10.83490297,59.938406926],[10.834899953,59.938646984],[10.834812949,59.938645978],[10.83481496,59.938551933],[10.834579932,59.938550927],[10.834580938,59.938564925],[10.834567946,59.938564925],[10.834567946,59.938551933],[10.834089926,59.938549922],[10.83408892,59.938588981]]]},"properties":{"id":1464,"tags":{"name":"Bulk Data Centers Oslo Internet Exchange","telecom":"data_center","website":"https://bulkinfrastructure.com/data-centers/locations/os-ix","building":"industrial","ref:bygningsnr":"80709943"},"type":"data_center","osm_id":80871917}} {"id":12158409495,"type":"Feature","geometry":{"type":"Point","coordinates":[9.689363435,62.59439936]},"properties":{"id":28723,"tags":{"name":"VisBook AS","telecom":"data_center","website":"https://visbook.com/"},"type":"data_center","osm_id":12158409495}} @@ -4349,3 +4349,4 @@ {"id":540895898,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[101.71747909,56.303855963],[101.71782015,56.304420065],[101.721113819,56.303806929],[101.720772759,56.303242994],[101.71747909,56.303855963]]]},"properties":{"id":8271,"tags":{"name":"Битривер Рус","name:en":"Bitriver Rus","name:ru":"Битривер Рус","telecom":"data_center","website":"https://bitriver.farm/","building":"data_center"},"type":"data_center","osm_id":540895898}} {"id":1082803064,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[123.948021193,47.340264299],[123.948354625,47.340268239],[123.948490663,47.340269831],[123.948488568,47.340349292],[123.948809762,47.340353064],[123.948811942,47.340267485],[123.948918643,47.340268658],[123.948922918,47.340137817],[123.948023959,47.340126082],[123.948021193,47.340264299]]]},"properties":{"id":13377,"tags":{"name":"联通云数据中心·齐齐哈尔","name:en":"Unicom Data Center of Qiqihar","name:zh":"联通云数据中心·齐齐哈尔","telecom":"data_center","building":"yes","name:zh-Hans":"联通云数据中心·齐齐哈尔","name:zh-Hant":"聯通雲數據中心·齊齊哈爾"},"type":"data_center","osm_id":1082803064}} {"id":442479059,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[142.76019226,46.910096898],[142.760192427,46.910325724],[142.760447656,46.910328155],[142.760460397,46.910102263],[142.76019226,46.910096898]]]},"properties":{"id":7471,"tags":{"telecom":"data_center","building":"yes"},"type":"data_center","osm_id":442479059}} +{"id":1551029155,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[7.1069147,62.8627625],[7.1071207,62.8621617],[7.1075917,62.8616616],[7.1088647,62.8620476],[7.1106308,62.862645],[7.1098655,62.8632827],[7.1089604,62.8630042],[7.1086734,62.8632156],[7.1069147,62.8627625]]]},"properties":{"id":29471,"tags":{"layer":"-1","loc_name":"Troll Housing Klempertåsen","name":"Troll Housing","operator":"Troll Housing AS","telecom":"data_center","website":"https://www.trollhousing.no/"},"type":"data_center","osm_id":1551029155}} From 5b5d942d95dddd56b3bacb465c69098913688d1f Mon Sep 17 00:00:00 2001 From: Trygve Hemminghytt <274629477+trygvehhh@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:23:02 +0200 Subject: [PATCH 03/71] fix(data): make the Norway datacenter script portable and re-runnable Read and write files as UTF-8 with LF line endings so the script and --verify work on Windows, where the default codec is cp1252 (thanks to quanticsoul4772 for the report). Geofabrik keeps only its newest dated extract, so --extract now defaults to the current one and accepts a dated name for --verify while it is listed; the dataset README says so. Downloads time out instead of hanging. --- CHANGELOG.md | 1 + scripts/build-datacenters-norway.py | 52 +++++++++++++++-------- src/data/local_data/datacenters/README.md | 8 +++- 3 files changed, 42 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 20a9da4e6..067ca16b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -184,6 +184,7 @@ `scripts/build-datacenters-norway.py` reproduces or `--verify`s the merge, touching only features inside Geofabrik's Norway boundary, and the dataset README records the extract, checksum and filter. + - Add a keyless Air quality layer to the Weather group, with Environment and Climate Change Canada's AQHI as the first registered provider. Readings carry `{ provider, scale, value, band }` so another network can be added without diff --git a/scripts/build-datacenters-norway.py b/scripts/build-datacenters-norway.py index 01a891a8b..687395f7b 100644 --- a/scripts/build-datacenters-norway.py +++ b/scripts/build-datacenters-norway.py @@ -1,10 +1,13 @@ #!/usr/bin/env python3 """Maintainer-only Norway refresh of the bundled datacenter extract (osmium-tool). -Downloads a pinned Geofabrik Norway extract, verifies its MD5, filters +Downloads a dated Geofabrik Norway extract, verifies its MD5, filters telecom=data_center / building=data_center with osmium, and merges the result into datacenters.geojsonl: features inside the Geofabrik Norway boundary are added, updated or removed; every other line is kept byte-for-byte. + +Geofabrik keeps only its newest dated extract, so --extract defaults to the +current one and a recorded extract can be re-verified only while it is listed. """ import argparse import hashlib @@ -16,17 +19,28 @@ import subprocess import urllib.request ROOT = Path(__file__).resolve().parent.parent -EXTRACT = 'norway-261005' GEOFABRIK = 'https://download.geofabrik.de/europe/' DATASET = ROOT / 'src/data/local_data/datacenters/datacenters.geojsonl' CONTACT_TAGS = {'email', 'phone', 'fax', 'mobile', 'whatsapp', 'contact:email', 'contact:phone', 'contact:fax', 'contact:mobile', 'contact:whatsapp'} SAME_GEOMETRY_METERS = 0.05 +TIMEOUT_SECONDS = 60 + + +def latest_extract(): + """Resolve norway-latest to the dated name Geofabrik redirects it to.""" + request = urllib.request.Request(GEOFABRIK + 'norway-latest.osm.pbf', method='HEAD') + with urllib.request.urlopen(request, timeout=TIMEOUT_SECONDS) as response: + name = response.geturl().rsplit('/', 1)[-1] + if not name.startswith('norway-') or not name.endswith('.osm.pbf') or name == 'norway-latest.osm.pbf': + raise RuntimeError('Unexpected Geofabrik latest target: %s' % name) + return name[:-len('.osm.pbf')] def fetch(url, target): if not target.exists(): - with urllib.request.urlopen(url) as response, open(str(target) + '.part', 'wb') as out: + with urllib.request.urlopen(url, timeout=TIMEOUT_SECONDS) as response, \ + open(str(target) + '.part', 'wb') as out: shutil.copyfileobj(response, out) Path(str(target) + '.part').rename(target) return target @@ -43,7 +57,7 @@ def md5(path): def read_poly(path): """Geofabrik .poly -> list of (ring, is_hole).""" rings, ring, hole = [], None, False - for raw in path.read_text().splitlines()[1:]: + for raw in path.read_text(encoding='utf-8').splitlines()[1:]: line = raw.strip() if ring is None: if line and line != 'END': @@ -80,15 +94,15 @@ def osm_key(feature): return ('n' if feature['geometry']['type'] == 'Point' else 'w') + str(osm_id) -def export(osmium, pbf, cache): - filtered = cache / (EXTRACT + '-datacenters.osm.pbf') - exported = cache / (EXTRACT + '-datacenters.geojsonseq') +def export(osmium, pbf, cache, extract): + filtered = cache / (extract + '-datacenters.osm.pbf') + exported = cache / (extract + '-datacenters.geojsonseq') subprocess.run([osmium, 'tags-filter', '-O', '-o', str(filtered), str(pbf), 'nwr/telecom=data_center', 'nwr/building=data_center'], check=True) subprocess.run([osmium, 'export', '-O', '-f', 'geojsonseq', '--add-unique-id=type_id', '-o', str(exported), str(filtered)], check=True) fresh = {} - for line in exported.read_text().splitlines(): + for line in exported.read_text(encoding='utf-8').splitlines(): line = line.lstrip('\x1e').strip() if not line: continue @@ -169,26 +183,30 @@ def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--cache', type=Path, required=True) parser.add_argument('--osmium', default=shutil.which('osmium') or 'osmium') + parser.add_argument('--extract', default='latest', + help="dated Geofabrik name such as norway-261005, or 'latest'") parser.add_argument('--verify', action='store_true') args = parser.parse_args() args.cache.mkdir(parents=True, exist_ok=True) - pbf = fetch(GEOFABRIK + EXTRACT + '.osm.pbf', args.cache / (EXTRACT + '.osm.pbf')) - expected = fetch(GEOFABRIK + EXTRACT + '.osm.pbf.md5', - args.cache / (EXTRACT + '.osm.pbf.md5')).read_text().split()[0] + extract = latest_extract() if args.extract == 'latest' else args.extract + pbf = fetch(GEOFABRIK + extract + '.osm.pbf', args.cache / (extract + '.osm.pbf')) + expected = fetch(GEOFABRIK + extract + '.osm.pbf.md5', + args.cache / (extract + '.osm.pbf.md5')).read_text(encoding='utf-8').split()[0] actual = md5(pbf) if actual != expected: raise RuntimeError('MD5 mismatch for %s: %s != %s' % (pbf.name, actual, expected)) rings = read_poly(fetch(GEOFABRIK + 'norway.poly', args.cache / 'norway.poly')) - fresh = export(args.osmium, pbf, args.cache) - current = [line for line in DATASET.read_text().splitlines() if line.strip()] + fresh = export(args.osmium, pbf, args.cache, extract) + current = [line for line in DATASET.read_text(encoding='utf-8').splitlines() if line.strip()] merged, report = merge(current, fresh, rings) payload = '\n'.join(merged) + '\n' if args.verify: - if payload != DATASET.read_text(): - raise SystemExit('datacenters.geojsonl is not the %s merge' % EXTRACT) + if payload != DATASET.read_text(encoding='utf-8'): + raise SystemExit('datacenters.geojsonl is not the %s merge' % extract) else: - DATASET.write_text(payload) - print(json.dumps({'extract': EXTRACT, 'md5': actual, 'norway': len(fresh), + with open(DATASET, 'w', encoding='utf-8', newline='\n') as handle: + handle.write(payload) + print(json.dumps({'extract': extract, 'md5': actual, 'norway': len(fresh), 'features': len(merged), **report})) diff --git a/src/data/local_data/datacenters/README.md b/src/data/local_data/datacenters/README.md index ef0720ae4..8b803dcc7 100644 --- a/src/data/local_data/datacenters/README.md +++ b/src/data/local_data/datacenters/README.md @@ -28,5 +28,9 @@ reflects OSM as of the extract below, everything else the original snapshot. - Filter: `osmium tags-filter nwr/telecom=data_center nwr/building=data_center` - Contact tags are dropped as above; unchanged features keep their original line, so geometry is only rewritten when tags or vertices actually changed. -- Rebuild or check: `python3 scripts/build-datacenters-norway.py --cache ` - (add `--verify` to check without writing; requires `osmium-tool`) +- Refresh: `python3 scripts/build-datacenters-norway.py --cache ` uses + Geofabrik's current Norway extract and prints the dated name it used; record + that name, its OSM timestamp and MD5 here. Requires `osmium-tool`. +- Check: add `--extract --verify` to compare without writing. + Geofabrik keeps only its newest dated extract, so a recorded extract can be + re-verified only while it is still listed; the MD5 above identifies it. From 4318f51de4114bfb68137ff48bedd070b976f771 Mon Sep 17 00:00:00 2001 From: Trygve Hemminghytt <274629477+trygvehhh@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:03:23 +0200 Subject: [PATCH 04/71] docs: move the changelog entry to changelog.d --- CHANGELOG.md | 7 ------- changelog.d/953-datacenters-norway-refresh.md | 6 ++++++ 2 files changed, 6 insertions(+), 7 deletions(-) create mode 100644 changelog.d/953-datacenters-norway-refresh.md diff --git a/CHANGELOG.md b/CHANGELOG.md index a285bc555..98d32365f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -284,13 +284,6 @@ ## [Unreleased] -- Refresh Norway's datacenters from a Geofabrik OpenStreetMap extract - (2026-10-05). Troll Housing joins the layer (4,352 features) and four - Norwegian sites pick up current OSM tags; no site moved or was removed. - `scripts/build-datacenters-norway.py` reproduces or `--verify`s the merge, - touching only features inside Geofabrik's Norway boundary, and the dataset - README records the extract, checksum and filter. - - Add a keyless Air quality layer to the Weather group, with Environment and Climate Change Canada's AQHI as the first registered provider. Readings carry `{ provider, scale, value, band }` so another network can be added without diff --git a/changelog.d/953-datacenters-norway-refresh.md b/changelog.d/953-datacenters-norway-refresh.md new file mode 100644 index 000000000..48a019605 --- /dev/null +++ b/changelog.d/953-datacenters-norway-refresh.md @@ -0,0 +1,6 @@ +- Refresh Norway's datacenters from a Geofabrik OpenStreetMap extract + (2026-10-05). Troll Housing joins the layer (4,352 features) and four + Norwegian sites pick up current OSM tags; no site moved or was removed. + `scripts/build-datacenters-norway.py` reproduces or `--verify`s the merge, + touching only features inside Geofabrik's Norway boundary, and the dataset + README records the extract, checksum and filter (trygvehhh, #953). From a4c7b20ca825a731531b61fc2935f1a47621248a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Itallo=20Brand=C3=A3o?= <45429643+IMBrandao@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:11:10 -0300 Subject: [PATCH 05/71] feat(transit): add Rio de Janeiro buses and BRT (SMTR GTFS-RT, CC BY 4.0) Register Rio's two keyless GTFS-Realtime VehiclePositions feeds from the city transport secretariat (SMTR): municipal buses (SPPO) and the Mobi-Rio BRT, the Transit layer's first Latin American region. Both feeds publish Position.speed in km/h instead of m/s. A new optional registry field, speedUnit: 'kmh', makes buildTransitSnapshot convert it once, so every speedMps consumer stays correct and other feeds are untouched. Document the source in DATA_SOURCES.md and update the region lists in README.md and docs/CURRENT-STATE.md. Refs #1480 Co-Authored-By: Claude Opus 5.5 --- DATA_SOURCES.md | 3 +- README.md | 2 +- docs/CURRENT-STATE.md | 8 ++++-- src/data/transitFeeds.js | 50 ++++++++++++++++++++++++++++++++++ src/data/transitFeeds.test.mjs | 43 +++++++++++++++++++++++++++++ src/data/transitProxy.js | 21 +++++++++++++- src/data/transitProxy.test.mjs | 26 ++++++++++++++++++ 7 files changed, 147 insertions(+), 6 deletions(-) diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index e31d0d3f0..2b989c509 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -95,6 +95,7 @@ How to read this: | **Barrie Transit** (myridebarrie.ca) | Transit layer live vehicles, Barrie ON | Barrie Transit realtime data, published by the City of Barrie under a licence accepted by use of the data. The licence is revocable and requires no attribution. | Courtesy credit to the City of Barrie (the licence requires none, and its trademark clause is broad) | | **YRT/Viva** (rtu.york.ca) | Transit layer live vehicles, York Region ON | York Region Transit realtime data, first-party York Region infrastructure under the YRT Open Data Licence v2.0, which permits commercial use and makes attribution optional. Viva route ids are named by colour from York Region’s published GTFS. | "Contains public transit Information made available under YRT’s Open Data Licence" — the licence’s own wording, given though not required | | **Burlington Transit** (opendata.burlington.ca) | Transit layer live vehicles, Burlington ON | Burlington Transit realtime data under the City of Burlington Open Data Terms of Use, which permit any lawful purpose. The terms are revocable and must travel with the data, so the credit links them. Route ids are internal keys and are joined to the City’s published GTFS. | Credit to the City of Burlington plus a link to its Open Data Terms of Use, which the licence requires | +| **SMTR Rio de Janeiro** (dados.mobilidade.rio) | Transit layer live vehicles, Rio de Janeiro: municipal buses (SPPO) and the Mobi-Rio BRT, as two feeds | CC BY 4.0, stated on both data.rio catalog pages. Keyless GTFS-Realtime published by the Secretaria Municipal de Transportes, labelled beta, with a gateway limit of 5 requests a second and 60 a minute. Speeds arrive in km/h rather than m/s and are converted by the proxy. Route ids are internal keys and are shown raw. | Attribution to SMTR / Prefeitura da Cidade do Rio de Janeiro under CC BY 4.0 | | **Radio Browser** | Geolocated internet-radio station directory and station-level tags | Public-domain directory data under PDDL 1.0; individual broadcaster stream terms apply | "Radio Browser" plus a link to the selected broadcaster | | **Re:Earth Terrain** (Mapterhorn) | Terrain (keyless globe stacks — OSM etc. — + `/api/terrain/heights` ellipsoidal-height lookups) | Terrain mesh: CC BY 4.0; geoid: EGM2008 (NGA, public domain) | "Terrain (keyless globe stacks): Re:Earth Terrain / Mapterhorn (CC BY 4.0) / EGM2008 (NGA)" | | **OSRM on the FOSSGIS routing servers** (`routing.openstreetmap.de`) | Street-following routes for the Directions layer and voice route annotations, via `/api/route` | [FOSSGIS routing usage policy](https://routing.openstreetmap.de/about.html): "Display the required attribution and display a link to 'fix the map'", "Use a valid user agent and, if applicable, a correct referrer", "One request per second max", "No scraping, no heavy usage". The full policy is the German [FOSSGIS Nutzungsbedingungen](https://www.fossgis.de/arbeitsgruppen/osm-server/nutzungsbedingungen/); FOSSGIS also states that the server may be embedded in your own pages but "eine gewerbliche Nutzung ist nur mit Einschränkungen erlaubt" (commercial use only with restrictions). Route data derives from OpenStreetMap (ODbL 1.0) | "Routing: OSRM on the FOSSGIS servers" + a "fix the map" link — shown in the Data attribution popover | @@ -177,7 +178,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **NYC DOT Traffic Management Center cameras.** Off by default. NYC DOT's [developer data feeds page](https://www.nyc.gov/html/dot/html/about/datafeeds.shtml) asks developers who want the camera feed to contact `tmcdot@dot.nyc.gov` and sign a data-sharing agreement. This project does not hold one that covers downstream installations, so the pack loads only when an operator who holds their own agreement sets `CCTV_NYC_ENABLED=1`; with the flag unset, empty or any other value, the server makes no request to NYC DOT. The endpoint needs no key. The camera list is the public `webcams.nyctmc.org/api/cameras` catalog (~980 cameras across the five boroughs); frames are stills at `webcams.nyctmc.org/api/cameras//image`, synthesized from a validated UUID rather than taken from the payload, and only that host is registered. Cameras the feed reports as offline are dropped. The catalog publishes no camera facing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. Once enabled, the 250 cameras nearest the borough cores load; `CCTV_NYC_MAX_SOURCES` (8–400) changes the cap. Frames are fetched at request time and never stored or redistributed. NYC DOT asks every application that uses its data to carry its disclaimer; it is registered verbatim in the Data attribution popover. - **City of Toronto RESCU cameras.** Keyless. The camera list is the City's public `tmcearthcameras.json` on `opendata.toronto.ca` (dataset "Traffic Cameras", Transportation Services), served as JSONP and unwrapped with an anchored pattern. This is the municipal network the Ontario 511 pack does not carry: downtown arterials plus the Gardiner, DVP, Allen and Lake Shore. The list publishes no frame URL, so frame URLs are BUILT from the documented `CameraImages/loc####.jpg` convention and a strictly-digits camera number, pinning the frame proxy to the portal origin by construction; the list fetch refuses redirects and bounds the body it buffers. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing: the `D1`-`D4` fields are the directions of the *comparison* images (static reference stills captured in 2016 under a different path), not a bearing for the live pan-tilt-zoom frame, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. A camera that is temporarily out of service still answers 200 with a byte-identical "Image Unavailable" placeholder rather than an error (about 6% of the network at any moment), so such a camera reads as healthy; the frame it shows is the agency's, not a synthesized one. The licence requires the attribution statement above. Disable with `CCTV_TORONTO_ENABLED=0`. - **Camera frame content.** Public camera frames can contain people, vehicles and license plates, and the camera layer does nothing with them beyond putting them on the map. A successful upstream response is relayed as the provider served it: nothing in the camera pipeline blurs, redacts, enhances or restores it, and nothing there runs face, plate or object detection or otherwise recognises or classifies what is in the picture. (The ALPR layer described below maps where plate-reader cameras are, from locations contributors tagged in OpenStreetMap; it reads nothing from any camera frame.) When an upstream has no frame for a camera, what you see instead is a Google Street View still or the app's own placeholder card; the panel's source badge names which of them you are looking at, and a refresh that fails after a frame has arrived leaves the last good picture on screen rather than replacing it. The browser resamples each frame for display: onto a fixed 1920x1080 surface for the camera's projection plane in the scene, and onto a 192x108 canvas for the small ambient card. That scaling is the only change made to the picture, and the only pixels the camera layer reads back are a 64x36 downscale hashed to tell whether the picture has changed. Live streams are piped through; the still path reads the image into memory to pass it on — TxDOT wraps its JPEG in a JSON envelope, which is decoded there — and no frame is written to disk (the disk cache, `.gev-cache/`, holds feed JSON for other layers and never camera imagery). Outside that pipeline, the voice assistant is the one feature that sends imagery anywhere: when it answers a question about what is in view it may attach a downscaled screenshot of the rendered scene — including any camera plane on screen — to the request it sends to the voice model. Frames are fetched only from server-registered camera URLs — a client cannot hand the proxy a URL (see [SECURITY.md](SECURITY.md)). What the app does stays inside the line README draws: no named-person search, no face recognition, no tracking individuals. -- **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. All sixteen registered regions are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth. The TTC feed is BusTime, the surface system: it carries buses and streetcars and no subway at all. About four in ten of its vehicles report no route at all (unassigned or deadheading), and those are shown with the feed default rather than a route-established mode. It sends no ETag or Last-Modified, so its conditional requests fall through to the ordinary snapshot cache. Three Golden Horseshoe neighbours run alongside it — MiWay, Hamilton HSR and Durham Region Transit — each bus-only and each polled only near its own region. Durham Region Transit publishes no bearing on any vehicle, so those sprites take their heading from observed movement like any other feed that omits it. Guelph, Kingston and Barrie extend the same pattern eastward and north. Hamilton, Guelph, Burlington and YRT publish route ids that are not the number on the vehicle, so those entries carry a route-name table taken from each operator’s own published GTFS; any id missing from a table renders unchanged rather than blank. A label is never invented: YRT runs a route `9101` that appears nowhere in its published routes, so it stays as the feed sends it. Burlington’s is the one that cannot be shortcut: `351` is route 1 while `3510` is route 10, so the table is a real join rather than a trimmed prefix. GO Transit / Metrolinx is deliberately absent: its REALTIME feed requires an API key issued by manual review, which the keyless admission rule excludes (its static schedule download is keyless, but carries no vehicle positions). Barrie and YRT/Viva each invite acceptance of their licence through a form, and each licence also states that using the data is itself acceptance; this deployment relies on that clause, which is recorded in both entries. Grand River Transit in Waterloo is absent for a different reason: it answers, but its feed header was months stale and it publishes no per-vehicle timestamps, so it would render vehicles that look live and are not. Ottawa’s OC Transpo falls to the same keyless admission rule as GO Transit — its realtime feed requires an API key. London Transit and Thunder Bay Transit publish vehicle positions over plain HTTP with no HTTPS endpoint available. Every other Ontario system — Brampton, Oakville, Niagara, Milton, Windsor, Greater Sudbury, UP Express and a dozen smaller operators — publishes static GTFS schedules only, with no realtime vehicle positions to register. Between the nine included and these exclusions, every Ontario realtime vehicle-position feed catalogued in the Mobility Database is accounted for. +- **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. All eighteen registered feeds, covering seventeen regions, are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth. The TTC feed is BusTime, the surface system: it carries buses and streetcars and no subway at all. About four in ten of its vehicles report no route at all (unassigned or deadheading), and those are shown with the feed default rather than a route-established mode. It sends no ETag or Last-Modified, so its conditional requests fall through to the ordinary snapshot cache. Three Golden Horseshoe neighbours run alongside it — MiWay, Hamilton HSR and Durham Region Transit — each bus-only and each polled only near its own region. Durham Region Transit publishes no bearing on any vehicle, so those sprites take their heading from observed movement like any other feed that omits it. Guelph, Kingston and Barrie extend the same pattern eastward and north. Hamilton, Guelph, Burlington and YRT publish route ids that are not the number on the vehicle, so those entries carry a route-name table taken from each operator’s own published GTFS; any id missing from a table renders unchanged rather than blank. A label is never invented: YRT runs a route `9101` that appears nowhere in its published routes, so it stays as the feed sends it. Burlington’s is the one that cannot be shortcut: `351` is route 1 while `3510` is route 10, so the table is a real join rather than a trimmed prefix. GO Transit / Metrolinx is deliberately absent: its REALTIME feed requires an API key issued by manual review, which the keyless admission rule excludes (its static schedule download is keyless, but carries no vehicle positions). Barrie and YRT/Viva each invite acceptance of their licence through a form, and each licence also states that using the data is itself acceptance; this deployment relies on that clause, which is recorded in both entries. Grand River Transit in Waterloo is absent for a different reason: it answers, but its feed header was months stale and it publishes no per-vehicle timestamps, so it would render vehicles that look live and are not. Ottawa’s OC Transpo falls to the same keyless admission rule as GO Transit — its realtime feed requires an API key. London Transit and Thunder Bay Transit publish vehicle positions over plain HTTP with no HTTPS endpoint available. Every other Ontario system — Brampton, Oakville, Niagara, Milton, Windsor, Greater Sudbury, UP Express and a dozen smaller operators — publishes static GTFS schedules only, with no realtime vehicle positions to register. Between the nine included and these exclusions, every Ontario realtime vehicle-position feed catalogued in the Mobility Database is accounted for. - **Radio Browser.** `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; schema-valid responses whose rows all fail the product's health policy do not count as successful queries. A usable partial cold catalog is explicitly `DEGRADED`, and non-array or empty successful payloads are rejected atomically. Individual structurally malformed rows are skipped rather than failing the whole query; the response carries their bounded `rejectedStationCount` (at most 10,000), which the client adds to its own rejected rows and reports as degraded coverage without discarding the accepted generation. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply. - **TomTom Traffic.** Optional and BYOK: without `TOMTOM_API_KEY` the traffic layer runs its built-in simulation and no TomTom data (or attribution) appears. With a key, flow vector tiles are fetched through the server-side `/api/tomtom` proxy (120 s cache + a daily tile-budget governor — `TOMTOM_DAILY_TILE_BUDGET`, default 6,000 — sized so a 31-day month stays inside TomTom's 200K monthly free allowance; still a configurable application safety ceiling, not a guarantee; TomTom's [current pricing](https://docs.tomtom.com/pricing/) lists 200K free tile requests per month) and the "Traffic flow data © TomTom" credit is registered in the Data attribution popover the moment live mode activates. TomTom data is served live and cached only transiently (≤120 s TTL under `.gev-cache/`, gitignored) — it is not bundled or redistributed. One 23 KB point-in-time tile snapshot is committed as a decode-test fixture (`src/data/fixtures/`, © TomTom, never served to the app). - **Routing (OSRM / FOSSGIS).** `/api/route` proxies the public OSRM instances FOSSGIS e.V. runs for the OpenStreetMap community (car, bike, foot profiles). They are keyless and intended for fair, interactive use — the proxy rate-limits clients (60/min, 200/min total), caps route span and response size, caches each route for 10 minutes, and identifies itself in the User-Agent. Heavy or automated use must run its own OSRM. Routes are computed from OpenStreetMap data (ODbL); the Directions layer and voice routes never redistribute them. A route the router cannot find is reported as such; the Directions layer draws nothing in that case, and voice routes fall back to a clearly labeled straight line. diff --git a/README.md b/README.md index 745bec3d8..965c0e283 100644 --- a/README.md +++ b/README.md @@ -403,7 +403,7 @@ Twenty layers and map sources. **Eighteen have a keyless path.** Some offer addi | 📷 **Mapped ALPR Cameras** | License-plate-reader camera locations tagged by OpenStreetMap contributors, loaded one city-sized view at a time, with **SHOW NEAREST**. Locations and tags only: no plate data, no video | OpenStreetMap (incl. DeFlock mapping) | 🟢 | | 📷 **Street Level** | Street-level photos behind one panel, one chip per provider: coverage on the streets, image cones, an embedded viewer with full-screen **EXPAND**. Mapillary, Google Street View, KartaView and Panoramax | Mapillary · Google · KartaView · Panoramax | 🟢 KartaView, Panoramax · 🟡 Mapillary token · 🔴 Google Maps key | | 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 | -| 🚌 **Transit** | Live buses, trams, metros, trains and ferries with delayed playback between reports, selected-vehicle trails, and mode-coloured DETECT labels — Boston, Austin, Minneapolis, Helsinki, the Netherlands, Norway, South East Queensland, and nine in Ontario: Toronto (surface vehicles; the subway publishes no positions), Mississauga, Hamilton, Durham Region, York Region, Guelph, Burlington, Barrie and Kingston | Operator GTFS-Realtime feeds | 🟢 | +| 🚌 **Transit** | Live buses, trams, metros, trains and ferries with delayed playback between reports, selected-vehicle trails, and mode-coloured DETECT labels — Boston, Austin, Minneapolis, Helsinki, the Netherlands, Norway, South East Queensland, Rio de Janeiro (buses and BRT), and nine in Ontario: Toronto (surface vehicles; the subway publishes no positions), Mississauga, Hamilton, Durham Region, York Region, Guelph, Burlington, Barrie and Kingston | Operator GTFS-Realtime feeds | 🟢 | | 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 | | 🧭 **Directions** | Click A and B on the globe for a street-following drive, walk or cycle route draped on the terrain with turn-by-turn steps — then FLY the camera along it. No key, no geocoder, no mic | OSRM on FOSSGIS servers (OpenStreetMap) | 🟢 | | 🔥 **Active Fires** | Live NASA FIRMS detections, trailing 24h | NASA FIRMS | 🟡 | diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 966bb3878..2adac270e 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4295,11 +4295,13 @@ Transit is off by default, and share links use token `j`. The movement panel ord places Transit directly after Street Traffic and before Bike Share. Cameras and utilities retain their own groups. The layer polls registered feeds near the camera every 15 seconds, below a 3,000 km altitude -gate. Sixteen keyless regions are available: Boston, Austin, Minneapolis–St Paul, -Helsinki, the Netherlands, Norway, South East Queensland, and nine in +gate. Seventeen keyless regions are available: Boston, Austin, Minneapolis–St Paul, +Helsinki, the Netherlands, Norway, South East Queensland, Rio de Janeiro +(municipal buses and the Mobi-Rio BRT, two feeds), and nine in Ontario — Toronto (TTC surface vehicles; the subway publishes no positions), MiWay, Hamilton HSR, Durham Region, YRT/Viva, Guelph, -Burlington, Barrie and Kingston. Mode silhouettes +Burlington, Barrie and Kingston. Rio's feeds publish speed in km/h, which +the proxy converts to m/s before any card reads it. Mode silhouettes identify buses, trams, subways, trains, ferries and unclassified vehicles. Retained transit history uses the same outlier quarantine as live observations and diff --git a/src/data/transitFeeds.js b/src/data/transitFeeds.js index b8476a6ee..e871a2e89 100644 --- a/src/data/transitFeeds.js +++ b/src/data/transitFeeds.js @@ -385,7 +385,12 @@ function burlingtonRouteLabel(routeId) { * url: string, headers?: Record, * license: string, licenseUrl: string, attribution: string, * defaultMode: string, routeMode?: (routeId: string|null) => string, + * speedUnit?: 'kmh', * }>>} + * + * `speedUnit: 'kmh'` marks a feed that publishes `Position.speed` in km/h + * instead of the m/s the spec requires; the proxy converts it before anything + * downstream reads `speedMps`. */ export const TRANSIT_FEED_REGISTRY = Object.freeze([ Object.freeze({ @@ -729,6 +734,51 @@ export const TRANSIT_FEED_REGISTRY = Object.freeze([ defaultMode: 'bus', routeLabel: burlingtonRouteLabel, }), + Object.freeze({ + id: 'smtr-rio-onibus', + name: 'Rio buses (SPPO)', + operator: 'Secretaria Municipal de Transportes (SMTR), Rio de Janeiro', + region: 'Rio de Janeiro, RJ', + // Every vehicle in a 2026-10-29 snapshot, from Santa Cruz in the west to + // the Zona Sul, sat within 31 km of this point. + center: Object.freeze({ lat: -22.92, lon: -43.42 }), + loadRadiusKm: 45, + url: 'https://dados.mobilidade.rio/gtfs/realtime/onibus/vehicle-positions', + license: 'CC BY 4.0', + licenseUrl: + 'https://www.data.rio/documents/997bc16cc4344a5cb0e48ab06cbdfd1f', + attribution: 'SMTR / Prefeitura da Cidade do Rio de Janeiro (CC BY 4.0)', + defaultEnabled: true, + terms: Object.freeze({ + quote: + 'This work is licensed under a Creative Commons Attribution 4.0 International License', + note: 'The data.rio catalog page labels the feed [beta] and states a gateway limit of 5 requests a second and 60 a minute behind a 10-second cache, which the 15-second proxy TTL stays inside. It is the named replacement for the JSON GPS endpoints SMTR deprecated on 2026-10-07 and says it will switch off by 2026-11-30. SMTR relays the concessionaires’ ITS data and repairs trips with its own matching; the page places accuracy with the suppliers. Speeds arrive in km/h, not m/s (a 2026-10-29 snapshot peaked at 89), hence `speedUnit`. Route ids are internal keys: `O0313AAA0A` carried service 313 in the same day’s JSON feed. They are shown raw until a published table is joined.', + }), + defaultMode: 'bus', + speedUnit: 'kmh', + }), + Object.freeze({ + id: 'smtr-rio-brt', + name: 'Mobi-Rio BRT', + operator: + 'Secretaria Municipal de Transportes (SMTR), Rio de Janeiro, for the Mobi-Rio BRT fleet', + region: 'Rio de Janeiro, RJ', + center: Object.freeze({ lat: -22.92, lon: -43.42 }), + loadRadiusKm: 45, + url: 'https://dados.mobilidade.rio/gtfs/realtime/brt/vehicle-positions', + license: 'CC BY 4.0', + licenseUrl: + 'https://www.data.rio/documents/8bb196cc02174d25b77437bb2f6443d4', + attribution: 'SMTR / Prefeitura da Cidade do Rio de Janeiro (CC BY 4.0)', + defaultEnabled: true, + terms: Object.freeze({ + quote: + 'This work is licensed under a Creative Commons Attribution 4.0 International License', + note: 'Same catalog terms, [beta] label and gateway limits as the bus feed. The fleet is operated by Mobi-Rio and tracked by SONDA, whose data SMTR relays. Speeds are km/h here too (peak 62 in the same snapshot). Route ids are opaque (`20000461130`) and are shown raw.', + }), + defaultMode: 'bus', + speedUnit: 'kmh', + }), ]); /** diff --git a/src/data/transitFeeds.test.mjs b/src/data/transitFeeds.test.mjs index fd990af45..51903b7db 100644 --- a/src/data/transitFeeds.test.mjs +++ b/src/data/transitFeeds.test.mjs @@ -482,3 +482,46 @@ test('Burlington is live and carries its terms link, which its licence requires' assert.match(bur.licenseUrl, /Open%20Data%20Terms%20of%20Use\.pdf$/); assert.match(bur.url, /^https:\/\//, 'port 80 has no listener at all'); }); + +test('Rio buses and BRT cover the city from Centro to Santa Cruz, and not São Paulo', () => { + const ids = (lat, lon) => + transitFeedsInRange(lat, lon).map((feed) => feed.id); + for (const [place, lat, lon] of [ + ['Centro', -22.9068, -43.1729], + ['Barra da Tijuca', -23.0004, -43.3659], + ['Santa Cruz', -22.9194, -43.6847], + ]) { + assert.ok( + ids(lat, lon).includes('smtr-rio-onibus'), + `buses reach ${place}`, + ); + assert.ok(ids(lat, lon).includes('smtr-rio-brt'), `BRT reaches ${place}`); + } + assert.deepEqual( + ids(-23.5505, -46.6333).filter((id) => id.startsWith('smtr-rio')), + [], + 'São Paulo, 360 km away, never polls Rio', + ); +}); + +test('Rio feeds are CC BY 4.0, live, and declare the km/h speeds they publish', () => { + for (const id of ['smtr-rio-onibus', 'smtr-rio-brt']) { + const feed = getTransitFeed(id); + assert.equal(feed.defaultEnabled, true, `${id} is live`); + assert.equal(feed.license, 'CC BY 4.0'); + assert.match(feed.licenseUrl, /^https:\/\/www\.data\.rio\/documents\//); + assert.match(feed.attribution, /Prefeitura da Cidade do Rio de Janeiro/); + assert.equal(feed.speedUnit, 'kmh', `${id} publishes km/h`); + assert.equal(transitModeFor(feed, null), 'bus'); + } + const sources = readFileSync( + new URL('../../DATA_SOURCES.md', import.meta.url), + 'utf8', + ); + assert.ok( + sources + .split('\n') + .some((line) => line.startsWith('| **SMTR Rio de Janeiro')), + 'Rio has a DATA_SOURCES row', + ); +}); diff --git a/src/data/transitProxy.js b/src/data/transitProxy.js index 458240e9d..2923c9c32 100644 --- a/src/data/transitProxy.js +++ b/src/data/transitProxy.js @@ -224,6 +224,25 @@ export function repairVehicleTimestamps(vehicles, headerTimestamp, fetchedAtS) { }); } +/** + * Bring a feed's speeds to the m/s the spec requires. A feed registered with + * `speedUnit: 'kmh'` publishes km/h in `Position.speed`, and read as m/s a + * 44 km/h bus would be reported at 158 km/h. Converting here, once, keeps every + * consumer of `speedMps` honest without each one knowing about the feed. + * + * @param {object} feed Registry entry. + * @param {object[]} vehicles Normalized vehicle records. + * @returns {object[]} + */ +export function normalizeFeedSpeeds(feed, vehicles) { + if (feed?.speedUnit !== 'kmh') return vehicles; + return vehicles.map((vehicle) => + Number.isFinite(vehicle.speedMps) + ? { ...vehicle, speedMps: vehicle.speedMps / 3.6 } + : vehicle, + ); +} + /** * Decode upstream bytes into the JSON snapshot the browser consumes. * @@ -257,7 +276,7 @@ export function buildTransitSnapshot(feed, bytes, now = Date.now()) { truncated: decoded.truncated, count: decoded.vehicles.length, vehicles: repairVehicleTimestamps( - decoded.vehicles, + normalizeFeedSpeeds(feed, decoded.vehicles), decoded.timestamp, Math.floor(now / 1000), ), diff --git a/src/data/transitProxy.test.mjs b/src/data/transitProxy.test.mjs index dea10ab25..445e658f2 100644 --- a/src/data/transitProxy.test.mjs +++ b/src/data/transitProxy.test.mjs @@ -137,6 +137,7 @@ function encodeFeed({ const position = new PbfWriter(); position.writeFloatField(1, entity.lat); position.writeFloatField(2, entity.lon); + if (entity.speed != null) position.writeFloatField(5, entity.speed); vehicle.writeBytesField(2, position.finish()); if (entity.timestamp) vehicle.writeVarintField(5, entity.timestamp); const descriptor = new PbfWriter(); @@ -284,6 +285,31 @@ test('the snapshot builder fills timestamps from the header it decoded', () => { assert.equal(snapshot.feedTimestamp, 1_700_000_000); }); +test('a feed that publishes km/h has its speeds converted to m/s, and only that feed', () => { + // Rio's SMTR feeds put km/h in Position.speed; read as m/s, a 36 km/h bus + // would be reported at 130 km/h. + const bytes = encodeFeed({ + entities: [ + { id: 'moving', lat: -22.9, lon: -43.2, speed: 36 }, + { id: 'still', lat: -22.9, lon: -43.2, speed: 0 }, + { id: 'silent', lat: -22.9, lon: -43.2 }, + ], + }); + const rio = buildTransitSnapshot(getTransitFeed('smtr-rio-onibus'), bytes, 1); + const speeds = Object.fromEntries( + rio.vehicles.map((vehicle) => [vehicle.id, vehicle.speedMps]), + ); + assert.ok(Math.abs(speeds.moving - 10) < 1e-6, '36 km/h is 10 m/s'); + assert.equal(speeds.still, 0); + assert.equal(speeds.silent, null, 'an absent speed stays absent'); + const mbta = buildTransitSnapshot(getTransitFeed('mbta'), bytes, 1); + assert.equal( + mbta.vehicles.find((vehicle) => vehicle.id === 'moving').speedMps, + 36, + 'a spec-conforming feed is left alone', + ); +}); + test('conditional-request validators and gzip ride along with feed identification', () => { const entur = getTransitFeed('entur-norway'); const plain = transitUpstreamHeaders(entur); From 53d30c9e80d765b14eb2a2c35a957168cad2bf4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Itallo=20Brand=C3=A3o?= <45429643+IMBrandao@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:30:47 -0300 Subject: [PATCH 06/71] docs(changelog): add fragment for #1481 Co-Authored-By: Claude Opus 5.5 --- changelog.d/1481-rio-transit.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelog.d/1481-rio-transit.md diff --git a/changelog.d/1481-rio-transit.md b/changelog.d/1481-rio-transit.md new file mode 100644 index 000000000..62b4ede04 --- /dev/null +++ b/changelog.d/1481-rio-transit.md @@ -0,0 +1,4 @@ +- Add Rio de Janeiro to the Transit layer: municipal buses and the Mobi-Rio BRT + from SMTR's keyless GTFS-Realtime feeds (CC BY 4.0), the layer's first Latin + American region. A feed that publishes speed in km/h declares + `speedUnit: 'kmh'` and the proxy converts it to m/s (IMBrandao, #1481). From 04d626a4b190149ffcb017290c5d9aa17d32748d Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 3 Nov 2026 15:02:42 +0400 Subject: [PATCH 07/71] fix(datacenters): preserve missing geometry and compare ring topology --- scripts/build-datacenters-norway.py | 64 ++++++++++++--- src/data/datacentersNorwayRefresh.test.mjs | 93 ++++++++++++++++++++++ src/data/local_data/datacenters/README.md | 4 +- 3 files changed, 148 insertions(+), 13 deletions(-) create mode 100644 src/data/datacentersNorwayRefresh.test.mjs diff --git a/scripts/build-datacenters-norway.py b/scripts/build-datacenters-norway.py index 687395f7b..03286b864 100644 --- a/scripts/build-datacenters-norway.py +++ b/scripts/build-datacenters-norway.py @@ -4,7 +4,8 @@ Downloads a dated Geofabrik Norway extract, verifies its MD5, filters telecom=data_center / building=data_center with osmium, and merges the result into datacenters.geojsonl: features inside the Geofabrik Norway boundary are -added, updated or removed; every other line is kept byte-for-byte. +added, updated or removed once no longer tagged; every other line is kept +byte-for-byte. Geofabrik keeps only its newest dated extract, so --extract defaults to the current one and a recorded extract can be re-verified only while it is listed. @@ -16,6 +17,7 @@ import math from pathlib import Path import shutil import subprocess +import sys import urllib.request ROOT = Path(__file__).resolve().parent.parent @@ -95,10 +97,15 @@ def osm_key(feature): def export(osmium, pbf, cache, extract): + """Exported features by key, and the keys of every tagged object in the extract.""" filtered = cache / (extract + '-datacenters.osm.pbf') + listed = cache / (extract + '-datacenters.opl') exported = cache / (extract + '-datacenters.geojsonseq') subprocess.run([osmium, 'tags-filter', '-O', '-o', str(filtered), str(pbf), 'nwr/telecom=data_center', 'nwr/building=data_center'], check=True) + subprocess.run([osmium, 'tags-filter', '-O', '-R', '-f', 'opl', '-o', str(listed), str(filtered), + 'nwr/telecom=data_center', 'nwr/building=data_center'], check=True) + tagged = {line.split(' ', 1)[0] for line in listed.read_text(encoding='utf-8').splitlines() if line} subprocess.run([osmium, 'export', '-O', '-f', 'geojsonseq', '--add-unique-id=type_id', '-o', str(exported), str(filtered)], check=True) fresh = {} @@ -122,22 +129,48 @@ def export(osmium, pbf, cache, extract): elif key in fresh or geometry['type'] == 'LineString': continue # closed ways are taken from their area export fresh[key] = {'geometry': geometry, 'tags': tags} - return fresh + return fresh, tagged def same_geometry(old, new): - """Same type and vertex set within tolerance; ring start and winding may differ.""" + """Same type and rings within tolerance; ring start, winding and part or hole order may differ.""" if old['type'] != new['type']: return False - a, b = points(old['coordinates']), points(new['coordinates']) - if len(a) != len(b): - return False def meters(p, q): dx = (p[0] - q[0]) * 111320 * math.cos(math.radians(p[1])) return math.hypot(dx, (p[1] - q[1]) * 110540) - return all(min(meters(p, q) for q in b) <= SAME_GEOMETRY_METERS for p in a) and \ - all(min(meters(q, p) for p in a) <= SAME_GEOMETRY_METERS for q in b) + + def same_line(a, b): + return len(a) == len(b) and all(meters(p, q) <= SAME_GEOMETRY_METERS for p, q in zip(a, b)) + + def same_ring(a, b): + a, b = a[:-1], b[:-1] # closed rings repeat their first vertex + return len(a) == len(b) and \ + any(same_line(a[i:] + a[:i], c) for c in (b, b[::-1]) for i in range(len(a))) + + def same_parts(a, b, same): + rest = list(b) + for part in a: + match = next((i for i, other in enumerate(rest) if same(part, other)), None) + if match is None: + return False + rest.pop(match) + return not rest + + def same_polygon(a, b): + return same_ring(a[0], b[0]) and same_parts(a[1:], b[1:], same_ring) + + a, b = old['coordinates'], new['coordinates'] + if old['type'] == 'Point': + return same_line([a], [b]) + if old['type'] == 'LineString': + return same_line(a, b) + if old['type'] == 'Polygon': + return same_polygon(a, b) + if old['type'] == 'MultiPolygon': + return same_parts(a, b, same_polygon) + return a == b def feature_line(key, sequence, fresh): @@ -148,9 +181,9 @@ def feature_line(key, sequence, fresh): return json.dumps(feature, separators=(',', ':'), ensure_ascii=False) -def merge(lines, fresh, rings): +def merge(lines, fresh, rings, tagged): out, seen, sequence = [], set(), 0 - report = {'kept': 0, 'updated': [], 'removed': [], 'added': []} + report = {'kept': 0, 'updated': [], 'removed': [], 'added': [], 'unexported': []} for line in lines: feature = json.loads(line) sequence = max(sequence, feature['properties']['id']) @@ -165,6 +198,10 @@ def merge(lines, fresh, rings): out.append(feature_line(key, feature['properties']['id'], fresh[key])) report['updated'].append(key) continue + if key in tagged: # still tagged, but osmium built no usable geometry: keep the old line + report['unexported'].append(key) + out.append(line) + continue lon, lat = points(feature['geometry']['coordinates'])[0][:2] if inside(rings, lon, lat): report['removed'].append(key) @@ -196,9 +233,12 @@ def main(): if actual != expected: raise RuntimeError('MD5 mismatch for %s: %s != %s' % (pbf.name, actual, expected)) rings = read_poly(fetch(GEOFABRIK + 'norway.poly', args.cache / 'norway.poly')) - fresh = export(args.osmium, pbf, args.cache, extract) + fresh, tagged = export(args.osmium, pbf, args.cache, extract) current = [line for line in DATASET.read_text(encoding='utf-8').splitlines() if line.strip()] - merged, report = merge(current, fresh, rings) + merged, report = merge(current, fresh, rings, tagged) + if report['unexported']: + print('warning: kept the old line of tagged features osmium did not export: %s' + % ', '.join(report['unexported']), file=sys.stderr) payload = '\n'.join(merged) + '\n' if args.verify: if payload != DATASET.read_text(encoding='utf-8'): diff --git a/src/data/datacentersNorwayRefresh.test.mjs b/src/data/datacentersNorwayRefresh.test.mjs new file mode 100644 index 000000000..2b7b7d5bf --- /dev/null +++ b/src/data/datacentersNorwayRefresh.test.mjs @@ -0,0 +1,93 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const SCRIPT = fileURLToPath( + new URL('../../scripts/build-datacenters-norway.py', import.meta.url), +); + +function python(t, source) { + const result = spawnSync('python3', ['-c', source, SCRIPT], { + encoding: 'utf8', + }); + if (result.error?.code === 'ENOENT') return t.skip('python3 is not installed'); + assert.equal(result.status, 0, result.stderr); +} + +test('Norway refresh compares rings, not vertex sets', (t) => { + python( + t, + String.raw` +import runpy, sys +same = runpy.run_path(sys.argv[1])['same_geometry'] +poly = lambda *rings: {'type': 'Polygon', 'coordinates': list(rings)} +multi = lambda *polys: {'type': 'MultiPolygon', 'coordinates': [p['coordinates'] for p in polys]} +a, b, c, d, e = [0, 0], [4, 0], [4, 4], [0, 4], [2, 1] +assert not same(poly([a, b, c, d, e, a]), poly([a, b, e, c, d, a])), 'reordered edges' +assert same(poly([a, b, c, d, e, a]), poly([c, b, a, e, d, c])), 'ring start and winding' +assert same(poly([a, b, c, d, a]), poly([[0, 1e-7], b, c, d, [0, 1e-7]])), 'tolerance' +outer = [[0, 0], [9, 0], [9, 9], [0, 9], [0, 0]] +one, two = [[1, 1], [2, 1], [2, 2], [1, 1]], [[5, 5], [6, 5], [6, 6], [5, 5]] +assert same(poly(outer, one, two), poly(outer, two, one)), 'hole order' +assert not same(poly(outer, one), poly(one, outer)), 'outer and inner roles swapped' +assert same(multi(poly(one), poly(two)), multi(poly(two), poly(one))), 'part order' +p, q, r, s, u, v = [0, 0], [1, 0], [0, 1], [5, 5], [6, 5], [5, 6] +assert not same(multi(poly([p, q, r, p]), poly([s, u, v, s])), + multi(poly([p, q, s, p]), poly([r, u, v, r]))), 'vertices moved between parts' +`, + ); +}); + +test('Norway refresh keeps a still-tagged feature osmium did not export', (t) => { + python( + t, + String.raw` +import json, runpy, sys +merge = runpy.run_path(sys.argv[1])['merge'] +square = [[[10, 60], [10.01, 60], [10.01, 60.01], [10, 60.01], [10, 60]]] +def line(osm_id, sequence): + return json.dumps({'id': osm_id, 'type': 'Feature', 'geometry': {'type': 'Polygon', 'coordinates': square}, + 'properties': {'id': sequence, 'tags': {'building': 'data_center'}, + 'type': 'data_center', 'osm_id': osm_id}}) +norway = [([(0, 0), (20, 0), (20, 80), (0, 80)], False)] +lines = [line(7, 1), line(8, 2)] +out, report = merge(lines, {}, norway, {'w7'}) +assert out == [lines[0]], out +assert report['unexported'] == ['w7'] and report['removed'] == ['w8'], report +`, + ); +}); + +test('Norway refresh lists tagged objects apart from the exported geometry', (t) => { + python( + t, + String.raw` +import json, runpy, subprocess, sys, tempfile +from pathlib import Path +export = runpy.run_path(sys.argv[1])['export'] +tags = {'building': 'data_center'} +ring = [[10, 60], [10.01, 60], [10.01, 60.01], [10, 60]] +features = [ + {'id': 'n1', 'properties': {'telecom': 'data_center'}, 'geometry': {'type': 'Point', 'coordinates': [10, 60]}}, + {'id': 'w5', 'properties': tags, 'geometry': {'type': 'LineString', 'coordinates': ring}}, + {'id': 'a10', 'properties': tags, 'geometry': {'type': 'MultiPolygon', 'coordinates': [[ring]]}}, + {'id': 'w7', 'properties': tags, 'geometry': {'type': 'LineString', 'coordinates': ring[:3]}}, +] +outputs = { + '.opl': 'n1 v1 Ttelecom=data_center x10 y60\nw5 v1 Tbuilding=data_center Nn2,n3,n4,n2\n' + 'w7 v1 Tbuilding=data_center Nn2,n3,n4\nr9 v1 Ttelecom=data_center Mw8@outer\n', + '.geojsonseq': ''.join(chr(30) + json.dumps({'type': 'Feature', **f}) + '\n' for f in features), +} +def run(args, check=False): + target = Path(args[args.index('-o') + 1]) + target.write_text(outputs.get(target.suffix, ''), encoding='utf-8') +subprocess.run = run +with tempfile.TemporaryDirectory() as cache: + fresh, tagged = export('osmium', Path(cache) / 'norway-test.osm.pbf', Path(cache), 'norway-test') +assert sorted(fresh) == ['n1', 'w5'], fresh +assert fresh['w5']['geometry'] == {'type': 'Polygon', 'coordinates': [ring]}, fresh['w5'] +assert tagged == {'n1', 'w5', 'w7', 'r9'}, tagged +`, + ); +}); diff --git a/src/data/local_data/datacenters/README.md b/src/data/local_data/datacenters/README.md index 8b803dcc7..5a80df267 100644 --- a/src/data/local_data/datacenters/README.md +++ b/src/data/local_data/datacenters/README.md @@ -27,7 +27,9 @@ reflects OSM as of the extract below, everything else the original snapshot. 2026-10-05T20:21:35Z, MD5 `1d2cfdeb98980fce11b8891d0f9146c0` - Filter: `osmium tags-filter nwr/telecom=data_center nwr/building=data_center` - Contact tags are dropped as above; unchanged features keep their original - line, so geometry is only rewritten when tags or vertices actually changed. + line, so geometry is only rewritten when tags or rings actually changed. + A feature is removed only once it is no longer tagged; one still tagged but + without an exportable geometry keeps its line and is reported as unexported. - Refresh: `python3 scripts/build-datacenters-norway.py --cache ` uses Geofabrik's current Norway extract and prints the dated name it used; record that name, its OSM timestamp and MD5 here. Requires `osmium-tool`. From c68f167acb5b20c692aa0632b5de48c446b05e40 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 3 Nov 2026 15:07:00 +0400 Subject: [PATCH 08/71] fix(transit): count distinct coverage regions --- src/layers/transit/queries.js | 7 ++++++- src/layers/transit/queries.test.mjs | 28 ++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 src/layers/transit/queries.test.mjs diff --git a/src/layers/transit/queries.js b/src/layers/transit/queries.js index b86613160..dc3ea4d79 100644 --- a/src/layers/transit/queries.js +++ b/src/layers/transit/queries.js @@ -12,6 +12,11 @@ import { } from '../../data/transitFeeds.js'; import { transitModeTier } from '../../data/transitPresetStyle.js'; +/** Regions, not feeds: one region can carry several feeds (Rio has two). */ +const TRANSIT_REGION_COUNT = new Set( + TRANSIT_ENABLED_FEEDS.map((feed) => feed.region), +).size; + /** * The registry entry behind a vehicle. Live feeds answer first so a feed the * session disabled stops supplying vocabulary; the registry is the fallback, @@ -249,7 +254,7 @@ export function createQueries({ state, parts }) { // English data (the analyst's empty note to the model); the voice // card shows it through NOTE_PATTERNS (voice/resultDisplay.js). coverage: state._altitudeGateOpen - ? `No feed here yet · ${TRANSIT_ENABLED_FEEDS.length} regions available` + ? `No feed here yet · ${TRANSIT_REGION_COUNT} regions available` : `Fly below ${Math.round(ACTIVATION_ALTITUDE_M / 1000).toLocaleString('en-US')} km to a covered region`, }; } diff --git a/src/layers/transit/queries.test.mjs b/src/layers/transit/queries.test.mjs new file mode 100644 index 000000000..a7ac1224c --- /dev/null +++ b/src/layers/transit/queries.test.mjs @@ -0,0 +1,28 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createQueries } from './queries.js'; +import { TRANSIT_ENABLED_FEEDS } from '../../data/transitFeeds.js'; + +test('the empty-coverage note counts regions, not feeds', () => { + const rio = TRANSIT_ENABLED_FEEDS.filter( + (feed) => feed.region === 'Rio de Janeiro, RJ', + ); + assert.equal(rio.length, 2, 'Rio is one region with two feeds'); + const regions = new Set(TRANSIT_ENABLED_FEEDS.map((feed) => feed.region)); + assert.ok(regions.size < TRANSIT_ENABLED_FEEDS.length); + + const stats = createQueries({ + state: { + _activeFeeds: new Map(), + _vehicles: new Map(), + _enabled: true, + _altitudeGateOpen: true, + }, + parts: {}, + }).methods.getStats(); + assert.equal(stats.status, 'zoom-in'); + assert.equal( + stats.coverage, + `No feed here yet · ${regions.size} regions available`, + ); +}); From 69152868e4eff63d17b8cbeef8bdffbd68e71c05 Mon Sep 17 00:00:00 2001 From: Manjunath Date: Tue, 3 Nov 2026 16:42:18 +0530 Subject: [PATCH 09/71] Fix voice camera tilt from a top-down view A voice fly-to frames a city top-down, at pitch -90. The tilt clamp rejected any step that left the pitch below -89, so the first frame of "tilt up" stopped the motion and "tilt down" was refused: from the view voice lands on, no tilt ever moved the camera. A bare "tilt the camera" also failed because tilt required a direction. - Clamp tilt only in the direction of travel, so -90 can tilt up. - Swing the camera about the ground under the crosshair, so the place stays centred and the heading is kept. A tracked view still tilts in place, and a pivot tilt yields when tracking takes the camera. - Add an optional move_camera pitchDeg for "tilt 45 degrees"; a tilt with neither angle nor direction settles at -45 (oblique view). - Re-derive the voice tool schema pins for the new argument and wording, and keep the Realtime prompt under its 52,000-character budget. --- docs/CURRENT-STATE.md | 2 +- scripts/format-scope.json | 1 + server/providers/openai/toolDescriptions.js | 7 +- src/cameraTilt.test.mjs | 224 ++++++++++++++++++++ src/cameraVerbs.js | 163 ++++++++++++-- src/firstRunExperience.test.mjs | 7 + src/radioMarkup.test.mjs | 7 +- src/voice/actionSchemas.js | 5 + src/voice/actionSchemas.test.mjs | 14 +- 9 files changed, 403 insertions(+), 27 deletions(-) create mode 100644 src/cameraTilt.test.mjs diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index be0718fd1..3c17ed79f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5364,7 +5364,7 @@ and unreachable upstream (502/504) separately from road geometry. > branch: **2,281 on Node 25.6.1** (allocation suites skipped) = 2,255 on > `main` + 26 route pins; the same tree on Node 24 reports 2,295. -- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. `rotate_globe` rotates the world-space camera rigidly around Earth's center using its screen-up axis, preserving geocentric range and framing. Left/right describe visible globe motion, while `rotate` turns the camera in place. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck current framing without demanding a reset merely because ellipsoid altitude varied. A close-up or overlap refuses without moving the camera. When the unchanged Globe View preset fits, voice says “Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’” without asking a permission question or automatically resetting. Explicit Reset/Show Globe View commands remain direct; an explicit reset-and-rotate request waits for reset success before requesting rotation. If OpenAI dispatches both tools together, rotation waits for earlier same-response resets and is skipped if any fails or is cancelled. OpenAI rejects later tool calls in the same response after a globe refusal, including calls arriving while globe admission is pending, and preserves response-scoped `tool_choice: none` for the refusal follow-up; Gemini rejects further tool execution, including queued sibling calls, until fresh typed input or admitted speech. Model interruption, advisory captions and microphone toggles do not release that guard. There is no pending Yes/No offer. Outside the refused response, explicit-command interpretation and avoiding repeated resets remain model-guided; a distinct model-issued reset call is not an app-enforced proof of user intent. A failed standalone reset can still be retried by the model, and a reset result does not guarantee the camera has fully settled. Before suggesting a reset, a cloned camera checks the unchanged Globe View destination against the same keyhole/depth rules without moving the live camera. If that preset cannot fit, `globe-reset-unavailable` asks for further zoom and the refusal instructs further zoom instead of a reset; this also covers ultrawide views. Unsupported view geometry or non-WGS84 projection assumptions fail closed without the closer-zoom instruction. Supported camera inputs/navigation intents cancel rotation before their action. Finite radius greater than the ellipsoid's maximum radius proves whole-arc clearance at constant centre-distance; no low-altitude swept solver is needed. The unchanged Globe View reset preset remains 18,000,000 m. Terrain and buildings remain outside the guarantee. +- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. `rotate_globe` rotates the world-space camera rigidly around Earth's center using its screen-up axis, preserving geocentric range and framing. Left/right describe visible globe motion, while `rotate` turns the camera in place. `tilt` swings the camera about the ground under the crosshair (the tracked view excepted, which tilts in place), so the place stays centred and the heading is kept; pitch stays within -89° to -5°, clamped only in the direction of travel so a top-down (-90°) view can tilt up out of it. A tilt with `pitchDeg` (or with neither angle nor direction, which means -45°) eases to that angle; a positive angle means the same angle below the horizon. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck current framing without demanding a reset merely because ellipsoid altitude varied. A close-up or overlap refuses without moving the camera. When the unchanged Globe View preset fits, voice says “Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’” without asking a permission question or automatically resetting. Explicit Reset/Show Globe View commands remain direct; an explicit reset-and-rotate request waits for reset success before requesting rotation. If OpenAI dispatches both tools together, rotation waits for earlier same-response resets and is skipped if any fails or is cancelled. OpenAI rejects later tool calls in the same response after a globe refusal, including calls arriving while globe admission is pending, and preserves response-scoped `tool_choice: none` for the refusal follow-up; Gemini rejects further tool execution, including queued sibling calls, until fresh typed input or admitted speech. Model interruption, advisory captions and microphone toggles do not release that guard. There is no pending Yes/No offer. Outside the refused response, explicit-command interpretation and avoiding repeated resets remain model-guided; a distinct model-issued reset call is not an app-enforced proof of user intent. A failed standalone reset can still be retried by the model, and a reset result does not guarantee the camera has fully settled. Before suggesting a reset, a cloned camera checks the unchanged Globe View destination against the same keyhole/depth rules without moving the live camera. If that preset cannot fit, `globe-reset-unavailable` asks for further zoom and the refusal instructs further zoom instead of a reset; this also covers ultrawide views. Unsupported view geometry or non-WGS84 projection assumptions fail closed without the closer-zoom instruction. Supported camera inputs/navigation intents cancel rotation before their action. Finite radius greater than the ellipsoid's maximum radius proves whole-arc clearance at constant centre-distance; no low-altitude swept solver is needed. The unchanged Globe View reset preset remains 18,000,000 m. Terrain and buildings remain outside the guarantee. - **The route dolly is shaped, not linear** (2026-08-20). A trapezoid speed profile (smoothstep up, cruise, smoothstep down, distance taken as the closed-form integral) eases both ends without changing the pace — duration is still `totalM / ROUTE_M_S[speed]`, with one exception: a 0.5 s minimum keeps a degenerate route from being an instant teleport, so routes under 10 m (slow) / 20 m (normal) / 45 m (fast) fly SLOWER than the speed word, never faster. Turns bank up to **10°** (a 90° street corner settles near 7.5°), measured as a triangular pulse over a 4 s window centred on the camera so the roll leads in and unwinds after. Altitude breathes ±20 m around the 260 m mean and lifts up to 26 m into turns. Pitch is LOCKED at −32°; heading comes from a gaze that leads the path by 6.5 s of travel. `prefers-reduced-motion` zeroes the roll and the altitude shaping and keeps the easing. - **Invariants — do not "fix" these.** (1) **Every** release levels the roll: completion unwinds it through the ease-out envelope, and `interruptCameraMotion` zeroes it synchronously (heading/pitch/position preserved). Cesium keeps the last up vector it was handed, so skipping this leaves the user holding a tilted horizon. (2) Heading is interpolated as an ANGLE about the local up, never as a Cartesian lerp — a lerp cannot cross an antipodal pair, so an out-and-back route looked backwards for the entire return leg; `signedTurnRad` branches the exact-180° case deterministically because the cross product's sign there is a floating-point coin toss. (3) A COLD floor cell is missing data, not flat ground: route vertices carry height 0, so trusting them flew a mountain corridor at 260 m above the ELLIPSOID. The corridor warm is fire-and-forget, so the dolly ARMS — camera untouched, no teleport onto the route — for up to 1.2 s waiting for real floor data, falls back to a rendered-mesh probe (`scene.sampleHeight`) when the DEM stays cold — latched to ONE firing per flight, for exactly the cells the cache could not answer, so a route costs at most 8 `sampleHeight` calls however long it arms — and otherwise holds the launch altitude for the whole route. A corridor is only RESOLVED when every cell is accounted for: one warm cell says nothing about the ground under the other seven, and treating it as an answer let the dolly descend to 460 m over a 1,600 m rendered surface. The probe reads the rendered surface at the current LOD (rooftops and primitives included) — a better estimate than nothing, not a guaranteed upper bound. Never descend blind. The floor is SMOOTHED in both directions for the ride while the hard clearance clamp reads the RAW sample, so a cell boundary cannot pop the eye but a cliff is still cleared on the frame it is seen. A pre-departure floor is adopted whole; one arriving mid-flight is eased onto, and the eye's descent rate is capped. - 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). diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 447dbf199..79117e775 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1108,6 +1108,7 @@ "scripts/qa-status-badges.mjs", "src/dockerTrustedPeers.test.mjs", "src/cameraGlobeRotation.test.mjs", + "src/cameraTilt.test.mjs", "src/voice/globeResetConfirmation.test.mjs", "src/hudDms.test.mjs", "src/hudSummaryGate.test.mjs", diff --git a/server/providers/openai/toolDescriptions.js b/server/providers/openai/toolDescriptions.js index 23d5da9de..873a52c8a 100644 --- a/server/providers/openai/toolDescriptions.js +++ b/server/providers/openai/toolDescriptions.js @@ -523,7 +523,7 @@ export const ACTION_DESCRIPTIONS = { properties: { direction: { description: - 'Required for pan/tilt/rotate; orbit/rotate_globe default right.', + 'Required for pan/rotate and a tilt nudge; orbit/rotate_globe default right.', $position: 2, }, mode: { @@ -531,6 +531,11 @@ export const ACTION_DESCRIPTIONS = { 'once = bounded eased nudge; continuous = until stop/manual input. rotate_globe defaults continuous; other motions default once.', $position: 2, }, + pitchDeg: { + description: + 'Tilt to this angle below the horizon (-45 oblique, -89 top-down). Bare tilt: -45.', + $position: 2, + }, }, }, }, diff --git a/src/cameraTilt.test.mjs b/src/cameraTilt.test.mjs new file mode 100644 index 000000000..babe95807 --- /dev/null +++ b/src/cameraTilt.test.mjs @@ -0,0 +1,224 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import * as Cesium from 'cesium'; +import { + getActiveCameraMotion, + initCameraVerbs, + interruptCameraMotion, + moveCamera, +} from './cameraVerbs.js'; + +const radians = Cesium.Math.toRadians; +const degrees = Cesium.Math.toDegrees; + +/** Real Cesium camera over London with no renderer: the voice fly-to frame. */ +function harness( + t, + { height = 4000, heading = 0, pitch = -90, pivot = true } = {}, +) { + const realPerformance = globalThis.performance; + let nowMs = 0; + globalThis.performance = { now: () => nowMs }; + const canvas = Object.assign(new EventTarget(), { + clientWidth: 1280, + clientHeight: 800, + width: 1280, + height: 800, + }); + const ellipsoid = Cesium.Ellipsoid.WGS84; + const scene = { + canvas, + drawingBufferWidth: 1280, + drawingBufferHeight: 800, + frameState: { mode: Cesium.SceneMode.SCENE3D }, + mapProjection: new Cesium.GeographicProjection(ellipsoid), + globe: { ellipsoid }, + mapMode2D: Cesium.MapMode2D.INFINITE_SCROLL, + mode: Cesium.SceneMode.SCENE3D, + pixelRatio: 1, + tweens: [], + requestRender() {}, + }; + const camera = new Cesium.Camera(scene); + scene.camera = camera; + camera.setView({ + destination: Cesium.Cartesian3.fromDegrees(-0.1276, 51.5072, height), + orientation: { heading: radians(heading), pitch: radians(pitch), roll: 0 }, + }); + const viewer = { + camera, + scene, + trackedEntity: undefined, + trackedEntityChanged: new Cesium.Event(), + clock: { onTick: new Cesium.Event() }, + }; + // The app's view target: the ground point under the screen centre. + const viewTarget = () => { + const ray = new Cesium.Ray(camera.positionWC, camera.directionWC); + const hit = Cesium.IntersectionTests.rayEllipsoid(ray, ellipsoid); + return hit ? Cesium.Ray.getPoint(ray, hit.start) : undefined; + }; + const target = viewTarget(); + initCameraVerbs(viewer, pivot ? viewTarget : () => undefined); + t.after(() => { + interruptCameraMotion('test-cleanup'); + globalThis.performance = realPerformance; + }); + const tick = (seconds = 1 / 30) => { + nowMs += seconds * 1000; + viewer.clock.onTick.raiseEvent(viewer.clock); + }; + const finish = () => { + for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) tick(); + assert.equal(getActiveCameraMotion(), null, 'tilt must finish'); + }; + return { viewer, camera, target, tick, finish, viewTarget }; +} + +const pitchDeg = (camera) => degrees(camera.pitch); +const headingDeg = (camera) => + degrees(Cesium.Math.negativePiToPi(camera.heading)); + +/** Metres between the original centre and the ground now under the crosshair. */ +function centreDrift(h) { + return Cesium.Cartesian3.distance(h.viewTarget(), h.target); +} + +test('tilt up leaves a top-down view and keeps the place centred', (t) => { + const h = harness(t); + const result = moveCamera({ motion: 'tilt', direction: 'up' }); + assert.equal(result.ok, true); + h.finish(); + assert.ok( + Math.abs(pitchDeg(h.camera) - -75) < 0.5, + `pitch ${pitchDeg(h.camera)}`, + ); + assert.ok(centreDrift(h) < 5, `centre drifted ${centreDrift(h)} m`); + assert.ok(Cesium.Matrix4.equals(h.camera.transform, Cesium.Matrix4.IDENTITY)); +}); + +test('continuous tilt up from top-down keeps moving', (t) => { + const h = harness(t); + assert.equal( + moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' }).ok, + true, + ); + for (let i = 0; i < 30; i += 1) h.tick(); + assert.equal(getActiveCameraMotion()?.kind, 'tilt'); + assert.ok(pitchDeg(h.camera) > -82, `pitch ${pitchDeg(h.camera)}`); +}); + +test('a requested angle tilts to that pitch', (t) => { + const h = harness(t); + const result = moveCamera({ motion: 'tilt', pitchDeg: -45 }); + assert.equal(result.ok, true); + assert.equal(result.direction, 'up'); + assert.equal(result.pitchDeg, -45); + h.finish(); + assert.ok( + Math.abs(pitchDeg(h.camera) - -45) < 0.5, + `pitch ${pitchDeg(h.camera)}`, + ); + assert.ok(centreDrift(h) < 5, `centre drifted ${centreDrift(h)} m`); +}); + +test('a bare tilt settles on the default oblique angle', (t) => { + const h = harness(t); + const result = moveCamera({ motion: 'tilt' }); + assert.equal(result.ok, true); + assert.equal(result.pitchDeg, -45); + h.finish(); + assert.ok(Math.abs(pitchDeg(h.camera) - -45) < 0.5); +}); + +test('a requested angle can tilt back down toward top-down', (t) => { + const h = harness(t, { pitch: -30 }); + const result = moveCamera({ motion: 'tilt', pitchDeg: -80 }); + assert.equal(result.direction, 'down'); + h.finish(); + assert.ok(Math.abs(pitchDeg(h.camera) - -80) < 0.5); +}); + +test('requested angles clamp to the tilt limits', (t) => { + const h = harness(t); + assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -2 }).pitchDeg, -5); + h.finish(); + assert.ok(Math.abs(pitchDeg(h.camera) - -5) < 0.5); +}); + +test('a positive angle is read as the same angle below the horizon', (t) => { + const h = harness(t); + assert.equal(moveCamera({ motion: 'tilt', pitchDeg: 45 }).pitchDeg, -45); + h.finish(); + assert.ok(Math.abs(pitchDeg(h.camera) - -45) < 0.5); +}); + +for (const pitch of ['', null, false, ' ', 'steep']) + test(`a blank or non-numeric angle ${JSON.stringify(pitch)} is ignored`, (t) => { + harness(t); + const result = moveCamera({ motion: 'tilt', pitchDeg: pitch }); + // No angle and no direction: the default oblique view, never the horizon. + assert.equal(result.pitchDeg, -45); + }); + +test('a requested angle the camera already holds is a no-op', (t) => { + harness(t, { pitch: -45 }); + const result = moveCamera({ motion: 'tilt', pitchDeg: -45 }); + assert.equal(result.ok, true); + assert.equal(result.unchanged, true); + assert.equal(getActiveCameraMotion(), null); +}); + +test('tilt down from top-down still refuses honestly', (t) => { + harness(t); + const result = moveCamera({ motion: 'tilt', direction: 'down' }); + assert.equal(result.ok, false); + assert.match(result.error, /straight down/); +}); + +test('a tilt up nudge stops at the horizon limit', (t) => { + const h = harness(t, { pitch: -12 }); + assert.equal(moveCamera({ motion: 'tilt', direction: 'up' }).ok, true); + h.finish(); + assert.ok(Math.abs(pitchDeg(h.camera) - -5) < 0.5); +}); + +test('without a ground pivot a tilt still leaves top-down in place', (t) => { + const h = harness(t, { pivot: false }); + const before = Cesium.Cartesian3.clone(h.camera.positionWC); + assert.equal(moveCamera({ motion: 'tilt', direction: 'up' }).ok, true); + h.finish(); + assert.ok(Math.abs(pitchDeg(h.camera) - -75) < 0.5); + assert.ok(Cesium.Cartesian3.distance(h.camera.positionWC, before) < 1e-6); +}); + +for (const heading of [0, 30, -120]) + test(`tilt from top-down keeps the ${heading}° heading`, (t) => { + const h = harness(t, { heading, height: 67000 }); + // Top-down float noise must not pick a new heading for the pivot. + assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -45 }).ok, true); + h.finish(); + assert.ok( + Math.abs(headingDeg(h.camera) - heading) < 1, + `heading ${headingDeg(h.camera)}`, + ); + assert.ok(centreDrift(h) < 50, `centre drifted ${centreDrift(h)} m`); + }); + +test('an oblique tilt keeps its heading toward the pivot', (t) => { + const h = harness(t, { heading: 75, pitch: -60 }); + assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -30 }).ok, true); + h.finish(); + assert.ok(Math.abs(headingDeg(h.camera) - 75) < 0.5); + assert.ok(centreDrift(h) < 5, `centre drifted ${centreDrift(h)} m`); +}); + +test('a pivoting tilt yields when tracking takes the camera', (t) => { + const h = harness(t); + assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -45 }).ok, true); + h.tick(); + const entity = { id: 'tracked' }; + h.viewer.trackedEntity = entity; + h.viewer.trackedEntityChanged.raiseEvent(entity); + assert.equal(getActiveCameraMotion(), null); +}); diff --git a/src/cameraVerbs.js b/src/cameraVerbs.js index e037472cb..3898acaa7 100644 --- a/src/cameraVerbs.js +++ b/src/cameraVerbs.js @@ -48,6 +48,10 @@ const ONCE = { }; const PITCH_MIN = Cesium.Math.toRadians(-89); const PITCH_MAX = Cesium.Math.toRadians(-5); +/** A bare "tilt the camera" / "oblique view" settles here. */ +const TILT_DEFAULT_PITCH_DEG = -45; +/** °/s for a tilt to a requested angle; short ones keep the once duration. */ +const TILT_TO_DEG_S = 30; /* ── Route dolly: cinematic tuning ────────────────────────────────────────── * Every knob an owner may want to retune lives in this block. The shaping is @@ -1052,7 +1056,9 @@ export function interruptCameraMotion(reason = 'interrupt') { if ( wasActive && !tracking && - (_active.kind === 'orbit' || _active.kind === 'route') + (_active.kind === 'orbit' || + _active.kind === 'route' || + (_active.kind === 'tilt' && _active.hpr)) ) clearLookAt(); // Instant, not eased: the user grabbed the camera, or the flight is over. @@ -1104,7 +1110,7 @@ function onTick() { let scale = 1; if (m.mode === 'once') { m.elapsed = (m.elapsed || 0) + dt; - const t = Math.min(1, m.elapsed / ONCE.durationS); + const t = Math.min(1, m.elapsed / (m.durationS || ONCE.durationS)); scale = 1 - (1 - t) * (1 - t); // ease-out progress share if (t >= 1) { interruptCameraMotion('once-complete'); @@ -1173,18 +1179,13 @@ function onTick() { else if (m.direction === 'right') cam.moveRight(step); else if (m.direction === 'up') cam.moveUp(step); else cam.moveDown(step); - } else if ( - m.kind === 'tilt' || - m.kind === 'rotate' || - m.kind === 'rotate_globe' - ) { + } else if (m.kind === 'tilt') { + tickTilt(cam, m, scale, dt); + } else if (m.kind === 'rotate' || m.kind === 'rotate_globe') { const degS = TILT_DEG_S[m.speed]; const stepRad = m.mode === 'once' - ? Cesium.Math.toRadians( - m.kind === 'tilt' ? ONCE.tiltDeg : ONCE.rotateDeg, - ) * - (scale - (m.lastScale || 0)) + ? Cesium.Math.toRadians(ONCE.rotateDeg) * (scale - (m.lastScale || 0)) : Cesium.Math.toRadians(degS) * dt; m.lastScale = scale; if (m.kind === 'rotate_globe') { @@ -1218,16 +1219,6 @@ function onTick() { return; } cam.rotate(m.rotationAxis, angle); - } else if (m.kind === 'tilt') { - const up = m.direction === 'up'; - // Clamp pitch to [-89°, -5°]: never through the ground, never at the sky. - const next = cam.pitch + (up ? stepRad : -stepRad); - if (next > PITCH_MAX || next < PITCH_MIN) { - interruptCameraMotion('tilt-clamp'); - return; - } - if (up) cam.lookUp(stepRad); - else cam.lookDown(stepRad); } else if (m.direction === 'left') cam.lookLeft(stepRad); else cam.lookRight(stepRad); } else if (m.kind === 'route') { @@ -1252,6 +1243,67 @@ function onTick() { } } +/** + * One frame of tilt. With a ground pivot the camera swings about it (lookAt), + * so the place under the crosshair stays centred; without one it looks up or + * down in place. Pitch is clamped to [-89°, -5°] only in the direction of + * travel: a top-down view sits at -90°, below the limit, and must still be + * able to tilt up out of it. + */ +function tickTilt(cam, m, scale, dt) { + let step; + if (m.mode === 'once') { + step = m.tiltRad * (scale - (m.lastScale || 0)); + m.lastScale = scale; + } else { + step = + Cesium.Math.toRadians(TILT_DEG_S[m.speed]) * + dt * + (m.direction === 'up' ? 1 : -1); + } + const from = m.hpr ? m.hpr.pitch : cam.pitch; + const next = from + step; + const limited = + step > 0 + ? Math.min(next, Math.max(from, PITCH_MAX)) + : Math.max(next, Math.min(from, PITCH_MIN)); + if (m.hpr) { + m.hpr.pitch = limited; + cam.lookAt(m.target, m.hpr); + // A once motion that just completed released the lookAt frame before + // this last step re-took it. + if (_active !== m) clearLookAt(); + } else { + cam.lookUp(limited - from); + } + if (limited !== next) interruptCameraMotion('tilt-clamp'); +} + +/** Ground pivot for a tilt: heading, pitch and range in the target's own frame. */ +function tiltPivot(cam, target) { + const local = Cesium.Matrix4.multiplyByPoint( + Cesium.Matrix4.inverseTransformation( + Cesium.Transforms.eastNorthUpToFixedFrame(target), + new Cesium.Matrix4(), + ), + cam.positionWC, + new Cesium.Cartesian3(), + ); + const range = Cesium.Cartesian3.magnitude(local); + if (!(range > 1) || local.z <= 0) return null; + // Near straight overhead the offset's heading is float noise (it flipped a + // north-up London view to south); keep the camera's own. + const heading = + Math.hypot(local.x, local.y) > range * 0.01 + ? Math.atan2(-local.x, -local.y) + : cam.heading; + return new Cesium.HeadingPitchRange( + heading, + -Math.asin(Math.min(1, local.z / range)), + range, + ); +} + /** Rigid origin-centred rotation stays outside the actual ellipsoid's bounding sphere. */ function globeRotationHasClearance(position) { const ellipsoid = _viewer.scene.globe?.ellipsoid ?? Cesium.Ellipsoid.WGS84; @@ -1421,6 +1473,9 @@ export function initCameraVerbs( _trackingRemover = viewer.trackedEntityChanged?.addEventListener((entity) => { if (entity && _active?.kind === 'rotate_globe') interruptCameraMotion('globe-camera-owner-changed'); + // A pivoting tilt's lookAt would fight the follow camera every frame. + else if (entity && _active?.kind === 'tilt' && _active.hpr) + interruptCameraMotion('tilt-camera-owner-changed'); }); for (const rm of _inputRemovers) rm(); _inputRemovers = []; @@ -1472,9 +1527,32 @@ export function moveCamera(args = {}, runNavigation = null) { error: `Unknown motion "${args.motion}" — use orbit, pan, tilt, rotate, rotate_globe, or stop.`, }; } + // Tilt to an angle: the one asked for, or a default oblique view when a + // bare "tilt the camera" names neither angle nor direction. + let tiltToPitch = null; + if (motion === 'tilt') { + // Only a real number or numeric text: Number('') and Number(null) are 0, + // which would swing the camera to the horizon. + const raw = args.pitchDeg; + const requested = + typeof raw === 'number' || (typeof raw === 'string' && raw.trim() !== '') + ? Number(raw) + : NaN; + // The camera never looks above the horizon, so a positive angle can only + // mean the same angle below it ("tilt 45 degrees" sent as 45). + if (Number.isFinite(requested)) + tiltToPitch = Cesium.Math.clamp( + Cesium.Math.toRadians(-Math.abs(requested)), + PITCH_MIN, + PITCH_MAX, + ); + else if (direction === null) + tiltToPitch = Cesium.Math.toRadians(TILT_DEFAULT_PITCH_DEG); + } if ( motion !== 'orbit' && motion !== 'rotate_globe' && + tiltToPitch === null && !['left', 'right', 'up', 'down'].includes(direction) ) { return { @@ -1501,7 +1579,19 @@ export function moveCamera(args = {}, runNavigation = null) { error: 'rotate_globe needs left or right.', }; } - if (motion === 'tilt') { + if (motion === 'tilt' && tiltToPitch !== null) { + if ( + Math.abs(tiltToPitch - _viewer.camera.pitch) < Cesium.Math.toRadians(0.5) + ) { + return { + ok: true, + action: 'move_camera', + motion, + pitchDeg: Math.round(Cesium.Math.toDegrees(tiltToPitch)), + unchanged: true, + }; + } + } else if (motion === 'tilt') { // Honest limits: at the clamp a tilt is a no-op — say so instead of // reporting success and letting the model gaslight itself (field test). const pitch = _viewer.camera.pitch; @@ -1615,6 +1705,30 @@ export function moveCamera(args = {}, runNavigation = null) { speed, mode, }; + if (motion === 'tilt') { + const cam = _viewer.camera; + // Swing about the ground under the crosshair so the place stays + // centred. The follow camera owns a tracked view's frame. + const target = _viewer.trackedEntity ? null : _getTarget?.(_viewer); + const pivot = target ? tiltPivot(cam, target) : null; + if (pivot) { + state.target = target; + state.hpr = pivot; + } + const from = pivot ? pivot.pitch : cam.pitch; + if (tiltToPitch !== null) { + state.mode = 'once'; + state.direction = tiltToPitch > from ? 'up' : 'down'; + state.tiltRad = tiltToPitch - from; + state.durationS = Math.max( + ONCE.durationS, + Math.abs(Cesium.Math.toDegrees(state.tiltRad)) / TILT_TO_DEG_S, + ); + } else { + state.tiltRad = + Cesium.Math.toRadians(ONCE.tiltDeg) * (direction === 'up' ? 1 : -1); + } + } if (motion === 'rotate_globe') { // Convert an inherited ground-target frame without changing the world // pose. Rotation must pivot about Earth, never the previous local target. @@ -1699,7 +1813,10 @@ export function moveCamera(args = {}, runNavigation = null) { motion, direction: state.direction, speed, - mode, + mode: state.mode, + ...(tiltToPitch !== null && { + pitchDeg: Math.round(Cesium.Math.toDegrees(tiltToPitch)), + }), }; }; const preserveCameraFlight = diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs index 87229e5c5..9ca5262a4 100644 --- a/src/firstRunExperience.test.mjs +++ b/src/firstRunExperience.test.mjs @@ -1014,6 +1014,13 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping 'noir', ]); visualStyle.enum = [...visualStyle.enum, 'snow']; + // Tilt to an angle adds pitchDeg and rewords direction; restore + // the legacy camera fields to keep the pin. + const camera = legacyTools.find((tool) => tool.name === 'move_camera') + .parameters.properties; + delete camera.pitchDeg; + camera.direction.description = + 'Required for pan/tilt/rotate; orbit/rotate_globe default right.'; const block = JSON.stringify(legacyTools); // Re-derived for the voice layer manifest (generated layer enums, alias and // field hints), point-and-ask's pointer/referent arguments and the prompt diff --git a/src/radioMarkup.test.mjs b/src/radioMarkup.test.mjs index 74a44e77d..cfc2da5a7 100644 --- a/src/radioMarkup.test.mjs +++ b/src/radioMarkup.test.mjs @@ -225,10 +225,15 @@ test('no unchanged Realtime tool definition drifts silently', () => { camera.description, 'Direct the camera like a drone operator: orbit the current view target, pan, tilt, or rotate — one bounded nudge (mode=once) or continuous motion until stopped (mode=continuous). Continuous motion also stops on any manual camera input or when a navigation tool runs. Say the RESULTING state when confirming ("Orbiting slowly"). Globe rotation (rotate_globe) spins Earth right continuously by default; explicit direction, mode and speed take precedence. It checks fit and may refuse.', ); + // Tilt to an angle adds pitchDeg and says a tilt nudge needs a + // direction; check them, then restore the legacy fields for the pin. assert.equal( camera.parameters.properties.direction.description, - 'Required for pan/tilt/rotate; orbit/rotate_globe default right.', + 'Required for pan/rotate and a tilt nudge; orbit/rotate_globe default right.', ); + assert.equal(camera.parameters.properties.pitchDeg.type, 'number'); + assert.match(camera.parameters.properties.pitchDeg.description, /-45 oblique/); + delete camera.parameters.properties.pitchDeg; assert.equal(camera.parameters.properties.mode.description, 'once = bounded eased nudge; continuous = until stop/manual input. rotate_globe defaults continuous; other motions default once.'); camera.parameters.properties.mode.description = 'once = bounded eased nudge (default); continuous = until stop/manual input.'; camera.parameters.properties.motion.enum = diff --git a/src/voice/actionSchemas.js b/src/voice/actionSchemas.js index 9ec47028c..2b31c26d6 100644 --- a/src/voice/actionSchemas.js +++ b/src/voice/actionSchemas.js @@ -749,6 +749,11 @@ const schemas = [ type: 'string', enum: ['once', 'continuous'], }, + pitchDeg: { + type: 'number', + minimum: -90, + maximum: 90, + }, }, required: ['motion'], }, diff --git a/src/voice/actionSchemas.test.mjs b/src/voice/actionSchemas.test.mjs index d15a7c25e..d7805e215 100644 --- a/src/voice/actionSchemas.test.mjs +++ b/src/voice/actionSchemas.test.mjs @@ -49,7 +49,8 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea // Snow leaves the set_visual_style enum. // The Operator HUD layout leaves set_hud's layout enum. // The layer alias hint leaves out aliases the layer id already spells. - 'dc2bac1be93a503dc88d637beea2f1bc457e1f9167422bd91548b44756a8aca5', + // move_camera tilt takes an optional pitchDeg angle. + '35261624ef8596608b80e2876f83760723ecf6592acc43fd77691cdb0a972608', ); }); @@ -134,6 +135,8 @@ test('all legacy action arguments are byte-identical after removing the delibera 'stop', ]); motion.enum = motion.enum.filter((value) => value !== 'rotate_globe'); + // Tilt to an angle is additive. + delete property('move_camera').pitchDeg; const panels = property('set_panel_open').panelId; panels.enum = panels.enum.filter( (id) => @@ -272,3 +275,12 @@ test('direct voice timeout schema accepts None and exact integers, rejects malfo for (const minutes of [-1, 61, 1.5, '5']) assert.ok(validateArgs(tool.parameters, { minutes }).length > 0); }); + +test('tilt accepts an angle with no direction', () => { + const schema = GEV_ACTION_SCHEMAS.find( + (tool) => tool.name === 'move_camera', + ).parameters; + assert.deepEqual(validateArgs(schema, { motion: 'tilt', pitchDeg: -45 }), []); + assert.deepEqual(validateArgs(schema, { motion: 'tilt' }), []); + assert.ok(validateArgs(schema, { motion: 'tilt', pitchDeg: 120 }).length > 0); +}); From 91a3f787d4bc4afd4495c5ff6a5b77e5d2bedd9a Mon Sep 17 00:00:00 2001 From: Manjunath Date: Tue, 3 Nov 2026 16:42:39 +0530 Subject: [PATCH 10/71] Hold a voice tilt until its fly-to lands "Fly to London and tilt the camera" reaches the app as two sibling tool calls, and OpenAI runs them concurrently. The tilt's navigation step cancelled the flight already in the air, so the camera tilted where it was; when the place still needed a lookup, the late flight replaced the tilt and London came out top-down. The voice runner now tracks its latest flight. A tilt sent while that flight is still looking up or flying waits for it to land (up to 30 s), then tilts at the destination. If the flight is cancelled, finds nothing or times out, the tilt is not run and says so. --- docs/CURRENT-STATE.md | 2 +- src/voice/gevActions.js | 90 +++++++++++++++++++++++-- src/voice/gevActions.test.mjs | 123 ++++++++++++++++++++++++++++++++++ 3 files changed, 207 insertions(+), 8 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 3c17ed79f..2730ae8de 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5364,7 +5364,7 @@ and unreachable upstream (502/504) separately from road geometry. > branch: **2,281 on Node 25.6.1** (allocation suites skipped) = 2,255 on > `main` + 26 route pins; the same tree on Node 24 reports 2,295. -- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. `rotate_globe` rotates the world-space camera rigidly around Earth's center using its screen-up axis, preserving geocentric range and framing. Left/right describe visible globe motion, while `rotate` turns the camera in place. `tilt` swings the camera about the ground under the crosshair (the tracked view excepted, which tilts in place), so the place stays centred and the heading is kept; pitch stays within -89° to -5°, clamped only in the direction of travel so a top-down (-90°) view can tilt up out of it. A tilt with `pitchDeg` (or with neither angle nor direction, which means -45°) eases to that angle; a positive angle means the same angle below the horizon. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck current framing without demanding a reset merely because ellipsoid altitude varied. A close-up or overlap refuses without moving the camera. When the unchanged Globe View preset fits, voice says “Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’” without asking a permission question or automatically resetting. Explicit Reset/Show Globe View commands remain direct; an explicit reset-and-rotate request waits for reset success before requesting rotation. If OpenAI dispatches both tools together, rotation waits for earlier same-response resets and is skipped if any fails or is cancelled. OpenAI rejects later tool calls in the same response after a globe refusal, including calls arriving while globe admission is pending, and preserves response-scoped `tool_choice: none` for the refusal follow-up; Gemini rejects further tool execution, including queued sibling calls, until fresh typed input or admitted speech. Model interruption, advisory captions and microphone toggles do not release that guard. There is no pending Yes/No offer. Outside the refused response, explicit-command interpretation and avoiding repeated resets remain model-guided; a distinct model-issued reset call is not an app-enforced proof of user intent. A failed standalone reset can still be retried by the model, and a reset result does not guarantee the camera has fully settled. Before suggesting a reset, a cloned camera checks the unchanged Globe View destination against the same keyhole/depth rules without moving the live camera. If that preset cannot fit, `globe-reset-unavailable` asks for further zoom and the refusal instructs further zoom instead of a reset; this also covers ultrawide views. Unsupported view geometry or non-WGS84 projection assumptions fail closed without the closer-zoom instruction. Supported camera inputs/navigation intents cancel rotation before their action. Finite radius greater than the ellipsoid's maximum radius proves whole-arc clearance at constant centre-distance; no low-altitude swept solver is needed. The unchanged Globe View reset preset remains 18,000,000 m. Terrain and buildings remain outside the guarantee. +- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. `rotate_globe` rotates the world-space camera rigidly around Earth's center using its screen-up axis, preserving geocentric range and framing. Left/right describe visible globe motion, while `rotate` turns the camera in place. `tilt` swings the camera about the ground under the crosshair (the tracked view excepted, which tilts in place), so the place stays centred and the heading is kept; pitch stays within -89° to -5°, clamped only in the direction of travel so a top-down (-90°) view can tilt up out of it. A tilt with `pitchDeg` (or with neither angle nor direction, which means -45°) eases to that angle; a positive angle means the same angle below the horizon. A voice tilt sent while a voice `fly_to_location` is still looking up or flying ("fly to London and tilt the camera" arrives as two sibling calls) waits for that flight to land, up to 30 s, so it neither cancels the flight nor gets replaced by it; if the flight is cancelled, finds nothing or times out, the tilt is not run and says so. A tilt sent before its fly-to is not reordered. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck current framing without demanding a reset merely because ellipsoid altitude varied. A close-up or overlap refuses without moving the camera. When the unchanged Globe View preset fits, voice says “Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’” without asking a permission question or automatically resetting. Explicit Reset/Show Globe View commands remain direct; an explicit reset-and-rotate request waits for reset success before requesting rotation. If OpenAI dispatches both tools together, rotation waits for earlier same-response resets and is skipped if any fails or is cancelled. OpenAI rejects later tool calls in the same response after a globe refusal, including calls arriving while globe admission is pending, and preserves response-scoped `tool_choice: none` for the refusal follow-up; Gemini rejects further tool execution, including queued sibling calls, until fresh typed input or admitted speech. Model interruption, advisory captions and microphone toggles do not release that guard. There is no pending Yes/No offer. Outside the refused response, explicit-command interpretation and avoiding repeated resets remain model-guided; a distinct model-issued reset call is not an app-enforced proof of user intent. A failed standalone reset can still be retried by the model, and a reset result does not guarantee the camera has fully settled. Before suggesting a reset, a cloned camera checks the unchanged Globe View destination against the same keyhole/depth rules without moving the live camera. If that preset cannot fit, `globe-reset-unavailable` asks for further zoom and the refusal instructs further zoom instead of a reset; this also covers ultrawide views. Unsupported view geometry or non-WGS84 projection assumptions fail closed without the closer-zoom instruction. Supported camera inputs/navigation intents cancel rotation before their action. Finite radius greater than the ellipsoid's maximum radius proves whole-arc clearance at constant centre-distance; no low-altitude swept solver is needed. The unchanged Globe View reset preset remains 18,000,000 m. Terrain and buildings remain outside the guarantee. - **The route dolly is shaped, not linear** (2026-08-20). A trapezoid speed profile (smoothstep up, cruise, smoothstep down, distance taken as the closed-form integral) eases both ends without changing the pace — duration is still `totalM / ROUTE_M_S[speed]`, with one exception: a 0.5 s minimum keeps a degenerate route from being an instant teleport, so routes under 10 m (slow) / 20 m (normal) / 45 m (fast) fly SLOWER than the speed word, never faster. Turns bank up to **10°** (a 90° street corner settles near 7.5°), measured as a triangular pulse over a 4 s window centred on the camera so the roll leads in and unwinds after. Altitude breathes ±20 m around the 260 m mean and lifts up to 26 m into turns. Pitch is LOCKED at −32°; heading comes from a gaze that leads the path by 6.5 s of travel. `prefers-reduced-motion` zeroes the roll and the altitude shaping and keeps the easing. - **Invariants — do not "fix" these.** (1) **Every** release levels the roll: completion unwinds it through the ease-out envelope, and `interruptCameraMotion` zeroes it synchronously (heading/pitch/position preserved). Cesium keeps the last up vector it was handed, so skipping this leaves the user holding a tilted horizon. (2) Heading is interpolated as an ANGLE about the local up, never as a Cartesian lerp — a lerp cannot cross an antipodal pair, so an out-and-back route looked backwards for the entire return leg; `signedTurnRad` branches the exact-180° case deterministically because the cross product's sign there is a floating-point coin toss. (3) A COLD floor cell is missing data, not flat ground: route vertices carry height 0, so trusting them flew a mountain corridor at 260 m above the ELLIPSOID. The corridor warm is fire-and-forget, so the dolly ARMS — camera untouched, no teleport onto the route — for up to 1.2 s waiting for real floor data, falls back to a rendered-mesh probe (`scene.sampleHeight`) when the DEM stays cold — latched to ONE firing per flight, for exactly the cells the cache could not answer, so a route costs at most 8 `sampleHeight` calls however long it arms — and otherwise holds the launch altitude for the whole route. A corridor is only RESOLVED when every cell is accounted for: one warm cell says nothing about the ground under the other seven, and treating it as an answer let the dolly descend to 460 m over a 1,600 m rendered surface. The probe reads the rendered surface at the current LOD (rooftops and primitives included) — a better estimate than nothing, not a guaranteed upper bound. Never descend blind. The floor is SMOOTHED in both directions for the ride while the hard clearance clamp reads the RAW sample, so a cell boundary cannot pop the eye but a cliff is still cleared on the frame it is seen. A pre-departure floor is adopted whole; one arriving mid-flight is eased onto, and the eye's descent rate is capped. - 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). diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index fbb6cc5ce..985dece53 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -276,6 +276,10 @@ export function createGevActionRunner({ ? { ...SPEECH_BUILDERS, ...speechBuilders } : SPEECH_BUILDERS; const _layerEnabledAt = new Map(); + // The latest voice flight. A tilt in the same reply as "fly to X" waits for + // it to land: run at once, its navigation cancelled the flight, or the + // flight (still geocoding) then replaced the tilt. + let voiceFlight = null; let analystEngine; const resolveRegionRing = (name, signal) => annotationResolver.resolveRegionRingForQuery(name, signal, placeSearch); @@ -1103,7 +1107,10 @@ export function createGevActionRunner({ runOptions, ); } - return flyToRequestedLocation(viewer, args, { + const flight = trackVoiceFlight(); + voiceFlight = flight; + const flown = flyToRequestedLocation(viewer, args, { + onFlightEnd: flight.settle, onProgress: reportProgress, placeSearch, searchNavigation, @@ -1131,6 +1138,14 @@ export function createGevActionRunner({ if (viewer.trackedEntity) stopAllTracking(viewer, dataManager); }, }); + // No flight started (not found, refused, cancelled): nothing to wait for. + flown.then( + (result) => { + if (result?.ok !== true) flight.settle('failed'); + }, + () => flight.settle('failed'), + ); + return flown; } if (name === 'adjust_camera_zoom') { @@ -1173,6 +1188,23 @@ export function createGevActionRunner({ } if (name === 'move_camera') { + if ( + String(args.motion || '').toLowerCase() === 'tilt' && + voiceFlight && + !voiceFlight.done + ) { + const status = await waitForVoiceFlight(voiceFlight, runOptions.signal); + if (!current()) + return { ok: false, action: 'move_camera', cancelled: true }; + if (status !== 'arrived') + return { + ok: false, + action: 'move_camera', + motion: 'tilt', + error: + 'Not run: the flight this tilt was waiting for did not arrive.', + }; + } return moveCamera(args, (navigate, releaseOptions) => runManagedVoiceNavigation( styleManager, @@ -2690,6 +2722,42 @@ async function frameOverhead(viewer, dataManager, styleManager, args = {}) { } /** Run one validated voice camera mutation through the UI-owned authority seam. */ +/** A voice flight's end: 'arrived', 'cancelled' or 'failed', settled once. */ +function trackVoiceFlight() { + let resolve; + const flight = { + done: false, + ended: new Promise((settle) => { + resolve = settle; + }), + settle: (status) => { + if (flight.done) return; + flight.done = true; + resolve(status); + }, + }; + return flight; +} + +/** Longest a tilt waits for a flight (geocode plus flight) before giving up. */ +const VOICE_FLIGHT_WAIT_MS = 30_000; + +/** Wait for a voice flight to end; 'timeout' or 'aborted' if it does not. */ +function waitForVoiceFlight(flight, signal) { + if (signal?.aborted) return Promise.resolve('aborted'); + return new Promise((resolve) => { + const finish = (status) => { + clearTimeout(timer); + signal?.removeEventListener?.('abort', onAbort); + resolve(status); + }; + const onAbort = () => finish('aborted'); + const timer = setTimeout(() => finish('timeout'), VOICE_FLIGHT_WAIT_MS); + signal?.addEventListener?.('abort', onAbort, { once: true }); + flight.ended.then(finish); + }); +} + function runManagedVoiceNavigation( styleManager, noun, @@ -3252,6 +3320,7 @@ async function flyToRequestedLocation( runImmediate = null, beginDeferred = null, reassertDeferred = null, + onFlightEnd = null, } = {}, ) { const requestedRangeM = Number(args.rangeM); @@ -3275,12 +3344,19 @@ async function flyToRequestedLocation( settleArrival = resolve; }) : null; - const arrivalHooks = arrival - ? { - onComplete: () => settleArrival?.('arrived'), - onCancel: () => settleArrival?.('cancelled'), - } - : {}; + const arrivalHooks = + arrival || onFlightEnd + ? { + onComplete: () => { + settleArrival?.('arrived'); + onFlightEnd?.('arrived'); + }, + onCancel: () => { + settleArrival?.('cancelled'); + onFlightEnd?.('cancelled'); + }, + } + : {}; const afterArrival = async (result, label) => { if (!arrival || result?.ok !== true) return result; onProgress('fly', label); diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index f6bccdfa9..ddf29babe 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -217,6 +217,129 @@ test('dependent voice navigation waits for the destination viewport to arrive', assert.equal(result.arrived, true); }); +// "fly to London and tilt the camera" arrives as two sibling calls. +// Run at once, the tilt's navigation cancelled the flight; with a geocoded +// place, the late flight replaced the tilt. +function tiltAfterFlightHarness() { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + const harness = createVoiceNavigationHarness(); + const { viewer, styleManager } = harness; + const flights = []; + viewer.camera.flyTo = (options) => { + harness.order.push('fly'); + flights.push(options); + }; + styleManager.runImmediateLocationNavigation = (navigate) => ( + styleManager.runImmediateNavigation('location', navigate) + ); + return { ...harness, flights }; +} + +const settledYet = (promise) => { + let settled = false; + promise.then(() => { settled = true; }); + return () => settled; +}; + +test('a tilt in the same reply as a fly-to waits for the flight to land', async () => { + const { viewer, styleManager, order, flights } = tiltAfterFlightHarness(); + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + }); + const fly = await runner('fly_to_location', { locationId: 'austin' }); + assert.equal(fly.ok, true); + const tilt = runner('move_camera', { motion: 'tilt', pitchDeg: -45 }); + const tiltDone = settledYet(tilt); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(tiltDone(), false, 'the tilt must wait while the camera flies'); + assert.equal( + order.slice(order.indexOf('fly')).includes('cancel'), + false, + 'the tilt must not cancel the flight', + ); + flights[0].complete(); + const result = await tilt; + assert.equal(result.ok, true); + assert.equal(result.motion, 'tilt'); +}); + +test('a tilt waits through a geocoded fly-to search, then for its flight', async () => { + const { viewer, styleManager } = tiltAfterFlightHarness(); + let finishSearch; + let landFlight; + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + searchNavigation: (_viewer, query, options) => new Promise((resolve) => { + finishSearch = () => { + landFlight = options.onComplete; + resolve({ label: query, navigationMode: 'city-overview' }); + }; + }), + }); + const fly = runner('fly_to_location', { query: 'Somewhere Far' }); + const tilt = runner('move_camera', { motion: 'tilt', pitchDeg: -45 }); + const tiltDone = settledYet(tilt); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(tiltDone(), false, 'the tilt must wait for the place lookup'); + finishSearch(); + assert.equal((await fly).ok, true); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(tiltDone(), false, 'the tilt must wait for the flight too'); + landFlight(); + assert.equal((await tilt).ok, true); +}); + +test('a tilt waiting on a flight that never lands is not run', async () => { + const { viewer, styleManager, flights } = tiltAfterFlightHarness(); + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + }); + await runner('fly_to_location', { locationId: 'austin' }); + const tilt = runner('move_camera', { motion: 'tilt', pitchDeg: -45 }); + flights[0].cancel(); + const result = await tilt; + assert.equal(result.ok, false); + assert.match(result.error, /did not arrive/); +}); + +test('a tilt beside a fly-to that found nothing is not run', async () => { + const { viewer, styleManager } = tiltAfterFlightHarness(); + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + searchNavigation: async () => null, + }); + const fly = runner('fly_to_location', { query: 'Nowhere' }); + const tilt = runner('move_camera', { motion: 'tilt', pitchDeg: -45 }); + assert.equal((await fly).ok, false); + // Nothing flew: the tilt answers at once and honestly, never at the old view. + const result = await tilt; + assert.equal(result.ok, false); + assert.match(result.error, /did not arrive/); + // A later, separate tilt is not held by the failed lookup. + assert.equal((await runner('move_camera', { motion: 'tilt', pitchDeg: -45 })).ok, true); +}); + +test('a tilt with no voice flight pending runs at once', async () => { + const { viewer, styleManager, flights } = tiltAfterFlightHarness(); + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + }); + await runner('fly_to_location', { locationId: 'austin' }); + flights[0].complete(); + const result = await runner('move_camera', { motion: 'tilt', pitchDeg: -45 }); + assert.equal(result.ok, true); +}); + test('nearest-aircraft voice action serializes layer enable, arrival, refresh, airborne query, and selection', async () => { globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; const { viewer, styleManager } = createVoiceNavigationHarness(); From 75000cf6a9f71fb8f534a47c5844160ce6b95e1c Mon Sep 17 00:00:00 2001 From: Manjunath Date: Tue, 3 Nov 2026 16:42:50 +0530 Subject: [PATCH 11/71] Hold a voice orbit, pan or rotate until its fly-to lands "Fly to Paris and orbit it" reaches the app as two sibling tool calls, which OpenAI runs at once. With a place that needs a lookup, the orbit started over the old view and the late flight then replaced it; with a known place, pan and rotate cancelled the flight already in the air. Orbit, pan and rotate now wait for a pending voice flight the same way tilt does: up to 30 s, and not run (saying so) if the flight is cancelled, finds nothing or times out. Stop and rotate_globe do not wait. Orbit keeps its own arming for flights the voice runner does not track. --- docs/CURRENT-STATE.md | 2 +- src/cameraVerbs.js | 8 +-- src/voice/gevActions.js | 24 ++++++--- src/voice/gevActions.test.mjs | 93 +++++++++++++++++++++++++++++++++++ 4 files changed, 117 insertions(+), 10 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 2730ae8de..6f20b01ad 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5364,7 +5364,7 @@ and unreachable upstream (502/504) separately from road geometry. > branch: **2,281 on Node 25.6.1** (allocation suites skipped) = 2,255 on > `main` + 26 route pins; the same tree on Node 24 reports 2,295. -- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. `rotate_globe` rotates the world-space camera rigidly around Earth's center using its screen-up axis, preserving geocentric range and framing. Left/right describe visible globe motion, while `rotate` turns the camera in place. `tilt` swings the camera about the ground under the crosshair (the tracked view excepted, which tilts in place), so the place stays centred and the heading is kept; pitch stays within -89° to -5°, clamped only in the direction of travel so a top-down (-90°) view can tilt up out of it. A tilt with `pitchDeg` (or with neither angle nor direction, which means -45°) eases to that angle; a positive angle means the same angle below the horizon. A voice tilt sent while a voice `fly_to_location` is still looking up or flying ("fly to London and tilt the camera" arrives as two sibling calls) waits for that flight to land, up to 30 s, so it neither cancels the flight nor gets replaced by it; if the flight is cancelled, finds nothing or times out, the tilt is not run and says so. A tilt sent before its fly-to is not reordered. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck current framing without demanding a reset merely because ellipsoid altitude varied. A close-up or overlap refuses without moving the camera. When the unchanged Globe View preset fits, voice says “Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’” without asking a permission question or automatically resetting. Explicit Reset/Show Globe View commands remain direct; an explicit reset-and-rotate request waits for reset success before requesting rotation. If OpenAI dispatches both tools together, rotation waits for earlier same-response resets and is skipped if any fails or is cancelled. OpenAI rejects later tool calls in the same response after a globe refusal, including calls arriving while globe admission is pending, and preserves response-scoped `tool_choice: none` for the refusal follow-up; Gemini rejects further tool execution, including queued sibling calls, until fresh typed input or admitted speech. Model interruption, advisory captions and microphone toggles do not release that guard. There is no pending Yes/No offer. Outside the refused response, explicit-command interpretation and avoiding repeated resets remain model-guided; a distinct model-issued reset call is not an app-enforced proof of user intent. A failed standalone reset can still be retried by the model, and a reset result does not guarantee the camera has fully settled. Before suggesting a reset, a cloned camera checks the unchanged Globe View destination against the same keyhole/depth rules without moving the live camera. If that preset cannot fit, `globe-reset-unavailable` asks for further zoom and the refusal instructs further zoom instead of a reset; this also covers ultrawide views. Unsupported view geometry or non-WGS84 projection assumptions fail closed without the closer-zoom instruction. Supported camera inputs/navigation intents cancel rotation before their action. Finite radius greater than the ellipsoid's maximum radius proves whole-arc clearance at constant centre-distance; no low-altitude swept solver is needed. The unchanged Globe View reset preset remains 18,000,000 m. Terrain and buildings remain outside the guarantee. +- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. `rotate_globe` rotates the world-space camera rigidly around Earth's center using its screen-up axis, preserving geocentric range and framing. Left/right describe visible globe motion, while `rotate` turns the camera in place. `tilt` swings the camera about the ground under the crosshair (the tracked view excepted, which tilts in place), so the place stays centred and the heading is kept; pitch stays within -89° to -5°, clamped only in the direction of travel so a top-down (-90°) view can tilt up out of it. A tilt with `pitchDeg` (or with neither angle nor direction, which means -45°) eases to that angle; a positive angle means the same angle below the horizon. A voice tilt, orbit, pan or rotate sent while a voice `fly_to_location` is still looking up or flying ("fly to London and tilt the camera" or "fly to Paris and orbit it" arrives as two sibling calls) waits for that flight to land, up to 30 s, so it neither cancels the flight nor gets replaced by it; if the flight is cancelled, finds nothing or times out, the motion is not run and says so. `stop` and `rotate_globe` do not wait, and a motion sent before its fly-to is not reordered. Orbit still arms (`armed: 'waiting-for-arrival'`) when it starts during any other camera flight, and captures its target once the tween queue has been quiet for 0.8 s. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck current framing without demanding a reset merely because ellipsoid altitude varied. A close-up or overlap refuses without moving the camera. When the unchanged Globe View preset fits, voice says “Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’” without asking a permission question or automatically resetting. Explicit Reset/Show Globe View commands remain direct; an explicit reset-and-rotate request waits for reset success before requesting rotation. If OpenAI dispatches both tools together, rotation waits for earlier same-response resets and is skipped if any fails or is cancelled. OpenAI rejects later tool calls in the same response after a globe refusal, including calls arriving while globe admission is pending, and preserves response-scoped `tool_choice: none` for the refusal follow-up; Gemini rejects further tool execution, including queued sibling calls, until fresh typed input or admitted speech. Model interruption, advisory captions and microphone toggles do not release that guard. There is no pending Yes/No offer. Outside the refused response, explicit-command interpretation and avoiding repeated resets remain model-guided; a distinct model-issued reset call is not an app-enforced proof of user intent. A failed standalone reset can still be retried by the model, and a reset result does not guarantee the camera has fully settled. Before suggesting a reset, a cloned camera checks the unchanged Globe View destination against the same keyhole/depth rules without moving the live camera. If that preset cannot fit, `globe-reset-unavailable` asks for further zoom and the refusal instructs further zoom instead of a reset; this also covers ultrawide views. Unsupported view geometry or non-WGS84 projection assumptions fail closed without the closer-zoom instruction. Supported camera inputs/navigation intents cancel rotation before their action. Finite radius greater than the ellipsoid's maximum radius proves whole-arc clearance at constant centre-distance; no low-altitude swept solver is needed. The unchanged Globe View reset preset remains 18,000,000 m. Terrain and buildings remain outside the guarantee. - **The route dolly is shaped, not linear** (2026-08-20). A trapezoid speed profile (smoothstep up, cruise, smoothstep down, distance taken as the closed-form integral) eases both ends without changing the pace — duration is still `totalM / ROUTE_M_S[speed]`, with one exception: a 0.5 s minimum keeps a degenerate route from being an instant teleport, so routes under 10 m (slow) / 20 m (normal) / 45 m (fast) fly SLOWER than the speed word, never faster. Turns bank up to **10°** (a 90° street corner settles near 7.5°), measured as a triangular pulse over a 4 s window centred on the camera so the roll leads in and unwinds after. Altitude breathes ±20 m around the 260 m mean and lifts up to 26 m into turns. Pitch is LOCKED at −32°; heading comes from a gaze that leads the path by 6.5 s of travel. `prefers-reduced-motion` zeroes the roll and the altitude shaping and keeps the easing. - **Invariants — do not "fix" these.** (1) **Every** release levels the roll: completion unwinds it through the ease-out envelope, and `interruptCameraMotion` zeroes it synchronously (heading/pitch/position preserved). Cesium keeps the last up vector it was handed, so skipping this leaves the user holding a tilted horizon. (2) Heading is interpolated as an ANGLE about the local up, never as a Cartesian lerp — a lerp cannot cross an antipodal pair, so an out-and-back route looked backwards for the entire return leg; `signedTurnRad` branches the exact-180° case deterministically because the cross product's sign there is a floating-point coin toss. (3) A COLD floor cell is missing data, not flat ground: route vertices carry height 0, so trusting them flew a mountain corridor at 260 m above the ELLIPSOID. The corridor warm is fire-and-forget, so the dolly ARMS — camera untouched, no teleport onto the route — for up to 1.2 s waiting for real floor data, falls back to a rendered-mesh probe (`scene.sampleHeight`) when the DEM stays cold — latched to ONE firing per flight, for exactly the cells the cache could not answer, so a route costs at most 8 `sampleHeight` calls however long it arms — and otherwise holds the launch altitude for the whole route. A corridor is only RESOLVED when every cell is accounted for: one warm cell says nothing about the ground under the other seven, and treating it as an answer let the dolly descend to 460 m over a 1,600 m rendered surface. The probe reads the rendered surface at the current LOD (rooftops and primitives included) — a better estimate than nothing, not a guaranteed upper bound. Never descend blind. The floor is SMOOTHED in both directions for the ride while the hard clearance clamp reads the RAW sample, so a cell boundary cannot pop the eye but a cliff is still cleared on the frame it is seen. A pre-departure floor is adopted whole; one arriving mid-flight is eased onto, and the eye's descent rate is capped. - 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). diff --git a/src/cameraVerbs.js b/src/cameraVerbs.js index 3898acaa7..0b0f06792 100644 --- a/src/cameraVerbs.js +++ b/src/cameraVerbs.js @@ -1747,9 +1747,11 @@ export function moveCamera(args = {}, runNavigation = null) { } if (motion === 'orbit') { if (_viewer.scene.tweens.length > 0) { - // A camera flight is in progress ("fly to X and orbit it") — ALWAYS arm - // and capture at the destination; a target existing right now is - // meaningless (the globe is always under the crosshair). + // A camera flight is in progress — ALWAYS arm and capture at the + // destination; a target existing right now is meaningless (the globe + // is always under the crosshair). The voice runner already holds an + // orbit until its own fly-to lands; this covers any other + // flight. setActiveMotion({ kind: 'orbit', direction: direction || 'right', diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index 985dece53..6d9411dfe 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -1188,8 +1188,9 @@ export function createGevActionRunner({ } if (name === 'move_camera') { + const motion = String(args.motion || '').toLowerCase(); if ( - String(args.motion || '').toLowerCase() === 'tilt' && + VOICE_FLIGHT_WAITING_MOTIONS.has(motion) && voiceFlight && !voiceFlight.done ) { @@ -1200,9 +1201,8 @@ export function createGevActionRunner({ return { ok: false, action: 'move_camera', - motion: 'tilt', - error: - 'Not run: the flight this tilt was waiting for did not arrive.', + motion, + error: `Not run: the flight this ${motion} was waiting for did not arrive.`, }; } return moveCamera(args, (navigate, releaseOptions) => @@ -2721,7 +2721,6 @@ async function frameOverhead(viewer, dataManager, styleManager, args = {}) { ); } -/** Run one validated voice camera mutation through the UI-owned authority seam. */ /** A voice flight's end: 'arrived', 'cancelled' or 'failed', settled once. */ function trackVoiceFlight() { let resolve; @@ -2739,9 +2738,21 @@ function trackVoiceFlight() { return flight; } -/** Longest a tilt waits for a flight (geocode plus flight) before giving up. */ +/** Longest a motion waits for a flight (geocode plus flight) before giving up. */ const VOICE_FLIGHT_WAIT_MS = 30_000; +/** + * Motions that act on the place the camera ends up at, so one sent beside a + * fly-to waits for it. Stop is not one, and rotate_globe turns the + * whole Earth from Globe View rather than at a place. + */ +const VOICE_FLIGHT_WAITING_MOTIONS = new Set([ + 'tilt', + 'orbit', + 'pan', + 'rotate', +]); + /** Wait for a voice flight to end; 'timeout' or 'aborted' if it does not. */ function waitForVoiceFlight(flight, signal) { if (signal?.aborted) return Promise.resolve('aborted'); @@ -2758,6 +2769,7 @@ function waitForVoiceFlight(flight, signal) { }); } +/** Run one validated voice camera mutation through the UI-owned authority seam. */ function runManagedVoiceNavigation( styleManager, noun, diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index ddf29babe..ee078ac57 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -340,6 +340,99 @@ test('a tilt with no voice flight pending runs at once', async () => { assert.equal(result.ok, true); }); +// Orbit, pan and rotate also act at the place the camera ends up, so "fly to +// Paris and orbit it" holds the orbit the same way. +for (const motion of [ + { motion: 'orbit' }, + { motion: 'pan', direction: 'left' }, + { motion: 'rotate', direction: 'right' }, +]) { + test(`${motion.motion} in the same reply as a fly-to waits for the flight to land`, async () => { + const { viewer, styleManager, order, flights } = tiltAfterFlightHarness(); + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + }); + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + const moved = runner('move_camera', motion); + const movedDone = settledYet(moved); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(movedDone(), false, `the ${motion.motion} must wait while the camera flies`); + assert.equal( + order.slice(order.indexOf('fly')).includes('cancel'), + false, + `the ${motion.motion} must not cancel the flight`, + ); + flights[0].complete(); + const result = await moved; + assert.equal(result.ok, true); + assert.equal(result.motion, motion.motion); + interruptCameraMotion('test-cleanup'); + }); + + test(`${motion.motion} waits through a geocoded fly-to search, then for its flight`, async () => { + const { viewer, styleManager } = tiltAfterFlightHarness(); + let finishSearch; + let landFlight; + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + searchNavigation: (_viewer, query, options) => new Promise((resolve) => { + finishSearch = () => { + landFlight = options.onComplete; + resolve({ label: query, navigationMode: 'city-overview' }); + }; + }), + }); + const fly = runner('fly_to_location', { query: 'Somewhere Far' }); + const moved = runner('move_camera', motion); + const movedDone = settledYet(moved); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(movedDone(), false, `the ${motion.motion} must wait for the place lookup`); + assert.equal(getActiveCameraMotion(), null, 'nothing moves before the flight'); + finishSearch(); + assert.equal((await fly).ok, true); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(movedDone(), false, `the ${motion.motion} must wait for the flight too`); + landFlight(); + assert.equal((await moved).ok, true); + assert.equal(getActiveCameraMotion()?.kind, motion.motion); + interruptCameraMotion('test-cleanup'); + }); + + test(`${motion.motion} waiting on a flight that never lands is not run`, async () => { + const { viewer, styleManager, flights } = tiltAfterFlightHarness(); + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + }); + await runner('fly_to_location', { locationId: 'austin' }); + const moved = runner('move_camera', motion); + flights[0].cancel(); + const result = await moved; + assert.equal(result.ok, false); + assert.equal(result.motion, motion.motion); + assert.match(result.error, new RegExp(`this ${motion.motion} was waiting for did not arrive`)); + assert.equal(getActiveCameraMotion(), null); + }); +} + +test('stop does not wait for a voice flight', async () => { + const { viewer, styleManager } = tiltAfterFlightHarness(); + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + }); + await runner('fly_to_location', { locationId: 'austin' }); + const result = await runner('move_camera', { motion: 'stop' }); + assert.equal(result.ok, true); + assert.equal(result.motion, 'stop'); +}); + test('nearest-aircraft voice action serializes layer enable, arrival, refresh, airborne query, and selection', async () => { globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; const { viewer, styleManager } = createVoiceNavigationHarness(); From 2ac73519ec71a5c54da96e3544f18d96985a35d2 Mon Sep 17 00:00:00 2001 From: Manjunath Date: Tue, 3 Nov 2026 16:43:01 +0530 Subject: [PATCH 12/71] Add changelog fragment for voice camera tilt (#1520) --- changelog.d/1520-voice-camera-tilt.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 changelog.d/1520-voice-camera-tilt.md diff --git a/changelog.d/1520-voice-camera-tilt.md b/changelog.d/1520-voice-camera-tilt.md new file mode 100644 index 000000000..87d2aef3a --- /dev/null +++ b/changelog.d/1520-voice-camera-tilt.md @@ -0,0 +1,5 @@ +- Voice "tilt the camera" works from the top-down view a fly-to lands on: the + camera swings about the place under the crosshair, keeps its heading, and can + tilt to a spoken angle ("tilt 45 degrees") or a default oblique view. A tilt, + orbit, pan or rotate said with a fly-to ("fly to Paris and orbit it") now + waits for the flight to land (Manjunath, @manjunath22466, #1520). From 415f23739f7c81dc9f798fb5107f0e9e1044b2bf Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 3 Nov 2026 16:26:55 +0400 Subject: [PATCH 13/71] Name the reporting provider in Contacts and Cockpit counts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Cockpit Contact counts named fixed providers (OpenSky, adsb.lol, AISStream) while the Layers panel and Contacts named the provider the last snapshot reported, and the vessels layer kept that name out of getStats(), so Contacts always said AISStream. Each cohort now carries the provider and whether it answered as a fallback or from a stale snapshot, and both views show it the way the Layers row does ("adsb.lol · FALLBACK"). Fixes HAL-721. --- docs/CURRENT-STATE.md | 1 + scripts/package-boundaries.json | 7 ++++ src/layers/awareness/model.js | 1 + src/layers/awareness/panel.js | 3 +- src/layers/awareness/panel.test.mjs | 38 +++++++++++++++++++++ src/layers/awareness/queries.test.mjs | 26 +++++++++++++++ src/layers/awareness/subject.js | 12 +++++++ src/layers/vessels/ownership.test.mjs | 14 ++++++++ src/layers/vessels/queries.js | 2 ++ src/ui/cockpitContext.js | 3 ++ src/ui/cockpitContext.test.mjs | 46 ++++++++++++++++++++++++++ src/ui/cockpitContextState.js | 5 ++- src/ui/layerNames.js | 14 ++++++++ src/ui/layerNames.test.mjs | 17 ++++++++++ src/ui/views/panels/CockpitContext.vue | 8 ++--- 15 files changed, 191 insertions(+), 6 deletions(-) create mode 100644 src/layers/awareness/panel.test.mjs diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 35d81dd05..9f5a40e63 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4965,6 +4965,7 @@ silently demoting every later lookup for the session. - Enabled Data Layer controls report normalized feed health on the button (`LOADING`, `DEGRADED`, `STALE`, `FALLBACK`, or `UNAVAILABLE`) while the metadata line retains the source and reason. A partial CelesTrak group failure keeps the usable catalog and reports `DEGRADED`; a total outage keeps last-good catalog data visible but reports `UNAVAILABLE`. - On activation it focuses the nearest currently observed aircraft across the civilian and military feeds, with military winning an exact distance tie; if none are available, it focuses an observed AIS vessel. The aircraft search is deliberately uncapped and one refresh-tick retry handles initially empty feeds. This is an attention-priority navigation shortcut, not a high-risk, affiliation, or threat classification. - A selected aircraft, AIS vessel, or mapped installation gets a 250 km **context window** with nearby cohort counts, nearest examples, source labels, and stale/unavailable reasons. It emits `NEARBY` or `UNKNOWN`; no detection, engagement, affiliation, or sensor-activity conclusion is calculated. +- Contacts rows and the Cockpit Contact counts name each cohort's provider as its layer row does: the source the last snapshot reported, marked `FALLBACK` or `STALE` in the same cases. A flight snapshot answered by the adsb.lol regional fallback reads `adsb.lol · FALLBACK` everywhere, never `OpenSky`. - For a selected live aircraft or vessel, the context window refreshes from the existing tracker/feed position every 750 ms, so nearest distances and cohort counts follow the subject without introducing a duplicate poll loop. - Aircraft cohort membership uses every locally loaded, selectable contact inside the 250 km window, including a plane hidden only by horizon culling or because its 3D model owns the visual. Counts and navigation therefore do not change with the current camera angle or billboard/model handoff. - Nearby examples in the context panel are focus controls: they use their owning layer's existing selection/tracking path, then frame that contact. Static-installation distances use ellipsoidal surface distance so the count matches the ground-projected context disk. diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 6d55213e7..f5529e731 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -871,6 +871,7 @@ "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", + "src/ui/layerNames.js", "src/ui/unitLabels.js" ], "external": ["cesium"] @@ -1713,6 +1714,10 @@ "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", + "src/data/feedState.js", + "src/loadingFeedback.js", + "src/data/installationFeedback.js", + "src/ui/layerNames.js", "src/ui/unitLabels.js" ], "external": ["cesium"] @@ -3208,6 +3213,8 @@ "application-layer-construction": { "exports": ["./application/layers"], "modules": [ + "src/data/feedState.js", + "src/loadingFeedback.js", "src/sources/adsbFields.js", "src/data/headingDegrees.js", "src/data/distanceFormat.js", diff --git a/src/layers/awareness/model.js b/src/layers/awareness/model.js index 1a399c5af..1ff2c93cf 100644 --- a/src/layers/awareness/model.js +++ b/src/layers/awareness/model.js @@ -196,6 +196,7 @@ export function createModel({ state: layerState, services, parts, source }) { id: cohort.id, label: cohort.label, source: cohort.source, + sourceStatus: cohort.sourceStatus || null, coverage: cohort.coverage || null, relationship: cohort.summary.relationship, count: cohort.summary.count, diff --git a/src/layers/awareness/panel.js b/src/layers/awareness/panel.js index c0c71ed01..5396a0390 100644 --- a/src/layers/awareness/panel.js +++ b/src/layers/awareness/panel.js @@ -8,6 +8,7 @@ import { t, translateKnown, } from '../../i18n/index.js'; +import { sourceStatusLabel } from '../../ui/layerNames.js'; import { formatQuantity } from '../../ui/unitLabels.js'; import { AWARENESS_PAGE_SIZE, AWARENESS_PAGE_ROTATE_MS } from './policy.js'; import { distanceParts } from '../../data/distanceFormat.js'; @@ -150,7 +151,7 @@ export function createPanel({ state: layerState, parts }) { relationship: summary.relationship.toLowerCase(), label: cohort.label, count: `${presentAwarenessCount(summary)}${pageLabel}`, - detail: `${cohort.source}${coverage} · ${presentAwarenessReason(summary)}`, + detail: `${sourceStatusLabel(cohort.source, cohort.sourceStatus)}${coverage} · ${presentAwarenessReason(summary)}`, nearest, }; } diff --git a/src/layers/awareness/panel.test.mjs b/src/layers/awareness/panel.test.mjs new file mode 100644 index 000000000..eaaac70e9 --- /dev/null +++ b/src/layers/awareness/panel.test.mjs @@ -0,0 +1,38 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createPanel } from './panel.js'; + +test('a Contacts row names its provider as the Layers panel does, with its feed state', () => { + const cohort = (id, source, sourceStatus) => ({ + id, + label: id, + source, + sourceStatus, + summary: { relationship: 'OBSERVED', count: 1, nearest: [], reason: 'observed' }, + }); + const state = { + enabled: true, + results: { + subject: { label: 'AFR38JF' }, + cohorts: [ + cohort('flights', 'adsb.lol', 'fallback'), + cohort('military', 'adsb.lol', null), + cohort('ais-live-vessels', 'Datalastic', 'stale'), + ], + }, + cohortPages: new Map(), + panelListeners: new Set(), + panelSignature: '', + navigationIndex: 0, + }; + const parts = { + navigation: { canNavigateNext: () => false }, + rendering: { hideDirectionOverlay() {} }, + }; + createPanel({ state, parts }).renderResults(); + assert.deepEqual( + state.panelView.rows.map((row) => row.detail), + ['adsb.lol · FALLBACK · observed', 'adsb.lol · observed', 'Datalastic · STALE · observed'], + ); + state.offPanelLocale?.(); +}); diff --git a/src/layers/awareness/queries.test.mjs b/src/layers/awareness/queries.test.mjs index 04e8a8982..07686dcfc 100644 --- a/src/layers/awareness/queries.test.mjs +++ b/src/layers/awareness/queries.test.mjs @@ -81,3 +81,29 @@ test('Contacts excludes square corners and measures from the current moving subj assert.equal(moved.reason, 'mapped matches within 100 km of the subject'); assert.deepEqual(installationState.contextAnchor, { latitude: 0, longitude: 0 }); }); + +test('Contacts cohorts carry the provider and whether it answered as a fallback or stale', () => { + const services = { installations: { getNearby: () => [], getStats: () => ({}) }, + flights: { getNearby: () => [] }, military: { getNearby: () => [] }, vessels: { getNearby: () => [] } }; + const parts = { navigation: { summarizeAwarenessCohortForNavigation: (items, source) => + summarizeAwarenessCohort(items, source) } }; + parts.queries = createQueries({ state: {}, services, parts }); + const subjectOwner = createSubject({ state: {}, services, parts }); + const states = { + // The OpenSky proxy answers from adsb.lol when OpenSky fails. + flights: { available: true, stats: { source: 'adsb.lol', coverage: '250nm regional fallback', count: 4, lastUpdate: 1 } }, + military: { available: true, stats: { source: 'adsb.lol', fallback: false, count: 2, lastUpdate: 1 } }, + 'ais-live-vessels': { available: true, stats: { source: 'Datalastic', stale: true, count: 3, lastUpdate: 1 } }, + 'military-installations': { available: true, stats: { coverage: { kind: 'subject', radiusM: 100000 } } }, + }; + const { cohorts } = subjectOwner.evaluateSubject({ + id: 'subject', layerId: 'flights', position: Cesium.Cartesian3.fromDegrees(0, 0, 10000), + }, states); + const byId = Object.fromEntries(cohorts.map((cohort) => [cohort.id, cohort])); + assert.equal(byId.flights.source, 'adsb.lol'); + assert.equal(byId.flights.sourceStatus, 'fallback'); + assert.equal(byId.military.sourceStatus, null); + assert.equal(byId['ais-live-vessels'].source, 'Datalastic'); + assert.equal(byId['ais-live-vessels'].sourceStatus, 'stale'); + assert.equal(byId['military-installations'].sourceStatus, null); +}); diff --git a/src/layers/awareness/subject.js b/src/layers/awareness/subject.js index 1baf54ec4..a555d332e 100644 --- a/src/layers/awareness/subject.js +++ b/src/layers/awareness/subject.js @@ -3,6 +3,7 @@ import { AWARENESS_RELATIONSHIP, } from '../../data/militaryAwarenessEngine.js'; import * as Cesium from 'cesium'; +import { layerFeedState } from '../../data/feedState.js'; import { t } from '../../i18n/index.js'; import { AWARENESS_QUERY_LIMIT, @@ -52,6 +53,13 @@ export function createSubject({ state: layerState, services, parts, source }) { const vesselsState = sourceStates['ais-live-vessels']; const installationsState = sourceStates['military-installations']; const evaluatedAt = Date.now(); + // Whether the provider answered as a fallback or from a stale snapshot. + const sourceStatus = (sourceState) => { + const feedState = layerFeedState(sourceState.stats); + return feedState === 'fallback' || feedState === 'stale' + ? feedState + : null; + }; const retainedProvenance = (id, label, sourceState, fallbackSource) => { const retainedSource = sourceState.stats.source || fallbackSource; return { @@ -94,6 +102,7 @@ export function createSubject({ state: layerState, services, parts, source }) { id: 'flights', label: t('Flights'), source: flightsState.stats.source || SOURCE_LABEL.flights, + sourceStatus: sourceStatus(flightsState), provenance: retainedProvenance( 'flights', 'Flights', @@ -113,6 +122,7 @@ export function createSubject({ state: layerState, services, parts, source }) { id: 'military', label: t('Military flights'), source: militaryState.stats.source || SOURCE_LABEL.military, + sourceStatus: sourceStatus(militaryState), provenance: retainedProvenance( 'military', 'Military flights', @@ -132,6 +142,7 @@ export function createSubject({ state: layerState, services, parts, source }) { id: 'ais-live-vessels', label: t('AIS vessels'), source: vesselsState.stats.source || SOURCE_LABEL['ais-live-vessels'], + sourceStatus: sourceStatus(vesselsState), provenance: retainedProvenance( 'ais-live-vessels', 'AIS vessels', @@ -149,6 +160,7 @@ export function createSubject({ state: layerState, services, parts, source }) { source: installationsState.stats.source || SOURCE_LABEL['military-installations'], + sourceStatus: sourceStatus(installationsState), provenance: retainedProvenance( 'military-installations', 'Mapped installations', diff --git a/src/layers/vessels/ownership.test.mjs b/src/layers/vessels/ownership.test.mjs index 4ef9b7525..a838a8099 100644 --- a/src/layers/vessels/ownership.test.mjs +++ b/src/layers/vessels/ownership.test.mjs @@ -409,3 +409,17 @@ test('empty coverage cannot hide stale, incomplete or rejected source data', asy assert.ok(layer.getStats().error, JSON.stringify(patch)); } }); + +test('vessel stats name the provider the last snapshot reported', async () => { + // HAL-721: Contacts named a fixed AIS provider while this layer showed the + // one its server reported. + const { layer } = setup({ + label: 'Vessels', + async getSnapshot() { + return snapshot([observation('111')], { source: 'Datalastic' }); + }, + }); + assert.equal(layer.getStats().source, 'Vessels'); + await layer.update(); + assert.equal(layer.getStats().source, 'Datalastic'); +}); diff --git a/src/layers/vessels/queries.js b/src/layers/vessels/queries.js index c848acc2d..48ded012b 100644 --- a/src/layers/vessels/queries.js +++ b/src/layers/vessels/queries.js @@ -548,6 +548,8 @@ export function createQueries({ // backing off, say how long until the next attempt instead of leaving // the user to guess whether anything is still happening. retryInSec: aisRetryInSec(), + // The provider the last snapshot named, as flights and military report it. + source: layer.source, }; }, }; diff --git a/src/ui/cockpitContext.js b/src/ui/cockpitContext.js index 1158c632b..ad73869db 100644 --- a/src/ui/cockpitContext.js +++ b/src/ui/cockpitContext.js @@ -1,6 +1,7 @@ /** Contact readouts and nearby-signal presentation for the Cockpit controller. */ import * as Cesium from 'cesium'; import { formatNumber, t, tn } from '../i18n/index.js'; +import { sourceStatusLabel } from './layerNames.js'; import { unitLabel } from './unitLabels.js'; import { bearingBetweenCoordinates, @@ -110,6 +111,8 @@ export function updateContext(info, heading) { if (shown) { shown.count = cohort.count === null ? '?' : String(cohort.count); shown.unknown = cohort.relationship === 'UNKNOWN'; + // The provider that answered, named as the Layers panel names it. + shown.source = sourceStatusLabel(cohort.source, cohort.sourceStatus); } if (cohort.count === null) unknownCount += 1; for (const item of cohort.nearest) nearest.push({ ...item, cohort }); diff --git a/src/ui/cockpitContext.test.mjs b/src/ui/cockpitContext.test.mjs index 47d3719c7..a8d6616d5 100644 --- a/src/ui/cockpitContext.test.mjs +++ b/src/ui/cockpitContext.test.mjs @@ -96,6 +96,13 @@ test('before the controller presents, the card shows the state it starts in', (t ), ['—', '—', '—', '—'], ); + // No provider is named before a snapshot reports one. + assert.deepEqual( + [...document.querySelectorAll('[data-context-cohort] small')].map( + (source) => source.textContent, + ), + ['—', '—', '—', '—'], + ); assert.equal(text('#cockpit-context-nearest-label'), 'NO AVAILABLE EXAMPLE'); assert.equal( $('#cockpit-context-nearest-label').hasAttribute('aria-label'), @@ -159,6 +166,45 @@ test('a snapshot fills the card and keeps classes other code sets', async (t) => assert.equal(layouts.length, 1); }); +test('each count names the provider its snapshot reports, with its feed state', async (t) => { + // HAL-721: the counters named fixed providers while the layer and the + // Contacts list named the one that answered. + const reported = snapshot(); + reported.cohorts[0].source = 'adsb.lol'; + reported.cohorts[0].sourceStatus = 'fallback'; + reported.cohorts[1].source = 'Plane Finder'; + reported.cohorts.push( + { + id: 'ais-live-vessels', + label: 'AIS vessels', + source: 'Datalastic', + sourceStatus: 'stale', + count: 3, + relationship: 'OBSERVED', + nearest: [], + }, + { + id: 'military-installations', + label: 'Mapped installations', + source: 'OpenStreetMap', + sourceStatus: null, + count: 0, + relationship: 'MAPPED', + nearest: [], + }, + ); + const { $, owner } = fixture(t, reported); + updateContext.call(owner, INFO, 0); + await nextTick(); + const source = (id) => $(`[data-context-cohort="${id}"] small`); + assert.equal(source('flights').textContent, 'adsb.lol · FALLBACK'); + assert.equal(source('military').textContent, 'Plane Finder'); + assert.equal(source('ais-live-vessels').textContent, 'Datalastic · STALE'); + assert.equal(source('military-installations').textContent, 'OpenStreetMap'); + // The tile ellipsizes; the full name stays readable on hover. + assert.equal(source('flights').title, 'adsb.lol · FALLBACK'); +}); + test('a lost contact holds its readout behind the CONTACT LOST cue', async (t) => { const { $, text, owner, layouts } = fixture(t, snapshot()); updateContext.call(owner, INFO, 0); diff --git a/src/ui/cockpitContextState.js b/src/ui/cockpitContextState.js index dafffc11f..3c2db3c5a 100644 --- a/src/ui/cockpitContextState.js +++ b/src/ui/cockpitContextState.js @@ -25,7 +25,10 @@ export function createContextState() { canPrevious: true, canNext: true, cohorts: Object.fromEntries( - COCKPIT_CONTEXT_COHORTS.map((id) => [id, { count: '—', unknown: false }]), + COCKPIT_CONTEXT_COHORTS.map((id) => [ + id, + { count: '—', unknown: false, source: '—' }, + ]), ), nearest: { label: t('NO AVAILABLE EXAMPLE'), ariaLabel: null }, distance: { text: '—', ariaLabel: null }, diff --git a/src/ui/layerNames.js b/src/ui/layerNames.js index 2c918ad95..519b3e207 100644 --- a/src/ui/layerNames.js +++ b/src/ui/layerNames.js @@ -137,3 +137,17 @@ export function sourceLabel(source) { ? source.split(' · ').map(translateKnown).join(' · ') : translateKnown(source); } + +/** + * A provider name with the feed state it answered in, so a fallback or stale + * snapshot does not read as the live primary feed. + * @param {string} source English source name. + * @param {'fallback'|'stale'|null} status Feed state from `layerFeedState`. + * @returns {string} + */ +export function sourceStatusLabel(source, status) { + const name = sourceLabel(source); + if (status === 'fallback') return `${name} · ${t('FALLBACK')}`; + if (status === 'stale') return `${name} · ${t('STALE')}`; + return name; +} diff --git a/src/ui/layerNames.test.mjs b/src/ui/layerNames.test.mjs index df15c79bf..e1815e477 100644 --- a/src/ui/layerNames.test.mjs +++ b/src/ui/layerNames.test.mjs @@ -7,6 +7,7 @@ import { LAYER_NAME_KEYS, PANEL_LABEL_KEYS, sourceLabel, + sourceStatusLabel, } from './layerNames.js'; // Layers whose module name is a proper name and stays as it is in every @@ -51,3 +52,19 @@ for (const locale of ['es', 'ru']) { } }); } + +test('a provider name carries the fallback or stale state it answered in', () => { + assert.equal(sourceStatusLabel('Plane Finder', null), 'Plane Finder'); + assert.equal(sourceStatusLabel('adsb.lol', 'fallback'), 'adsb.lol · FALLBACK'); + assert.equal(sourceStatusLabel('OpenSky Network', 'stale'), 'OpenSky Network · STALE'); + setLocale('ru'); + try { + assert.equal( + sourceStatusLabel('adsb.lol', 'fallback'), + `adsb.lol · ${t('FALLBACK')}`, + ); + assert.notEqual(t('FALLBACK'), 'FALLBACK'); + } finally { + setLocale('en'); + } +}); diff --git a/src/ui/views/panels/CockpitContext.vue b/src/ui/views/panels/CockpitContext.vue index ed6df8362..2374deb3e 100644 --- a/src/ui/views/panels/CockpitContext.vue +++ b/src/ui/views/panels/CockpitContext.vue @@ -49,10 +49,10 @@ const weatherLabel = computed(() => {{ t('CONTEXT ONLY') }}
-
{{ t('FLT') }}{{ c.cohorts.flights.count }}OpenSky
-
{{ t('MIL') }}{{ c.cohorts.military.count }}adsb.lol
-
AIS{{ c.cohorts['ais-live-vessels'].count }}AISStream
-
{{ t('SITE') }}{{ c.cohorts['military-installations'].count }}OpenStreetMap
+
{{ t('FLT') }}{{ c.cohorts.flights.count }}{{ c.cohorts.flights.source }}
+
{{ t('MIL') }}{{ c.cohorts.military.count }}{{ c.cohorts.military.source }}
+
AIS{{ c.cohorts['ais-live-vessels'].count }}{{ c.cohorts['ais-live-vessels'].source }}
+
{{ t('SITE') }}{{ c.cohorts['military-installations'].count }}{{ c.cohorts['military-installations'].source }}
From 490c8d1769cc96fd9c93c4daff7986028e9a02e7 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 3 Nov 2026 16:27:25 +0400 Subject: [PATCH 14/71] Add the changelog entry for #1521 --- changelog.d/1521-provider-labels.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelog.d/1521-provider-labels.md diff --git a/changelog.d/1521-provider-labels.md b/changelog.d/1521-provider-labels.md new file mode 100644 index 000000000..d921d9d7b --- /dev/null +++ b/changelog.d/1521-provider-labels.md @@ -0,0 +1,4 @@ +- Name the provider that answered in the Cockpit Contact counts and the + Contacts list, as the Layers panel does, marked `FALLBACK` or `STALE` when + it is. The counts no longer read OpenSky while flights come from the adsb.lol + fallback, and vessels no longer always read AISStream (German, @gekh, #1521). From 5c74b860b77aa83ddbc36fa87442d6db620d28bc Mon Sep 17 00:00:00 2001 From: Manjunath Date: Tue, 3 Nov 2026 19:35:02 +0530 Subject: [PATCH 15/71] Address review: zoom, drag, clearance and pitchDeg for voice tilt - A zoom during a pivoting tilt now scales the range the tilt holds, as it already did for orbit, instead of being undone on the next frame while reporting success. The zoom limits no longer move a view the wrong way: a zoom in leaves a view closer than 80 m in place, and a zoom out leaves an orbit beyond 5,000 km where it is. - A grab, touch or wheel on the map while a voice fly-to is in flight drops the motion queued behind it. Cesium lands the flight anyway, so the orbit or tilt would otherwise take the camera back. Chained fly-tos in one reply keep the earliest snapshot. - A pivot tilt toward the horizon stops lowering the camera at 150 m above the pivot and tilts the rest in place, so a close view no longer ends inside tiles or a hillside. - pitchDeg is an absolute angle. A direction that contradicts it is refused, judged from the pitch the swing starts at, and the tool description says so. --- changelog.d/1520-voice-camera-tilt.md | 3 +- docs/CURRENT-STATE.md | 2 +- server/providers/openai/toolDescriptions.js | 2 +- src/cameraTilt.test.mjs | 146 ++++++++++++++++++++ src/cameraVerbs.js | 113 ++++++++++++--- src/voice/actionSchemas.test.mjs | 5 +- src/voice/gevActions.js | 22 ++- src/voice/gevActions.test.mjs | 69 +++++++++ 8 files changed, 331 insertions(+), 31 deletions(-) diff --git a/changelog.d/1520-voice-camera-tilt.md b/changelog.d/1520-voice-camera-tilt.md index 87d2aef3a..f46ae3c51 100644 --- a/changelog.d/1520-voice-camera-tilt.md +++ b/changelog.d/1520-voice-camera-tilt.md @@ -2,4 +2,5 @@ camera swings about the place under the crosshair, keeps its heading, and can tilt to a spoken angle ("tilt 45 degrees") or a default oblique view. A tilt, orbit, pan or rotate said with a fly-to ("fly to Paris and orbit it") now - waits for the flight to land (Manjunath, @manjunath22466, #1520). + waits for the flight to land, and is dropped if you move the map meanwhile + (Manjunath, @manjunath22466, #1520). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 6f20b01ad..15aeddfec 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5364,7 +5364,7 @@ and unreachable upstream (502/504) separately from road geometry. > branch: **2,281 on Node 25.6.1** (allocation suites skipped) = 2,255 on > `main` + 26 route pins; the same tree on Node 24 reports 2,295. -- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. `rotate_globe` rotates the world-space camera rigidly around Earth's center using its screen-up axis, preserving geocentric range and framing. Left/right describe visible globe motion, while `rotate` turns the camera in place. `tilt` swings the camera about the ground under the crosshair (the tracked view excepted, which tilts in place), so the place stays centred and the heading is kept; pitch stays within -89° to -5°, clamped only in the direction of travel so a top-down (-90°) view can tilt up out of it. A tilt with `pitchDeg` (or with neither angle nor direction, which means -45°) eases to that angle; a positive angle means the same angle below the horizon. A voice tilt, orbit, pan or rotate sent while a voice `fly_to_location` is still looking up or flying ("fly to London and tilt the camera" or "fly to Paris and orbit it" arrives as two sibling calls) waits for that flight to land, up to 30 s, so it neither cancels the flight nor gets replaced by it; if the flight is cancelled, finds nothing or times out, the motion is not run and says so. `stop` and `rotate_globe` do not wait, and a motion sent before its fly-to is not reordered. Orbit still arms (`armed: 'waiting-for-arrival'`) when it starts during any other camera flight, and captures its target once the tween queue has been quiet for 0.8 s. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck current framing without demanding a reset merely because ellipsoid altitude varied. A close-up or overlap refuses without moving the camera. When the unchanged Globe View preset fits, voice says “Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’” without asking a permission question or automatically resetting. Explicit Reset/Show Globe View commands remain direct; an explicit reset-and-rotate request waits for reset success before requesting rotation. If OpenAI dispatches both tools together, rotation waits for earlier same-response resets and is skipped if any fails or is cancelled. OpenAI rejects later tool calls in the same response after a globe refusal, including calls arriving while globe admission is pending, and preserves response-scoped `tool_choice: none` for the refusal follow-up; Gemini rejects further tool execution, including queued sibling calls, until fresh typed input or admitted speech. Model interruption, advisory captions and microphone toggles do not release that guard. There is no pending Yes/No offer. Outside the refused response, explicit-command interpretation and avoiding repeated resets remain model-guided; a distinct model-issued reset call is not an app-enforced proof of user intent. A failed standalone reset can still be retried by the model, and a reset result does not guarantee the camera has fully settled. Before suggesting a reset, a cloned camera checks the unchanged Globe View destination against the same keyhole/depth rules without moving the live camera. If that preset cannot fit, `globe-reset-unavailable` asks for further zoom and the refusal instructs further zoom instead of a reset; this also covers ultrawide views. Unsupported view geometry or non-WGS84 projection assumptions fail closed without the closer-zoom instruction. Supported camera inputs/navigation intents cancel rotation before their action. Finite radius greater than the ellipsoid's maximum radius proves whole-arc clearance at constant centre-distance; no low-altitude swept solver is needed. The unchanged Globe View reset preset remains 18,000,000 m. Terrain and buildings remain outside the guarantee. +- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. `rotate_globe` rotates the world-space camera rigidly around Earth's center using its screen-up axis, preserving geocentric range and framing. Left/right describe visible globe motion, while `rotate` turns the camera in place. `tilt` swings the camera about the ground under the crosshair (the tracked view excepted, which tilts in place), so the place stays centred and the heading is kept; pitch stays within -89° to -5°, clamped only in the direction of travel so a top-down (-90°) view can tilt up out of it. A pivot swing toward the horizon lowers the camera (height above the pivot is range × sin|pitch|), so the swing stops lowering it at 150 m above the pivot (not above the terrain behind the camera) and the rest of the tilt happens in place. A zoom during a pivoting tilt scales the range it holds, as for orbit; a zoom in moves the camera along its view line and may take it below that height, as any zoom does, and the tilt then continues in place. A tilt with `pitchDeg` (or with neither angle nor direction, which means -45°) eases to that angle; a positive angle means the same angle below the horizon. `pitchDeg` is absolute, not a step: sent with a `direction` it contradicts, the tilt is refused. A voice tilt, orbit, pan or rotate sent while a voice `fly_to_location` is still looking up or flying ("fly to London and tilt the camera" or "fly to Paris and orbit it" arrives as two sibling calls) waits for that flight to land, up to 30 s, so it neither cancels the flight nor gets replaced by it; if the flight is cancelled, finds nothing or times out, or the user grabs, touches or scrolls the map during it, the motion is not run and says so. `stop` and `rotate_globe` do not wait, and a motion sent before its fly-to is not reordered. Orbit still arms (`armed: 'waiting-for-arrival'`) when it starts during any other camera flight, and captures its target once the tween queue has been quiet for 0.8 s. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck current framing without demanding a reset merely because ellipsoid altitude varied. A close-up or overlap refuses without moving the camera. When the unchanged Globe View preset fits, voice says “Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’” without asking a permission question or automatically resetting. Explicit Reset/Show Globe View commands remain direct; an explicit reset-and-rotate request waits for reset success before requesting rotation. If OpenAI dispatches both tools together, rotation waits for earlier same-response resets and is skipped if any fails or is cancelled. OpenAI rejects later tool calls in the same response after a globe refusal, including calls arriving while globe admission is pending, and preserves response-scoped `tool_choice: none` for the refusal follow-up; Gemini rejects further tool execution, including queued sibling calls, until fresh typed input or admitted speech. Model interruption, advisory captions and microphone toggles do not release that guard. There is no pending Yes/No offer. Outside the refused response, explicit-command interpretation and avoiding repeated resets remain model-guided; a distinct model-issued reset call is not an app-enforced proof of user intent. A failed standalone reset can still be retried by the model, and a reset result does not guarantee the camera has fully settled. Before suggesting a reset, a cloned camera checks the unchanged Globe View destination against the same keyhole/depth rules without moving the live camera. If that preset cannot fit, `globe-reset-unavailable` asks for further zoom and the refusal instructs further zoom instead of a reset; this also covers ultrawide views. Unsupported view geometry or non-WGS84 projection assumptions fail closed without the closer-zoom instruction. Supported camera inputs/navigation intents cancel rotation before their action. Finite radius greater than the ellipsoid's maximum radius proves whole-arc clearance at constant centre-distance; no low-altitude swept solver is needed. The unchanged Globe View reset preset remains 18,000,000 m. Terrain and buildings remain outside the guarantee. - **The route dolly is shaped, not linear** (2026-08-20). A trapezoid speed profile (smoothstep up, cruise, smoothstep down, distance taken as the closed-form integral) eases both ends without changing the pace — duration is still `totalM / ROUTE_M_S[speed]`, with one exception: a 0.5 s minimum keeps a degenerate route from being an instant teleport, so routes under 10 m (slow) / 20 m (normal) / 45 m (fast) fly SLOWER than the speed word, never faster. Turns bank up to **10°** (a 90° street corner settles near 7.5°), measured as a triangular pulse over a 4 s window centred on the camera so the roll leads in and unwinds after. Altitude breathes ±20 m around the 260 m mean and lifts up to 26 m into turns. Pitch is LOCKED at −32°; heading comes from a gaze that leads the path by 6.5 s of travel. `prefers-reduced-motion` zeroes the roll and the altitude shaping and keeps the easing. - **Invariants — do not "fix" these.** (1) **Every** release levels the roll: completion unwinds it through the ease-out envelope, and `interruptCameraMotion` zeroes it synchronously (heading/pitch/position preserved). Cesium keeps the last up vector it was handed, so skipping this leaves the user holding a tilted horizon. (2) Heading is interpolated as an ANGLE about the local up, never as a Cartesian lerp — a lerp cannot cross an antipodal pair, so an out-and-back route looked backwards for the entire return leg; `signedTurnRad` branches the exact-180° case deterministically because the cross product's sign there is a floating-point coin toss. (3) A COLD floor cell is missing data, not flat ground: route vertices carry height 0, so trusting them flew a mountain corridor at 260 m above the ELLIPSOID. The corridor warm is fire-and-forget, so the dolly ARMS — camera untouched, no teleport onto the route — for up to 1.2 s waiting for real floor data, falls back to a rendered-mesh probe (`scene.sampleHeight`) when the DEM stays cold — latched to ONE firing per flight, for exactly the cells the cache could not answer, so a route costs at most 8 `sampleHeight` calls however long it arms — and otherwise holds the launch altitude for the whole route. A corridor is only RESOLVED when every cell is accounted for: one warm cell says nothing about the ground under the other seven, and treating it as an answer let the dolly descend to 460 m over a 1,600 m rendered surface. The probe reads the rendered surface at the current LOD (rooftops and primitives included) — a better estimate than nothing, not a guaranteed upper bound. Never descend blind. The floor is SMOOTHED in both directions for the ride while the hard clearance clamp reads the RAW sample, so a cell boundary cannot pop the eye but a cliff is still cleared on the frame it is seen. A pre-departure floor is adopted whole; one arriving mid-flight is eased onto, and the eye's descent rate is capped. - 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). diff --git a/server/providers/openai/toolDescriptions.js b/server/providers/openai/toolDescriptions.js index 873a52c8a..c79511e14 100644 --- a/server/providers/openai/toolDescriptions.js +++ b/server/providers/openai/toolDescriptions.js @@ -533,7 +533,7 @@ export const ACTION_DESCRIPTIONS = { }, pitchDeg: { description: - 'Tilt to this angle below the horizon (-45 oblique, -89 top-down). Bare tilt: -45.', + 'Absolute angle below the horizon, not a step (-45 oblique, -89 top-down). Bare tilt: -45.', $position: 2, }, }, diff --git a/src/cameraTilt.test.mjs b/src/cameraTilt.test.mjs index babe95807..d70b07cbb 100644 --- a/src/cameraTilt.test.mjs +++ b/src/cameraTilt.test.mjs @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import * as Cesium from 'cesium'; import { + adjustOrbitRange, getActiveCameraMotion, initCameraVerbs, interruptCameraMotion, @@ -222,3 +223,148 @@ test('a pivoting tilt yields when tracking takes the camera', (t) => { h.viewer.trackedEntityChanged.raiseEvent(entity); assert.equal(getActiveCameraMotion(), null); }); + +/** Metres between the camera and the point it pivots about. */ +const pivotRange = (h) => + Cesium.Cartesian3.distance(h.camera.positionWC, h.target); +const heightM = (camera) => + Cesium.Cartographic.fromCartesian(camera.positionWC).height; + +test('a zoom during a pivoting tilt changes the range it holds', (t) => { + const h = harness(t); + assert.equal( + moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' }).ok, + true, + ); + h.tick(); + const before = pivotRange(h); + assert.equal(adjustOrbitRange(0.5), true, 'the tilt must take the zoom'); + for (let i = 0; i < 5; i += 1) h.tick(); + // The next frame's lookAt must not put the old range back. + assert.ok( + Math.abs(pivotRange(h) - before / 2) < 1, + `range ${pivotRange(h)} m, wanted ${before / 2} m`, + ); +}); + +test('a zoom with no pivot to hold is left to the camera', (t) => { + harness(t, { pivot: false }); + moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' }); + assert.equal(adjustOrbitRange(0.5), false); +}); + +test('a pivot tilt toward the horizon keeps its clearance above the ground', (t) => { + // A close top-down view: swinging 250 m of range to -5° would end ~22 m up. + const h = harness(t, { height: 250 }); + assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -5 }).ok, true); + let lowest = Infinity; + for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) { + h.tick(); + lowest = Math.min(lowest, heightM(h.camera)); + } + assert.equal(getActiveCameraMotion(), null, 'tilt must finish'); + assert.ok(lowest > 149, `camera dropped to ${lowest} m`); + assert.ok( + Math.abs(pitchDeg(h.camera) - -5) < 0.5, + `pitch ${pitchDeg(h.camera)}`, + ); + assert.ok(Cesium.Matrix4.equals(h.camera.transform, Cesium.Matrix4.IDENTITY)); +}); + +test('a continuous tilt up from close in keeps its clearance', (t) => { + const h = harness(t, { height: 250 }); + moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' }); + for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) { + h.tick(); + assert.ok(heightM(h.camera) > 149, `camera at ${heightM(h.camera)} m`); + } + assert.ok(Math.abs(pitchDeg(h.camera) - -5) < 0.5); +}); + +test('a city-scale pivot tilt keeps the place centred to the horizon limit', (t) => { + const h = harness(t); + moveCamera({ motion: 'tilt', pitchDeg: -5 }); + h.finish(); + // 4 km of range clears the floor at every pitch: all pivot, no drift. + assert.ok(centreDrift(h) < 5, `centre drifted ${centreDrift(h)} m`); +}); + +test('pitchDeg against a contradicting direction is refused', (t) => { + harness(t, { pitch: -45 }); + // "tilt down 10 degrees": -10° is an absolute angle, which is up from -45°. + const result = moveCamera({ + motion: 'tilt', + direction: 'down', + pitchDeg: 10, + }); + assert.equal(result.ok, false); + assert.match(result.error, /absolute angle/); + assert.equal(getActiveCameraMotion(), null); +}); + +test('pitchDeg with a direction that agrees still tilts', (t) => { + const h = harness(t, { pitch: -45 }); + const result = moveCamera({ + motion: 'tilt', + direction: 'down', + pitchDeg: -80, + }); + assert.equal(result.ok, true); + assert.equal(result.direction, 'down'); + h.finish(); + assert.ok(Math.abs(pitchDeg(h.camera) - -80) < 0.5); +}); + +test('the pitchDeg and direction check uses the pitch the swing starts from', (t) => { + // At regional range the Earth curves away: the pivot pitch is a degree or + // two shallower than the camera's (-30°), so -29° is still down from it. + harness(t, { height: 150000, pitch: -30 }); + const up = moveCamera({ motion: 'tilt', direction: 'up', pitchDeg: -29 }); + assert.equal(up.ok, false); + assert.match(up.error, /tilts down, not up/); + const down = moveCamera({ motion: 'tilt', direction: 'down', pitchDeg: -29 }); + assert.equal(down.ok, true); + assert.equal(down.direction, 'down'); +}); + +test('after a zoom in, a pivoting tilt never lowers the camera further', (t) => { + const h = harness(t, { height: 1000 }); + moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' }); + while (pitchDeg(h.camera) < -30) h.tick(); + adjustOrbitRange(1 / 2.4); + adjustOrbitRange(1 / 2.4); + h.tick(); + const afterZoom = heightM(h.camera); + for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) { + h.tick(); + assert.ok( + heightM(h.camera) > afterZoom - 1, + `camera fell from ${afterZoom} m to ${heightM(h.camera)} m`, + ); + } + assert.ok(Math.abs(pitchDeg(h.camera) - -5) < 0.5); +}); + +test('a zoom in during a close pivot tilt never pushes the camera out', (t) => { + // 62 m of range, under the 80 m zoom floor. + const h = harness(t, { height: 40, pitch: -40 }); + moveCamera({ motion: 'tilt', direction: 'down', mode: 'continuous' }); + h.tick(); + const before = pivotRange(h); + assert.ok(before < 80, `range ${before} m`); + adjustOrbitRange(1 / 1.6); + h.tick(); + assert.ok(pivotRange(h) <= before + 0.01, `range grew to ${pivotRange(h)} m`); +}); + +test('a zoom out during a far orbit never pulls the camera in', (t) => { + // 15,000 km up, beyond the 5,000 km zoom cap. + const h = harness(t, { height: 15_000_000 }); + assert.equal(moveCamera({ motion: 'orbit', mode: 'continuous' }).ok, true); + h.tick(); + const before = pivotRange(h); + assert.ok(before > 5_000_000, `range ${before} m`); + assert.equal(adjustOrbitRange(2.4), true); + h.tick(); + assert.ok(pivotRange(h) >= before - 1, `range fell to ${pivotRange(h)} m`); +}); diff --git a/src/cameraVerbs.js b/src/cameraVerbs.js index 0b0f06792..7e94d0edf 100644 --- a/src/cameraVerbs.js +++ b/src/cameraVerbs.js @@ -52,6 +52,11 @@ const PITCH_MAX = Cesium.Math.toRadians(-5); const TILT_DEFAULT_PITCH_DEG = -45; /** °/s for a tilt to a requested angle; short ones keep the once duration. */ const TILT_TO_DEG_S = 30; +/** + * A pivoting tilt keeps the range, so its height above the pivot is + * range × sin|pitch|. It swings no lower than this; the rest tilts in place. + */ +const TILT_MIN_CLEARANCE_M = 150; /* ── Route dolly: cinematic tuning ────────────────────────────────────────── * Every knob an owner may want to retune lives in this block. The shaping is @@ -970,6 +975,17 @@ let _trackingRemover = null; let _inputRemovers = []; /** Monotonic id for the motion currently running; 0 means "no motion". */ let _motionSeq = 0; +/** Canvas grabs, touches and wheel turns since load. */ +let _manualInputCount = 0; + +/** + * Lets a caller holding a motion back (behind a fly-to) see whether the user + * took the camera meanwhile; Cesium does not cancel a flight on input. + * @returns {number} Manual camera inputs so far. + */ +export function manualCameraInputCount() { + return _manualInputCount; +} /** * Install a motion and stamp it with a fresh id. The id is what lets a caller @@ -1248,7 +1264,8 @@ function onTick() { * so the place under the crosshair stays centred; without one it looks up or * down in place. Pitch is clamped to [-89°, -5°] only in the direction of * travel: a top-down view sits at -90°, below the limit, and must still be - * able to tilt up out of it. + * able to tilt up out of it. A pivot swing toward the horizon lowers the + * camera, so it stops at TILT_MIN_CLEARANCE_M and the rest tilts in place. */ function tickTilt(cam, m, scale, dt) { let step; @@ -1261,24 +1278,58 @@ function tickTilt(cam, m, scale, dt) { dt * (m.direction === 'up' ? 1 : -1); } - const from = m.hpr ? m.hpr.pitch : cam.pitch; - const next = from + step; - const limited = - step > 0 - ? Math.min(next, Math.max(from, PITCH_MAX)) - : Math.max(next, Math.min(from, PITCH_MIN)); if (m.hpr) { + const from = m.hpr.pitch; + const next = from + step; + const limited = + step > 0 + ? Math.min(next, Math.max(from, tiltPivotPitchMax(m.hpr.range))) + : Math.max(next, Math.min(from, PITCH_MIN)); m.hpr.pitch = limited; cam.lookAt(m.target, m.hpr); // A once motion that just completed released the lookAt frame before // this last step re-took it. if (_active !== m) clearLookAt(); - } else { - cam.lookUp(limited - from); + if (limited === next) return; + if (step < 0 || limited >= PITCH_MAX) { + interruptCameraMotion('tilt-clamp'); + return; + } + // Down to its clearance: let go of the pivot and tilt the rest in place, + // which keeps the height. + if (_active === m) clearLookAt(); + m.hpr = null; + m.target = null; + step = next - limited; } + const from = cam.pitch; + const next = from + step; + const limited = + step > 0 + ? Math.min(next, Math.max(from, PITCH_MAX)) + : Math.max(next, Math.min(from, PITCH_MIN)); + cam.lookUp(limited - from); if (limited !== next) interruptCameraMotion('tilt-clamp'); } +/** Shallowest pivot pitch that keeps the clearance at this range. */ +function tiltPivotPitchMax(range) { + return Math.min( + PITCH_MAX, + -Math.asin(Math.min(1, TILT_MIN_CLEARANCE_M / range)), + ); +} + +/** + * Where a tilt starts. It swings about the ground under the crosshair so the + * place stays centred; the follow camera owns a tracked view's frame. + */ +function tiltStartFrame(cam) { + const target = _viewer.trackedEntity ? null : _getTarget?.(_viewer); + const pivot = target ? tiltPivot(cam, target) : null; + return { target, pivot, from: pivot ? pivot.pitch : cam.pitch }; +} + /** Ground pivot for a tilt: heading, pitch and range in the target's own frame. */ function tiltPivot(cam, target) { const local = Cesium.Matrix4.multiplyByPoint( @@ -1482,7 +1533,10 @@ export function initCameraVerbs( const canvas = viewer.scene.canvas; // ANY manual camera input reclaims control — the cancelFlight reflex. for (const evt of ['pointerdown', 'touchstart', 'wheel']) { - const h = () => interruptCameraMotion('manual-input'); + const h = () => { + _manualInputCount += 1; + interruptCameraMotion('manual-input'); + }; canvas.addEventListener(evt, h, { passive: true }); _inputRemovers.push(() => canvas.removeEventListener(evt, h)); } @@ -1580,9 +1634,10 @@ export function moveCamera(args = {}, runNavigation = null) { }; } if (motion === 'tilt' && tiltToPitch !== null) { - if ( - Math.abs(tiltToPitch - _viewer.camera.pitch) < Cesium.Math.toRadians(0.5) - ) { + // Judge from the pitch the motion will start at: the pivot's, which at + // regional range is a degree or two shallower than the camera's. + const { from } = tiltStartFrame(_viewer.camera); + if (Math.abs(tiltToPitch - from) < Cesium.Math.toRadians(0.5)) { return { ok: true, action: 'move_camera', @@ -1591,6 +1646,18 @@ export function moveCamera(args = {}, runNavigation = null) { unchanged: true, }; } + // pitchDeg is an absolute angle, not a step: "tilt down 10 degrees" sent + // as {direction:'down', pitchDeg:10} would tilt up from -45°. + const towards = tiltToPitch > from ? 'up' : 'down'; + if ((direction === 'up' || direction === 'down') && direction !== towards) { + const target = Math.round(Cesium.Math.toDegrees(tiltToPitch)); + const now = Math.round(Cesium.Math.toDegrees(from)); + return { + ok: false, + action: 'move_camera', + error: `pitchDeg is an absolute angle, not a step: ${target}° from ${now}° tilts ${towards}, not ${direction}. Send direction alone for a nudge, or pitchDeg alone for an angle.`, + }; + } } else if (motion === 'tilt') { // Honest limits: at the clamp a tilt is a no-op — say so instead of // reporting success and letting the model gaslight itself (field test). @@ -1706,16 +1773,11 @@ export function moveCamera(args = {}, runNavigation = null) { mode, }; if (motion === 'tilt') { - const cam = _viewer.camera; - // Swing about the ground under the crosshair so the place stays - // centred. The follow camera owns a tracked view's frame. - const target = _viewer.trackedEntity ? null : _getTarget?.(_viewer); - const pivot = target ? tiltPivot(cam, target) : null; + const { target, pivot, from } = tiltStartFrame(_viewer.camera); if (pivot) { state.target = target; state.hpr = pivot; } - const from = pivot ? pivot.pitch : cam.pitch; if (tiltToPitch !== null) { state.mode = 'once'; state.direction = tiltToPitch > from ? 'up' : 'down'; @@ -1833,14 +1895,19 @@ export function moveCamera(args = {}, runNavigation = null) { /** * Zoom while orbiting: scale the orbit RADIUS instead of letting the zoom * fight the per-frame lookAt (field test: "zoom out did literally nothing"). + * A tilt swinging about its pivot holds a range the same way. * @param {number} factor >1 zooms out (radius grows), <1 zooms in. - * @returns {boolean} true when an orbit consumed the zoom. + * @returns {boolean} true when an orbit or pivoting tilt consumed the zoom. */ export function adjustOrbitRange(factor) { - if (!_active || _active.kind !== 'orbit' || !_active.hpr) return false; + if (!_active || !['orbit', 'tilt'].includes(_active.kind) || !_active.hpr) + return false; + // The limits never move a view the wrong way: a zoom in leaves a closer + // view in place, and a zoom out leaves a farther one. + const range = _active.hpr.range; _active.hpr.range = Math.max( - 80, - Math.min(5_000_000, _active.hpr.range * factor), + Math.min(80, range), + Math.min(Math.max(5_000_000, range), range * factor), ); return true; } diff --git a/src/voice/actionSchemas.test.mjs b/src/voice/actionSchemas.test.mjs index d7805e215..436828dba 100644 --- a/src/voice/actionSchemas.test.mjs +++ b/src/voice/actionSchemas.test.mjs @@ -49,8 +49,9 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea // Snow leaves the set_visual_style enum. // The Operator HUD layout leaves set_hud's layout enum. // The layer alias hint leaves out aliases the layer id already spells. - // move_camera tilt takes an optional pitchDeg angle. - '35261624ef8596608b80e2876f83760723ecf6592acc43fd77691cdb0a972608', + // move_camera tilt takes an optional pitchDeg angle, described as + // absolute rather than a step. + '7258f48a19dead97d1a52cd897355ac84cb1f9077f1e8573fd64e3937660f5d0', ); }); diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index 6d9411dfe..07d0e4021 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -47,6 +47,7 @@ import { flyRoute, interruptCameraMotion, adjustOrbitRange, + manualCameraInputCount, } from '../cameraVerbs.js'; import * as defaultFloorServices from '../data/groundFloor.js'; import { isPickedWorldPosition } from '../data/scenePick.js'; @@ -1107,7 +1108,10 @@ export function createGevActionRunner({ runOptions, ); } - const flight = trackVoiceFlight(); + // Chained fly-tos in one reply: a grab during the earlier one counts. + const flight = trackVoiceFlight( + voiceFlight && !voiceFlight.done ? voiceFlight.inputCount : undefined, + ); voiceFlight = flight; const flown = flyToRequestedLocation(viewer, args, { onFlightEnd: flight.settle, @@ -1194,7 +1198,8 @@ export function createGevActionRunner({ voiceFlight && !voiceFlight.done ) { - const status = await waitForVoiceFlight(voiceFlight, runOptions.signal); + const flight = voiceFlight; + const status = await waitForVoiceFlight(flight, runOptions.signal); if (!current()) return { ok: false, action: 'move_camera', cancelled: true }; if (status !== 'arrived') @@ -1204,6 +1209,15 @@ export function createGevActionRunner({ motion, error: `Not run: the flight this ${motion} was waiting for did not arrive.`, }; + // The flight lands even when the user grabs the globe mid-flight; + // the queued motion must not take the camera back from them. + if (manualCameraInputCount() !== flight.inputCount) + return { + ok: false, + action: 'move_camera', + motion, + error: `Not run: the user moved the camera during the flight, so the ${motion} was dropped.`, + }; } return moveCamera(args, (navigate, releaseOptions) => runManagedVoiceNavigation( @@ -2722,10 +2736,12 @@ async function frameOverhead(viewer, dataManager, styleManager, args = {}) { } /** A voice flight's end: 'arrived', 'cancelled' or 'failed', settled once. */ -function trackVoiceFlight() { +function trackVoiceFlight(inputCount = manualCameraInputCount()) { let resolve; const flight = { done: false, + // Manual input after this means the user took the camera mid-flight. + inputCount, ended: new Promise((settle) => { resolve = settle; }), diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index ee078ac57..d71892130 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -420,6 +420,75 @@ for (const motion of [ }); } +// Cesium lands a flight even when the user grabs the globe mid-flight, so the +// motion queued behind it must not then take the camera back. +for (const motion of [ + { motion: 'orbit' }, + { motion: 'tilt', pitchDeg: -45 }, +]) { + test(`${motion.motion} queued behind a fly-to is dropped when the user drags mid-flight`, async () => { + const { viewer, styleManager, flights } = tiltAfterFlightHarness(); + const canvasListeners = new Map(); + viewer.scene.canvas.addEventListener = (type, listener) => { + canvasListeners.set(type, listener); + }; + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + }); + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + const moved = runner('move_camera', motion); + canvasListeners.get('pointerdown')(); + flights[0].complete(); + const result = await moved; + assert.equal(result.ok, false); + assert.equal(result.motion, motion.motion); + assert.match(result.error, /moved the camera during the flight/); + assert.equal(getActiveCameraMotion(), null); + }); +} + +test('a drag during the first of two chained fly-tos still drops the queued motion', async () => { + const { viewer, styleManager, flights } = tiltAfterFlightHarness(); + const canvasListeners = new Map(); + viewer.scene.canvas.addEventListener = (type, listener) => { + canvasListeners.set(type, listener); + }; + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + }); + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + canvasListeners.get('touchstart')(); + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + const moved = runner('move_camera', { motion: 'orbit' }); + flights.at(-1).complete(); + const result = await moved; + assert.equal(result.ok, false); + assert.match(result.error, /moved the camera during the flight/); +}); + +test('input before a fly-to does not drop the motion queued behind it', async () => { + const { viewer, styleManager, flights } = tiltAfterFlightHarness(); + const canvasListeners = new Map(); + viewer.scene.canvas.addEventListener = (type, listener) => { + canvasListeners.set(type, listener); + }; + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + }); + canvasListeners.get('wheel')(); + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + const moved = runner('move_camera', { motion: 'orbit' }); + flights[0].complete(); + assert.equal((await moved).ok, true); + interruptCameraMotion('test-cleanup'); +}); + test('stop does not wait for a voice flight', async () => { const { viewer, styleManager } = tiltAfterFlightHarness(); const runner = createGevActionRunner({ From 9e64d517adf3e6c8c538a92c1ba6736fd3a97483 Mon Sep 17 00:00:00 2001 From: Manjunath Date: Tue, 3 Nov 2026 20:05:34 +0530 Subject: [PATCH 16/71] Follow up voice tilt review: superseded flights, wait, globe range - A motion waiting on a voice fly-to follows a later fly-to in the same reply that cancels it, within one deadline, instead of reporting that its flight did not arrive. A stale reply stops following. - The wait is 15 s, down from 30 s: it holds the tool output, and on Gemini the later tools in the turn. - Beyond 1,000 km of range a tilt happens in place instead of swinging thousands of km about the crosshair point. - The tilt-nudge limits read the pivot pitch the motion starts from, including for a voice tilt from a tracked view, whose follow camera is released before it moves. - move_camera's argument checks run before the flight wait, so a malformed call is refused at once. - Tests: t.after cleanup in the fly-then-motion loop, the pitchDeg pin asserts the field before removing it, and the handoff-order pin follows the extracted argument checks. --- docs/CURRENT-STATE.md | 2 +- src/cameraHandoff.test.mjs | 11 ++- src/cameraTilt.test.mjs | 32 +++++++ src/cameraVerbs.js | 165 +++++++++++++++++++------------- src/firstRunExperience.test.mjs | 1 + src/voice/gevActions.js | 38 ++++++-- src/voice/gevActions.test.mjs | 96 +++++++++++++++++-- 7 files changed, 266 insertions(+), 79 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 5b3b88201..babe26a0b 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5372,7 +5372,7 @@ and unreachable upstream (502/504) separately from road geometry. > branch: **2,281 on Node 25.6.1** (allocation suites skipped) = 2,255 on > `main` + 26 route pins; the same tree on Node 24 reports 2,295. -- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. `rotate_globe` rotates the world-space camera rigidly around Earth's center using its screen-up axis, preserving geocentric range and framing. Left/right describe visible globe motion, while `rotate` turns the camera in place. `tilt` swings the camera about the ground under the crosshair (the tracked view excepted, which tilts in place), so the place stays centred and the heading is kept; pitch stays within -89° to -5°, clamped only in the direction of travel so a top-down (-90°) view can tilt up out of it. A pivot swing toward the horizon lowers the camera (height above the pivot is range × sin|pitch|), so the swing stops lowering it at 150 m above the pivot (not above the terrain behind the camera) and the rest of the tilt happens in place. A zoom during a pivoting tilt scales the range it holds, as for orbit; a zoom in moves the camera along its view line and may take it below that height, as any zoom does, and the tilt then continues in place. A tilt with `pitchDeg` (or with neither angle nor direction, which means -45°) eases to that angle; a positive angle means the same angle below the horizon. `pitchDeg` is absolute, not a step: sent with a `direction` it contradicts, the tilt is refused. A voice tilt, orbit, pan or rotate sent while a voice `fly_to_location` is still looking up or flying ("fly to London and tilt the camera" or "fly to Paris and orbit it" arrives as two sibling calls) waits for that flight to land, up to 30 s, so it neither cancels the flight nor gets replaced by it; if the flight is cancelled, finds nothing or times out, or the user grabs, touches or scrolls the map during it, the motion is not run and says so. `stop` and `rotate_globe` do not wait, and a motion sent before its fly-to is not reordered. Orbit still arms (`armed: 'waiting-for-arrival'`) when it starts during any other camera flight, and captures its target once the tween queue has been quiet for 0.8 s. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck current framing without demanding a reset merely because ellipsoid altitude varied. A close-up or overlap refuses without moving the camera. When the unchanged Globe View preset fits, voice says “Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’” without asking a permission question or automatically resetting. Explicit Reset/Show Globe View commands remain direct; an explicit reset-and-rotate request waits for reset success before requesting rotation. If OpenAI dispatches both tools together, rotation waits for earlier same-response resets and is skipped if any fails or is cancelled. OpenAI rejects later tool calls in the same response after a globe refusal, including calls arriving while globe admission is pending, and preserves response-scoped `tool_choice: none` for the refusal follow-up; Gemini rejects further tool execution, including queued sibling calls, until fresh typed input or admitted speech. Model interruption, advisory captions and microphone toggles do not release that guard. There is no pending Yes/No offer. Outside the refused response, explicit-command interpretation and avoiding repeated resets remain model-guided; a distinct model-issued reset call is not an app-enforced proof of user intent. A failed standalone reset can still be retried by the model, and a reset result does not guarantee the camera has fully settled. Before suggesting a reset, a cloned camera checks the unchanged Globe View destination against the same keyhole/depth rules without moving the live camera. If that preset cannot fit, `globe-reset-unavailable` asks for further zoom and the refusal instructs further zoom instead of a reset; this also covers ultrawide views. Unsupported view geometry or non-WGS84 projection assumptions fail closed without the closer-zoom instruction. Supported camera inputs/navigation intents cancel rotation before their action. Finite radius greater than the ellipsoid's maximum radius proves whole-arc clearance at constant centre-distance; no low-altitude swept solver is needed. The unchanged Globe View reset preset remains 18,000,000 m. Terrain and buildings remain outside the guarantee. +- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. `rotate_globe` rotates the world-space camera rigidly around Earth's center using its screen-up axis, preserving geocentric range and framing. Left/right describe visible globe motion, while `rotate` turns the camera in place. `tilt` swings the camera about the ground under the crosshair, so the place stays centred and the heading is kept. It tilts in place instead with no ground under the crosshair, from beyond 1,000 km of range, or while an entity is tracked (voice releases the follow camera before moving, so a voice tilt from a tracked view still pivots); pitch stays within -89° to -5°, clamped only in the direction of travel so a top-down (-90°) view can tilt up out of it. A pivot swing toward the horizon lowers the camera (height above the pivot is range × sin|pitch|), so the swing stops lowering it at 150 m above the pivot (not above the terrain behind the camera) and the rest of the tilt happens in place. A zoom during a pivoting tilt scales the range it holds, as for orbit; a zoom in moves the camera along its view line and may take it below that height, as any zoom does, and the tilt then continues in place. A tilt with `pitchDeg` (or with neither angle nor direction, which means -45°) eases to that angle; a positive angle means the same angle below the horizon. `pitchDeg` is absolute, not a step: sent with a `direction` it contradicts, the tilt is refused. A voice tilt, orbit, pan or rotate sent while a voice `fly_to_location` is still looking up or flying ("fly to London and tilt the camera" or "fly to Paris and orbit it" arrives as two sibling calls) waits for that flight to land, up to 15 s (following a later fly-to in the same reply that cancels it), so it neither cancels the flight nor gets replaced by it; if the flight is cancelled, finds nothing or times out, or the user grabs, touches or scrolls the map during it, the motion is not run and says so. `stop` and `rotate_globe` do not wait, and a motion sent before its fly-to is not reordered. Orbit still arms (`armed: 'waiting-for-arrival'`) when it starts during any other camera flight, and captures its target once the tween queue has been quiet for 0.8 s. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck current framing without demanding a reset merely because ellipsoid altitude varied. A close-up or overlap refuses without moving the camera. When the unchanged Globe View preset fits, voice says “Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’” without asking a permission question or automatically resetting. Explicit Reset/Show Globe View commands remain direct; an explicit reset-and-rotate request waits for reset success before requesting rotation. If OpenAI dispatches both tools together, rotation waits for earlier same-response resets and is skipped if any fails or is cancelled. OpenAI rejects later tool calls in the same response after a globe refusal, including calls arriving while globe admission is pending, and preserves response-scoped `tool_choice: none` for the refusal follow-up; Gemini rejects further tool execution, including queued sibling calls, until fresh typed input or admitted speech. Model interruption, advisory captions and microphone toggles do not release that guard. There is no pending Yes/No offer. Outside the refused response, explicit-command interpretation and avoiding repeated resets remain model-guided; a distinct model-issued reset call is not an app-enforced proof of user intent. A failed standalone reset can still be retried by the model, and a reset result does not guarantee the camera has fully settled. Before suggesting a reset, a cloned camera checks the unchanged Globe View destination against the same keyhole/depth rules without moving the live camera. If that preset cannot fit, `globe-reset-unavailable` asks for further zoom and the refusal instructs further zoom instead of a reset; this also covers ultrawide views. Unsupported view geometry or non-WGS84 projection assumptions fail closed without the closer-zoom instruction. Supported camera inputs/navigation intents cancel rotation before their action. Finite radius greater than the ellipsoid's maximum radius proves whole-arc clearance at constant centre-distance; no low-altitude swept solver is needed. The unchanged Globe View reset preset remains 18,000,000 m. Terrain and buildings remain outside the guarantee. - **The route dolly is shaped, not linear** (2026-08-20). A trapezoid speed profile (smoothstep up, cruise, smoothstep down, distance taken as the closed-form integral) eases both ends without changing the pace — duration is still `totalM / ROUTE_M_S[speed]`, with one exception: a 0.5 s minimum keeps a degenerate route from being an instant teleport, so routes under 10 m (slow) / 20 m (normal) / 45 m (fast) fly SLOWER than the speed word, never faster. Turns bank up to **10°** (a 90° street corner settles near 7.5°), measured as a triangular pulse over a 4 s window centred on the camera so the roll leads in and unwinds after. Altitude breathes ±20 m around the 260 m mean and lifts up to 26 m into turns. Pitch is LOCKED at −32°; heading comes from a gaze that leads the path by 6.5 s of travel. `prefers-reduced-motion` zeroes the roll and the altitude shaping and keeps the easing. - **Invariants — do not "fix" these.** (1) **Every** release levels the roll: completion unwinds it through the ease-out envelope, and `interruptCameraMotion` zeroes it synchronously (heading/pitch/position preserved). Cesium keeps the last up vector it was handed, so skipping this leaves the user holding a tilted horizon. (2) Heading is interpolated as an ANGLE about the local up, never as a Cartesian lerp — a lerp cannot cross an antipodal pair, so an out-and-back route looked backwards for the entire return leg; `signedTurnRad` branches the exact-180° case deterministically because the cross product's sign there is a floating-point coin toss. (3) A COLD floor cell is missing data, not flat ground: route vertices carry height 0, so trusting them flew a mountain corridor at 260 m above the ELLIPSOID. The corridor warm is fire-and-forget, so the dolly ARMS — camera untouched, no teleport onto the route — for up to 1.2 s waiting for real floor data, falls back to a rendered-mesh probe (`scene.sampleHeight`) when the DEM stays cold — latched to ONE firing per flight, for exactly the cells the cache could not answer, so a route costs at most 8 `sampleHeight` calls however long it arms — and otherwise holds the launch altitude for the whole route. A corridor is only RESOLVED when every cell is accounted for: one warm cell says nothing about the ground under the other seven, and treating it as an answer let the dolly descend to 460 m over a 1,600 m rendered surface. The probe reads the rendered surface at the current LOD (rooftops and primitives included) — a better estimate than nothing, not a guaranteed upper bound. Never descend blind. The floor is SMOOTHED in both directions for the ride while the hard clearance clamp reads the RAW sample, so a cell boundary cannot pop the eye but a cliff is still cleared on the frame it is seen. A pre-departure floor is adopted whole; one arriving mid-flight is eased onto, and the eye's descent rate is capped. - 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). diff --git a/src/cameraHandoff.test.mjs b/src/cameraHandoff.test.mjs index 2c0d356b2..398c51c34 100644 --- a/src/cameraHandoff.test.mjs +++ b/src/cameraHandoff.test.mjs @@ -223,11 +223,20 @@ test('validated voice camera destinations share the UI navigation authority faca 'move camera', ); ordered(move, [ - "if (!['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe'].includes(motion))", + 'if (argsError) return argsError;', 'const start = () => {', /const preserveCameraFlight =\s*motion === 'orbit'/, 'runNavigation(start, { preserveCameraFlight })', ], 'move validation before handoff'); + const moveArgs = body( + cameraVerbs, + /export function moveCameraArgsError\(args = \{\}\) \{([\s\S]*?)\n\}/, + 'move camera argument checks', + ); + assert.ok( + moveArgs.includes("if (!['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe'].includes(motion))"), + 'unknown motions are refused before handoff', + ); const route = body( cameraVerbs, diff --git a/src/cameraTilt.test.mjs b/src/cameraTilt.test.mjs index d70b07cbb..592098445 100644 --- a/src/cameraTilt.test.mjs +++ b/src/cameraTilt.test.mjs @@ -368,3 +368,35 @@ test('a zoom out during a far orbit never pulls the camera in', (t) => { h.tick(); assert.ok(pivotRange(h) >= before - 1, `range fell to ${pivotRange(h)} m`); }); + +test('from globe range a tilt happens in place', (t) => { + // A pivot 2,000 km out would swing the camera thousands of km. + const h = harness(t, { height: 2_000_000 }); + const before = Cesium.Cartesian3.clone(h.camera.positionWC); + assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -45 }).ok, true); + h.finish(); + assert.ok(Math.abs(pitchDeg(h.camera) - -45) < 0.5); + assert.ok(Cesium.Cartesian3.distance(h.camera.positionWC, before) < 1e-3); +}); + +test('a regional tilt-up nudge at the pivot limit refuses honestly', (t) => { + // 20 km up at -7°, the pivot 186 km out sits at -5.3°: already at the limit. + harness(t, { height: 20000, pitch: -7 }); + const result = moveCamera({ motion: 'tilt', direction: 'up' }); + assert.equal(result.ok, false); + assert.match(result.error, /upper tilt limit/); + assert.equal(getActiveCameraMotion(), null); +}); + +test('a voice tilt from a tracked view judges its limit from the pivot it will use', (t) => { + const h = harness(t, { height: 20000, pitch: -7 }); + h.viewer.trackedEntity = { id: 'tracked' }; + // Voice releases the follow camera before the motion starts. + const release = (start) => { + h.viewer.trackedEntity = undefined; + return start(); + }; + const result = moveCamera({ motion: 'tilt', direction: 'up' }, release); + assert.equal(result.ok, false); + assert.match(result.error, /upper tilt limit/); +}); diff --git a/src/cameraVerbs.js b/src/cameraVerbs.js index 7e94d0edf..1c12a9a74 100644 --- a/src/cameraVerbs.js +++ b/src/cameraVerbs.js @@ -57,6 +57,11 @@ const TILT_TO_DEG_S = 30; * range × sin|pitch|. It swings no lower than this; the rest tilts in place. */ const TILT_MIN_CLEARANCE_M = 150; +/** + * Beyond this range a pivot would swing the camera thousands of km about the + * crosshair point, so the tilt happens in place instead. + */ +const TILT_PIVOT_MAX_RANGE_M = 1_000_000; /* ── Route dolly: cinematic tuning ────────────────────────────────────────── * Every knob an owner may want to retune lives in this block. The shaping is @@ -1322,11 +1327,15 @@ function tiltPivotPitchMax(range) { /** * Where a tilt starts. It swings about the ground under the crosshair so the - * place stays centred; the follow camera owns a tracked view's frame. + * place stays centred. It tilts in place with no ground there, from globe + * range, or while an entity is tracked (the follow camera owns that frame; + * voice releases it before moving, so a voice tilt still pivots). */ -function tiltStartFrame(cam) { - const target = _viewer.trackedEntity ? null : _getTarget?.(_viewer); - const pivot = target ? tiltPivot(cam, target) : null; +function tiltStartFrame(cam, { releasesTracking = false } = {}) { + const tracked = Boolean(_viewer.trackedEntity) && !releasesTracking; + const target = tracked ? null : _getTarget?.(_viewer); + let pivot = target ? tiltPivot(cam, target) : null; + if (pivot && pivot.range > TILT_PIVOT_MAX_RANGE_M) pivot = null; return { target, pivot, from: pivot ? pivot.pitch : cam.pitch }; } @@ -1542,6 +1551,83 @@ export function initCameraVerbs( } } +/** + * A tilt's angle: the one asked for, the default oblique view when a bare + * "tilt the camera" names neither angle nor direction, or null for a nudge. + */ +function requestedTiltPitch(args, direction) { + // Only a real number or numeric text: Number('') and Number(null) are 0, + // which would swing the camera to the horizon. + const raw = args.pitchDeg; + const requested = + typeof raw === 'number' || (typeof raw === 'string' && raw.trim() !== '') + ? Number(raw) + : NaN; + // The camera never looks above the horizon, so a positive angle can only + // mean the same angle below it ("tilt 45 degrees" sent as 45). + if (Number.isFinite(requested)) + return Cesium.Math.clamp( + Cesium.Math.toRadians(-Math.abs(requested)), + PITCH_MIN, + PITCH_MAX, + ); + return direction === null + ? Cesium.Math.toRadians(TILT_DEFAULT_PITCH_DEG) + : null; +} + +/** + * Argument errors move_camera refuses whatever the camera is doing, so a + * caller holding a motion back behind a flight can refuse it before waiting. + * @param {Object} args + * @returns {Object|null} The refusal, or null when the arguments are usable. + */ +export function moveCameraArgsError(args = {}) { + const motion = String(args.motion || '').toLowerCase(); + const direction = args.direction + ? String(args.direction).toLowerCase() + : null; + if (motion === 'stop') return null; + if (!['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe'].includes(motion)) { + return { + ok: false, + action: 'move_camera', + error: `Unknown motion "${args.motion}" — use orbit, pan, tilt, rotate, rotate_globe, or stop.`, + }; + } + if ( + motion !== 'orbit' && + motion !== 'rotate_globe' && + (motion !== 'tilt' || requestedTiltPitch(args, direction) === null) && + !['left', 'right', 'up', 'down'].includes(direction) + ) { + return { + ok: false, + action: 'move_camera', + error: `${motion} needs a direction (left/right${motion !== 'rotate' ? '/up/down' : ''}).`, + }; + } + if (motion === 'rotate' && !['left', 'right'].includes(direction)) { + return { + ok: false, + action: 'move_camera', + error: 'rotate needs left or right.', + }; + } + if ( + motion === 'rotate_globe' && + direction !== null && + !['left', 'right'].includes(direction) + ) { + return { + ok: false, + action: 'move_camera', + error: 'rotate_globe needs left or right.', + }; + } + return null; +} + /** * move_camera implementation. Returns the house result shape; rejections are * plain-English `ok:false` errors. @@ -1574,69 +1660,17 @@ export function moveCamera(args = {}, runNavigation = null) { }; return typeof runNavigation === 'function' ? runNavigation(stop) : stop(); } - if (!['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe'].includes(motion)) { - return { - ok: false, - action: 'move_camera', - error: `Unknown motion "${args.motion}" — use orbit, pan, tilt, rotate, rotate_globe, or stop.`, - }; - } - // Tilt to an angle: the one asked for, or a default oblique view when a - // bare "tilt the camera" names neither angle nor direction. - let tiltToPitch = null; - if (motion === 'tilt') { - // Only a real number or numeric text: Number('') and Number(null) are 0, - // which would swing the camera to the horizon. - const raw = args.pitchDeg; - const requested = - typeof raw === 'number' || (typeof raw === 'string' && raw.trim() !== '') - ? Number(raw) - : NaN; - // The camera never looks above the horizon, so a positive angle can only - // mean the same angle below it ("tilt 45 degrees" sent as 45). - if (Number.isFinite(requested)) - tiltToPitch = Cesium.Math.clamp( - Cesium.Math.toRadians(-Math.abs(requested)), - PITCH_MIN, - PITCH_MAX, - ); - else if (direction === null) - tiltToPitch = Cesium.Math.toRadians(TILT_DEFAULT_PITCH_DEG); - } - if ( - motion !== 'orbit' && - motion !== 'rotate_globe' && - tiltToPitch === null && - !['left', 'right', 'up', 'down'].includes(direction) - ) { - return { - ok: false, - action: 'move_camera', - error: `${motion} needs a direction (left/right${motion !== 'rotate' ? '/up/down' : ''}).`, - }; - } - if (motion === 'rotate' && !['left', 'right'].includes(direction)) { - return { - ok: false, - action: 'move_camera', - error: 'rotate needs left or right.', - }; - } - if ( - motion === 'rotate_globe' && - direction !== null && - !['left', 'right'].includes(direction) - ) { - return { - ok: false, - action: 'move_camera', - error: 'rotate_globe needs left or right.', - }; - } + const argsError = moveCameraArgsError(args); + if (argsError) return argsError; + const tiltToPitch = + motion === 'tilt' ? requestedTiltPitch(args, direction) : null; + // A managed (voice) navigation releases the follow camera before start(), + // so checks made now must judge from the frame start() will see. + const releasesTracking = typeof runNavigation === 'function'; if (motion === 'tilt' && tiltToPitch !== null) { // Judge from the pitch the motion will start at: the pivot's, which at // regional range is a degree or two shallower than the camera's. - const { from } = tiltStartFrame(_viewer.camera); + const { from } = tiltStartFrame(_viewer.camera, { releasesTracking }); if (Math.abs(tiltToPitch - from) < Cesium.Math.toRadians(0.5)) { return { ok: true, @@ -1661,7 +1695,8 @@ export function moveCamera(args = {}, runNavigation = null) { } else if (motion === 'tilt') { // Honest limits: at the clamp a tilt is a no-op — say so instead of // reporting success and letting the model gaslight itself (field test). - const pitch = _viewer.camera.pitch; + // The clamp reads the pitch the motion starts from. + const pitch = tiltStartFrame(_viewer.camera, { releasesTracking }).from; if (direction === 'up' && pitch >= PITCH_MAX - Cesium.Math.toRadians(0.5)) { return { ok: false, diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs index 9ca5262a4..60a37f73c 100644 --- a/src/firstRunExperience.test.mjs +++ b/src/firstRunExperience.test.mjs @@ -1018,6 +1018,7 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping // the legacy camera fields to keep the pin. const camera = legacyTools.find((tool) => tool.name === 'move_camera') .parameters.properties; + assert.equal(camera.pitchDeg.type, 'number'); delete camera.pitchDeg; camera.direction.description = 'Required for pan/tilt/rotate; orbit/rotate_globe default right.'; diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index 926876f6d..66199e277 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -48,6 +48,7 @@ import { interruptCameraMotion, adjustOrbitRange, manualCameraInputCount, + moveCameraArgsError, } from '../cameraVerbs.js'; import * as defaultFloorServices from '../data/groundFloor.js'; import { isPickedWorldPosition } from '../data/scenePick.js'; @@ -1198,8 +1199,26 @@ export function createGevActionRunner({ voiceFlight && !voiceFlight.done ) { - const flight = voiceFlight; - const status = await waitForVoiceFlight(flight, runOptions.signal); + // Bad arguments are refused now, not after waiting on the flight. + const argsError = moveCameraArgsError(args); + if (argsError) return argsError; + const deadline = Date.now() + VOICE_FLIGHT_WAIT_MS; + let flight = voiceFlight; + let status = await waitForVoiceFlight( + flight, + runOptions.signal, + deadline, + ); + // A later fly-to in the same reply cancels this one: follow it to + // where the camera lands, within the same deadline. + while (status === 'cancelled' && voiceFlight !== flight && current()) { + flight = voiceFlight; + status = await waitForVoiceFlight( + flight, + runOptions.signal, + deadline, + ); + } if (!current()) return { ok: false, action: 'move_camera', cancelled: true }; if (status !== 'arrived') @@ -2759,8 +2778,12 @@ function trackVoiceFlight(inputCount = manualCameraInputCount()) { return flight; } -/** Longest a motion waits for a flight (geocode plus flight) before giving up. */ -const VOICE_FLIGHT_WAIT_MS = 30_000; +/** + * Longest a motion waits for its flight (geocode plus flight, which is about + * 2 s) before giving up. The wait also holds the tool output, and on Gemini + * the later tools in the turn. + */ +const VOICE_FLIGHT_WAIT_MS = 15_000; /** * Motions that act on the place the camera ends up at, so one sent beside a @@ -2775,7 +2798,7 @@ const VOICE_FLIGHT_WAITING_MOTIONS = new Set([ ]); /** Wait for a voice flight to end; 'timeout' or 'aborted' if it does not. */ -function waitForVoiceFlight(flight, signal) { +function waitForVoiceFlight(flight, signal, deadline) { if (signal?.aborted) return Promise.resolve('aborted'); return new Promise((resolve) => { const finish = (status) => { @@ -2784,7 +2807,10 @@ function waitForVoiceFlight(flight, signal) { resolve(status); }; const onAbort = () => finish('aborted'); - const timer = setTimeout(() => finish('timeout'), VOICE_FLIGHT_WAIT_MS); + const timer = setTimeout( + () => finish('timeout'), + Math.max(0, deadline - Date.now()), + ); signal?.addEventListener?.('abort', onAbort, { once: true }); flight.ended.then(finish); }); diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index e4bffb26b..08af6b288 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -347,7 +347,8 @@ for (const motion of [ { motion: 'pan', direction: 'left' }, { motion: 'rotate', direction: 'right' }, ]) { - test(`${motion.motion} in the same reply as a fly-to waits for the flight to land`, async () => { + test(`${motion.motion} in the same reply as a fly-to waits for the flight to land`, async (t) => { + t.after(() => interruptCameraMotion('test-cleanup')); const { viewer, styleManager, order, flights } = tiltAfterFlightHarness(); const runner = createGevActionRunner({ viewer, @@ -368,10 +369,10 @@ for (const motion of [ const result = await moved; assert.equal(result.ok, true); assert.equal(result.motion, motion.motion); - interruptCameraMotion('test-cleanup'); }); - test(`${motion.motion} waits through a geocoded fly-to search, then for its flight`, async () => { + test(`${motion.motion} waits through a geocoded fly-to search, then for its flight`, async (t) => { + t.after(() => interruptCameraMotion('test-cleanup')); const { viewer, styleManager } = tiltAfterFlightHarness(); let finishSearch; let landFlight; @@ -399,7 +400,6 @@ for (const motion of [ landFlight(); assert.equal((await moved).ok, true); assert.equal(getActiveCameraMotion()?.kind, motion.motion); - interruptCameraMotion('test-cleanup'); }); test(`${motion.motion} waiting on a flight that never lands is not run`, async () => { @@ -470,7 +470,8 @@ test('a drag during the first of two chained fly-tos still drops the queued moti assert.match(result.error, /moved the camera during the flight/); }); -test('input before a fly-to does not drop the motion queued behind it', async () => { +test('input before a fly-to does not drop the motion queued behind it', async (t) => { + t.after(() => interruptCameraMotion('test-cleanup')); const { viewer, styleManager, flights } = tiltAfterFlightHarness(); const canvasListeners = new Map(); viewer.scene.canvas.addEventListener = (type, listener) => { @@ -486,7 +487,90 @@ test('input before a fly-to does not drop the motion queued behind it', async () const moved = runner('move_camera', { motion: 'orbit' }); flights[0].complete(); assert.equal((await moved).ok, true); - interruptCameraMotion('test-cleanup'); +}); + +test('a motion follows a later fly-to in the same reply that cancels its flight', async (t) => { + t.after(() => interruptCameraMotion('test-cleanup')); + const { viewer, styleManager, flights } = tiltAfterFlightHarness(); + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + }); + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + const moved = runner('move_camera', { motion: 'orbit' }); + const movedDone = settledYet(moved); + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + // The second flight supersedes the first, as Cesium's flyTo does. + flights[0].cancel(); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(movedDone(), false, 'the orbit must wait for the second flight'); + flights[1].complete(); + const result = await moved; + assert.equal(result.ok, true); + assert.equal(result.motion, 'orbit'); +}); + +test('a stale motion does not follow a newer reply\'s fly-to', async () => { + const { viewer, styleManager, flights } = tiltAfterFlightHarness(); + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + }); + let live = true; + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + const moved = runner('move_camera', { motion: 'orbit' }, { isCurrent: () => live }); + // A newer reply takes over and flies elsewhere; this reply is stale. + live = false; + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + flights[0].cancel(); + // The newer flight never lands here: following it would hold this answer. + const result = await Promise.race([ + moved, + new Promise((resolve) => setTimeout(() => resolve('still waiting'), 50)), + ]); + assert.notEqual(result, 'still waiting'); + assert.equal(result.ok, false); + assert.equal(result.cancelled, true); +}); + +test('a motion gives up on a flight that has not landed in 15 s', async (t) => { + const { viewer, styleManager } = tiltAfterFlightHarness(); + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + }); + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + t.mock.timers.enable({ apis: ['setTimeout', 'Date'] }); + const moved = runner('move_camera', { motion: 'orbit' }); + const movedDone = settledYet(moved); + await Promise.resolve(); + t.mock.timers.tick(14_999); + await Promise.resolve(); + assert.equal(movedDone(), false); + t.mock.timers.tick(1); + const result = await moved; + assert.equal(result.ok, false); + assert.match(result.error, /did not arrive/); +}); + +test('bad move_camera arguments are refused without waiting for a flight', async () => { + const { viewer, styleManager } = tiltAfterFlightHarness(); + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + }); + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + const moved = runner('move_camera', { motion: 'pan' }); + const movedDone = settledYet(moved); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(movedDone(), true, 'the refusal must not wait on the flight'); + const result = await moved; + assert.equal(result.ok, false); + assert.match(result.error, /needs a direction/); }); test('stop does not wait for a voice flight', async () => { From 2c936ae131ed8b24abfc45cc861c8de75df50061 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Tue, 3 Nov 2026 16:26:45 +0000 Subject: [PATCH 17/71] docs: correct hidden-contact rotation interval --- docs/CURRENT-STATE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 5be325222..2a6dc5fdb 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4989,7 +4989,7 @@ silently demoting every later lookup for the session. ### Motion & Symbology Correctness (June 10, 2026) -- **Flights (commercial + military)** render one poll interval behind real time (30s/15s) and interpolate between two known feed-stamped fixes (OpenSky `time_position`; adsb.lol `receipt − seen_pos`). The display latency is an intentional product decision — do not "fix" it away. The fleet dead-reckons at ~12Hz (1m² write gating); a contact already hidden behind the horizon whose last position still is re-reckons on a staggered one-in-12-ticks rotation (about once a second) instead, so the worldwide feed's ~13k contacts no longer cost a full pass when well under 1% are on screen. Anything that position puts in view, and any contact with a fleet model, takes the full pass immediately, and hidden-inclusive proximity queries read positions at most about a second old; aircraft get a 3-poll grace period (faded icon) before removal. When a position epoch pauses, both layers coast for at least 60 seconds of contact grace with an absolute five-minute ceiling. Source backoff marks each contact and the cockpit `STALE`; the cockpit then holds the exact layer position instead of continuing inertial flight. Repeated-position kinematic changes create a forward-only synthetic fix rather than mutating history, and grounded history is lifted only when no owned 3D model already controls its datum. A nominally successful worldwide OpenSky response whose own snapshot epoch is more than two minutes old prefers the existing 250-nm viewport-scoped adsb.lol fallback and labels the source/coverage accordingly when that upstream is available. If the fallback is also unavailable, the layer's freshness/error fields use the source epoch—never the cache receipt time—so the UI reports an old snapshot rather than “just now.” +- **Flights (commercial + military)** render one poll interval behind real time (30s/15s) and interpolate between two known feed-stamped fixes (OpenSky `time_position`; adsb.lol `receipt − seen_pos`). The display latency is an intentional product decision — do not "fix" it away. The fleet dead-reckons at ~12Hz (1m² write gating); a contact already hidden behind the horizon whose last position still is re-reckons on a staggered one-in-13-ticks rotation (about once a second) instead, so the worldwide feed's ~13k contacts no longer cost a full pass when well under 1% are on screen. Anything that position puts in view, and any contact with a fleet model, takes the full pass immediately, and hidden-inclusive proximity queries read positions at most about a second old; aircraft get a 3-poll grace period (faded icon) before removal. When a position epoch pauses, both layers coast for at least 60 seconds of contact grace with an absolute five-minute ceiling. Source backoff marks each contact and the cockpit `STALE`; the cockpit then holds the exact layer position instead of continuing inertial flight. Repeated-position kinematic changes create a forward-only synthetic fix rather than mutating history, and grounded history is lifted only when no owned 3D model already controls its datum. A nominally successful worldwide OpenSky response whose own snapshot epoch is more than two minutes old prefers the existing 250-nm viewport-scoped adsb.lol fallback and labels the source/coverage accordingly when that upstream is available. If the fallback is also unavailable, the layer's freshness/error fields use the source epoch—never the cache receipt time—so the UI reports an old snapshot rather than “just now.” - **World-space headings at every angle** (`src/data/iconOrientation.js`): aircraft/vessel icon rotation uses the camera right/up basis per tick (alignedAxis always ZERO), which is exact at screen center and for orthographic/nadir views and remains stable through >180° tracked orbits. Perspective rays vary across the viewport, so off-center contacts at oblique pitch can diverge from an exact finite-difference window projection; a regression test pins that known regime, and field evidence decides whether to adopt exact projection with the basis method as fallback. Fleet rotations refresh on camera-pose change; tracked entities per frame. Billboards are horizon-culled via a shared EllipsoidalOccluder. - **Military/OpenSky reconciliation** (`src/data/militaryRegistry.js`): known-military ICAOs render amber in the flights layer (60s self-poll of the cached mil endpoint when the military layer is off) and are suppressed there while the military layer renders them. - **Satellites**: 838-sat core catalog (stations/visual/GPS/GLONASS/Galileo/GEO), tracking lands ~726km out via `viewFrom` (tracked entity owns a point graphic so the tracking camera engages), rings realign via primitive modelMatrix (no per-second rebuild flicker), optional `setParams({catalog:'dense'})` Starlink mode. From fdecc8c5818e8ad4d1a0d09749472b76957e69f1 Mon Sep 17 00:00:00 2001 From: Manjunath Date: Tue, 3 Nov 2026 22:23:38 +0530 Subject: [PATCH 18/71] Keep the tilt clearance through a zoom in A zoom in during a pivoting tilt stops at the range that keeps the camera 150 m above the pivot at its current pitch. A zoom that moves the range by less than half a metre (at the clearance, the 80 m floor or the 5,000 km cap) now answers not zoomed instead of ok, and a tilt reports tiltRangeAdjusted rather than orbitRadiusAdjusted. --- docs/CURRENT-STATE.md | 2 +- src/cameraTilt.test.mjs | 49 ++++++++++++++++++++++++++++++++++++++--- src/cameraVerbs.js | 24 +++++++++++++------- src/voice/gevActions.js | 18 ++++++++++++--- 4 files changed, 78 insertions(+), 15 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 5eb025b1f..55a46973f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5381,7 +5381,7 @@ and unreachable upstream (502/504) separately from road geometry. > branch: **2,281 on Node 25.6.1** (allocation suites skipped) = 2,255 on > `main` + 26 route pins; the same tree on Node 24 reports 2,295. -- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. `rotate_globe` rotates the world-space camera rigidly around Earth's center using its screen-up axis, preserving geocentric range and framing. Left/right describe visible globe motion, while `rotate` turns the camera in place. `tilt` swings the camera about the ground under the crosshair, so the place stays centred and the heading is kept. It tilts in place instead with no ground under the crosshair, from beyond 1,000 km of range, or while an entity is tracked (voice releases the follow camera before moving, so a voice tilt from a tracked view still pivots); pitch stays within -89° to -5°, clamped only in the direction of travel so a top-down (-90°) view can tilt up out of it. A pivot swing toward the horizon lowers the camera (height above the pivot is range × sin|pitch|), so the swing stops lowering it at 150 m above the pivot (not above the terrain behind the camera) and the rest of the tilt happens in place. A zoom during a pivoting tilt scales the range it holds, as for orbit; a zoom in moves the camera along its view line and may take it below that height, as any zoom does, and the tilt then continues in place. A tilt with `pitchDeg` (or with neither angle nor direction, which means -45°) eases to that angle; a positive angle means the same angle below the horizon. `pitchDeg` is absolute, not a step: sent with a `direction` it contradicts, the tilt is refused. A voice tilt, orbit, pan or rotate sent while a voice `fly_to_location` is still looking up or flying ("fly to London and tilt the camera" or "fly to Paris and orbit it" arrives as two sibling calls) waits for that flight to land, up to 15 s (following a later fly-to in the same reply that cancels it), so it neither cancels the flight nor gets replaced by it; if the flight is cancelled, finds nothing or times out, or the user grabs, touches or scrolls the map during it, the motion is not run and says so. `stop` and `rotate_globe` do not wait, and a motion sent before its fly-to is not reordered. Orbit still arms (`armed: 'waiting-for-arrival'`) when it starts during any other camera flight, and captures its target once the tween queue has been quiet for 0.8 s. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck current framing without demanding a reset merely because ellipsoid altitude varied. A close-up or overlap refuses without moving the camera. When the unchanged Globe View preset fits, voice says “Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’” without asking a permission question or automatically resetting. Explicit Reset/Show Globe View commands remain direct; an explicit reset-and-rotate request waits for reset success before requesting rotation. If OpenAI dispatches both tools together, rotation waits for earlier same-response resets and is skipped if any fails or is cancelled. OpenAI rejects later tool calls in the same response after a globe refusal, including calls arriving while globe admission is pending, and preserves response-scoped `tool_choice: none` for the refusal follow-up; Gemini rejects further tool execution, including queued sibling calls, until fresh typed input or admitted speech. Model interruption, advisory captions and microphone toggles do not release that guard. There is no pending Yes/No offer. Outside the refused response, explicit-command interpretation and avoiding repeated resets remain model-guided; a distinct model-issued reset call is not an app-enforced proof of user intent. A failed standalone reset can still be retried by the model, and a reset result does not guarantee the camera has fully settled. Before suggesting a reset, a cloned camera checks the unchanged Globe View destination against the same keyhole/depth rules without moving the live camera. If that preset cannot fit, `globe-reset-unavailable` asks for further zoom and the refusal instructs further zoom instead of a reset; this also covers ultrawide views. Unsupported view geometry or non-WGS84 projection assumptions fail closed without the closer-zoom instruction. Supported camera inputs/navigation intents cancel rotation before their action. Finite radius greater than the ellipsoid's maximum radius proves whole-arc clearance at constant centre-distance; no low-altitude swept solver is needed. The unchanged Globe View reset preset remains 18,000,000 m. Terrain and buildings remain outside the guarantee. +- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. `rotate_globe` rotates the world-space camera rigidly around Earth's center using its screen-up axis, preserving geocentric range and framing. Left/right describe visible globe motion, while `rotate` turns the camera in place. `tilt` swings the camera about the ground under the crosshair, so the place stays centred and the heading is kept. It tilts in place instead with no ground under the crosshair, from beyond 1,000 km of range, or while an entity is tracked (voice releases the follow camera before moving, so a voice tilt from a tracked view still pivots); pitch stays within -89° to -5°, clamped only in the direction of travel so a top-down (-90°) view can tilt up out of it. A pivot swing toward the horizon lowers the camera (height above the pivot is range × sin|pitch|), so the swing stops lowering it at 150 m above the pivot (not above the terrain behind the camera) and the rest of the tilt happens in place. A zoom during a pivoting tilt scales the range it holds, as for orbit, but a zoom in stops at the range that keeps that 150 m clearance; a zoom that can move no further (that floor, the 80 m floor or the 5,000 km cap) is reported as not zoomed. Once the tilt continues in place, a zoom moves the camera like any zoom. A tilt with `pitchDeg` (or with neither angle nor direction, which means -45°) eases to that angle; a positive angle means the same angle below the horizon. `pitchDeg` is absolute, not a step: sent with a `direction` it contradicts, the tilt is refused. A voice tilt, orbit, pan or rotate sent while a voice `fly_to_location` is still looking up or flying ("fly to London and tilt the camera" or "fly to Paris and orbit it" arrives as two sibling calls) waits for that flight to land, up to 15 s (following a later fly-to in the same reply that cancels it), so it neither cancels the flight nor gets replaced by it; if the flight is cancelled, finds nothing or times out, or the user grabs, touches or scrolls the map during it, the motion is not run and says so. `stop` and `rotate_globe` do not wait, and a motion sent before its fly-to is not reordered. Orbit still arms (`armed: 'waiting-for-arrival'`) when it starts during any other camera flight, and captures its target once the tween queue has been quiet for 0.8 s. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck current framing without demanding a reset merely because ellipsoid altitude varied. A close-up or overlap refuses without moving the camera. When the unchanged Globe View preset fits, voice says “Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’” without asking a permission question or automatically resetting. Explicit Reset/Show Globe View commands remain direct; an explicit reset-and-rotate request waits for reset success before requesting rotation. If OpenAI dispatches both tools together, rotation waits for earlier same-response resets and is skipped if any fails or is cancelled. OpenAI rejects later tool calls in the same response after a globe refusal, including calls arriving while globe admission is pending, and preserves response-scoped `tool_choice: none` for the refusal follow-up; Gemini rejects further tool execution, including queued sibling calls, until fresh typed input or admitted speech. Model interruption, advisory captions and microphone toggles do not release that guard. There is no pending Yes/No offer. Outside the refused response, explicit-command interpretation and avoiding repeated resets remain model-guided; a distinct model-issued reset call is not an app-enforced proof of user intent. A failed standalone reset can still be retried by the model, and a reset result does not guarantee the camera has fully settled. Before suggesting a reset, a cloned camera checks the unchanged Globe View destination against the same keyhole/depth rules without moving the live camera. If that preset cannot fit, `globe-reset-unavailable` asks for further zoom and the refusal instructs further zoom instead of a reset; this also covers ultrawide views. Unsupported view geometry or non-WGS84 projection assumptions fail closed without the closer-zoom instruction. Supported camera inputs/navigation intents cancel rotation before their action. Finite radius greater than the ellipsoid's maximum radius proves whole-arc clearance at constant centre-distance; no low-altitude swept solver is needed. The unchanged Globe View reset preset remains 18,000,000 m. Terrain and buildings remain outside the guarantee. - **The route dolly is shaped, not linear** (2026-08-20). A trapezoid speed profile (smoothstep up, cruise, smoothstep down, distance taken as the closed-form integral) eases both ends without changing the pace — duration is still `totalM / ROUTE_M_S[speed]`, with one exception: a 0.5 s minimum keeps a degenerate route from being an instant teleport, so routes under 10 m (slow) / 20 m (normal) / 45 m (fast) fly SLOWER than the speed word, never faster. Turns bank up to **10°** (a 90° street corner settles near 7.5°), measured as a triangular pulse over a 4 s window centred on the camera so the roll leads in and unwinds after. Altitude breathes ±20 m around the 260 m mean and lifts up to 26 m into turns. Pitch is LOCKED at −32°; heading comes from a gaze that leads the path by 6.5 s of travel. `prefers-reduced-motion` zeroes the roll and the altitude shaping and keeps the easing. - **Invariants — do not "fix" these.** (1) **Every** release levels the roll: completion unwinds it through the ease-out envelope, and `interruptCameraMotion` zeroes it synchronously (heading/pitch/position preserved). Cesium keeps the last up vector it was handed, so skipping this leaves the user holding a tilted horizon. (2) Heading is interpolated as an ANGLE about the local up, never as a Cartesian lerp — a lerp cannot cross an antipodal pair, so an out-and-back route looked backwards for the entire return leg; `signedTurnRad` branches the exact-180° case deterministically because the cross product's sign there is a floating-point coin toss. (3) A COLD floor cell is missing data, not flat ground: route vertices carry height 0, so trusting them flew a mountain corridor at 260 m above the ELLIPSOID. The corridor warm is fire-and-forget, so the dolly ARMS — camera untouched, no teleport onto the route — for up to 1.2 s waiting for real floor data, falls back to a rendered-mesh probe (`scene.sampleHeight`) when the DEM stays cold — latched to ONE firing per flight, for exactly the cells the cache could not answer, so a route costs at most 8 `sampleHeight` calls however long it arms — and otherwise holds the launch altitude for the whole route. A corridor is only RESOLVED when every cell is accounted for: one warm cell says nothing about the ground under the other seven, and treating it as an answer let the dolly descend to 460 m over a 1,600 m rendered surface. The probe reads the rendered surface at the current LOD (rooftops and primitives included) — a better estimate than nothing, not a guaranteed upper bound. Never descend blind. The floor is SMOOTHED in both directions for the ride while the hard clearance clamp reads the RAW sample, so a cell boundary cannot pop the eye but a cliff is still cleared on the frame it is seen. A pre-departure floor is adopted whole; one arriving mid-flight is eased onto, and the eye's descent rate is capped. - 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). diff --git a/src/cameraTilt.test.mjs b/src/cameraTilt.test.mjs index 592098445..58cdbc05f 100644 --- a/src/cameraTilt.test.mjs +++ b/src/cameraTilt.test.mjs @@ -8,6 +8,7 @@ import { interruptCameraMotion, moveCamera, } from './cameraVerbs.js'; +import { createGevActionRunner } from './voice/gevActions.js'; const radians = Cesium.Math.toRadians; const degrees = Cesium.Math.toDegrees; @@ -238,7 +239,11 @@ test('a zoom during a pivoting tilt changes the range it holds', (t) => { ); h.tick(); const before = pivotRange(h); - assert.equal(adjustOrbitRange(0.5), true, 'the tilt must take the zoom'); + assert.deepEqual( + adjustOrbitRange(0.5), + { kind: 'tilt', changed: true }, + 'the tilt must take the zoom', + ); for (let i = 0; i < 5; i += 1) h.tick(); // The next frame's lookAt must not put the old range back. assert.ok( @@ -250,7 +255,7 @@ test('a zoom during a pivoting tilt changes the range it holds', (t) => { test('a zoom with no pivot to hold is left to the camera', (t) => { harness(t, { pivot: false }); moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' }); - assert.equal(adjustOrbitRange(0.5), false); + assert.equal(adjustOrbitRange(0.5), null); }); test('a pivot tilt toward the horizon keeps its clearance above the ground', (t) => { @@ -364,11 +369,49 @@ test('a zoom out during a far orbit never pulls the camera in', (t) => { h.tick(); const before = pivotRange(h); assert.ok(before > 5_000_000, `range ${before} m`); - assert.equal(adjustOrbitRange(2.4), true); + assert.deepEqual(adjustOrbitRange(2.4), { kind: 'orbit', changed: false }); h.tick(); assert.ok(pivotRange(h) >= before - 1, `range fell to ${pivotRange(h)} m`); }); +test('a zoom in during a pivot tilt stops at the clearance', (t) => { + // 400 m of range at -40°, then "zoom in a lot" twice: unfloored, that left + // the camera about 107 m up, then at the 80 m zoom floor. + const h = harness(t, { height: 400 }); + moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' }); + while (pitchDeg(h.camera) < -40) h.tick(); + assert.deepEqual(adjustOrbitRange(1 / 2.4), { kind: 'tilt', changed: true }); + // At -40° the floor is 150 / sin 40° ≈ 233 m, which the first zoom reached. + assert.deepEqual(adjustOrbitRange(1 / 2.4), { kind: 'tilt', changed: false }); + for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) { + h.tick(); + assert.ok(heightM(h.camera) > 149, `camera at ${heightM(h.camera)} m`); + } + assert.ok(Math.abs(pitchDeg(h.camera) - -5) < 0.5); +}); + +test('a voice zoom during a pivot tilt says what it changed', async (t) => { + const h = harness(t, { height: 400 }); + const runner = createGevActionRunner({ + viewer: h.viewer, + styleManager: {}, + dataManager: { layers: new Map(), getAll: () => [] }, + }); + moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' }); + h.tick(); + const zoom = { direction: 'in', amount: 'lot' }; + const first = await runner('adjust_camera_zoom', zoom); + assert.equal(first.ok, true); + assert.equal(first.tiltRangeAdjusted, true); + assert.equal('orbitRadiusAdjusted' in first, false); + // 400 m → 167 m → the 150 m clearance; a third zoom in moves nothing, and + // says so. + assert.equal((await runner('adjust_camera_zoom', zoom)).ok, true); + const third = await runner('adjust_camera_zoom', zoom); + assert.equal(third.ok, false); + assert.match(third.error, /tilt is already at its closest range/); +}); + test('from globe range a tilt happens in place', (t) => { // A pivot 2,000 km out would swing the camera thousands of km. const h = harness(t, { height: 2_000_000 }); diff --git a/src/cameraVerbs.js b/src/cameraVerbs.js index 1c12a9a74..16888d73e 100644 --- a/src/cameraVerbs.js +++ b/src/cameraVerbs.js @@ -1932,19 +1932,27 @@ export function moveCamera(args = {}, runNavigation = null) { * fight the per-frame lookAt (field test: "zoom out did literally nothing"). * A tilt swinging about its pivot holds a range the same way. * @param {number} factor >1 zooms out (radius grows), <1 zooms in. - * @returns {boolean} true when an orbit or pivoting tilt consumed the zoom. + * @returns {{kind: string, changed: boolean}|null} the motion that consumed + * the zoom, and whether its range moved; null when none did. */ export function adjustOrbitRange(factor) { if (!_active || !['orbit', 'tilt'].includes(_active.kind) || !_active.hpr) - return false; - // The limits never move a view the wrong way: a zoom in leaves a closer - // view in place, and a zoom out leaves a farther one. - const range = _active.hpr.range; - _active.hpr.range = Math.max( - Math.min(80, range), + return null; + // A tilt's zoom in stops at the range that keeps its clearance. The limits + // never move a view the wrong way: a zoom in leaves a closer view in place, + // and a zoom out leaves a farther one. + const { kind, hpr } = _active; + const floor = + kind === 'tilt' + ? Math.max(80, TILT_MIN_CLEARANCE_M / Math.abs(Math.sin(hpr.pitch))) + : 80; + const range = hpr.range; + hpr.range = Math.max( + Math.min(floor, range), Math.min(Math.max(5_000_000, range), range * factor), ); - return true; + // Under half a metre counts as not moved, as for a plain zoom. + return { kind, changed: Math.abs(hpr.range - range) >= 0.5 }; } /** diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index 66199e277..e851827df 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -411,13 +411,25 @@ export function createGevActionRunner({ const zoomOut = String(args.direction || '').toLowerCase() === 'out'; const amt = String(args.amount || 'medium').toLowerCase(); const factor = { little: 1.25, medium: 1.6, lot: 2.4 }[amt] || 1.6; - if (adjustOrbitRange(zoomOut ? factor : 1 / factor)) { + const held = adjustOrbitRange(zoomOut ? factor : 1 / factor); + if (held) { + const direction = zoomOut ? 'out' : 'in'; + if (!held.changed) { + return { + ok: false, + action: 'adjust_camera_zoom', + direction, + amount: amt, + error: `Not zoomed: the ${held.kind} is already at its ${zoomOut ? 'farthest' : 'closest'} range.`, + }; + } return { ok: true, action: 'adjust_camera_zoom', - direction: zoomOut ? 'out' : 'in', + direction, amount: amt, - orbitRadiusAdjusted: true, + [held.kind === 'tilt' ? 'tiltRangeAdjusted' : 'orbitRadiusAdjusted']: + true, }; } } From 99520b529c4d1a3a4adc7268ca935d66bcdba923 Mon Sep 17 00:00:00 2001 From: Manjunath Date: Tue, 3 Nov 2026 20:02:54 +0530 Subject: [PATCH 19/71] fix(camera): keep the globe in view from orbit From the whole-globe view the camera could end up facing empty space with only stars on screen. Cesium's free look (Shift+drag) turns the camera in place, and from 18,000 km the planet's edge is only about 15 degrees from nadir, so a few drags turned it out of frame. The rolled pose was then saved into the share hash, so a reload or shared link opened on stars too. - Allow free look only below 1,000 km. Orbit, tilt and zoom are unchanged. - Measure how much of the frame shows the planet's surface by casting a 32x18 grid of view rays at the WGS84 ellipsoid. While the scope mask is on (the default) only the rays inside its keyhole count, since the mask hides or dims the rest of the frame. - When a camera move ends above 1,000 km with no ray on the surface, turn straight down at the same position and heading. Limb glow and a sliver up to about one grid cell thick can count as out of view. The end of a move comes from Cesium's moveEnd and from a check of its own that samples the pose every 250 ms after moveStart and acts once it has held for half a second: at some of these poses the camera drifts by a rounding error every frame, so Cesium never sees it stop and never raises moveEnd. The zoom-out guard from #1522 keeps wheel and pinch zoom-outs in view; this catches the other ways a move can end looking away, such as a saved or shared view that did, or a drag that Cesium turns into an in-place look below its 7,500 km trackball height (one that starts on the stars, or a tilt drag with the screen centre off the planet). Views with more surface, tracked or locked cameras and the 2D and Columbus modes are left alone. - Voice tilt and in-place turns of a free 3D globe camera above 1,000 km stop when about a tenth of the frame still shows the planet (or about as much as it showed when it started, two grid cells of sampling noise allowed), never below about 1% of the visible frame; a view that already shows less only keeps or gains what it has. A turn that cannot go half a degree further without crossing that limit is refused with a reason instead of reported as done, and a turn it accepts turns at least that half degree. A turn that keeps or gains surface is not refused, even after the window has been resized, except that near the limit, where the planet's edge crosses the sample grid, sampling wobble can occasionally refuse one (most often above about 15,000 km and on phone or ultrawide frames); nothing then turns away from the planet. A turn that starts where the last voice turn left the camera keeps that turn's limit, or a higher one if the planet now fills more, so repeating the request does not take any more; once anything else has moved or turned the camera, a turn is measured from what is on screen. A request made while the camera is tracked is judged by the pose voice's release of the follow camera will leave, before that release, so a refused turn keeps the tracking. The frame measure and the look-step limit live in src/globeInView.js, shared by the viewer and the voice camera verbs. --- docs/CURRENT-STATE.md | 40 +++ scripts/package-boundaries.json | 5 + src/app/application.test.mjs | 431 ++++++++++++++++++++++++++- src/app/scene.js | 11 + src/app/viewer.js | 172 +++++++++++ src/cameraGlobeRotation.test.mjs | 491 +++++++++++++++++++++++++++++++ src/cameraVerbs.js | 179 ++++++++++- src/globeInView.js | 167 +++++++++++ 8 files changed, 1491 insertions(+), 5 deletions(-) create mode 100644 src/globeInView.js diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 87b918a4e..3fde50e05 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -65,6 +65,46 @@ settling. Oblique views below 300 km are left alone. `QA_BASE_URL=http://localhost:4173 node scripts/qa-zoom-out-guard.mjs` exercises the real browser controller at desktop and phone widths with keyless fixtures. +Cesium's free look (Shift+drag, which turns the camera in place) works only +below 1,000 km. Above that height a few drags could turn the whole planet out of +frame and leave only stars, so Shift+drag does nothing there. Orbit, tilt and +zoom are unchanged at every height. How much of the frame shows the planet's +surface is measured by casting a 32×18 grid of view rays at the ellipsoid; while +the scope mask is on (the default) only the rays inside its keyhole count, since +the mask hides or dims the rest. Voice tilt and in-place turns of a free 3D +camera above 1,000 km stop when about a tenth of the frame still shows the +surface (or about as much as it showed when it started), never less than about +1% of the visible frame, and a view that already shows less only keeps or gains +what it has. A turn that cannot go half a degree further without crossing that +limit is refused with a reason instead of reported as done, and a turn it +accepts turns at least that half degree. A turn that keeps or gains surface is +not refused, even after the window has been resized, except that near the limit, +where the planet's edge crosses the sample grid, sampling wobble can +occasionally refuse one (most often above about 15,000 km and on phone or +ultrawide frames); nothing then turns away from the planet. A turn that starts +where the last voice turn left the camera keeps that turn's limit, or a higher +one if the planet now fills more, so repeating the request does not take any +more; once anything else has moved or turned the camera, a turn is measured from +what is on screen. When a camera move ends above 1,000 km with no ray on the +surface, the camera turns straight down at the same position and heading. The +end of a move is taken from Cesium's move-end event and from a check of its own +that samples the pose every 250 ms after a move starts and acts once it has held +for half a second, because at some of these poses the camera drifts by a +rounding error every frame and Cesium never sees it stop. That check keeps +watching until move-end arrives, recovering once after each move that comes to +rest, and gives up five minutes after the camera last moved. Limb glow, and a +surface sliver up to about one grid cell thick (some 50 px on a 900 px tall +frame), can count as out of view. The zoom-out guard above keeps wheel and +pinch zoom-outs in view; this catches the other ways a move can end looking +away, such as a saved or shared view that did, or a drag +that Cesium turns into an in-place look below its 7,500 km trackball height (one +that starts on the stars, or a tilt drag with the screen centre off the planet). +Views with more surface than that, such as the oblique orbital Director shots, +are left as they are, and so are tracked or locked cameras and the 2D and +Columbus modes. A voice turn requested while the camera is tracked is judged +by the pose voice's release of the follow camera will leave, before that +release, so a refused turn keeps the tracking. + Analyst region queries resolve unique Natural Earth names offline without geocoding, so Gold Coast and Ivory Coast name the West African natural regions. Duplicate names require a geocoded containment anchor; when several entries for diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 44c481152..b029af646 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -158,6 +158,7 @@ "modules": [ "src/app/viewer.js", "src/app/atmosphereCompat.js", + "src/globeInView.js", "src/sky/starrySky.js", "src/sky/stars.js", "src/data/bundledJson.js", @@ -2297,6 +2298,7 @@ "src/camera.js", "src/cameraGroundGuard.js", "src/cameraVerbs.js", + "src/globeInView.js", "src/cctvFocusPolicy.js", "src/cctvFocusRequest.js", "src/celestialRing.js", @@ -3604,6 +3606,9 @@ "src/annotations/worldAnnotationRenderer.js", "src/cameraGroundGuard.js", "src/cameraVerbs.js", + "src/globeInView.js", + "src/scopeMaskPolicy.js", + "src/scopeMask.js", "src/services/groundFloor.js", "src/data/routeSteps.js", "src/layers/directions/index.js", diff --git a/src/app/application.test.mjs b/src/app/application.test.mjs index 3e9079b87..401eefa0b 100644 --- a/src/app/application.test.mjs +++ b/src/app/application.test.mjs @@ -2,7 +2,20 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; import { createApplication } from './application.js'; -import { installTrackpadPinchZoom } from './viewer.js'; +import { KEYHOLE_OUTER_RADIUS } from '../celestialRing.js'; +import * as Cesium from 'cesium'; +import { + installFreeLookHeightLimit, + installGlobeInViewRecovery, + installTrackpadPinchZoom, +} from './viewer.js'; +import { + FREE_LOOK_MAX_HEIGHT_METERS, + globeFrameShare, + globeOutOfFrame, + lookAngleWithinFrame, + lookStepLosesGlobe, +} from '../globeInView.js'; const phases = ['Scene', 'Controls', 'Data', 'Tools']; function fixture(overrides = {}) { @@ -343,3 +356,419 @@ test('trackpad pinch falls back to the original event when relay construction fa assert.equal(event.propagationStopped, false); assert.equal(f.relayed.length, 0); }); + +function freeLookFixture({ enableLook = true, height = 0 } = {}) { + const listeners = new Set(); + const controller = { enableLook }; + const camera = { positionCartographic: { height } }; + const viewer = { + camera, + scene: { + screenSpaceCameraController: controller, + preUpdate: { + addEventListener(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }, + }, + }; + const frame = (nextHeight) => { + camera.positionCartographic = { height: nextHeight }; + for (const listener of listeners) listener(); + }; + return { viewer, controller, listeners, frame }; +} + +test('free look follows camera height and is restored on dispose', () => { + const f = freeLookFixture({ height: 500 }); + const dispose = installFreeLookHeightLimit(f.viewer); + assert.equal(f.controller.enableLook, true); + + f.frame(FREE_LOOK_MAX_HEIGHT_METERS + 1); + assert.equal(f.controller.enableLook, false); + f.frame(FREE_LOOK_MAX_HEIGHT_METERS); + assert.equal(f.controller.enableLook, true); + f.frame(Number.NaN); + assert.equal(f.controller.enableLook, true); + + f.frame(FREE_LOOK_MAX_HEIGHT_METERS * 20); + dispose(); + dispose(); + assert.equal(f.controller.enableLook, true); + assert.equal(f.listeners.size, 0); +}); + +test('free look stays off when the viewer had it disabled', () => { + const f = freeLookFixture({ enableLook: false, height: 500 }); + installFreeLookHeightLimit(f.viewer); + f.frame(10); + assert.equal(f.controller.enableLook, false); +}); + +// A real Cesium camera on the smallest scene its constructor reads, so the +// frustum test runs on Cesium's own view math rather than a stub. +function realCamera({ width = 1600, height = 900, mode } = {}) { + const scene = { + canvas: { clientWidth: width, clientHeight: height }, + drawingBufferWidth: width, + drawingBufferHeight: height, + mapProjection: new Cesium.GeographicProjection(), + mode: mode ?? Cesium.SceneMode.SCENE3D, + pixelRatio: 1, + ellipsoid: Cesium.Ellipsoid.WGS84, + mapMode2D: Cesium.MapMode2D.INFINITE_SCROLL, + renders: 0, + requestRender() { + this.renders += 1; + }, + }; + const camera = new Cesium.Camera(scene); + camera.frustum.aspectRatio = width / height; + return { camera, scene, viewer: { camera, scene } }; +} + +function aim( + camera, + { lon = -94.69, lat = 18.87, alt, heading = 0, pitch, roll = 0 }, +) { + camera.setView({ + destination: Cesium.Cartesian3.fromDegrees(lon, lat, alt), + orientation: { + heading: Cesium.Math.toRadians(heading), + pitch: Cesium.Math.toRadians(pitch), + roll: Cesium.Math.toRadians(roll), + }, + }); +} + +const reportedStuckView = { + alt: 17_996_811, + heading: 180, + pitch: -42, + roll: 180, +}; +// Oblique poses that still show part of the planet: Director recipe shots +// (Global Flights Radar, Omniscience Pullback) and a voice tilt from orbit. +const partialGlobeViews = [ + { lon: -30, lat: 20, alt: 19_000_000, pitch: -65 }, + { lon: 2, lat: 46, alt: 8_000_000, pitch: -52 }, + { lon: 110, lat: 20, alt: 9_500_000, pitch: -66 }, + { lon: 0, lat: 0, alt: 2_000_000, pitch: -30 }, +]; + +// Stars and a few pixels of limb, no surface: where a frame limit measured +// against a sphere of the planet's largest radius used to stop a voice tilt. +const limbOnlyView = { alt: 18_000_000, pitch: -56.75 }; + +/** + * An oracle independent of the code under test: the share of an 80×45 grid of + * Cesium pick rays across the canvas that hit the ellipsoid surface. + */ +function surfacePixelShare(camera, width, height, { keyhole = false } = {}) { + const radius = (KEYHOLE_OUTER_RADIUS * height) / 2; + let hits = 0; + let counted = 0; + for (let row = 0; row < 45; row += 1) { + for (let column = 0; column < 80; column += 1) { + const x = ((column + 0.5) * width) / 80; + const y = ((row + 0.5) * height) / 45; + if (keyhole && Math.hypot(x - width / 2, y - height / 2) > radius) + continue; + counted += 1; + const ray = camera.getPickRay(new Cesium.Cartesian2(x, y)); + if (Cesium.IntersectionTests.rayEllipsoid(ray, Cesium.Ellipsoid.WGS84)) + hits += 1; + } + } + return hits / counted; +} + +test('the globe is out of frame only when no surface is on screen', () => { + const cases = [ + [reportedStuckView, 1225, 923, true], + [limbOnlyView, 1600, 900, true], + [{ alt: 17_996_811, pitch: -90 }, 1225, 923, false], + ...partialGlobeViews.map((view) => [view, 1600, 900, false]), + ]; + for (const [view, width, height, out] of cases) { + const { camera } = realCamera({ width, height }); + aim(camera, view); + const label = JSON.stringify(view); + assert.equal(globeOutOfFrame(camera), out, label); + assert.equal(surfacePixelShare(camera, width, height) === 0, out, label); + assert.ok( + Math.abs( + globeFrameShare(camera) - surfacePixelShare(camera, width, height), + ) < 0.03, + `${label}: the frame share matches the pick-ray oracle`, + ); + } +}); + +test('a move that ends with the globe out of frame turns back to nadir', () => { + const { camera, scene, viewer } = realCamera({ width: 1225, height: 923 }); + const dispose = installGlobeInViewRecovery(viewer); + aim(camera, reportedStuckView); + const position = Cesium.Cartesian3.clone(camera.positionWC); + const heading = camera.heading; + camera.moveEnd.raiseEvent(); + assert.ok(Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6)); + assert.ok(Cesium.Math.equalsEpsilon(camera.heading, heading, 1e-6)); + assert.ok( + Cesium.Cartesian3.equalsEpsilon(camera.positionWC, position, 0, 1e-3), + ); + assert.equal(globeOutOfFrame(camera), false); + assert.equal(scene.renders, 1); + + // Looking up near the ground, then zoomed out past the limit. + aim(camera, { alt: 3_834_000, pitch: 10 }); + camera.moveEnd.raiseEvent(); + assert.ok(Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6)); + + // Only the limb on a 16:9 screen still counts as lost. + const wide = realCamera({ width: 1600, height: 900 }); + const disposeWide = installGlobeInViewRecovery(wide.viewer); + aim(wide.camera, limbOnlyView); + wide.camera.moveEnd.raiseEvent(); + assert.ok(Cesium.Math.equalsEpsilon(wide.camera.pitch, -Math.PI / 2, 1e-6)); + disposeWide(); + + dispose(); + dispose(); + aim(camera, reportedStuckView); + camera.moveEnd.raiseEvent(); + assert.equal(globeOutOfFrame(camera), true, 'disposed recovery is inert'); +}); + +// True when a moveEnd leaves the camera's view direction as it was. +function moveEndKeepsView(camera) { + const direction = Cesium.Cartesian3.clone(camera.directionWC); + camera.moveEnd.raiseEvent(); + return Cesium.Cartesian3.equalsEpsilon(camera.directionWC, direction, 1e-9); +} + +test('globe recovery leaves partial, low, locked and 2D views alone', () => { + for (const view of partialGlobeViews) { + const { camera, viewer } = realCamera(); + installGlobeInViewRecovery(viewer); + aim(camera, view); + assert.ok(moveEndKeepsView(camera), JSON.stringify(view)); + } + + const low = realCamera(); + installGlobeInViewRecovery(low.viewer); + aim(low.camera, { alt: 900_000, pitch: 85 }); + assert.equal(globeOutOfFrame(low.camera), true); + assert.ok(moveEndKeepsView(low.camera), 'below the limit'); + + // Locked on a point in space, between it and the planet, facing away. + const locked = realCamera(); + installGlobeInViewRecovery(locked.viewer); + locked.camera.lookAtTransform( + Cesium.Matrix4.fromTranslation(new Cesium.Cartesian3(30_000_000, 0, 0)), + new Cesium.Cartesian3(-1_000_000, 0, 0), + ); + assert.equal(globeOutOfFrame(locked.camera), true); + assert.ok(moveEndKeepsView(locked.camera), 'locked camera'); + + // Tracking an entity that has no position yet keeps an identity transform. + const tracked = realCamera(); + tracked.viewer.trackedEntity = { id: 'pending-fix' }; + installGlobeInViewRecovery(tracked.viewer); + aim(tracked.camera, reportedStuckView); + assert.ok(moveEndKeepsView(tracked.camera), 'tracked camera'); + + const flat = realCamera({ mode: Cesium.SceneMode.COLUMBUS_VIEW }); + installGlobeInViewRecovery(flat.viewer); + flat.scene.mode = Cesium.SceneMode.SCENE3D; + aim(flat.camera, reportedStuckView); + flat.scene.mode = Cesium.SceneMode.COLUMBUS_VIEW; + assert.ok(moveEndKeepsView(flat.camera), 'Columbus view'); +}); + +test('the look limit from orbit tolerates grid noise and never reaches zero', () => { + const radians = Cesium.Math.toRadians; + // About 2% of a 16:9 frame on the planet, below the 10% limit. + const sliver = { alt: 18_000_000, pitch: -60 }; + const { camera } = realCamera(); + aim(camera, sliver); + const start = surfacePixelShare(camera, 1600, 900); + assert.ok(start > 0 && start < 0.1, `starts below the limit: ${start}`); + + // Sideways across the sliver: the share wobbles by a cell, the planet stays. + assert.equal(lookStepLosesGlobe(camera, 'lookLeft', radians(0.5)), false); + const startShare = globeFrameShare(camera); + for (let i = 0; i < 20; i += 1) { + const step = lookAngleWithinFrame(camera, 'lookLeft', radians(0.25), { + startShare, + }); + assert.equal(step, radians(0.25), `sideways step ${i + 1}`); + camera.lookLeft(step); + } + assert.ok(surfacePixelShare(camera, 1600, 900) > 0); + + // Toward the planet is always allowed. + aim(camera, sliver); + assert.equal( + lookAngleWithinFrame(camera, 'lookDown', radians(10)), + radians(10), + ); + + // Away from it stops short of losing it. + aim(camera, sliver); + const away = lookAngleWithinFrame(camera, 'lookUp', radians(30)); + assert.ok(away < radians(30)); + camera.lookUp(away); + assert.ok(globeFrameShare(camera) > 0); + assert.ok(surfacePixelShare(camera, 1600, 900) > 0); +}); + +test('the look limit leaves a view with no surface to the move-end recovery', () => { + const { camera } = realCamera({ width: 1225, height: 923 }); + aim(camera, reportedStuckView); + assert.equal( + lookAngleWithinFrame(camera, 'lookUp', Cesium.Math.toRadians(5)), + Cesium.Math.toRadians(5), + ); +}); + +// Surface only in the corners of a 16:9 frame, all of it outside the scope's +// keyhole: the full frame shows some planet, the scope shows none. +const cornerOnlyView = { alt: 18_000_000, pitch: -52, roll: 60 }; + +test('with a scope keyhole, surface hidden in the masked corners counts as none', () => { + const { camera } = realCamera(); + aim(camera, cornerOnlyView); + const keyhole = { keyholeRadius: KEYHOLE_OUTER_RADIUS }; + assert.ok(surfacePixelShare(camera, 1600, 900) > 0); + assert.equal(surfacePixelShare(camera, 1600, 900, { keyhole: true }), 0); + assert.equal(globeOutOfFrame(camera), false); + assert.equal(globeOutOfFrame(camera, keyhole), true); + aim(camera, { alt: 18_000_000, pitch: -90 }); + assert.ok( + Math.abs( + globeFrameShare(camera, keyhole) - + surfacePixelShare(camera, 1600, 900, { keyhole: true }), + ) < 0.03, + 'the keyhole share matches the pick-ray oracle', + ); +}); + +test('the recovery follows the scope: corner-only views are turned back only behind it', () => { + for (const [scopeOn, expectRecovery] of [ + [true, true], + [false, false], + ]) { + const { camera, viewer } = realCamera(); + installGlobeInViewRecovery(viewer, { + keyholeRadius: () => (scopeOn ? KEYHOLE_OUTER_RADIUS : 0), + }); + aim(camera, cornerOnlyView); + camera.moveEnd.raiseEvent(); + assert.equal( + Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6), + expectRecovery, + `scope ${scopeOn ? 'on' : 'off'}`, + ); + } +}); + +function fakeTimers() { + let next = 1; + const pending = new Map(); + return { + setTimeout: (callback) => { + const id = next++; + pending.set(id, callback); + return id; + }, + clearTimeout: (id) => pending.delete(id), + pending: () => pending.size, + /** Run the earliest pending callback, as 250 ms passing would. */ + tick() { + const [id, callback] = pending.entries().next().value ?? []; + if (id === undefined) return false; + pending.delete(id); + callback(); + return true; + }, + }; +} + +test('a stuck view is recovered once it has held still, without waiting for moveEnd', () => { + // At this pose the camera can drift by a rounding error every frame, so + // Cesium never sees it stop and never raises moveEnd for it. + const timers = fakeTimers(); + const { camera, viewer } = realCamera({ width: 1225, height: 923 }); + installGlobeInViewRecovery(viewer, timers); + aim(camera, reportedStuckView); + camera.moveStart.raiseEvent(); + timers.tick(); + assert.equal(globeOutOfFrame(camera), true, 'one quiet sample is not enough'); + // A rounding-error drift between samples still counts as at rest. + camera.lookUp(1e-15); + timers.tick(); + assert.ok(Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6)); + // Under that drift Cesium raises no new moveStart, so a later move that + // ends looking away is caught by the same check. + aim(camera, reportedStuckView); + timers.tick(); + timers.tick(); + assert.equal(globeOutOfFrame(camera), true, 'not yet at rest'); + timers.tick(); + assert.ok(Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6)); + camera.moveEnd.raiseEvent(); + assert.equal(timers.pending(), 0, 'moveEnd ends the check'); +}); + +test('the settle check follows a long move and gives up five minutes after the last one', () => { + // A drag of half a minute that stops at a stuck pose is still recovered. + const long = fakeTimers(); + const dragged = realCamera({ width: 1225, height: 923 }); + installGlobeInViewRecovery(dragged.viewer, long); + aim(dragged.camera, reportedStuckView); + dragged.camera.moveStart.raiseEvent(); + for (let i = 0; i < 120; i += 1) { + dragged.camera.moveLeft(0.01); + long.tick(); + } + assert.equal(globeOutOfFrame(dragged.camera), true, 'still moving'); + while (long.tick()); + assert.ok( + Cesium.Math.equalsEpsilon(dragged.camera.pitch, -Math.PI / 2, 1e-6), + 'recovered once it stopped', + ); + + // A camera that keeps moving is followed, and left alone, for as long as it + // moves; once it rests the check gives up five minutes later. + const timers = fakeTimers(); + const { camera, viewer } = realCamera({ width: 1225, height: 923 }); + installGlobeInViewRecovery(viewer, timers); + aim(camera, reportedStuckView); + camera.moveStart.raiseEvent(); + for (let i = 0; i < 2000; i += 1) { + camera.moveLeft(0.01); + timers.tick(); + } + assert.equal(timers.pending(), 1, 'still following after eight minutes'); + assert.equal(globeOutOfFrame(camera), true, 'a moving camera is left alone'); + let idleSamples = 0; + while (timers.tick()) idleSamples += 1; + assert.ok( + Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6), + 'recovered once it stopped', + ); + assert.ok( + idleSamples >= 1200 && idleSamples <= 1205, + `gave up ${idleSamples} samples after it stopped`, + ); + + const disposed = fakeTimers(); + const other = realCamera({ width: 1225, height: 923 }); + const dispose = installGlobeInViewRecovery(other.viewer, disposed); + aim(other.camera, reportedStuckView); + other.camera.moveStart.raiseEvent(); + dispose(); + assert.equal(disposed.pending(), 0, 'dispose cancels the pending check'); +}); diff --git a/src/app/scene.js b/src/app/scene.js index 1455b8268..9c115638d 100644 --- a/src/app/scene.js +++ b/src/app/scene.js @@ -2,9 +2,13 @@ import { createApplicationOperations } from './operations.js'; import * as Cesium from 'cesium'; import { createApplicationViewer, + installFreeLookHeightLimit, + installGlobeInViewRecovery, installTrackpadPinchZoom, } from '../app/viewer.js'; import { registerDataCredits } from '../data/dataCredits.js'; +import { KEYHOLE_OUTER_RADIUS } from '../celestialRing.js'; +import { isScopeMaskEnabled } from '../scopeMask.js'; import { configureCreditKeyboardAccess } from '../creditKeyboard.js'; import { MapStackController } from '../mapStackController.js'; import { loadPhotorealisticTileset } from '../mapStartup.js'; @@ -58,6 +62,13 @@ export async function createApplicationScene({ if (!viewer.isDestroyed()) viewer.destroy(); }); defer(installTrackpadPinchZoom(viewer)); + defer(installFreeLookHeightLimit(viewer)); + defer( + installGlobeInViewRecovery(viewer, { + // The scope mask hides everything outside its keyhole. + keyholeRadius: () => (isScopeMaskEnabled() ? KEYHOLE_OUTER_RADIUS : 0), + }), + ); registerDataCredits(viewer, credits); configureCreditKeyboardAccess(document); defer(onLocaleChange(() => configureCreditKeyboardAccess(document))); diff --git a/src/app/viewer.js b/src/app/viewer.js index c8b841352..ce182a0ab 100644 --- a/src/app/viewer.js +++ b/src/app/viewer.js @@ -1,6 +1,10 @@ import * as Cesium from 'cesium'; import { applyModelAtmosphereWorkaround } from './atmosphereCompat.js'; import { StarrySky } from '../sky/starrySky.js'; +import { + FREE_LOOK_MAX_HEIGHT_METERS, + globeOutOfFrame, +} from '../globeInView.js'; const PINCH_ZOOM_MULTIPLIER = 8; const MAX_PINCH_PIXEL_DELTA = 120; @@ -103,6 +107,174 @@ export function installTrackpadPinchZoom( }; } +/** + * Allow Cesium's free look only below `maxHeight` and return its disposer. + * Orbit, tilt and zoom stay available at every height. `enableLook` gates the + * Shift+drag binding only; a drag that starts on the stars below Cesium's + * trackball height still turns the camera in place, which + * `installGlobeInViewRecovery` catches. + */ +export function installFreeLookHeightLimit( + viewer, + maxHeight = FREE_LOOK_MAX_HEIGHT_METERS, +) { + const scene = viewer?.scene; + const controller = scene?.screenSpaceCameraController; + const camera = viewer?.camera; + if (!controller || !camera || !scene?.preUpdate) + throw new TypeError('A complete Cesium viewer is required'); + + const originalEnableLook = controller.enableLook; + const sync = () => { + const height = camera.positionCartographic?.height; + controller.enableLook = + originalEnableLook && !(Number.isFinite(height) && height > maxHeight); + }; + sync(); + const removeListener = scene.preUpdate.addEventListener(sync); + + let active = true; + return () => { + if (!active) return; + active = false; + removeListener(); + controller.enableLook = originalEnableLook; + }; +} + +// How often a moving camera is sampled to see whether it has come to rest, how +// far a pose may drift between samples and still count as at rest, and how +// long after the camera last moved the check gives up if Cesium never raises +// moveEnd. +const SETTLE_SAMPLE_MS = 250; +const SETTLE_POSITION_TOLERANCE_M = 1e-3; +const SETTLE_DIRECTION_TOLERANCE = 1e-9; +const SETTLE_GIVE_UP_MS = 5 * 60_000; + +/** + * Turn the camera back to the planet when a move ends above `minHeight` with + * no surface on screen (`globeOutOfFrame`: limb glow and slivers up to about + * one sample cell thick can count as none), and return the disposer. Views with + * more surface, such as an oblique orbital shot, are left as they are. This + * catches a saved or shared view that looked away, and a drag that Cesium turns + * into an in-place look below its trackball height whatever `enableLook` says. + * Heading and position are kept. Tracked cameras, locked ones (non-identity + * transform) and the 2D and Columbus modes are left alone. `keyholeRadius` + * returns the radius of the visible circle when a scope mask hides the rest of + * the frame (see `globeFrameShare`), or 0; it is read at every check. + * + * Cesium's `moveEnd` alone is not enough: Cesium compares the camera with the + * last frame's to the last rounding error, and at some of these poses the + * camera drifts by a rounding error every frame, so Cesium never sees it stop. + * So each `moveStart` also starts a check of its own that samples the pose + * every 250 ms, with a tolerance for that drift, and once the pose has held + * for half a second after moving runs the same recovery. It keeps watching + * until Cesium raises `moveEnd`, since under that drift Cesium raises no new + * `moveStart` for a later move, and gives up five minutes after the camera + * last moved. + * `setTimeout` and `clearTimeout` can be injected for tests. + */ +export function installGlobeInViewRecovery( + viewer, + { + minHeight = FREE_LOOK_MAX_HEIGHT_METERS, + keyholeRadius = () => 0, + setTimeout: schedule = globalThis.setTimeout, + clearTimeout: unschedule = globalThis.clearTimeout, + } = {}, +) { + const scene = viewer?.scene; + const camera = viewer?.camera; + if (!scene || !camera?.moveEnd || !camera?.moveStart) + throw new TypeError('A complete Cesium viewer is required'); + + const recover = () => { + if (scene.mode !== Cesium.SceneMode.SCENE3D || viewer.trackedEntity) return; + if (!Cesium.Matrix4.equals(camera.transform, Cesium.Matrix4.IDENTITY)) + return; + const height = camera.positionCartographic?.height; + if ( + !(height > minHeight) || + !globeOutOfFrame(camera, { keyholeRadius: keyholeRadius() }) + ) + return; + camera.setView({ + orientation: { + heading: camera.heading, + pitch: -Cesium.Math.PI_OVER_TWO, + roll: 0, + }, + }); + scene.requestRender?.(); + }; + + const lastPosition = new Cesium.Cartesian3(); + const lastDirection = new Cesium.Cartesian3(); + let settleTimer = null; + let settleElapsed = 0; + let restingSamples = 0; + let movedSinceCheck = false; + const stopSettling = () => { + if (settleTimer !== null) unschedule(settleTimer); + settleTimer = null; + }; + const sample = () => { + settleTimer = null; + settleElapsed += SETTLE_SAMPLE_MS; + const atRest = + Cesium.Cartesian3.equalsEpsilon( + camera.positionWC, + lastPosition, + 0, + SETTLE_POSITION_TOLERANCE_M, + ) && + Cesium.Cartesian3.equalsEpsilon( + camera.directionWC, + lastDirection, + 0, + SETTLE_DIRECTION_TOLERANCE, + ); + restingSamples = atRest ? restingSamples + 1 : 0; + if (!atRest) { + movedSinceCheck = true; + settleElapsed = 0; + } + // Two quiet samples in a row after a move: at rest for half a second, as + // for moveEnd. + if (restingSamples >= 2 && movedSinceCheck) { + movedSinceCheck = false; + recover(); + } + if (settleElapsed >= SETTLE_GIVE_UP_MS) return; + Cesium.Cartesian3.clone(camera.positionWC, lastPosition); + Cesium.Cartesian3.clone(camera.directionWC, lastDirection); + settleTimer = schedule(sample, SETTLE_SAMPLE_MS); + }; + const startSettling = () => { + stopSettling(); + settleElapsed = 0; + restingSamples = 0; + movedSinceCheck = true; + Cesium.Cartesian3.clone(camera.positionWC, lastPosition); + Cesium.Cartesian3.clone(camera.directionWC, lastDirection); + settleTimer = schedule(sample, SETTLE_SAMPLE_MS); + }; + const removeMoveEnd = camera.moveEnd.addEventListener(() => { + stopSettling(); + recover(); + }); + const removeMoveStart = camera.moveStart.addEventListener(startSettling); + + let active = true; + return () => { + if (!active) return; + active = false; + stopSettling(); + removeMoveEnd(); + removeMoveStart(); + }; +} + /** Cesium.Viewer options of the standard globe viewer. */ export function applicationViewerOptions(creditContainer) { return { diff --git a/src/cameraGlobeRotation.test.mjs b/src/cameraGlobeRotation.test.mjs index 814e4de1e..f15ae4978 100644 --- a/src/cameraGlobeRotation.test.mjs +++ b/src/cameraGlobeRotation.test.mjs @@ -13,6 +13,9 @@ import { moveCamera, } from './cameraVerbs.js'; import { getRenderGovernorDiagnostics } from './renderGovernor.js'; +import { MIN_GLOBE_FRAME_SHARE, globeFrameShare } from './globeInView.js'; +import { KEYHOLE_OUTER_RADIUS } from './celestialRing.js'; +import { setScopeMaskEnabled } from './scopeMask.js'; const radians = Cesium.Math.toRadians; const clone = (value) => Cesium.Cartesian3.clone(value); @@ -1347,3 +1350,491 @@ test('close-up ultrawide refusal does not offer an unfit reset or move the camer samePose(h.camera, before); assert.equal(hasRenderHold(), false); }); + +/** + * An oracle independent of the code under test: the share of an 80×45 grid of + * Cesium pick rays across the canvas that hit the ellipsoid surface. With + * `keyhole`, only the pixels inside the scope's visible circle count. + */ +function surfacePixelShare(camera, width, height, { keyhole = false } = {}) { + const radius = (KEYHOLE_OUTER_RADIUS * height) / 2; + let hits = 0; + let counted = 0; + for (let row = 0; row < 45; row += 1) { + for (let column = 0; column < 80; column += 1) { + const x = ((column + 0.5) * width) / 80; + const y = ((row + 0.5) * height) / 45; + if (keyhole && Math.hypot(x - width / 2, y - height / 2) > radius) + continue; + counted += 1; + const ray = camera.getPickRay(new Cesium.Cartesian2(x, y)); + if (Cesium.IntersectionTests.rayEllipsoid(ray, Cesium.Ellipsoid.WGS84)) + hits += 1; + } + } + return hits / counted; +} + +/** Run a test with the scope mask on or off, restoring the default after. */ +function withScope(t, enabled) { + setScopeMaskEnabled(enabled); + t.after(() => setScopeMaskEnabled(true)); +} + +// The frame limit is measured on a coarser grid than the oracle. +const SHARE_SLACK = 0.02; +// Far-out poses, height in metres and pitch in degrees, where a few tilts up +// are accepted before the limit refuses the next one. +const FAR_OUT_POSES = [ + [35_884_000, -86.26], + [35_884_000, -89], + [30_000_000, -86], + [25_000_000, -80], + [40_000_000, -88], +]; + +test('voice tilt from orbit stops with the planet still on screen and then says so', (t) => { + withScope(t, false); // the whole frame is visible + // Slightly off nadir: from straight down a tilt is clamped before it moves. + const { camera, finishOnce } = harness(t, { + pitch: -80, + viewportWidth: 1600, + viewportHeight: 900, + }); + const results = []; + for (let i = 0; i < 4; i += 1) { + const result = moveCamera({ + motion: 'tilt', + direction: 'up', + mode: 'once', + }); + results.push(result); + if (result.ok) finishOnce(); + assert.ok( + surfacePixelShare(camera, 1600, 900) >= + MIN_GLOBE_FRAME_SHARE - SHARE_SLACK, + `after tilt ${i + 1}`, + ); + } + // From 18,000 km a 16:9 frame keeps a tenth of its area on the planet down + // to about -67°: the first tilt stops there, and later ones are refused. + assert.deepEqual( + results.map((result) => result.ok), + [true, false, false, false], + ); + assert.match(results[1].error, /globe out of view/); + assert.ok(camera.pitch > radians(-68.5) && camera.pitch < radians(-66)); +}); + +test('voice turn in place from orbit stops with the planet still on screen', (t) => { + withScope(t, false); // the whole frame is visible + const { camera, tick } = harness(t, { pitch: -75 }); + assert.equal( + moveCamera({ motion: 'rotate', direction: 'left', mode: 'continuous' }).ok, + true, + ); + const heading = camera.heading; + for (let i = 0; i < 400 && getActiveCameraMotion(); i += 1) { + tick(); + assert.ok( + surfacePixelShare(camera, 1280, 800) >= + MIN_GLOBE_FRAME_SHARE - SHARE_SLACK, + `tick ${i + 1}`, + ); + } + assert.equal(getActiveCameraMotion(), null, 'the turn stops at the limit'); + assert.notEqual(camera.heading, heading, 'the turn moved before stopping'); +}); + +test('a long voice turn below the limit keeps the share it started with', (t) => { + withScope(t, false); // the whole frame is visible + // About 2% of a 16:9 frame on the planet: the limit is that share, less two + // grid cells of sampling noise, for the whole motion rather than per step. + const { camera, tick } = harness(t, { + pitch: -60, + viewportWidth: 1600, + viewportHeight: 900, + }); + const start = globeFrameShare(camera); + assert.ok(start > 0 && start < MIN_GLOBE_FRAME_SHARE); + assert.equal( + moveCamera({ motion: 'rotate', direction: 'left', mode: 'continuous' }).ok, + true, + ); + for (let i = 0; i < 400 && getActiveCameraMotion(); i += 1) { + tick(); + assert.ok( + globeFrameShare(camera) >= start - 2 / (32 * 18) - 1e-9, + `tick ${i + 1}`, + ); + } + assert.equal(getActiveCameraMotion(), null, 'the turn stops at the limit'); + assert.ok(surfacePixelShare(camera, 1600, 900) > 0); +}); + +test('repeated voice turns from one spot cannot each take a little more', (t) => { + withScope(t, false); // the whole frame is visible + // About 2% of a 16:9 frame on the planet. Every turn from this position is + // measured against the same baseline, so asking again stops being allowed. + const { camera, finishOnce, tick } = harness(t, { + pitch: -60, + viewportWidth: 1600, + viewportHeight: 900, + }); + const start = globeFrameShare(camera); + const floor = start - 2 / (32 * 18) - 1e-9; + const results = []; + for (const mode of ['once', 'once', 'continuous', 'once', 'continuous']) { + for (let i = 0; i < 4; i += 1) { + const result = moveCamera({ motion: 'rotate', direction: 'left', mode }); + results.push(result.ok); + if (!result.ok) continue; + if (mode === 'once') finishOnce(); + else for (let j = 0; j < 400 && getActiveCameraMotion(); j += 1) tick(); + assert.ok(globeFrameShare(camera) >= floor, `${mode} turn ${i + 1}`); + } + } + assert.ok(results.includes(false), 'a turn at the limit is refused'); + assert.equal(results.at(-1), false); + assert.ok(surfacePixelShare(camera, 1600, 900) > 0); +}); + +test('a voice turn after something else turned the camera starts over', (t) => { + withScope(t, false); // the whole frame is visible + // The same spot, first framed straight down, then turned to about 2% of the + // frame by something other than voice: a turn left is measured from there. + const { camera, finishOnce } = harness(t, { + pitch: -90, + viewportWidth: 1600, + viewportHeight: 900, + }); + assert.equal( + moveCamera({ motion: 'rotate', direction: 'left', mode: 'once' }).ok, + true, + ); + finishOnce(); + camera.setView({ + orientation: { heading: 0, pitch: radians(-60), roll: 0 }, + }); + const start = globeFrameShare(camera); + assert.ok(start > 0 && start < MIN_GLOBE_FRAME_SHARE); + assert.equal( + moveCamera({ motion: 'rotate', direction: 'left', mode: 'once' }).ok, + true, + ); + finishOnce(); + assert.ok(globeFrameShare(camera) >= start - 2 / (32 * 18) - 1e-9); +}); + +test('nudging the camera between voice turns cannot slide the planet away', (t) => { + withScope(t, false); // the whole frame is visible + // A wheel notch or an orbit drag between requests starts each turn afresh; + // the 1% floor still holds, and below it a turn may only keep what it has. + for (const [nudge, pitch] of [ + [(camera) => camera.moveBackward(2), -60], + [(camera) => camera.rotateRight(radians(0.2)), -60], + [(camera) => camera.moveBackward(2), -58], + ]) { + const { camera, tick } = harness(t, { + pitch, + viewportWidth: 1600, + viewportHeight: 900, + }); + const start = globeFrameShare(camera); + const floor = Math.min(0.01, start) - 1e-9; + for (let i = 0; i < 12; i += 1) { + nudge(camera); + if ( + moveCamera({ motion: 'rotate', direction: 'left', mode: 'continuous' }) + .ok + ) + for (let j = 0; j < 400 && getActiveCameraMotion(); j += 1) tick(); + assert.ok( + globeFrameShare(camera) >= floor, + `start ${start}, turn ${i + 1}`, + ); + } + assert.ok(surfacePixelShare(camera, 1600, 900) > 0); + } +}); + +test('after the frame changes size, a voice turn toward the planet is allowed', (t) => { + withScope(t, false); // the whole frame is visible + // A tilt stops at its limit; then the window widens at the same pose, so the + // frame shows less than the carried limit. Toward the planet must still go. + const { camera, canvas, viewer, finishOnce } = harness(t, { + pitch: -80, + viewportWidth: 1600, + viewportHeight: 900, + }); + assert.equal( + moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' }).ok, + true, + ); + finishOnce(); + canvas.clientWidth = canvas.width = viewer.scene.drawingBufferWidth = 3200; + camera.frustum.aspectRatio = 3200 / 900; + const narrowed = globeFrameShare(camera); + assert.ok(narrowed < MIN_GLOBE_FRAME_SHARE, `frame now shows ${narrowed}`); + + const down = moveCamera({ motion: 'tilt', direction: 'down', mode: 'once' }); + assert.equal(down.ok, true, down.error); + finishOnce(); + assert.ok(globeFrameShare(camera) > narrowed); + assert.equal( + moveCamera({ motion: 'tilt', direction: 'down', mode: 'once' }).ok, + true, + ); + finishOnce(); + const up = moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' }); + if (up.ok) finishOnce(); + assert.ok(surfacePixelShare(camera, 3200, 900) > 0); +}); + +test('with the scope on, a voice tilt stops while the keyhole still shows the planet', (t) => { + withScope(t, true); + const { camera, finishOnce } = harness(t, { + pitch: -80, + viewportWidth: 1600, + viewportHeight: 900, + }); + const results = []; + for (let i = 0; i < 4; i += 1) { + const result = moveCamera({ + motion: 'tilt', + direction: 'up', + mode: 'once', + }); + results.push(result.ok); + if (result.ok) finishOnce(); + assert.ok( + surfacePixelShare(camera, 1600, 900, { keyhole: true }) >= + MIN_GLOBE_FRAME_SHARE - SHARE_SLACK, + `after tilt ${i + 1}`, + ); + } + assert.equal(results[0], true); + assert.equal(results.at(-1), false, 'a tilt past the limit is refused'); +}); + +test('with the scope on, a voice turn cannot leave the planet only in the masked corners', (t) => { + // An oblique orbit view: turning in place swings the planet toward a corner, + // where the full frame still shows it but the scope mask hides it. + for (const [height, pitch] of [ + [5_000_000, -70], + [3_000_000, -75], + [18_000_000, -80], + ]) { + withScope(t, true); + const { camera, tick } = harness(t, { + height, + pitch, + viewportWidth: 1600, + viewportHeight: 900, + }); + assert.equal( + moveCamera({ motion: 'rotate', direction: 'left', mode: 'continuous' }) + .ok, + true, + ); + for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) tick(); + assert.equal(getActiveCameraMotion(), null, 'the turn stops at the limit'); + assert.ok( + surfacePixelShare(camera, 1600, 900, { keyhole: true }) >= + MIN_GLOBE_FRAME_SHARE - SHARE_SLACK, + `${height / 1000} km at ${pitch}°`, + ); + } +}); + +test('far out, a voice tilt is either refused or really moves, at display frame rates', (t) => { + // A small planet disc: its sampled share wobbles as it crosses the grid. + // Whatever the limit decides, "done" must mean the camera turned, and some + // of these tilts must be accepted for that to be checked at all. + let accepted = 0; + for (const [height, pitch] of FAR_OUT_POSES) { + withScope(t, true); + const { camera, tick } = harness(t, { + latitude: 45.37, + longitudeDeg: -33.33, + height, + heading: 89.9, + pitch, + viewportWidth: 1280, + viewportHeight: 1024, + }); + for (let i = 0; i < 4; i += 1) { + const before = camera.pitch; + const result = moveCamera({ + motion: 'tilt', + direction: 'up', + mode: 'once', + }); + if (!result.ok) { + assert.match(result.error, /zoom in first/); + continue; + } + accepted += 1; + for (let j = 0; j < 600 && getActiveCameraMotion(); j += 1) tick(1 / 60); + assert.ok( + camera.pitch - before > radians(0.5) - 1e-9, + `${height / 1000} km at ${pitch}°: tilt ${i + 1} reported done but moved ${Cesium.Math.toDegrees(camera.pitch - before)}°`, + ); + } + assert.ok(surfacePixelShare(camera, 1280, 1024, { keyhole: true }) > 0); + } + assert.ok(accepted > 0, 'no far-out tilt was accepted'); +}); + +test('a voice turn reported as done turns at least the half degree its check proved', (t) => { + // A grazing sliver: the first frame's small step lands in a dip of the + // sampled share, which used to stop the turn before it moved at all. + for (const scope of [true, false]) { + withScope(t, scope); + const { camera, tick } = harness(t, { + latitude: 20, + longitudeDeg: 10, + height: 18_000_000, + pitch: -58, + heading: 37, + viewportWidth: 1600, + viewportHeight: 900, + }); + const before = Cesium.Cartesian3.clone(camera.directionWC); + const result = moveCamera({ + motion: 'rotate', + direction: 'left', + mode: 'once', + }); + if (!result.ok) { + assert.match(result.error, /globe out of view/); + continue; + } + for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) tick(1 / 60); + const turned = Cesium.Cartesian3.angleBetween(before, camera.directionWC); + assert.ok( + turned >= radians(0.5) - 1e-9, + `scope ${scope ? 'on' : 'off'}: turned ${Cesium.Math.toDegrees(turned)}°`, + ); + assert.ok(surfacePixelShare(camera, 1600, 900, { keyhole: scope }) > 0); + } +}); + +/** Track an entity below `camera`, locking it into that entity's frame. */ +function trackFrom(viewer, camera) { + viewer.trackedEntity = { id: 'high-satellite' }; + const below = Cesium.Cartesian3.fromDegrees(10, 20, 400_000); + camera.lookAtTransform(Cesium.Transforms.eastNorthUpToFixedFrame(below)); +} + +test('a voice turn that releases a tracked camera is checked as released', (t) => { + // Voice releases the follow camera inside the navigation that runs the + // motion, so the request is judged by the pose the release will leave. + let accepted = 0; + for (const [scope, direction, motion] of [ + [true, 'up', 'tilt'], + [false, 'up', 'tilt'], + [false, 'left', 'rotate'], + ]) { + withScope(t, scope); + const { camera, viewer, tick } = harness(t, { + latitude: 20, + longitudeDeg: 10, + height: 18_000_000, + pitch: -58, + heading: 37, + viewportWidth: 1600, + viewportHeight: 900, + }); + trackFrom(viewer, camera); + const releaseThenRun = (start) => { + viewer.trackedEntity = undefined; + camera.lookAtTransform(Cesium.Matrix4.IDENTITY); + return start(); + }; + const before = Cesium.Cartesian3.clone(camera.directionWC); + const result = moveCamera( + { motion, direction, mode: 'once' }, + releaseThenRun, + ); + const label = `${motion} ${direction}, scope ${scope ? 'on' : 'off'}`; + if (!result.ok) { + assert.match(result.error, /globe out of view/, label); + continue; + } + accepted += 1; + for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) tick(1 / 60); + const turned = Cesium.Cartesian3.angleBetween(before, camera.directionWC); + assert.ok(turned >= radians(0.5) - 1e-9, `${label}: turned ${turned}`); + assert.ok( + surfacePixelShare(camera, 1600, 900, { keyhole: scope }) > 0, + `${label}: the planet stays on screen`, + ); + } + assert.ok(accepted > 0, 'no released turn was accepted'); +}); + +test('a refused voice turn leaves a tracked camera tracking', (t) => { + for (const scope of [true, false]) { + withScope(t, scope); + const { camera, viewer, tick } = harness(t, { + pitch: -80, + viewportWidth: 1600, + viewportHeight: 900, + }); + // Tilt up to the limit untracked, then track from there. + for (let i = 0; i < 40; i += 1) { + if (!moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' }).ok) + break; + for (let j = 0; j < 600 && getActiveCameraMotion(); j += 1) tick(1 / 60); + } + trackFrom(viewer, camera); + const tracked = viewer.trackedEntity; + const transform = Cesium.Matrix4.clone(camera.transform); + let released = false; + const result = moveCamera( + { motion: 'tilt', direction: 'up', mode: 'once' }, + (start) => { + released = true; + viewer.trackedEntity = undefined; + camera.lookAtTransform(Cesium.Matrix4.IDENTITY); + return start(); + }, + ); + const label = `scope ${scope ? 'on' : 'off'}`; + assert.equal(result.ok, false, label); + assert.match(result.error, /globe out of view/, label); + assert.equal(released, false, label); + assert.equal(viewer.trackedEntity, tracked, label); + assert.ok(Cesium.Matrix4.equals(camera.transform, transform), label); + } +}); + +test('the voice frame limit leaves Columbus view alone', (t) => { + // At its limit in 3D a further tilt up is refused; the same tilt in Columbus + // view is not limited by the frame. + withScope(t, false); + const { viewer, finishOnce } = harness(t, { + pitch: -80, + viewportWidth: 1600, + viewportHeight: 900, + }); + assert.equal( + moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' }).ok, + true, + ); + finishOnce(); + const refused = moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' }); + assert.match(refused.error || '', /globe out of view/); + viewer.scene.mode = Cesium.SceneMode.COLUMBUS_VIEW; + const result = moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' }); + assert.doesNotMatch(result.error || '', /globe out of view/); +}); + +test('voice tilt below the free-look height is not limited by the frame', (t) => { + const { camera, finishOnce } = harness(t, { height: 500_000, pitch: -60 }); + const result = moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' }); + assert.equal(result.ok, true); + finishOnce(); + near(camera.pitch, radians(-45), 1e-6, 'full 15° tilt'); +}); diff --git a/src/cameraVerbs.js b/src/cameraVerbs.js index e037472cb..964b5c1a7 100644 --- a/src/cameraVerbs.js +++ b/src/cameraVerbs.js @@ -20,11 +20,18 @@ import * as Cesium from 'cesium'; import { GLOBE_VIEW } from './locations.js'; +import { + globeFrameShare, + lookAngleWithinFrame, + lookStepLosesGlobe, +} from './globeInView.js'; import { projectEarthDiscToViewport, earthDiscScreenRadius, getKeyholeGeometry, + KEYHOLE_OUTER_RADIUS, } from './celestialRing.js'; +import { isScopeMaskEnabled } from './scopeMask.js'; import { holdContinuousRender, releaseContinuousRender, @@ -1008,6 +1015,137 @@ export function interruptCameraMotionIfActive( return interruptCameraMotion(reason); } +/** + * A free globe camera: 3D, not tracking, not locked to a frame. The frame + * limit for in-place turns applies to it alone, as the move-end recovery does. + */ +function isFreeGlobeCamera(cam) { + return ( + _viewer.scene.mode === Cesium.SceneMode.SCENE3D && + !_viewer.trackedEntity && + Cesium.Matrix4.equals(cam.transform, Cesium.Matrix4.IDENTITY) + ); +} + +// The frame share that in-place voice turns measure their limit against, with +// the pose the last voice turn left the camera in. A turn that starts where +// the last one left off keeps that baseline (raised if the planet now fills +// more of the frame), so repeating a request cannot take a little more each +// time. Once anything else has moved or turned the camera, a turn starts over +// from what is on screen. +let _lookBaseline = null; + +// The pre-check's proof that a half-degree turn from this pose keeps the planet +// in view: which way, from which direction. A motion that starts from that +// pose turns at least that far, so a request reported as done really moves +// even when a smaller first step lands in a dip of the sampled share. +const PROVEN_TURN_RAD = Cesium.Math.toRadians(0.5); +let _provenLook = null; + +/** The part of the frame a viewer can see: the scope keyhole when it is on. */ +function visibleFrame() { + return { keyholeRadius: isScopeMaskEnabled() ? KEYHOLE_OUTER_RADIUS : 0 }; +} + +/** The frame share an in-place turn from `cam`'s pose is limited against. */ +function lookBaselineShare(cam) { + const share = globeFrameShare(cam, visibleFrame()); + const continues = + _lookBaseline && + Cesium.Cartesian3.equalsEpsilon( + _lookBaseline.position, + cam.positionWC, + 0, + 1, + ) && + Cesium.Cartesian3.equalsEpsilon( + _lookBaseline.direction, + cam.directionWC, + 0, + 1e-9, + ); + _lookBaseline = { + position: Cesium.Cartesian3.clone(cam.positionWC), + direction: Cesium.Cartesian3.clone(cam.directionWC), + share: continues ? Math.max(_lookBaseline.share, share) : share, + }; + return _lookBaseline.share; +} + +/** + * One in-place look step of motion `m`. From orbit a free globe camera turns + * only as far as keeps the planet in frame, measured against the baseline the + * turn started from, then the motion ends there. + */ +function lookWithinFrame(cam, m, look, angle) { + let allowed = angle; + if (isFreeGlobeCamera(cam)) { + if (m.globeShareAtStart === undefined) { + m.globeShareAtStart = lookBaselineShare(cam); + m.provenTurnRad = lookProven(cam, look) ? PROVEN_TURN_RAD : 0; + m.turnedRad = 0; + } + _provenLook = null; + allowed = lookAngleWithinFrame(cam, look, angle, { + ...visibleFrame(), + startShare: m.globeShareAtStart, + }); + const owed = Math.min(angle, m.provenTurnRad - m.turnedRad); + if (allowed < owed) allowed = owed; + m.turnedRad += allowed; + } + if (allowed > 0) cam[look](allowed); + // The pose this turn leaves, for a turn that continues from it. + if (_lookBaseline) + Cesium.Cartesian3.clone(cam.directionWC, _lookBaseline.direction); + if (allowed < angle) interruptCameraMotion('globe-out-of-frame'); +} + +/** True when the pre-check proved a half-degree `look` from `cam`'s pose. */ +function lookProven(cam, look) { + return ( + _provenLook?.look === look && + Cesium.Cartesian3.equalsEpsilon( + _provenLook.direction, + cam.directionWC, + 0, + 1e-9, + ) + ); +} + +/** + * The check a voice in-place turn (`look`: 'lookUp', 'lookDown', 'lookLeft' + * or 'lookRight') must pass from orbit: refused when even half a degree would + * cross the frame limit, otherwise proven for that half degree from this pose. + * With `released`, the turn runs through a navigation that first releases a + * tracked or locked camera and keeps its world pose. The frame measure and the + * turn are the same in any reference frame, so such a camera is judged as it + * is, before the release, and a refusal leaves tracking as it was. Otherwise a + * camera that is not a free globe camera is not checked. Returns the refusal, + * or null. + */ +function checkLookFromOrbit(look, { released = false } = {}) { + _provenLook = null; + if (_viewer.scene.mode !== Cesium.SceneMode.SCENE3D) return null; + const cam = _viewer.camera; + if (!released && !isFreeGlobeCamera(cam)) return null; + if ( + lookStepLosesGlobe(cam, look, PROVEN_TURN_RAD, { + ...visibleFrame(), + startShare: lookBaselineShare(cam), + }) + ) + return { + ok: false, + action: 'move_camera', + error: + 'From this height that would turn the globe out of view — zoom in first, or turn the other way.', + }; + _provenLook = { look, direction: Cesium.Cartesian3.clone(cam.directionWC) }; + return null; +} + function clearLookAt() { try { _viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY); @@ -1226,10 +1364,15 @@ function onTick() { interruptCameraMotion('tilt-clamp'); return; } - if (up) cam.lookUp(stepRad); - else cam.lookDown(stepRad); - } else if (m.direction === 'left') cam.lookLeft(stepRad); - else cam.lookRight(stepRad); + lookWithinFrame(cam, m, up ? 'lookUp' : 'lookDown', stepRad); + } else { + lookWithinFrame( + cam, + m, + m.direction === 'left' ? 'lookLeft' : 'lookRight', + stepRad, + ); + } } else if (m.kind === 'route') { // The dolly's shaping lives in advanceRouteFlight(); the tick only // applies the pose and honours the same completion path as before. @@ -1413,6 +1556,7 @@ export function initCameraVerbs( _isCockpitActive = typeof isCockpitActive === 'function' ? isCockpitActive : null; _viewer = viewer; + _lookBaseline = null; if (_tickRemover) _tickRemover(); _tickRemover = viewer.clock.onTick.addEventListener(onTick); if (_trackingRemover) _trackingRemover(); @@ -1524,6 +1668,27 @@ export function moveCamera(args = {}, runNavigation = null) { }; } } + const look = + motion === 'tilt' || motion === 'rotate' + ? { + up: 'lookUp', + down: 'lookDown', + left: 'lookLeft', + right: 'lookRight', + }[direction] + : null; + // Honest limits from orbit too: a stopped turn rests within 0.05° of its + // limit, so one that cannot go half a degree further is refused rather than + // run and reported as done, and one that can is then turned at least that + // far. Near the limit the sampled share wobbles as the planet's edge crosses + // the grid, so a turn that would keep the planet can occasionally be refused + // too. + // A camera that is tracked or locked now is judged as the navigation that + // runs the turn will release it, so a refusal keeps the tracking. + const lookRefusal = + look && + checkLookFromOrbit(look, { released: typeof runNavigation === 'function' }); + if (lookRefusal) return lookRefusal; if ( (motion === 'rotate_globe' || (motion === 'orbit' && typeof runNavigation !== 'function')) && @@ -1609,6 +1774,12 @@ export function moveCamera(args = {}, runNavigation = null) { } const start = () => { interruptCameraMotion('replaced'); + // Checked again only if the release left the camera somewhere other than + // where the check above expected it. + if (look && !lookProven(_viewer.camera, look)) { + const refusal = checkLookFromOrbit(look); + if (refusal) return refusal; + } const state = { kind: motion, direction: direction || 'right', diff --git a/src/globeInView.js b/src/globeInView.js new file mode 100644 index 000000000..1ccbcda23 --- /dev/null +++ b/src/globeInView.js @@ -0,0 +1,167 @@ +import * as Cesium from 'cesium'; + +// Cesium's free look (Shift+drag) turns the camera in place. Near the ground +// that surveys a city; from orbit a few drags turn the whole planet out of +// frame and leave only stars. +export const FREE_LOOK_MAX_HEIGHT_METERS = 1_000_000; + +// A voice turn from orbit stops when about this share of the frame still shows +// the planet's surface. +export const MIN_GLOBE_FRAME_SHARE = 0.1; + +const GRID_COLUMNS = 32; +const GRID_ROWS = 18; +// A turn across a sliver of planet moves its edge through sample points, so +// the share wobbles by a cell or two without the planet leaving. +const GRID_NOISE_CELLS = 2; +// Whatever the baseline, a voice turn never takes the planet below this share +// of the frame; a view that already shows less may only keep what it has. +const LOOK_ABSOLUTE_FLOOR_SHARE = 0.01; +const sampleRay = new Cesium.Ray(); +const sampleOffset = new Cesium.Cartesian3(); + +/** + * Cast a 32×18 grid of view rays, one through the centre of each cell, at the + * WGS84 ellipsoid. With `keyholeRadius` (a radius around the frame centre, in + * units of half the frame height) only the cells inside that circle count: the + * scope mask hides the rest. Returns the surface hits and the cells counted, or + * null for a frustum other than a perspective one. + */ +function sampleFrame(camera, keyholeRadius) { + const frustum = camera.frustum; + if (!(frustum instanceof Cesium.PerspectiveFrustum)) return null; + const aspect = frustum.aspectRatio; + const tanY = Math.tan(frustum.fovy / 2); + const tanX = tanY * aspect; + const keyholeSquared = keyholeRadius > 0 ? keyholeRadius * keyholeRadius : 0; + Cesium.Cartesian3.clone(camera.positionWC, sampleRay.origin); + let hits = 0; + let cells = 0; + for (let row = 0; row < GRID_ROWS; row += 1) { + const rowY = (2 * (row + 0.5)) / GRID_ROWS - 1; + for (let column = 0; column < GRID_COLUMNS; column += 1) { + const columnX = (2 * (column + 0.5)) / GRID_COLUMNS - 1; + if ( + keyholeSquared && + (columnX * aspect) ** 2 + rowY * rowY > keyholeSquared + ) + continue; + cells += 1; + Cesium.Cartesian3.multiplyByScalar( + camera.rightWC, + columnX * tanX, + sampleOffset, + ); + Cesium.Cartesian3.add( + camera.directionWC, + sampleOffset, + sampleRay.direction, + ); + Cesium.Cartesian3.multiplyByScalar( + camera.upWC, + rowY * tanY, + sampleOffset, + ); + Cesium.Cartesian3.add( + sampleRay.direction, + sampleOffset, + sampleRay.direction, + ); + Cesium.Cartesian3.normalize(sampleRay.direction, sampleRay.direction); + if ( + Cesium.IntersectionTests.rayEllipsoid(sampleRay, Cesium.Ellipsoid.WGS84) + ) + hits += 1; + } + } + return { hits, cells }; +} + +/** + * Share of the frame, from 0 to 1, that shows the planet's surface: the + * fraction of a 32×18 grid of view rays, one through the centre of each cell, + * that hit the WGS84 ellipsoid. With `keyholeRadius` (see `sampleFrame`) it is + * the share of the visible circle instead. A frustum other than a perspective + * one counts as fully covered. + */ +export function globeFrameShare(camera, { keyholeRadius = 0 } = {}) { + const sample = sampleFrame(camera, keyholeRadius); + if (!sample || sample.cells === 0) return 1; + return sample.hits / sample.cells; +} + +/** True when none of the planet's surface is in `camera`'s visible frame. */ +export function globeOutOfFrame(camera, options) { + return globeFrameShare(camera, options) === 0; +} + +const lookProbes = new WeakMap(); +const LOOK_EDGE_TOLERANCE = Cesium.Math.toRadians(0.05); + +/** + * How much of one in-place look step (`lookUp`, `lookDown`, `lookLeft` or + * `lookRight` by a non-negative `angle` in radians) keeps the planet in frame + * from orbit. Above the free-look height the step may not leave less than + * `MIN_GLOBE_FRAME_SHARE` of the visible frame on the planet, or less than + * `startShare` when that is lower, give or take two grid cells of sampling + * noise. However the baseline was taken, it never leaves less than 1% of the + * frame, and a view that already shows less may only keep or gain what it has. + * `startShare` is the baseline the turn is measured against (the current share + * by default): callers pass one value to every step of a motion, and to every + * turn that continues from where the last one left off, so the limit cannot + * slide down a step or a command at a time. `keyholeRadius` limits the measure + * to the scope's visible circle, as in `globeFrameShare`. + * With nothing on screen the limit is nothing either, so the step is left to + * the move-end recovery. Returns `angle` when the whole step fits, otherwise a + * smaller angle that fits, found by bisection to within 0.05°, so a stopped + * motion rests at the limit. Steps are tried on a copy; `camera` is not moved. + */ +export function lookAngleWithinFrame( + camera, + look, + angle, + { startShare, keyholeRadius = 0 } = {}, +) { + if ( + !(angle > 0) || + !(camera.positionCartographic?.height > FREE_LOOK_MAX_HEIGHT_METERS) + ) + return angle; + const sample = sampleFrame(camera, keyholeRadius); + if (!sample || sample.cells === 0) return angle; + const share = sample.hits / sample.cells; + const baseline = startShare ?? share; + // Capped at what is on screen now, so a step that keeps or gains surface is + // never refused, even when the frame has shrunk since the baseline was taken + // (a resized window or a turned phone). + const floor = Math.min( + share, + Math.max( + Math.min(MIN_GLOBE_FRAME_SHARE, baseline) - + GRID_NOISE_CELLS / sample.cells, + Math.min(LOOK_ABSOLUTE_FLOOR_SHARE, baseline), + ), + ); + const fits = (step) => { + const probe = Cesium.Camera.clone(camera, lookProbes.get(camera)); + lookProbes.set(camera, probe); + probe[look](step); + // Shares are whole grid cells; the epsilon keeps rounding in `floor` from + // costing a cell. + return globeFrameShare(probe, { keyholeRadius }) >= floor - 1e-9; + }; + if (fits(angle)) return angle; + let inside = 0; + let outside = angle; + while (outside - inside > LOOK_EDGE_TOLERANCE) { + const middle = (inside + outside) / 2; + if (fits(middle)) inside = middle; + else outside = middle; + } + return inside; +} + +/** True when an in-place look step of `angle` would lose part of its turn. */ +export function lookStepLosesGlobe(camera, look, angle, options) { + return lookAngleWithinFrame(camera, look, angle, options) < angle; +} From d36b28f352389c7f9caff1c46801cd698f93b209 Mon Sep 17 00:00:00 2001 From: Manjunath Date: Wed, 4 Nov 2026 01:19:22 +0530 Subject: [PATCH 20/71] docs(changelog): add the globe-in-view fragment --- changelog.d/1532-globe-in-view.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 changelog.d/1532-globe-in-view.md diff --git a/changelog.d/1532-globe-in-view.md b/changelog.d/1532-globe-in-view.md new file mode 100644 index 000000000..f697959b2 --- /dev/null +++ b/changelog.d/1532-globe-in-view.md @@ -0,0 +1,8 @@ +- Keep the globe in view from orbit. Cesium's free look (Shift+drag) turned + the camera in place, so a few drags from the whole-globe view left only + stars, and the pose stayed in the share link. Free look now works only below + 1,000 km; a camera move that ends above it with no planet surface in view + (inside the scope keyhole when the scope is on) turns straight down; and + voice tilt and turns from orbit stop while the planet is still on screen and + say so instead of reporting a turn they could not make (Manjunath, + @manjunath22466, #1532). From 1f0a8b5f1640921d0742cd94cdd8520310a90e2f Mon Sep 17 00:00:00 2001 From: Trygve Hemminghytt <274629477+trygvehhh@users.noreply.github.com> Date: Tue, 3 Nov 2026 22:58:09 +0100 Subject: [PATCH 21/71] fix(datacenters): report newly tagged features osmium cannot export A still-tagged feature without exportable geometry already keeps its old line. A newly tagged one had nowhere to go and was dropped silently; it is now listed as skipped with a warning, so a refresh never loses a site or hides a broken one. --- scripts/build-datacenters-norway.py | 10 ++++++++-- src/data/datacentersNorwayRefresh.test.mjs | 3 ++- src/data/local_data/datacenters/README.md | 3 ++- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/scripts/build-datacenters-norway.py b/scripts/build-datacenters-norway.py index 03286b864..d1203f38d 100644 --- a/scripts/build-datacenters-norway.py +++ b/scripts/build-datacenters-norway.py @@ -182,12 +182,13 @@ def feature_line(key, sequence, fresh): def merge(lines, fresh, rings, tagged): - out, seen, sequence = [], set(), 0 - report = {'kept': 0, 'updated': [], 'removed': [], 'added': [], 'unexported': []} + out, seen, known, sequence = [], set(), set(), 0 + report = {'kept': 0, 'updated': [], 'removed': [], 'added': [], 'unexported': [], 'skipped': []} for line in lines: feature = json.loads(line) sequence = max(sequence, feature['properties']['id']) key = osm_key(feature) + known.add(key) if key in fresh: seen.add(key) old_tags = feature['properties']['tags'] @@ -211,6 +212,8 @@ def merge(lines, fresh, rings, tagged): sequence += 1 out.append(feature_line(key, sequence, fresh[key])) report['added'].append(key) + # newly tagged, but osmium built no usable geometry: nothing to add yet + report['skipped'] = sorted(set(tagged) - set(fresh) - known, key=lambda k: (k[0], int(k[1:]))) if len({osm_key(json.loads(line)) for line in out}) != len(out): raise ValueError('Duplicate OSM identity') return out, report @@ -239,6 +242,9 @@ def main(): if report['unexported']: print('warning: kept the old line of tagged features osmium did not export: %s' % ', '.join(report['unexported']), file=sys.stderr) + if report['skipped']: + print('warning: did not add newly tagged features osmium did not export: %s' + % ', '.join(report['skipped']), file=sys.stderr) payload = '\n'.join(merged) + '\n' if args.verify: if payload != DATASET.read_text(encoding='utf-8'): diff --git a/src/data/datacentersNorwayRefresh.test.mjs b/src/data/datacentersNorwayRefresh.test.mjs index 2b7b7d5bf..88527295b 100644 --- a/src/data/datacentersNorwayRefresh.test.mjs +++ b/src/data/datacentersNorwayRefresh.test.mjs @@ -52,9 +52,10 @@ def line(osm_id, sequence): 'type': 'data_center', 'osm_id': osm_id}}) norway = [([(0, 0), (20, 0), (20, 80), (0, 80)], False)] lines = [line(7, 1), line(8, 2)] -out, report = merge(lines, {}, norway, {'w7'}) +out, report = merge(lines, {}, norway, {'w7', 'w12'}) assert out == [lines[0]], out assert report['unexported'] == ['w7'] and report['removed'] == ['w8'], report +assert report['skipped'] == ['w12'] and report['added'] == [], report `, ); }); diff --git a/src/data/local_data/datacenters/README.md b/src/data/local_data/datacenters/README.md index 5a80df267..86f573c61 100644 --- a/src/data/local_data/datacenters/README.md +++ b/src/data/local_data/datacenters/README.md @@ -29,7 +29,8 @@ reflects OSM as of the extract below, everything else the original snapshot. - Contact tags are dropped as above; unchanged features keep their original line, so geometry is only rewritten when tags or rings actually changed. A feature is removed only once it is no longer tagged; one still tagged but - without an exportable geometry keeps its line and is reported as unexported. + without an exportable geometry keeps its line and is reported as unexported, + and a newly tagged one osmium cannot export is reported as skipped. - Refresh: `python3 scripts/build-datacenters-norway.py --cache ` uses Geofabrik's current Norway extract and prints the dated name it used; record that name, its OSM timestamp and MD5 here. Requires `osmium-tool`. From 44860df90e78d854c0523f9563d32f56d0c86e03 Mon Sep 17 00:00:00 2001 From: Manjunath Date: Wed, 4 Nov 2026 13:40:43 +0530 Subject: [PATCH 22/71] Follow a replacement voice flight whatever its predecessor reports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A motion queued behind a voice fly-to now follows a later fly-to in the same reply whether the earlier one arrived, was cancelled or failed. A geocode that loses navigation reports failed, so the motion used to be dropped; and a flight left flying while the next place is looked up could land and run the motion there, taking navigation and cancelling the newer flight. The deadline, abort and stale-reply checks still end the wait. pitchDeg -90 or -89 from a top-down view is now unchanged rather than a 1° tilt up, and the tilt tests' pitch loops are bounded. --- src/cameraTilt.test.mjs | 23 +++++++++++-- src/cameraVerbs.js | 8 ++++- src/voice/gevActions.js | 14 ++++++-- src/voice/gevActions.test.mjs | 65 +++++++++++++++++++++++++++++++++++ 4 files changed, 104 insertions(+), 6 deletions(-) diff --git a/src/cameraTilt.test.mjs b/src/cameraTilt.test.mjs index 7d2664c9f..93bbf246e 100644 --- a/src/cameraTilt.test.mjs +++ b/src/cameraTilt.test.mjs @@ -78,6 +78,11 @@ function harness( } const pitchDeg = (camera) => degrees(camera.pitch); +/** Tick until done() holds; a regression fails here instead of hanging. */ +const tickUntil = (h, done, frames = 600) => { + for (let i = 0; i < frames && !done(); i += 1) h.tick(); + assert.ok(done(), `not reached in ${frames} frames`); +}; const headingDeg = (camera) => degrees(Cesium.Math.negativePiToPi(camera.heading)); @@ -171,6 +176,20 @@ test('a requested angle the camera already holds is a no-op', (t) => { assert.equal(getActiveCameraMotion(), null); }); +for (const pitch of [-90, -89]) { + test(`a top-down view asked for ${pitch}° stays put`, (t) => { + // The tool description calls -89 top-down; the clamp must not tilt a + // -90° view up 1° and report it as a move up. + const h = harness(t); + const before = pitchDeg(h.camera); + const result = moveCamera({ motion: 'tilt', pitchDeg: pitch }); + assert.equal(result.ok, true); + assert.equal(result.unchanged, true); + assert.equal(getActiveCameraMotion(), null); + assert.equal(pitchDeg(h.camera), before); + }); +} + test('tilt down from top-down still refuses honestly', (t) => { harness(t); const result = moveCamera({ motion: 'tilt', direction: 'down' }); @@ -335,7 +354,7 @@ test('the pitchDeg and direction check uses the pitch the swing starts from', (t test('after a zoom in, a pivoting tilt never lowers the camera further', (t) => { const h = harness(t, { height: 1000 }); moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' }); - while (pitchDeg(h.camera) < -30) h.tick(); + tickUntil(h, () => pitchDeg(h.camera) >= -30); adjustOrbitRange(1 / 2.4); adjustOrbitRange(1 / 2.4); h.tick(); @@ -390,7 +409,7 @@ test('a zoom in during a pivot tilt stops at the clearance', (t) => { // the camera about 107 m up, then at the 80 m zoom floor. const h = harness(t, { height: 400 }); moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' }); - while (pitchDeg(h.camera) < -40) h.tick(); + tickUntil(h, () => pitchDeg(h.camera) >= -40); assert.deepEqual(adjustOrbitRange(1 / 2.4), { kind: 'tilt', changed: true }); // At -40° the floor is 150 / sin 40° ≈ 233 m, which the first zoom reached. assert.deepEqual(adjustOrbitRange(1 / 2.4), { kind: 'tilt', changed: false }); diff --git a/src/cameraVerbs.js b/src/cameraVerbs.js index 16888d73e..380a737f0 100644 --- a/src/cameraVerbs.js +++ b/src/cameraVerbs.js @@ -1671,7 +1671,13 @@ export function moveCamera(args = {}, runNavigation = null) { // Judge from the pitch the motion will start at: the pivot's, which at // regional range is a degree or two shallower than the camera's. const { from } = tiltStartFrame(_viewer.camera, { releasesTracking }); - if (Math.abs(tiltToPitch - from) < Cesium.Math.toRadians(0.5)) { + // -89 or -90 means top-down, which a -90° view already is: the clamp + // must not tilt it up 1°. + const atTopDown = tiltToPitch <= PITCH_MIN && from <= PITCH_MIN; + if ( + atTopDown || + Math.abs(tiltToPitch - from) < Cesium.Math.toRadians(0.5) + ) { return { ok: true, action: 'move_camera', diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index f7573075e..c9bc811a4 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -1302,9 +1302,17 @@ export function createGevActionRunner({ runOptions.signal, deadline, ); - // A later fly-to in the same reply cancels this one: follow it to - // where the camera lands, within the same deadline. - while (status === 'cancelled' && voiceFlight !== flight && current()) { + // A later fly-to in the same reply replaces this one: follow it to + // where the camera lands, within the same deadline. Its status does + // not matter. A geocode that loses navigation reports failed, and a + // flight left flying while the next place is looked up can still + // land; running there would take navigation and cancel the newer one. + while ( + voiceFlight !== flight && + status !== 'aborted' && + status !== 'timeout' && + current() + ) { flight = voiceFlight; status = await waitForVoiceFlight( flight, diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index a464c94bd..14d008933 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -570,6 +570,71 @@ test('a motion follows a later fly-to in the same reply that cancels its flight' assert.equal(result.motion, 'orbit'); }); +test('a motion follows a later fly-to whose start cancels its geocode', async (t) => { + t.after(() => interruptCameraMotion('test-cleanup')); + const { viewer, styleManager } = tiltAfterFlightHarness(); + const searches = []; + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + searchNavigation: (_viewer, query, options) => new Promise((resolve) => { + searches.push({ query, options, resolve }); + }), + }); + const flyA = runner('fly_to_location', { query: 'Place A' }); + const moved = runner('move_camera', { motion: 'orbit' }); + const movedDone = settledYet(moved); + const flyB = runner('fly_to_location', { query: 'Place B' }); + await new Promise((resolve) => setTimeout(resolve, 0)); + // B takes navigation authority, so A's lookup comes back cancelled. + searches[0].resolve({ cancelled: true }); + assert.equal((await flyA).cancelled, true); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(movedDone(), false, 'the orbit must follow B, not drop'); + searches[1].resolve({ label: 'Place B', navigationMode: 'city-overview' }); + assert.equal((await flyB).ok, true); + searches[1].options.onComplete(); + const result = await moved; + assert.equal(result.ok, true); + assert.equal(result.motion, 'orbit'); +}); + +test('a motion does not run at a flight that lands while the next place is looked up', async (t) => { + t.after(() => interruptCameraMotion('test-cleanup')); + const { viewer, styleManager, order, flights } = tiltAfterFlightHarness(); + const searches = []; + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + searchNavigation: (_viewer, query, options) => new Promise((resolve) => { + searches.push({ query, options, resolve }); + }), + }); + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + const moved = runner('move_camera', { motion: 'tilt', pitchDeg: -45 }); + const movedDone = settledYet(moved); + const flyB = runner('fly_to_location', { query: 'Place B' }); + await new Promise((resolve) => setTimeout(resolve, 0)); + // Deferred navigation leaves A flying while B is looked up, and A lands. + const before = order.length; + flights[0].complete(); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(movedDone(), false, 'the tilt must wait for B'); + assert.equal( + order.slice(before).includes('cancel'), + false, + 'the tilt must not take navigation and cancel B', + ); + searches[0].resolve({ label: 'Place B', navigationMode: 'city-overview' }); + assert.equal((await flyB).ok, true); + searches[0].options.onComplete(); + const result = await moved; + assert.equal(result.ok, true); + assert.equal(result.motion, 'tilt'); +}); + test('a stale motion does not follow a newer reply\'s fly-to', async () => { const { viewer, styleManager, flights } = tiltAfterFlightHarness(); const runner = createGevActionRunner({ From b683b8a64b7d71e33f468469e4b7494f60f6c6ff Mon Sep 17 00:00:00 2001 From: Manjunath Date: Wed, 4 Nov 2026 14:29:37 +0530 Subject: [PATCH 23/71] Look past voice lookups that found nothing A motion queued behind a voice fly-to acts on the last flight that moved the camera. A later lookup that was not found, threw, or was cancelled once a newer voice fly-to had begun moved nothing, so the motion looks past it rather than refusing or running early and cancelling the flight still in the air. It never acts at a flight older than the one it started waiting on. A lookup cancelled while still the latest voice flight (a search, a share link, Cockpit) still refuses. The top-down no-op now reports the camera's real pitch. --- src/cameraTilt.test.mjs | 1 + src/cameraVerbs.js | 4 +- src/voice/gevActions.js | 108 ++++++++++++++++------- src/voice/gevActions.test.mjs | 156 ++++++++++++++++++++++++++++++++++ 4 files changed, 237 insertions(+), 32 deletions(-) diff --git a/src/cameraTilt.test.mjs b/src/cameraTilt.test.mjs index 93bbf246e..d372d0746 100644 --- a/src/cameraTilt.test.mjs +++ b/src/cameraTilt.test.mjs @@ -185,6 +185,7 @@ for (const pitch of [-90, -89]) { const result = moveCamera({ motion: 'tilt', pitchDeg: pitch }); assert.equal(result.ok, true); assert.equal(result.unchanged, true); + assert.equal(result.pitchDeg, Math.round(before)); assert.equal(getActiveCameraMotion(), null); assert.equal(pitchDeg(h.camera), before); }); diff --git a/src/cameraVerbs.js b/src/cameraVerbs.js index 380a737f0..c1e5c5d1a 100644 --- a/src/cameraVerbs.js +++ b/src/cameraVerbs.js @@ -1682,7 +1682,9 @@ export function moveCamera(args = {}, runNavigation = null) { ok: true, action: 'move_camera', motion, - pitchDeg: Math.round(Cesium.Math.toDegrees(tiltToPitch)), + pitchDeg: Math.round( + Cesium.Math.toDegrees(atTopDown ? from : tiltToPitch), + ), unchanged: true, }; } diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index c9bc811a4..83cdea367 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -288,9 +288,10 @@ export function createGevActionRunner({ ? { ...SPEECH_BUILDERS, ...speechBuilders } : SPEECH_BUILDERS; const _layerEnabledAt = new Map(); - // The latest voice flight. A tilt in the same reply as "fly to X" waits for - // it to land: run at once, its navigation cancelled the flight, or the - // flight (still geocoding) then replaced the tilt. + // The latest voice flight. A tilt, orbit, pan or rotate in the same reply + // as "fly to X" waits for it to land: run at once, its navigation + // cancelled the flight, or the flight (still geocoding) then replaced the + // motion. The wait follows later fly-tos (voiceFlightTarget). let voiceFlight = null; let analystEngine; // Area handles live for this runner (the page): resolve_area, the @@ -1143,10 +1144,11 @@ export function createGevActionRunner({ runOptions, ); } - // Chained fly-tos in one reply: a grab during the earlier one counts. - const flight = trackVoiceFlight( - voiceFlight && !voiceFlight.done ? voiceFlight.inputCount : undefined, - ); + // Chained fly-tos: a grab during the earlier one counts. The flight + // replaced is the last one that moved, past lookups that found nothing. + const prior = voiceFlightTarget(voiceFlight); + const replaced = prior && !prior.done ? prior : null; + const flight = trackVoiceFlight(replaced?.inputCount, replaced); voiceFlight = flight; const flown = flyToRequestedLocation(viewer, args, { onFlightEnd: flight.settle, @@ -1177,13 +1179,23 @@ export function createGevActionRunner({ if (viewer.trackedEntity) stopAllTracking(viewer, dataManager); }, }); - // No flight started (not found, refused, cancelled): nothing to wait for. - flown.then( - (result) => { - if (result?.ok !== true) flight.settle('failed'); - }, - () => flight.settle('failed'), - ); + // No flight started: nothing to wait for. A lookup that was not found, + // threw, or was cancelled once a newer voice fly-to had begun (which + // takes navigation from it) moved nothing, so a queued motion looks + // past it to the flight it replaced. One cancelled while still the + // latest voice flight lost navigation to something else (a search, a + // share link, Cockpit): the motion then refuses. A flight its own hooks + // already settled (cancelled mid-air) keeps its status. + const notStarted = () => { + if (!flight.done) flight.movedNothing = true; + flight.settle('failed'); + }; + flown.then((result) => { + if (result?.ok === true) return; + if (result?.cancelled && voiceFlight === flight) + flight.settle('failed'); + else notStarted(); + }, notStarted); return flown; } @@ -1287,39 +1299,45 @@ export function createGevActionRunner({ if (name === 'move_camera') { const motion = String(args.motion || '').toLowerCase(); + // A lookup that already found nothing does not hide the last flight + // that moved: running now would cancel that flight mid-air. + const pending = voiceFlightTarget(voiceFlight); if ( VOICE_FLIGHT_WAITING_MOTIONS.has(motion) && - voiceFlight && - !voiceFlight.done + pending && + !pending.done ) { // Bad arguments are refused now, not after waiting on the flight. const argsError = moveCameraArgsError(args); if (argsError) return argsError; const deadline = Date.now() + VOICE_FLIGHT_WAIT_MS; - let flight = voiceFlight; + const start = pending; + let flight = start; let status = await waitForVoiceFlight( flight, runOptions.signal, deadline, ); - // A later fly-to in the same reply replaces this one: follow it to - // where the camera lands, within the same deadline. Its status does - // not matter. A geocode that loses navigation reports failed, and a - // flight left flying while the next place is looked up can still - // land; running there would take navigation and cancel the newer one. - while ( - voiceFlight !== flight && - status !== 'aborted' && - status !== 'timeout' && - current() - ) { - flight = voiceFlight; + // A later fly-to replaces this one: follow it to where the camera + // lands, within the same deadline, whatever this flight reported. A + // geocode that loses navigation reports failed, and a flight left + // flying while the next place is looked up can still land; running + // there would take navigation and cancel the newer one. A later + // lookup that found nothing moved nothing, so the motion acts on the + // last flight it saw that did move the camera. + let landed = flight.movedNothing ? null : { flight, status }; + while (status !== 'aborted' && status !== 'timeout' && current()) { + const target = voiceFlightTarget(voiceFlight, start); + if (!target || target === flight) break; + flight = target; status = await waitForVoiceFlight( flight, runOptions.signal, deadline, ); + if (!flight.movedNothing) landed = { flight, status }; } + if (flight.movedNothing && landed) ({ flight, status } = landed); if (!current()) return { ok: false, action: 'move_camera', cancelled: true }; if (status !== 'arrived') @@ -2939,11 +2957,25 @@ async function frameOverhead(viewer, dataManager, styleManager, args = {}) { ); } -/** A voice flight's end: 'arrived', 'cancelled' or 'failed', settled once. */ -function trackVoiceFlight(inputCount = manualCameraInputCount()) { +let voiceFlightCount = 0; + +/** + * A voice flight's end: 'arrived', 'cancelled' or 'failed', settled once. + * `replaced` is the last flight that moved, if still pending when this one + * began (past lookups that found nothing). It is kept only + * while this one may turn out to have moved nothing (a lookup that found no + * place), so a queued motion can look past it. + */ +function trackVoiceFlight( + inputCount = manualCameraInputCount(), + replaced = null, +) { let resolve; const flight = { done: false, + seq: (voiceFlightCount += 1), + replaced, + movedNothing: false, // Manual input after this means the user took the camera mid-flight. inputCount, ended: new Promise((settle) => { @@ -2952,12 +2984,26 @@ function trackVoiceFlight(inputCount = manualCameraInputCount()) { settle: (status) => { if (flight.done) return; flight.done = true; + if (!flight.movedNothing) flight.replaced = null; resolve(status); }, }; return flight; } +/** + * The flight a motion queued since `start` acts on: the latest one that + * actually started. A lookup that found nothing moved nothing, so look past + * it to the flight it replaced, but never to one older than `start` (a flight + * from an earlier reply). Null when every later lookup found nothing. With + * no `start`, the latest flight that started, however old. + */ +function voiceFlightTarget(latest, start = null) { + let flight = latest; + while (flight?.movedNothing) flight = flight.replaced; + return flight && (!start || flight.seq >= start.seq) ? flight : null; +} + /** * Longest a motion waits for its flight (geocode plus flight, which is about * 2 s) before giving up. The wait also holds the tool output, and on Gemini diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index 14d008933..ece2f92e0 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -635,6 +635,162 @@ test('a motion does not run at a flight that lands while the next place is looke assert.equal(result.motion, 'tilt'); }); +// A later fly-to that finds nothing moves nothing: the motion still runs where +// the earlier flight lands, whether the lookup fails before or after that. +for (const order of ['lands first', 'not found first']) { + test(`a motion runs at its flight when a later fly-to finds nothing (${order})`, async (t) => { + t.after(() => interruptCameraMotion('test-cleanup')); + const harness = tiltAfterFlightHarness(); + const { viewer, styleManager, flights } = harness; + const searches = []; + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + searchNavigation: (_viewer, query, options) => new Promise((resolve) => { + searches.push({ query, options, resolve }); + }), + }); + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + const moved = runner('move_camera', { motion: 'tilt', pitchDeg: -45 }); + const flyB = runner('fly_to_location', { query: 'Nowhere' }); + await new Promise((resolve) => setTimeout(resolve, 0)); + if (order === 'lands first') flights[0].complete(); + searches[0].resolve(null); + assert.equal((await flyB).ok, false); + if (order !== 'lands first') flights[0].complete(); + const result = await moved; + assert.equal(result.ok, true, result.error); + assert.equal(result.motion, 'tilt'); + }); +} + +test('a motion beside a fly-to that finds nothing never acts at an earlier reply\'s flight', async () => { + const { viewer, styleManager, flights } = tiltAfterFlightHarness(); + const searches = []; + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + searchNavigation: (_viewer, query, options) => new Promise((resolve) => { + searches.push({ query, options, resolve }); + }), + }); + // Reply 1: "fly to Austin", still flying. + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + // Reply 2: "fly to Nowhere and tilt". + const flyB = runner('fly_to_location', { query: 'Nowhere' }); + const moved = runner('move_camera', { motion: 'tilt', pitchDeg: -45 }); + await new Promise((resolve) => setTimeout(resolve, 0)); + searches[0].resolve(null); + assert.equal((await flyB).ok, false); + flights[0].complete(); + const result = await moved; + assert.equal(result.ok, false); + assert.match(result.error, /did not arrive/); +}); + +// Two later lookups that both find nothing, in either order, leave the motion +// acting where the first flight landed. +for (const first of [0, 1]) { + test(`a motion looks past two lookups that found nothing (lookup ${first + 1} fails first)`, async (t) => { + t.after(() => interruptCameraMotion('test-cleanup')); + const { viewer, styleManager, flights } = tiltAfterFlightHarness(); + const searches = []; + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + searchNavigation: (_viewer, query, options) => new Promise((resolve) => { + searches.push({ query, options, resolve }); + }), + }); + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + const moved = runner('move_camera', { motion: 'orbit' }); + const flyB = runner('fly_to_location', { query: 'Nowhere B' }); + const flyC = runner('fly_to_location', { query: 'Nowhere C' }); + await new Promise((resolve) => setTimeout(resolve, 0)); + searches[first].resolve(null); + searches[1 - first].resolve(null); + assert.equal((await flyB).ok, false); + assert.equal((await flyC).ok, false); + flights[0].complete(); + const result = await moved; + assert.equal(result.ok, true, result.error); + }); +} + +test('a lookup cancelled by a newer one that finds nothing does not hide the flight that landed', async (t) => { + t.after(() => interruptCameraMotion('test-cleanup')); + const { viewer, styleManager, flights } = tiltAfterFlightHarness(); + const searches = []; + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + searchNavigation: (_viewer, query, options) => new Promise((resolve) => { + searches.push({ query, options, resolve }); + }), + }); + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + const moved = runner('move_camera', { motion: 'tilt', pitchDeg: -45 }); + const flyB = runner('fly_to_location', { query: 'Place B' }); + const flyC = runner('fly_to_location', { query: 'Nowhere C' }); + await new Promise((resolve) => setTimeout(resolve, 0)); + flights[0].complete(); + // C's start takes navigation from B's lookup; C then finds nothing. + searches[0].resolve({ cancelled: true }); + searches[1].resolve(null); + assert.equal((await flyB).cancelled, true); + assert.equal((await flyC).ok, false); + const result = await moved; + assert.equal(result.ok, true, result.error); +}); + +test('a motion sent after a fly-to that found nothing still waits for the flight before it', async (t) => { + // Serial dispatch: "fly to Austin, fly to Nowhere, tilt", one call at a time. + t.after(() => interruptCameraMotion('test-cleanup')); + const { viewer, styleManager, order, flights } = tiltAfterFlightHarness(); + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + searchNavigation: async () => null, + }); + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + assert.equal((await runner('fly_to_location', { query: 'Nowhere' })).ok, false); + const before = order.length; + const moved = runner('move_camera', { motion: 'tilt', pitchDeg: -45 }); + const movedDone = settledYet(moved); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(movedDone(), false, 'the tilt must wait for the Austin flight'); + assert.equal(order.slice(before).includes('cancel'), false); + flights[0].complete(); + assert.equal((await moved).ok, true); +}); + +test('two lookups that found nothing in a row still leave the motion waiting for the flight', async (t) => { + t.after(() => interruptCameraMotion('test-cleanup')); + const { viewer, styleManager, order, flights } = tiltAfterFlightHarness(); + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + searchNavigation: async () => null, + }); + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + assert.equal((await runner('fly_to_location', { query: 'Nowhere' })).ok, false); + assert.equal((await runner('fly_to_location', { query: 'Nowhere 2' })).ok, false); + const before = order.length; + const moved = runner('move_camera', { motion: 'orbit' }); + const movedDone = settledYet(moved); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(movedDone(), false, 'the orbit must wait for the Austin flight'); + assert.equal(order.slice(before).includes('cancel'), false); + flights[0].complete(); + assert.equal((await moved).ok, true); +}); + test('a stale motion does not follow a newer reply\'s fly-to', async () => { const { viewer, styleManager, flights } = tiltAfterFlightHarness(); const runner = createGevActionRunner({ From 4c9b27dc204d52603942be7803e2432d2e8b1b0e Mon Sep 17 00:00:00 2001 From: Manjunath Date: Wed, 4 Nov 2026 14:48:07 +0530 Subject: [PATCH 24/71] Keep a settled flight in reach of a later failed lookup A new voice fly-to now records the last flight that moved as the one it replaced, landed or not; only a still-flying one passes on its manual-input count. A slow lookup cancelled by a direct flight that landed, followed by a lookup that found nothing, used to drop the queued motion, and a cancelled direct flight in the same place let it fall back to an older landing. --- src/voice/gevActions.js | 22 +++++++------ src/voice/gevActions.test.mjs | 59 +++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 9 deletions(-) diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index 83cdea367..089d95be0 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -1144,11 +1144,14 @@ export function createGevActionRunner({ runOptions, ); } - // Chained fly-tos: a grab during the earlier one counts. The flight - // replaced is the last one that moved, past lookups that found nothing. + // Chained fly-tos: a grab during an earlier one still flying counts. + // The flight replaced is the last one that moved, past lookups that + // found nothing. const prior = voiceFlightTarget(voiceFlight); - const replaced = prior && !prior.done ? prior : null; - const flight = trackVoiceFlight(replaced?.inputCount, replaced); + const flight = trackVoiceFlight( + prior && !prior.done ? prior.inputCount : undefined, + prior, + ); voiceFlight = flight; const flown = flyToRequestedLocation(viewer, args, { onFlightEnd: flight.settle, @@ -2961,10 +2964,10 @@ let voiceFlightCount = 0; /** * A voice flight's end: 'arrived', 'cancelled' or 'failed', settled once. - * `replaced` is the last flight that moved, if still pending when this one - * began (past lookups that found nothing). It is kept only - * while this one may turn out to have moved nothing (a lookup that found no - * place), so a queued motion can look past it. + * `replaced` is the last flight that moved when this one began (past lookups + * that found nothing), landed or not. It is kept only while this one may turn + * out to have moved nothing (a lookup that found no place), so a queued + * motion can look past it to that flight and its outcome. */ function trackVoiceFlight( inputCount = manualCameraInputCount(), @@ -2996,7 +2999,8 @@ function trackVoiceFlight( * actually started. A lookup that found nothing moved nothing, so look past * it to the flight it replaced, but never to one older than `start` (a flight * from an earlier reply). Null when every later lookup found nothing. With - * no `start`, the latest flight that started, however old. + * no `start`, the latest flight that started, back through lookups that + * found nothing. */ function voiceFlightTarget(latest, start = null) { let flight = latest; diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index ece2f92e0..678a13659 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -791,6 +791,65 @@ test('two lookups that found nothing in a row still leave the motion waiting for assert.equal((await moved).ok, true); }); +// A flight that already settled is still what a later failed lookup replaced. +test('a motion acts at a landed flight that a later failed lookup replaced', async (t) => { + t.after(() => interruptCameraMotion('test-cleanup')); + const { viewer, styleManager, flights } = tiltAfterFlightHarness(); + const searches = []; + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + searchNavigation: (_viewer, query, options) => new Promise((resolve) => { + searches.push({ query, options, resolve }); + }), + }); + const flyA = runner('fly_to_location', { query: 'Slow A' }); + const moved = runner('move_camera', { motion: 'orbit' }); + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + flights[0].complete(); + const flyC = runner('fly_to_location', { query: 'Nowhere C' }); + await new Promise((resolve) => setTimeout(resolve, 0)); + // B's start made A's lookup stale; C then finds nothing. + searches[0].resolve({ cancelled: true }); + searches[1].resolve(null); + assert.equal((await flyA).cancelled, true); + assert.equal((await flyC).ok, false); + const result = await moved; + assert.equal(result.ok, true, result.error); +}); + +test('a motion refuses when the flight a later failed lookup replaced was cancelled', async () => { + const { viewer, styleManager, flights } = tiltAfterFlightHarness(); + const searches = []; + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + searchNavigation: (_viewer, query, options) => new Promise((resolve) => { + searches.push({ query, options, resolve }); + }), + }); + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + const moved = runner('move_camera', { motion: 'orbit' }); + const flyA = runner('fly_to_location', { query: 'Slow A' }); + await new Promise((resolve) => setTimeout(resolve, 0)); + // The motion sees Austin land, then follows A's lookup. + flights[0].complete(); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + flights[1].cancel(); + const flyC = runner('fly_to_location', { query: 'Nowhere C' }); + await new Promise((resolve) => setTimeout(resolve, 0)); + searches[0].resolve({ cancelled: true }); + searches[1].resolve(null); + assert.equal((await flyA).cancelled, true); + assert.equal((await flyC).ok, false); + const result = await moved; + assert.equal(result.ok, false); + assert.match(result.error, /did not arrive/); +}); + test('a stale motion does not follow a newer reply\'s fly-to', async () => { const { viewer, styleManager, flights } = tiltAfterFlightHarness(); const runner = createGevActionRunner({ From 99831ea19b2ff5b9fff9d40e26a11422e277bb99 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 4 Nov 2026 16:34:39 +0400 Subject: [PATCH 25/71] Keep STALE on Contacts while a poll is in flight A poll that returned the same stale snapshot dropped STALE from the Contacts cohort until the next answer, because the in-flight loading flag outranked stale and the source revision ignored the provider name. A source that has answered now reads its status without the loading flag; one that has never answered (AIS first connect) still reads as loading, so it shows no STALE before any snapshot exists. --- src/layers/awareness/queries.js | 1 + .../awareness/staleAfterLoading.test.mjs | 159 ++++++++++++++++++ src/layers/awareness/subject.js | 8 +- src/layers/vessels/ownership.test.mjs | 2 - src/ui/cockpitContext.test.mjs | 2 - 5 files changed, 167 insertions(+), 5 deletions(-) create mode 100644 src/layers/awareness/staleAfterLoading.test.mjs diff --git a/src/layers/awareness/queries.js b/src/layers/awareness/queries.js index 593c85ebf..0c5bd78f3 100644 --- a/src/layers/awareness/queries.js +++ b/src/layers/awareness/queries.js @@ -91,6 +91,7 @@ export function createQueries({ state: layerState, services, parts, source }) { stats.count ?? null, stats.status || null, stats.error || null, + stats.source || null, ]; }) .map((parts) => parts.join(':')) diff --git a/src/layers/awareness/staleAfterLoading.test.mjs b/src/layers/awareness/staleAfterLoading.test.mjs new file mode 100644 index 000000000..7128f4958 --- /dev/null +++ b/src/layers/awareness/staleAfterLoading.test.mjs @@ -0,0 +1,159 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { createQueries } from './queries.js'; +import { createSubject } from './subject.js'; +import { createModel } from './model.js'; +import { summarizeAwarenessCohort } from '../../data/militaryAwarenessEngine.js'; +import { layerFeedState } from '../../data/feedState.js'; +import aisLiveVesselsLayer, { + _setVesselStateForTest, + _beginAisSessionForTest, + _applyAisFeedSnapshotForTest, + _setAisRuntimeForTest, +} from '../../data/aisLiveVessels.js'; + +test('a poll that returns the same stale AIS snapshot restores STALE on Contacts cohorts', () => { + globalThis.document ??= { body: null }; + const services = { + installations: { getNearby: () => [], getStats: () => ({}) }, + flights: { getNearby: () => [] }, + military: { getNearby: () => [] }, + vessels: { getNearby: () => [] }, + }; + const layerState = { + enabled: true, + passive: true, + sourceRevision: '', + cohortPages: new Map(), + subject: { + id: 'site-1', + layerId: 'military-installations', + label: 'Site', + position: Cesium.Cartesian3.fromDegrees(0, 0, 0), + }, + }; + let states; + const parts = { + navigation: { + summarizeAwarenessCohortForNavigation: (items, source) => + summarizeAwarenessCohort(items, source), + isFlightLayer: (id) => id === 'flights' || id === 'military', + }, + rendering: { renderVisual() {}, scheduleDirectionOverlayUpdate() {} }, + panel: { renderResults() {} }, + }; + const realQueries = createQueries({ state: layerState, services, parts }); + parts.queries = { ...realQueries, collectSourceStates: () => states }; + parts.model = createModel({ state: layerState, services, parts }); + const subject = createSubject({ state: layerState, services, parts }); + const flights = (lastUpdate) => ({ + available: true, enabled: true, stale: false, + stats: { source: 'OpenSky Network', count: 4, lastUpdate }, + }); + const vessels = (loading) => ({ + available: true, enabled: true, stale: true, + stats: { source: 'Datalastic', stale: true, count: 3, lastUpdate: 1000, loading }, + }); + const rest = { + military: { available: true, enabled: true, stale: false, stats: { source: 'adsb.lol', fallback: false, count: 1, lastUpdate: 1 } }, + 'military-installations': { available: true, enabled: true, stale: false, stats: { coverage: { kind: 'subject', radiusM: 100000 } } }, + }; + const ais = () => layerState.results.cohorts.find((c) => c.id === 'ais-live-vessels'); + + // 1. Stale AIS cache, nothing loading. + states = { flights: flights(1), 'ais-live-vessels': vessels(false), ...rest }; + subject.refreshSelectedSubject(true); + assert.equal(ais().sourceStatus, 'stale'); + + // 2. AIS re-poll in flight while the flights poll lands (revision changes). + states = { flights: flights(2), 'ais-live-vessels': vessels(true), ...rest }; + subject.refreshSelectedSubject(); + assert.equal(ais().sourceStatus, 'stale', 'a poll in flight keeps the snapshot stale'); + + // 3. AIS poll returns the same stale snapshot; only loading flips back. + states = { flights: flights(2), 'ais-live-vessels': vessels(false), ...rest }; + subject.refreshSelectedSubject(); + subject.refreshSelectedSubject(); + assert.equal(ais().sourceStatus, 'stale', 'Layers reads STALE again; Contacts/Cockpit must too'); +}); + +function fakeRuntime() { + return { + now: () => 1000, + setTimeout: () => ({}), + clearTimeout() {}, + }; +} + +test('first AIS connect (no snapshot yet) carries no STALE on the Contacts cohort', () => { + globalThis.document ??= { body: null }; + _setAisRuntimeForTest(fakeRuntime()); + _setVesselStateForTest({ viewer: {}, records: [] }); + try { + _beginAisSessionForTest(); + // What server/providers/vessels/ais-live.js answers while its socket connects. + _applyAisFeedSnapshotForTest({}, { + rows: [], source: 'AISStream', status: 'connecting', refreshing: true, + }); + const vesselStats = aisLiveVesselsLayer.getStats(); + assert.equal(vesselStats.loading, true); + assert.equal(vesselStats.stale, true); + assert.equal(vesselStats.lastUpdate, null); + assert.equal(layerFeedState(vesselStats), 'loading', 'Layers chip reads LOADING'); + + const flightsStats = { source: 'OpenSky Network', count: 2, lastUpdate: 5 }; + const militaryStats = { source: 'adsb.lol', fallback: false, count: 1, lastUpdate: 5 }; + const installStats = { coverage: { kind: 'subject', radiusM: 100000 } }; + const statsById = { + flights: flightsStats, + military: militaryStats, + 'ais-live-vessels': vesselStats, + 'military-installations': installStats, + }; + const services = { + installations: { getNearby: () => [], getStats: () => installStats }, + flights: { getNearby: () => [] }, + military: { getNearby: () => [] }, + vessels: aisLiveVesselsLayer, + }; + const layerState = { + enabled: true, + passive: true, + sourceRevision: '', + cohortPages: new Map(), + dataManager: { + isEnabled: () => true, + getLayerLifecycleState: () => ({ enabled: true, lifecycleState: 'enabled' }), + layers: new Map(Object.entries(statsById).map(([id, s]) => [id, { module: { getStats: () => s } }])), + }, + subject: { + id: 'site-1', + layerId: 'military-installations', + label: 'Site', + position: Cesium.Cartesian3.fromDegrees(0, 0, 0), + }, + }; + const parts = { + navigation: { + summarizeAwarenessCohortForNavigation: (items, source) => summarizeAwarenessCohort(items, source), + isFlightLayer: (id) => id === 'flights' || id === 'military', + }, + rendering: { renderVisual() {}, scheduleDirectionOverlayUpdate() {} }, + panel: { renderResults() {} }, + }; + parts.queries = createQueries({ state: layerState, services, parts }); + parts.model = createModel({ state: layerState, services, parts }); + const subject = createSubject({ state: layerState, services, parts }); + + const vesselsSource = parts.queries.collectSourceStates()['ais-live-vessels']; + assert.equal(vesselsSource.available, false, 'never-answered source is unavailable'); + + subject.refreshSelectedSubject(true); + const ais = layerState.results.cohorts.find((c) => c.id === 'ais-live-vessels'); + assert.equal(ais.sourceStatus, null, 'no snapshot yet: no STALE label, Layers says LOADING'); + } finally { + _setVesselStateForTest({ enabled: false }); + _setAisRuntimeForTest(); + } +}); diff --git a/src/layers/awareness/subject.js b/src/layers/awareness/subject.js index a555d332e..32123d248 100644 --- a/src/layers/awareness/subject.js +++ b/src/layers/awareness/subject.js @@ -54,8 +54,14 @@ export function createSubject({ state: layerState, services, parts, source }) { const installationsState = sourceStates['military-installations']; const evaluatedAt = Date.now(); // Whether the provider answered as a fallback or from a stale snapshot. + // A poll in flight does not change that, and the source revision ignores it. + // Before the first answer there is no snapshot, so loading stays. const sourceStatus = (sourceState) => { - const feedState = layerFeedState(sourceState.stats); + const feedState = layerFeedState( + sourceState.available + ? { ...sourceState.stats, loading: false } + : sourceState.stats, + ); return feedState === 'fallback' || feedState === 'stale' ? feedState : null; diff --git a/src/layers/vessels/ownership.test.mjs b/src/layers/vessels/ownership.test.mjs index a838a8099..2404434a0 100644 --- a/src/layers/vessels/ownership.test.mjs +++ b/src/layers/vessels/ownership.test.mjs @@ -411,8 +411,6 @@ test('empty coverage cannot hide stale, incomplete or rejected source data', asy }); test('vessel stats name the provider the last snapshot reported', async () => { - // HAL-721: Contacts named a fixed AIS provider while this layer showed the - // one its server reported. const { layer } = setup({ label: 'Vessels', async getSnapshot() { diff --git a/src/ui/cockpitContext.test.mjs b/src/ui/cockpitContext.test.mjs index a8d6616d5..c2f20abcf 100644 --- a/src/ui/cockpitContext.test.mjs +++ b/src/ui/cockpitContext.test.mjs @@ -167,8 +167,6 @@ test('a snapshot fills the card and keeps classes other code sets', async (t) => }); test('each count names the provider its snapshot reports, with its feed state', async (t) => { - // HAL-721: the counters named fixed providers while the layer and the - // Contacts list named the one that answered. const reported = snapshot(); reported.cohorts[0].source = 'adsb.lol'; reported.cohorts[0].sourceStatus = 'fallback'; From 53ea76251f46fd2b6f0c08914a30bbbb74a1f423 Mon Sep 17 00:00:00 2001 From: manjunath22466 <301209272+manjunath22466@users.noreply.github.com> Date: Wed, 4 Nov 2026 19:34:30 +0530 Subject: [PATCH 26/71] fix(voice): keep chat's view across a provider switch and clear stale errors Switching AI Provider in Settings opened from chat rebuilt the voice control, so Settings fell back to voice and the dock went idle, widening the wings. The switch now carries chat's Settings, or chat itself, to the new provider as it already carries the input mode; the conversation still starts fresh and focus stays on the provider choice. After a voice error, opening chat or closing the voice panel left the dock red on ERROR, because both stopped only an active session. They now also end a session left in error, which returns the dock to idle. --- docs/CURRENT-STATE.md | 7 ++++-- src/voice/chatControl.js | 34 ++++++++++++++++++++++++++++- src/voice/chatControlView.test.mjs | 31 ++++++++++++++++++++++++++ src/voice/panel/host.test.mjs | 21 ++++++++++++++++++ src/voice/providerCommands.js | 7 ++++-- src/voice/providerCommands.test.mjs | 15 +++++++++++++ src/voice/sessionCommands.js | 11 +++++++++- 7 files changed, 120 insertions(+), 6 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c3e15ea4d..fa2287567 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -766,9 +766,12 @@ each side, and the empty Voice Preview suggests things to say until voice starts. Settings opened from chat keep chat's frame and show the shared AI provider, the quick-commands option and then the voice preferences (Voice Preview, inactivity timeout and model tier), so they stay in reach while -Preview is off, with Back to chat; voice Settings show Back to voice. Escape steps back from either Settings view, closes Voice +Preview is off, with Back to chat; voice Settings show Back to voice. Switching +AI Provider keeps that view: chat's Settings, or chat itself, stay as they were +for the new provider. Escape steps back from either Settings view, closes Voice Preview, and does nothing in chat. Closing chat ends a typed session, and the -voice panel's close turns voice off. Asking the assistant from chat to open +voice panel's close turns voice off. Opening chat or closing the voice panel +also clears a voice error left on the dock. Asking the assistant from chat to open Voice Settings or Voice Preview puts chat away, minimized or not, and shows the voice panel; the typed conversation and its running reply carry on, and the chat button brings them back. diff --git a/src/voice/chatControl.js b/src/voice/chatControl.js index 6ab5bf494..d366181a7 100644 --- a/src/voice/chatControl.js +++ b/src/voice/chatControl.js @@ -94,7 +94,10 @@ export function bindChatControl({ ui, session, onOpen, dataManager }) { : ''; function open({ focus = true } = {}) { leaveChatSettings(); - if (!isTyped() && session.isActive()) session.stop(); + // Chat replaces voice, a stale voice error included, so the dock stops + // showing it. + if (!isTyped() && (session.isActive() || session.state === 'error')) + session.stop(); onOpen?.(); ui.update('chat', { open: true, minimized: false, rows: 2, authNote }); sync(); @@ -413,10 +416,39 @@ export function bindChatControl({ ui, session, onOpen, dataManager }) { }), ]; sync(); + // What a provider switch carries to the next mount: chat's Settings or + // chat itself, never the conversation. + function presentation() { + if (inChatSettings()) { + const { frameWidth, frameHeight } = ui.state.voicePanel; + return { view: 'settings', frameWidth, frameHeight }; + } + if (chat().open) return { view: chat().minimized ? 'minimized' : 'open' }; + return null; + } + // Restored without moving focus: the switch keeps it on the provider choice. + function restorePresentation(state) { + if (disposed || !state) return; + if (state.view === 'settings') { + ui.update('voicePanel', { + context: 'chat', + ...(state.frameWidth && { + frameWidth: state.frameWidth, + frameHeight: state.frameHeight, + }), + }); + ui.update('chat', { backVisible: true }); + return sync(); + } + open({ focus: false }); + if (state.view === 'minimized') minimize({ focus: false }); + } return { close, minimize, restore: open, + presentation, + restorePresentation, get minimized() { return chat().open && chat().minimized; }, diff --git a/src/voice/chatControlView.test.mjs b/src/voice/chatControlView.test.mjs index 5dee1ad97..e329a2dc3 100644 --- a/src/voice/chatControlView.test.mjs +++ b/src/voice/chatControlView.test.mjs @@ -364,3 +364,34 @@ test('the chat gear is named for the settings it opens', async (t) => { 'Settings', ); }); + +test('a provider switch carries chat Settings or chat itself to the next mount', async (t) => { + const mount = () => { + const ui = createTestVoiceUi({ panel: true }); + const chat = bindChatControl({ ui, session: typedSession() }); + t.after(() => chat.destroy()); + return { ui, chat }; + }; + // Chat's Settings: the next mount shows Settings in chat's frame again. + const before = mount(); + before.ui.emit('chat-button'); + before.ui.update('voicePanel', { hidden: false }); + before.ui.emit('settings-button'); + const settings = before.chat.presentation(); + assert.equal(settings.view, 'settings'); + const after = mount(); + after.chat.restorePresentation(settings); + assert.equal(after.ui.state.voicePanel.context, 'chat'); + assert.equal(after.ui.state.chat.backVisible, true); + assert.equal(after.ui.state.root.assistantMode, 'chat'); + // Open and minimized chat come back as they were, without taking focus. + for (const view of ['open', 'minimized']) { + const next = mount(); + next.chat.restorePresentation({ view }); + assert.equal(next.ui.state.chat.open, true); + assert.equal(next.ui.state.chat.minimized, view === 'minimized'); + assert.equal(next.chat.presentation().view, view); + } + // Idle chat carries nothing. + assert.equal(mount().chat.presentation(), null); +}); diff --git a/src/voice/panel/host.test.mjs b/src/voice/panel/host.test.mjs index 3c3b3332a..ae5cd1994 100644 --- a/src/voice/panel/host.test.mjs +++ b/src/voice/panel/host.test.mjs @@ -1298,6 +1298,27 @@ test('undocking while a tray is open brings the panel back as its bar', async (t assert.equal($('#gev-chat-panel').dataset.placement, 'compact'); }); +test('opening chat or closing the voice panel clears a stale voice error', async (t) => { + for (const leave of ['chat', 'close']) { + const f = mounted(t, { view: true }); + await f.commands.session.start(); + f.commands.voicePanel.select('preview'); + f.instances[0].hooks.emit({ + type: 'state', + state: 'error', + detail: 'Lost', + }); + await f.settle(); + assert.equal($('#gev-voice-control').dataset.status, 'error'); + assert.equal(f.commands.session.isActive(), false); + if (leave === 'chat') $('#gev-chat-button').click(); + else $('#gev-voice-settings-close').click(); + await f.settle(); + assert.equal(f.commands.session.state, 'idle', leave); + assert.notEqual($('#gev-voice-control').dataset.status, 'error', leave); + } +}); + test('the voice panel close turns voice off', async (t) => { const f = mounted(t, { view: true }); await f.commands.session.start(); diff --git a/src/voice/providerCommands.js b/src/voice/providerCommands.js index 66201141e..756215c7b 100644 --- a/src/voice/providerCommands.js +++ b/src/voice/providerCommands.js @@ -64,6 +64,8 @@ export function createProviderCommands(options, { bind, factories, storage }) { const textOnly = Boolean( current.session.adapter.getStartOptions?.().textOnly, ); + // The panel stays where the user is: chat's Settings or chat itself. + const presentation = current.chatPresentation?.() ?? null; const generation = ++mountGeneration; voiceEnabled = false; current.session.destroy(); @@ -80,7 +82,7 @@ export function createProviderCommands(options, { bind, factories, storage }) { } catch { /* Storage is optional. */ } - mount(); + mount(presentation); if (resume && !disposed && provider === next) void current.session.start({ pushToTalk, @@ -142,7 +144,7 @@ export function createProviderCommands(options, { bind, factories, storage }) { return typeof value === 'function' ? value.bind(current) : value; }, }); - function mount() { + function mount(presentation = null) { voiceEnabled = false; const generation = ++mountGeneration; current = bind({ @@ -158,6 +160,7 @@ export function createProviderCommands(options, { bind, factories, storage }) { handleSessionEvent(event); }, createSession: factories[provider], + presentation, }); window.__gevVoiceCommands = facade; } diff --git a/src/voice/providerCommands.test.mjs b/src/voice/providerCommands.test.mjs index 41d092e73..dfcf77052 100644 --- a/src/voice/providerCommands.test.mjs +++ b/src/voice/providerCommands.test.mjs @@ -856,3 +856,18 @@ for (const action of ['stop', 'destroy']) { }); } } + +test('a provider switch hands chat Settings or chat to the new mount', async (t) => { + const f = compositionFixture(t); + const first = f.facade.provider; + const next = first === 'openai' ? 'gemini' : 'openai'; + // Without chat open there is nothing to carry. + assert.equal(f.mounts[0].controls.chatPresentation(), null); + const shown = { view: 'settings', frameWidth: 320, frameHeight: 480 }; + f.mounts[0].controls.chatPresentation = () => shown; + f.select(next); + await settle(); + assert.equal(f.mounts.length, 2); + assert.deepEqual(f.mounts[1].options.presentation, shown); + assert.equal(f.facade.provider, next); +}); diff --git a/src/voice/sessionCommands.js b/src/voice/sessionCommands.js index 78a1816b3..5a8a7e90c 100644 --- a/src/voice/sessionCommands.js +++ b/src/voice/sessionCommands.js @@ -40,6 +40,8 @@ export function createVoiceCommands({ createReticle = createDefaultReticle, panelHost = null, nativePanelProvider = null, + // Chat's Settings or chat itself, carried across a provider switch. + presentation = null, }) { if (resetExisting) window.__gevVoiceCommands?.stop?.({ removeUi: true }); const ui = createControl(panelHost ? { panel: true } : {}); @@ -225,6 +227,8 @@ export function createVoiceCommands({ onOpen: () => panelMount?.panel.close(), }) : null; + chat?.restorePresentation(presentation); + controls.chatPresentation = () => chat?.presentation() ?? null; function minimizeVoicePanel() { const panel = ui.element('voicePanel'); const holdsFocus = panel?.contains?.(globalThis.document?.activeElement); @@ -284,7 +288,12 @@ export function createVoiceCommands({ // The panel's close turns voice off, as chat's close ends the chat. A typed // session belongs to chat, which handles its own close. const offClose = ui.on('settings-close', () => { - if (!session.isActive() || adapter.getStartOptions?.().textOnly) return; + // A voice error the user closes is over too; stopping returns to idle. + if ( + (!session.isActive() && session.state !== 'error') || + adapter.getStartOptions?.().textOnly + ) + return; const ticket = panelMount?.stopTicket(); session.stop(); panelMount?.closeAfterStop(ticket); From 46dde909ccc403585d0dc4958430ab2822f790ce Mon Sep 17 00:00:00 2001 From: manjunath22466 <301209272+manjunath22466@users.noreply.github.com> Date: Wed, 4 Nov 2026 19:37:47 +0530 Subject: [PATCH 27/71] docs(changelog): add #1546 fragment --- changelog.d/1546-chat-provider-switch.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 changelog.d/1546-chat-provider-switch.md diff --git a/changelog.d/1546-chat-provider-switch.md b/changelog.d/1546-chat-provider-switch.md new file mode 100644 index 000000000..1c3210330 --- /dev/null +++ b/changelog.d/1546-chat-provider-switch.md @@ -0,0 +1,5 @@ +- Keep typed chat's view when switching AI Provider: Settings opened from + chat, or chat itself, stay as they were for the new provider instead of + falling back to voice and widening the dock. Opening chat or closing the + voice panel now also clears a voice error left on the dock (Manjunath, + @manjunath22466, #1546). From 55e6c0017c79215220421c6a727742ec8005839d Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 4 Nov 2026 18:15:16 +0400 Subject: [PATCH 28/71] Tighten the provider-label comments and docs --- changelog.d/1521-provider-labels.md | 8 ++++---- docs/CURRENT-STATE.md | 2 +- src/layers/awareness/subject.js | 3 +-- src/layers/vessels/queries.js | 2 +- src/ui/cockpitContext.js | 1 - 5 files changed, 7 insertions(+), 9 deletions(-) diff --git a/changelog.d/1521-provider-labels.md b/changelog.d/1521-provider-labels.md index d921d9d7b..887d6a775 100644 --- a/changelog.d/1521-provider-labels.md +++ b/changelog.d/1521-provider-labels.md @@ -1,4 +1,4 @@ -- Name the provider that answered in the Cockpit Contact counts and the - Contacts list, as the Layers panel does, marked `FALLBACK` or `STALE` when - it is. The counts no longer read OpenSky while flights come from the adsb.lol - fallback, and vessels no longer always read AISStream (German, @gekh, #1521). +- Cockpit Contact counts and the Contacts list name the provider that + answered, marked `FALLBACK` or `STALE` as in the Layers panel. Flights from + the adsb.lol fallback no longer read OpenSky, and vessels no longer always + read AISStream (German, @gekh, #1521). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index e845b1472..c6abbf74a 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5112,7 +5112,7 @@ silently demoting every later lookup for the session. - Enabled Data Layer controls report normalized feed health on the button (`LOADING`, `DEGRADED`, `STALE`, `FALLBACK`, or `UNAVAILABLE`) while the metadata line retains the source and reason. A partial CelesTrak group failure keeps the usable catalog and reports `DEGRADED`; a total outage keeps last-good catalog data visible but reports `UNAVAILABLE`. - On activation it focuses the nearest currently observed aircraft across the civilian and military feeds, with military winning an exact distance tie; if none are available, it focuses an observed AIS vessel. The aircraft search is deliberately uncapped and one refresh-tick retry handles initially empty feeds. This is an attention-priority navigation shortcut, not a high-risk, affiliation, or threat classification. - A selected aircraft, AIS vessel, or mapped installation gets a 250 km **context window** with nearby cohort counts, nearest examples, source labels, and stale/unavailable reasons. It emits `NEARBY` or `UNKNOWN`; no detection, engagement, affiliation, or sensor-activity conclusion is calculated. -- Contacts rows and the Cockpit Contact counts name each cohort's provider as its layer row does: the source the last snapshot reported, marked `FALLBACK` or `STALE` in the same cases. A flight snapshot answered by the adsb.lol regional fallback reads `adsb.lol · FALLBACK` everywhere, never `OpenSky`. +- Contacts rows and the Cockpit Contact counts name each cohort's provider: the source of the last snapshot, marked `FALLBACK` or `STALE` as its layer row would be. A poll in flight keeps the mark, where the layer row shows `LOADING`. A flight snapshot from the adsb.lol regional fallback reads `adsb.lol · FALLBACK`, not `OpenSky`. - The window's edge is drawn on the globe as a 250 km range ring: a 4 px polyline with a 2 px Context-blue core inside a 1 px dark halo on each side, 2.5 km above the ellipsoid (`AWARENESS_RANGE_RING_STYLE` in `src/layers/awareness/policy.js`). The halo keeps it legible over bright desert and snow, dark ocean and forest, and under the FLIR, NVG and CRT styles, where the earlier 1 px ellipse outline disappeared. It is a polyline because WebGL draws ellipse outlines at 1 px whatever width is asked for, drawn as an unpickable primitive rather than an entity so a click or tap on its edge reaches the contact underneath (headless viewers without a primitive collection get a static entity instead); it is rebuilt when the subject moves. Three dim sonar pulses (3 px with a 1 px dark outline, same colours, at most 60% opacity) grow slowly from the subject at constant speed, one every 3 s and each taking 9 s, and arrive at the ring at about a third of that. Behind each pulse a radial gradient in the same blue fades inward over 35% of the radius. One unpickable 250 km disc draws both the pulse lines and the gradient, its shader reading the pulse radii and opacities each frame, so a click or tap near a pulse still reaches the contact underneath; the disc follows the subject by a model-matrix rotation and is rebuilt only after the subject moves 20 km, and not at all while the pulses rest. While the camera is below the disc's 2.5 km height, where it would sweep across the sky, the pulses rest: the disc is hidden and there is no ring flash, no echo and no frame request. Terrain or 3D tiles higher than 2.5 km (the Alps, Andes, Rockies) still cut through the disc and the ring when seen from above, as the ring alone already did; the height check uses the camera's height above the ellipsoid, not above the ground. While pulses run, the ring dims to 40% and flashes to full strength as each pulse reaches it, fading over 1.8 s. As a pulse front crosses a flight, military aircraft or AIS vessel, the detection overlay draws a second bracket around that contact's own bracket, in the pulse blue on the default detection style and in the style's own line colour under NVG, FLIR and CRT, which spreads outward as it fades over 1.4 s (`AWARENESS_RANGE_ECHO`, read through `src/awarenessEcho.js`; no layer's icon is restyled, and mapped installations have no detection bracket). Pulses are born 5 km out, so the subject itself and anything nearer never echo. Pulses run for 9 s (three ring flashes) after a selection or a camera move, fading in and out over 1.2 s, then rest: the ring stays on screen at full strength, the pulses, gradient and echoes are hidden, and nothing asks for frames, so a parked Contacts view is quiet again. Only a camera move within 1.5 s of the user handling the map (pointer, wheel or touch on the Cesium canvas, which covers a drag's inertia, and a drag or touch begun there keeps counting until its last finger or button lifts, even outside the canvas, unless the window loses focus or the page is hidden first; keys typed elsewhere do not count, nor does input while Cesium's camera controls are off, as in the cockpit view, and a gesture still pending when the cockpit or another owner takes the camera is dropped) wakes them; a moving subject and the app's own camera moves (a tracked aircraft, the cockpit view, an orbit, a fly-to) do not, since they move on every refresh. While they run they never hold continuous rendering: each drawn frame arms one render request 80 ms later (the Cyber sonar cadence, `CYBER_SONAR_RENDER_INTERVAL_MS`), so they repaint at about 12.5 fps and never beat the detection lane's 80 ms gate. Under `prefers-reduced-motion`, or in a scene without primitives or preRender and postRender hooks, there are no pulses or echoes and the ring stays on screen; changing the preference mid-session rebuilds the ring. The ring follows a moving subject once it has moved 250 m. - For a selected live aircraft or vessel, the context window refreshes from the existing tracker/feed position every 750 ms, so nearest distances and cohort counts follow the subject without introducing a duplicate poll loop. - Aircraft cohort membership uses every locally loaded, selectable contact inside the 250 km window, including a plane hidden only by horizon culling or because its 3D model owns the visual. Counts and navigation therefore do not change with the current camera angle or billboard/model handoff. diff --git a/src/layers/awareness/subject.js b/src/layers/awareness/subject.js index 32123d248..60f622e29 100644 --- a/src/layers/awareness/subject.js +++ b/src/layers/awareness/subject.js @@ -53,8 +53,7 @@ export function createSubject({ state: layerState, services, parts, source }) { const vesselsState = sourceStates['ais-live-vessels']; const installationsState = sourceStates['military-installations']; const evaluatedAt = Date.now(); - // Whether the provider answered as a fallback or from a stale snapshot. - // A poll in flight does not change that, and the source revision ignores it. + // A poll in flight keeps FALLBACK or STALE: the revision key ignores loading. // Before the first answer there is no snapshot, so loading stays. const sourceStatus = (sourceState) => { const feedState = layerFeedState( diff --git a/src/layers/vessels/queries.js b/src/layers/vessels/queries.js index 48ded012b..d4d9ef796 100644 --- a/src/layers/vessels/queries.js +++ b/src/layers/vessels/queries.js @@ -548,7 +548,7 @@ export function createQueries({ // backing off, say how long until the next attempt instead of leaving // the user to guess whether anything is still happening. retryInSec: aisRetryInSec(), - // The provider the last snapshot named, as flights and military report it. + // Same field as flights and military, so Contacts names the provider. source: layer.source, }; }, diff --git a/src/ui/cockpitContext.js b/src/ui/cockpitContext.js index ad73869db..6dc209977 100644 --- a/src/ui/cockpitContext.js +++ b/src/ui/cockpitContext.js @@ -111,7 +111,6 @@ export function updateContext(info, heading) { if (shown) { shown.count = cohort.count === null ? '?' : String(cohort.count); shown.unknown = cohort.relationship === 'UNKNOWN'; - // The provider that answered, named as the Layers panel names it. shown.source = sourceStatusLabel(cohort.source, cohort.sourceStatus); } if (cohort.count === null) unknownCount += 1; From 82f17e694e9fe2f1b49d0ca322572fcbfa39d51d Mon Sep 17 00:00:00 2001 From: Denis Groshev Date: Wed, 4 Nov 2026 17:03:52 +0100 Subject: [PATCH 29/71] refactor(cockpit): place the camera from a per-subject eye profile Split the Cockpit camera update into the aircraft's own front half and a shared placeCamera that reads the eye's forward and up offsets, pitch and ground clearance from a profile. The aircraft profile carries the existing constants, so the aircraft view is unchanged; other subjects can now supply their own eye. --- src/ui/cockpitCamera.js | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/src/ui/cockpitCamera.js b/src/ui/cockpitCamera.js index c33ba3c15..0a2d1f372 100644 --- a/src/ui/cockpitCamera.js +++ b/src/ui/cockpitCamera.js @@ -19,6 +19,14 @@ import { COCKPIT_GROUND_WAIT_TIMEOUT_MS, } from './cockpitPresentation.js'; +/** The aircraft eye: just ahead of and above the reported position. */ +const AIRCRAFT_CAMERA_PROFILE = Object.freeze({ + forwardM: COCKPIT_FORWARD_OFFSET_M, + upM: COCKPIT_UP_OFFSET_M, + pitchDeg: COCKPIT_VIEW_PITCH_DEG, + minClearanceM: COCKPIT_MIN_GROUND_CLEARANCE_M, +}); + export function update() { if (this.destroyed) return false; if (!this.active) { @@ -75,7 +83,20 @@ export function update() { COCKPIT_HEADING_SLEW_DPS * dtSec, ); } + placeCamera.call(this, target, info, nowMs, dtSec, AIRCRAFT_CAMERA_PROFILE); +} +/** + * Place the camera for one update: advance the inertial anchor, clamp it and + * the eye against the rendered surface, and look along the slewed heading. + * @param {Cesium.Cartesian3} target Authoritative subject position. + * @param {object} info Subject info. + * @param {number} nowMs Frame time. + * @param {number} dtSec Seconds since the previous camera update. + * @param {{forwardM: number, upM: number, pitchDeg: number, + * minClearanceM: number}} profile Eye offsets for the subject kind. + */ +function placeCamera(target, info, nowMs, dtSec, profile) { if (!this.cockpitAnchorValid) { Cesium.Cartesian3.clone(target, this.cockpitAnchor); this.cockpitAnchorValid = true; @@ -89,7 +110,7 @@ export function update() { // 15/30-second interpolation and per-frame cache without exposing its // sample-boundary corrections to the camera. const headingRad = Cesium.Math.toRadians(this.heading ?? 0); - const pitchRad = Cesium.Math.toRadians(COCKPIT_VIEW_PITCH_DEG); + const pitchRad = Cesium.Math.toRadians(profile.pitchDeg); const speedMps = Number.isFinite(info.velocityMps) ? Math.max(0, info.velocityMps) : 0; @@ -248,7 +269,7 @@ export function update() { const minimumAnchorHeightM = cockpitGroundSafeHeight( anchorCartographic.height, cockpitFloorM, - COCKPIT_MIN_GROUND_CLEARANCE_M - COCKPIT_UP_OFFSET_M, + profile.minClearanceM - profile.upM, ); if (minimumAnchorHeightM !== anchorCartographic.height) { anchorCartographic.height = minimumAnchorHeightM; @@ -297,7 +318,7 @@ export function update() { Cesium.Cartesian3.multiplyByScalar( this.scratchHorizontal, - COCKPIT_FORWARD_OFFSET_M, + profile.forwardM, this.scratchCamera, ); Cesium.Cartesian3.add( @@ -309,7 +330,7 @@ export function update() { Cesium.Cartesian3.normalize(this.scratchTarget, this.scratchTarget); Cesium.Cartesian3.multiplyByScalar( this.scratchTarget, - COCKPIT_UP_OFFSET_M, + profile.upM, this.scratchTarget, ); Cesium.Cartesian3.add( @@ -338,7 +359,7 @@ export function update() { const safeHeightM = cockpitGroundSafeHeight( cameraCartographic.height, cockpitFloorM, - COCKPIT_MIN_GROUND_CLEARANCE_M, + profile.minClearanceM, ); if (safeHeightM !== cameraCartographic.height) { cameraCartographic.height = safeHeightM; From 3577d195133fc28f7c8bbc1ada3155188a85f3fa Mon Sep 17 00:00:00 2001 From: manjunath22466 <301209272+manjunath22466@users.noreply.github.com> Date: Thu, 5 Nov 2026 00:17:08 +0530 Subject: [PATCH 30/71] Cover the real provider switch and share chat Settings and stop checks - Host test switches AI Provider from chat Settings through the real provider commands and checks the new mount keeps chat's context, frame, Back to chat and focus on the provider choice. - Drop the open and minimized branches of the carried presentation: the provider choice lives only in Settings, so chat is never open then. - One showChatSettings helper for the gear and the restore, and one isStoppable helper for the live-or-error check in chat and the panel close. --- changelog.d/1546-chat-provider-switch.md | 4 +- docs/CURRENT-STATE.md | 4 +- src/voice/chatControl.js | 56 ++++++++++-------------- src/voice/chatControlView.test.mjs | 23 +++++----- src/voice/panel/host.test.mjs | 50 +++++++++++++++++++++ src/voice/providerCommands.js | 2 +- src/voice/providerCommands.test.mjs | 4 +- src/voice/session.js | 4 ++ src/voice/sessionCommands.js | 10 ++--- 9 files changed, 99 insertions(+), 58 deletions(-) diff --git a/changelog.d/1546-chat-provider-switch.md b/changelog.d/1546-chat-provider-switch.md index 1c3210330..69cdbc939 100644 --- a/changelog.d/1546-chat-provider-switch.md +++ b/changelog.d/1546-chat-provider-switch.md @@ -1,5 +1,5 @@ - Keep typed chat's view when switching AI Provider: Settings opened from - chat, or chat itself, stay as they were for the new provider instead of - falling back to voice and widening the dock. Opening chat or closing the + chat stay chat's Settings for the new provider instead of falling back to + voice and widening the dock. Opening chat or closing the voice panel now also clears a voice error left on the dock (Manjunath, @manjunath22466, #1546). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index fa2287567..c01693e3d 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -767,8 +767,8 @@ starts. Settings opened from chat keep chat's frame and show the shared AI provider, the quick-commands option and then the voice preferences (Voice Preview, inactivity timeout and model tier), so they stay in reach while Preview is off, with Back to chat; voice Settings show Back to voice. Switching -AI Provider keeps that view: chat's Settings, or chat itself, stay as they were -for the new provider. Escape steps back from either Settings view, closes Voice +AI Provider keeps that view: Settings opened from chat stay chat's Settings, +in chat's frame, for the new provider. Escape steps back from either Settings view, closes Voice Preview, and does nothing in chat. Closing chat ends a typed session, and the voice panel's close turns voice off. Opening chat or closing the voice panel also clears a voice error left on the dock. Asking the assistant from chat to open diff --git a/src/voice/chatControl.js b/src/voice/chatControl.js index d366181a7..0525b9bc5 100644 --- a/src/voice/chatControl.js +++ b/src/voice/chatControl.js @@ -1,6 +1,7 @@ import { bindChatAttachments } from './chatAttachments.js'; import { bindChatPreferences } from './chatPreferences.js'; import { readStoredCloudVoiceAuthMode } from './cloudVoiceAuth.js'; +import { isStoppable } from './session.js'; import { t } from '../i18n/index.js'; /** @@ -96,8 +97,7 @@ export function bindChatControl({ ui, session, onOpen, dataManager }) { leaveChatSettings(); // Chat replaces voice, a stale voice error included, so the dock stops // showing it. - if (!isTyped() && (session.isActive() || session.state === 'error')) - session.stop(); + if (!isTyped() && isStoppable(session)) session.stop(); onOpen?.(); ui.update('chat', { open: true, minimized: false, rows: 2, authNote }); sync(); @@ -392,15 +392,7 @@ export function bindChatControl({ ui, session, onOpen, dataManager }) { } const frame = ui.element('chatPanel')?.getBoundingClientRect?.(); close(); - ui.update('voicePanel', { - context: 'chat', - ...(frame?.width && { - frameWidth: frame.width, - frameHeight: frame.height, - }), - }); - ui.update('chat', { backVisible: true }); - sync(); + showChatSettings(frame); void ui.focus(['chatBack']); }), // The panel clears its context as it closes, so chat tracks its own. @@ -416,32 +408,30 @@ export function bindChatControl({ ui, session, onOpen, dataManager }) { }), ]; sync(); - // What a provider switch carries to the next mount: chat's Settings or - // chat itself, never the conversation. + // Settings shown from chat, in chat's frame when it has one. + function showChatSettings(frame) { + ui.update('voicePanel', { + context: 'chat', + ...(frame?.width && { + frameWidth: frame.width, + frameHeight: frame.height, + }), + }); + ui.update('chat', { backVisible: true }); + sync(); + } + // What a provider switch carries to the next mount: chat's Settings, never + // the conversation. The provider choice lives only in Settings, so chat + // itself is never open during a switch. function presentation() { - if (inChatSettings()) { - const { frameWidth, frameHeight } = ui.state.voicePanel; - return { view: 'settings', frameWidth, frameHeight }; - } - if (chat().open) return { view: chat().minimized ? 'minimized' : 'open' }; - return null; + if (!inChatSettings()) return null; + const { frameWidth: width, frameHeight: height } = ui.state.voicePanel; + return { view: 'settings', frame: width ? { width, height } : null }; } // Restored without moving focus: the switch keeps it on the provider choice. function restorePresentation(state) { - if (disposed || !state) return; - if (state.view === 'settings') { - ui.update('voicePanel', { - context: 'chat', - ...(state.frameWidth && { - frameWidth: state.frameWidth, - frameHeight: state.frameHeight, - }), - }); - ui.update('chat', { backVisible: true }); - return sync(); - } - open({ focus: false }); - if (state.view === 'minimized') minimize({ focus: false }); + if (disposed || state?.view !== 'settings') return; + showChatSettings(state.frame); } return { close, diff --git a/src/voice/chatControlView.test.mjs b/src/voice/chatControlView.test.mjs index e329a2dc3..c0175456c 100644 --- a/src/voice/chatControlView.test.mjs +++ b/src/voice/chatControlView.test.mjs @@ -365,7 +365,7 @@ test('the chat gear is named for the settings it opens', async (t) => { ); }); -test('a provider switch carries chat Settings or chat itself to the next mount', async (t) => { +test('a provider switch carries chat Settings to the next mount', async (t) => { const mount = () => { const ui = createTestVoiceUi({ panel: true }); const chat = bindChatControl({ ui, session: typedSession() }); @@ -377,21 +377,22 @@ test('a provider switch carries chat Settings or chat itself to the next mount', before.ui.emit('chat-button'); before.ui.update('voicePanel', { hidden: false }); before.ui.emit('settings-button'); + before.ui.update('voicePanel', { frameWidth: 320, frameHeight: 480 }); const settings = before.chat.presentation(); - assert.equal(settings.view, 'settings'); + assert.deepEqual(settings, { + view: 'settings', + frame: { width: 320, height: 480 }, + }); const after = mount(); after.chat.restorePresentation(settings); assert.equal(after.ui.state.voicePanel.context, 'chat'); + assert.equal(after.ui.state.voicePanel.frameWidth, 320); + assert.equal(after.ui.state.voicePanel.frameHeight, 480); assert.equal(after.ui.state.chat.backVisible, true); assert.equal(after.ui.state.root.assistantMode, 'chat'); - // Open and minimized chat come back as they were, without taking focus. - for (const view of ['open', 'minimized']) { - const next = mount(); - next.chat.restorePresentation({ view }); - assert.equal(next.ui.state.chat.open, true); - assert.equal(next.ui.state.chat.minimized, view === 'minimized'); - assert.equal(next.chat.presentation().view, view); - } - // Idle chat carries nothing. + // Chat itself, or no chat at all, carries nothing. + const open = mount(); + open.ui.emit('chat-button'); + assert.equal(open.chat.presentation(), null); assert.equal(mount().chat.presentation(), null); }); diff --git a/src/voice/panel/host.test.mjs b/src/voice/panel/host.test.mjs index ae5cd1994..57b512ac0 100644 --- a/src/voice/panel/host.test.mjs +++ b/src/voice/panel/host.test.mjs @@ -1319,6 +1319,56 @@ test('opening chat or closing the voice panel clears a stale voice error', async } }); +test('switching AI Provider from chat Settings keeps chat Settings on the new mount', async (t) => { + const view = mountVoiceView(t); + const host = createVoicePanelHost({ storage: null }); + const instances = []; + const commands = createProviderCommands( + { + runner: async () => ({}), + dataManager: { layers: new Map() }, + supportedProviders: ['openai', 'gemini'], + }, + { + bind: (options) => + bind({ + ...options, + panelHost: host, + createControl: (control) => + createTestVoiceUi({ ...control, pinia: view.pinia }), + }), + factories: { openai: factory(instances), gemini: factory(instances) }, + storage: null, + }, + ); + t.after(() => { + commands.destroy(); + host.destroy(); + }); + await view.settle(); + $('#gev-chat-button').click(); + await view.settle(); + $('#gev-chat-panel').getBoundingClientRect = () => ({ + width: 320, + height: 480, + }); + $('#gev-voice-settings-button').click(); + await view.settle(); + const old = commands.ui; + assert.equal(old.state.voicePanel.context, 'chat'); + old.emit('provider-change', 'gemini'); + await view.settle(); + const { ui } = commands; + assert.notEqual(ui, old); + assert.equal(ui.state.voicePanel.context, 'chat'); + assert.equal(ui.state.voicePanel.frameWidth, 320); + assert.equal(ui.state.voicePanel.frameHeight, 480); + assert.equal(ui.state.chat.backVisible, true); + assert.equal(ui.state.root.assistantMode, 'chat'); + assert.equal($('#gev-chat-back').hidden, false); + assert.equal(active(), 'gev-voice-provider'); +}); + test('the voice panel close turns voice off', async (t) => { const f = mounted(t, { view: true }); await f.commands.session.start(); diff --git a/src/voice/providerCommands.js b/src/voice/providerCommands.js index 756215c7b..5d77ebef6 100644 --- a/src/voice/providerCommands.js +++ b/src/voice/providerCommands.js @@ -64,7 +64,7 @@ export function createProviderCommands(options, { bind, factories, storage }) { const textOnly = Boolean( current.session.adapter.getStartOptions?.().textOnly, ); - // The panel stays where the user is: chat's Settings or chat itself. + // Settings opened from chat stay chat's Settings. const presentation = current.chatPresentation?.() ?? null; const generation = ++mountGeneration; voiceEnabled = false; diff --git a/src/voice/providerCommands.test.mjs b/src/voice/providerCommands.test.mjs index dfcf77052..a9acd2804 100644 --- a/src/voice/providerCommands.test.mjs +++ b/src/voice/providerCommands.test.mjs @@ -857,13 +857,13 @@ for (const action of ['stop', 'destroy']) { } } -test('a provider switch hands chat Settings or chat to the new mount', async (t) => { +test('a provider switch hands chat Settings to the new mount', async (t) => { const f = compositionFixture(t); const first = f.facade.provider; const next = first === 'openai' ? 'gemini' : 'openai'; // Without chat open there is nothing to carry. assert.equal(f.mounts[0].controls.chatPresentation(), null); - const shown = { view: 'settings', frameWidth: 320, frameHeight: 480 }; + const shown = { view: 'settings', frame: { width: 320, height: 480 } }; f.mounts[0].controls.chatPresentation = () => shown; f.select(next); await settle(); diff --git a/src/voice/session.js b/src/voice/session.js index 21400f04c..7c31f17d2 100644 --- a/src/voice/session.js +++ b/src/voice/session.js @@ -185,6 +185,10 @@ export function createVoiceSession({ createAdapter, runner, signal }) { }; } +// Live, or left in error: stopping either returns the session to idle. +export const isStoppable = (session) => + session.isActive() || session.state === 'error'; + function abortError() { return new DOMException('Voice action cancelled', 'AbortError'); } diff --git a/src/voice/sessionCommands.js b/src/voice/sessionCommands.js index 5a8a7e90c..242a2126b 100644 --- a/src/voice/sessionCommands.js +++ b/src/voice/sessionCommands.js @@ -3,7 +3,7 @@ import { bindAssistantPanelLayout, bindLayoutDock } from './panelLayout.js'; import { bindChatControl } from './chatControl.js'; import { bindVoiceMiniPreview } from './voiceMiniPreview.js'; import { createVoiceControl, voiceStatusLabel } from './control.js'; -import { createVoiceSession } from './session.js'; +import { createVoiceSession, isStoppable } from './session.js'; import { VoiceCardControls } from './voiceCard.js'; import { PointerReticle } from './pointerReticle.js'; import { VOICE_PROVIDERS } from './providerCapabilities.js'; @@ -40,7 +40,7 @@ export function createVoiceCommands({ createReticle = createDefaultReticle, panelHost = null, nativePanelProvider = null, - // Chat's Settings or chat itself, carried across a provider switch. + // Chat's Settings, carried across a provider switch. presentation = null, }) { if (resetExisting) window.__gevVoiceCommands?.stop?.({ removeUi: true }); @@ -289,11 +289,7 @@ export function createVoiceCommands({ // session belongs to chat, which handles its own close. const offClose = ui.on('settings-close', () => { // A voice error the user closes is over too; stopping returns to idle. - if ( - (!session.isActive() && session.state !== 'error') || - adapter.getStartOptions?.().textOnly - ) - return; + if (!isStoppable(session) || adapter.getStartOptions?.().textOnly) return; const ticket = panelMount?.stopTicket(); session.stop(); panelMount?.closeAfterStop(ticket); From 219f3e1079cb8efb0a60068677d9cb942348d5a2 Mon Sep 17 00:00:00 2001 From: Denis Groshev Date: Wed, 4 Nov 2026 17:04:05 +0100 Subject: [PATCH 31/71] feat(cockpit): enter Cockpit with only the ridden aircraft's layer on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cockpit entry no longer needs Contacts or both flight feeds: a civilian aircraft needs Live Flights, a military one Military Flights. Contacts and every other layer can be turned on or off before or during a session, and turning off the ridden layer ends it. A Context mode change in flight still blocks entry. When the other air feed is off, Live Signals and the Contact card say FLIGHTS FEED OFF or MILITARY FEED OFF · PARTIAL PICTURE instead of presenting a partial picture as complete. Voice refuses entry with subject-layer-off rather than contacts-inactive, and its tool description says Contacts is not required. Double-clicking the followed aircraft enters Cockpit through the same gate. The Cockpit takes the scene's double-click before Cesium's stock handler, which would otherwise untrack a followed aircraft whose model is a primitive. The entry also shows under the Intel HUD readout, sharing the Contacts button's face. A test keeps the classes the Cockpit adds to the body from matching another element's rule. --- docs/CURRENT-STATE.md | 18 +- scripts/package-boundaries.json | 5 + scripts/qa-voice-routing.mjs | 29 ++- server/providers/openai/toolDescriptions.js | 2 +- src/cockpitMarkup.test.mjs | 25 ++- src/cockpitSignalFocus.test.mjs | 34 +++ src/contextModePolicy.js | 39 ++-- src/contextModePolicy.test.mjs | 41 ++-- src/firstRunExperience.test.mjs | 5 +- src/i18n/locales/ar.json | 3 + src/i18n/locales/de.json | 3 + src/i18n/locales/es.json | 3 + src/i18n/locales/fr.json | 3 + src/i18n/locales/it.json | 3 + src/i18n/locales/ja.json | 5 +- src/i18n/locales/pt.json | 5 +- src/i18n/locales/ru.json | 5 +- src/i18n/locales/uk.json | 5 +- src/i18n/locales/zh-CN.json | 5 +- src/i18n/locales/zh-Hant.json | 5 +- src/materialSymbolsSubset.test.mjs | 22 +- src/ui/cockpitBodyClasses.test.mjs | 91 ++++++++ src/ui/cockpitContext.js | 51 ++++- src/ui/cockpitContext.test.mjs | 57 +++++ src/ui/cockpitController.js | 37 +++- src/ui/cockpitCoordinator.js | 78 +++++-- src/ui/cockpitEntryGate.test.mjs | 234 ++++++++++++++++++++ src/ui/cockpitInput.js | 94 ++++++++ src/ui/cockpitPresentation.js | 18 ++ src/ui/cockpitSignals.js | 65 +++++- src/ui/cockpitTrackingController.js | 31 ++- src/ui/contextLayerChanges.js | 16 +- src/ui/contextTransactions.js | 9 +- src/ui/styles/cockpit.css | 30 +++ src/ui/views/ContextView.vue | 7 +- src/ui/views/IntelHudView.vue | 12 + src/voice/actionSchemas.test.mjs | 3 +- 37 files changed, 964 insertions(+), 134 deletions(-) create mode 100644 src/ui/cockpitBodyClasses.test.mjs create mode 100644 src/ui/cockpitEntryGate.test.mjs diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c3e15ea4d..ad0dc30e4 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4799,7 +4799,7 @@ cannot claim full acceptance. **Lifecycle and proxy.** Contacts temporarily disables Transit and restores its prior state on exit; Space Missions refuses enablement while isolating replay -data. Cockpit enters through Contacts and does not independently enable Transit. +data. Cockpit does not need Contacts and does not independently enable Transit. The proxy fetches registered URLs only, validates redirect origins, bounds responses and decoding, caches successful snapshots, and backs off after failed refreshes. A 304 confirms contact without appending history. Missing vehicles @@ -6017,12 +6017,16 @@ apply to `public/` assets or built-preview serving. - Panoptic mode shows labels on ALL items (no stride skipping). Tracked/selected items keep bounding box but suppress label (skipLabel flag). Tracked items get enlarged bounding boxes (56×44 vs default 22×14). - The 3D aircraft toggle reveals `Proximity` and `All` modes and drives both commercial and military aircraft layers. It ships ON in `Proximity`, so the button paints lit and the mode row paints open from markup; the panel is therefore ~36 px taller than before, which Cockpit's Display/Radio strip absorbs through its existing primary-only corridor solver. - The host-painted tracked-target readout sits above the post-FX layer and follows tracked aircraft/satellites or selected mapped installations using each layer's display-position contract. -- Cockpit entry is gated to the operational Contacts context bundle. The - Context chooser must be in Contacts mode, Live Flights and Military Flights must both - be enabled, and a civilian or military aircraft must be tracked. The visible - Cockpit actions and the `C` shortcut use the same gate, so ordinary standalone - flight-layer selection cannot enter a context-dependent cockpit. -- **Aircraft cockpit view:** selecting a commercial or military aircraft reveals a `COCKPIT` action (`C`). Cockpit mode temporarily releases Cesium's orbit-follow transform and drives a first-person camera from the tracked aircraft's existing smoothed display position and course. Its concave helmet-visor HUD shows callsign, UTC time, coordinates, curved roll/pitch guides, and a seven-division heading tape. Ambient commercial and military AIR contacts use a Cockpit near/far band selected by the shared Display 3D mode: Proximity admits at 150 km and retains to 185 km; All admits at 400 km and retains to 450 km. The shared 3D toggle now applies in Cockpit: Off keeps in-range contacts as rotating 2D aircraft silhouettes, while On lets a ready admitted glTF take over without a drawing gap. The Cockpit model cap remains 60 and can only lower the map budget; in-range contacts that are capped or still loading remain 2D silhouettes instead of degrading to out-of-range dots. Contacts outside the selected band use small rotation-free cyan-white pips for civilian aircraft and amber pips for military. The pilot's own airframe is not drawn in first person, and exiting clears the Cockpit band and restores normal map silhouettes/models. The normal left accordion remains available for Layers and Authoring and keeps the same 26vh HUD-aligned anchor used in map mode, independent of whether the bottom-left Contact card is expanded or collapsed; the right-rail CCTV and Context chooser are hidden to avoid duplicating or overlapping the cockpit presentation. When the intelligence HUD is enabled, its classification, scene summary, collection/orbit metadata, coordinates, and imaging-status text remain visible as reduced peripheral cockpit telemetry; the optical center and flight instruments stay clear, and turning the HUD off still hides it. Ground speed, exact heading, and rendered altitude form one compact lower-center instrument cluster, keeping the horizon and peripheral view open. A second live altitude tape hugs the inside-right visor rim: its rail and nine moving ticks are derived from the same responsive keyhole radius, remain 20 px inside the circular edge even on wide displays, fade deeply at both vertical ends, and move continuously behind a fixed current-altitude pointer; its interval tightens automatically near the surface and it is suppressed on narrow screens. Cockpit mode also has a weather-backed transparent volumetric-cloud pass derived from the supplied FBM/domain-warped R&D shader. It renders at no more than 520×320, uses 24 ray steps/three FBM octaves at 12 FPS, is clipped to the visor, fails clear when Open-Meteo is unavailable, and stops its animation completely on cockpit exit. It does not restore the prior CPU weather canvases, precipitation, scene fog, or any map-mode effect. When Contacts is active for the tracked aircraft, the compact `CONTACT` rail adds the 250 km subject window, four cohort counts, nearest observed/mapped example with relative bearing and distance, freshness, explicit uncertainty, and Previous and Next controls plus its own collapse control. The mirrored right rail is a three-page **cockpit briefing carousel**: source-backed live signals, location-matched regional headlines, and physical-region/current-weather context from bundled Natural Earth polygons and Open-Meteo. It is manual-first: Previous, Next, and direct page controls are always available, and the visible `CYCLE OFF` / `CYCLE ON` control starts or stops the nine-second page cycle. The cycle pauses on hover or keyboard focus and stops while collapsed, hidden, or outside cockpit mode; live signal data continues refreshing either way. Empty news matches and unavailable news use compact text states rather than reserving an empty media frame; partial local data remains explicit. Article links open their original publisher, and no headline is treated as verified risk intelligence. On desktop both cockpit rails use the same width and share a bottom-aligned safe baseline in opposite corners above the peripheral MGRS/GSD/time telemetry, leaving both that text and the lower-center instrument cluster readable. They collapse independently to slim tabs without stopping live data updates. Narrow screens use separated top/bottom fallbacks. Empty-space globe clicks are inert while cockpit owns the camera; `C`, `Escape`, or `EXIT COCKPIT` explicitly exits and restores the same tracked entity and standard follow camera. Unknown feeds remain unknown, selection loss still exits safely without inventing a replacement track, and the cockpit never presents the summary as threat scoring or an all-clear. This is a desktop first-person presentation, not a WebXR session. +- Cockpit entry needs only the layer of the aircraft it rides and that aircraft + tracked: Live Flights for a civilian, Military Flights for a military aircraft + (the tracked info's `layerId`; `cockpitEntryAllowed` in + `src/contextModePolicy.js`). Contacts is not required, and the other aircraft + layer, Live AIS Vessels and every other layer may be turned on or off before or + during a session. A Context mode change still in flight blocks entry until it + settles. Turning off the layer the view rides ends it. The visible Cockpit + actions, the `C` shortcut and voice use the same gate; voice entry still opens + Contacts first, as its layer-targeted entry navigates through Contacts. +- **Aircraft cockpit view:** selecting a commercial or military aircraft reveals a `COCKPIT` action (`C`) once that aircraft's own layer is on; Contacts is not required. The action shows in Contacts (`#cockpit-entry`) and under the Intel HUD's bottom-right readout (`#cockpit-entry-readout`, with the HUD on, in its Tactical and Cyber layouts), so entry needs no Contacts panel. Turning off the ridden aircraft's layer ends the view (the layer clears its tracking). When an air feed the surrounding picture uses is off, the Cockpit says so instead of blocking: Live Signals leads with `MILITARY FEED OFF · PARTIAL PICTURE` or `FLIGHTS FEED OFF · PARTIAL PICTURE`, and the Contact card shows that line in place of its inputs status, so a partial picture is never presented as complete. `control_cockpit` refuses entry with `entryBlockedReason` `contacts-starting` (a Context change in flight), `subject-layer-off` (the aircraft's layer, or with none tracked any aircraft layer, is off) or `no-tracked-aircraft`. Cockpit mode temporarily releases Cesium's orbit-follow transform and drives a first-person camera from the tracked aircraft's existing smoothed display position and course. Its concave helmet-visor HUD shows callsign, UTC time, coordinates, curved roll/pitch guides, and a seven-division heading tape. Ambient commercial and military AIR contacts use a Cockpit near/far band selected by the shared Display 3D mode: Proximity admits at 150 km and retains to 185 km; All admits at 400 km and retains to 450 km. The shared 3D toggle now applies in Cockpit: Off keeps in-range contacts as rotating 2D aircraft silhouettes, while On lets a ready admitted glTF take over without a drawing gap. The Cockpit model cap remains 60 and can only lower the map budget; in-range contacts that are capped or still loading remain 2D silhouettes instead of degrading to out-of-range dots. Contacts outside the selected band use small rotation-free cyan-white pips for civilian aircraft and amber pips for military. The pilot's own airframe is not drawn in first person, and exiting clears the Cockpit band and restores normal map silhouettes/models. The normal left accordion remains available for Layers and Authoring and keeps the same 26vh HUD-aligned anchor used in map mode, independent of whether the bottom-left Contact card is expanded or collapsed; the right-rail CCTV and Context chooser are hidden to avoid duplicating or overlapping the cockpit presentation. When the intelligence HUD is enabled, its classification, scene summary, collection/orbit metadata, coordinates, and imaging-status text remain visible as reduced peripheral cockpit telemetry; the optical center and flight instruments stay clear, and turning the HUD off still hides it. Ground speed, exact heading, and rendered altitude form one compact lower-center instrument cluster, keeping the horizon and peripheral view open. A second live altitude tape hugs the inside-right visor rim: its rail and nine moving ticks are derived from the same responsive keyhole radius, remain 20 px inside the circular edge even on wide displays, fade deeply at both vertical ends, and move continuously behind a fixed current-altitude pointer; its interval tightens automatically near the surface and it is suppressed on narrow screens. Cockpit mode also has a weather-backed transparent volumetric-cloud pass derived from the supplied FBM/domain-warped R&D shader. It renders at no more than 520×320, uses 24 ray steps/three FBM octaves at 12 FPS, is clipped to the visor, fails clear when Open-Meteo is unavailable, and stops its animation completely on cockpit exit. It does not restore the prior CPU weather canvases, precipitation, scene fog, or any map-mode effect. When Contacts is active for the tracked aircraft, the compact `CONTACT` rail adds the 250 km subject window, four cohort counts, nearest observed/mapped example with relative bearing and distance, freshness, explicit uncertainty, and Previous and Next controls plus its own collapse control. The mirrored right rail is a three-page **cockpit briefing carousel**: source-backed live signals, location-matched regional headlines, and physical-region/current-weather context from bundled Natural Earth polygons and Open-Meteo. It is manual-first: Previous, Next, and direct page controls are always available, and the visible `CYCLE OFF` / `CYCLE ON` control starts or stops the nine-second page cycle. The cycle pauses on hover or keyboard focus and stops while collapsed, hidden, or outside cockpit mode; live signal data continues refreshing either way. Empty news matches and unavailable news use compact text states rather than reserving an empty media frame; partial local data remains explicit. Article links open their original publisher, and no headline is treated as verified risk intelligence. On desktop both cockpit rails use the same width and share a bottom-aligned safe baseline in opposite corners above the peripheral MGRS/GSD/time telemetry, leaving both that text and the lower-center instrument cluster readable. They collapse independently to slim tabs without stopping live data updates. Narrow screens use separated top/bottom fallbacks. Empty-space globe clicks are inert while cockpit owns the camera; `C`, `Escape`, or `EXIT COCKPIT` explicitly exits and restores the same tracked entity and standard follow camera. Unknown feeds remain unknown, selection loss still exits safely without inventing a replacement track, and the cockpit never presents the summary as threat scoring or an all-clear. This is a desktop first-person presentation, not a WebXR session. - **Cockpit left-panel clearance:** the Cockpit Contact card and peripheral HUD participate in the adaptive left accordion's live obstacle measurements, including live viewport-height changes. Expanding Layers or Authoring keeps the active panel in the available upper-left corridor with internal scrolling; it does not cover the Contact card, lower Cockpit controls, or Cesium credit line. Outside Cockpit the hidden card does not alter the normal corridor. - **Cockpit Context scope:** the 250 km radius applies to the air/sea proximity cohorts. Installation counts come only from the currently loaded viewport and are labeled `CURRENT VIEWPORT ONLY` in the cockpit as well as the normal Context panel; neither surface presents them as a complete 250 km installation survey. - **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. diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index a774993b2..842ec9920 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -39,6 +39,9 @@ "src/ui/views/panels/CockpitBriefing.vue", "src/ui/views/panels/CockpitContext.vue", "src/ui/views/panels/CockpitRoute.vue", + "src/ui/cockpitPresentation.js", + "src/data/headingDegrees.js", + "src/data/distanceFormat.js", "src/ui/views/panels/CockpitRollingValue.vue", "src/ui/views/panels/CockpitSignalList.vue", "src/ui/views/panels/CockpitUtilityControls.vue", @@ -866,6 +869,7 @@ "src/ui/cockpitDisplayPortal.js", "src/ui/displayPanelScroll.js", "src/ui/cockpitInput.js", + "src/data/inputOwnership.js", "src/ui/cockpitInstruments.js", "src/ui/cockpitLayout.js", "src/ui/cockpitPresentation.js", @@ -992,6 +996,7 @@ "src/ui/cockpitDisplayPortal.js", "src/ui/displayPanelScroll.js", "src/ui/cockpitInput.js", + "src/data/inputOwnership.js", "src/ui/cockpitInstruments.js", "src/ui/cockpitLayout.js", "src/ui/cockpitPresentation.js", diff --git a/scripts/qa-voice-routing.mjs b/scripts/qa-voice-routing.mjs index 094119030..0b0a5213a 100644 --- a/scripts/qa-voice-routing.mjs +++ b/scripts/qa-voice-routing.mjs @@ -2321,9 +2321,11 @@ async function runBehaviorLayer() { `result=${JSON.stringify(r)?.slice(0, 120)} state=${JSON.stringify(contextExited)?.slice(0, 80)}`, ); - // (9f) With Contacts OFF the entry gate is shut. Entry must refuse with the - // reason rather than produce the half-entered state the operator saw: a - // plane anchored under the camera with no HUD and no exit control. + // (9f) Cockpit needs only the tracked aircraft's own layer, not Contacts. + // With Contacts OFF, status must explain a shut gate without blaming + // Contacts, and entry must either enter cleanly or refuse with the reason + // rather than produce the half-entered state the operator saw: a plane + // anchored under the camera with no HUD and no exit control. r = await run('control_cockpit', { action: 'status' }); const gateReport = r?.state || {}; const cockpitBeforeGate = await page.evaluate(() => @@ -2331,10 +2333,13 @@ async function runBehaviorLayer() { ); report( gateReport.active === false && - gateReport.entryAllowed === false && - gateReport.entryBlockedReason === 'contacts-inactive' && + (gateReport.entryAllowed === true + ? gateReport.entryBlockedReason === null + : ['subject-layer-off', 'no-tracked-aircraft'].includes( + gateReport.entryBlockedReason, + )) && !cockpitBeforeGate, - 'behavior: cockpit status names why entry is blocked with Contacts off', + 'behavior: cockpit status explains its gate without requiring Contacts', `state=${JSON.stringify(gateReport)?.slice(0, 150)}`, ); @@ -2348,12 +2353,16 @@ async function runBehaviorLayer() { Boolean(window.__godsEyeView?.styleManager?.cockpitView?.active), ); report( - gated?.ok === false && - /contacts/i.test(gated?.error || '') && - !cockpitAfterGate, - 'behavior: cockpit entry with Contacts off is refused, not half-entered', + gated?.ok === true + ? cockpitAfterGate + : !cockpitAfterGate && !/contacts/i.test(gated?.error || ''), + 'behavior: cockpit entry with Contacts off follows the own-layer gate, never half-entered', `ok=${gated?.ok} error=${String(gated?.error || '').slice(0, 90)} active=${cockpitAfterGate}`, ); + if (cockpitAfterGate) + await page.evaluate(() => + window.__godsEyeView?.styleManager?.controlCockpit?.('exit'), + ); await runBundledOutlineScenarios({ page, diff --git a/server/providers/openai/toolDescriptions.js b/server/providers/openai/toolDescriptions.js index 7b25d34db..891145021 100644 --- a/server/providers/openai/toolDescriptions.js +++ b/server/providers/openai/toolDescriptions.js @@ -135,7 +135,7 @@ export const ACTION_DESCRIPTIONS = { }, control_cockpit: { description: - 'Explicit Cockpit requests only: enter from a selected/tracked aircraft (establishes Contacts itself; no set_context_mode), exit, status, or next/previous. Explicit exit-and-show voice: voicePanel preview/settings (Voice Settings), no confirmation.', + 'Explicit Cockpit requests only: enter from a selected/tracked aircraft (needs only its own Flights/Military layer on, not Contacts; opens Contacts itself, no set_context_mode), exit, status, or next/previous. Explicit exit-and-show voice: voicePanel preview/settings (Voice Settings), no confirmation.', $position: 1, parameters: { properties: { diff --git a/src/cockpitMarkup.test.mjs b/src/cockpitMarkup.test.mjs index 64d03914b..aa06193ba 100644 --- a/src/cockpitMarkup.test.mjs +++ b/src/cockpitMarkup.test.mjs @@ -1306,12 +1306,12 @@ test('voice Cockpit entry honours a requested contact layer before it enters', ( ui.indexOf("if (normalized === 'exit') {"), ); assert.ok(branch, 'cockpit enter branch is missing'); - const gateIndex = branch.indexOf('isEntryAllowed?.()'); + const gateIndex = branch.indexOf('this._cockpitEntryRefusal()'); const retargetIndex = branch.indexOf('_retargetCockpitEntryLayer('); const enterIndex = branch.indexOf('enterCockpitWithTracking({'); assert.ok( gateIndex >= 0, - 'entry consults the same gate the manual chip uses', + 'entry refuses on the gate it can check before tracking', ); assert.ok( retargetIndex > gateIndex, @@ -1362,9 +1362,21 @@ test('voice Cockpit entry refuses when the entry gate is shut', () => { ui.indexOf("if (normalized === 'enter') {"), ui.indexOf("if (normalized === 'exit') {"), ); - assert.match(branch, /if \(!this\.cockpitView\.isEntryAllowed\?\.\(\)\) \{/); - assert.match(branch, /Contacts must be active to enter Cockpit/); - assert.match(branch, /Contacts is still starting up/); + // Contacts is not required: only a Context change in flight or no aircraft + // layer to ride shuts the gate before tracking, and the entry itself then + // applies the manual chip's gate to the aircraft it rides. + assert.match( + branch, + /const refusal = this\._cockpitEntryRefusal\(\);\s*if \(refusal\) \{/, + ); + assert.doesNotMatch(branch, /Contacts must be active/); + const refusal = ui.slice( + ui.indexOf(' _cockpitEntryRefusal() {'), + ui.indexOf(' getCockpitState() {'), + ); + assert.match(refusal, /Context is still changing/); + assert.match(refusal, /No aircraft layer is on/); + assert.doesNotMatch(refusal, /[Cc]ontacts/); }); test('cockpit state cannot report entryAllowed while already active', () => { @@ -1382,7 +1394,8 @@ test('cockpit state cannot report entryAllowed while already active', () => { ); assert.match(state, /entryBlockedReason:/, 'and a refusal can be explained'); assert.match(state, /'contacts-starting'/); - assert.match(state, /'contacts-inactive'/); + assert.match(state, /'subject-layer-off'/); + assert.doesNotMatch(state, /'contacts-inactive'/, 'Contacts is not required'); assert.match(state, /'no-tracked-aircraft'/); }); diff --git a/src/cockpitSignalFocus.test.mjs b/src/cockpitSignalFocus.test.mjs index a4f73c4ce..89ee1cac1 100644 --- a/src/cockpitSignalFocus.test.mjs +++ b/src/cockpitSignalFocus.test.mjs @@ -147,6 +147,40 @@ const snapshot = (distanceM = 2000) => ({ ], }); +test('a feed-off row leads the rebuilt rows and the list stays five long', async (t) => { + const f = fixture(t); + const crowded = { + ...snapshot(), + cohorts: [ + { + id: 'flights', + count: 6, + nearest: [1, 2, 3, 4, 5, 6].map((n) => ({ + id: `n${n}`, + label: `N${n}`, + distanceM: n * 1000, + })), + }, + ], + }; + updateCockpitSignals.call(f.owner, crowded, 0, { + title: 'MILITARY FEED OFF · PARTIAL PICTURE', + detail: 'NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR', + }); + await nextTick(); + assert.equal(f.owner.signalItems.length, 5); + assert.equal(f.owner.signalItems[0].key, 'feed-status'); + assert.equal(f.owner.signalItems[0].tone, 'warning'); + assert.equal(f.owner.signalItems[0].target, null); + assert.match(f.list.textContent, /MILITARY FEED OFF · PARTIAL PICTURE/); + updateCockpitSignals.call(f.owner, crowded, 0); + assert.equal( + f.owner.signalItems.some((item) => item.key === 'feed-status'), + false, + 'with the feed back, the row goes', + ); +}); + test('rows render the signals the controller presents', async (t) => { const f = fixture(t); await render(f, [ diff --git a/src/contextModePolicy.js b/src/contextModePolicy.js index 4c0b7c9cb..fd3324760 100644 --- a/src/contextModePolicy.js +++ b/src/contextModePolicy.js @@ -411,30 +411,37 @@ export function contextRestoreLayerIds(snapshot = {}) { return restored; } +/** The layers an aircraft Cockpit subject can come from. */ +export const COCKPIT_AIRCRAFT_LAYER_IDS = Object.freeze([ + 'flights', + 'military', +]); + /** - * Cockpit entry belongs to the operational Contacts context bundle. A tracked - * aircraft alone is not sufficient: both observed-flight feeds must still be - * active so the cockpit's surrounding-contact picture is not presented as a - * complete context view when one source is disabled. + * Cockpit entry needs only the aircraft it rides: that aircraft's own layer + * (Live Flights for a civilian, Military Flights for a military aircraft) must + * be on. Contacts and the other feeds are not required. When a source the + * surrounding picture uses is off, the Cockpit's Contact card and Live + * Signals say the picture is partial instead of blocking entry, so a partial + * picture is never presented as complete. A Context mode change in flight + * still owns the camera, so entry waits it out. * - * @param {object} input Current context and dependency visibility. - * @param {string|null} input.contextMode Active Context mode. - * @param {boolean} input.contextModeChanging Whether the Context transaction is unsettled. - * @param {boolean} input.flightsEnabled Whether Live Flights is enabled. - * @param {boolean} input.militaryEnabled Whether Military Flights is enabled. + * @param {object} input Current subject and Context state. + * @param {string|null} input.subjectLayerId Layer of the tracked aircraft + * ('flights' or 'military'), or null with none tracked. + * @param {boolean} input.subjectLayerEnabled Whether that layer is enabled. + * @param {boolean} input.contextModeChanging Whether a Context transaction is unsettled. * @returns {boolean} Whether cockpit entry may be offered or activated. */ export function cockpitEntryAllowed({ - contextMode, + subjectLayerId, + subjectLayerEnabled, contextModeChanging, - flightsEnabled, - militaryEnabled, }) { return ( - contextMode === 'flights' && - !contextModeChanging && - Boolean(flightsEnabled) && - Boolean(militaryEnabled) + COCKPIT_AIRCRAFT_LAYER_IDS.includes(subjectLayerId) && + Boolean(subjectLayerEnabled) && + !contextModeChanging ); } diff --git a/src/contextModePolicy.test.mjs b/src/contextModePolicy.test.mjs index 40a7b291e..0453bd9b8 100644 --- a/src/contextModePolicy.test.mjs +++ b/src/contextModePolicy.test.mjs @@ -400,37 +400,32 @@ test('explicit layer intent finishing during restore overrides the stale queued } }); -test('cockpit entry requires Contacts context with both aircraft feeds enabled', () => { +test("cockpit entry needs only the tracked aircraft's own layer", () => { assert.equal(cockpitEntryAllowed({ - contextMode: 'flights', + subjectLayerId: 'flights', + subjectLayerEnabled: true, contextModeChanging: false, - flightsEnabled: true, - militaryEnabled: true, - }), true); + }), true, 'a civilian needs Live Flights alone: no Contacts, no Military'); assert.equal(cockpitEntryAllowed({ - contextMode: 'flights', + subjectLayerId: 'military', + subjectLayerEnabled: true, + contextModeChanging: false, + }), true, 'a military aircraft needs Military Flights alone'); + assert.equal(cockpitEntryAllowed({ + subjectLayerId: 'flights', + subjectLayerEnabled: true, contextModeChanging: true, - flightsEnabled: true, - militaryEnabled: true, - }), false, 'Cockpit remains unavailable until Contacts activation settles'); + }), false, 'Cockpit remains unavailable until a Context mode change settles'); assert.equal(cockpitEntryAllowed({ - contextMode: null, + subjectLayerId: 'military', + subjectLayerEnabled: false, contextModeChanging: false, - flightsEnabled: true, - militaryEnabled: true, - }), false, 'ordinary layer selection is not an operational Contacts context'); + }), false, "the ridden aircraft's own layer is required"); assert.equal(cockpitEntryAllowed({ - contextMode: 'flights', + subjectLayerId: null, + subjectLayerEnabled: false, contextModeChanging: false, - flightsEnabled: true, - militaryEnabled: false, - }), false, 'the military contact feed is required'); - assert.equal(cockpitEntryAllowed({ - contextMode: 'flights', - contextModeChanging: false, - flightsEnabled: false, - militaryEnabled: true, - }), false, 'the civilian contact feed is required'); + }), false, 'there is no aircraft to ride'); }); test('context teardown guard restores coordination state after success and failure', async () => { diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs index 8d6317cb2..ddcdbbd53 100644 --- a/src/firstRunExperience.test.mjs +++ b/src/firstRunExperience.test.mjs @@ -1039,10 +1039,11 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping // The Utilities panel adds one set_panel_open target; Sea Marks and Ski // Pistes extend the generated layer enums and alias hint; voice geometry // adds the analyst area scopes and the osm-places layer. - assert.equal(block.length, 31318, 'serialized tool schema length drifted'); + // control_cockpit states the own-layer entry rule (Contacts not required). + assert.equal(block.length, 31372, 'serialized tool schema length drifted'); assert.equal( crypto.createHash('sha256').update(block).digest('hex'), - 'f5b42f41c2f575838f979e88a1dbff615a77ccd85fdf8fcd0a0a47d02f7d3ddc', + '7f593e78c232db972b04441f830b6a60f93916a6e5bc5a68eca7422cd1035508', 'the first-run missions must ride EXISTING tools: no schema edit, no cache bust', ); const instructions = fs.readFileSync( diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index deb68ec36..93ec661f8 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -2187,6 +2187,9 @@ "{value} KM/H": "{value} كم/س", "{label} · {radius} AIR/SEA WINDOW": "{label} · نطاق جو/بحر {radius}", "{scope} · INSTALLATIONS {coverage}": "{scope} · المنشآت {coverage}", + "MILITARY FEED OFF · PARTIAL PICTURE": "مصدر البيانات العسكرية متوقف · صورة جزئية", + "FLIGHTS FEED OFF · PARTIAL PICTURE": "مصدر الرحلات متوقف · صورة جزئية", + "NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "قائمة الطائرات القريبة غير مكتملة · لا يعني أن الوضع آمن", "SQUAWK {code} · {status}": "SQUAWK {code} · {status}", "ADS-B PRIORITY · {status}": "أولوية ADS-B · {status}", "ADS-B EMERGENCY · {status}": "طوارئ ADS-B · {status}", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 6876199d1..e4a41e7f2 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -2087,6 +2087,9 @@ "{value} KM/H": "{value} KM/H", "{label} · {radius} AIR/SEA WINDOW": "{label} · {radius} LUFT-/SEEFENSTER", "{scope} · INSTALLATIONS {coverage}": "{scope} · ANLAGEN {coverage}", + "MILITARY FEED OFF · PARTIAL PICTURE": "MILITÄR-FEED AUS · UNVOLLSTÄNDIGES LAGEBILD", + "FLIGHTS FEED OFF · PARTIAL PICTURE": "FLUG-FEED AUS · UNVOLLSTÄNDIGES LAGEBILD", + "NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "FLUGZEUGE IN DER NÄHE UNVOLLSTÄNDIG · KEINE ENTWARNUNG", "SQUAWK {code} · {status}": "SQUAWK {code} · {status}", "ADS-B PRIORITY · {status}": "ADS-B-PRIORITÄT · {status}", "ADS-B EMERGENCY · {status}": "ADS-B-NOTFALL · {status}", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index df11469ab..1066d8aea 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -1920,6 +1920,9 @@ "N/A": "N/D", "{label} · {radius} AIR/SEA WINDOW": "{label} · VENTANA AIRE/MAR {radius}", "{scope} · INSTALLATIONS {coverage}": "{scope} · INSTALACIONES: {coverage}", + "MILITARY FEED OFF · PARTIAL PICTURE": "FUENTE MILITAR DESACTIVADA · PANORAMA PARCIAL", + "FLIGHTS FEED OFF · PARTIAL PICTURE": "FUENTE DE VUELOS DESACTIVADA · PANORAMA PARCIAL", + "NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "AERONAVES CERCANAS INCOMPLETAS · NO IMPLICA TODO DESPEJADO", "{source} · LOCATION QUERY · RECENT": "{source} · CONSULTA POR UBICACIÓN · RECIENTE", "ft": "ft", "kts": "kn", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 69d19c9f7..ab3032a3f 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1660,6 +1660,9 @@ "REGIONAL NEWS": "ACTUS RÉGIONALES", "{label} · {radius} AIR/SEA WINDOW": "{label} · AIR/MER {radius}", "{scope} · INSTALLATIONS {coverage}": "{scope} · INSTALLATIONS : {coverage}", + "MILITARY FEED OFF · PARTIAL PICTURE": "FLUX MILITAIRE COUPÉ · SITUATION PARTIELLE", + "FLIGHTS FEED OFF · PARTIAL PICTURE": "FLUX DES VOLS COUPÉ · SITUATION PARTIELLE", + "NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "AÉRONEFS PROCHES INCOMPLETS · PAS UN FEU VERT", "CONTEXT STANDBY": "CONTEXTE EN VEILLE", "ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "ACTIVER LE CONTEXTE GLOBAL POUR LES PINGS DE PROXIMITÉ", "CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "CONTACT PERDU · DERNIER RELEVÉ CONNU · PAS UN FEU VERT", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index f33721cbb..cd5c23cef 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -1660,6 +1660,9 @@ "REGIONAL NEWS": "NOTIZIE REGIONALI", "{label} · {radius} AIR/SEA WINDOW": "{label} · ARIA/MARE {radius}", "{scope} · INSTALLATIONS {coverage}": "{scope} · SITI {coverage}", + "MILITARY FEED OFF · PARTIAL PICTURE": "FEED MILITARE DISATTIVATO · QUADRO PARZIALE", + "FLIGHTS FEED OFF · PARTIAL PICTURE": "FEED VOLI DISATTIVATO · QUADRO PARZIALE", + "NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "AEREI VICINI INCOMPLETI · NON È UN VIA LIBERA", "CONTEXT STANDBY": "CONTESTO IN ATTESA", "ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "ATTIVA IL CONTESTO GLOBALE PER I PING DI PROSSIMITÀ", "CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "CONTATTO PERSO · ULTIMA LETTURA NOTA · NON È UN VIA LIBERA", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index e47708830..84499fd8a 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1612,6 +1612,9 @@ "REGIONAL NEWS": "地域ニュース", "{label} · {radius} AIR/SEA WINDOW": "{label} · 航空機/船舶の範囲 {radius}", "{scope} · INSTALLATIONS {coverage}": "{scope} · 施設 {coverage}", + "MILITARY FEED OFF · PARTIAL PICTURE": "軍用フィードオフ · 部分的な状況", + "FLIGHTS FEED OFF · PARTIAL PICTURE": "フライトフィードオフ · 部分的な状況", + "NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "付近の航空機情報は不完全 · 安全の保証ではありません", "CONTEXT STANDBY": "コンテキスト待機", "ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "グローバルコンテキストを有効にすると近接ピングを表示", "CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "コンタクト喪失 · 最終既知の値 · 安全の保証ではありません", @@ -1684,7 +1687,7 @@ "Restore real aircraft": "実際の航空機に戻す", "Reclassify as TR-3B": "TR-3Bに再分類", "TRACK ACQUIRED": "追跡開始", - "{label} · COURSE {heading}°": "{label} · 針路 {heading}°", + "{label} · COURSE {heading}°": "{label} · 対地針路 {heading}°", "Context could not restore every layer; try again": "コンテキストがすべてのレイヤーを復元できませんでした。もう一度お試しください", "No selected data layers": "選択中のデータレイヤーはありません", "{count} data layers could not be cleared": { diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 23c44f786..56c56f36f 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -1660,6 +1660,9 @@ "REGIONAL NEWS": "NOTÍCIAS DA ÁREA", "{label} · {radius} AIR/SEA WINDOW": "{label} · AR/MAR {radius}", "{scope} · INSTALLATIONS {coverage}": "{scope} · INSTALAÇÕES: {coverage}", + "MILITARY FEED OFF · PARTIAL PICTURE": "FEED MILITAR DESLIGADO · QUADRO PARCIAL", + "FLIGHTS FEED OFF · PARTIAL PICTURE": "FEED DE VOOS DESLIGADO · QUADRO PARCIAL", + "NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "AERONAVES PRÓXIMAS INCOMPLETAS · NÃO GARANTE SEGURANÇA", "CONTEXT STANDBY": "CONTEXTO EM ESPERA", "ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "ATIVE O CONTEXTO GLOBAL PARA PINGS DE PROXIMIDADE", "CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "CONTATO PERDIDO · ÚLTIMA LEITURA CONHECIDA · NÃO GARANTE SEGURANÇA", @@ -1736,7 +1739,7 @@ "Restore real aircraft": "Restaurar a aeronave real", "Reclassify as TR-3B": "Reclassificar como TR-3B", "TRACK ACQUIRED": "RASTRO ADQUIRIDO", - "{label} · COURSE {heading}°": "{label} · CURSO {heading}°", + "{label} · COURSE {heading}°": "{label} · RUMO {heading}°", "Context could not restore every layer; try again": "O contexto não conseguiu restaurar todas as camadas; tente novamente", "No selected data layers": "Nenhuma camada de dados selecionada", "{count} data layers could not be cleared": { diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index b364d3bf1..764260167 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -458,7 +458,7 @@ "SOURCE STATUS UNAVAILABLE": "СТАТУС ИСТОЧНИКА НЕДОСТУПЕН", "Select flight {title}": "Выбрать рейс {title}", "TRACK ACQUIRED": "ЦЕЛЬ ЗАХВАЧЕНА", - "{label} · COURSE {heading}°": "{label} · КУРС {heading}°", + "{label} · COURSE {heading}°": "{label} · ПУТЕВОЙ УГОЛ {heading}°", "Restore real aircraft": "Вернуть реальный самолёт", "Reclassify as TR-3B": "Переклассифицировать как TR-3B", "No selected data layers": "Нет выбранных слоёв данных", @@ -1953,6 +1953,9 @@ "N/A": "Н/Д", "{label} · {radius} AIR/SEA WINDOW": "{label} · ОКНО ВОЗДУХ/МОРЕ {radius}", "{scope} · INSTALLATIONS {coverage}": "{scope} · ОБЪЕКТЫ: {coverage}", + "MILITARY FEED OFF · PARTIAL PICTURE": "ВОЕННЫЙ ПОТОК ВЫКЛ · КАРТИНА НЕПОЛНАЯ", + "FLIGHTS FEED OFF · PARTIAL PICTURE": "ПОТОК РЕЙСОВ ВЫКЛ · КАРТИНА НЕПОЛНАЯ", + "NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "БЛИЖНИЕ ВОЗДУШНЫЕ СУДА: ДАННЫЕ НЕПОЛНЫЕ · ОТСУТСТВИЕ УГРОЗ НЕ ПОДТВЕРЖДЕНО", "{source} · LOCATION QUERY · RECENT": "{source} · ЗАПРОС ПО МЕСТУ · НЕДАВНИЕ", "ft": "фт", "kts": "уз", diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index d01a6af13..abf5a81a1 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -1683,6 +1683,9 @@ "REGIONAL NEWS": "РЕГІОНАЛЬНІ НОВИНИ", "{label} · {radius} AIR/SEA WINDOW": "{label} · РЕЙСИ / СУДНА · РАДІУС {radius}", "{scope} · INSTALLATIONS {coverage}": "{scope} · ОБʼЄКТИ: {coverage}", + "MILITARY FEED OFF · PARTIAL PICTURE": "ВІЙСЬКОВИЙ ПОТІК ВИМК. · КАРТИНА НЕПОВНА", + "FLIGHTS FEED OFF · PARTIAL PICTURE": "ПОТІК РЕЙСІВ ВИМК. · КАРТИНА НЕПОВНА", + "NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "БЛИЗЬКІ ПОВІТРЯНІ СУДНА: ДАНІ НЕПОВНІ · НЕ ОЗНАЧАЄ «УСЕ ЧИСТО»", "CONTEXT STANDBY": "КОНТЕКСТ: ОЧІКУВАННЯ", "ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "УВІМКНІТЬ ГЛОБАЛЬНИЙ КОНТЕКСТ, ЩОБ БАЧИТИ КОНТАКТИ ПОБЛИЗУ", "CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "КОНТАКТ ВТРАЧЕНО · ОСТАННІ ВІДОМІ ПОКАЗНИКИ · НЕ ОЗНАЧАЄ «УСЕ ЧИСТО»", @@ -1761,7 +1764,7 @@ "Restore real aircraft": "Повернути справжній літак", "Reclassify as TR-3B": "Перекласифікувати як TR-3B", "TRACK ACQUIRED": "ТРЕК ЗАХОПЛЕНО", - "{label} · COURSE {heading}°": "{label} · КУРС {heading}°", + "{label} · COURSE {heading}°": "{label} · ШЛЯХОВИЙ КУТ {heading}°", "Context could not restore every layer; try again": "Не вдалося відновити всі шари контексту; спробуйте ще раз", "No selected data layers": "Шари даних не вибрано", "{count} data layers could not be cleared": { diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 07b1083ab..4259d04a0 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1588,6 +1588,9 @@ "REGIONAL NEWS": "地区新闻", "{label} · {radius} AIR/SEA WINDOW": "{label} · {radius} 空/海监视窗口", "{scope} · INSTALLATIONS {coverage}": "{scope} · 设施 {coverage}", + "MILITARY FEED OFF · PARTIAL PICTURE": "军用数据源已关闭 · 态势不完整", + "FLIGHTS FEED OFF · PARTIAL PICTURE": "航班数据源已关闭 · 态势不完整", + "NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "附近航空器信息不完整 · 不代表安全无虞", "CONTEXT STANDBY": "态势待机", "ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "启用全局态势以接收邻近目标提醒", "CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "目标丢失 · 最后已知读数 · 不代表安全无虞", @@ -1660,7 +1663,7 @@ "Restore real aircraft": "恢复真实飞机", "Reclassify as TR-3B": "重新归类为 TR-3B", "TRACK ACQUIRED": "已获取航迹", - "{label} · COURSE {heading}°": "{label} · 航向 {heading}°", + "{label} · COURSE {heading}°": "{label} · 对地航向 {heading}°", "Context could not restore every layer; try again": "态势未能恢复所有图层,请重试", "No selected data layers": "没有已选数据图层", "{count} data layers could not be cleared": { diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index c13b7d39c..805255ca4 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -1578,6 +1578,9 @@ "REGIONAL NEWS": "地區新聞", "{label} · {radius} AIR/SEA WINDOW": "{label} · {radius} 空/海監視窗口", "{scope} · INSTALLATIONS {coverage}": "{scope} · 設施 {coverage}", + "MILITARY FEED OFF · PARTIAL PICTURE": "軍用數據源已關閉 · 態勢不完整", + "FLIGHTS FEED OFF · PARTIAL PICTURE": "航班數據源已關閉 · 態勢不完整", + "NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "附近航空器資訊不完整 · 不代表安全無虞", "CONTEXT STANDBY": "態勢待機", "ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "啟用全局態勢以接收鄰近目標提醒", "CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "目標丟失 · 最後已知讀數 · 不代表安全無虞", @@ -1650,7 +1653,7 @@ "Restore real aircraft": "恢復真實飛機", "Reclassify as TR-3B": "重新歸類為 TR-3B", "TRACK ACQUIRED": "已獲取航跡", - "{label} · COURSE {heading}°": "{label} · 航向 {heading}°", + "{label} · COURSE {heading}°": "{label} · 對地航向 {heading}°", "Context could not restore every layer; try again": "態勢未能恢復所有圖層,請重試", "No selected data layers": "沒有已選數據圖層", "{count} data layers could not be cleared": { diff --git a/src/materialSymbolsSubset.test.mjs b/src/materialSymbolsSubset.test.mjs index fdba1c49f..ab77d7c0c 100644 --- a/src/materialSymbolsSubset.test.mjs +++ b/src/materialSymbolsSubset.test.mjs @@ -11,6 +11,9 @@ const INDEX_HTML = path.join(REPO_ROOT, 'index.html'); /** The glyph written as element text: `radar`. */ const SPAN_TEXT = /class="[^"]*material-symbols-outlined[^"]*"[^>]*>\s*([a-z0-9_]+)\s*{{ a ? 'x' : 'y' }}`. */ +const SPAN_MUSTACHE = + /class="[^"]*material-symbols-outlined[^"]*"[^>]*>\s*\{\{([^}]{0,400})\}\}\s*\\s*\\{([\\s\\S]{0,600}?)\\n\\}\\);`, + ).exec(source)?.[1]; + for (const literal of body?.matchAll( + /\breturn\s+['"`]([a-z0-9_]{2,})['"`]/g, + ) ?? []) + add(literal[1]); + } + const expressions = [ + ...source.matchAll(TEXT_ASSIGNMENT), + ...source.matchAll(SPAN_MUSTACHE), + ]; + for (const match of expressions) { const statement = match[1].replaceAll('?.', '.'); const assigned = statement.includes('?') ? statement.slice(statement.indexOf('?') + 1) diff --git a/src/ui/cockpitBodyClasses.test.mjs b/src/ui/cockpitBodyClasses.test.mjs new file mode 100644 index 000000000..adf897ce8 --- /dev/null +++ b/src/ui/cockpitBodyClasses.test.mjs @@ -0,0 +1,91 @@ +// src/ui/cockpitBodyClasses.test.mjs — the classes the Cockpit puts on +// must not also name an element's rule. A bare `.name { … }` written +// for a panel matches the moment carries `name`, and its +// pointer-events, position and overflow then apply to the whole page. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, readdirSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const UI_ROOT = fileURLToPath(new URL('.', import.meta.url)); +const SRC_ROOT = path.resolve(UI_ROOT, '..'); +const CONTROLLER = path.join(UI_ROOT, 'cockpitTrackingController.js'); + +/** Every stylesheet the app ships. */ +function stylesheets(directory = SRC_ROOT) { + const files = []; + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const absolute = path.join(directory, entry.name); + if (entry.isDirectory()) files.push(...stylesheets(absolute)); + else if (entry.isFile() && entry.name.endsWith('.css')) + files.push(absolute); + } + return files; +} + +/** A selector list split on its top-level commas, not those in `:not(a, b)`. */ +function splitSelectorList(list) { + const parts = []; + let depth = 0; + let start = 0; + for (let i = 0; i < list.length; i += 1) { + if (list[i] === '(') depth += 1; + else if (list[i] === ')') depth -= 1; + else if (list[i] === ',' && depth === 0) { + parts.push(list.slice(start, i)); + start = i + 1; + } + } + parts.push(list.slice(start)); + return parts; +} + +/** The classes the tracking controller adds to . */ +function cockpitBodyClasses() { + const source = readFileSync(CONTROLLER, 'utf8'); + const names = [ + ...source.matchAll(/body\.classList\.add\(\s*'([a-z0-9-]+)'/g), + ].map((match) => match[1]); + return [...new Set(names)]; +} + +test('the Cockpit adds its subject classes to ', () => { + const classes = cockpitBodyClasses(); + for (const name of ['cockpit-mode']) { + assert.ok(classes.includes(name), `${name} is not added to `); + } +}); + +test('no stylesheet rule matches a Cockpit body class on another element', () => { + const offenders = []; + for (const name of cockpitBodyClasses()) { + // `.name` as a whole class token, not as `.name-suffix`. + const token = new RegExp(`\\.${name}(?![\\w-])`); + for (const file of stylesheets()) { + const css = readFileSync(file, 'utf8').replace(/\/\*[\s\S]*?\*\//g, ''); + for (const [, selectors] of css.matchAll(/([^{}]+)\{/g)) { + for (const selector of splitSelectorList(selectors)) { + // Prettier wraps long `:not(\n .name\n)` arguments; unwrap them. + const compound = selector + .trim() + .replace(/\(\s+/g, '(') + .replace(/\s+\)/g, ')'); + if (!token.test(compound)) continue; + // Allowed only on itself: `body.name` or `body:not(.name)`. + const onBody = new RegExp( + `(^|[\\s>+~])body[^\\s>+~]*\\.${name}(?![\\w-])`, + ); + if (onBody.test(compound)) continue; + offenders.push(`${path.relative(SRC_ROOT, file)}: ${compound}`); + } + } + } + } + assert.deepEqual( + offenders, + [], + 'Selectors that match a Cockpit body class without `body.`: ' + + offenders.join('; '), + ); +}); diff --git a/src/ui/cockpitContext.js b/src/ui/cockpitContext.js index 1158c632b..a343bbb91 100644 --- a/src/ui/cockpitContext.js +++ b/src/ui/cockpitContext.js @@ -24,6 +24,27 @@ const LOCALIZED_CONTEXT_SCOPE = Object.freeze({ t('{scope} · INSTALLATIONS {coverage}', { scope, coverage }), }); +/** + * Which air feed the aircraft Cockpit's surrounding picture is missing. Entry + * needs only the ridden aircraft's own layer, so the other one may be off; + * the panels then say the picture is partial rather than present it as + * complete. Null with both feeds on, or when the owner cannot tell. + * @returns {?{title: string, detail: string}} + */ +export function readPartialPicture() { + const isLayerEnabled = this.services.isLayerEnabled; + if (typeof isLayerEnabled !== 'function') return null; + const militaryOff = !isLayerEnabled('military'); + const flightsOff = !isLayerEnabled('flights'); + if (!militaryOff && !flightsOff) return null; + return { + title: militaryOff + ? t('MILITARY FEED OFF · PARTIAL PICTURE') + : t('FLIGHTS FEED OFF · PARTIAL PICTURE'), + detail: t('NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR'), + }; +} + /** Hand a fresh copy of the Contact card to the view. */ export function publishContextView() { const view = this.contextView; @@ -52,6 +73,7 @@ export function updateContext(info, heading) { this.services.militaryAwarenessLayer.getContextSnapshot?.() || null; const trackedId = info.icao24 || info.id; const readout = resolveCockpitContextReadout({ snapshot, info }); + const partial = readPartialPicture.call(this); if (!readout.visible) { this.showContextCard(false); this.contextLayoutStamp = null; @@ -61,6 +83,14 @@ export function updateContext(info, heading) { t('CONTEXT STANDBY'), t('ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS'), ); + if (partial) + this.pushCockpitSignal( + 'feed-status', + 'warning', + partial.title, + partial.detail, + ); + else this.dropCockpitSignal?.('feed-status'); return; } @@ -171,19 +201,22 @@ export function updateContext(info, heading) { angle: `${String(Math.round(Math.abs(relative))).padStart(3, '0')}°`, }); } - view.uncertainty = unknownCount - ? tn( - unknownCount, - '{count} INPUT UNKNOWN · NOT AN ALL-CLEAR', - '{count} INPUTS UNKNOWN · NOT AN ALL-CLEAR', - ) - : t('AVAILABLE INPUTS CURRENT · NOT AN ALL-CLEAR'); + // A feed that is off outranks unknown inputs: the picture is partial. + view.uncertainty = partial + ? partial.title + : unknownCount + ? tn( + unknownCount, + '{count} INPUT UNKNOWN · NOT AN ALL-CLEAR', + '{count} INPUTS UNKNOWN · NOT AN ALL-CLEAR', + ) + : t('AVAILABLE INPUTS CURRENT · NOT AN ALL-CLEAR'); view.updated = Number.isFinite(snapshot.evaluatedAt) ? new Date(snapshot.evaluatedAt).toISOString().slice(11, 19) + 'Z' : '--:--:--Z'; - view.state = unknownCount ? 'uncertain' : 'current'; + view.state = unknownCount || partial ? 'uncertain' : 'current'; this.publishContextView(); - this.updateCockpitSignals(snapshot, unknownCount); + this.updateCockpitSignals(snapshot, unknownCount, partial); if (this.contextLayoutStamp !== snapshot.evaluatedAt) { this.contextLayoutStamp = snapshot.evaluatedAt; this.scheduleContextLayout(); diff --git a/src/ui/cockpitContext.test.mjs b/src/ui/cockpitContext.test.mjs index 47d3719c7..4896d238e 100644 --- a/src/ui/cockpitContext.test.mjs +++ b/src/ui/cockpitContext.test.mjs @@ -204,6 +204,63 @@ test('without a snapshot the card stands by, hidden', async (t) => { assert.equal(signals.at(-1)[2], 'CONTEXT STANDBY'); }); +test('a feed that is off makes the card and Live Signals say the picture is partial', async (t) => { + // Every input known, so only the feed that is off can make it partial. + const { $, text, owner } = fixture( + t, + snapshot({ + cohorts: [ + { + id: 'flights', + label: 'Flights', + count: 12, + relationship: 'OBSERVED', + nearest: [{ id: 'N1', distanceM: 4200 }], + }, + ], + }), + ); + const rebuilt = []; + owner.updateCockpitSignals = (...args) => rebuilt.push(args); + owner.services.isLayerEnabled = () => true; + updateContext.call(owner, INFO, 0); + await nextTick(); + assert.equal($('#cockpit-context').dataset.state, 'current'); + assert.equal(rebuilt.at(-1)[2], null, 'both feeds on: nothing to add'); + + owner.services.isLayerEnabled = (layerId) => layerId !== 'military'; + updateContext.call(owner, INFO, 0); + await nextTick(); + assert.equal($('#cockpit-context').dataset.state, 'uncertain'); + assert.equal( + text('#cockpit-context-uncertainty'), + 'MILITARY FEED OFF · PARTIAL PICTURE', + ); + assert.deepEqual(rebuilt.at(-1)[2], { + title: 'MILITARY FEED OFF · PARTIAL PICTURE', + detail: 'NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR', + }); +}); + +test('without Contacts, a feed that is off adds a signal until it is back', async (t) => { + const { owner, signals } = fixture(t, null); + const dropped = []; + owner.dropCockpitSignal = (key) => dropped.push(key); + // Riding a military aircraft with Live Flights off. + owner.services.isLayerEnabled = (layerId) => layerId === 'military'; + updateContext.call(owner, { ...INFO, layerId: 'military' }, 0); + assert.equal(signals.at(-2)[2], 'CONTEXT STANDBY'); + assert.deepEqual(signals.at(-1), [ + 'feed-status', + 'warning', + 'FLIGHTS FEED OFF · PARTIAL PICTURE', + 'NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR', + ]); + owner.services.isLayerEnabled = () => true; + updateContext.call(owner, { ...INFO, layerId: 'military' }, 0); + assert.deepEqual(dropped, ['feed-status']); +}); + test('the weather toggle renders its state and the action it offers', async (t) => { const { $, text, owner } = fixture(t); syncWeatherToggle.call(owner, true); diff --git a/src/ui/cockpitController.js b/src/ui/cockpitController.js index 50a397fc8..1e69146e3 100644 --- a/src/ui/cockpitController.js +++ b/src/ui/cockpitController.js @@ -1,6 +1,8 @@ /** Coordinate Cockpit lifecycle through supplied aircraft, terrain, rendering and briefing operations. */ import { readAircraftInfo, + readSubjectLayerId, + resolveEntryKind, dispatchCockpitModeChanged, toggleTrackedTr3b, syncTr3bToggle, @@ -48,6 +50,7 @@ import { relocalizeBrief, } from './cockpitBriefing.js'; import { + dropCockpitSignal, handleSignalClick, publishCockpitSignals, pushCockpitSignal, @@ -60,7 +63,11 @@ import { syncContextLayout, syncSignalLayout, } from './cockpitLayout.js'; -import { onKeyDown } from './cockpitInput.js'; +import { + onKeyDown, + onSceneDoubleClick, + routeSceneDoubleClick, +} from './cockpitInput.js'; import * as Cesium from 'cesium'; export class CockpitViewController { @@ -205,6 +212,13 @@ export class CockpitViewController { }), ); this._listen(this.entry, 'click', () => this.enter()); + // Double-clicking the followed track is a third way in. + this._listenerRemovers.push(routeSceneDoubleClick.call(this)); + // The readout's entry renders inside the Intel HUD, which may mount after + // this controller, so its clicks are delegated. + this._listen(document, 'click', (event) => { + if (event.target?.closest?.('#cockpit-entry-readout')) this.enter(); + }); this._listen(this.tr3bToggle, 'click', () => this.toggleTrackedTr3b()); this._listen(this.mapViewButton, 'click', () => this.exit()); this._listen(this.visionPrevious, 'click', () => this.cycleVisionMode(-1)); @@ -272,6 +286,12 @@ export class CockpitViewController { readAircraftInfo() { return readAircraftInfo.call(this); } + readSubjectLayerId() { + return readSubjectLayerId.call(this); + } + resolveEntryKind() { + return resolveEntryKind.call(this); + } dispatchCockpitModeChanged(active, info) { return dispatchCockpitModeChanged.call(this, active, info); } @@ -371,8 +391,16 @@ export class CockpitViewController { pushCockpitSignal(key, tone, title, detail, target) { return pushCockpitSignal.call(this, key, tone, title, detail, target); } - updateCockpitSignals(snapshot, unknownCount) { - return updateCockpitSignals.call(this, snapshot, unknownCount); + dropCockpitSignal(key) { + return dropCockpitSignal.call(this, key); + } + updateCockpitSignals(snapshot, unknownCount, partialPicture) { + return updateCockpitSignals.call( + this, + snapshot, + unknownCount, + partialPicture, + ); } scheduleContextLayout() { return scheduleContextLayout.call(this); @@ -392,6 +420,9 @@ export class CockpitViewController { onKeyDown(event) { return onKeyDown.call(this, event); } + onSceneDoubleClick(position) { + return onSceneDoubleClick.call(this, position); + } handleSignalClick(event) { return handleSignalClick.call(this, event); diff --git a/src/ui/cockpitCoordinator.js b/src/ui/cockpitCoordinator.js index b71889b4c..7140f3172 100644 --- a/src/ui/cockpitCoordinator.js +++ b/src/ui/cockpitCoordinator.js @@ -1,6 +1,9 @@ import { nextTick } from 'vue'; import { CockpitViewController, CockpitDisplayPortal } from './cockpit.js'; -import { cockpitEntryAllowed } from '../contextModePolicy.js'; +import { + COCKPIT_AIRCRAFT_LAYER_IDS, + cockpitEntryAllowed, +} from '../contextModePolicy.js'; import { formatAwarenessLabel } from '../data/militaryAwarenessEngine.js'; import { enterCockpitWithTracking } from '../cockpitTracking.js'; import { cockpitVisionModeForStyle } from '../cockpitVisionPolicy.js'; @@ -72,19 +75,24 @@ export class CockpitCoordinator { fetchRegionalBrief, regionalDistanceM, weatherCodeLabel, + // The Contact card and Live Signals say which feeds are off. + isLayerEnabled: (layerId) => this._isLayerEnabled(layerId), }, onVisionChange: (mode, active, options) => this._setCockpitVision(mode, active, options), getInitialVisionMode: () => cockpitVisionModeForStyle(this.activeStyle), onCameraTakeover: () => this._stampNavigation({ cancelPendingSelection: false }), - isEntryAllowed: () => - cockpitEntryAllowed({ - contextMode: this._contextMode, + // Only the tracked aircraft's own layer gates entry (see the policy). + isEntryAllowed: () => { + const subjectLayerId = + this.cockpitView?.readAircraftInfo?.()?.layerId ?? null; + return cockpitEntryAllowed({ + subjectLayerId, + subjectLayerEnabled: this._isLayerEnabled(subjectLayerId), contextModeChanging: this._contextModeChanging, - flightsEnabled: !!this._dataManager?.isEnabled('flights'), - militaryEnabled: !!this._dataManager?.isEnabled('military'), - }), + }); + }, publish: this.publishCockpit, isUtilityDisclosureOpen: () => !!this.readUtilityDisclosureOpen?.(), onEntered: () => this.enterPanels(), @@ -115,6 +123,28 @@ export class CockpitCoordinator { get activeStyle() { return this.readActiveStyle(); } + /** Whether a data layer is enabled; false for no layer. */ + _isLayerEnabled(layerId) { + return Boolean(layerId) && !!this._dataManager?.isEnabled(layerId); + } + /** Whether either aircraft layer is on, so there is an aircraft to ride. */ + get _aircraftLayerEnabled() { + return COCKPIT_AIRCRAFT_LAYER_IDS.some((layerId) => + this._isLayerEnabled(layerId), + ); + } + /** + * Why voice entry must refuse before it touches tracking, or null. The + * tracked aircraft's own layer is checked again by the entry itself. + * @returns {string|null} + */ + _cockpitEntryRefusal() { + if (this._contextModeChanging) + return 'Context is still changing — try Cockpit again in a moment'; + if (!this._aircraftLayerEnabled) + return 'No aircraft layer is on — turn on Flights or Military Flights and track an aircraft to enter Cockpit'; + return null; + } getCockpitState() { const { militaryAwarenessLayer } = this.services; const snapshot = militaryAwarenessLayer.getContextSnapshot?.(); @@ -129,19 +159,24 @@ export class CockpitCoordinator { const entryAllowed = !active && Boolean(gateOpen && info && this.viewer?.trackedEntity?.position); + // Entry needs only the ridden aircraft's own layer: with one tracked, + // that layer; with none, any aircraft layer to track one from. + const subjectLayerOff = info + ? !this._isLayerEnabled(info.layerId) + : !this._aircraftLayerEnabled; return { active, entryAllowed, // Why entry is unavailable, so a refusal can be explained rather than - // guessed at. + // guessed at. Contacts is not required, so it is never the reason. entryBlockedReason: entryAllowed || active ? null - : !gateOpen - ? this._contextModeChanging - ? 'contacts-starting' - : 'contacts-inactive' - : 'no-tracked-aircraft', + : this._contextModeChanging + ? 'contacts-starting' + : subjectLayerOff + ? 'subject-layer-off' + : 'no-tracked-aircraft', visionMode: this.cockpitView?.visionMode || null, subject: info ? { @@ -260,17 +295,18 @@ export class CockpitCoordinator { }; } if (normalized === 'enter') { - // Entry is gated exactly as the manual entry chip is. Attempting it while - // the gate is shut produced the half-entered look the operator reported - // (a plane anchored under the camera with no cockpit around it), so - // refuse with the reason instead of trying. - if (!this.cockpitView.isEntryAllowed?.()) { + // Attempting entry while the gate is shut produced the half-entered look + // the operator reported (a plane anchored under the camera with no + // cockpit around it), so refuse with the reason instead of trying. The + // aircraft may not be tracked yet (a selection or a requested layer is + // adopted below), so this checks what can be known now; the entry + // itself then applies the manual chip's gate to the aircraft it rides. + const refusal = this._cockpitEntryRefusal(); + if (refusal) { return { ok: false, action: 'control_cockpit', - error: this._contextModeChanging - ? 'Contacts is still starting up — try Cockpit again in a moment' - : 'Contacts must be active to enter Cockpit — say "open contacts" first', + error: refusal, state: this.getCockpitState(), }; } diff --git a/src/ui/cockpitEntryGate.test.mjs b/src/ui/cockpitEntryGate.test.mjs new file mode 100644 index 000000000..5931ca5b2 --- /dev/null +++ b/src/ui/cockpitEntryGate.test.mjs @@ -0,0 +1,234 @@ +// src/ui/cockpitEntryGate.test.mjs — first person needs only the layer of +// the vehicle it rides: Contacts and the other feeds may be off before or +// during a session, turning the ridden layer off ends it, and the voice gate +// and state say so without claiming Contacts is required. Over happy-dom. +import '../testSupport/registerDom.mjs'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createPinia } from 'pinia'; +import { createApp, defineComponent, h, nextTick } from 'vue'; +import { CockpitCoordinator } from './cockpitCoordinator.js'; +import { _handleContextLayerChange } from './contextLayerChanges.js'; +import { useCockpitStore } from './stores/cockpit.js'; +import { useHudStore } from './stores/hud.js'; +import ContextView from './views/ContextView.vue'; +import IntelHudView from './views/IntelHudView.vue'; + +const CIVIL = Object.freeze({ + icao24: 'abc123', + callsign: 'UAL123', + track: 92, + latitude: 30, + longitude: -97, +}); +const MILITARY = Object.freeze({ + ...CIVIL, + icao24: 'ae0001', + callsign: 'RCH1', +}); + +function fixture( + t, + { + enabled = [], + tracked = null, + contextMode = null, + changing = false, + } = {}, +) { + document.body.replaceChildren(); + const host = document.body.appendChild(document.createElement('div')); + const pinia = createPinia(); + const app = createApp( + defineComponent(() => () => [h(ContextView), h(IntelHudView)]), + ).use(pinia); + app.mount(host); + const store = useCockpitStore(pinia); + const layers = new Set(enabled); + const dataManager = { isEnabled: (layerId) => layers.has(layerId) }; + // A layer that is off has no tracked aircraft, as the flight layers clear + // tracking on disable. + const trackedFrom = (layerId) => () => + tracked?.layerId === layerId && layers.has(layerId) ? tracked.info : null; + const entity = { + position: { getValue: () => null }, + show: true, + gevTrackedId: tracked ? `${tracked.layerId}:${tracked.info.icao24}` : '', + }; + const listeners = () => ({ addEventListener: () => () => {} }); + const viewer = { + trackedEntity: tracked ? entity : undefined, + clock: { currentTime: null }, + camera: { cancelFlight() {}, frustum: { fov: 1, aspectRatio: 1 } }, + entities: { contains: (candidate) => candidate === entity }, + scene: { + preUpdate: listeners(), + screenSpaceCameraController: { enableInputs: true }, + }, + trackedEntityChanged: listeners(), + }; + const context = { mode: contextMode, changing }; + const coordinator = new CockpitCoordinator({ + viewer, + services: { + flightsLayer: { getTrackedInfo: trackedFrom('flights') }, + militaryFlightsLayer: { getTrackedInfo: trackedFrom('military') }, + militaryAwarenessLayer: { getContextSnapshot: () => null }, + isTr3b: () => false, + holdContinuousRender() {}, + releaseContinuousRender() {}, + }, + elements: {}, + operations: { + publishCockpit: { presence: (view) => store.publishPresence(view) }, + _stampNavigation() {}, + getAircraftTrackingTarget: () => + tracked ? { layerId: tracked.layerId, id: tracked.info.icao24 } : null, + }, + readDataManager: () => dataManager, + readContext: () => context, + readActiveStyle: () => 'normal', + readUtilityDisclosureOpen: () => false, + enterPanels() {}, + exitPanels() {}, + }); + const cockpit = coordinator.cockpitView; + // The HUD, briefing and signals have their own tests. + Object.assign(cockpit, { + updateHud() {}, + setVisionMode() {}, + showBriefPage() {}, + startBriefRotation() {}, + stopBriefRotation() {}, + pushCockpitSignal() {}, + scheduleContextLayout() {}, + clearPredictiveRoute() {}, + }); + t.after(() => { + coordinator.stop(); + app.unmount(); + document.body.classList.remove('cockpit-mode'); + }); + /** Turn a layer off and tell the Context owner, as the data manager does. */ + const disable = (layerId) => { + layers.delete(layerId); + _handleContextLayerChange.call( + { + cockpitView: cockpit, + _dataManager: dataManager, + _contextMode: null, + _contextModeChanging: false, + _contextSessionSnapshot: null, + _contextRestoreState: null, + _syncContextModeButtons() {}, + }, + { type: 'visibility', layerId, enabled: false, origin: 'user' }, + ); + }; + const $ = (selector) => document.querySelector(selector); + return { $, coordinator, cockpit, store, layers, disable, pinia }; +} + +test('a civilian aircraft enters with Live Flights alone: no Contacts, no Military', (t) => { + const { coordinator, cockpit } = fixture(t, { + enabled: ['flights'], + tracked: { layerId: 'flights', info: CIVIL }, + }); + const state = coordinator.getCockpitState(); + assert.equal(state.entryAllowed, true); + assert.equal(state.entryBlockedReason, null); + assert.equal(cockpit.enter(), true); +}); + +test('a military aircraft enters with Military Flights alone', (t) => { + const { coordinator, cockpit } = fixture(t, { + enabled: ['military'], + tracked: { layerId: 'military', info: MILITARY }, + }); + assert.equal(coordinator.getCockpitState().entryAllowed, true); + assert.equal(cockpit.enter(), true); +}); + +test('a Context change in flight still blocks entry, and says so', (t) => { + const { coordinator, cockpit } = fixture(t, { + enabled: ['flights', 'military'], + tracked: { layerId: 'flights', info: CIVIL }, + contextMode: 'flights', + changing: true, + }); + const state = coordinator.getCockpitState(); + assert.equal(state.entryAllowed, false); + assert.equal(state.entryBlockedReason, 'contacts-starting'); + assert.equal(cockpit.enter(), false); + const voice = coordinator.controlCockpit('enter'); + assert.equal(voice.ok, false); + assert.match(voice.error, /Context is still changing/); + assert.equal(cockpit.active, false); +}); + +test('with no aircraft layer on, entry is refused for that reason, never for Contacts', (t) => { + const { coordinator, cockpit } = fixture(t); + const state = coordinator.getCockpitState(); + assert.equal(state.entryBlockedReason, 'subject-layer-off'); + const voice = coordinator.controlCockpit('enter'); + assert.equal(voice.ok, false); + assert.match(voice.error, /No aircraft layer is on/); + assert.doesNotMatch(voice.error, /contacts/i); + assert.equal(voice.state.entryBlockedReason, 'subject-layer-off'); + assert.equal(cockpit.active, false); +}); + +test('with an aircraft layer on but nothing tracked, the reason is the missing aircraft', (t) => { + const { coordinator } = fixture(t, { enabled: ['military'] }); + assert.equal( + coordinator.getCockpitState().entryBlockedReason, + 'no-tracked-aircraft', + ); +}); + +test('voice enters without Contacts when the ridden layer is on', (t) => { + const { coordinator, cockpit } = fixture(t, { + enabled: ['flights'], + tracked: { layerId: 'flights', info: CIVIL }, + }); + const result = coordinator.controlCockpit('enter'); + assert.equal(result.ok, true, result.error); + assert.equal(cockpit.active, true); +}); + +test('other layers may be turned off during a session; the ridden layer ends it', (t) => { + const { cockpit, disable } = fixture(t, { + enabled: ['flights', 'military', 'ais-live-vessels'], + tracked: { layerId: 'flights', info: CIVIL }, + }); + assert.equal(cockpit.enter(), true); + disable('military'); + disable('ais-live-vessels'); + assert.equal(cockpit.active, true, 'other feeds are free to go off'); + disable('flights'); + assert.equal(cockpit.active, false, 'the ridden layer going off ends it'); +}); + +test('the readout offers the same entry, labelled by kind, and enters on click', async (t) => { + const { $, cockpit, pinia } = fixture(t, { + enabled: ['flights'], + tracked: { layerId: 'flights', info: CIVIL }, + }); + useHudStore(pinia); + const readoutEntry = $('#cockpit-entry-readout'); + assert.equal(readoutEntry.hidden, true, 'hidden until entry is available'); + cockpit.syncEntry(); + await nextTick(); + assert.equal(readoutEntry.hidden, false); + assert.match(readoutEntry.textContent, /flight/); + assert.match(readoutEntry.textContent, /COCKPIT/); + assert.equal( + $('#cockpit-entry').hidden, + false, + 'the Contacts entry follows the same presence', + ); + readoutEntry.click(); + await nextTick(); + assert.equal(cockpit.active, true); + assert.equal(readoutEntry.hidden, true, 'inside, there is nothing to enter'); +}); diff --git a/src/ui/cockpitInput.js b/src/ui/cockpitInput.js index 54b8f56a9..15b4207e1 100644 --- a/src/ui/cockpitInput.js +++ b/src/ui/cockpitInput.js @@ -1,5 +1,99 @@ /** Route Cockpit keys while preserving the active disclosure and form-control owner. */ +import * as Cesium from 'cesium'; +import { isPointerFree } from '../data/inputOwnership.js'; +/** How far from the followed track a double-click may land, CSS pixels. */ +export const SUBJECT_DOUBLE_CLICK_RADIUS_PX = 32; + +/** + * Whether a double-click landed on the followed track's mark. + * @param {{x: number, y: number}|null} click Canvas position of the click. + * @param {{x: number, y: number}|null|undefined} subject Canvas position of + * the track, or nothing when it is off screen. + * @param {number} [radiusPx] + */ +export function doubleClickHitsSubject( + click, + subject, + radiusPx = SUBJECT_DOUBLE_CLICK_RADIUS_PX, +) { + if (!click || !subject) return false; + const distance = Math.hypot(click.x - subject.x, click.y - subject.y); + return Number.isFinite(distance) && distance <= radiusPx; +} + +/** Where the track `kind` would ride is drawn now, as a world position. */ +function subjectWorldPosition(kind) { + if (kind !== 'aircraft') return null; + return ( + this.viewer.trackedEntity?.position?.getValue?.( + this.viewer.clock?.currentTime, + ) ?? null + ); +} + +/** The followed track's canvas position now, or null off screen. */ +function subjectCanvasPosition() { + const kind = this.resolveEntryKind(); + const world = kind ? subjectWorldPosition.call(this, kind) : null; + const scene = this.viewer.scene; + if (!world || typeof scene?.cartesianToCanvasCoordinates !== 'function') + return null; + return ( + scene.cartesianToCanvasCoordinates(world, new Cesium.Cartesian2()) ?? null + ); +} + +/** + * Enter first person when the followed track itself is double-clicked: the + * same entry, and the same gates, as C and the entry buttons. A + * double-click anywhere else, or one a tool owns (drawing ends a shape with + * one), is left alone. + * @param {{x: number, y: number}|null|undefined} position Canvas position. + * @returns {boolean} True when it landed on the followed track, entered or + * refused by the gate: the caller does nothing else with it. + */ +export function onSceneDoubleClick(position) { + if (this.active || this.destroyed || !isPointerFree()) return false; + if (!Number.isFinite(position?.x) || !Number.isFinite(position?.y)) + return false; + if (!doubleClickHitsSubject(position, subjectCanvasPosition.call(this))) + return false; + this.enter(); + return true; +} + +/** + * Take the scene's double-click through the Cockpit first. Cesium's Viewer + * installs its own (pickAndTrackObject), which untracks whenever the pick is + * not an Entity: on a followed aircraft's model, a primitive, it would drop + * the very track the double-click means to ride. A double-click on the + * followed track is the Cockpit's; any other goes on to Cesium's. + * @returns {() => void} Puts Cesium's handler back, unless another owner + * (the draw tool borrows it) has replaced ours since. + */ +export function routeSceneDoubleClick() { + const handler = this.viewer.screenSpaceEventHandler; + if (typeof handler?.setInputAction !== 'function') return () => {}; + const type = Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK; + const stock = handler.getInputAction?.(type) ?? null; + const routed = (event) => { + if (this.onSceneDoubleClick(event?.position)) return; + stock?.(event); + }; + handler.setInputAction(routed, type); + return () => { + if (handler.isDestroyed?.() || handler.getInputAction?.(type) !== routed) + return; + if (stock) handler.setInputAction(stock, type); + else handler.removeInputAction(type); + }; +} + +/** + * The Cockpit's capture-phase keydown: Escape and C. + * @param {KeyboardEvent} event + */ export function onKeyDown(event) { if (this.destroyed) return false; if (event.repeat || event.isComposing) return; diff --git a/src/ui/cockpitPresentation.js b/src/ui/cockpitPresentation.js index fc62ceaad..b56a81892 100644 --- a/src/ui/cockpitPresentation.js +++ b/src/ui/cockpitPresentation.js @@ -144,3 +144,21 @@ export function formatCockpitWindDirection(value) { const degrees = roundHeadingDegrees(value); return `${compassPointLabel(labels[Math.round(normalized / 45) % labels.length])} · ${degrees}°`; } + +/** + * The Cockpit entry for each subject it can ride: an aircraft's cockpit. + * Both entry buttons (Contacts and the Intel HUD readout) show it. + */ +const COCKPIT_ENTRY_FACES = Object.freeze({ + aircraft: Object.freeze({ icon: 'flight', label: msg('COCKPIT') }), +}); + +/** + * The entry's glyph and localized label for the subject it would ride. + * @param {'aircraft'|null|undefined} kind + * @returns {{icon: string, label: string}} + */ +export function cockpitEntryFace(kind) { + const face = COCKPIT_ENTRY_FACES[kind] ?? COCKPIT_ENTRY_FACES.aircraft; + return { icon: face.icon, label: t(face.label) }; +} diff --git a/src/ui/cockpitSignals.js b/src/ui/cockpitSignals.js index 2319a6d21..395db8116 100644 --- a/src/ui/cockpitSignals.js +++ b/src/ui/cockpitSignals.js @@ -62,7 +62,31 @@ export function pushCockpitSignal(key, tone, title, detail, target = null) { this.publishCockpitSignals(); } -export function updateCockpitSignals(snapshot, unknownCount) { +/** + * Take a status row off the list, as when the feed it warned about is back. + * @param {string} key The row's key. + */ +export function dropCockpitSignal(key) { + this.signalSignatures.delete(key); + const kept = this.signalItems.filter((item) => item.key !== key); + if (kept.length === this.signalItems.length) return; + this.signalItems = kept; + this.publishCockpitSignals(); +} + +/** + * Rebuild the rows from a Contacts snapshot: the nearest flights, a row for + * unknown inputs and, first, a row naming an air feed that is off. + * @param {object} snapshot `militaryAwareness.getContextSnapshot()` result. + * @param {number} unknownCount Cohorts whose count is unknown. + * @param {?{title: string, detail: string}} [partialPicture] The feed-off + * row (see `readPartialPicture`), or null with every air feed on. + */ +export function updateCockpitSignals( + snapshot, + unknownCount, + partialPicture = null, +) { const previous = new Map(this.signalItems.map((item) => [item.key, item])); const contacts = []; const subject = snapshot.subject; @@ -110,7 +134,9 @@ export function updateCockpitSignals(snapshot, unknownCount) { } } contacts.sort((a, b) => a.distanceM - b.distanceM); - const nextItems = contacts.slice(0, 5).map((item) => ({ + // A feed-off row leads, so the list never reads as the whole picture. + const reserved = partialPicture ? 1 : 0; + const nextItems = contacts.slice(0, 5 - reserved).map((item) => ({ ...item, timestamp: previous.get(item.key)?.timestamp || snapshot.evaluatedAt || Date.now(), @@ -120,18 +146,35 @@ export function updateCockpitSignals(snapshot, unknownCount) { .filter((cohort) => cohort.count === null) .map((cohort) => cohort.source) .join(' · '); - nextItems.splice(4, Math.max(0, nextItems.length - 4), { - key: 'input-status', + nextItems.splice( + 4 - reserved, + Math.max(0, nextItems.length - 4 + reserved), + { + key: 'input-status', + tone: 'warning', + title: tn( + unknownCount, + '{count} INPUT UNKNOWN', + '{count} INPUTS UNKNOWN', + ), + detail: sources || t('SOURCE STATUS UNAVAILABLE'), + target: null, + timestamp: + previous.get('input-status')?.timestamp || + snapshot.evaluatedAt || + Date.now(), + }, + ); + } + if (partialPicture) { + nextItems.unshift({ + key: 'feed-status', tone: 'warning', - title: tn( - unknownCount, - '{count} INPUT UNKNOWN', - '{count} INPUTS UNKNOWN', - ), - detail: sources || t('SOURCE STATUS UNAVAILABLE'), + title: partialPicture.title, + detail: partialPicture.detail, target: null, timestamp: - previous.get('input-status')?.timestamp || + previous.get('feed-status')?.timestamp || snapshot.evaluatedAt || Date.now(), }); diff --git a/src/ui/cockpitTrackingController.js b/src/ui/cockpitTrackingController.js index f308b0d2f..3ad6267ff 100644 --- a/src/ui/cockpitTrackingController.js +++ b/src/ui/cockpitTrackingController.js @@ -17,6 +17,30 @@ export function readAircraftInfo() { }); } +/** + * The layer the active view rides: the aircraft's own layer. It is read from + * the tracked entity's id once the layer has dropped its tracking, so a layer + * turned off still names itself. + * @returns {'flights'|'military'|null} + */ +export function readSubjectLayerId() { + if (!this.active) return null; + const layerId = this.readAircraftInfo()?.layerId; + if (layerId) return layerId; + const [prefix] = String(this.trackedEntity?.gevTrackedId || '').split(':'); + return ['flights', 'military'].includes(prefix) ? prefix : null; +} + +/** + * The subject entry would take: a Cesium-tracked aircraft. + * @returns {'aircraft'|null} + */ +export function resolveEntryKind() { + return this.readAircraftInfo() && this.viewer.trackedEntity?.position + ? 'aircraft' + : null; +} + export function dispatchCockpitModeChanged(active, info = null) { const subjectId = active ? String(info?.icao24 || '') @@ -247,9 +271,12 @@ export function exit({ restoreTracking = true } = {}) { this.restoreTrackingFrame(entity); } this.syncEntry(); + // Back to whichever entry was used: the Contacts button or the readout's. + const readoutEntry = document.getElementById('cockpit-entry-readout'); const restoreTarget = - this.entryFocusOrigin === this.entry - ? this.entry + this.entryFocusOrigin === this.entry || + (readoutEntry && this.entryFocusOrigin === readoutEntry) + ? this.entryFocusOrigin : this.entry || this.entryFocusOrigin; this.entryFocusOrigin = null; // The entry shows when Vue flushes the presence; read it after. diff --git a/src/ui/contextLayerChanges.js b/src/ui/contextLayerChanges.js index fc5abe3a7..d4ddd6cd5 100644 --- a/src/ui/contextLayerChanges.js +++ b/src/ui/contextLayerChanges.js @@ -1,7 +1,6 @@ import { t, translateKnown } from '../i18n/index.js'; import { SPACE_MISSIONS_LAYER_BLOCK_REASON, - cockpitEntryAllowed, isExplicitUserIntentOrigin, parseContextLayerBlockReason, recordContextRestoreExplicitChange, @@ -245,15 +244,12 @@ export function _handleContextLayerChange(change) { ); } } - if ( - this.cockpitView?.active && - !cockpitEntryAllowed({ - contextMode: this._contextMode, - contextModeChanging: this._contextModeChanging, - flightsEnabled: !!this._dataManager?.isEnabled('flights'), - militaryEnabled: !!this._dataManager?.isEnabled('military'), - }) - ) { + // Turning off the layer the view rides ends it; every other layer may be + // turned on or off freely during a session. + const riddenLayerId = this.cockpitView?.active + ? this.cockpitView.readSubjectLayerId?.() + : null; + if (riddenLayerId && !this._dataManager?.isEnabled(riddenLayerId)) { this.cockpitView.exit({ restoreTracking: false }); } this._syncContextModeButtons(); diff --git a/src/ui/contextTransactions.js b/src/ui/contextTransactions.js index 729edc654..e8a69b5fc 100644 --- a/src/ui/contextTransactions.js +++ b/src/ui/contextTransactions.js @@ -18,7 +18,10 @@ export async function _selectContextMode( this._contextModeChanging = true; this._syncContextModeButtons(); try { - if (mode !== 'flights' && this.cockpitView?.active) { + // Cockpit does not need Contacts, so leaving it keeps the view; the + // restore below ends it only by turning off the layer it rides. Another + // mode takes the camera, so switching to one ends it. + if (mode && mode !== 'flights' && this.cockpitView?.active) { this.cockpitView.exit({ restoreTracking: false }); } if (!mode) { @@ -220,8 +223,8 @@ export async function _deactivateContextForLayerChange({ this._contextMode = null; this._contextModeEntryIntent = null; this._contextModeReplacementIntent = null; - if (this.cockpitView?.active) - this.cockpitView.exit({ restoreTracking: false }); + // A layer turned off ends Contacts but not the Cockpit: the view ends only + // when the layer it rides goes off (see _handleContextLayerChange). this._syncContextModeButtons(); try { await this._restoreContextSession({ notificationToken }); diff --git a/src/ui/styles/cockpit.css b/src/ui/styles/cockpit.css index befbfbbff..d7ad845b6 100644 --- a/src/ui/styles/cockpit.css +++ b/src/ui/styles/cockpit.css @@ -33,6 +33,36 @@ color: #00dcff; font-size: 18px; } +/* The same entry under the selected vehicle's Intel HUD readout. The HUD + lets clicks through, so the button takes them back. */ +#intel-hud #cockpit-entry-readout { + display: inline-flex; + align-items: center; + gap: 6px; + margin-top: 6px; + min-height: 30px; + padding: 0 10px; + border: 1px solid var(--hud-border); + border-radius: 6px; + background: rgba(2, 12, 18, 0.72); + font-size: 10px; + letter-spacing: 0.14em; + pointer-events: auto; + cursor: pointer; +} +#intel-hud #cockpit-entry-readout[hidden] { + display: none; +} +#intel-hud #cockpit-entry-readout:hover, +#intel-hud #cockpit-entry-readout:focus-visible { + border-color: var(--hud-color); + outline: none; +} +#intel-hud #cockpit-entry-readout .material-symbols-outlined { + font-family: 'Material Symbols Outlined'; + font-size: 16px; + text-transform: none; +} #cockpit-entry kbd { padding: 3px 5px; border: 1px solid rgba(220, 238, 255, 0.25); diff --git a/src/ui/views/ContextView.vue b/src/ui/views/ContextView.vue index f330391e9..c40665fe4 100644 --- a/src/ui/views/ContextView.vue +++ b/src/ui/views/ContextView.vue @@ -14,11 +14,14 @@ import MissionDetail from './panels/MissionDetail.vue'; import MissionRoster from './panels/MissionRoster.vue'; import RadioPanel from './panels/RadioPanel.vue'; import UiButton from '../kit/UiButton.vue'; +import { cockpitEntryFace } from '../cockpitPresentation.js'; // ContextControls presents the mode switch and the nearby sites search and // publishes them; until it does, the panel shows its initial markup. const { view: ctx } = storeToRefs(useContextStore()); const { presence: cockpit } = storeToRefs(useCockpitStore()); +// The entry rides the tracked aircraft (cockpitEntryFace). +const entry = computed(() => cockpitEntryFace('aircraft')); // While the Bhote Koshi event is on, its panel is the rail's only content. const { panel: bhotePanel } = storeToRefs(useBhoteKoshiPanelStore()); // The Scene runtime chip SceneControls presents while a scene plays. @@ -56,8 +59,8 @@ const sceneRuntime = computed(() => scenePanel.value?.runtime ?? '');
🛸 diff --git a/src/ui/views/IntelHudView.vue b/src/ui/views/IntelHudView.vue index 77cd0c4fd..6f60bce76 100644 --- a/src/ui/views/IntelHudView.vue +++ b/src/ui/views/IntelHudView.vue @@ -2,6 +2,8 @@ import { computed } from 'vue'; import { storeToRefs } from 'pinia'; import { useHudStore } from '../stores/hud.js'; +import { useCockpitStore } from '../stores/cockpit.js'; +import { cockpitEntryFace } from '../cockpitPresentation.js'; // The readouts, visibility, layout variant and colors IntelHUD publishes. const { hud } = storeToRefs(useHudStore()); @@ -10,6 +12,12 @@ const { hud } = storeToRefs(useHudStore()); // right after IntelHUD is built. const EMPTY = Object.freeze({ vessel: {}, sensor: [], recShown: null }); const view = computed(() => hud.value ?? EMPTY); +// The selected vehicle's readout offers the same entry as the Contacts +// button, so first person needs no Contacts panel. The Cockpit controller +// answers its clicks. +const { presence: cockpit } = storeToRefs(useCockpitStore()); +// The entry rides the tracked aircraft (cockpitEntryFace). +const entry = computed(() => cockpitEntryFace('aircraft'));