From 81f49406b80f2f4c8076ed739360f79ab2e2c34f Mon Sep 17 00:00:00 2001 From: Wilmer Alzate Date: Wed, 30 Sep 2026 12:31:59 -0500 Subject: [PATCH 001/156] 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 002/156] 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 ffb332472d00e21aceb6d24e4f3ae97c8dbcff78 Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Mon, 12 Oct 2026 19:35:29 +0000 Subject: [PATCH 003/156] feat(layers): add FAA drone restrictions airspace layer Show US prohibited and restricted special-use airspace from the keyless FAA SUA FeatureServer as an off-by-default Infrastructure layer, with share-link token 3 and an informational-only disclaimer. --- CHANGELOG.md | 8 + DATA_SOURCES.md | 2 + README.md | 3 +- docs/CURRENT-STATE.md | 25 +- package.json | 2 + scripts/format-scope.json | 11 + scripts/package-boundaries.json | 32 ++ server/providers/droneAirspace.js | 135 ++++++++ server/providers/local.js | 2 + src/app/constructCatalog.js | 4 + src/app/constructCatalog.test.mjs | 5 +- src/app/layers/droneAirspace.js | 22 ++ src/app/sourceComposition.js | 1 + src/app/sourceComposition.test.mjs | 2 +- src/data/dataCredits.js | 7 + src/data/droneAirspaceProxy.test.mjs | 146 +++++++++ src/data/layerState.js | 5 + src/data/layerState.test.mjs | 34 +- src/data/layerStateTokenLedger.test.mjs | 62 ++-- src/data/layerStateTokenReservations.json | 1 + src/firstRunExperience.test.mjs | 5 +- src/layers/droneAirspace/cards.js | 93 ++++++ src/layers/droneAirspace/cards.test.mjs | 74 +++++ src/layers/droneAirspace/index.js | 326 ++++++++++++++++++++ src/layers/droneAirspace/ownership.test.mjs | 161 ++++++++++ src/layers/droneAirspace/records.js | 97 ++++++ src/layers/droneAirspace/records.test.mjs | 95 ++++++ src/layers/droneAirspace/source.js | 23 ++ src/layers/droneAirspace/source.test.mjs | 105 +++++++ src/sources/reference.js | 2 + src/sources/reference.test.mjs | 3 + src/tooling/previewServing.test.mjs | 1 + src/ui/layerPanel.js | 1 + src/voice/layerManifest.js | 24 ++ src/voice/layerManifest.test.mjs | 128 ++++++-- 35 files changed, 1573 insertions(+), 74 deletions(-) create mode 100644 server/providers/droneAirspace.js create mode 100644 src/app/layers/droneAirspace.js create mode 100644 src/data/droneAirspaceProxy.test.mjs create mode 100644 src/layers/droneAirspace/cards.js create mode 100644 src/layers/droneAirspace/cards.test.mjs create mode 100644 src/layers/droneAirspace/index.js create mode 100644 src/layers/droneAirspace/ownership.test.mjs create mode 100644 src/layers/droneAirspace/records.js create mode 100644 src/layers/droneAirspace/records.test.mjs create mode 100644 src/layers/droneAirspace/source.js create mode 100644 src/layers/droneAirspace/source.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index b24ac95ed..90402cad5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +- Add a Drone Restrictions layer for FAA prohibited (P) and restricted (R) + special-use airspace. The keyless `/api/drone-airspace` proxy pages the + public SUA FeatureServer, keeps a one-hour snapshot, and serves stale data + when a refresh fails. Zones draw as ground polygons (red prohibited, orange + restricted), selection shows name, altitudes, times of use and agency, and + the row legend states the layer is informational only. Off by default; + share-link token `3`. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 0b8eac358..2aa957e2b 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -35,6 +35,7 @@ How to read this: | **NASA GIBS** (WMTS tiles + Worldview Snapshots) and **NASA CMR** (granule catalog) | Recent Imagery layer: HLS S30/L30 (Sentinel-2, Landsat 8/9) and VIIRS true-colour tiles draped on the map, per-day thumbnails and PNG exports; the HLS granule catalog (dates, cloud, footprints) for a selected box | U.S. public domain (NASA data); keyless, browser-direct (CORS `*`); no bulk use — one 30-day catalog query per box, tiles bounded to the box, six tile requests in flight | "We acknowledge the use of imagery provided by services from NASA's Global Imagery Browse Services (GIBS), part of NASA's Earth Science Data and Information System (ESDIS)." — [gibs.earthdata.nasa.gov](https://gibs.earthdata.nasa.gov), [HLS](https://lpdaac.usgs.gov/products/hlss30v002/) | | **NIFC WFIGS** (Wildland Fire Interagency Geospatial Services, ArcGIS feature service) | Fire Perimeters layer: current interagency wildfire incident perimeters, containment, and incident facts | U.S. public domain (interagency wildland-fire data published through the [NIFC Open Data portal](https://data-nifc.opendata.arcgis.com/)); fetched keyless through `/api/fire-perimeters` (5-minute server cache) with `exceededTransferLimit` paging | "Wildfire perimeters: National Interagency Fire Center (WFIGS)" | | **InciWeb** (inciweb.wildfire.gov) | Per-incident "InciWeb ↗" card links to official incident pages (narratives, evacuation/closure notices) | U.S. government public incident information. Links are matched best-effort by incident name and state, with incident page origin and update times checked on selection. Same-origin `/api/fire-perimeters/inciweb` routes cache the catalog for 1 hour and publication metadata for 30 minutes; linked pages remain InciWeb content | "Incident information: InciWeb (inciweb.wildfire.gov)" | +| **FAA Special Use Airspace** (`services6.arcgis.com` FeatureServer `Special_Use_Airspace`) | Drone Restrictions layer: US prohibited (P) and restricted (R) special-use airspace polygons, altitude limits, times of use, and controlling agency | U.S. public domain (FAA aeronautical data published through the [FAA AIS Open Data portal](https://adds-faa.opendata.arcgis.com/)); fetched keyless through `/api/drone-airspace` (1-hour server cache). Informational only — not a substitute for official UAS flight-planning tools | "Drone restrictions: FAA Special Use Airspace" | | **OpenFreeMap / OpenMapTiles** | Vector tiles | OpenStreetMap data under ODbL 1.0 | OpenFreeMap © OpenMapTiles | | **Overture Maps Foundation** | Military area names pack | [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/) | Overture Maps Foundation | | **TomTom Traffic API** (flow vector tiles) | Traffic roads and live congestion (optional, BYOK) | [TomTom for Developers terms](https://docs.tomtom.com/legal/terms-and-conditions) (proprietary, your own key; free tier currently 200K tile requests/month — see [current pricing](https://docs.tomtom.com/pricing/)) | "Traffic flow data © TomTom" — registered when live mode activates | @@ -98,6 +99,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **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. - **Re:Earth Terrain.** Keyless (no API key). Used two ways: (1) `src/mapStackController.js` swaps in a `Cesium.CesiumTerrainProvider` pointed at Re:Earth's `cesium-mesh/ellipsoid` quantized-mesh endpoint for globe stacks without a Cesium ion token (e.g. OSM), replacing a flat `EllipsoidTerrainProvider`; falls back to the flat provider if the endpoint can't be reached. (2) The server-side `/api/terrain/heights` proxy (disk-cached, serve-stale) resolves per-point ellipsoidal ground height for entity placement. Both are best-effort with a keyless-safe fallback (bundled EGM96 geoid math) if Re:Earth is unreachable. - **OSRM on the FOSSGIS routing servers.** Keyless (no API key). Every route is requested by a person: clicking A and B in the Directions layer, or a voice "route from A to B". The server-side `/api/route` proxy sends an identifying `User-Agent`, pins the upstream host, accepts 2-12 coordinates, rejects any leg over 600 km or a total over 2,500 km, caps the buffered response at 8 MB, times the upstream out at 12 s, rate-limits per client, caches each route for 10 minutes, and coalesces identical requests that are in flight at the same time. Turn-by-turn maneuvers are asked for only when a caller wants them (`steps=1`), so the callers that do not read them send the smaller request. The "fix the map" link the policy asks for is in the Data attribution popover beside the OpenStreetMap credit. +- **FAA Special Use Airspace.** Keyless. `/api/drone-airspace` queries the public FAA AIS FeatureServer for prohibited (P) and restricted (R) zones only — not MOAs, TFRs or other SUA types — with simplified geometry, a body cap, paging, a one-hour cache and stale-on-error. The layer is US coverage, informational only, and is not a substitute for B4UFLY, LAANC or a preflight briefing. Data is fetched at request time and never stored or redistributed. US government public domain; courtesy attribution is registered in the Data attribution popover. - **Mapped installations.** OpenFreeMap military polygons use a bundled Overture/OSM name index; wide views show named points. Optional operator-configured Overpass and explicit Google Places searches keep their own source paths. Mapped data does not establish current activity or complete coverage. - **Place search.** Named-place fly-to uses Google Geocoding when a Maps key is configured, then Photon's public instance, then `/api/geocode` (Nominatim search) when neither answers. `/api/geocode` uses the shared Nominatim forward-search queue at no more than one request per second, sends an identifying `User-Agent` and `Referer`, caches answers for five minutes, shares one upstream call between identical searches already in flight, bounds the queue so a burst is refused rather than held, and drops a queued search whose caller has given up. It asks for one result per search and is not used for systematic or bulk queries. - **Cockpit regional briefing.** `/api/regional-brief` rounds aircraft coordinates into 0.1° cache cells, caches results for five minutes, and resolves physical regions from bundled public-domain Natural Earth polygons without reverse-geocoding requests. Google News RSS is queried with the resolved locality/region first; GDELT is used only when that RSS query fails or is empty. Google's published Google News terms restrict that source to personal, noncommercial use, so commercial deployments must disable/replace it or obtain separate permission; GDELT permits commercial dataset use with citation. The Data attribution popover identifies the active headline sources; article links retain publisher attribution. Headlines are location-query matches, not verified incidents, risk rankings, or evidence that a location is safe. Empty, partial, stale, and unavailable source states remain distinct. Open-Meteo supplies current conditions independently of the news source. `WX OFF` disables cockpit weather rendering only; the Local Info briefing still fetches its source-backed weather values and displays the required linked Open-Meteo credit. diff --git a/README.md b/README.md index 5d97dfb2f..6b8043e5a 100644 --- a/README.md +++ b/README.md @@ -289,7 +289,7 @@ _Ask for radio near anywhere and the globe starts broadcasting — every station ## 🛰️ What's on the Globe -Twenty layers and map sources. **Seventeen have a keyless path.** Some offer additional capabilities with a provider key. (🟢 no key · 🟡 free key · 🔴 metered.) +Twenty-one layers and map sources. **Eighteen have a keyless path.** Some offer additional capabilities with a provider key. (🟢 no key · 🟡 free key · 🔴 metered.) | Layer | What you get | Source | Auth | | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------- | @@ -310,6 +310,7 @@ Twenty layers and map sources. **Seventeen have a keyless path.** Some offer add | 🔥 **Active Fires** | Live NASA FIRMS detections, trailing 24h | NASA FIRMS | 🟡 | | 🚀 **Space Missions** | Rolling 30-day launches with payload, stage, and recovery detail | Launch Library 2 | 🟢 (🟡 optional token raises the allowance) | | 🎖️ **Mapped Installations** | Viewport-bounded military-site context from community mapping — incomplete by nature, and labeled that way | OpenStreetMap | 🟢 | +| 🚫 **Drone Restrictions** | FAA prohibited (P) and restricted (R) special-use airspace as ground polygons, with name, altitudes and times of use on select — informational only, not for flight planning | FAA SUA | 🟢 | | 🌬️ **Wind** | Animated 10 m forecast wind with an optional speed, temperature or pressure color field and a reading at map center | NOAA GFS / ECMWF IFS | 🟢 | | 🌦️ **Observed Weather** | Rain radar (contiguous US), Satellite clouds (GOES regional or global infrared, Clouds only or Full image) and Lightning density (Americas and Pacific) on one history timeline | NOAA nowCOAST | 🟢 | | 🌀 **Cyclones** | Advisory positions, forecast tracks and uncertainty cones for the Atlantic and eastern/central North Pacific | NOAA NHC / CPHC | 🟢 | diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 26d56e66b..2fc40751a 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,19 @@ # God's Eye View Current State +## Drone Restrictions — FAA special-use airspace + +The Infrastructure group includes **Drone Restrictions** (`drone-airspace`, +share-link token `3`), off by default. `/api/drone-airspace` pages the public +FAA Special Use Airspace FeatureServer for `TYPE_CODE` P (prohibited) and R +(restricted), simplifies geometry, caches for an hour, and serves the last +good snapshot as stale when a refresh fails. The globe draws ground-clamped +outlines with a translucent fill: red for prohibited, orange for restricted. +Selecting a zone shows name, altitudes, times of use and controlling agency, +plus a notice that the layer is informational only and not a substitute for +official UAS flight-planning tools. Pick ownership is unregistered so sibling +layer cards still work over a zone. Voice can toggle the layer and query the +loaded records. See the live-layers table below. + Vessel sources can report healthy empty coverage using a current, complete snapshot with zero raw rows, a healthy transport and a positive last-message time. This clears obsolete contacts and settles startup without manufacturing @@ -335,7 +349,7 @@ Consult the linked official advisory for safety decisions. Voice and HUD snapshots reuse the existing feedState classifier. Analyst follow-ups retain their original data provenance; current-view results append provenance without replacing legacy fields. HUD context and deterministic telemetry include non-nominal feed state. -Analyst records come on demand from each layer's `getAnalystRecords`: flights, military, local ADS-B, vessels, satellites, launches, earthquakes, FIRMS (VIIRS and MODIS, with a `sensor` field), fire perimeters, cyclones, transit, bikeshare, ALPR cameras, mapped installations, datacenters and dams. `analyst_query` counts the whole loaded set (up to 250,000 records per layer; past that `coverage.records` reports `returned`, `total` and `truncated: true` and the result carries `complete: false`). Ranking keeps a top-k and scans in 20,000-row slices that yield to the page and stop when superseded. It refuses unknown fields, operators, units that do not convert (`altitudeM(ft)` converts), wrong-typed values, invalid centres, unknown scope kinds and sort fields with `code` and `allowed`, refuses a follow-up with no previous answer (`NO_RESULT_CONTEXT`) or naming other layers than that answer (`FOLLOW_UP_MISMATCH`), and tells `LAYER_OFF`, `NOT_READY` and `FEED_UNAVAILABLE` apart from a real zero; when only some layers can answer the result is `partial` with `unanswered` ids. The Contacts-window path applies the same filters, ranking and item shape to one immutable panel snapshot. Each aircraft layer retains at most 20,000 rows; reaching that bound sets `complete: false` and the spoken/card count becomes “At least”, never an exact claim. A superseded query never replaces follow-up memory, while sibling analyst calls from one assistant response do not cancel one another. Items carry `lat`/`lon`; the feed window (earthquakes and FIRMS: last 24 h), the precise scope and any caveat sit in `display`, which is on-screen detail rather than something the model recites. "In view" is still a radius around the view centre, not the visible footprint. Satellite distance is ground distance. +Analyst records come on demand from each layer's `getAnalystRecords`: flights, military, local ADS-B, vessels, satellites, launches, earthquakes, FIRMS (VIIRS and MODIS, with a `sensor` field), fire perimeters, drone restrictions, cyclones, transit, bikeshare, ALPR cameras, mapped installations, datacenters and dams. `analyst_query` counts the whole loaded set (up to 250,000 records per layer; past that `coverage.records` reports `returned`, `total` and `truncated: true` and the result carries `complete: false`). Ranking keeps a top-k and scans in 20,000-row slices that yield to the page and stop when superseded. It refuses unknown fields, operators, units that do not convert (`altitudeM(ft)` converts), wrong-typed values, invalid centres, unknown scope kinds and sort fields with `code` and `allowed`, refuses a follow-up with no previous answer (`NO_RESULT_CONTEXT`) or naming other layers than that answer (`FOLLOW_UP_MISMATCH`), and tells `LAYER_OFF`, `NOT_READY` and `FEED_UNAVAILABLE` apart from a real zero; when only some layers can answer the result is `partial` with `unanswered` ids. The Contacts-window path applies the same filters, ranking and item shape to one immutable panel snapshot. Each aircraft layer retains at most 20,000 rows; reaching that bound sets `complete: false` and the spoken/card count becomes “At least”, never an exact claim. A superseded query never replaces follow-up memory, while sibling analyst calls from one assistant response do not cancel one another. Items carry `lat`/`lon`; the feed window (earthquakes and FIRMS: last 24 h), the precise scope and any caveat sit in `display`, which is on-screen detail rather than something the model recites. "In view" is still a radius around the view centre, not the visible footprint. Satellite distance is ground distance. Keyless terrain tiles retry when Re:Earth throttles them. The browser fetches `terrain.reearth.land/cesium-mesh/ellipsoid/{z}/{x}/{y}.terrain` directly; no @@ -3070,9 +3084,18 @@ its criteria cannot be silently ignored. | Street Level 📷 | Street-level imagery; Mapillary is the first provider (vector tiles, Graph API, MapillaryJS) | `src/layers/streetLevel/` via `src/app/layers/streetLevel.js` | `/api/mapillary/status`, `/api/mapillary/tiles/coverage/{z}/{x}/{y}` (`MAPILLARY_CLIENT_TOKEN`) | camera-driven (320 ms debounce, ≤9 tiles, cached 24 h) | | Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) | | Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) | +| Drone Restrictions 🚫 | FAA Special Use Airspace prohibited (P) and restricted (R) zones (keyless FeatureServer, simplified geometry) | `src/layers/droneAirspace/` via `src/app/layers/droneAirspace.js` | `/api/drone-airspace` | 1 h (server cache 1 h; stale-on-error) | Fire Perimeters uses capped, timed server reads with stale-on-error caching and a per-client limit. Unchanged snapshots retain geometry; link checks cancel on disable or selection change, and the row legend shows reported containment. +Drone Restrictions is off by default (share-link token `3`) and sits in +Infrastructure. The proxy pages `TYPE_CODE IN ('P','R')` from the FAA SUA +FeatureServer, caps the body, and reuses a one-hour snapshot, serving the last +good payload as stale when a refresh fails. Client-supplied URLs are rejected. +Polygons keep holes; pick ownership is deliberately unregistered so sibling +cards still work over a zone. The row legend and selected card state that the +layer is informational only and not a substitute for B4UFLY / LAANC. + Street Level is a collapsible right-rail panel (`#street-level-panel`, layer token `0`, option owner `street-level`, panel `ui` token `t`) that starts collapsed. It opens itself only when the user, voice or a tool switches the diff --git a/package.json b/package.json index d9bf6cf8a..ca8a972ce 100644 --- a/package.json +++ b/package.json @@ -165,6 +165,7 @@ "./layers/recent-imagery": "./src/layers/recentImagery/index.js", "./layers/recent-imagery/source": "./src/layers/recentImagery/source.js", "./layers/perimeters": "./src/layers/perimeters/index.js", + "./layers/drone-airspace": "./src/layers/droneAirspace/index.js", "./sources/firms-csv": "./src/data/firmsCsv.js", "./layers/firms": "./src/layers/firms/index.js", "./layers/satellites": "./src/layers/satellites/index.js", @@ -238,6 +239,7 @@ "./layers/cctv/source": "./src/layers/cctv/source.js", "./layers/earthquakes/source": "./src/layers/earthquakes/source.js", "./layers/perimeters/source": "./src/layers/perimeters/source.js", + "./layers/drone-airspace/source": "./src/layers/droneAirspace/source.js", "./layers/firms/source": "./src/layers/firms/source.js", "./layers/installations/source": "./src/layers/installations/source.js", "./layers/launches/source": "./src/layers/launches/source.js", diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 6b5c9b544..0d72c1096 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -371,6 +371,14 @@ "src/layers/perimeters/cards.test.mjs", "src/layers/perimeters/inciweb.test.mjs", "src/layers/perimeters/ownership.test.mjs", + "src/layers/droneAirspace/index.js", + "src/layers/droneAirspace/records.js", + "src/layers/droneAirspace/source.js", + "src/layers/droneAirspace/cards.js", + "src/layers/droneAirspace/records.test.mjs", + "src/layers/droneAirspace/source.test.mjs", + "src/layers/droneAirspace/cards.test.mjs", + "src/layers/droneAirspace/ownership.test.mjs", "src/data/firmsHeatmap.js", "src/data/fireAnchors.js", "src/layers/firms/anchors.js", @@ -643,6 +651,7 @@ "src/app/layers/radio.js", "src/app/layers/earthquakes.js", "src/app/layers/perimeters.js", + "src/app/layers/droneAirspace.js", "src/app/layers/cctv.js", "src/app/layers/firms.js", "src/app/layers/satellites.js", @@ -824,8 +833,10 @@ "src/layers/weather/imageryOrder.test.mjs", "server/providers/cyclones.js", "server/providers/firePerimeters.js", + "server/providers/droneAirspace.js", "src/data/cyclonesProxy.test.mjs", "src/data/firePerimetersProxy.test.mjs", + "src/data/droneAirspaceProxy.test.mjs", "src/layers/cyclones/index.js", "src/layers/cyclones/rendering.js", "src/layers/cyclones/labels.js", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 497be5b08..ed1fb5c4d 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -2023,6 +2023,11 @@ "src/layers/perimeters/records.js", "src/layers/perimeters/cards.js", "src/layers/perimeters/inciweb.js", + "src/app/layers/droneAirspace.js", + "src/layers/droneAirspace/index.js", + "src/layers/droneAirspace/source.js", + "src/layers/droneAirspace/records.js", + "src/layers/droneAirspace/cards.js", "src/layers/cctv/sourcePolicy.js", "src/sources/reference.js", "src/search/nominatim.js", @@ -2602,6 +2607,11 @@ "src/layers/perimeters/records.js", "src/layers/perimeters/cards.js", "src/layers/perimeters/inciweb.js", + "src/app/layers/droneAirspace.js", + "src/layers/droneAirspace/index.js", + "src/layers/droneAirspace/source.js", + "src/layers/droneAirspace/records.js", + "src/layers/droneAirspace/cards.js", "src/layers/cctv/sourcePolicy.js", "src/search/nominatim.js", "src/nominatimGeocode.js", @@ -3097,6 +3107,8 @@ "src/sources/httpBody.js", "src/layers/perimeters/source.js", "src/layers/perimeters/records.js", + "src/layers/droneAirspace/source.js", + "src/layers/droneAirspace/records.js", "src/layers/submarineCables/bundledSource.js" ], "external": [] @@ -3317,6 +3329,26 @@ ], "external": [] }, + "drone-airspace": { + "exports": ["./layers/drone-airspace"], + "modules": [ + "src/layers/droneAirspace/index.js", + "src/sources/httpBody.js", + "src/layers/droneAirspace/source.js", + "src/layers/droneAirspace/records.js", + "src/layers/droneAirspace/cards.js" + ], + "external": ["cesium"] + }, + "drone-airspace-source": { + "exports": ["./layers/drone-airspace/source"], + "modules": [ + "src/layers/droneAirspace/records.js", + "src/sources/httpBody.js", + "src/layers/droneAirspace/source.js" + ], + "external": [] + }, "tools": { "exports": ["./tools"], "modules": [ diff --git a/server/providers/droneAirspace.js b/server/providers/droneAirspace.js new file mode 100644 index 000000000..3a1905fc9 --- /dev/null +++ b/server/providers/droneAirspace.js @@ -0,0 +1,135 @@ +import { normalizeDroneAirspaceSnapshot } from '../../src/layers/droneAirspace/records.js'; +import { readResponseJsonCapped, coalesceProxyRequest } from './common/http.js'; +import { makeRateLimiter, clientKey } from './common/rate-limit.js'; + +// FAA Special Use Airspace (public, keyless). TYPE_CODE P (prohibited) and +// R (restricted) only — the small, always-in-force slice of the national +// SUA table. maxAllowableOffset trades ~100 m of boundary fidelity for a +// payload small enough to refresh (~330 KB for the current P+R set). +const API_URL = + 'https://services6.arcgis.com/ssFJjBXIUyZDrSYZ/arcgis/rest/services/' + + 'Special_Use_Airspace/FeatureServer/0/query?' + + new URLSearchParams({ + where: "TYPE_CODE IN ('P','R')", + outFields: [ + 'NAME', + 'TYPE_CODE', + 'UPPER_DESC', + 'LOWER_DESC', + 'TIMESOFUSE', + 'CONT_AGENT', + 'CITY', + 'STATE', + 'OBJECTID', + ].join(','), + maxAllowableOffset: '0.001', + outSR: '4326', + f: 'geojson', + }).toString(); + +const MAX_PAGES = 5; +const MIB = 1024 * 1024; +const SNAPSHOT_TTL_MS = 3_600_000; + +const exceededTransferLimit = (payload) => + payload?.exceededTransferLimit === true || + payload?.properties?.exceededTransferLimit === true; + +/** Fixed-origin, bounded FAA SUA routes for dev and preview. */ +export function droneAirspaceProxy({ + fetchImpl = (...args) => globalThis.fetch(...args), + now = () => Date.now(), +} = {}) { + const cache = new Map(); + const inFlight = new Map(); + const allow = makeRateLimiter({ windowMs: 60_000, max: 60, globalMax: 1200 }); + + async function upstream(url, cap, timeout) { + const signal = AbortSignal.timeout(timeout); + const response = await fetchImpl(url, { + signal, + redirect: 'error', + }); + if (!response.ok) { + await response.body?.cancel(); + throw new Error('upstream_unavailable'); + } + return readResponseJsonCapped(response, cap, signal); + } + + async function fetchZones() { + const features = []; + for (let page = 0; page < MAX_PAGES; page++) { + const url = + page === 0 ? API_URL : `${API_URL}&resultOffset=${features.length}`; + const payload = await upstream(url, 8 * MIB, 30_000); + if (!Array.isArray(payload?.features)) + throw new Error('invalid_snapshot'); + features.push(...payload.features); + if (!exceededTransferLimit(payload) || !payload.features.length) break; + } + const rows = normalizeDroneAirspaceSnapshot({ features }); + if (!rows) throw new Error('invalid_snapshot'); + return { fetchedAt: now(), rows }; + } + + async function acquire(key, ttl, load) { + const previous = cache.get(key); + if (previous && now() - previous.savedAt < ttl) + return { value: previous.value, stale: false }; + if (!inFlight.has(key) && inFlight.size >= 256) + throw Object.assign(new Error('busy'), { status: 429 }); + try { + const { promise } = coalesceProxyRequest(inFlight, key, async () => { + const value = await load(); + cache.set(key, { value, savedAt: now() }); + return value; + }); + return { value: await promise, stale: false }; + } catch (error) { + if (previous) return { value: previous.value, stale: true }; + throw error; + } + } + + async function handler(req, res) { + const json = (status, value, stale = false) => { + if (res.destroyed) return; + res.writeHead(status, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + ...(status === 405 ? { Allow: 'GET' } : {}), + ...(status === 429 ? { 'Retry-After': '60' } : {}), + ...(stale ? { 'X-Data-Stale': 'true' } : {}), + }); + res.end(JSON.stringify(value)); + }; + if (req.method !== 'GET') return json(405, { error: 'method_not_allowed' }); + const path = (req.url || '/').split('?')[0]; + if (path !== '/' && path !== '') + return json(404, { error: 'unknown_route' }); + if (!allow(clientKey(req))) return json(429, { error: 'rate_limited' }); + try { + const { value, stale } = await acquire( + 'zones', + SNAPSHOT_TTL_MS, + fetchZones, + ); + json(200, stale ? { ...value, stale: true } : value, stale); + } catch (error) { + json(error.status === 429 ? 429 : 502, { + error: 'drone_airspace_unavailable', + }); + } + } + + return { + name: 'drone-airspace', + configureServer({ middlewares }) { + middlewares.use('/api/drone-airspace', handler); + }, + configurePreviewServer({ middlewares }) { + middlewares.use('/api/drone-airspace', handler); + }, + }; +} diff --git a/server/providers/local.js b/server/providers/local.js index 9acbade31..e976455ed 100644 --- a/server/providers/local.js +++ b/server/providers/local.js @@ -24,6 +24,7 @@ import { mapillaryProxy } from './mapillary.js'; import { keySetupEndpoint } from '../standalone/key-setup.js'; import { weatherProxy } from './weather.js'; import { firePerimetersProxy } from './firePerimeters.js'; +import { droneAirspaceProxy } from './droneAirspace.js'; import { cycloneProxy } from './cyclones.js'; import { windProxy } from './wind.js'; @@ -60,6 +61,7 @@ function localProviderPlugins({ realtime } = {}) { cycloneProxy(), mapillaryProxy(), firePerimetersProxy(), + droneAirspaceProxy(), keySetupEndpoint(), ]; } diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index 80315fe85..fe36dd76a 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -25,6 +25,7 @@ import { createApplicationAwareness } from './layers/militaryAwareness.js'; import { createApplicationFirms } from './layers/firms.js'; import { createApplicationEarthquakes } from './layers/earthquakes.js'; import { createApplicationFirePerimeters } from './layers/perimeters.js'; +import { createApplicationDroneAirspace } from './layers/droneAirspace.js'; import { createApplicationCables } from './layers/submarineCables.js'; import { createApplicationStreetLevel } from './layers/streetLevel.js'; import { createInfrastructureLayers } from '../data/infrastructure.js'; @@ -120,6 +121,9 @@ export function createApplicationCatalog({ createApplicationFirePerimeters({ source: sources['fire-perimeters'], }), + createApplicationDroneAirspace({ + source: sources['drone-airspace'], + }), createApplicationAlpr({ surface, source: sources.alpr }), createApplicationStreetLevel({ surface, diff --git a/src/app/constructCatalog.test.mjs b/src/app/constructCatalog.test.mjs index f42e82c7c..e298580ad 100644 --- a/src/app/constructCatalog.test.mjs +++ b/src/app/constructCatalog.test.mjs @@ -40,7 +40,7 @@ test('catalogs construct distinct layers and classification from their supplied signal: b.signal, surface: fixtureSurface(b.signal), }); - assert.equal(first.layers.length, 30); + assert.equal(first.layers.length, 31); assert.ok(first.get('local-adsb'), 'Local ADS-B is registered'); assert.deepEqual( first.metadata.find(({ id }) => id === 'local-adsb'), @@ -60,6 +60,7 @@ test('catalogs construct distinct layers and classification from their supplied '2026-09-21T12:00:00.000Z', ); assert.ok(first.get('fire-perimeters')); + assert.ok(first.get('drone-airspace')); assert.ok(first.get('transit')); const order = first.layers.map(({ id }) => id); assert.deepEqual( @@ -153,7 +154,7 @@ test('Mapillary can be omitted or replaced without changing catalog membership', surface: fixtureSurface(lifetime.signal), }; const catalog = createApplicationCatalog(options); - assert.equal(catalog.layers.length, 30); + assert.equal(catalog.layers.length, 31); assert.deepEqual(catalog.get('street-level').providerIds, []); assert.equal( catalogControlServices(catalog).streetLevelLayer, diff --git a/src/app/layers/droneAirspace.js b/src/app/layers/droneAirspace.js new file mode 100644 index 000000000..f3472fec4 --- /dev/null +++ b/src/app/layers/droneAirspace.js @@ -0,0 +1,22 @@ +import * as Cesium from 'cesium'; +import { createDroneAirspaceLayer } from '../../layers/droneAirspace/index.js'; +import * as picking from '../../data/pickRegistry.js'; +import * as overlays from '../../overlays/worldOverlay.js'; +import { isPointerFree } from '../../data/inputOwnership.js'; + +/** Wire FAA special-use airspace into the application catalog. */ +export function createApplicationDroneAirspace(options) { + return createDroneAirspaceLayer({ + overlayHost: { + setEntries: overlays.setOverlayEntries, + setVisible: overlays.setOverlaySourceVisible, + clearSource: overlays.clearOverlaySource, + hitTest: overlays.hitTestWorldOverlay, + }, + screenSpaceEventHandlerFactory: (viewer) => + new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas), + picking, + pointer: { isPointerFree }, + ...options, + }); +} diff --git a/src/app/sourceComposition.js b/src/app/sourceComposition.js index 14c864ac5..c9f79e407 100644 --- a/src/app/sourceComposition.js +++ b/src/app/sourceComposition.js @@ -46,6 +46,7 @@ export const CATALOG_SOURCE_CONTRACTS = Object.freeze({ cyclones: contract(['weather-cyclones'], ['getSnapshot']), earthquakes: contract(['earthquakes'], ['getSnapshot']), 'fire-perimeters': contract(['fire-perimeters'], ['getSnapshot']), + 'drone-airspace': contract(['drone-airspace'], ['getSnapshot']), cables: contract(['telegeography-submarine-cables'], ['fetch']), transit: contract(['transit'], ['requestSnapshot', 'getHistory']), }); diff --git a/src/app/sourceComposition.test.mjs b/src/app/sourceComposition.test.mjs index 44a7c8fa0..cf8646e25 100644 --- a/src/app/sourceComposition.test.mjs +++ b/src/app/sourceComposition.test.mjs @@ -37,7 +37,7 @@ test('every acquisition source can be omitted without losing catalog membership' test('all acquisition sources may be absent, and enable fails before initializing or fetching', async (t) => { const actual = catalog({}, t); - assert.equal(actual.layers.length, 30); + assert.equal(actual.layers.length, 31); const manager = new LayerLifecycle( {}, { getSourceAvailability: actual.getSourceAvailability }, diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index 2967f0a2b..2e4a8148e 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -160,6 +160,13 @@ export const DATA_CREDITS = [ ' · Incident information: ' + 'InciWeb', }, + { + key: 'faa-sua', + html: + 'Drone restrictions: ' + + 'FAA Special Use Airspace' + + ' (prohibited and restricted zones; informational only)', + }, OSM_CREDIT, OPENMAPTILES_CREDIT, { diff --git a/src/data/droneAirspaceProxy.test.mjs b/src/data/droneAirspaceProxy.test.mjs new file mode 100644 index 000000000..52eb0dcc8 --- /dev/null +++ b/src/data/droneAirspaceProxy.test.mjs @@ -0,0 +1,146 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { droneAirspaceProxy } from '../../server/providers/droneAirspace.js'; + +const feature = (id = 310, type = 'P') => ({ + properties: { + NAME: `${type}-${id}`, + TYPE_CODE: type, + OBJECTID: id, + UPPER_DESC: 'TNI', + }, + geometry: { + type: 'Polygon', + coordinates: [ + [ + [-77, 38], + [-76, 38], + [-76, 39], + [-77, 38], + ], + ], + }, +}); + +function install(options = {}, hook = 'configureServer') { + let handler; + const plugin = droneAirspaceProxy(options); + assert.equal(typeof plugin.configureServer, 'function'); + assert.equal(typeof plugin.configurePreviewServer, 'function'); + plugin[hook]({ + middlewares: { + use(path, callback) { + assert.equal(path, '/api/drone-airspace'); + handler = callback; + }, + }, + }); + return async (url = '/', method = 'GET', peer = 'local') => { + const res = { + writeHead(status, headers) { + this.status = status; + this.headers = headers; + }, + end(body) { + this.body = JSON.parse(body); + }, + }; + await handler({ url, method, socket: { remoteAddress: peer } }, res); + return res; + }; +} + +for (const hook of ['configureServer', 'configurePreviewServer']) { + test(`${hook}: paging stops at five pages and normalizes the combined snapshot`, async () => { + const calls = []; + const request = install( + { + now: () => 1234, + fetchImpl: async (url, options) => { + calls.push(new URL(url)); + assert.ok(options.signal instanceof AbortSignal); + assert.equal(options.redirect, 'error'); + return Response.json({ + features: [feature(calls.length)], + exceededTransferLimit: true, + }); + }, + }, + hook, + ); + const res = await request('/?url=https://invalid.example&resultOffset=999'); + assert.equal(res.status, 200); + assert.equal(res.body.fetchedAt, 1234); + assert.equal(res.body.rows.length, 5); + assert.equal(calls.length, 5); + assert.equal(calls[0].searchParams.get('where'), "TYPE_CODE IN ('P','R')"); + assert.equal(calls[0].searchParams.get('f'), 'geojson'); + assert.equal(calls[0].searchParams.has('resultOffset'), false); + assert.equal(calls[1].searchParams.get('resultOffset'), '1'); + }); +} + +test('a cached snapshot is reused until TTL, then refreshed', async () => { + let now = 1000; + let fetches = 0; + const request = install({ + now: () => now, + fetchImpl: async () => { + fetches += 1; + return Response.json({ features: [feature(fetches)] }); + }, + }); + const first = await request(); + now += 3_599_000; + const cached = await request(); + now += 2000; + const refreshed = await request(); + assert.equal(first.body.rows[0].stableId, '1'); + assert.equal(cached.body.rows[0].stableId, '1'); + assert.equal(refreshed.body.rows[0].stableId, '2'); + assert.equal(fetches, 2); +}); + +test('a failed refresh serves the last good snapshot as stale', async () => { + let now = 1000; + let fail = false; + const request = install({ + now: () => now, + fetchImpl: async () => { + if (fail) return new Response('nope', { status: 503 }); + return Response.json({ features: [feature(7)] }); + }, + }); + const first = await request(); + fail = true; + now += 3_600_001; + const stale = await request(); + assert.equal(first.status, 200); + assert.equal(stale.status, 200); + assert.equal(stale.headers['X-Data-Stale'], 'true'); + assert.equal(stale.body.stale, true); + assert.equal(stale.body.rows[0].stableId, '7'); +}); + +test('method, unknown routes and rate limits are rejected', async () => { + const request = install({ + fetchImpl: async () => Response.json({ features: [feature()] }), + }); + assert.equal((await request('/', 'POST')).status, 405); + assert.equal((await request('/nope')).status, 404); + const limited = install({ + fetchImpl: async () => Response.json({ features: [feature()] }), + }); + let last; + for (let i = 0; i < 61; i++) last = await limited('/', 'GET', '10.0.0.9'); + assert.equal(last.status, 429); +}); + +test('a cold upstream failure is 502, not an empty snapshot', async () => { + const request = install({ + fetchImpl: async () => new Response('nope', { status: 503 }), + }); + const res = await request(); + assert.equal(res.status, 502); + assert.equal(res.body.error, 'drone_airspace_unavailable'); +}); diff --git a/src/data/layerState.js b/src/data/layerState.js index 373eac20d..bc5c660ca 100644 --- a/src/data/layerState.js +++ b/src/data/layerState.js @@ -595,6 +595,11 @@ export const LAYER_STATE_REGISTRY = Object.freeze([ optionOwner: 'cctv', }), Object.freeze({ id: 'directions', token: 'n', disposition: 'enabled-only' }), + Object.freeze({ + id: 'drone-airspace', + token: '3', + disposition: 'enabled-only', + }), Object.freeze({ id: 'earthquakes', token: 'e', disposition: 'enabled-only' }), Object.freeze({ id: 'fire-perimeters', diff --git a/src/data/layerState.test.mjs b/src/data/layerState.test.mjs index 08120a604..0fbdcf000 100644 --- a/src/data/layerState.test.mjs +++ b/src/data/layerState.test.mjs @@ -202,8 +202,8 @@ function encode(state) { test('production registry is exact, canonical, and rejects incomplete contracts', async () => { assert.equal(validateLayerStateRegistry(), true); - assert.equal(REGISTERED_LAYER_IDS.length, 29); - assert.equal(new Set(REGISTERED_LAYER_IDS).size, 29); + assert.equal(REGISTERED_LAYER_IDS.length, 30); + assert.equal(new Set(REGISTERED_LAYER_IDS).size, 30); assert.ok(REGISTERED_LAYER_IDS.includes('transit')); assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort()); assert.deepEqual(LEGACY_LAYER_STATE_TOKENS, { @@ -242,7 +242,7 @@ test('production registry is exact, canonical, and rejects incomplete contracts' for (const [id, token] of Object.entries(LEGACY_LAYER_STATE_TOKENS)) { assert.equal(LAYER_STATE_TOKEN_RESERVATIONS[id], token); } - assert.equal(nextLayerStateToken(), '3'); + assert.equal(nextLayerStateToken(), '4'); assert.equal( nextLayerStateToken({ ...LAYER_STATE_TOKEN_RESERVATIONS, @@ -307,8 +307,8 @@ test('production registry is exact, canonical, and rejects incomplete contracts' assert.equal( validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, { ...LAYER_STATE_TOKEN_RESERVATIONS, - future: '3', - next: '4', + future: '4', + next: '5', }), true, ); @@ -318,7 +318,7 @@ test('production registry is exact, canonical, and rejects incomplete contracts' ...LAYER_STATE_TOKEN_RESERVATIONS, future: '00', }), - /next free token 3/, + /next free token 4/, ); const beforeLastDigit = { ...digitsExhausted }; delete beforeLastDigit['prior-9']; @@ -341,10 +341,10 @@ test('production registry is exact, canonical, and rejects incomplete contracts' assert.throws( () => validateLayerStateAllocations( - { ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '3' }, - { ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '3', competing: '3' }, + { ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '4' }, + { ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '4', competing: '4' }, ), - /next free token 4/, + /next free token 5/, ); assert.throws( () => validateLayerStateAllocations({ future: '00' }, { future: '01' }), @@ -2639,6 +2639,22 @@ test('the recent-imagery split is share-link only: never stored locally, and a s ); }); +test('drone restrictions uses digit 3 without colliding with fire perimeters', () => { + const decoded = decodeLayerStateParams(new URLSearchParams('v=2&l=3.2')); + assert.deepEqual(decoded.enabledLayerIds, [ + 'drone-airspace', + 'fire-perimeters', + ]); + assert.equal( + LAYER_STATE_REGISTRY.find(({ id }) => id === 'drone-airspace').token, + '3', + ); + assert.deepEqual( + decodeLayerStateParams(new URLSearchParams(encode(decoded))), + decoded, + ); +}); + test('fire perimeters uses digit 2 without colliding with wind or recent imagery', () => { const decoded = decodeLayerStateParams(new URLSearchParams('v=2&l=2.k.1')); assert.deepEqual(decoded.enabledLayerIds, [ diff --git a/src/data/layerStateTokenLedger.test.mjs b/src/data/layerStateTokenLedger.test.mjs index 5a02d85cf..56b2e1582 100644 --- a/src/data/layerStateTokenLedger.test.mjs +++ b/src/data/layerStateTokenLedger.test.mjs @@ -128,10 +128,14 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed { ...parseLayerStateTokenReservations(reservationRows) }, { ...LAYER_STATE_TOKEN_RESERVATIONS }, ); - assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 29); + assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 30); assert.deepEqual( { ...LAYER_STATE_TOKEN_RESERVATIONS }, - { ...LEGACY_LAYER_STATE_TOKENS, 'street-level': '0' }, + { + ...LEGACY_LAYER_STATE_TOKENS, + 'street-level': '0', + 'drone-airspace': '3', + }, ); assert.throws( () => parseLayerStateTokenReservations(reservationRows.slice(1)), @@ -151,9 +155,9 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed /Duplicate layer-state token reservation/, ); assert.equal( - parseLayerStateTokenReservations([...reservationRows, ['future', '3']]) + parseLayerStateTokenReservations([...reservationRows, ['future', '4']]) .future, - '3', + '4', ); assert.throws( () => @@ -171,14 +175,14 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed test('published retired digits and competing PRs advance the merge-time allocation', () => { withPublishedBase( - { rows: [...reservationRows, ['retired-layer', '3']] }, + { rows: [...reservationRows, ['retired-layer', '4']] }, (cwd) => { const published = readPublishedLayerStateReservations('HEAD', cwd); - assert.equal(nextLayerStateToken(published), '4'); + assert.equal(nextLayerStateToken(published), '5'); assert.equal( validateLayerStateAllocations(published, { ...published, - newcomer: '4', + newcomer: '5', }), true, ); @@ -186,15 +190,15 @@ test('published retired digits and competing PRs advance the merge-time allocati () => validateLayerStateAllocations(published, { ...published, - competing: '3', + competing: '4', }), - /next free token 4/, + /next free token 5/, ); assert.throws( () => validateLayerStateAllocations(published, { ...LAYER_STATE_TOKEN_RESERVATIONS, - newcomer: '3', + newcomer: '4', }), /changed or removed: retired-layer/, ); @@ -202,8 +206,8 @@ test('published retired digits and competing PRs advance the merge-time allocati ); }); -test('PR B manually replaces provisional 3 with 4 after PR A publishes 3', () => { - withPublishedBase({ rows: [...reservationRows, ['pr-a', '3']] }, (cwd) => { +test('PR B manually replaces provisional 4 with 5 after PR A publishes 4', () => { + withPublishedBase({ rows: [...reservationRows, ['pr-a', '4']] }, (cwd) => { mkdirSync(path.join(cwd, 'scripts')); const fixtureChecker = path.join( cwd, @@ -224,8 +228,8 @@ test('PR B manually replaces provisional 3 with 4 after PR A publishes 3', () => writeCodecFixture( cwd, - [...reservationRows, ['pr-b', '3']], - [{ id: 'pr-b', token: '3' }], + [...reservationRows, ['pr-b', '4']], + [{ id: 'pr-b', token: '4' }], ); const beforeRebase = runCheck(); assert.equal(beforeRebase.status, 1); @@ -233,10 +237,10 @@ test('PR B manually replaces provisional 3 with 4 after PR A publishes 3', () => writeCodecFixture( cwd, - [...reservationRows, ['pr-a', '3'], ['pr-b', '3']], + [...reservationRows, ['pr-a', '4'], ['pr-b', '4']], [ - { id: 'pr-a', token: '3' }, - { id: 'pr-b', token: '3' }, + { id: 'pr-a', token: '4' }, + { id: 'pr-b', token: '4' }, ], ); const staleRebase = runCheck(); @@ -244,16 +248,16 @@ test('PR B manually replaces provisional 3 with 4 after PR A publishes 3', () => assert.match(staleRebase.stderr, /Duplicate layer-state token reservation/); const published = readPublishedLayerStateReservations('HEAD', cwd); - assert.equal(nextLayerStateToken(published), '4'); - const validBaselineRows = [...reservationRows, ['pr-a', '3']]; - writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '3' }]); + assert.equal(nextLayerStateToken(published), '5'); + const validBaselineRows = [...reservationRows, ['pr-a', '4']]; + writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '4' }]); const helper = spawnSync( process.execPath, [realpathSync(fixtureNext), 'pr-b'], { cwd, encoding: 'utf8' }, ); assert.equal(helper.status, 0, helper.stderr); - assert.match(helper.stdout, /pr-b: 4/); + assert.match(helper.stdout, /pr-b: 5/); assert.deepEqual( JSON.parse(readFileSync(path.join(cwd, LEDGER_PATH), 'utf8')), validBaselineRows, @@ -261,20 +265,20 @@ test('PR B manually replaces provisional 3 with 4 after PR A publishes 3', () => ); writeCodecFixture( cwd, - [...reservationRows, ['pr-a', '3'], ['pr-b', '4']], + [...reservationRows, ['pr-a', '4'], ['pr-b', '5']], [ - { id: 'pr-a', token: '3' }, - { id: 'pr-b', token: '4' }, + { id: 'pr-a', token: '4' }, + { id: 'pr-b', token: '5' }, ], ); const corrected = runCheck(); assert.equal(corrected.status, 0, corrected.stderr); - assert.match(corrected.stdout, /30 published, 1 new/); + assert.match(corrected.stdout, /31 published, 1 new/); }); }); test('allocation batches cross the last digit and base-36 pair boundaries in order', () => { - const digitRows = [...'3456789'].map((digit) => [`retired-${digit}`, digit]); + const digitRows = [...'456789'].map((digit) => [`retired-${digit}`, digit]); const allDigits = parseLayerStateTokenReservations([ ...reservationRows, ...digitRows, @@ -326,7 +330,7 @@ test('allocation batches cross the last digit and base-36 pair boundaries in ord test('an isolated valid two-character fixture round-trips an l field beyond the old 64-character cap', async () => { const cwd = mkdtempSync(path.join(tmpdir(), 'gev-layer-token-width-')); try { - const priorDigits = [...'3456789'].map((digit) => [ + const priorDigits = [...'456789'].map((digit) => [ `qa-prior-digit-${digit}`, digit, ]); @@ -363,7 +367,7 @@ test('an isolated valid two-character fixture round-trips an l field beyond the const params = new URLSearchParams([['v', '2']]); codec.encodeLayerStateParams(params, state); assert.ok(params.get('l').length > 64, 'fixture must cross the old cap'); - assert.equal(params.get('l').length, 69); + assert.equal(params.get('l').length, 71); const restored = codec.decodeLayerStateParams(params); assert.deepEqual(restored?.enabledLayerIds, expectedLayerIds); assert.deepEqual(restored?.options, state.options); @@ -381,7 +385,7 @@ test('checker reads a complete future base ledger and rejects retired-token reus }, (cwd) => { const published = readPublishedLayerStateReservations('HEAD', cwd); - assert.equal(Object.keys(published).length, 30); + assert.equal(Object.keys(published).length, 31); assert.equal(published['retired-layer'], '00'); assert.throws( () => diff --git a/src/data/layerStateTokenReservations.json b/src/data/layerStateTokenReservations.json index eb49eed27..6024f5248 100644 --- a/src/data/layerStateTokenReservations.json +++ b/src/data/layerStateTokenReservations.json @@ -6,6 +6,7 @@ ["bikeshare", "b"], ["cctv", "c"], ["directions", "n"], + ["drone-airspace", "3"], ["earthquakes", "e"], ["fire-perimeters", "2"], ["flights", "f"], diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs index 49711163f..cd8efce77 100644 --- a/src/firstRunExperience.test.mjs +++ b/src/firstRunExperience.test.mjs @@ -674,10 +674,11 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping // schema, which may not carry top-level anyOf), and Contacts requested-radius // list descriptions; the missions still ride existing tools. // Street Level adds toggle enum values and the generated alias hint. - assert.equal(block.length, 29802, 'serialized tool schema length drifted'); + // Drone Restrictions adds toggle/query enum values and spoken aliases. + assert.equal(block.length, 29988, 'serialized tool schema length drifted'); assert.equal( crypto.createHash('sha256').update(block).digest('hex'), - 'b316ca2e67848eb565b6847ac044a19d1465d8ef8252a5dfbd7d947a2a36d5ed', + 'e3605286e8b91c07f29b687cb3cf0e9400fef92225fbc7efeaa35451274df571', 'the first-run missions must ride EXISTING tools: no schema edit, no cache bust', ); const instructions = fs.readFileSync(new URL('../server/providers/openai/instructions.js', import.meta.url), 'utf8'); diff --git a/src/layers/droneAirspace/cards.js b/src/layers/droneAirspace/cards.js new file mode 100644 index 000000000..eb6cc6433 --- /dev/null +++ b/src/layers/droneAirspace/cards.js @@ -0,0 +1,93 @@ +/** Card model for one selected special-use airspace zone. Pure — no Cesium types. */ + +export const DRONE_AIRSPACE_OVERLAY_SOURCE_ID = 'drone-airspace'; + +/** Fill/line CSS color for a zone by TYPE_CODE. */ +export function droneAirspaceAccent(typeCode) { + return typeCode === 'P' ? '#ff3b30' : '#ff7a00'; +} + +/** Shoelace area (degree², sign dropped) — relative sizes only. */ +function ringArea(ring) { + let sum = 0; + for (let i = 0; i < ring.length - 1; i++) { + const [x1, y1] = ring[i]; + const [x2, y2] = ring[i + 1]; + sum += x1 * y2 - x2 * y1; + } + return Math.abs(sum / 2); +} + +/** + * Anchor point for the zone card: the centroid of the largest polygon's + * outer ring. Degrees, so callers own the Cartesian conversion. + * @param {Array>>} polygons + * @returns {{lon: number, lat: number}} + */ +export function droneAirspaceAnchorDegrees(polygons) { + let best = null; + let bestArea = -1; + for (const rings of polygons) { + const outer = rings[0]; + const area = ringArea(outer); + if (area > bestArea) { + bestArea = area; + best = outer; + } + } + let lon = 0; + let lat = 0; + const count = best.length - 1; + for (let i = 0; i < count; i++) { + lon += best[i][0]; + lat += best[i][1]; + } + return { lon: lon / count, lat: lat / count }; +} + +function altitudeLine(row) { + const lower = row.lowerDesc; + const upper = row.upperDesc; + if (lower && upper) return `${lower} – ${upper}`; + if (upper) return `up to ${upper}`; + if (lower) return `from ${lower}`; + return null; +} + +function placeLine(row) { + if (row.city && row.state) return `${row.city}, ${row.state}`; + return row.city || row.state || null; +} + +/** + * Build the overlay-host entry for one selected zone. The caller supplies + * `position` (Cartesian) separately — this model stays JSON-safe for tests. + * @param {Object} row - Normalized zone row (see records.js). + * @returns {Object} World-overlay entry without `position`. + */ +export function buildDroneAirspaceCard(row) { + const facts = [row.type || row.typeCode].filter(Boolean); + const altitude = altitudeLine(row); + if (altitude) facts.push(altitude); + if (row.timesOfUse) facts.push(row.timesOfUse); + + const details = [facts.join(' · ')]; + const place = placeLine(row); + if (place) details.push(place); + if (row.agency) details.push(row.agency); + details.push('Informational only · not for flight planning'); + + const title = `${(row.type || 'SUA').toUpperCase()} · ${row.name || 'Unnamed zone'}`; + return { + id: `drone-airspace-card:${row.stableId}`, + selected: true, + interactive: false, + title, + details, + accent: droneAirspaceAccent(row.typeCode), + priority: Number.MAX_SAFE_INTEGER, + gapPx: 15, + verticalOnly: true, + placement: 'above', + }; +} diff --git a/src/layers/droneAirspace/cards.test.mjs b/src/layers/droneAirspace/cards.test.mjs new file mode 100644 index 000000000..e8d58c37c --- /dev/null +++ b/src/layers/droneAirspace/cards.test.mjs @@ -0,0 +1,74 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + droneAirspaceAnchorDegrees, + droneAirspaceAccent, + buildDroneAirspaceCard, +} from './cards.js'; + +const square = [ + [-77.2, 38.8], + [-77.0, 38.8], + [-77.0, 39.0], + [-77.2, 39.0], + [-77.2, 38.8], +]; +const tiny = [ + [-76.01, 38.0], + [-76.0, 38.0], + [-76.0, 38.01], + [-76.01, 38.0], +]; + +test('the card anchors at the centroid of the largest polygon', () => { + const anchor = droneAirspaceAnchorDegrees([[tiny], [square]]); + assert.ok(Math.abs(anchor.lon - -77.1) < 1e-9); + assert.ok(Math.abs(anchor.lat - 38.9) < 1e-9); +}); + +test('a prohibited zone card names the type, altitudes and the disclaimer', () => { + const card = buildDroneAirspaceCard({ + stableId: '310', + name: 'P-56', + typeCode: 'P', + type: 'Prohibited', + lowerDesc: 'SFC', + upperDesc: 'TNI', + timesOfUse: 'CONTINUOUS', + agency: 'WASHINGTON CENTER', + city: 'WASHINGTON', + state: 'DC', + }); + assert.equal(card.id, 'drone-airspace-card:310'); + assert.equal(card.title, 'PROHIBITED · P-56'); + assert.deepEqual(card.details, [ + 'Prohibited · SFC – TNI · CONTINUOUS', + 'WASHINGTON, DC', + 'WASHINGTON CENTER', + 'Informational only · not for flight planning', + ]); + assert.equal(card.accent, droneAirspaceAccent('P')); + assert.equal(card.interactive, false); + assert.equal(card.selected, true); +}); + +test('missing attributes degrade to available facts instead of placeholders', () => { + const card = buildDroneAirspaceCard({ + stableId: 'x', + name: null, + typeCode: 'R', + type: 'Restricted', + lowerDesc: null, + upperDesc: null, + timesOfUse: null, + agency: null, + city: null, + state: null, + }); + assert.equal(card.title, 'RESTRICTED · Unnamed zone'); + assert.deepEqual(card.details, [ + 'Restricted', + 'Informational only · not for flight planning', + ]); + assert.equal(card.accent, droneAirspaceAccent('R')); +}); diff --git a/src/layers/droneAirspace/index.js b/src/layers/droneAirspace/index.js new file mode 100644 index 000000000..d7b4a40c8 --- /dev/null +++ b/src/layers/droneAirspace/index.js @@ -0,0 +1,326 @@ +import * as Cesium from 'cesium'; +import { + DRONE_AIRSPACE_OVERLAY_SOURCE_ID, + droneAirspaceAnchorDegrees, + buildDroneAirspaceCard, + droneAirspaceAccent, +} from './cards.js'; +export { normalizeDroneAirspaceSnapshot } from './records.js'; +export { createDroneAirspaceSource } from './source.js'; +export * from './cards.js'; + +/** Fill/line color for a zone by TYPE_CODE. */ +export function droneAirspaceColor(typeCode) { + return Cesium.Color.fromCssColorString(droneAirspaceAccent(typeCode)); +} + +const ringPositions = (ring) => + ring.map(([lon, lat]) => Cesium.Cartesian3.fromDegrees(lon, lat)); + +const PICK_PREFIX = 'drone-airspace:'; +const CARD_HOST_OPTIONS = Object.freeze({ + cohortLimit: 1, + collisionCapacity: 1, + moving: false, +}); + +/** Own one drone-restriction display, its refresh lifecycle, and click selection. */ +export function createDroneAirspaceLayer({ + source, + overlayHost = null, + screenSpaceEventHandlerFactory = null, + picking = null, + pointer = null, +} = {}) { + if (typeof source?.getSnapshot !== 'function') + throw new TypeError('Drone airspace requires a snapshot source'); + let _viewer = null; + let _request = null; + let _snapshotSignature = null; + let _dataSource = null; + let _count = 0; + let _lastUpdate = null; + let _lastError = null; + let _enabled = false; + let _clickHandler = null; + let _selectedId = null; + let _selectedCardId = null; + /** @type {Map} */ + const _rowById = new Map(); + + const canSelect = () => + overlayHost && screenSpaceEventHandlerFactory && picking; + + function publishSelectedCard() { + if (!canSelect()) return; + const row = _selectedId ? _rowById.get(_selectedId) : null; + if (!row) { + _selectedId = null; + _selectedCardId = null; + overlayHost.setEntries( + DRONE_AIRSPACE_OVERLAY_SOURCE_ID, + [], + CARD_HOST_OPTIONS, + ); + return; + } + const card = { + ...buildDroneAirspaceCard(row), + position: Cesium.Cartesian3.fromDegrees(row.anchor.lon, row.anchor.lat), + }; + _selectedCardId = card.id; + overlayHost.setEntries( + DRONE_AIRSPACE_OVERLAY_SOURCE_ID, + [card], + CARD_HOST_OPTIONS, + ); + } + + /** Resolve a scene pick to one of this layer's zone ids, or null. */ + function pickedZoneId(picked) { + const pickId = picking.resolvePickId(picked); + if (typeof pickId !== 'string' || !pickId.startsWith(PICK_PREFIX)) + return null; + const zoneId = pickId.slice(PICK_PREFIX.length).split(':')[0]; + return _rowById.has(zoneId) ? zoneId : null; + } + + function installClickHandler() { + if (!canSelect() || _clickHandler || !_viewer) return; + // Deliberately NOT registered in the pick-ownership registry: sibling + // layers yield to owned picks before their own overlay-card hit-tests, + // so claiming these large ground polygons would make FIRMS/vessel/CCTV + // cards inert anywhere over a zone. Unowned, our picks read as + // "empty space" to siblings — the same contract as fire perimeters. + _clickHandler = screenSpaceEventHandlerFactory(_viewer); + _clickHandler.setInputAction((click) => { + if (pointer && !pointer.isPointerFree()) return; + const cardHit = overlayHost.hitTest?.( + click.position?.x, + click.position?.y, + { sourceId: DRONE_AIRSPACE_OVERLAY_SOURCE_ID }, + ); + if (cardHit && cardHit.entryId === _selectedCardId) return; + const picked = _viewer.scene.pick(click.position); + const zoneId = picked ? pickedZoneId(picked) : null; + if (zoneId) { + _selectedId = zoneId; + publishSelectedCard(); + return; + } + if (picked) { + const pickId = picking.resolvePickId(picked); + if (pickId && picking.isOwnedByOtherLayer(layer.id, pickId)) return; + } + if (_selectedId) { + _selectedId = null; + publishSelectedCard(); + } + }, Cesium.ScreenSpaceEventType.LEFT_CLICK); + } + + function removeClickHandler() { + if (_clickHandler) { + _clickHandler.destroy(); + _clickHandler = null; + } + } + + function clearSelection() { + _selectedId = null; + _selectedCardId = null; + if (overlayHost) { + overlayHost.clearSource(DRONE_AIRSPACE_OVERLAY_SOURCE_ID); + overlayHost.setVisible?.(DRONE_AIRSPACE_OVERLAY_SOURCE_ID, false); + } + } + + const layer = { + id: 'drone-airspace', + name: 'Drone Restrictions', + icon: '🚫', + source: 'FAA SUA', + updateInterval: 3600000, + + init(viewer) { + if (_viewer) + throw new Error('Drone airspace layer is already initialized'); + _viewer = viewer; + _dataSource = new Cesium.CustomDataSource('drone-airspace'); + _dataSource.show = false; + viewer.dataSources.add(_dataSource); + _count = 0; + _lastUpdate = null; + _lastError = null; + _enabled = false; + console.log('[Data:DroneAirspace] Initialized'); + }, + + enable() { + _enabled = true; + if (_dataSource) _dataSource.show = true; + overlayHost?.setVisible?.(DRONE_AIRSPACE_OVERLAY_SOURCE_ID, true); + installClickHandler(); + }, + + disable() { + _request?.abort(); + _request = null; + _enabled = false; + if (_dataSource) _dataSource.show = false; + removeClickHandler(); + clearSelection(); + }, + + async update() { + if (!_enabled || !_dataSource) return false; + _request?.abort(); + const request = new AbortController(); + _request = request; + try { + const rows = await source.getSnapshot({ signal: request.signal }); + if (request.signal.aborted || _request !== request || !_enabled) + return false; + + const signature = JSON.stringify( + rows + .map(({ polygons, ...facts }) => facts) + .sort((a, b) => a.stableId.localeCompare(b.stableId)), + ); + if (signature === _snapshotSignature) { + if (_selectedId) publishSelectedCard(); + _lastUpdate = Date.now(); + _lastError = null; + return true; + } + const nextEntities = []; + _rowById.clear(); + for (const row of rows) { + const color = droneAirspaceColor(row.typeCode); + for (const [index, rings] of row.polygons.entries()) { + const [outer, ...holes] = rings; + const outerPositions = ringPositions(outer); + nextEntities.push( + new Cesium.Entity({ + id: `drone-airspace:${row.stableId}:${index}`, + polygon: { + hierarchy: new Cesium.PolygonHierarchy( + outerPositions, + holes.map( + (hole) => + new Cesium.PolygonHierarchy(ringPositions(hole)), + ), + ), + material: new Cesium.ColorMaterialProperty( + color.withAlpha(0.25), + ), + }, + polyline: { + positions: outerPositions, + clampToGround: true, + width: 2, + material: new Cesium.ColorMaterialProperty( + color.withAlpha(0.9), + ), + }, + }), + ); + } + const { polygons, ...facts } = row; + _rowById.set(row.stableId, { + ...facts, + anchor: droneAirspaceAnchorDegrees(polygons), + }); + } + + _dataSource.entities.removeAll(); + for (const entity of nextEntities) _dataSource.entities.add(entity); + _snapshotSignature = signature; + if (_selectedId) publishSelectedCard(); + _count = rows.length; + _lastUpdate = Date.now(); + _lastError = null; + console.log( + `[Data:DroneAirspace] Updated: ${_count} zones, ${nextEntities.length} polygons`, + ); + return true; + } catch (e) { + if (request.signal.aborted || _request !== request || !_enabled) + return false; + console.warn('[Data:DroneAirspace] Fetch error:', e); + _lastError = e?.message || 'Drone airspace source unavailable'; + return false; + } finally { + if (_request === request) _request = null; + } + }, + + destroy(viewer = _viewer) { + _request?.abort(); + _request = null; + removeClickHandler(); + clearSelection(); + _rowById.clear(); + _snapshotSignature = null; + _viewer = null; + _enabled = false; + if (_dataSource) { + viewer.dataSources.remove(_dataSource, true); + _dataSource = null; + } + _count = 0; + _lastUpdate = null; + _lastError = null; + }, + + getAnalystRecords(maxCount = 2000) { + if (!_dataSource || !_dataSource.show) return []; + const limit = Number.isFinite(maxCount) + ? Math.max(1, Math.floor(maxCount)) + : 2000; + const result = []; + for (const row of _rowById.values()) { + if (result.length >= limit) break; + const { anchor, stableId, ...facts } = row; + result.push({ + id: stableId, + ...facts, + lat: anchor.lat, + lon: anchor.lon, + }); + } + return result; + }, + + getRowControls() { + const bands = [ + { label: 'Prohibited (P)', color: droneAirspaceAccent('P') }, + { label: 'Restricted (R)', color: droneAirspaceAccent('R') }, + ]; + return { + chips: [], + legend: bands.map((band, index) => ({ + ...band, + count: [..._rowById.values()].filter( + (row) => droneAirspaceAccent(row.typeCode) === band.color, + ).length, + ...(index === 0 + ? { + blurb: + 'FAA special-use airspace. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).', + } + : {}), + })), + }; + }, + + getStats() { + return { + count: _count, + lastUpdate: _lastUpdate, + error: _lastError, + }; + }, + }; + return layer; +} diff --git a/src/layers/droneAirspace/ownership.test.mjs b/src/layers/droneAirspace/ownership.test.mjs new file mode 100644 index 000000000..ae10184f2 --- /dev/null +++ b/src/layers/droneAirspace/ownership.test.mjs @@ -0,0 +1,161 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createDroneAirspaceLayer } from './index.js'; + +function harness(source, { pick = () => null, cardHit = () => null } = {}) { + const sources = []; + const overlay = { entries: new Map(), visible: null }; + const clicks = { handler: null, destroyed: 0 }; + const owners = new Map(); + const viewer = { + scene: { pick }, + dataSources: { + add(value) { + sources.push(value); + }, + remove(value) { + sources.splice(sources.indexOf(value), 1); + }, + }, + }; + const layer = createDroneAirspaceLayer({ + source, + overlayHost: { + setEntries(sourceId, entries) { + overlay.entries.set(sourceId, entries); + }, + setVisible(sourceId, visible) { + overlay.visible = visible; + }, + clearSource(sourceId) { + overlay.entries.delete(sourceId); + }, + hitTest: (x, y, options) => cardHit(x, y, options), + }, + screenSpaceEventHandlerFactory: () => ({ + setInputAction(callback) { + clicks.handler = callback; + }, + destroy() { + clicks.destroyed += 1; + clicks.handler = null; + }, + }), + picking: { + resolvePickId: (picked) => picked?.id ?? null, + isOwnedByOtherLayer: (layerId, pickedId) => + String(pickedId).startsWith('other-layer:'), + registerPickOwner: (layerId, predicate) => owners.set(layerId, predicate), + unregisterPickOwner: (layerId) => owners.delete(layerId), + }, + pointer: { isPointerFree: () => true }, + }); + layer.init(viewer); + layer.enable(viewer); + return { layer, viewer, sources, overlay, clicks, owners }; +} + +const ring = [ + [-77.04, 38.89], + [-77.03, 38.89], + [-77.03, 38.9], + [-77.04, 38.89], +]; +const row = { + stableId: '310', + name: 'P-56', + typeCode: 'P', + type: 'Prohibited', + lowerDesc: 'SFC', + upperDesc: 'TNI', + timesOfUse: 'CONTINUOUS', + agency: null, + city: 'WASHINGTON', + state: 'DC', + polygons: [[ring]], +}; + +test('each zone polygon renders as one filled entity with its outline', async () => { + const h = harness({ + getSnapshot: async () => [ + row, + { ...row, stableId: '400', typeCode: 'R', polygons: [[ring], [ring]] }, + ], + }); + assert.equal(await h.layer.update(h.viewer), true); + const entities = h.sources[0].entities.values; + assert.equal(entities.length, 3); + assert.equal(entities[0].id, 'drone-airspace:310:0'); + assert.ok(entities[0].polygon, 'zone fill missing'); + assert.ok(entities[0].polyline, 'zone outline missing'); + assert.equal(h.layer.getStats().count, 2); +}); + +test('clicking a zone publishes its card; empty space clears it', async () => { + let pickResult = null; + const h = harness( + { getSnapshot: async () => [row] }, + { pick: () => pickResult }, + ); + await h.layer.update(h.viewer); + assert.equal(typeof h.clicks.handler, 'function', 'click handler missing'); + assert.equal(h.owners.size, 0); + + pickResult = { id: 'drone-airspace:310:0' }; + h.clicks.handler({ position: { x: 10, y: 10 } }); + const entries = h.overlay.entries.get('drone-airspace'); + assert.equal(entries.length, 1); + assert.equal(entries[0].id, 'drone-airspace-card:310'); + assert.equal(entries[0].title, 'PROHIBITED · P-56'); + assert.ok(entries[0].position, 'card must carry a world anchor'); + + pickResult = { id: 'other-layer:aircraft-1' }; + h.clicks.handler({ position: { x: 10, y: 10 } }); + assert.equal( + h.overlay.entries.get('drone-airspace')?.length, + 1, + 'sibling-layer picks must not clear the selection', + ); + + pickResult = null; + h.clicks.handler({ position: { x: 10, y: 10 } }); + assert.equal(h.overlay.entries.get('drone-airspace')?.length ?? 0, 0); +}); + +test('late refresh cannot publish after disable or destroy', async () => { + for (const action of ['disable', 'destroy']) { + let resolve, signal; + const h = harness({ + getSnapshot(options) { + signal = options.signal; + return new Promise((done) => { + resolve = done; + }); + }, + }); + const pending = h.layer.update(h.viewer); + h.layer[action](h.viewer); + assert.equal(signal.aborted, true); + if (action === 'disable') h.layer.enable(h.viewer); + resolve([row]); + assert.equal(await pending, false); + assert.equal(h.layer.getStats().count, 0); + h.layer.destroy(h.viewer); + } +}); + +test('the row legend names prohibited and restricted with the disclaimer', async () => { + const h = harness({ + getSnapshot: async () => [ + row, + { ...row, stableId: '400', typeCode: 'R', type: 'Restricted' }, + ], + }); + await h.layer.update(h.viewer); + const legend = h.layer.getRowControls().legend; + assert.equal(legend[0].label, 'Prohibited (P)'); + assert.equal(legend[1].label, 'Restricted (R)'); + assert.equal(legend[0].count, 1); + assert.equal(legend[1].count, 1); + assert.match(legend[0].blurb, /not a substitute/); +}); diff --git a/src/layers/droneAirspace/records.js b/src/layers/droneAirspace/records.js new file mode 100644 index 000000000..c1d5f73e2 --- /dev/null +++ b/src/layers/droneAirspace/records.js @@ -0,0 +1,97 @@ +/** + * Normalize an FAA Special Use Airspace feed, skipping individually invalid + * features. Only a payload that is not a feature collection at all rejects + * the whole snapshot: a single degenerate polygon must never blank the layer. + */ + +const TYPE_LABELS = Object.freeze({ + P: 'Prohibited', + R: 'Restricted', +}); + +const finiteOrNull = (value) => (Number.isFinite(value) ? value : null); +const textOrNull = (value) => { + if (typeof value !== 'string') return null; + const text = value.trim(); + return text ? text : null; +}; + +function validRing(ring) { + if (!Array.isArray(ring) || ring.length < 4) return false; + for (const position of ring) { + if (!Array.isArray(position) || position.length < 2) return false; + const [lon, lat] = position; + if (!Number.isFinite(lon) || Math.abs(lon) > 180) return false; + if (!Number.isFinite(lat) || Math.abs(lat) > 90) return false; + } + return true; +} + +/** Normalize Polygon/MultiPolygon geometry to an array of polygons (each an + * array of rings). Returns null on malformed geometry, [] when empty. */ +function normalizePolygons(geometry) { + if (!geometry || typeof geometry !== 'object') return null; + let polygons; + if (geometry.type === 'Polygon') polygons = [geometry.coordinates]; + else if (geometry.type === 'MultiPolygon') polygons = geometry.coordinates; + else return null; + if (!Array.isArray(polygons)) return null; + const result = []; + for (const rings of polygons) { + if (!Array.isArray(rings)) return null; + if (!rings.length) continue; + if (!rings.every(validRing)) return null; + result.push(rings); + } + return result; +} + +/** Human label for a TYPE_CODE, or null when the code is not in the pack. */ +export function droneAirspaceTypeLabel(typeCode) { + return TYPE_LABELS[typeCode] || null; +} + +export function normalizeDroneAirspaceSnapshot(geojson) { + if (!Array.isArray(geojson?.features)) return null; + const rows = []; + const ids = new Set(); + for (const feature of geojson.features) { + const properties = feature?.properties; + if ( + !properties || + typeof properties !== 'object' || + Array.isArray(properties) + ) + continue; + const typeCode = textOrNull(properties.TYPE_CODE); + const type = droneAirspaceTypeLabel(typeCode); + if (!type) continue; + const polygons = normalizePolygons(feature.geometry); + if (polygons === null || !polygons.length) continue; + const objectId = finiteOrNull(Number(properties.OBJECTID)); + const featureId = + feature.id == null || feature.id === '' ? null : String(feature.id); + const stableId = + objectId != null + ? String(objectId) + : featureId && !ids.has(featureId) + ? featureId + : null; + if (stableId == null || ids.has(stableId)) continue; + ids.add(stableId); + rows.push({ + stableId, + name: textOrNull(properties.NAME), + typeCode, + type, + lowerDesc: textOrNull(properties.LOWER_DESC), + upperDesc: textOrNull(properties.UPPER_DESC), + timesOfUse: textOrNull(properties.TIMESOFUSE), + agency: textOrNull(properties.CONT_AGENT), + city: textOrNull(properties.CITY), + state: textOrNull(properties.STATE), + polygons, + }); + } + return rows; +} diff --git a/src/layers/droneAirspace/records.test.mjs b/src/layers/droneAirspace/records.test.mjs new file mode 100644 index 000000000..4c8c1a215 --- /dev/null +++ b/src/layers/droneAirspace/records.test.mjs @@ -0,0 +1,95 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + droneAirspaceTypeLabel, + normalizeDroneAirspaceSnapshot, +} from './records.js'; + +const ring = [ + [-77.04, 38.89], + [-77.03, 38.89], + [-77.03, 38.9], + [-77.04, 38.89], +]; + +function feature(overrides = {}) { + return { + id: 310, + geometry: { type: 'Polygon', coordinates: [ring] }, + properties: { + NAME: 'P-56', + TYPE_CODE: 'P', + UPPER_DESC: 'TNI', + LOWER_DESC: 'SFC', + TIMESOFUSE: 'CONTINUOUS', + CONT_AGENT: 'WASHINGTON CENTER', + CITY: 'WASHINGTON', + STATE: 'DC', + OBJECTID: 310, + ...overrides.properties, + }, + ...overrides, + }; +} + +test('a valid snapshot yields one row per prohibited or restricted zone', () => { + const rows = normalizeDroneAirspaceSnapshot({ + features: [ + feature(), + feature({ + id: 400, + properties: { + NAME: 'R-2508', + TYPE_CODE: 'R', + OBJECTID: 400, + }, + }), + ], + }); + assert.equal(rows.length, 2); + assert.deepEqual(rows[0], { + stableId: '310', + name: 'P-56', + typeCode: 'P', + type: 'Prohibited', + lowerDesc: 'SFC', + upperDesc: 'TNI', + timesOfUse: 'CONTINUOUS', + agency: 'WASHINGTON CENTER', + city: 'WASHINGTON', + state: 'DC', + polygons: [[ring]], + }); + assert.equal(rows[1].type, 'Restricted'); + assert.equal(rows[1].typeCode, 'R'); +}); + +test('MOA, warning and advisory codes are dropped, as are bad geometries', () => { + const rows = normalizeDroneAirspaceSnapshot({ + features: [ + feature({ properties: { TYPE_CODE: 'MOA', OBJECTID: 1 } }), + feature({ properties: { TYPE_CODE: 'W', OBJECTID: 2 } }), + feature({ properties: { TYPE_CODE: 'A', OBJECTID: 3 } }), + feature({ geometry: null, properties: { OBJECTID: 4 } }), + feature({ + geometry: { type: 'Point', coordinates: [-77, 38] }, + properties: { OBJECTID: 5 }, + }), + feature(), + ], + }); + assert.equal(rows.length, 1); + assert.equal(rows[0].name, 'P-56'); +}); + +test('duplicate ids and a missing collection reject or skip as documented', () => { + const dup = normalizeDroneAirspaceSnapshot({ + features: [feature(), feature({ id: 311 })], + }); + assert.equal(dup.length, 1); + assert.equal(normalizeDroneAirspaceSnapshot({}), null); + assert.equal(normalizeDroneAirspaceSnapshot(null), null); + assert.equal(droneAirspaceTypeLabel('P'), 'Prohibited'); + assert.equal(droneAirspaceTypeLabel('R'), 'Restricted'); + assert.equal(droneAirspaceTypeLabel('MOA'), null); +}); diff --git a/src/layers/droneAirspace/source.js b/src/layers/droneAirspace/source.js new file mode 100644 index 000000000..17e67b3ae --- /dev/null +++ b/src/layers/droneAirspace/source.js @@ -0,0 +1,23 @@ +import { readResponseJsonCapped } from '../../sources/httpBody.js'; + +/** Request a normalized snapshot through the bounded, same-origin FAA proxy. */ +export function createDroneAirspaceSource({ + fetchImpl = (...args) => globalThis.fetch(...args), +} = {}) { + return { + async getSnapshot({ signal } = {}) { + signal?.throwIfAborted(); + const response = await fetchImpl('/api/drone-airspace', { signal }); + if (!response.ok) throw new Error(`FAA SUA HTTP ${response.status}`); + const payload = await readResponseJsonCapped( + response, + 80 * 1024 * 1024, + signal, + ); + signal?.throwIfAborted(); + if (!Array.isArray(payload?.rows)) + throw new Error('Malformed drone airspace snapshot'); + return payload.rows; + }, + }; +} diff --git a/src/layers/droneAirspace/source.test.mjs b/src/layers/droneAirspace/source.test.mjs new file mode 100644 index 000000000..160fb1789 --- /dev/null +++ b/src/layers/droneAirspace/source.test.mjs @@ -0,0 +1,105 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createDroneAirspaceSource } from './source.js'; +import { normalizeDroneAirspaceSnapshot } from './records.js'; +import { droneAirspaceProxy } from '../../../server/providers/droneAirspace.js'; + +const ring = [ + [-77.04, 38.89], + [-77.03, 38.89], + [-77.03, 38.9], + [-77.04, 38.89], +]; +const validPayload = { + features: [ + { + id: 310, + geometry: { type: 'Polygon', coordinates: [ring] }, + properties: { + NAME: 'P-56', + TYPE_CODE: 'P', + OBJECTID: 310, + }, + }, + ], +}; + +test('a successful response yields normalized zone rows', async () => { + let requested; + const source = createDroneAirspaceSource({ + fetchImpl: async (url) => { + requested = String(url); + return Response.json({ + rows: normalizeDroneAirspaceSnapshot(validPayload), + }); + }, + }); + const rows = await source.getSnapshot(); + assert.equal(rows.length, 1); + assert.equal(rows[0].stableId, '310'); + assert.equal(requested, '/api/drone-airspace'); +}); + +test('a truncated response pages until the feed is complete', async () => { + const pageRing = (id) => ({ + id, + geometry: { type: 'Polygon', coordinates: [ring] }, + properties: { NAME: `P-${id}`, TYPE_CODE: 'P', OBJECTID: id }, + }); + const requests = []; + let handler; + const plugin = droneAirspaceProxy({ + fetchImpl: async (url) => { + requests.push(new URL(String(url)).searchParams.get('resultOffset')); + const page = requests.length; + return Response.json({ + features: [pageRing(page)], + properties: { exceededTransferLimit: page < 3 }, + }); + }, + }); + plugin.configureServer({ + middlewares: { + use: (_path, callback) => { + handler = callback; + }, + }, + }); + let payload; + await handler( + { url: '/', method: 'GET' }, + { + writeHead(status) { + assert.equal(status, 200); + }, + end(body) { + payload = JSON.parse(body); + }, + }, + ); + const rows = payload.rows; + assert.deepEqual( + rows.map((row) => row.stableId), + ['1', '2', '3'], + ); + assert.deepEqual(requests, [null, '1', '2']); +}); + +test('an upstream failure surfaces its HTTP status', async () => { + const source = createDroneAirspaceSource({ + fetchImpl: async () => ({ ok: false, status: 503 }), + }); + await assert.rejects(source.getSnapshot(), /FAA SUA HTTP 503/); +}); + +test('a malformed successful response is never accepted as an empty snapshot', async () => { + for (const payload of [{}, { rows: null }, { rows: {} }]) { + const source = createDroneAirspaceSource({ + fetchImpl: async () => Response.json(payload), + }); + await assert.rejects( + source.getSnapshot(), + /Malformed drone airspace snapshot/, + ); + } +}); diff --git a/src/sources/reference.js b/src/sources/reference.js index 4bdf7e9ac..302e15303 100644 --- a/src/sources/reference.js +++ b/src/sources/reference.js @@ -1,5 +1,6 @@ import { createUsgsEarthquakeSource } from '../layers/earthquakes/source.js'; import { createWfigsPerimeterSource } from '../layers/perimeters/source.js'; +import { createDroneAirspaceSource } from '../layers/droneAirspace/source.js'; import { createBundledCableSource } from '../layers/submarineCables/bundledSource.js'; /** Construct the existing reference feeds independently of application setup. */ @@ -7,6 +8,7 @@ export function createReferenceSources() { return { earthquakes: createUsgsEarthquakeSource(), 'fire-perimeters': createWfigsPerimeterSource(), + 'drone-airspace': createDroneAirspaceSource(), cables: createBundledCableSource(), }; } diff --git a/src/sources/reference.test.mjs b/src/sources/reference.test.mjs index 902a72c83..735b5c72e 100644 --- a/src/sources/reference.test.mjs +++ b/src/sources/reference.test.mjs @@ -15,13 +15,16 @@ test('reference factories retain compatibility without starting acquisition or s assert.deepEqual(Object.keys(first), [ 'earthquakes', 'fire-perimeters', + 'drone-airspace', 'cables', ]); assert.notEqual(first.earthquakes, second.earthquakes); assert.notEqual(first['fire-perimeters'], second['fire-perimeters']); + assert.notEqual(first['drone-airspace'], second['drone-airspace']); assert.notEqual(first.cables, second.cables); assert.equal(typeof first.earthquakes.getSnapshot, 'function'); assert.equal(typeof first['fire-perimeters'].getSnapshot, 'function'); + assert.equal(typeof first['drone-airspace'].getSnapshot, 'function'); assert.equal(typeof first.cables.fetch, 'function'); assert.equal(requests, 0); }); diff --git a/src/tooling/previewServing.test.mjs b/src/tooling/previewServing.test.mjs index d6eb80e20..b88b4e1f8 100644 --- a/src/tooling/previewServing.test.mjs +++ b/src/tooling/previewServing.test.mjs @@ -10,6 +10,7 @@ import { makeFixtureRoot } from './fixtureRoot.mjs'; test('data providers have both hooks; credential editing stays development-only', () => { const plugins = localProviderPlugins(); assert.ok(plugins.some(({ name }) => name === 'fire-perimeters')); + assert.ok(plugins.some(({ name }) => name === 'drone-airspace')); for (const plugin of plugins) { if (plugin.name === 'gev-key-setup') { assert.equal(plugin.configurePreviewServer, undefined); diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js index e4c240f23..ecbf42cca 100644 --- a/src/ui/layerPanel.js +++ b/src/ui/layerPanel.js @@ -39,6 +39,7 @@ const PANEL_GROUPS = [ ids: [ 'alpr-cameras', 'military-installations', + 'drone-airspace', 'local-datacenters', 'telegeography-submarine-cables', 'local-dams', diff --git a/src/voice/layerManifest.js b/src/voice/layerManifest.js index 1e348ee41..facc90e32 100644 --- a/src/voice/layerManifest.js +++ b/src/voice/layerManifest.js @@ -327,6 +327,30 @@ const layers = [ context: true, query: { fields: { name: ['t'], class: ['t'], kind: ['t'] } }, }, + { + id: 'drone-airspace', + aliases: [ + 'drone restrictions', + 'drone airspace', + 'no-fly zones', + 'no fly zones', + 'drones', + ], + context: false, + query: { + fields: { + name: ['t'], + typeCode: ['t'], + type: ['t'], + lowerDesc: ['t'], + upperDesc: ['t'], + timesOfUse: ['t'], + agency: ['t'], + city: ['t'], + state: ['t'], + }, + }, + }, { id: 'local-datacenters', aliases: ['datacenters', 'data centers', 'data centres'], diff --git a/src/voice/layerManifest.test.mjs b/src/voice/layerManifest.test.mjs index d0222b934..98e3889bf 100644 --- a/src/voice/layerManifest.test.mjs +++ b/src/voice/layerManifest.test.mjs @@ -20,14 +20,28 @@ test('every catalog layer is in the voice manifest or explicitly voice-off', () const missing = catalogIds.filter( (id) => !manifestIds.includes(id) && !Object.hasOwn(VOICE_OFF_LAYERS, id), ); - assert.deepEqual(missing, [], `add a voice manifest entry for: ${missing.join(', ')}`); + assert.deepEqual( + missing, + [], + `add a voice manifest entry for: ${missing.join(', ')}`, + ); for (const id of Object.keys(VOICE_OFF_LAYERS)) { - assert.ok(catalogIds.includes(id), `${id} is voice-off but not in the catalog`); - assert.ok(!manifestIds.includes(id), `${id} is both voice-off and in the manifest`); + assert.ok( + catalogIds.includes(id), + `${id} is voice-off but not in the catalog`, + ); + assert.ok( + !manifestIds.includes(id), + `${id} is both voice-off and in the manifest`, + ); } const unknown = manifestIds.filter((id) => !catalogIds.includes(id)); assert.deepEqual(unknown, [], 'the manifest names only catalog layers'); - assert.equal(new Set(manifestIds).size, manifestIds.length, 'one entry per layer'); + assert.equal( + new Set(manifestIds).size, + manifestIds.length, + 'one entry per layer', + ); }); test('each layer declares a query or the reason it has none', () => { @@ -41,7 +55,8 @@ test('each layer declares a query or the reason it has none', () => { }); test('tool enums are generated from the manifest', () => { - const toggle = tool('set_layer_visibility').parameters.properties.layerId.enum; + const toggle = tool('set_layer_visibility').parameters.properties.layerId + .enum; assert.deepEqual(toggle, [...VOICE_TOGGLE_LAYER_IDS]); for (const id of [ 'transit', @@ -57,9 +72,13 @@ test('tool enums are generated from the manifest', () => { 'rocket-launches', 'bikeshare', 'local-adsb', + 'drone-airspace', ]) assert.ok(toggle.includes(id), `${id} is voice-toggleable`); - assert.ok(!toggle.includes('military-awareness'), 'Global Context is reached through set_context_mode'); + assert.ok( + !toggle.includes('military-awareness'), + 'Global Context is reached through set_context_mode', + ); assert.deepEqual( tool('show_data_layers_menu').parameters.properties.layerId.enum, [...VOICE_TOGGLE_LAYER_IDS], @@ -68,47 +87,98 @@ test('tool enums are generated from the manifest', () => { tool('get_entity_context').parameters.properties.layerId.enum, [...VOICE_CONTEXT_LAYER_IDS], ); - assert.ok(!VOICE_CONTEXT_LAYER_IDS.includes('telegeography-submarine-cables'), 'cables register no context records'); + assert.ok( + !VOICE_CONTEXT_LAYER_IDS.includes('telegeography-submarine-cables'), + 'cables register no context records', + ); assert.deepEqual( tool('analyst_query').parameters.properties.layers.items.enum, [...VOICE_QUERY_LAYER_IDS], ); assert.deepEqual(Object.keys(ANALYST_LAYERS), [...VOICE_QUERY_LAYER_IDS]); const panels = tool('set_panel_open').parameters.properties.panelId.enum; - assert.ok(panels.includes('weather-panel') && panels.includes('recent-imagery-panel')); + assert.ok( + panels.includes('weather-panel') && panels.includes('recent-imagery-panel'), + ); }); test('spoken aliases resolve to real layers, keep every legacy phrase and never collide', () => { const aliases = voiceLayerAliasMap(); for (const id of aliases.values()) assert.ok(manifestIds.includes(id), id); const legacy = { - planes: 'flights', aircraft: 'flights', 'military flights': 'military', quakes: 'earthquakes', - 'space mission': 'rocket-launches', 'space missions': 'rocket-launches', missions: 'rocket-launches', - 'street traffic': 'traffic', cameras: 'cctv', 'internet radio': 'radio', 'radio stations': 'radio', - bikes: 'bikeshare', ais: 'ais-live-vessels', ships: 'ais-live-vessels', vessels: 'ais-live-vessels', - 'live vessels': 'ais-live-vessels', datacenters: 'local-datacenters', 'data centers': 'local-datacenters', - 'data centres': 'local-datacenters', dams: 'local-dams', 'submarine cables': 'telegeography-submarine-cables', - cables: 'telegeography-submarine-cables', telegeography: 'telegeography-submarine-cables', - firms: 'local-firms', fires: 'local-firms', 'active fires': 'local-firms', alpr: 'alpr-cameras', - 'alpr cameras': 'alpr-cameras', 'flock cameras': 'alpr-cameras', 'license plate readers': 'alpr-cameras', - 'license plate cameras': 'alpr-cameras', 'plate readers': 'alpr-cameras', 'local-adsb': 'local-adsb', - 'local adsb': 'local-adsb', 'local ads-b': 'local-adsb', 'my receiver': 'local-adsb', - 'my antenna': 'local-adsb', 'my sdr': 'local-adsb', + planes: 'flights', + aircraft: 'flights', + 'military flights': 'military', + quakes: 'earthquakes', + 'space mission': 'rocket-launches', + 'space missions': 'rocket-launches', + missions: 'rocket-launches', + 'street traffic': 'traffic', + cameras: 'cctv', + 'internet radio': 'radio', + 'radio stations': 'radio', + bikes: 'bikeshare', + ais: 'ais-live-vessels', + ships: 'ais-live-vessels', + vessels: 'ais-live-vessels', + 'live vessels': 'ais-live-vessels', + datacenters: 'local-datacenters', + 'data centers': 'local-datacenters', + 'data centres': 'local-datacenters', + dams: 'local-dams', + 'submarine cables': 'telegeography-submarine-cables', + cables: 'telegeography-submarine-cables', + telegeography: 'telegeography-submarine-cables', + firms: 'local-firms', + fires: 'local-firms', + 'active fires': 'local-firms', + alpr: 'alpr-cameras', + 'alpr cameras': 'alpr-cameras', + 'flock cameras': 'alpr-cameras', + 'license plate readers': 'alpr-cameras', + 'license plate cameras': 'alpr-cameras', + 'plate readers': 'alpr-cameras', + 'local-adsb': 'local-adsb', + 'local adsb': 'local-adsb', + 'local ads-b': 'local-adsb', + 'my receiver': 'local-adsb', + 'my antenna': 'local-adsb', + 'my sdr': 'local-adsb', }; - for (const [phrase, id] of Object.entries(legacy)) assert.equal(aliases.get(phrase), id, phrase); + for (const [phrase, id] of Object.entries(legacy)) + assert.equal(aliases.get(phrase), id, phrase); const added = { - buses: 'transit', trains: 'transit', hurricanes: 'weather-cyclones', cyclones: 'weather-cyclones', - 'weather radar': 'weather-radar', lightning: 'weather-lightning', 'military bases': 'military-installations', - 'recent imagery': 'recent-imagery', 'bike share': 'bikeshare', 'rocket launches': 'rocket-launches', - 'street level': 'street-level', mapillary: 'street-level', - wind: 'wind', directions: 'directions', 'fire perimeters': 'fire-perimeters', - 'wildfire perimeters': 'fire-perimeters', 'burn areas': 'fire-perimeters', + buses: 'transit', + trains: 'transit', + hurricanes: 'weather-cyclones', + cyclones: 'weather-cyclones', + 'weather radar': 'weather-radar', + lightning: 'weather-lightning', + 'military bases': 'military-installations', + 'recent imagery': 'recent-imagery', + 'bike share': 'bikeshare', + 'rocket launches': 'rocket-launches', + 'street level': 'street-level', + mapillary: 'street-level', + wind: 'wind', + directions: 'directions', + 'fire perimeters': 'fire-perimeters', + 'wildfire perimeters': 'fire-perimeters', + 'burn areas': 'fire-perimeters', + 'drone restrictions': 'drone-airspace', + 'drone airspace': 'drone-airspace', + 'no-fly zones': 'drone-airspace', + drones: 'drone-airspace', }; - for (const [phrase, id] of Object.entries(added)) assert.equal(aliases.get(phrase), id, phrase); + for (const [phrase, id] of Object.entries(added)) + assert.equal(aliases.get(phrase), id, phrase); const claimed = new Map(); for (const entry of VOICE_LAYER_MANIFEST) for (const alias of entry.aliases) { - assert.ok(!claimed.has(alias), `"${alias}" claimed by ${claimed.get(alias)} and ${entry.id}`); + assert.ok( + !claimed.has(alias), + `"${alias}" claimed by ${claimed.get(alias)} and ${entry.id}`, + ); claimed.set(alias, entry.id); } }); From a275c2c63047964de9b2ba5241d7e6cb450edb40 Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Mon, 12 Oct 2026 19:39:15 +0000 Subject: [PATCH 004/156] test: pin voice schema and README layer count for drone airspace --- src/tooling/transitQa.test.mjs | 2 +- src/voice/actionSchemas.test.mjs | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/tooling/transitQa.test.mjs b/src/tooling/transitQa.test.mjs index 2ec277748..4e79bffbf 100644 --- a/src/tooling/transitQa.test.mjs +++ b/src/tooling/transitQa.test.mjs @@ -675,7 +675,7 @@ test('public transit documentation uses courtesy attribution and the approved gr ); assert.ok( readme.includes( - 'Twenty layers and map sources. **Seventeen have a keyless path.**', + 'Twenty-one layers and map sources. **Eighteen have a keyless path.**', ), ); assert.ok( diff --git a/src/voice/actionSchemas.test.mjs b/src/voice/actionSchemas.test.mjs index d3b8e8a90..a62c292f7 100644 --- a/src/voice/actionSchemas.test.mjs +++ b/src/voice/actionSchemas.test.mjs @@ -37,7 +37,8 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea // omit top-level anyOf (OpenAI rejects it). Contacts list routing preserves // requested radius and center authority in descriptions only. // Street Level adds its toggle enum values and generated alias hint. - '9eb113454c5af5e32e27ffcdc7919d0e4600ba1d204f8f175e071152be41ad0f', + // Drone Restrictions adds toggle/query enum values and spoken aliases. + '3ceef7975c1437eacb2c602bf9728829b299532b7dd95534cf7d9eb47115fa54', ); }); 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 005/156] 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 052435473e878deb59b91b4bb0a009474f6d6feb Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Wed, 14 Oct 2026 01:51:25 +0000 Subject: [PATCH 006/156] feat(cctv): add Houston TranStar camera pack Register the keyless TranStar snapshot catalog as a live CCTV source, synthesize frame URLs from validated numeric camera ids, and keep the nearest cameras to Houston's metro cores under the existing catalog ceiling. --- .env.example | 3 + CHANGELOG.md | 11 + DATA_SOURCES.md | 2 + README.md | 2 +- docs/CURRENT-STATE.md | 19 +- scripts/format-scope.json | 1 + server/providers/cctv/catalog.js | 6 + server/providers/cctv/constants.js | 31 +++ server/providers/cctv/normalize.js | 12 + server/providers/cctv/sources.js | 143 +++++++++++ src/data/cctvHouston.test.mjs | 379 +++++++++++++++++++++++++++++ src/data/dataCredits.js | 6 + 12 files changed, 613 insertions(+), 2 deletions(-) create mode 100644 src/data/cctvHouston.test.mjs diff --git a/.env.example b/.env.example index a2f8afa24..265377bf7 100644 --- a/.env.example +++ b/.env.example @@ -237,6 +237,9 @@ AISSTREAM_API_KEY= # CCTV_TORONTO_LIST_URL= # override the Toronto RESCU camera list URL # CCTV_LITHUANIA_ENABLED= # off by default; set 1 to load Via Lietuva / Eismoinfo road cameras (photo licence unconfirmed; see DATA_SOURCES.md) # CCTV_LITHUANIA_MAX_SOURCES=400 # cap Lithuanian cameras; no API key required +# CCTV_HOUSTON_ENABLED=1 # set 0 to disable the Houston TranStar cameras +# CCTV_HOUSTON_MAX_SOURCES=250 # cap cameras loaded from the Houston TranStar catalog (nearest metro cores first) +# CCTV_HOUSTON_CAMERAS_URL= # override the Houston TranStar catalog URL # CCTV_MAX_SOURCES=4000 # catalog-wide ceiling, shared round-robin across packs # CCTV_PREFER_AUSTIN=1 # prioritize Austin cameras in the registry # CCTV_FORCE_AUSTIN= # force live default sources when a source-pack JSON is configured diff --git a/CHANGELOG.md b/CHANGELOG.md index 98d32365f..b4de32107 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +- Add Houston traffic cameras from Houston TranStar as a CCTV pack. The camera + list is one keyless JSON request to + `traffic.houstontranstar.org/data/layers/cctvSnapshots_json.js`; frames are + stills synthesized from each numeric camera id on `www.houstontranstar.org`, + so a catalog field cannot steer the proxy off-host. Cameras without a + snapshot id, and cameras outside Greater Houston, are dropped. By default + the 250 cameras nearest downtown, Uptown, the Medical Center, IAH and + Galveston load. `CCTV_HOUSTON_ENABLED=0` turns the pack off and + `CCTV_HOUSTON_MAX_SOURCES` changes the cap. The catalog-wide ceiling is + unchanged; extra cameras share it round-robin with the other packs. + - The tracked-aircraft descriptor now publishes `verticalRateMps`, on both the civil and military layers, so a consumer reading `getTrackedInfo()` no longer has to reach past it into the raw poll record for climb/descent. Reported, diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 0e04fb39b..913cad6d1 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -65,6 +65,7 @@ How to read this: | **Statens vegvesen** (catalog `ogckart-sn1.atlas.vegvesen.no`, frames `kamera.atlas.vegvesen.no`, HLS `kamera.vegvesen.no`) | CCTV camera catalog + frames + live HLS video, Norway road cameras | [NLOD 2.0](https://data.norge.no/nlod/en/2.0) — attribution REQUIRED | "Contains data under the Norwegian licence for Open Government data (NLOD) distributed by Statens vegvesen" | | **NYC DOT Traffic Management Center** (`webcams.nyctmc.org`) | CCTV camera catalog + frames, New York City five boroughs | NYC DOT requires developers to sign a [data-sharing agreement](https://www.nyc.gov/html/dot/html/about/datafeeds.shtml) for the camera feed. **Off by default**; enable only under your own agreement | "NYC DOT Traffic Management Center" + NYC DOT's required application disclaimer (in the Data attribution popover) | | **City of Toronto Open Data** (`opendata.toronto.ca`) | CCTV camera catalog + frames, Toronto city streets | [Open Government Licence – Toronto](https://www.toronto.ca/city-government/data-research-maps/open-data/open-data-licence/) — attribution REQUIRED | "Contains information licensed under the Open Government Licence – Toronto" | +| **Houston TranStar** (`traffic.houstontranstar.org`, frames `www.houstontranstar.org`) | CCTV camera catalog + frames, Greater Houston | Public Houston TranStar traffic camera stills | "Houston TranStar" (courtesy) | | **GBFS (Lyft / BCycle / Social Bicycles / PBSC)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle) + its `license_url` | | **Bike Share Toronto** (`toronto.publicbikesystem.net`) | Bikeshare station availability, Toronto | Operator GBFS feed. No licence is published: the City's open-data package declares none and the feed has no `license_url`. Attribution is given to the operator. | "Bike Share Toronto (Toronto Parking Authority)" | | **Bike Itaú / Tembici** (`*.publicbikesystem.net`, seven Brazilian cities, Santiago and Bogotá) | Bikeshare station availability: São Paulo, Rio de Janeiro, Salvador, Recife, Porto Alegre, Brasília, Curitiba, Santiago de Chile and Bogotá | Operator GBFS feeds. No licence is published: the feeds' `system_information.json` carries no `license_url`, operator or attribution fields. Attribution is given to the operator. | "Bike Itaú (Tembici)"; "Tembici Bogotá" | @@ -166,6 +167,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **Statens vegvesen road cameras.** Keyless. The camera list is one GeoJSON request to the OGC API Features view of the official DATEX 3.1 CCTV site table (`ogckart-sn1.atlas.vegvesen.no/ogc/features/v1/collections/datex_3_1:CctvSimple/items`), so no DATEX account is needed; frames are stills on `kamera.atlas.vegvesen.no/api/images/`, and only that exact path per camera is registered. Cameras the feed reports as faulty are dropped. About 135 working cameras also publish live HLS (`kamera.vegvesen.no/public//manifest.m3u8`, MPEG-TS); those play as live video through the bounded in-memory HLS relay, with the still as the snapshot fallback, and only that exact manifest path per camera is registered. `CCTV_VEGVESEN_VIDEO=0` keeps every camera on stills. `orientationDescription` names the place a camera faces, not a bearing, so it goes into the label and headings use the id-hash fallback at low confidence. By default every live camera loads (~850); `CCTV_VEGVESEN_MAX_SOURCES` (8–1000) lowers the cap, keeping the cameras nearest Oslo, Bergen, Stavanger, Trondheim, Kristiansand, Bodø and Tromsø and `CCTV_VEGVESEN_ENABLED=0` turns the pack off. Frames are fetched at request time and never stored or redistributed. The NLOD attribution is registered in the Data attribution popover. - **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`. +- **Houston TranStar cameras.** Keyless. The camera list is the public `traffic.houstontranstar.org/data/layers/cctvSnapshots_json.js` catalog (~1,000 cameras across Greater Houston); frames are stills at `www.houstontranstar.org/snapshots/cctv/.jpg`, synthesized from a validated numeric id rather than taken from the payload, and only that host is registered. Cameras whose catalog id is `0` (a different, non-snapshot class) and cameras outside Greater Houston are dropped. Dedicated `dir` values (North/South/East/West) become high-confidence headings; `None` uses the shared id-hash fallback at low confidence. By default the 250 cameras nearest downtown, Uptown, the Medical Center, IAH and Galveston load; `CCTV_HOUSTON_MAX_SOURCES` (8–400) changes the cap and `CCTV_HOUSTON_ENABLED=0` turns the pack off. Frames are fetched at request time and never stored or redistributed. - **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. - **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 malformed or empty successful payloads are rejected atomically. 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. diff --git a/README.md b/README.md index c49c53ece..867f14824 100644 --- a/README.md +++ b/README.md @@ -377,7 +377,7 @@ Twenty layers and map sources. **Eighteen have a keyless path.** Some offer addi | 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 | | ◍ **Air quality** | Live AQHI on the published 1–10+ health-risk scale, with a Weather-panel card showing the band legend and the highest readings in view. **Canada only** — ECCC is the first registered provider | ECCC MSC GeoMet | 🟢 | | 🚗 **Traffic** | Simulated vehicles on OSM roads. With TomTom, live flow speeds drive the simulation and congestion colors below ~8 km; individual vehicle positions are not live observations | TomTom + OSM | 🟢 simulation · 🟡 live flow speeds | -| 📹 **CCTV Mesh** | ~4,000 public cameras (shared cap) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen) · Toronto · King County (WA) · Washington (WSDOT) · Catalonia (Servei Català de Trànsit), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement) and opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 | +| 📹 **CCTV Mesh** | ~4,000 public cameras (shared cap) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen) · Toronto · King County (WA) · Washington (WSDOT) · Catalonia (Servei Català de Trànsit) · Houston (TranStar), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement) and opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 | | 📷 **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 and **FOLLOW**. 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 | 🟢 | diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 8cb7b6d17..99cfc3234 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1072,6 +1072,23 @@ By default the 250 cameras nearest downtown Vancouver and Victoria load. The Open Government Licence – British Columbia attribution is registered in the Data attribution popover. +## Houston TranStar CCTV source pack + +The CCTV catalog adds Greater Houston traffic cameras from Houston TranStar +alongside the other live packs. The server reads the keyless camera list at +`https://traffic.houstontranstar.org/data/layers/cctvSnapshots_json.js` and +keeps cameras with a numeric snapshot id inside the metro bounding box. Frame +URLs are built from each camera id under +`https://www.houstontranstar.org/snapshots/cctv/.jpg`; they are never taken +from the payload. Dedicated `dir` values become high-confidence headings; +`None` uses the shared id-hash fallback at low confidence. + +By default the 250 cameras nearest downtown, Uptown, the Medical Center, IAH +and Galveston load. `CCTV_HOUSTON_MAX_SOURCES` sets the pack cap (8–400) and +`CCTV_HOUSTON_ENABLED=0` turns the pack off. The catalog-wide ceiling stays at +4,000 and is shared round-robin, so adding this pack does not silently drop +another region. + ## Location control ownership City/POI rows, search/reset bindings, location readouts and the orbit indicator @@ -3366,7 +3383,7 @@ its criteria cannot be silently ignored. | Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s | | Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min | | Traffic | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache | -| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) | +| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + Houston TranStar + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) | | Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh | | Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) | | Bikeshare 🚲 | GBFS (Lyft + BCycle + Social Bicycles + PBSC incl. Bike Share Toronto, Bike Itaú (Brazil) and Bicing (Barcelona) + Urban Sharing, Entur, JCDecaux NO + nextbike (AMBici)) | `src/data/bikeshare.js` | `/api/gbfs` | 60s | diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 8bcc4c04d..84002f2cf 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -552,6 +552,7 @@ "src/data/cctvCataloniaSource.test.mjs", "src/data/cctvVegvesen.test.mjs", "src/data/cctvNyc.test.mjs", + "src/data/cctvHouston.test.mjs", "src/data/cctvNswSource.test.mjs", "src/data/cctvTxdotSource.test.mjs", "src/layers/cctv/source.test.mjs", diff --git a/server/providers/cctv/catalog.js b/server/providers/cctv/catalog.js index 75e58d2a6..0ed194660 100644 --- a/server/providers/cctv/catalog.js +++ b/server/providers/cctv/catalog.js @@ -25,6 +25,7 @@ import { loadVegvesenSourcesFromOpenData, loadNycSourcesFromOpenData, loadTorontoSourcesFromOpenData, + loadHoustonSourcesFromOpenData, } from './sources.js'; /** Env kill switch: unset or anything but "0" means enabled. */ @@ -144,6 +145,11 @@ const LIVE_PACKS = [ enabled: () => envOptIn('CCTV_LITHUANIA_ENABLED'), load: loadLithuaniaSources, }, + { + name: 'houston', + enabled: () => envEnabled('CCTV_HOUSTON_ENABLED'), + load: loadHoustonSourcesFromOpenData, + }, ]; /** * Load CCTV sources from a local JSON file (CCTV_SOURCES_FILE env or default). diff --git a/server/providers/cctv/constants.js b/server/providers/cctv/constants.js index 20b963770..43d804963 100644 --- a/server/providers/cctv/constants.js +++ b/server/providers/cctv/constants.js @@ -347,6 +347,37 @@ export const NYC_ANCHORS = [ { lat: 40.5795, lon: -74.1502 }, // Staten Island ]; +/** Houston TranStar cameras: one keyless JSON list for the metro (~1,000 + * cameras with a numeric id). Frames are stills synthesized from that id on + * the city's snapshot host — never taken from the payload. The catalog is + * served as `application/javascript`; asking for `application/json` gets HTTP + * 406, so the loader accepts any media type. */ +export const HOUSTON_CAMERAS_URL = + 'https://traffic.houstontranstar.org/data/layers/cctvSnapshots_json.js'; +/** The only origin Houston camera frames may come from. The catalog's `url` + * is a `showWin(...)` viewer helper and is ignored; the registered path is + * always this origin plus `/snapshots/cctv/.jpg`. */ +export const HOUSTON_IMAGE_ORIGIN = 'https://www.houstontranstar.org/'; +export const HOUSTON_IMAGE_URL = (id) => + `${HOUSTON_IMAGE_ORIGIN}snapshots/cctv/${id}.jpg`; +/** Numeric TranStar snapshot ids: 3–5 digits, no leading zero. Id `0` is a + * different (non-snapshot) camera class in the same list. */ +export const HOUSTON_CAMERA_ID_RE = /^[1-9]\d{2,4}$/; +export const DEFAULT_HOUSTON_MAX_SOURCES = 250; +export const HOUSTON_MAX_CATALOG_BYTES = 4 * 1024 * 1024; +/** Ground-elevation prior in metres. The TranStar payload carries none, and + * Houston sits a few metres above sea level. */ +export const HOUSTON_GROUND_ELEVATION_M = 15; +/** Downtown Houston (Main @ Texas): the primary prioritization anchor. */ +export const HOUSTON_DOWNTOWN = { lat: 29.7604, lon: -95.3698 }; +export const HOUSTON_ANCHORS = [ + { lat: 29.7604, lon: -95.3698 }, // Downtown + { lat: 29.739, lon: -95.461 }, // Uptown / Galleria + { lat: 29.71, lon: -95.398 }, // Texas Medical Center + { lat: 29.9902, lon: -95.3368 }, // IAH + { lat: 29.3013, lon: -94.7977 }, // Galveston +]; + /** DelDOT CCTV: one keyless statewide JSON catalog; live video via RTMP-over-HTTP (rtmpt:80). */ export const DELDOT_CCTV_URL = 'https://tmc.deldot.gov/json/videocamera.json'; export const DEFAULT_DELDOT_MAX_SOURCES = 300; diff --git a/server/providers/cctv/normalize.js b/server/providers/cctv/normalize.js index 2d4e16e11..1b0e79a0c 100644 --- a/server/providers/cctv/normalize.js +++ b/server/providers/cctv/normalize.js @@ -388,6 +388,18 @@ export function isLikelyNycCoordinate(lat, lon) { ); } +/** Greater Houston (the Woodlands to Galveston, Katy to Baytown). Farther + * TranStar partners (Dallas, Columbus) are out of this city pack. */ +export function isLikelyHoustonCoordinate(lat, lon) { + return ( + isPlausibleLatLon(lat, lon) && + lat >= 29.2 && + lat <= 30.35 && + lon >= -96.05 && + lon <= -94.7 + ); +} + /** Mainland Norway, with slack for the coast and the Finnmark border. */ export function isLikelyNorwayCoordinate(lat, lon) { return ( diff --git a/server/providers/cctv/sources.js b/server/providers/cctv/sources.js index c3d11e752..895fe4c4d 100644 --- a/server/providers/cctv/sources.js +++ b/server/providers/cctv/sources.js @@ -96,6 +96,13 @@ import { NYC_MAX_CATALOG_BYTES, NYC_GROUND_ELEVATION_M, NYC_ANCHORS, + HOUSTON_CAMERAS_URL, + HOUSTON_IMAGE_URL, + HOUSTON_CAMERA_ID_RE, + DEFAULT_HOUSTON_MAX_SOURCES, + HOUSTON_MAX_CATALOG_BYTES, + HOUSTON_GROUND_ELEVATION_M, + HOUSTON_ANCHORS, } from './constants.js'; import { toFiniteNumber, @@ -116,6 +123,7 @@ import { isLikelyCataloniaCoordinate, decodeNumericEntities, isLikelyNycCoordinate, + isLikelyHoustonCoordinate, isLikelyNorwayCoordinate, isLikelyTorontoCoordinate, cameraDisplayCode, @@ -2784,3 +2792,138 @@ export async function loadTorontoSourcesFromOpenData() { return []; } } + +/** + * Label for one Houston TranStar camera: the interchange an operator + * recognises ("10 EAST @ SAN JACINTO"). + * + * @param {object} record - Raw TranStar camera row. + * @param {string} cameraId - Derived stable id. + * @returns {string} + */ +export function houstonCameraName(record, cameraId) { + const name = String(record?.location ?? '').trim(); + if (name) return name; + const short = String(cameraId || '').replace(/^houston-/, ''); + return short ? `Houston Camera ${short}` : 'Houston Camera'; +} + +/** + * One Houston TranStar camera row -> one catalog source, or null. + * + * Frame URLs are synthesized from a validated numeric id on the pinned + * snapshot origin. The payload's `url` is a `showWin(...)` viewer helper + * and is ignored so a catalog edit cannot steer the frame proxy off-host. + * Dedicated `dir` values (North/South/East/West) become high-confidence + * headings; `None`/blank fall back to the shared id-hash heading. + * + * @param {object} record - Raw TranStar camera row. + * @returns {?object} + */ +export function houstonCameraToSource(record) { + if (!record || typeof record !== 'object') return null; + + const lat = toFiniteNumber(record.lat); + const lon = toFiniteNumber(record.lng); + if (!isLikelyHoustonCoordinate(lat, lon)) return null; + + const rawId = String(record.id ?? '').trim(); + if (!HOUSTON_CAMERA_ID_RE.test(rawId)) return null; + const cameraId = `houston-${rawId}`; + const imageUrl = HOUSTON_IMAGE_URL(rawId); + const name = houstonCameraName(record, cameraId); + const extractedHeading = directionToHeading(record.dir, true); + const hasHeading = Number.isFinite(extractedHeading); + + return { + id: cameraId, + name, + city: 'Houston', + cityId: 'houston', + provider: 'Houston TranStar', + lat, + lon, + headingDeg: hasHeading ? extractedHeading : fallbackHeadingFromId(cameraId), + headingConfidence: hasHeading ? 'high' : 'low', + pitchDeg: hasHeading ? -24 : -18, + fovDeg: hasHeading ? 56 : 44, + rangeM: hasHeading ? 210 : 145, + mountHeightM: hasHeading ? 10 : 8, + groundElevationM: HOUSTON_GROUND_ELEVATION_M, + feedType: 'image', + url: imageUrl, + snapshotUrl: imageUrl, + sourceKind: 'houston-transtar', + license: 'Public Houston TranStar traffic camera', + code: cameraDisplayCode(name.toUpperCase()), + }; +} + +/** + * Fetch Houston TranStar cameras from the keyless `cctvSnapshots_json.js` + * catalog. Frames are stills on `www.houstontranstar.org`, registered from + * a validated numeric id rather than from the payload URL. The catalog host + * answers `Accept: application/json` with HTTP 406, so this request accepts + * any media type. + * + * @returns {Promise>} Normalized camera source objects. + */ +export async function loadHoustonSourcesFromOpenData() { + try { + const endpoint = + process.env.CCTV_HOUSTON_CAMERAS_URL || HOUSTON_CAMERAS_URL; + const resp = await fetch(endpoint, { + headers: { Accept: '*/*' }, + redirect: 'manual', + signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS), + }); + const discard = async () => { + try { + await resp.body?.cancel(); + } catch { + /* no-op */ + } + return []; + }; + if (resp.status >= 300 && resp.status < 400) { + console.warn( + '[CCTV] Houston catalog redirected; redirects are not followed', + ); + return discard(); + } + if (!resp.ok) { + console.warn('[CCTV] Houston camera download failed:', resp.status); + return discard(); + } + const payload = await readResponseJsonCapped( + resp, + HOUSTON_MAX_CATALOG_BYTES, + ); + const rows = Array.isArray(payload?.cameras) ? payload.cameras : []; + const cameras = []; + const seen = new Set(); + for (const record of rows) { + const camera = houstonCameraToSource(record); + if (!camera || seen.has(camera.id)) continue; + seen.add(camera.id); + cameras.push(camera); + } + const maxRaw = Number( + process.env.CCTV_HOUSTON_MAX_SOURCES || DEFAULT_HOUSTON_MAX_SOURCES, + ); + const maxCount = Number.isFinite(maxRaw) + ? Math.max(8, Math.min(400, Math.floor(maxRaw))) + : DEFAULT_HOUSTON_MAX_SOURCES; + const prioritized = prioritizeSources(cameras, maxCount, HOUSTON_ANCHORS); + console.log( + `[CCTV] Loaded Houston camera sources: ${cameras.length} (using nearest ${prioritized.length})`, + ); + return prioritized; + } catch (error) { + console.warn( + '[CCTV] Houston camera download error:', + error?.message || error, + ); + return []; + } +} diff --git a/src/data/cctvHouston.test.mjs b/src/data/cctvHouston.test.mjs new file mode 100644 index 000000000..97de95a84 --- /dev/null +++ b/src/data/cctvHouston.test.mjs @@ -0,0 +1,379 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + houstonCameraName, + houstonCameraToSource, + loadHoustonSourcesFromOpenData, +} from '../../server/providers/cctv/sources.js'; +import { + DEFAULT_CCTV_MAX_SOURCES, + DEFAULT_HOUSTON_MAX_SOURCES, + HOUSTON_CAMERAS_URL, + HOUSTON_GROUND_ELEVATION_M, + HOUSTON_IMAGE_ORIGIN, + HOUSTON_IMAGE_URL, + HOUSTON_MAX_CATALOG_BYTES, +} from '../../server/providers/cctv/constants.js'; +import { + CAMERA_CODE_MAX_CHARS, + isLikelyHoustonCoordinate, +} from '../../server/providers/cctv/normalize.js'; +import { createCctvCatalog } from '../../server/providers/cctv/catalog.js'; +import { directionToHeading } from './directionText.js'; + +/** + * A response whose body is a live stream, plus a flag that flips when the + * stream is cancelled. A rejection path that returns without cancelling holds + * the transport open, so the flag is what the refusal tests actually assert. + */ +const streamingResponse = (init = {}) => { + const state = { cancelled: false }; + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('{')); + }, + cancel() { + state.cancelled = true; + }, + }); + return { response: new Response(body, init), state }; +}; + +const DOWNTOWN_ID = '1002'; +const GALLERIA_ID = '2001'; + +/** One TranStar camera row, shaped like the live `cctvSnapshots_json.js` payload. */ +const row = (overrides = {}) => ({ + location: '10 EAST @ SAN JACINTO', + id: DOWNTOWN_ID, + lat: '29.76824', + lng: '-95.355711', + dir: 'South', + fc: '6', + fp: '300', + url: "showWin('/layers/gc.aspx?cam=1002&loc=IH-10_East_at_SAN_JACINTO')", + ...overrides, +}); + +test('a Houston row maps to a source on the pinned HTTPS frame host', () => { + const source = houstonCameraToSource(row()); + assert.equal(source.id, `houston-${DOWNTOWN_ID}`); + assert.equal(source.name, '10 EAST @ SAN JACINTO'); + assert.equal(source.city, 'Houston'); + assert.equal(source.cityId, 'houston'); + assert.equal(source.provider, 'Houston TranStar'); + assert.equal(source.lat, 29.76824); + assert.equal(source.lon, -95.355711); + assert.equal(source.feedType, 'image'); + assert.equal(source.sourceKind, 'houston-transtar'); + assert.equal(source.license, 'Public Houston TranStar traffic camera'); + assert.equal(source.groundElevationM, HOUSTON_GROUND_ELEVATION_M); + assert.equal(source.url, HOUSTON_IMAGE_URL(DOWNTOWN_ID)); + assert.equal(source.url, source.snapshotUrl); + assert.ok(source.url.startsWith(HOUSTON_IMAGE_ORIGIN)); + assert.equal(source.headingConfidence, 'high'); + assert.equal(source.headingDeg, 180); +}); + +test('frame URLs are synthesized from the numeric id; the payload URL is ignored', () => { + const source = houstonCameraToSource( + row({ url: 'https://evil.example/cameras/x/image' }), + ); + assert.equal(source.url, HOUSTON_IMAGE_URL(DOWNTOWN_ID)); + assert.equal(source.snapshotUrl, source.url); +}); + +test('zero ids, off-city geometry and unusable ids are dropped', () => { + assert.equal(houstonCameraToSource(row({ id: '0' })), null); + assert.equal(houstonCameraToSource(row({ id: '00' })), null); + assert.equal(houstonCameraToSource(row({ id: '../etc/passwd' })), null); + assert.equal(houstonCameraToSource(row({ id: '12' })), null); + assert.equal(houstonCameraToSource(row({ id: '1002/../x' })), null); + // Dallas: a plausible lat/lon, but not a Houston camera. + assert.equal( + houstonCameraToSource(row({ lat: '32.7767', lng: '-96.797' })), + null, + ); + assert.equal(houstonCameraToSource(row({ lat: '0', lng: '0' })), null); + assert.equal(houstonCameraToSource(null), null); + // 3-digit and 5-digit snapshot ids stay. + assert.equal(houstonCameraToSource(row({ id: '102' })).id, 'houston-102'); + assert.equal(houstonCameraToSource(row({ id: '13711' })).id, 'houston-13711'); +}); + +test('bare cardinal dirs are high-confidence; None uses the id-hash fallback', () => { + assert.equal(directionToHeading('South', true), 180); + assert.equal(directionToHeading('None', true), NaN); + + const south = houstonCameraToSource(row({ dir: 'South' })); + assert.equal(south.headingConfidence, 'high'); + assert.equal(south.headingDeg, 180); + + const none = houstonCameraToSource(row({ dir: 'None' })); + assert.equal(none.headingConfidence, 'low'); + assert.ok(Number.isFinite(none.headingDeg)); + assert.equal( + none.headingDeg, + houstonCameraToSource(row({ dir: '' })).headingDeg, + ); +}); + +test('a nameless row still gets a label', () => { + assert.equal( + houstonCameraName({ location: ' 45 GULF @ WEST DALLAS ' }, 'houston-102'), + '45 GULF @ WEST DALLAS', + ); + assert.equal(houstonCameraName({}, 'houston-102'), 'Houston Camera 102'); + assert.equal(houstonCameraName({}, ''), 'Houston Camera'); +}); + +test('the unselected label is the interchange, trimmed to the code width', () => { + assert.equal(houstonCameraToSource(row()).code, '10 EAST @ SAN JACINTO'); + const long = houstonCameraToSource( + row({ + location: + 'IH-10 Katy Managed Lanes Eastbound at a very long interchange name that must truncate', + }), + ); + assert.equal(long.code.length, CAMERA_CODE_MAX_CHARS); + assert.ok(long.code.endsWith('…')); +}); + +test('the loader reads the keyless catalog and collapses duplicate ids', async (t) => { + t.mock.method(console, 'log', () => {}); + const requested = []; + t.mock.method(globalThis, 'fetch', async (url, init) => { + requested.push([String(url), init.headers.Accept, init.redirect]); + return Response.json({ + cameras: [ + row(), + row({ location: '10 EAST @ SAN JACINTO (duplicate)' }), + row({ + id: GALLERIA_ID, + location: '610 WEST LOOP @ SAN FELIPE', + lat: '29.749', + lng: '-95.461', + dir: 'North', + }), + row({ url: 'https://evil.example/x.jpg', id: '0' }), + ], + }); + }); + const cameras = await loadHoustonSourcesFromOpenData(); + assert.deepEqual(requested, [[HOUSTON_CAMERAS_URL, '*/*', 'manual']]); + assert.deepEqual( + cameras.map((camera) => camera.id), + [`houston-${GALLERIA_ID}`, `houston-${DOWNTOWN_ID}`], + 'nearest-to-anchor first: Uptown sits closer to its pin than downtown does', + ); + assert.equal(cameras[1].name, '10 EAST @ SAN JACINTO'); +}); + +test('an upstream failure yields an empty pack and releases the response', async (t) => { + t.mock.method(console, 'warn', () => {}); + const failed = streamingResponse({ status: 503 }); + t.mock.method(globalThis, 'fetch', async () => failed.response); + assert.deepEqual(await loadHoustonSourcesFromOpenData(), []); + assert.equal(failed.state.cancelled, true, 'the failed body is cancelled'); + + t.mock.restoreAll(); + t.mock.method(console, 'warn', () => {}); + t.mock.method(globalThis, 'fetch', async () => { + throw new Error('network down'); + }); + assert.deepEqual(await loadHoustonSourcesFromOpenData(), []); +}); + +test('the catalog fetch refuses redirects and oversized bodies', async (t) => { + t.mock.method(console, 'warn', () => {}); + const seen = []; + const redirected = streamingResponse({ + status: 302, + headers: { location: 'https://evil.example/cameras.js' }, + }); + t.mock.method(globalThis, 'fetch', async (url, init) => { + seen.push([String(url), init.redirect]); + return redirected.response; + }); + assert.deepEqual(await loadHoustonSourcesFromOpenData(), []); + assert.deepEqual(seen, [[HOUSTON_CAMERAS_URL, 'manual']]); + assert.equal( + redirected.state.cancelled, + true, + 'the redirect body is cancelled', + ); + + t.mock.restoreAll(); + t.mock.method(console, 'warn', () => {}); + t.mock.method(globalThis, 'fetch', async () => { + const body = JSON.stringify({ cameras: [row()] }); + return new Response(body, { + headers: { + 'Content-Type': 'application/javascript', + 'content-length': String(HOUSTON_MAX_CATALOG_BYTES + 1), + }, + }); + }); + assert.deepEqual(await loadHoustonSourcesFromOpenData(), []); + + t.mock.restoreAll(); + t.mock.method(console, 'warn', () => {}); + t.mock.method(globalThis, 'fetch', async () => { + const oversized = 'x'.repeat(HOUSTON_MAX_CATALOG_BYTES + 1024); + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(oversized)); + controller.close(); + }, + }), + { headers: { 'Content-Type': 'application/javascript' } }, + ); + }); + assert.deepEqual(await loadHoustonSourcesFromOpenData(), []); +}); + +test('a reduced pack cap keeps cameras nearest the metro anchors', async (t) => { + t.mock.method(console, 'log', () => {}); + const saved = process.env.CCTV_HOUSTON_MAX_SOURCES; + t.after(() => { + if (saved === undefined) delete process.env.CCTV_HOUSTON_MAX_SOURCES; + else process.env.CCTV_HOUSTON_MAX_SOURCES = saved; + }); + process.env.CCTV_HOUSTON_MAX_SOURCES = '8'; + const rows = [ + ...[29.32, 29.322, 29.324, 29.326, 29.328, 29.33].map((lat, i) => + row({ + id: String(3000 + i), + lat: String(lat), + lng: '-94.80', + location: `Galveston ${i}`, + dir: 'East', + }), + ), + ...[29.76, 29.759, 29.758, 29.757, 29.756, 29.755].map((lat, i) => + row({ + id: String(4000 + i), + lat: String(lat), + lng: '-95.3698', + location: `Downtown ${i}`, + dir: 'North', + }), + ), + ]; + t.mock.method(globalThis, 'fetch', async () => + Response.json({ cameras: rows }), + ); + const cameras = await loadHoustonSourcesFromOpenData(); + assert.equal(cameras.length, 8); + assert.deepEqual( + cameras.map((camera) => camera.name.slice(0, 8)), + [ + 'Downtown', + 'Downtown', + 'Downtown', + 'Downtown', + 'Downtown', + 'Downtown', + 'Galvesto', + 'Galvesto', + ], + 'downtown fills first; the Galveston anchor still keeps a share', + ); +}); + +/** + * Serve the Houston catalog to the Houston endpoint and an empty payload to + * every other pack, so one catalog refresh exercises the registration without + * reaching the network. + */ +const runCatalogWithMockedUpstreams = async (t) => { + const requested = []; + t.mock.method(console, 'log', () => {}); + t.mock.method(console, 'warn', () => {}); + t.mock.method(globalThis, 'fetch', async (url) => { + const href = String(url); + requested.push(href); + if (href === HOUSTON_CAMERAS_URL) { + return Response.json({ + cameras: [ + row(), + row({ + id: GALLERIA_ID, + location: '610 WEST LOOP @ SAN FELIPE', + lat: '29.749', + lng: '-95.461', + dir: 'West', + }), + ], + }); + } + return Response.json([]); + }); + const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })(); + return { requested, sources }; +}; + +test('the Houston lane is wired into the catalog and its loader runs', async (t) => { + const saved = { ...process.env }; + try { + delete process.env.CCTV_SOURCES_FILE; + delete process.env.CCTV_SOURCES_JSON; + delete process.env.CCTV_HOUSTON_ENABLED; + const { requested, sources } = await runCatalogWithMockedUpstreams(t); + assert.ok( + requested.includes(HOUSTON_CAMERAS_URL), + 'the catalog refresh invokes the Houston loader', + ); + assert.deepEqual( + sources.filter((s) => s.cityId === 'houston').map((s) => s.id), + [`houston-${GALLERIA_ID}`, `houston-${DOWNTOWN_ID}`], + 'Houston cameras reach the served catalog through the registered lane', + ); + } finally { + for (const key of Object.keys(process.env)) { + if (!(key in saved)) delete process.env[key]; + } + Object.assign(process.env, saved); + } +}); + +test('CCTV_HOUSTON_ENABLED=0 keeps the lane from being loaded at all', async (t) => { + const saved = { ...process.env }; + try { + delete process.env.CCTV_SOURCES_FILE; + delete process.env.CCTV_SOURCES_JSON; + process.env.CCTV_HOUSTON_ENABLED = '0'; + const { requested, sources } = await runCatalogWithMockedUpstreams(t); + assert.equal( + requested.includes(HOUSTON_CAMERAS_URL), + false, + 'the disabled lane never reaches its upstream', + ); + assert.deepEqual( + sources.filter((s) => s.cityId === 'houston'), + [], + ); + } finally { + for (const key of Object.keys(process.env)) { + if (!(key in saved)) delete process.env[key]; + } + Object.assign(process.env, saved); + } +}); + +test('the shipped catalog ceiling is not raised to make room for this pack', () => { + assert.equal(DEFAULT_CCTV_MAX_SOURCES, 4000); + assert.equal(DEFAULT_HOUSTON_MAX_SOURCES, 250); +}); + +test('isLikelyHoustonCoordinate spans the metro and rejects the rest', () => { + assert.equal(isLikelyHoustonCoordinate(29.7604, -95.3698), true); // Downtown + assert.equal(isLikelyHoustonCoordinate(29.3013, -94.7977), true); // Galveston + assert.equal(isLikelyHoustonCoordinate(29.9902, -95.3368), true); // IAH + assert.equal(isLikelyHoustonCoordinate(32.7767, -96.797), false); // Dallas + assert.equal(isLikelyHoustonCoordinate(30.2672, -97.7431), false); // Austin + assert.equal(isLikelyHoustonCoordinate(0, 0), false); + assert.equal(isLikelyHoustonCoordinate(NaN, -95.37), false); +}); diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index c13c7dd94..b74e8c061 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -300,6 +300,12 @@ export const DATA_CREDITS = [ 'Traffic cameras (Toronto): contains information licensed under the ' + 'Open Government Licence – Toronto', }, + { + key: 'houston-cctv', + html: + 'CCTV cameras & frames (Houston): ' + + 'Houston TranStar', + }, { key: 'gbfs', html: From 01a6734a5620a8ad4668f7a1ce0d65040f2f8bf7 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Fri, 16 Oct 2026 18:22:41 -0700 Subject: [PATCH 007/156] fix(cctv): make the Houston TranStar pack opt-in TranStar's disclaimer prohibits copying, distribution and external framing of specific images without TxDOT's prior written permission, so the pack no longer loads by default. It now uses envOptIn and loads only when CCTV_HOUSTON_ENABLED=1, the same pattern as NYC DOT and Via Lietuva. - DATA_SOURCES.md: replace the licence column with the disclaimer's terms and a link, and document the opt-in - Ambient stills for Houston refresh every 3 minutes, matching TranStar's stated snapshot cadence - README, CHANGELOG, CURRENT-STATE and .env.example describe the pack as opt-in; the README camera count no longer includes Houston - Tests cover the default-off and =0 cases and the opted-in catalog path --- .env.example | 2 +- CHANGELOG.md | 11 --------- DATA_SOURCES.md | 4 +-- README.md | 2 +- changelog.d/1125-houston-cctv-pack.md | 13 ++++++++++ docs/CURRENT-STATE.md | 14 ++++++----- server/providers/cctv/catalog.js | 2 +- server/providers/cctv/sources.js | 3 ++- src/data/cctvHouston.test.mjs | 35 ++++++++++++++++----------- src/data/cctvLod.js | 2 ++ src/data/cctvLod.test.mjs | 1 + 11 files changed, 52 insertions(+), 37 deletions(-) create mode 100644 changelog.d/1125-houston-cctv-pack.md diff --git a/.env.example b/.env.example index 265377bf7..d176acf40 100644 --- a/.env.example +++ b/.env.example @@ -237,7 +237,7 @@ AISSTREAM_API_KEY= # CCTV_TORONTO_LIST_URL= # override the Toronto RESCU camera list URL # CCTV_LITHUANIA_ENABLED= # off by default; set 1 to load Via Lietuva / Eismoinfo road cameras (photo licence unconfirmed; see DATA_SOURCES.md) # CCTV_LITHUANIA_MAX_SOURCES=400 # cap Lithuanian cameras; no API key required -# CCTV_HOUSTON_ENABLED=1 # set 0 to disable the Houston TranStar cameras +# CCTV_HOUSTON_ENABLED= # set to 1 to opt in; off by default (TranStar terms) # CCTV_HOUSTON_MAX_SOURCES=250 # cap cameras loaded from the Houston TranStar catalog (nearest metro cores first) # CCTV_HOUSTON_CAMERAS_URL= # override the Houston TranStar catalog URL # CCTV_MAX_SOURCES=4000 # catalog-wide ceiling, shared round-robin across packs diff --git a/CHANGELOG.md b/CHANGELOG.md index b4de32107..98d32365f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,16 +1,5 @@ # Changelog -- Add Houston traffic cameras from Houston TranStar as a CCTV pack. The camera - list is one keyless JSON request to - `traffic.houstontranstar.org/data/layers/cctvSnapshots_json.js`; frames are - stills synthesized from each numeric camera id on `www.houstontranstar.org`, - so a catalog field cannot steer the proxy off-host. Cameras without a - snapshot id, and cameras outside Greater Houston, are dropped. By default - the 250 cameras nearest downtown, Uptown, the Medical Center, IAH and - Galveston load. `CCTV_HOUSTON_ENABLED=0` turns the pack off and - `CCTV_HOUSTON_MAX_SOURCES` changes the cap. The catalog-wide ceiling is - unchanged; extra cameras share it round-robin with the other packs. - - The tracked-aircraft descriptor now publishes `verticalRateMps`, on both the civil and military layers, so a consumer reading `getTrackedInfo()` no longer has to reach past it into the raw poll record for climb/descent. Reported, diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 913cad6d1..92f623deb 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -65,7 +65,7 @@ How to read this: | **Statens vegvesen** (catalog `ogckart-sn1.atlas.vegvesen.no`, frames `kamera.atlas.vegvesen.no`, HLS `kamera.vegvesen.no`) | CCTV camera catalog + frames + live HLS video, Norway road cameras | [NLOD 2.0](https://data.norge.no/nlod/en/2.0) — attribution REQUIRED | "Contains data under the Norwegian licence for Open Government data (NLOD) distributed by Statens vegvesen" | | **NYC DOT Traffic Management Center** (`webcams.nyctmc.org`) | CCTV camera catalog + frames, New York City five boroughs | NYC DOT requires developers to sign a [data-sharing agreement](https://www.nyc.gov/html/dot/html/about/datafeeds.shtml) for the camera feed. **Off by default**; enable only under your own agreement | "NYC DOT Traffic Management Center" + NYC DOT's required application disclaimer (in the Data attribution popover) | | **City of Toronto Open Data** (`opendata.toronto.ca`) | CCTV camera catalog + frames, Toronto city streets | [Open Government Licence – Toronto](https://www.toronto.ca/city-government/data-research-maps/open-data/open-data-licence/) — attribution REQUIRED | "Contains information licensed under the Open Government Licence – Toronto" | -| **Houston TranStar** (`traffic.houstontranstar.org`, frames `www.houstontranstar.org`) | CCTV camera catalog + frames, Greater Houston | Public Houston TranStar traffic camera stills | "Houston TranStar" (courtesy) | +| **Houston TranStar** (`traffic.houstontranstar.org`, frames `www.houstontranstar.org`) | CCTV camera catalog + frames, Greater Houston | No licence. The [TranStar disclaimer](https://traffic.houstontranstar.org/disclaimer.aspx) (Content Usage) prohibits copying or distribution, and external framing or linking to specific images, without TxDOT's prior written permission. Off unless `CCTV_HOUSTON_ENABLED=1` | "Houston TranStar" (courtesy) | | **GBFS (Lyft / BCycle / Social Bicycles / PBSC)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle) + its `license_url` | | **Bike Share Toronto** (`toronto.publicbikesystem.net`) | Bikeshare station availability, Toronto | Operator GBFS feed. No licence is published: the City's open-data package declares none and the feed has no `license_url`. Attribution is given to the operator. | "Bike Share Toronto (Toronto Parking Authority)" | | **Bike Itaú / Tembici** (`*.publicbikesystem.net`, seven Brazilian cities, Santiago and Bogotá) | Bikeshare station availability: São Paulo, Rio de Janeiro, Salvador, Recife, Porto Alegre, Brasília, Curitiba, Santiago de Chile and Bogotá | Operator GBFS feeds. No licence is published: the feeds' `system_information.json` carries no `license_url`, operator or attribution fields. Attribution is given to the operator. | "Bike Itaú (Tembici)"; "Tembici Bogotá" | @@ -167,7 +167,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **Statens vegvesen road cameras.** Keyless. The camera list is one GeoJSON request to the OGC API Features view of the official DATEX 3.1 CCTV site table (`ogckart-sn1.atlas.vegvesen.no/ogc/features/v1/collections/datex_3_1:CctvSimple/items`), so no DATEX account is needed; frames are stills on `kamera.atlas.vegvesen.no/api/images/`, and only that exact path per camera is registered. Cameras the feed reports as faulty are dropped. About 135 working cameras also publish live HLS (`kamera.vegvesen.no/public//manifest.m3u8`, MPEG-TS); those play as live video through the bounded in-memory HLS relay, with the still as the snapshot fallback, and only that exact manifest path per camera is registered. `CCTV_VEGVESEN_VIDEO=0` keeps every camera on stills. `orientationDescription` names the place a camera faces, not a bearing, so it goes into the label and headings use the id-hash fallback at low confidence. By default every live camera loads (~850); `CCTV_VEGVESEN_MAX_SOURCES` (8–1000) lowers the cap, keeping the cameras nearest Oslo, Bergen, Stavanger, Trondheim, Kristiansand, Bodø and Tromsø and `CCTV_VEGVESEN_ENABLED=0` turns the pack off. Frames are fetched at request time and never stored or redistributed. The NLOD attribution is registered in the Data attribution popover. - **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`. -- **Houston TranStar cameras.** Keyless. The camera list is the public `traffic.houstontranstar.org/data/layers/cctvSnapshots_json.js` catalog (~1,000 cameras across Greater Houston); frames are stills at `www.houstontranstar.org/snapshots/cctv/.jpg`, synthesized from a validated numeric id rather than taken from the payload, and only that host is registered. Cameras whose catalog id is `0` (a different, non-snapshot class) and cameras outside Greater Houston are dropped. Dedicated `dir` values (North/South/East/West) become high-confidence headings; `None` uses the shared id-hash fallback at low confidence. By default the 250 cameras nearest downtown, Uptown, the Medical Center, IAH and Galveston load; `CCTV_HOUSTON_MAX_SOURCES` (8–400) changes the cap and `CCTV_HOUSTON_ENABLED=0` turns the pack off. Frames are fetched at request time and never stored or redistributed. +- **Houston TranStar cameras.** Keyless. The camera list is the public `traffic.houstontranstar.org/data/layers/cctvSnapshots_json.js` catalog (~1,000 cameras across Greater Houston); frames are stills at `www.houstontranstar.org/snapshots/cctv/.jpg`, synthesized from a validated numeric id rather than taken from the payload, and only that host is registered. Cameras whose catalog id is `0` (a different, non-snapshot class) and cameras outside Greater Houston are dropped. Dedicated `dir` values (North/South/East/West) become high-confidence headings; `None` uses the shared id-hash fallback at low confidence. TranStar's disclaimer is not a licence to ship the stills on by default (its FAQ grants permission-free use only for the RSS feeds), so the pack stays off unless `CCTV_HOUSTON_ENABLED=1`. Opting in loads the 250 cameras nearest downtown, Uptown, the Medical Center, IAH and Galveston; `CCTV_HOUSTON_MAX_SOURCES` (8–400) changes the cap. Snapshots update about every 3 minutes, which is also the ambient still cadence. Frames are fetched at request time and never stored or redistributed. - **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. - **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 malformed or empty successful payloads are rejected atomically. 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. diff --git a/README.md b/README.md index 867f14824..6c2baec79 100644 --- a/README.md +++ b/README.md @@ -377,7 +377,7 @@ Twenty layers and map sources. **Eighteen have a keyless path.** Some offer addi | 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 | | ◍ **Air quality** | Live AQHI on the published 1–10+ health-risk scale, with a Weather-panel card showing the band legend and the highest readings in view. **Canada only** — ECCC is the first registered provider | ECCC MSC GeoMet | 🟢 | | 🚗 **Traffic** | Simulated vehicles on OSM roads. With TomTom, live flow speeds drive the simulation and congestion colors below ~8 km; individual vehicle positions are not live observations | TomTom + OSM | 🟢 simulation · 🟡 live flow speeds | -| 📹 **CCTV Mesh** | ~4,000 public cameras (shared cap) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen) · Toronto · King County (WA) · Washington (WSDOT) · Catalonia (Servei Català de Trànsit) · Houston (TranStar), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement) and opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 | +| 📹 **CCTV Mesh** | ~4,000 public cameras (shared cap) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen) · Toronto · King County (WA) · Washington (WSDOT) · Catalonia (Servei Català de Trànsit), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement), opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed) and opt-in Houston (TranStar, off unless CCTV_HOUSTON_ENABLED=1). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 | | 📷 **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 and **FOLLOW**. 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 | 🟢 | diff --git a/changelog.d/1125-houston-cctv-pack.md b/changelog.d/1125-houston-cctv-pack.md new file mode 100644 index 000000000..dc573993f --- /dev/null +++ b/changelog.d/1125-houston-cctv-pack.md @@ -0,0 +1,13 @@ +- Add Houston traffic cameras from Houston TranStar as a CCTV pack. The camera + list is one keyless JSON request to + `traffic.houstontranstar.org/data/layers/cctvSnapshots_json.js`; frames are + stills synthesized from each numeric camera id on `www.houstontranstar.org`, + so a catalog field cannot steer the proxy off-host. Cameras without a + snapshot id, and cameras outside Greater Houston, are dropped. TranStar's + disclaimer prohibits redistribution and external framing without TxDOT's + written permission, so the pack is off unless `CCTV_HOUSTON_ENABLED=1`. + Opting in loads the 250 cameras nearest downtown, Uptown, the Medical + Center, IAH and Galveston; `CCTV_HOUSTON_MAX_SOURCES` changes that cap. + Snapshots update about every 3 minutes. The catalog-wide ceiling is + unchanged; extra cameras share it round-robin with the other packs + (mvanhorn, #1125). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 99cfc3234..0c8fe6261 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1083,11 +1083,13 @@ URLs are built from each camera id under from the payload. Dedicated `dir` values become high-confidence headings; `None` uses the shared id-hash fallback at low confidence. -By default the 250 cameras nearest downtown, Uptown, the Medical Center, IAH -and Galveston load. `CCTV_HOUSTON_MAX_SOURCES` sets the pack cap (8–400) and -`CCTV_HOUSTON_ENABLED=0` turns the pack off. The catalog-wide ceiling stays at -4,000 and is shared round-robin, so adding this pack does not silently drop -another region. +TranStar's disclaimer prohibits redistribution and external framing of specific +images without TxDOT's written permission, so the pack is off unless +`CCTV_HOUSTON_ENABLED=1`. Opting in loads the 250 cameras nearest downtown, +Uptown, the Medical Center, IAH and Galveston. `CCTV_HOUSTON_MAX_SOURCES` sets +the pack cap (8–400). Snapshots update about every 3 minutes. The catalog-wide +ceiling stays at 4,000 and is shared round-robin, so adding this pack does not +silently drop another region. ## Location control ownership @@ -3383,7 +3385,7 @@ its criteria cannot be silently ignored. | Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s | | Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min | | Traffic | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache | -| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + Houston TranStar + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) | +| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + Houston TranStar (opt-in) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) | | Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh | | Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) | | Bikeshare 🚲 | GBFS (Lyft + BCycle + Social Bicycles + PBSC incl. Bike Share Toronto, Bike Itaú (Brazil) and Bicing (Barcelona) + Urban Sharing, Entur, JCDecaux NO + nextbike (AMBici)) | `src/data/bikeshare.js` | `/api/gbfs` | 60s | diff --git a/server/providers/cctv/catalog.js b/server/providers/cctv/catalog.js index 0ed194660..2268b45ee 100644 --- a/server/providers/cctv/catalog.js +++ b/server/providers/cctv/catalog.js @@ -147,7 +147,7 @@ const LIVE_PACKS = [ }, { name: 'houston', - enabled: () => envEnabled('CCTV_HOUSTON_ENABLED'), + enabled: () => envOptIn('CCTV_HOUSTON_ENABLED'), load: loadHoustonSourcesFromOpenData, }, ]; diff --git a/server/providers/cctv/sources.js b/server/providers/cctv/sources.js index 895fe4c4d..f51d6bf35 100644 --- a/server/providers/cctv/sources.js +++ b/server/providers/cctv/sources.js @@ -2854,7 +2854,8 @@ export function houstonCameraToSource(record) { url: imageUrl, snapshotUrl: imageUrl, sourceKind: 'houston-transtar', - license: 'Public Houston TranStar traffic camera', + license: + 'TranStar disclaimer — redistribution and external framing need TxDOT written permission', code: cameraDisplayCode(name.toUpperCase()), }; } diff --git a/src/data/cctvHouston.test.mjs b/src/data/cctvHouston.test.mjs index 97de95a84..54aace016 100644 --- a/src/data/cctvHouston.test.mjs +++ b/src/data/cctvHouston.test.mjs @@ -67,7 +67,10 @@ test('a Houston row maps to a source on the pinned HTTPS frame host', () => { assert.equal(source.lon, -95.355711); assert.equal(source.feedType, 'image'); assert.equal(source.sourceKind, 'houston-transtar'); - assert.equal(source.license, 'Public Houston TranStar traffic camera'); + assert.equal( + source.license, + 'TranStar disclaimer — redistribution and external framing need TxDOT written permission', + ); assert.equal(source.groundElevationM, HOUSTON_GROUND_ELEVATION_M); assert.equal(source.url, HOUSTON_IMAGE_URL(DOWNTOWN_ID)); assert.equal(source.url, source.snapshotUrl); @@ -290,6 +293,7 @@ test('a reduced pack cap keeps cameras nearest the metro anchors', async (t) => */ const runCatalogWithMockedUpstreams = async (t) => { const requested = []; + t.mock.restoreAll(); t.mock.method(console, 'log', () => {}); t.mock.method(console, 'warn', () => {}); t.mock.method(globalThis, 'fetch', async (url) => { @@ -320,7 +324,7 @@ test('the Houston lane is wired into the catalog and its loader runs', async (t) try { delete process.env.CCTV_SOURCES_FILE; delete process.env.CCTV_SOURCES_JSON; - delete process.env.CCTV_HOUSTON_ENABLED; + process.env.CCTV_HOUSTON_ENABLED = '1'; const { requested, sources } = await runCatalogWithMockedUpstreams(t); assert.ok( requested.includes(HOUSTON_CAMERAS_URL), @@ -339,22 +343,25 @@ test('the Houston lane is wired into the catalog and its loader runs', async (t) } }); -test('CCTV_HOUSTON_ENABLED=0 keeps the lane from being loaded at all', async (t) => { +test('Houston stays unloaded unless CCTV_HOUSTON_ENABLED=1', async (t) => { const saved = { ...process.env }; try { delete process.env.CCTV_SOURCES_FILE; delete process.env.CCTV_SOURCES_JSON; - process.env.CCTV_HOUSTON_ENABLED = '0'; - const { requested, sources } = await runCatalogWithMockedUpstreams(t); - assert.equal( - requested.includes(HOUSTON_CAMERAS_URL), - false, - 'the disabled lane never reaches its upstream', - ); - assert.deepEqual( - sources.filter((s) => s.cityId === 'houston'), - [], - ); + for (const value of [undefined, '0']) { + if (value === undefined) delete process.env.CCTV_HOUSTON_ENABLED; + else process.env.CCTV_HOUSTON_ENABLED = value; + const { requested, sources } = await runCatalogWithMockedUpstreams(t); + assert.equal( + requested.includes(HOUSTON_CAMERAS_URL), + false, + 'the pack reaches TranStar only after an explicit opt-in', + ); + assert.deepEqual( + sources.filter((s) => s.cityId === 'houston'), + [], + ); + } } finally { for (const key of Object.keys(process.env)) { if (!(key in saved)) delete process.env[key]; diff --git a/src/data/cctvLod.js b/src/data/cctvLod.js index 44b2e8ba3..d91e3b4b3 100644 --- a/src/data/cctvLod.js +++ b/src/data/cctvLod.js @@ -34,6 +34,8 @@ const PROVIDER_STATIC_REFRESH_MS = Object.freeze({ 'ontario 511': 3 * 60 * 1000, // TxDOT publishes roughly once a minute; 3 min matches the other highway packs. txdot: 3 * 60 * 1000, + // TranStar says snapshots update about every 3 minutes. + 'houston transtar': 3 * 60 * 1000, // Digitraffic publishes a new weathercam frame on each station's // collectionInterval, 600 s for every station sampled; polling faster only // re-fetches the same JPEG. diff --git a/src/data/cctvLod.test.mjs b/src/data/cctvLod.test.mjs index 4f5982496..55821ae43 100644 --- a/src/data/cctvLod.test.mjs +++ b/src/data/cctvLod.test.mjs @@ -639,6 +639,7 @@ test('staticFrameRefreshMs follows known pack cadences and bounds explicit value assert.equal(staticFrameRefreshMs({ provider: 'Caltrans' }), 180_000); assert.equal(staticFrameRefreshMs({ provider: 'Ontario 511' }), 180_000); assert.equal(staticFrameRefreshMs({ provider: 'TxDOT' }), 180_000); + assert.equal(staticFrameRefreshMs({ provider: 'Houston TranStar' }), 180_000); // Digitraffic publishes one weathercam frame per 600 s collection interval. assert.equal(staticFrameRefreshMs({ provider: 'Fintraffic' }), 600_000); assert.equal( 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 008/156] 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 25ec9e10a35ea3b850d078b9d3de36735078899d Mon Sep 17 00:00:00 2001 From: bassem chagra Date: Thu, 22 Oct 2026 16:53:24 +0200 Subject: [PATCH 009/156] feat(cctv): vendor the Bosch MIC PTZ datasheet records the Amsterdam register names Adds MIC-7522-Z30B, MIC-7502-Z30B and MIC-7130-PB4 (MIC IP starlight 7000 HD) from the CCTV Camera Database v2.29.0 model-enrichment extract. Colour siblings (B/G/W) share one datasheet, so they and the register's x wildcard (MIC-7522-Z30x) are aliases of one record. The MIC datasheets state FOV without an axis, so these records add specs but no horizontal FOV range. Refreshes the dataset size in the module header and DATA_SOURCES.md. --- DATA_SOURCES.md | 7 +-- src/layers/cctv/modelSpecs.js | 67 ++++++++++++++++++++++++++--- src/layers/cctv/modelSpecs.test.mjs | 28 ++++++++++++ 3 files changed, 94 insertions(+), 8 deletions(-) diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 6d2ee498b..a9d676c85 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -208,7 +208,7 @@ The [Bhote Koshi event pack](public/events/bhote-koshi-2026/README.md), under `p | **Yale Bright Star Catalogue** (9,096 stars, sky background) | `sky/` | **Public domain** | ✅ (no restrictions) | "Yale Bright Star Catalogue (Hoffleit & Warren, via CDS)" (courtesy credit) | | **ESO Milky Way panorama** (eso0932a, blurred cube map, sky background) | `sky/` | **CC BY 4.0** | ✅ (attribution; changes stated) | "ESO/S. Brunier" | | **CCTV ground heights** (3,445 cameras) | `cctv_ground_heights/` | Precomputed camera placement heights, aligned to work with Google Photorealistic 3D Tiles (folder README) | — | — | -| **CCTV Camera Database** model-enrichment extract v2.26.0 (17 camera-model records) | `src/layers/cctv/modelSpecs.js` | **CC0-1.0** (public domain dedication) | ✅ (no restrictions) | "Specs: cctv-database.com" (courtesy — not legally required) | +| **CCTV Camera Database** model-enrichment extract v2.26.0 + v2.29.0 (20 camera-model records) | `src/layers/cctv/modelSpecs.js` | **CC0-1.0** (public domain dedication) | ✅ (no restrictions) | "Specs: cctv-database.com" (courtesy — not legally required) | ### ⚠️ TeleGeography is bundled but NonCommercial @@ -236,11 +236,12 @@ fixtures retain their provenance in `src/data/fixtures/README.md`. ### CCTV Camera Database (`src/layers/cctv/modelSpecs.js`) -The CCTV layer's hardware-model specs are a small verbatim extract (v2.26.0) of the +The CCTV layer's hardware-model specs are a small verbatim extract (v2.26.0, plus the Bosch MIC +records from v2.29.0) of the [CCTV Camera Database](https://github.com/ch-bas/cctv-camera-database), an open dataset of camera models transcribed from manufacturer datasheets, dedicated to the public domain under **CC0-1.0**. Only the records for hardware that the integrated public catalogs name (King -County, Sarasota County and Sioux Falls) are bundled, compiled into the module. Nothing is +County, Sarasota County, Sioux Falls and the City of Amsterdam register) are bundled, compiled into the module. Nothing is fetched at runtime. The records describe camera products (optics, resolution, night vision) only, never endpoints, stream paths or access data. CC0 requires no attribution; the CCTV HUD credits it anyway as `SPECS: CCTV-DATABASE.COM` beside any spec line it renders. diff --git a/src/layers/cctv/modelSpecs.js b/src/layers/cctv/modelSpecs.js index 2d93166a7..89fcf58d2 100644 --- a/src/layers/cctv/modelSpecs.js +++ b/src/layers/cctv/modelSpecs.js @@ -14,18 +14,25 @@ * * Spec records are vendored verbatim from the CCTV Camera Database * (https://github.com/ch-bas/cctv-camera-database, CC0-1.0) — an open dataset - * of 28,000+ camera models transcribed from manufacturer datasheets (84% with - * FOV, as of v2.26.0). This module vendors the records that the currently integrated public + * of 29,000+ camera models transcribed from manufacturer datasheets (76% with + * FOV, as of v2.29.0). This module vendors the records that the currently integrated public * catalogs publish hardware for — King County WA (Cohu, Bosch AUTODOME, Axis * Q6135-LE), plus the Bosch AUTODOME and Axis PTZ/multisensor models that - * appear in Sarasota County FL and Sioux Falls SD open data; packs that - * publish other hardware models can grow it, or vendor the full extract. + * appear in Sarasota County FL and Sioux Falls SD open data, and the Bosch MIC + * PTZs in the City of Amsterdam camera register; packs that publish other + * hardware models can grow it, or vendor the full extract. * * Scope guardrail: records describe camera PRODUCTS (optics, resolution, * night vision) — never endpoints, stream paths, or access/config data. */ -/** Verbatim records from the dataset's model-enrichment extract (v2.26.0). */ +/** + * Verbatim records from the dataset's model-enrichment extract (v2.26.0; the + * Bosch MIC records from v2.29.0). Bosch MIC order codes end in a colour + * letter (B black, G grey, W white) on one shared datasheet; those siblings, + * and the `x` wildcard registries write for the colour letter, are listed as + * aliases of the one vendored record. + */ const RECORDS = [ { id: 'axis-q6135-le', @@ -264,6 +271,56 @@ const RECORDS = [ autotracking: true, environment: ['outdoor'], }, + { + id: 'bosch-mic-7130-pb4', + brand: 'Bosch', + model: 'MIC-7130-PB4', + type: 'ptz', + url: 'https://www.cctv-database.com/camera/bosch-mic-7130-pb4/', + aliases: [ + 'MIC IP starlight 7000 HD', + 'MIC-7130-PG4', + 'MIC-7130-PW4', + 'MIC-7130-Px4', + ], + fov_deg: '2.1-59', + resolution: { mp: 1, w: 1280, h: 720, label: '720p' }, + ptz: true, + autotracking: true, + varifocal: true, + environment: ['outdoor'], + }, + { + id: 'bosch-mic-7502-z30b', + brand: 'Bosch', + model: 'MIC-7502-Z30B', + type: 'ptz', + url: 'https://www.cctv-database.com/camera/bosch-mic-7502-z30b/', + aliases: ['MIC-7502-Z30G', 'MIC-7502-Z30W', 'MIC-7502-Z30x'], + fov_deg: '2.3-63.7', + resolution: { mp: 2.13, w: 1920, h: 1080, label: '1080p' }, + ptz: true, + autotracking: true, + varifocal: true, + night_vision: { type: 'color' }, + environment: ['outdoor'], + }, + { + id: 'bosch-mic-7522-z30b', + brand: 'Bosch', + model: 'MIC-7522-Z30B', + type: 'ptz', + url: 'https://www.cctv-database.com/camera/bosch-mic-7522-z30b/', + aliases: ['MIC-7522-Z30G', 'MIC-7522-Z30W', 'MIC-7522-Z30x'], + fov_deg: '2.1-58.3', + // i18n-ignore: datasheet label + resolution: { mp: 2, w: 1920, h: 1080, label: '1080p HD' }, + ptz: true, + varifocal: true, + night_vision: { type: 'color' }, + environment: ['outdoor'], + ndaa_compliant: true, + }, { id: 'cohu-3920-idome', brand: 'Cohu', diff --git a/src/layers/cctv/modelSpecs.test.mjs b/src/layers/cctv/modelSpecs.test.mjs index 9fbdb00dd..852c6ad64 100644 --- a/src/layers/cctv/modelSpecs.test.mjs +++ b/src/layers/cctv/modelSpecs.test.mjs @@ -245,6 +245,34 @@ test('King County snapshot model strings still resolve', () => { ); }); +test('Amsterdam camera register model strings resolve', () => { + // The register writes the Bosch MIC colour letter as `x`. + assert.equal( + lookupModelSpec('Bosch MIC-7522-Z30x')?.id, + 'bosch-mic-7522-z30b', + ); + assert.equal( + lookupModelSpec('Bosch MIC-7502-Z30x')?.id, + 'bosch-mic-7502-z30b', + ); + assert.equal(lookupModelSpec('Bosch MIC-7130-Px4')?.id, 'bosch-mic-7130-pb4'); + assert.equal(lookupModelSpec('MIC-7522-Z30W')?.id, 'bosch-mic-7522-z30b'); + // Its ANPR units are not in the table: unknown, never a guess. + assert.equal(lookupModelSpec('CSG-8800'), null); + // The MIC and AUTODOME starlight 7000 HD (code-less names, so the brand + // is needed) stay apart. + assert.equal( + lookupModelSpec('MIC IP starlight 7000 HD', 'Bosch')?.id, + 'bosch-mic-7130-pb4', + ); + assert.equal( + lookupModelSpec('AUTODOME IP starlight 7000 HD', 'Bosch')?.id, + 'bosch-autodome-ip-starlight-7000-hd', + ); + // The MIC datasheets state FOV without an axis, so no horizontal range. + assert.equal(horizontalFovRangeDeg(lookupModelSpec('MIC-7522-Z30B')), null); +}); + test('catalog never overwrites an explicit FOV or a curated pose', () => { const [explicit, curated, cohu, pelco] = browserCatalog().buildCatalogFromSources([ From 6a7597d344960f7495fb3bdee2b34eed0a917c17 Mon Sep 17 00:00:00 2001 From: bassem chagra Date: Thu, 22 Oct 2026 17:01:50 +0200 Subject: [PATCH 010/156] docs(changelog): fragment for #1319 --- changelog.d/1319-cctv-bosch-mic-specs.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1319-cctv-bosch-mic-specs.md diff --git a/changelog.d/1319-cctv-bosch-mic-specs.md b/changelog.d/1319-cctv-bosch-mic-specs.md new file mode 100644 index 000000000..bf1bd0b7f --- /dev/null +++ b/changelog.d/1319-cctv-bosch-mic-specs.md @@ -0,0 +1,3 @@ +- Vendor the Bosch MIC PTZ datasheet records that the City of Amsterdam camera + register names (MIC-7522, MIC-7502, MIC-7130), with the register's `x` colour + wildcard as an alias, from the CCTV Camera Database v2.29.0 (ch-bas, #1319). From dec6cb937ec290d514f2c44d8cdb87a1b4f7fba7 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Thu, 22 Oct 2026 23:27:00 +0100 Subject: [PATCH 011/156] test(cctv): verify Bosch MIC aliases and preserve estimated optics --- DATA_SOURCES.md | 10 ++++--- docs/CURRENT-STATE.md | 4 +++ src/layers/cctv/modelSpecs.js | 20 +++++++------- src/layers/cctv/modelSpecs.test.mjs | 41 +++++++++++++++++++++++++++++ 4 files changed, 62 insertions(+), 13 deletions(-) diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index a9d676c85..1602cfa49 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -236,13 +236,15 @@ fixtures retain their provenance in `src/data/fixtures/README.md`. ### CCTV Camera Database (`src/layers/cctv/modelSpecs.js`) -The CCTV layer's hardware-model specs are a small verbatim extract (v2.26.0, plus the Bosch MIC +The CCTV layer's hardware-model specs are a small extract (v2.26.0, plus the Bosch MIC records from v2.29.0) of the [CCTV Camera Database](https://github.com/ch-bas/cctv-camera-database), an open dataset of camera models transcribed from manufacturer datasheets, dedicated to the public domain under -**CC0-1.0**. Only the records for hardware that the integrated public catalogs name (King -County, Sarasota County, Sioux Falls and the City of Amsterdam register) are bundled, compiled into the module. Nothing is -fetched at runtime. The records describe camera products (optics, resolution, night vision) +**CC0-1.0**. Only the records for hardware named by the integrated public catalogs (King +County, Sarasota County and Sioux Falls), plus the City of Amsterdam register proposed +in [#251](https://github.com/bilawalsidhu/gods-eye-view/pull/251), are bundled, compiled into the module. Nothing is +fetched at runtime. The MIC specification fields are unchanged; product titles are shortened +to model codes and colour aliases are added locally. The records describe camera products (optics, resolution, night vision) only, never endpoints, stream paths or access data. CC0 requires no attribution; the CCTV HUD credits it anyway as `SPECS: CCTV-DATABASE.COM` beside any spec line it renders. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index a9db91490..13174d759 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4585,6 +4585,10 @@ silently demoting every later lookup for the session. multi-sensor datasheets publish a capability range, not the lens's current zoom, so those cameras keep their estimate and show `SPEC FOV min–max° (CAPABILITY)` beside it. Public camera state carries `fovSource`, `specFovMinDeg`, `specFovMaxDeg` and `spec` (#452). + The table also includes Bosch MIC-7130, MIC-7502 and MIC-7522 records for the Amsterdam + register proposed in #251, including its `x` colour aliases (#1319). Their axis-less + datasheet FOV does not supply a horizontal capability range or replace the estimated + FOV. Adding these records does not enable the proposed source pack. - **FIRMS**: no ground clamping (zero 3D-tiles height sampling), ≤18 screen-decluttered ambient labels, click-to-inspect detail card, 2.5k/3k sprite budgets viewport-clipped by FRP. - **CCTV v2 foundation:** a pitched frustum wireframe (4 corner rays + far-cap rectangle) with a monitor plane at the frustum's diff --git a/src/layers/cctv/modelSpecs.js b/src/layers/cctv/modelSpecs.js index 89fcf58d2..ba6702584 100644 --- a/src/layers/cctv/modelSpecs.js +++ b/src/layers/cctv/modelSpecs.js @@ -12,24 +12,26 @@ * Cameras without an identified model are untouched — unknown never implies * anything. * - * Spec records are vendored verbatim from the CCTV Camera Database + * Specification fields are vendored from the CCTV Camera Database * (https://github.com/ch-bas/cctv-camera-database, CC0-1.0) — an open dataset * of 29,000+ camera models transcribed from manufacturer datasheets (76% with - * FOV, as of v2.29.0). This module vendors the records that the currently integrated public + * FOV, as of v2.29.0). This module vendors records that the integrated public * catalogs publish hardware for — King County WA (Cohu, Bosch AUTODOME, Axis * Q6135-LE), plus the Bosch AUTODOME and Axis PTZ/multisensor models that - * appear in Sarasota County FL and Sioux Falls SD open data, and the Bosch MIC - * PTZs in the City of Amsterdam camera register; packs that publish other - * hardware models can grow it, or vendor the full extract. + * appear in Sarasota County FL and Sioux Falls SD open data, plus the Bosch MIC + * PTZs for the City of Amsterdam camera register proposed in #251; packs that + * publish other hardware models can grow it, or vendor the full extract. * * Scope guardrail: records describe camera PRODUCTS (optics, resolution, * night vision) — never endpoints, stream paths, or access/config data. */ /** - * Verbatim records from the dataset's model-enrichment extract (v2.26.0; the - * Bosch MIC records from v2.29.0). Bosch MIC order codes end in a colour - * letter (B black, G grey, W white) on one shared datasheet; those siblings, + * Records from the dataset's model-enrichment extract (v2.26.0; the Bosch MIC + * records from v2.29.0). MIC specification fields are unchanged; product + * titles are shortened to model codes and colour aliases are added locally. + * Bosch MIC order codes end in a colour letter (B black, G grey, W white) + * on one shared datasheet; those siblings, * and the `x` wildcard registries write for the colour letter, are listed as * aliases of the one vendored record. */ @@ -488,7 +490,7 @@ const NAME_INDEX = RECORDS.flatMap((record) => { * guesses). * @param {string} model - Hardware model as named by a source pack or seed. * @param {string} [manufacturer] - The source's manufacturer, when published. - * @returns {Object|null} The verbatim spec record, or null when unknown. + * @returns {Object|null} The matched spec record, or null when unknown. */ export function lookupModelSpec(model, manufacturer) { const query = words(model).map(stripVariantSuffix); diff --git a/src/layers/cctv/modelSpecs.test.mjs b/src/layers/cctv/modelSpecs.test.mjs index 852c6ad64..d76f37c24 100644 --- a/src/layers/cctv/modelSpecs.test.mjs +++ b/src/layers/cctv/modelSpecs.test.mjs @@ -273,6 +273,47 @@ test('Amsterdam camera register model strings resolve', () => { assert.equal(horizontalFovRangeDeg(lookupModelSpec('MIC-7522-Z30B')), null); }); +test('Bosch MIC colour aliases resolve without accepting unknown variants', () => { + for (const [pattern, id] of [ + ['MIC-7130-P?4', 'bosch-mic-7130-pb4'], + ['MIC-7502-Z30?', 'bosch-mic-7502-z30b'], + ['MIC-7522-Z30?', 'bosch-mic-7522-z30b'], + ]) { + for (const colour of ['B', 'G', 'W', 'x']) { + const model = pattern.replace('?', colour); + assert.equal(lookupModelSpec(model)?.id, id, model); + assert.equal(lookupModelSpec(`bosch ${model.toLowerCase()}`)?.id, id); + assert.equal(lookupModelSpec(model, 'Bosch')?.id, id); + assert.equal(lookupModelSpec(model, 'Axis'), null); + assert.equal(lookupModelSpec(`${model}-UNKNOWN`), null); + } + assert.equal(lookupModelSpec(pattern.replace('?', 'R')), null); + } +}); + +test('Bosch MIC catalog enrichment preserves estimated and explicit optics', () => { + for (const model of [ + 'Bosch MIC-7130-Px4', + 'Bosch MIC-7502-Z30x', + 'Bosch MIC-7522-Z30x', + ]) { + for (const pose of [{}, { fovDeg: 44 }, { poseSource: 'curated' }]) { + const [camera] = browserCatalog().buildCatalogFromSources([ + { id: 'amsterdam-mic', lat: 52.37, lon: 4.9, model, ...pose }, + ]); + assert.ok(camera.spec, model); + assert.equal(camera.fovDeg, pose.fovDeg ?? 74, model); + assert.equal(camera.fovSource, undefined); + assert.equal(camera.specFovMinDeg, undefined); + assert.equal(camera.specFovMaxDeg, undefined); + assert.equal(horizontalFovRangeDeg(camera.spec), null); + assert.equal(fixedHorizontalFovDeg(camera.spec), null); + assert.equal(specFovCapabilityToken(camera), null); + assert.match(specSummary(camera.spec), /^Bosch MIC-.* · PTZ$/); + } + } +}); + test('catalog never overwrites an explicit FOV or a curated pose', () => { const [explicit, curated, cohu, pelco] = browserCatalog().buildCatalogFromSources([ From 38d29f2beb22b5200b794751b32f1f21a0409e25 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 27 Oct 2026 13:09:32 +0400 Subject: [PATCH 012/156] feat(fires): FRP intensity filter, confidence switch and six-stop color ramp (HAL-676) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Color every FIRMS detection by fire radiative power on the Portolan cold → hot ramp (blue · teal · lime · amber · orange · red at 0 · 5 · 20 · 50 · 100 · 300 MW), replacing the three warm stops that made a 2 MW and a 40 MW fire look alike. Heat cells, glow sprites and card accents all read the same ramp (src/data/firmsIntensity.js). Add a minimum-FRP log slider whose track is painted with the ramp (so it doubles as the legend), an All / Nominal+ / High confidence switch and an "N of M shown" counter to the Active Fires row. The filter runs once in ingestion.applyFilter(), so markers, cards, heat cells, the context store, the top-fires query and the analyst records all see one set. Default is 0 MW / All: many of the strongest fires are rated low. Persist both options in the layer state (token w: minFrp `f`, confidence `c`), including the short share codec, and give the agent's get_active_fires tool matching min_frp_mw / confidence arguments. Layer panel gains generic `slider` and `segmented` row descriptors (src/ui/rowFilterControls.js). New strings in es / ru / zh-CN. Co-Authored-By: Claude Fable 5.1 --- scripts/format-scope.json | 6 +- scripts/package-boundaries.json | 17 +++ src/data/firmsCards.test.mjs | 22 ++- src/data/firmsIntensity.js | 209 +++++++++++++++++++++++++++++ src/data/firmsIntensity.test.mjs | 207 ++++++++++++++++++++++++++++ src/data/firmsLabels.js | 23 ++-- src/data/firmsLabels.test.mjs | 15 ++- src/data/layerState.js | 21 ++- src/data/layerState.test.mjs | 83 +++++++++++- src/i18n/locales/es.json | 14 +- src/i18n/locales/ru.json | 14 +- src/i18n/locales/zh-CN.json | 14 +- src/i18n/same-as-english.json | 5 +- src/layers/firms/ingestion.js | 32 ++++- src/layers/firms/lifecycle.js | 4 + src/layers/firms/model.js | 57 +++++--- src/layers/firms/policy.js | 19 ++- src/layers/firms/queries.js | 142 +++++++++++++++++++- src/layers/firms/rendering.js | 5 +- src/layers/firms/state.js | 28 ++++ src/share/viewCodec.js | 1 + src/tools/queries/hazards.js | 64 ++++++++- src/tools/queries/hazards.test.mjs | 36 +++++ src/ui/layerPanel.js | 54 +++++++- src/ui/rowFilterControls.js | 169 +++++++++++++++++++++++ src/ui/rowFilterControls.test.mjs | 207 ++++++++++++++++++++++++++++ src/ui/styles/radio.css | 102 ++++++++++++++ 27 files changed, 1500 insertions(+), 70 deletions(-) create mode 100644 src/data/firmsIntensity.js create mode 100644 src/data/firmsIntensity.test.mjs create mode 100644 src/ui/rowFilterControls.js create mode 100644 src/ui/rowFilterControls.test.mjs diff --git a/scripts/format-scope.json b/scripts/format-scope.json index ee23b8b6a..c7ae4e3cf 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1117,5 +1117,9 @@ "src/testSupport/shareViewFixtures.mjs", "src/ui/shareLinks.test.mjs", "src/ui/shareButton.test.mjs", - "src/share/shareLinkPath.test.mjs" + "src/share/shareLinkPath.test.mjs", + "src/data/firmsIntensity.js", + "src/data/firmsIntensity.test.mjs", + "src/ui/rowFilterControls.js", + "src/ui/rowFilterControls.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index b42d90fdc..ca2ecb542 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -495,6 +495,8 @@ "src/ui/railCards.js", "src/ui/railCardBlocks.js", "src/ui/slider.js", + "src/ui/rowFilterControls.js", + "src/ui/segmentedControl.js", "src/ui/chipGroup.js", "src/ui/rowList.js", "src/ui/railTimeline.js", @@ -670,6 +672,7 @@ "src/data/detectionPolicy.js", "src/data/installationFeedback.js", "src/data/idSwitches.js", + "src/data/firmsIntensity.js", "src/data/layerState.js", "src/data/layerStateCoordinator.js", "src/data/layerStateTokenReservations.json", @@ -734,6 +737,7 @@ "src/ui/effects.js", "src/ui/frameRateMonitor.js", "src/ui/layerPanel.js", + "src/ui/rowFilterControls.js", "src/ui/weatherPanel.js", "src/ui/railCards.js", "src/ui/railCardBlocks.js", @@ -954,6 +958,7 @@ "src/data/contactMatch.js", "src/data/iconOrientation.js", "src/data/idSwitches.js", + "src/data/firmsIntensity.js", "src/data/layerState.js", "src/data/layerStateTokenReservations.json", "src/data/modelVisualAnchor.js", @@ -1007,6 +1012,7 @@ "src/data/contactMatch.js", "src/data/iconOrientation.js", "src/data/idSwitches.js", + "src/data/firmsIntensity.js", "src/data/layerState.js", "src/data/layerStateTokenReservations.json", "src/data/modelVisualAnchor.js", @@ -1052,6 +1058,7 @@ "src/data/contactMatch.js", "src/data/iconOrientation.js", "src/data/idSwitches.js", + "src/data/firmsIntensity.js", "src/data/layerState.js", "src/data/layerStateCoordinator.js", "src/data/layerStateTokenReservations.json", @@ -1136,6 +1143,7 @@ "exports": ["./layers/firms"], "modules": [ "src/data/firmsAdapt.js", + "src/data/firmsIntensity.js", "src/data/firmsLabels.js", "src/data/iconOrientation.js", "src/layers/firms/anchors.js", @@ -1525,6 +1533,7 @@ "modules": [ "src/data/iconOrientation.js", "src/data/idSwitches.js", + "src/data/firmsIntensity.js", "src/data/layerState.js", "src/data/layerStateTokenReservations.json", "src/data/radioCountry.js", @@ -1848,6 +1857,7 @@ "src/data/fireAnchors.js", "src/data/firmsAdapt.js", "src/data/firmsHeatmap.js", + "src/data/firmsIntensity.js", "src/data/firmsLabels.js", "src/data/flights.js", "src/data/flowMatch.js", @@ -2259,6 +2269,7 @@ "src/ui/effects.js", "src/ui/frameRateMonitor.js", "src/ui/layerPanel.js", + "src/ui/rowFilterControls.js", "src/ui/weatherPanel.js", "src/ui/railCards.js", "src/ui/railCardBlocks.js", @@ -2687,6 +2698,7 @@ "src/data/detectionDraw.js", "src/data/detectionPolicy.js", "src/data/firmsAdapt.js", + "src/data/firmsIntensity.js", "src/data/firmsLabels.js", "src/data/flowMatch.js", "src/data/focusDeemphasis.js", @@ -3283,6 +3295,7 @@ "src/i18n/marker.js", "src/data/installationFeedback.js", "src/data/idSwitches.js", + "src/data/firmsIntensity.js", "src/data/layerState.js", "src/data/layerStateCoordinator.js", "src/data/layerStateTokenReservations.json", @@ -3899,6 +3912,7 @@ "src/styles/surveillance.js", "src/styles/thermal.js", "src/view/index.js", + "src/data/firmsIntensity.js", "src/data/layerState.js", "src/data/layerStateTokenReservations.json", "src/data/idSwitches.js", @@ -3975,6 +3989,8 @@ "src/tools/queries/brief.js", "src/tools/queries/maritime.js", "src/tools/queries/share.js", + "src/data/firmsIntensity.js", + "src/data/firmsAdapt.js", "src/data/idSwitches.js", "src/data/vesselTypes.js", "src/data/layerState.js", @@ -4107,6 +4123,7 @@ "modules": [ "src/view/index.js", "src/data/idSwitches.js", + "src/data/firmsIntensity.js", "src/data/layerState.js", "src/data/layerStateTokenReservations.json" ], diff --git a/src/data/firmsCards.test.mjs b/src/data/firmsCards.test.mjs index a0ee83c01..f82b8da80 100644 --- a/src/data/firmsCards.test.mjs +++ b/src/data/firmsCards.test.mjs @@ -68,11 +68,27 @@ test('buildFireCard: power that rounds to 10 MW drops the decimal', () => { assert.equal(selected.title, 'FIRE · 10 MW'); }); -test('buildFireCard: SNPP satellite code renders as SNPP, weak fire is not red', () => { +test('buildFireCard: SNPP satellite code renders as SNPP, weak fire is blue', () => { const card = buildFireCard({ fire: fire({ satellite: 'N', frp: 0.8, confidence: 0.3 }), position: {} }, NOW); assert.match(card.details[0], /SNPP$/); assert.equal(card.title, '▲ 0.8 MW'); - assert.notEqual(card.accent, accentForSeverity('red')); + assert.equal(card.accent, accentForSeverity('blue')); +}); + +test('buildFireCard: accent steps through the FRP ramp at 5 · 20 · 50 · 100 · 300 MW', () => { + const accentAt = (frp) => + buildFireCard({ fire: fire({ frp }), position: {} }, NOW).accent; + assert.equal(accentAt(4.9), accentForSeverity('blue')); + assert.equal(accentAt(5), accentForSeverity('teal')); + assert.equal(accentAt(20), accentForSeverity('lime')); + assert.equal(accentAt(50), accentForSeverity('amber')); + assert.equal(accentAt(100), accentForSeverity('orange')); + assert.equal(accentAt(300), accentForSeverity('red')); + // Confidence never moves the color: a low-confidence 864 MW fire stays red. + assert.equal( + buildFireCard({ fire: fire({ frp: 864, confidence: 0.3 }), position: {} }, NOW).accent, + accentForSeverity('red'), + ); }); test('buildSelectedFireCard: full detail card with coords, selected flag, no fade', () => { @@ -125,7 +141,7 @@ test('buildCellCard: singular noun and missing-age omission', () => { const card = buildCellCard(candidate, NOW); assert.equal(card.title, '1 FIRE'); assert.equal(card.details[0], 'max 9.9 MW'); - assert.equal(card.accent, accentForSeverity('yellow'), 'missing accent defaults to yellow'); + assert.equal(card.accent, accentForSeverity('blue'), 'missing accent defaults to the coldest stop'); }); test('FIRMS host policy keeps ambient cards bounded and selected cards protected', () => { diff --git a/src/data/firmsIntensity.js b/src/data/firmsIntensity.js new file mode 100644 index 000000000..417566346 --- /dev/null +++ b/src/data/firmsIntensity.js @@ -0,0 +1,209 @@ +/** + * @module firmsIntensity + * @description Fire intensity policy shared by the Active Fires layer and the + * agent fire tool: the six-stop cold → hot FRP color ramp, the log-scale + * slider that doubles as its legend, and the one filter every consumer + * (markers, cards, heat cells, the top-fires query, `get_active_fires`) runs + * before anything is rendered or selected. Pure — no Cesium, no DOM. + * + * Hue order follows the Portolan FIRMS explorer (`FRP_RAMP`, stepped, not + * interpolated). The thresholds are GEV's own, set on a real week's + * percentiles so every color appears: Portolan's 0/10/50/200/500/1000 MW + * leaves one red fire a week (HAL-675). + */ + +/** + * Stepped FRP ramp, coldest first. A detection takes the LAST stop whose + * `minFrp` it reaches. Names key the glow-sprite cache and the card accents. + */ +export const FRP_RAMP = Object.freeze([ + Object.freeze({ name: 'blue', minFrp: 0, hex: '#4a5bd4' }), + Object.freeze({ name: 'teal', minFrp: 5, hex: '#39a0a8' }), + Object.freeze({ name: 'lime', minFrp: 20, hex: '#c9cf4a' }), + Object.freeze({ name: 'amber', minFrp: 50, hex: '#f2b134' }), + Object.freeze({ name: 'orange', minFrp: 100, hex: '#e8722c' }), + Object.freeze({ name: 'red', minFrp: 300, hex: '#d1382a' }), +]); + +/** Slider ticks: the ramp thresholds, so slider, legend and markers agree. */ +export const FRP_SLIDER_TICKS = Object.freeze( + FRP_RAMP.map((stop) => stop.minFrp), +); + +/** Minimum-FRP values the share codec accepts, in whole megawatts. */ +export const MIN_FRP_MW_MIN = 0; +export const MIN_FRP_MW_MAX = 10_000; + +/** Confidence switch positions; VIIRS reports only low / nominal / high. */ +export const FIRMS_CONFIDENCE_MODES = Object.freeze(['all', 'nominal', 'high']); + +/** + * Normalized-confidence floor for a switch position. The thresholds match + * `confidenceBucket` in the layer model (0.3 / 0.6 / 0.9 after firmsAdapt). + */ +const CONFIDENCE_FLOOR = Object.freeze({ all: 0, nominal: 0.45, high: 0.75 }); + +export const DEFAULT_FIRE_FILTER = Object.freeze({ + minFrp: 0, + confidence: 'all', +}); + +/** + * The ramp stop for a fire radiative power. + * @param {number} frp Megawatts; non-finite or negative reads as 0. + * @returns {{name: string, minFrp: number, hex: string}} + */ +export function frpRampStop(frp) { + const value = Number.isFinite(frp) ? Math.max(0, frp) : 0; + let stop = FRP_RAMP[0]; + for (const candidate of FRP_RAMP) { + if (value >= candidate.minFrp) stop = candidate; + else break; + } + return stop; +} + +/** `#rrggbb` → `[r, g, b]` in 0..255. */ +export function hexToRgb(hex) { + const text = String(hex).replace('#', ''); + return [0, 2, 4].map((offset) => + Number.parseInt(text.slice(offset, offset + 2), 16), + ); +} + +/** + * Log-scale slider axis: position 0 means "no minimum" (0 MW), and the rest + * of the track runs 1 MW → SLIDER_MAX_MW logarithmically. The axis floor is 1 + * MW because the median detection is ~6 MW and a sub-megawatt floor hides + * nothing worth a tick. + */ +export const SLIDER_MAX_MW = 1000; +const LOG_MAX = Math.log10(SLIDER_MAX_MW); + +/** + * Track position in 0..1 for a minimum FRP. + * @param {number} minFrp Megawatts. + * @returns {number} + */ +export function sliderPositionForFrp(minFrp) { + if (!Number.isFinite(minFrp) || minFrp < 1) return 0; + return Math.min(1, Math.log10(minFrp) / LOG_MAX); +} + +/** + * Minimum FRP for a track position, rounded to whole megawatts so a slider + * value and its share-link form are the same number. + * @param {number} position 0..1. + * @returns {number} + */ +export function frpForSliderPosition(position) { + const p = Number.isFinite(position) ? Math.max(0, Math.min(1, position)) : 0; + if (p <= 0) return 0; + const frp = Math.pow(10, p * LOG_MAX); + return Math.max(1, Math.round(frp)); +} + +/** + * CSS `linear-gradient` painting the stepped ramp along the log axis, so the + * slider track IS the legend: the color under the thumb is the color of the + * weakest fire still shown. + * @returns {string} + */ +export function rampTrackGradient() { + const parts = []; + FRP_RAMP.forEach((stop, index) => { + const start = sliderPositionForFrp(stop.minFrp) * 100; + const next = FRP_RAMP[index + 1]; + const end = next ? sliderPositionForFrp(next.minFrp) * 100 : 100; + parts.push(`${stop.hex} ${start.toFixed(2)}% ${end.toFixed(2)}%`); + }); + return `linear-gradient(90deg, ${parts.join(', ')})`; +} + +/** + * Minimum FRP → whole megawatts inside the codec bounds, or null. + * @param {*} value + * @returns {?number} + */ +export function normalizeMinFrp(value) { + const number = + typeof value === 'number' + ? value + : typeof value === 'string' && value.trim() !== '' + ? Number(value) + : NaN; + if (!Number.isFinite(number)) return null; + const rounded = Math.round(number); + return rounded >= MIN_FRP_MW_MIN && rounded <= MIN_FRP_MW_MAX + ? rounded + : null; +} + +/** + * Confidence mode → one of FIRMS_CONFIDENCE_MODES, or null. + * @param {*} value + * @returns {?string} + */ +export function normalizeConfidenceMode(value) { + const text = String(value ?? '') + .trim() + .toLowerCase(); + return FIRMS_CONFIDENCE_MODES.includes(text) ? text : null; +} + +/** + * Sanitize a partial filter against the current one; unknown or invalid + * fields keep their current value. + * @param {Object} [params] + * @param {{minFrp: number, confidence: string}} [current] + * @returns {{minFrp: number, confidence: string}} + */ +export function normalizeFireFilter( + params = {}, + current = DEFAULT_FIRE_FILTER, +) { + const minFrp = normalizeMinFrp(params?.minFrp); + const confidence = normalizeConfidenceMode(params?.confidence); + return { + minFrp: minFrp === null ? current.minFrp : minFrp, + confidence: confidence === null ? current.confidence : confidence, + }; +} + +/** + * Whether one fire passes the filter. `confidence` is the normalized 0..1 + * value firmsAdapt produces; `frp` is megawatts. + * @param {{frp: number, confidence: number}} fire + * @param {{minFrp: number, confidence: string}} filter + * @returns {boolean} + */ +export function firePassesFilter(fire, filter = DEFAULT_FIRE_FILTER) { + const frp = Number.isFinite(fire?.frp) ? fire.frp : 0; + if (frp < (filter.minFrp || 0)) return false; + const floor = CONFIDENCE_FLOOR[filter.confidence] ?? 0; + if (floor <= 0) return true; + const confidence = Number.isFinite(fire?.confidence) ? fire.confidence : 0; + return confidence >= floor; +} + +/** + * The fires that pass the filter, in input order. Returns the SAME array when + * the filter is the default, so callers can tell "nothing hidden" cheaply. + * @template T + * @param {Array} fires Internal records (normalized confidence). + * @param {{minFrp: number, confidence: string}} filter + * @returns {Array} + */ +export function filterFires(fires, filter = DEFAULT_FIRE_FILTER) { + if (!Array.isArray(fires)) return []; + if (isDefaultFireFilter(filter)) return fires; + return fires.filter((fire) => firePassesFilter(fire, filter)); +} + +/** True when the filter hides nothing. */ +export function isDefaultFireFilter(filter) { + return ( + !(filter?.minFrp > 0) && + (filter?.confidence === 'all' || filter?.confidence == null) + ); +} diff --git a/src/data/firmsIntensity.test.mjs b/src/data/firmsIntensity.test.mjs new file mode 100644 index 000000000..558068e67 --- /dev/null +++ b/src/data/firmsIntensity.test.mjs @@ -0,0 +1,207 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + DEFAULT_FIRE_FILTER, + FIRMS_CONFIDENCE_MODES, + FRP_RAMP, + FRP_SLIDER_TICKS, + filterFires, + firePassesFilter, + frpForSliderPosition, + frpRampStop, + hexToRgb, + isDefaultFireFilter, + normalizeConfidenceMode, + normalizeFireFilter, + normalizeMinFrp, + rampTrackGradient, + sliderPositionForFrp, +} from './firmsIntensity.js'; + +test('the ramp has six cold → hot stops at 0 · 5 · 20 · 50 · 100 · 300 MW in Portolan hue order', () => { + assert.deepEqual( + FRP_RAMP.map((stop) => [stop.name, stop.minFrp, stop.hex]), + [ + ['blue', 0, '#4a5bd4'], + ['teal', 5, '#39a0a8'], + ['lime', 20, '#c9cf4a'], + ['amber', 50, '#f2b134'], + ['orange', 100, '#e8722c'], + ['red', 300, '#d1382a'], + ], + ); + assert.deepEqual([...FRP_SLIDER_TICKS], [0, 5, 20, 50, 100, 300]); + assert.ok(Object.isFrozen(FRP_RAMP)); +}); + +test('a detection takes the last stop it reaches; garbage reads as 0 MW', () => { + assert.equal(frpRampStop(0).name, 'blue'); + assert.equal(frpRampStop(4.99).name, 'blue'); + assert.equal(frpRampStop(5).name, 'teal'); + assert.equal(frpRampStop(19.9).name, 'teal'); + assert.equal(frpRampStop(20).name, 'lime'); + assert.equal(frpRampStop(50).name, 'amber'); + assert.equal(frpRampStop(100).name, 'orange'); + assert.equal(frpRampStop(299.9).name, 'orange'); + assert.equal(frpRampStop(300).name, 'red'); + assert.equal(frpRampStop(1246).name, 'red'); + assert.equal(frpRampStop(NaN).name, 'blue'); + assert.equal(frpRampStop(-3).name, 'blue'); + assert.equal(frpRampStop(undefined).name, 'blue'); +}); + +test('hex stops convert to the "r, g, b" accents the cards paint', () => { + assert.deepEqual(hexToRgb('#4a5bd4'), [74, 91, 212]); + assert.deepEqual(hexToRgb('d1382a'), [209, 56, 42]); +}); + +test('the log slider pins 0 MW at the left and runs 1 → 1000 MW; values round-trip', () => { + assert.equal(sliderPositionForFrp(0), 0); + assert.equal(sliderPositionForFrp(0.5), 0); + assert.equal(sliderPositionForFrp(1), 0); + assert.ok(Math.abs(sliderPositionForFrp(10) - 1 / 3) < 1e-9); + assert.ok(Math.abs(sliderPositionForFrp(100) - 2 / 3) < 1e-9); + assert.equal(sliderPositionForFrp(1000), 1); + assert.equal(sliderPositionForFrp(5000), 1, 'clamped past the axis end'); + assert.equal(frpForSliderPosition(0), 0); + assert.equal(frpForSliderPosition(1), 1000); + assert.equal(frpForSliderPosition(-1), 0); + assert.equal(frpForSliderPosition(2), 1000); + for (const frp of FRP_SLIDER_TICKS) { + assert.equal( + frpForSliderPosition(sliderPositionForFrp(frp)), + frp, + `tick ${frp} MW round-trips through the axis`, + ); + } + // Ticks spread along the track instead of bunching at one end. + const positions = FRP_SLIDER_TICKS.map(sliderPositionForFrp); + for (let i = 1; i < positions.length; i += 1) { + assert.ok(positions[i] - positions[i - 1] >= 0.09, `tick gap ${i}`); + } +}); + +test('the track gradient is the stepped ramp on the log axis, so it doubles as the legend', () => { + const gradient = rampTrackGradient(); + assert.match(gradient, /^linear-gradient\(90deg, /); + assert.match(gradient, /#4a5bd4 0\.00% 23\.3\d%/); + assert.match(gradient, /#39a0a8 23\.3\d% 43\.3\d%/); + assert.match(gradient, /#d1382a 82\.5\d% 100\.00%\)$/); + assert.equal(gradient.match(/#/g).length, FRP_RAMP.length); +}); + +test('minimum FRP normalizes to whole megawatts inside the codec bounds', () => { + assert.equal(normalizeMinFrp(0), 0); + assert.equal(normalizeMinFrp(19.6), 20); + assert.equal(normalizeMinFrp('100'), 100); + assert.equal(normalizeMinFrp(10000), 10000); + assert.equal(normalizeMinFrp(10001), null); + assert.equal(normalizeMinFrp(-1), null); + assert.equal(normalizeMinFrp('abc'), null); + assert.equal(normalizeMinFrp(''), null); + assert.equal(normalizeMinFrp(null), null); + assert.equal(normalizeMinFrp(NaN), null); +}); + +test('confidence modes are exactly all / nominal / high', () => { + assert.deepEqual([...FIRMS_CONFIDENCE_MODES], ['all', 'nominal', 'high']); + assert.equal(normalizeConfidenceMode(' High '), 'high'); + assert.equal(normalizeConfidenceMode('nominal'), 'nominal'); + assert.equal(normalizeConfidenceMode('low'), null); + assert.equal(normalizeConfidenceMode(0.9), null); + assert.equal(normalizeConfidenceMode(undefined), null); +}); + +test('a partial filter patches the current one and invalid fields keep their value', () => { + assert.deepEqual(normalizeFireFilter(), DEFAULT_FIRE_FILTER); + assert.deepEqual(normalizeFireFilter({ minFrp: 20 }), { + minFrp: 20, + confidence: 'all', + }); + assert.deepEqual( + normalizeFireFilter( + { confidence: 'high' }, + { minFrp: 50, confidence: 'all' }, + ), + { minFrp: 50, confidence: 'high' }, + ); + assert.deepEqual( + normalizeFireFilter( + { minFrp: 'nope', confidence: 'medium' }, + { minFrp: 50, confidence: 'nominal' }, + ), + { minFrp: 50, confidence: 'nominal' }, + ); + assert.equal(isDefaultFireFilter(DEFAULT_FIRE_FILTER), true); + assert.equal(isDefaultFireFilter({ minFrp: 0, confidence: 'all' }), true); + assert.equal(isDefaultFireFilter({ minFrp: 1, confidence: 'all' }), false); + assert.equal(isDefaultFireFilter({ minFrp: 0, confidence: 'high' }), false); +}); + +test('the filter keeps a fire at or above the floor and at or above the confidence mode', () => { + const low = { frp: 864, confidence: 0.3 }; + const nominal = { frp: 20, confidence: 0.6 }; + const high = { frp: 2, confidence: 0.9 }; + assert.equal(firePassesFilter(low), true, 'default shows everything'); + assert.equal( + firePassesFilter(nominal, { minFrp: 20, confidence: 'all' }), + true, + ); + assert.equal( + firePassesFilter(nominal, { minFrp: 21, confidence: 'all' }), + false, + ); + assert.equal( + firePassesFilter(low, { minFrp: 0, confidence: 'nominal' }), + false, + ); + assert.equal( + firePassesFilter(nominal, { minFrp: 0, confidence: 'nominal' }), + true, + ); + assert.equal( + firePassesFilter(high, { minFrp: 0, confidence: 'nominal' }), + true, + ); + assert.equal( + firePassesFilter(nominal, { minFrp: 0, confidence: 'high' }), + false, + ); + assert.equal(firePassesFilter(high, { minFrp: 0, confidence: 'high' }), true); + assert.equal( + firePassesFilter(high, { minFrp: 5, confidence: 'high' }), + false, + ); + // Missing numbers read as 0, never as a pass-through. + assert.equal(firePassesFilter({}, { minFrp: 1, confidence: 'all' }), false); + assert.equal( + firePassesFilter({ frp: 10 }, { minFrp: 0, confidence: 'nominal' }), + false, + ); +}); + +test('filterFires returns the same array for the default filter and preserves order otherwise', () => { + const fires = [ + { frp: 864, confidence: 0.3 }, + { frp: 20, confidence: 0.6 }, + { frp: 2, confidence: 0.9 }, + { frp: 120, confidence: 0.9 }, + ]; + assert.equal(filterFires(fires), fires); + assert.equal(filterFires(fires, { minFrp: 0, confidence: 'all' }), fires); + assert.deepEqual( + filterFires(fires, { minFrp: 20, confidence: 'all' }).map((f) => f.frp), + [864, 20, 120], + ); + assert.deepEqual( + filterFires(fires, { minFrp: 0, confidence: 'high' }).map((f) => f.frp), + [2, 120], + ); + assert.deepEqual( + filterFires(fires, { minFrp: 100, confidence: 'nominal' }).map( + (f) => f.frp, + ), + [120], + ); + assert.deepEqual(filterFires(null, { minFrp: 1, confidence: 'all' }), []); +}); diff --git a/src/data/firmsLabels.js b/src/data/firmsLabels.js index b9e9d1765..3af6a15a5 100644 --- a/src/data/firmsLabels.js +++ b/src/data/firmsLabels.js @@ -4,6 +4,8 @@ * dedicated card canvas moved into the shared world-overlay host. */ +import { FRP_RAMP, hexToRgb } from './firmsIntensity.js'; + /** Shipped ambient FIRMS card ceiling. Selected fires bypass this cohort. */ export const FIRMS_AMBIENT_COHORT_LIMIT = 18; export const FIRMS_OVERLAY_SOURCE_ID = 'firms'; @@ -23,20 +25,23 @@ export function fireDetectionKey(fire) { return `firms:${lat}:${lon}:${acq}:${source}`; } -/** Severity accent palette — matches the FIRMS glow-sprite color stops. */ -const ACCENT_RGB = Object.freeze({ - red: '224, 82, 82', - orange: '240, 178, 62', - yellow: '244, 227, 108', -}); +/** + * Severity accent palette — the FIRMS glow-sprite color stops (the six-stop + * FRP ramp in firmsIntensity.js) as "r, g, b" strings for card accents. + */ +const ACCENT_RGB = Object.freeze( + Object.fromEntries( + FRP_RAMP.map((stop) => [stop.name, hexToRgb(stop.hex).join(', ')]), + ), +); /** - * Severity stop name → "r, g, b" accent string (defaults to yellow). - * @param {string} stopName - 'red' | 'orange' | 'yellow'. + * Severity stop name → "r, g, b" accent string (defaults to the coldest stop). + * @param {string} stopName - A FRP_RAMP stop name ('blue' … 'red'). * @returns {string} */ export function accentForSeverity(stopName) { - return ACCENT_RGB[stopName] || ACCENT_RGB.yellow; + return ACCENT_RGB[stopName] || ACCENT_RGB[FRP_RAMP[0].name]; } /** diff --git a/src/data/firmsLabels.test.mjs b/src/data/firmsLabels.test.mjs index 8573d7330..62234bb2d 100644 --- a/src/data/firmsLabels.test.mjs +++ b/src/data/firmsLabels.test.mjs @@ -9,11 +9,16 @@ import { formatFrpMw, } from './firmsLabels.js'; -test('FIRMS formatting helpers retain the shipped severity palette', () => { - assert.equal(accentForSeverity('red'), '224, 82, 82'); - assert.equal(accentForSeverity('orange'), '240, 178, 62'); - assert.equal(accentForSeverity('yellow'), '244, 227, 108'); - assert.equal(accentForSeverity('chartreuse'), accentForSeverity('yellow')); +test('FIRMS card accents are the six FRP ramp stops, coldest as the fallback', () => { + // Portolan hue order; blue #4a5bd4 … red #d1382a (HAL-675). + assert.equal(accentForSeverity('blue'), '74, 91, 212'); + assert.equal(accentForSeverity('teal'), '57, 160, 168'); + assert.equal(accentForSeverity('lime'), '201, 207, 74'); + assert.equal(accentForSeverity('amber'), '242, 177, 52'); + assert.equal(accentForSeverity('orange'), '232, 114, 44'); + assert.equal(accentForSeverity('red'), '209, 56, 42'); + assert.equal(accentForSeverity('yellow'), accentForSeverity('blue')); + assert.equal(accentForSeverity('chartreuse'), accentForSeverity('blue')); }); test('FIRMS power that rounds to 10 MW drops the decimal', () => { diff --git a/src/data/layerState.js b/src/data/layerState.js index 089c8c08b..c68a67e67 100644 --- a/src/data/layerState.js +++ b/src/data/layerState.js @@ -298,6 +298,20 @@ function satelliteFilterOption() { } const OPTION_GROUPS = Object.freeze({ + // Active Fires intensity filter (HAL-676). `minFrp` is whole megawatts on + // the codec side; the panel's log slider rounds to it. Both default to + // "show everything": many of the strongest fires are rated low confidence. + // The bounds and modes mirror MIN_FRP_MW_MAX / FIRMS_CONFIDENCE_MODES in + // firmsIntensity.js (pinned by layerState.test); spelled out here because + // this file runs in isolation in the token-ledger tests. + 'local-firms': Object.freeze([ + boundedIntegerOption('minFrp', 'f', 0, { min: 0, max: 10000 }), + enumOption('confidence', 'c', 'all', ['all', 'nominal', 'high'], { + all: 'a', + nominal: 'n', + high: 'h', + }), + ]), traffic: Object.freeze([ enumOption('roadMode', 'r', null, ['tomtom', 'osm', 'hybrid'], { tomtom: 't', @@ -621,7 +635,12 @@ export const LAYER_STATE_REGISTRY = Object.freeze([ token: 'd', disposition: 'enabled-only', }), - Object.freeze({ id: 'local-firms', token: 'w', disposition: 'enabled-only' }), + Object.freeze({ + id: 'local-firms', + token: 'w', + disposition: 'enabled+options', + optionOwner: 'local-firms', + }), Object.freeze({ id: 'military', token: 'm', diff --git a/src/data/layerState.test.mjs b/src/data/layerState.test.mjs index d05fb9a95..d343fe20f 100644 --- a/src/data/layerState.test.mjs +++ b/src/data/layerState.test.mjs @@ -29,6 +29,7 @@ import radioLayer from './radio.js'; import { stampInitialShareGesture } from '../navigationPolicy.js'; import { MAX_SINCE_DAYS } from '../layers/streetLevel/policy.js'; import { SINCE_STOPS } from '../ui/streetLevelPresentation.js'; +import { FIRMS_CONFIDENCE_MODES, MIN_FRP_MW_MAX } from './firmsIntensity.js'; function deferred() { let resolve; @@ -2984,9 +2985,89 @@ test('Clear All cancels a saved restore still waiting to start', async () => { gate.resolve(); const results = await restore; for (const id of ['earthquakes', 'flights']) { - assert.equal(results.find((result) => result.layerId === id).succeeded, false); + assert.equal( + results.find((result) => result.layerId === id).succeeded, + false, + ); } assert.deepEqual([...manager.getEnabledLayerIds()], []); assert.equal(enabledLayerParam(sink), ''); coordinator.destroy(); }); + +/** Active Fires options decoded from a v2 link with these `lo` assignments. */ +function firmsOptions(lo) { + const params = new URLSearchParams([ + ['v', '2'], + ['l', 'w'], + ]); + if (lo) params.set('lo', lo); + return decodeLayerStateParams(params).options['local-firms']; +} + +test('Active Fires: the intensity filter rides share token w and round-trips', () => { + const state = createDefaultLayerState(); + assert.deepEqual(state.options['local-firms'], { + minFrp: 0, + confidence: 'all', + }); + state.enabledLayerIds = ['local-firms']; + state.options['local-firms'] = { minFrp: 20, confidence: 'high' }; + const encoded = encode(state); + assert.equal(new URLSearchParams(encoded).get('l'), 'w'); + // Other owners write their own explicit defaults into `lo` too (flights 3D, + // wind overlay); the fire assignments ride beside them. + const assignments = new URLSearchParams(encoded).get('lo').split('_'); + assert.ok(assignments.includes('w.f.20'), assignments.join(' ')); + assert.ok(assignments.includes('w.c.h'), assignments.join(' ')); + const decoded = decodeLayerStateParams(new URLSearchParams(encoded)); + assert.deepEqual(decoded.options['local-firms'], { + minFrp: 20, + confidence: 'high', + }); + assert.deepEqual( + parseStoredLayerState(serializeStoredLayerState(state)).options[ + 'local-firms' + ], + state.options['local-firms'], + ); + // Defaults are omitted from the link: a plain `l=w` is "show everything". + const plain = createDefaultLayerState(); + plain.enabledLayerIds = ['local-firms']; + assert.equal( + new URLSearchParams(encode(plain)) + .get('lo') + .split('_') + .filter((assignment) => assignment.startsWith('w.')).length, + 0, + ); + assert.deepEqual(firmsOptions(''), { minFrp: 0, confidence: 'all' }); +}); + +test('Active Fires: the codec accepts whole megawatts in bounds and the three confidence codes', () => { + // The codec spells its bounds out (it runs in isolation in the ledger + // tests); they must stay the layer's. + assert.equal(MIN_FRP_MW_MAX, 10000); + assert.deepEqual([...FIRMS_CONFIDENCE_MODES], ['all', 'nominal', 'high']); + assert.equal(firmsOptions('w.f.300').minFrp, 300); + assert.equal(firmsOptions('w.f.10000').minFrp, 10000); + assert.equal(firmsOptions('w.f.10001').minFrp, 0, 'out of bounds → default'); + assert.equal(firmsOptions('w.f.-5').minFrp, 0); + assert.equal( + firmsOptions('w.f.2.5').minFrp, + 0, + 'a third segment is rejected', + ); + assert.equal(firmsOptions('w.c.a').confidence, 'all'); + assert.equal(firmsOptions('w.c.n').confidence, 'nominal'); + assert.equal(firmsOptions('w.c.h').confidence, 'high'); + assert.equal( + firmsOptions('w.c.x').confidence, + 'all', + 'unknown code → default', + ); + assert.deepEqual(firmsOptions('w.c.n_w.f.5'), { + minFrp: 5, + confidence: 'nominal', + }); +}); diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 0285917b0..ffee7fb87 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -3001,5 +3001,17 @@ "Your choice applies now, but could not be saved.": "Tu elección se aplica ahora, pero no se pudo guardar.", "Voice replies appear here.": "Las respuestas de voz aparecen aquí.", "Preview is off.": "La vista previa está desactivada.", - "Automatic stop is unavailable for this voice connection.": "La parada automática no está disponible para esta conexión de voz." + "Automatic stop is unavailable for this voice connection.": "La parada automática no está disponible para esta conexión de voz.", + "fire confidence filter|All": "Todas", + "fire confidence filter|Nominal+": "Nominal+", + "fire confidence filter|High": "Alta", + "Every detection — many of the strongest fires are rated low confidence": "Todas las detecciones — muchos de los incendios más intensos tienen confianza baja", + "Nominal and high confidence detections": "Detecciones de confianza nominal y alta", + "High confidence detections only": "Solo detecciones de confianza alta", + "Min FRP": "FRP mín.", + "Minimum fire radiative power, megawatts": "Potencia radiativa mínima del fuego, en megavatios", + "{value} MW": "{value} MW", + "fire intensity filter|All": "Todo", + "Detection confidence": "Confianza de la detección", + "{shown} of {total} shown": "{shown} de {total} mostrados" } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index b1c1be229..1cd88757d 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3070,5 +3070,17 @@ "Your choice applies now, but could not be saved.": "Ваш выбор применён, но сохранить его не удалось.", "Voice replies appear here.": "Здесь появляются голосовые ответы.", "Preview is off.": "Предпросмотр отключён.", - "Automatic stop is unavailable for this voice connection.": "Автоматическая остановка недоступна для этого голосового подключения." + "Automatic stop is unavailable for this voice connection.": "Автоматическая остановка недоступна для этого голосового подключения.", + "fire confidence filter|All": "Все", + "fire confidence filter|Nominal+": "Средняя+", + "fire confidence filter|High": "Высокая", + "Every detection — many of the strongest fires are rated low confidence": "Все детекции — у многих сильнейших пожаров достоверность низкая", + "Nominal and high confidence detections": "Детекции средней и высокой достоверности", + "High confidence detections only": "Только детекции высокой достоверности", + "Min FRP": "Мин. FRP", + "Minimum fire radiative power, megawatts": "Минимальная мощность излучения пожара, мегаватты", + "{value} MW": "{value} МВт", + "fire intensity filter|All": "Все", + "Detection confidence": "Достоверность детекции", + "{shown} of {total} shown": "показано {shown} из {total}" } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 9bf76700c..3044c45f8 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -2863,5 +2863,17 @@ "{count} placemarks": { "other": "{count} 个地标" }, - "S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground 在 GitHub 发布这些 KMZ 产品。新上传的内容会在数秒内被检测到,并自动替换旧版本。点击标记查看报告。" + "S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground 在 GitHub 发布这些 KMZ 产品。新上传的内容会在数秒内被检测到,并自动替换旧版本。点击标记查看报告。", + "fire confidence filter|All": "全部", + "fire confidence filter|Nominal+": "标称+", + "fire confidence filter|High": "高", + "Every detection — many of the strongest fires are rated low confidence": "所有检测——许多最强的火点置信度为低", + "Nominal and high confidence detections": "标称和高置信度的检测", + "High confidence detections only": "仅高置信度的检测", + "Min FRP": "最小 FRP", + "Minimum fire radiative power, megawatts": "最小火辐射功率(兆瓦)", + "{value} MW": "{value} MW", + "fire intensity filter|All": "全部", + "Detection confidence": "检测置信度", + "{shown} of {total} shown": "显示 {shown} / {total}" } diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index 05f5d3dee..5f1b14f8a 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -98,6 +98,7 @@ "WX", "audio {state}", "dB", + "fire confidence filter|Nominal+", "fire confidence|nominal", "ft", "hPa", @@ -120,6 +121,7 @@ "{value} KM", "{value} KM/H", "{value} KM/S", + "{value} MW", "ΔE", "ΔN", "▲ {frp} MW" @@ -168,9 +170,9 @@ "MM", "NVG", "REC", - "SRC {value}", "S2 GhostMaps", "S2 · {name}", + "SRC {value}", "Sentinel-2", "Techno", "Trance", @@ -195,6 +197,7 @@ "{type} SPD: {speed} HDG: {heading}", "{value} KM", "{value} KM/S", + "{value} MW", "ΔE", "ΔN", "▲ {frp} MW" diff --git a/src/layers/firms/ingestion.js b/src/layers/firms/ingestion.js index 494ab5f1e..215c35da3 100644 --- a/src/layers/firms/ingestion.js +++ b/src/layers/firms/ingestion.js @@ -1,5 +1,6 @@ import { adaptFirmsRecords } from '../../data/firmsAdapt.js'; import { fireDetectionKey } from '../../data/firmsLabels.js'; +import { filterFires } from '../../data/firmsIntensity.js'; import { msg } from '../../i18n/index.js'; export function createIngestion({ @@ -40,12 +41,13 @@ export function createIngestion({ layerState._stale = Boolean(payload?.stale); const previousSelection = layerState._selectedFire; layerState._selectedFire = null; - layerState._fires = adaptFirmsRecords(payload?.fires); - layerState._cellCacheByGrid.clear(); // aggregation is per-dataset — new fires, new cells - layerState._firesByFrp = [...layerState._fires].sort( + layerState._allFires = adaptFirmsRecords(payload?.fires); + // Sorted once per payload; applyFilter only filters, so a slider drag + // over 200k detections never re-sorts. + layerState._allFiresByFrp = [...layerState._allFires].sort( (a, b) => b.frp - a.frp, ); - layerState._count = layerState._fires.length; + applyFilter(); // Data age, not response age: a stale proxy payload truthfully reads old. layerState._lastUpdate = Number.isFinite(payload?.fetchedAt) ? payload.fetchedAt @@ -65,6 +67,7 @@ export function createIngestion({ } components.rendering.renderCurrentLod(true); if (reselected) components.selection.selectFire(reselected, false); + layerState._rowControlsListener?.(); } catch (error) { if ( request.signal.aborted || @@ -83,6 +86,25 @@ export function createIngestion({ } } + /** + * Run the intensity filter over the loaded payload. This is the ONE place + * the shown set is derived: `_fires` / `_firesByFrp` feed markers, cards, + * heat cells, the context store, the top-fires query and the analyst + * records, so none of them can disagree about which fires exist. Cells are + * aggregated per shown set, so the cache goes with it. + */ + + function applyFilter() { + layerState._fires = filterFires(layerState._allFires, layerState._filter); + layerState._cellCacheByGrid.clear(); + layerState._firesByFrp = filterFires( + layerState._allFiresByFrp, + layerState._filter, + ); + layerState._count = layerState._fires.length; + layerState._totalCount = layerState._allFires.length; + } + /** * Find the record in the freshly-loaded set matching a previous selection. * Identity is the detection itself (lat/lon/acquisition time) — indices @@ -109,5 +131,5 @@ export function createIngestion({ }, }; - return { loadHeatmap, findMatchingFire, methods }; + return { loadHeatmap, applyFilter, findMatchingFire, methods }; } diff --git a/src/layers/firms/lifecycle.js b/src/layers/firms/lifecycle.js index 7e0b42a00..1138504dd 100644 --- a/src/layers/firms/lifecycle.js +++ b/src/layers/firms/lifecycle.js @@ -91,10 +91,14 @@ export function createLifecycle({ components.rendering.removeDetectionCollections(viewer); components.selection.clearContextRegistrations(); clearSelectedEntityContextForLayer(id); + layerState._allFires = []; + layerState._allFiresByFrp = []; layerState._fires = []; layerState._firesByFrp = []; layerState._cellCacheByGrid.clear(); layerState._count = 0; + layerState._totalCount = 0; + layerState._rowControlsListener = null; layerState._cellCount = 0; layerState._lastUpdate = null; layerState._keyRequired = false; diff --git a/src/layers/firms/model.js b/src/layers/firms/model.js index 14b875bf8..a90066cc6 100644 --- a/src/layers/firms/model.js +++ b/src/layers/firms/model.js @@ -6,6 +6,7 @@ import { accentForSeverity, formatFrpMw, } from '../../data/firmsLabels.js'; +import { FRP_RAMP, frpRampStop } from '../../data/firmsIntensity.js'; import { VIEW_PADDING, DETECTION_COLOR_STOPS, @@ -119,27 +120,42 @@ export function createModel({ ); } - function heatColor(value, alpha) { - if (value > 0.72) return Cesium.Color.RED.withAlpha(alpha); - if (value > 0.42) return Cesium.Color.ORANGE.withAlpha(alpha); - return Cesium.Color.YELLOW.withAlpha(alpha); + /** + * The ramp stop for a fire radiative power (stepped, coldest first). + * Intensity alone drives the color: confidence feeds the card text and the + * switch, brightness is unreliable (saturates at 367 K) — see HAL-675. + * @param {number} frp - Megawatts. + * @returns {{name: string, minFrp: number, color: Cesium.Color}} + */ + + function frpColorStop(frp) { + const name = frpRampStop(frp).name; + return ( + DETECTION_COLOR_STOPS.find((stop) => stop.name === name) || + DETECTION_COLOR_STOPS[0] + ); } /** - * Pick a sprite color stop from FRP + confidence, reusing the same - * yellow → orange → red thresholds as the aggregated heat cells. + * Fill color for an aggregated heat cell: the ramp color of its strongest + * detection, so cells, markers and the slider legend read on one scale. + * @param {number} maxFrp - Strongest FRP in the cell, megawatts. + * @param {number} alpha - Fill alpha (heat-normalized by the caller). + * @returns {Cesium.Color} + */ + + function heatColor(maxFrp, alpha) { + return frpColorStop(maxFrp).color.withAlpha(alpha); + } + + /** + * Pick a sprite color stop for one detection from its FRP on the shared ramp. * @param {Object} fire - Detection record. * @returns {{name: string, color: Cesium.Color}} */ function detectionColorStop(fire) { - const heat = Math.min( - 1, - Math.sqrt(Math.max(0, fire.frp) / 150) * 0.85 + fire.confidence * 0.15, - ); - if (heat > 0.72) return DETECTION_COLOR_STOPS[0]; - if (heat > 0.42) return DETECTION_COLOR_STOPS[1]; - return DETECTION_COLOR_STOPS[2]; + return frpColorStop(fire?.frp); } /** FRP → core marker pixel size, clamped to 8..28px. */ @@ -418,7 +434,7 @@ export function createModel({ position: candidate.position, cullPosition: candidate.cullPosition || candidate.position, gapPx: 10, - accent: candidate.accent || accentForSeverity('yellow'), + accent: candidate.accent || accentForSeverity(FRP_RAMP[0].name), title: `${cell.count} ${noun}`, details: [parts.join(' · ')], selected: false, @@ -460,16 +476,14 @@ export function createModel({ } /** - * Heat-normalized cell score → severity accent, mirroring heatColor's - * red/orange/yellow thresholds. - * @param {number} normalized - 0..1 normalized heat score. + * Cell card accent: the ramp stop of the cell's strongest detection, + * mirroring heatColor. + * @param {number} maxFrp - Strongest FRP in the cell, megawatts. * @returns {string} "r, g, b" accent string. */ - function cellAccent(normalized) { - if (normalized > 0.72) return accentForSeverity('red'); - if (normalized > 0.42) return accentForSeverity('orange'); - return accentForSeverity('yellow'); + function cellAccent(maxFrp) { + return accentForSeverity(frpColorStop(maxFrp).name); } /** Coordinate line like "30.512°N 75.831°E". */ @@ -576,6 +590,7 @@ export function createModel({ cellIntersectsBounds, heatScore, heatColor, + frpColorStop, detectionColorStop, frpPixelSize, sizeBucket, diff --git a/src/layers/firms/policy.js b/src/layers/firms/policy.js index 673aab871..18ea88238 100644 --- a/src/layers/firms/policy.js +++ b/src/layers/firms/policy.js @@ -1,4 +1,5 @@ import { FIRMS_AMBIENT_COHORT_LIMIT } from '../../data/firmsLabels.js'; +import { FRP_RAMP } from '../../data/firmsIntensity.js'; import * as Cesium from 'cesium'; /** Client poll interval; the proxy's 30 min TTL is what guards upstream quota. */ @@ -88,10 +89,16 @@ export const CULL_LIFT_THRESHOLD_M = 10; export const CULL_LIFT_M = 12; -/** Color stops shared by cell heat fills and detection glow sprites. */ +/** + * Color stops shared by cell heat fills and detection glow sprites: the + * six-stop cold → hot FRP ramp (firmsIntensity.js), coldest first, as Cesium + * colors. Stop names key the glow-sprite cache and the card accents. + */ -export const DETECTION_COLOR_STOPS = [ - { name: 'red', color: Cesium.Color.RED }, - { name: 'orange', color: Cesium.Color.ORANGE }, - { name: 'yellow', color: Cesium.Color.YELLOW }, -]; +export const DETECTION_COLOR_STOPS = FRP_RAMP.map((stop) => + Object.freeze({ + name: stop.name, + minFrp: stop.minFrp, + color: Cesium.Color.fromCssColorString(stop.hex), + }), +); diff --git a/src/layers/firms/queries.js b/src/layers/firms/queries.js index 5f663cc97..220012133 100644 --- a/src/layers/firms/queries.js +++ b/src/layers/firms/queries.js @@ -1,7 +1,19 @@ import { fireDetectionKey } from '../../data/firmsLabels.js'; -import { t } from '../../i18n/index.js'; +import { + FIRMS_CONFIDENCE_MODES, + FRP_SLIDER_TICKS, + firePassesFilter, + frpForSliderPosition, + normalizeFireFilter, + rampTrackGradient, + sliderPositionForFrp, +} from '../../data/firmsIntensity.js'; +import { formatNumber, t } from '../../i18n/index.js'; import { REFRESH_INTERVAL_MS } from './policy.js'; +/** Slider resolution: whole-percent positions along the log axis. */ +const SLIDER_STEPS = 1000; + export function createQueries({ layerState, services, @@ -11,6 +23,25 @@ export function createQueries({ }) { const { id, name, icon, source } = config; + /** Whole-megawatt label for a slider value or tick ("0", "20", "1 000"). */ + function frpLabel(frp) { + return formatNumber(frp, { maximumFractionDigits: 0 }); + } + + const confidenceLabels = () => ({ + all: t('All', { context: 'fire confidence filter' }), + nominal: t('Nominal+', { context: 'fire confidence filter' }), + high: t('High', { context: 'fire confidence filter' }), + }); + + const confidenceTitles = () => ({ + all: t( + 'Every detection — many of the strongest fires are rated low confidence', + ), + nominal: t('Nominal and high confidence detections'), + high: t('High confidence detections only'), + }); + const methods = { /** Return the selected detection as a plain record for application consumers. */ getSelectedInfo() { @@ -51,6 +82,109 @@ export function createQueries({ // not this interval — is what protects the upstream FIRMS quota). updateInterval: REFRESH_INTERVAL_MS, + /** + * The intensity filter as layer options (share token `w`, owner + * `local-firms` in layerState.js). + * @returns {{minFrp: number, confidence: string}} + */ + getParams() { + return { ...layerState._filter }; + }, + + /** + * Apply a (partial) intensity filter. Invalid fields keep their current + * value. The filter runs in one place (ingestion.applyFilter) and the LOD + * rebuild re-derives markers, cells, cards and context registrations from + * the shown set; a selected fire that no longer passes is deselected. + * @param {{minFrp?: number, confidence?: string}} params + * @returns {boolean} True — the request is always accepted. + */ + setParams(params = {}) { + const next = normalizeFireFilter(params, layerState._filter); + if ( + next.minFrp === layerState._filter.minFrp && + next.confidence === layerState._filter.confidence + ) + return true; + layerState._filter = next; + if (!layerState._allFires.length) return true; + components.ingestion.applyFilter(); + if ( + layerState._selectedFire && + !firePassesFilter(layerState._selectedFire, next) + ) { + components.selection.clearFireSelection(); + } + if (layerState._enabled) components.rendering.renderCurrentLod(true); + layerState._rowControlsListener?.(); + return true; + }, + + /** + * Row controls under the Active Fires row: the log-scale minimum-FRP + * slider whose track is painted with the intensity ramp (so it doubles as + * the legend), the All / Nominal+ / High confidence switch, and the + * "N of M shown" counter. + */ + getRowControls() { + const { minFrp, confidence } = layerState._filter; + const labels = confidenceLabels(); + const titles = confidenceTitles(); + const shown = layerState._count; + const total = layerState._totalCount; + return { + slider: { + id: 'min-frp', + label: t('Min FRP'), + ariaLabel: t('Minimum fire radiative power, megawatts'), + valueText: + minFrp > 0 + ? t('{value} MW', { value: frpLabel(minFrp) }) + : t('All', { context: 'fire intensity filter' }), + min: 0, + max: SLIDER_STEPS, + step: 1, + value: Math.round(sliderPositionForFrp(minFrp) * SLIDER_STEPS), + track: rampTrackGradient(), + ticks: FRP_SLIDER_TICKS.map((frp) => ({ + at: sliderPositionForFrp(frp), + label: frpLabel(frp), + })), + params: (value) => ({ + minFrp: frpForSliderPosition(Number(value) / SLIDER_STEPS), + }), + }, + segmented: { + id: 'confidence', + ariaLabel: t('Detection confidence'), + options: FIRMS_CONFIDENCE_MODES.map((mode) => ({ + id: `confidence-${mode}`, + label: labels[mode], + title: titles[mode], + active: mode === confidence, + params: { confidence: mode }, + })), + }, + info: total + ? t('{shown} of {total} shown', { + shown: formatNumber(shown), + total: formatNumber(total), + }) + : '', + }; + }, + + /** + * Install the panel's "row controls changed" callback: a load or a filter + * change moves the counter, and nothing else repaints the row before the + * next scheduled refresh. + * @param {(() => void)|null} listener + */ + setRowControlsListener(listener) { + layerState._rowControlsListener = + typeof listener === 'function' ? listener : null; + }, + /** * Layer stats for the data panel. Degraded feed states surface through * `error` (established qa-failstate pattern: a dead feed must never look @@ -84,7 +218,9 @@ export function createQueries({ }); } return { + // Shown (filtered) detections; `total` is the loaded payload. count: layerState._count, + total: layerState._totalCount, cells: layerState._cellCount, lastUpdate: layerState._lastUpdate, loading: layerState._loading, @@ -176,8 +312,12 @@ export function createQueries({ _bindInteractionForTest(viewer, fires = []) { layerState._viewer = viewer; layerState._enabled = true; + layerState._allFires = fires; + layerState._allFiresByFrp = [...fires]; layerState._fires = fires; layerState._firesByFrp = [...fires]; + layerState._count = fires.length; + layerState._totalCount = fires.length; layerState._pickIndexById.clear(); layerState._labelLodDistance = 1e7; layerState._labelCandidates = fires.map((fire) => { diff --git a/src/layers/firms/rendering.js b/src/layers/firms/rendering.js index 8ae445f91..757fd4cf7 100644 --- a/src/layers/firms/rendering.js +++ b/src/layers/firms/rendering.js @@ -144,7 +144,7 @@ export function createRendering({ const score = components.model.heatScore(cell); const normalized = Math.min(1, Math.sqrt(score / maxScore)); const alpha = 0.16 + normalized * 0.5; - const color = components.model.heatColor(normalized, alpha); + const color = components.model.heatColor(cell.maxFrp, alpha); const centerLon = cell.lonCell + lod.gridDegrees / 2; const centerLat = cell.latCell + lod.gridDegrees / 2; const position = Cesium.Cartesian3.fromDegrees(centerLon, centerLat, 0); @@ -170,13 +170,12 @@ export function createRendering({ }); // Cells arrive heat-sorted, so candidate order doubles as label priority. - // Accent is resolved here because `normalized` only exists in this walk. layerState._labelCandidates.push({ position, cullPosition: components.model.cellCullPosition(centerLon, centerLat), fire: null, cell, - accent: components.model.cellAccent(normalized), + accent: components.model.cellAccent(cell.maxFrp), }); } diff --git a/src/layers/firms/state.js b/src/layers/firms/state.js index 48ae11db2..d84ee2026 100644 --- a/src/layers/firms/state.js +++ b/src/layers/firms/state.js @@ -1,4 +1,5 @@ import * as Cesium from 'cesium'; +import { DEFAULT_FIRE_FILTER } from '../../data/firmsIntensity.js'; export function createFirmsState({ services, config }) { const layerState = {}; @@ -39,12 +40,39 @@ export function createFirmsState({ services, config }) { layerState._error = null; + /** Every adapted detection in the current payload, before the filter. */ + + layerState._allFires = []; + + /** `_allFires` sorted by FRP descending, once per payload. */ + + layerState._allFiresByFrp = []; + + /** + * The detections that pass the intensity filter — the ONLY set rendering, + * selection, cells, the top-fires query and the analyst records ever walk. + */ + layerState._fires = []; layerState._firesByFrp = []; + /** Shown (filtered) detection count. */ + layerState._count = 0; + /** Loaded detection count before the filter ("N of M shown"). */ + + layerState._totalCount = 0; + + /** Minimum FRP (MW) and confidence mode; see firmsIntensity.js. */ + + layerState._filter = { ...DEFAULT_FIRE_FILTER }; + + /** Panel callback: repaint the row controls (counter) after a load. */ + + layerState._rowControlsListener = null; + layerState._cellCount = 0; layerState._lastUpdate = null; diff --git a/src/share/viewCodec.js b/src/share/viewCodec.js index 84ba8fbc1..18f64e0aa 100644 --- a/src/share/viewCodec.js +++ b/src/share/viewCodec.js @@ -255,6 +255,7 @@ export const SHARE_VIEW_FLAGS = freezeAll([ * written explicitly. */ export const SHARE_VIEW_LAYER_OPTION_ABSENT = Object.freeze({ + 'local-firms': Object.freeze({ minFrp: 0, confidence: 'all' }), traffic: Object.freeze({ roadMode: null }), 'weather-lightning': Object.freeze({ opacity: 'strong' }), 'weather-radar': Object.freeze({ opacity: 'strong' }), diff --git a/src/tools/queries/hazards.js b/src/tools/queries/hazards.js index 2b11c639c..e671db426 100644 --- a/src/tools/queries/hazards.js +++ b/src/tools/queries/hazards.js @@ -4,6 +4,14 @@ import { suggestView } from '../views.js'; import { defineTool, ToolError } from '../catalog.js'; import { AREA_SCHEMA, areaContains, resolveArea } from '../area.js'; import { LIMIT_SCHEMA, capRows, countNoun, isoTime } from '../results.js'; +import { + FIRMS_CONFIDENCE_MODES, + MIN_FRP_MW_MAX, + MIN_FRP_MW_MIN, + firePassesFilter, + normalizeFireFilter, +} from '../../data/firmsIntensity.js'; +import { normalizeConfidence } from '../../data/firmsAdapt.js'; function acquiredMsUtc(acqDate, acqTime) { if (typeof acqDate !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(acqDate)) @@ -87,10 +95,30 @@ export const getActiveFires = defineTool({ title: 'Active fires', description: 'Satellite fire detections from NASA FIRMS in the last 24 hours within an ' + - 'area, most intense (fire radiative power) first.', + 'area, most intense (fire radiative power) first. Optionally limited to a ' + + 'minimum fire radiative power in megawatts and a confidence level — the ' + + "same filter as the Active Fires layer's intensity controls.", inputSchema: { type: 'object', - properties: { area: AREA_SCHEMA, limit: LIMIT_SCHEMA }, + properties: { + area: AREA_SCHEMA, + min_frp_mw: { + type: 'number', + minimum: MIN_FRP_MW_MIN, + maximum: MIN_FRP_MW_MAX, + description: + 'Hide detections weaker than this fire radiative power (MW). ' + + 'A typical detection is ~6 MW; 20 MW keeps the top tenth, 100 MW the top percent.', + }, + confidence: { + type: 'string', + enum: [...FIRMS_CONFIDENCE_MODES], + description: + '"all" (default), "nominal" (nominal and high), or "high". Many of the ' + + 'strongest fires are rated low confidence, so prefer "all" unless asked.', + }, + limit: LIMIT_SCHEMA, + }, required: ['area'], additionalProperties: false, }, @@ -103,9 +131,31 @@ export const getActiveFires = defineTool({ 'unavailable', 'Fire detections need a NASA FIRMS key configured for this server', ); + const filter = normalizeFireFilter({ + minFrp: args.min_frp_mw, + confidence: args.confidence, + }); const fires = snapshot.fires - .filter((fire) => areaContains(area, fire)) + .filter( + (fire) => + areaContains(area, fire) && + firePassesFilter( + { + frp: Number(fire.frp), + confidence: normalizeConfidence(fire.confidence), + }, + filter, + ), + ) .sort((a, b) => (b.frp ?? 0) - (a.frp ?? 0)); + const filterNotes = [ + ...(filter.minFrp > 0 ? [`≥ ${filter.minFrp} MW`] : []), + ...(filter.confidence !== 'all' + ? [ + `${filter.confidence === 'high' ? 'high' : 'nominal or high'} confidence`, + ] + : []), + ]; const result = capRows(fires, args.limit); // The proxy serves its last good snapshot when a refresh fails, and marks // satellites whose download failed. @@ -119,10 +169,16 @@ export const getActiveFires = defineTool({ ]; return { summary: - `${countNoun(fires.length, 'fire detection')} in ${area.label} in the last 24 hours` + + `${countNoun(fires.length, 'fire detection')}` + + (filterNotes.length ? ` (${filterNotes.join(', ')})` : '') + + ` in ${area.label} in the last 24 hours` + (notes.length ? ` (${notes.join('; ')}).` : '.'), data: { view: suggestView(services, { area, layers: ['local-firms'] }), + filter: { + min_frp_mw: filter.minFrp, + confidence: filter.confidence, + }, stale, missing_sources: missing, fetched_at: isoTime(snapshot.fetchedAt), diff --git a/src/tools/queries/hazards.test.mjs b/src/tools/queries/hazards.test.mjs index fa17158dd..c1d3eb43a 100644 --- a/src/tools/queries/hazards.test.mjs +++ b/src/tools/queries/hazards.test.mjs @@ -44,3 +44,39 @@ test('fire acquisition timestamps preserve valid leap days and UTC times', async assert.equal(result.data.rows[0].acquired, expected); } }); + +const WEEK = [ + { lat: 24.1, lon: 121.1, frp: 864, confidence: 'l', acqDate: '2026-10-01', acqTime: '0100' }, + { lat: 24.2, lon: 121.2, frp: 120, confidence: 'h', acqDate: '2026-10-01', acqTime: '0100' }, + { lat: 24.3, lon: 121.3, frp: 20, confidence: 'n', acqDate: '2026-10-01', acqTime: '0100' }, + { lat: 24.4, lon: 121.4, frp: 2, confidence: 'h', acqDate: '2026-10-01', acqTime: '0100' }, + { lat: 30, lon: 100, frp: 500, confidence: 'h', acqDate: '2026-10-01', acqTime: '0100' }, +]; +const TAIWAN = { area: { bbox: [120, 23, 122, 25] } }; + +test('get_active_fires shows every detection by default and reports the filter it used', async () => { + const result = await catalogWith(WEEK).call('get_active_fires', TAIWAN); + assert.deepEqual(result.data.rows.map((row) => row.frp_mw), [864, 120, 20, 2]); + assert.deepEqual(result.data.filter, { min_frp_mw: 0, confidence: 'all' }); + assert.match(result.summary, /^4 fire detections in /); +}); + +test('get_active_fires applies the same minimum-FRP and confidence filter as the layer', async () => { + const catalog = catalogWith(WEEK); + const strong = await catalog.call('get_active_fires', { ...TAIWAN, min_frp_mw: 20 }); + assert.deepEqual(strong.data.rows.map((row) => row.frp_mw), [864, 120, 20]); + assert.match(strong.summary, /^3 fire detections \(≥ 20 MW\) in /); + + const nominal = await catalog.call('get_active_fires', { ...TAIWAN, confidence: 'nominal' }); + assert.deepEqual(nominal.data.rows.map((row) => row.frp_mw), [120, 20, 2], 'the 864 MW low-confidence fire drops'); + assert.match(nominal.summary, /\(nominal or high confidence\)/); + + const both = await catalog.call('get_active_fires', { + ...TAIWAN, + min_frp_mw: 19.6, + confidence: 'high', + }); + assert.deepEqual(both.data.rows.map((row) => row.frp_mw), [120]); + assert.deepEqual(both.data.filter, { min_frp_mw: 20, confidence: 'high' }); + assert.match(both.summary, /\(≥ 20 MW, high confidence\)/); +}); diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js index 18b71dabf..dc6566848 100644 --- a/src/ui/layerPanel.js +++ b/src/ui/layerPanel.js @@ -1,4 +1,5 @@ import { syncChipGroup } from './chipGroup.js'; +import { syncSegmentedGroup, syncSliderBlock } from './rowFilterControls.js'; import { syncRowList } from './rowList.js'; import { layerFeedState } from '../data/feedState.js'; export { layerFeedState } from '../data/feedState.js'; @@ -388,18 +389,42 @@ export class LayerPanel { const controls = document.createElement('div'); controls.className = 'data-toggle-controls'; this._bind(controls, 'click', (event) => { - const button = event.target?.closest?.('.data-toggle-chip'); + const button = + event.target?.closest?.('.data-toggle-chip') || + event.target?.closest?.('.data-toggle-segment'); if (!button || button.disabled) return; // Re-read the live descriptor rather than trusting the rendered // chip, so a stale row can never apply an inverted toggle. - const chip = this._rowControlsFor(layer.id)?.chips?.find( - (entry) => entry.id === button.dataset.chipId, - ); + const live = this._rowControlsFor(layer.id); + const chip = + live?.chips?.find( + (entry) => entry.id === button.dataset.chipId, + ) || + live?.segmented?.options?.find( + (entry) => entry.id === button.dataset.chipId, + ); if (!chip || chip.disabled || !this.isEnabled(layer.id)) return; if (typeof chip.onClick === 'function') chip.onClick(); else if (chip.params) this.setLayerParams(layer.id, chip.params, { origin: 'user' }); }); + // A row slider (e.g. the Active Fires minimum-FRP filter) maps its + // position to params through the live descriptor on every tick. + this._bind(controls, 'input', (event) => { + const input = event.target; + if (!input?.matches?.('.data-toggle-slider-input')) return; + const slider = this._rowControlsFor(layer.id)?.slider; + if ( + !slider || + slider.id !== input.dataset.sliderId || + typeof slider.params !== 'function' || + !this.isEnabled(layer.id) + ) + return; + this.setLayerParams(layer.id, slider.params(input.value), { + origin: 'user', + }); + }); row.appendChild(controls); // An ordered list below the chips, for a layer whose row carries a // sequence (turn-by-turn directions). Its own delegated listener, its @@ -521,13 +546,30 @@ export class LayerPanel { const chips = controls?.chips || []; const legend = controls?.legend || []; const infoText = controls?.info; + const slider = controls?.slider || null; + const segmented = controls?.segmented || null; this._syncRowList(listContainer, controls?.list || null); - container.hidden = chips.length === 0 && legend.length === 0 && !infoText; + container.hidden = + chips.length === 0 && + legend.length === 0 && + !infoText && + !slider && + !segmented; const info = container._rowControlsInfo || null; const firstLegend = container.querySelector('.data-toggle-legend-item'); - syncChipGroup(container, chips, { before: firstLegend || info }); + // Order: chips, switch, slider, legend, info. + syncSegmentedGroup(container, segmented, { before: firstLegend || info }); + const switchNode = container.querySelector('.data-toggle-segmented'); + syncSliderBlock(container, slider, { + before: switchNode?.nextSibling || firstLegend || info, + }); + const firstControl = + switchNode || container.querySelector('.data-toggle-slider'); + syncChipGroup(container, chips, { + before: firstControl || firstLegend || info, + }); const legendSignature = JSON.stringify( legend.map(({ label, color, count, blurb }) => [ diff --git a/src/ui/rowFilterControls.js b/src/ui/rowFilterControls.js new file mode 100644 index 000000000..b0299d24b --- /dev/null +++ b/src/ui/rowFilterControls.js @@ -0,0 +1,169 @@ +import { createSlider } from './slider.js'; +import { syncSegmentedControl } from './segmentedControl.js'; + +const set = (node, key, value) => { + if (node[key] !== value) node[key] = value; +}; +const attribute = (node, key, value) => { + if (node.getAttribute(key) !== value) node.setAttribute(key, value); +}; +// Custom properties need setProperty; test fixtures carry a plain style bag. +const styleVar = (node, name, value) => { + if (typeof node.style?.setProperty === 'function') { + if (value) node.style.setProperty(name, value); + else node.style.removeProperty(name); + } else if (node.style) node.style[name] = value || ''; +}; + +/** + * Reconcile a layer row's quantitative slider block in place (keyed by the + * descriptor id), so a refresh triggered by the slider's own input never + * replaces the node under the pointer. The caller owns dispatch: it listens + * for `input` on the row container and reads `.data-toggle-slider-input`. + * + * Descriptor: `{ id, label, ariaLabel, valueText, min, max, step, value, + * track, ticks: [{ at, label }] }` — `track` is a CSS background painting the + * runnable track (a color ramp doubles as the legend), `at` is 0..1. + * @param {HTMLElement} container The row's `.data-toggle-controls` node. + * @param {?object} slider Descriptor, or null to remove the block. + * @param {{before?: ?Node}} [options] Insertion anchor for a new block. + * @returns {?HTMLElement} The block, when rendered. + */ +export function syncSliderBlock(container, slider, { before = null } = {}) { + if (!container) return null; + const document = container.ownerDocument ?? globalThis.document; + const existing = [...container.children].find( + (node) => node.dataset?.sliderId, + ); + if (!slider) { + existing?.remove(); + return null; + } + let block = existing; + if (!block || block.dataset.sliderId !== slider.id) { + existing?.remove(); + block = document.createElement('div'); + block.className = 'data-toggle-slider'; + block.dataset.sliderId = slider.id; + const head = document.createElement('div'); + head.className = 'data-toggle-slider-head'; + const label = document.createElement('span'); + label.className = 'data-toggle-slider-label'; + const value = document.createElement('span'); + value.className = 'data-toggle-slider-value gev-slider-value'; + head.append(label, value); + const input = createSlider({ + documentRef: document, + className: 'data-toggle-slider-input', + min: slider.min, + max: slider.max, + step: slider.step, + value: slider.value, + }); + input.dataset.sliderId = slider.id; + const ticks = document.createElement('div'); + ticks.className = 'data-toggle-slider-ticks'; + block.append(head, input, ticks); + block._parts = { label, value, input, ticks }; + container.insertBefore(block, before); + } + const { label, value, input, ticks } = block._parts; + set(label, 'textContent', slider.label ?? ''); + set(value, 'textContent', slider.valueText ?? ''); + set(input, 'min', String(slider.min)); + set(input, 'max', String(slider.max)); + set(input, 'step', String(slider.step ?? 1)); + // Never move the thumb under an active drag: the layer's rounded value can + // sit a hair off the pointer, and a refresh runs on every input tick. + if (document.activeElement !== input) + set(input, 'value', String(slider.value)); + if (slider.ariaLabel) attribute(input, 'aria-label', slider.ariaLabel); + if (slider.valueText) attribute(input, 'aria-valuetext', slider.valueText); + const track = slider.track || ''; + if ((block.dataset.track || '') !== track) { + styleVar(input, '--slider-track', track); + block.dataset.track = track; + } + const signature = JSON.stringify(slider.ticks || []); + if (block.dataset.ticks !== signature) { + for (const node of [...ticks.children]) node.remove(); + for (const tick of slider.ticks || []) { + const node = document.createElement('span'); + node.className = 'data-toggle-slider-tick'; + styleVar(node, '--at', `${(tick.at * 100).toFixed(2)}%`); + node.textContent = tick.label; + ticks.appendChild(node); + } + block.dataset.ticks = signature; + } + return block; +} + +/** + * Reconcile a one-of-many switch (`.gev-segmented` radiogroup) in place. + * Buttons carry `data-chip-id` like chips so the row's delegated click handler + * can resolve them; the caller matches `.data-toggle-segment`. + * + * Descriptor: `{ id, ariaLabel, options: [{ id, label, title, active, + * disabled, params }] }`. + * @param {HTMLElement} container The row's `.data-toggle-controls` node. + * @param {?object} segmented Descriptor, or null to remove the group. + * @param {{before?: ?Node}} [options] Insertion anchor for a new group. + * @returns {?HTMLElement} The group, when rendered. + */ +export function syncSegmentedGroup( + container, + segmented, + { before = null } = {}, +) { + if (!container) return null; + const document = container.ownerDocument ?? globalThis.document; + const existing = [...container.children].find( + (node) => node.dataset?.segmentedId, + ); + if (!segmented) { + existing?.remove(); + return null; + } + let group = existing; + if (!group || group.dataset.segmentedId !== segmented.id) { + existing?.remove(); + group = document.createElement('div'); + group.className = 'gev-segmented data-toggle-segmented'; + group.dataset.segmentedId = segmented.id; + group.setAttribute('role', 'radiogroup'); + container.insertBefore(group, before); + } + if (segmented.ariaLabel) attribute(group, 'aria-label', segmented.ariaLabel); + const stale = new Map(); + for (const node of [...group.children]) + if (node.dataset?.chipId) stale.set(node.dataset.chipId, node); + const ordered = []; + for (const option of segmented.options || []) { + let button = stale.get(option.id); + stale.delete(option.id); + if (!button) { + button = document.createElement('button'); + button.type = 'button'; + button.className = 'gev-segment data-toggle-segment'; + button.dataset.chipId = option.id; + button.setAttribute('role', 'radio'); + } + set(button, 'textContent', option.label); + set(button, 'title', option.title || ''); + set(button, 'disabled', Boolean(option.disabled)); + ordered.push(button); + } + for (const node of stale.values()) node.remove(); + ordered.forEach((node, index) => { + if (group.children[index] !== node) + group.insertBefore(node, group.children[index] || null); + }); + const active = (segmented.options || []).find((option) => option.active); + syncSegmentedControl(ordered, active?.id, { + valueOf: (button) => button.dataset.chipId, + activeClass: 'active', + rovingFocus: true, + }); + return group; +} diff --git a/src/ui/rowFilterControls.test.mjs b/src/ui/rowFilterControls.test.mjs new file mode 100644 index 000000000..9cc8fb1d4 --- /dev/null +++ b/src/ui/rowFilterControls.test.mjs @@ -0,0 +1,207 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { syncSegmentedGroup, syncSliderBlock } from './rowFilterControls.js'; +import { railFixture } from './railTestFixture.mjs'; + +const sliderDescriptor = (overrides = {}) => ({ + id: 'min-frp', + label: 'MIN FRP', + ariaLabel: 'Minimum fire radiative power', + valueText: 'All', + min: 0, + max: 1000, + step: 1, + value: 0, + track: 'linear-gradient(90deg, #4a5bd4 0% 23%, #d1382a 82% 100%)', + ticks: [ + { at: 0, label: '0' }, + { at: 0.233, label: '5' }, + { at: 0.826, label: '300' }, + ], + params: (value) => ({ minFrp: Number(value) }), + ...overrides, +}); + +const segmentedDescriptor = (active = 'all') => ({ + id: 'confidence', + ariaLabel: 'Detection confidence', + options: ['all', 'nominal', 'high'].map((mode) => ({ + id: `confidence-${mode}`, + label: mode.toUpperCase(), + title: `${mode} title`, + active: mode === active, + params: { confidence: mode }, + })), +}); + +test('the slider block renders label, value, painted track and ticks, and is reconciled in place', () => { + const f = railFixture(); + const container = f.document.createElement('div'); + const block = syncSliderBlock(container, sliderDescriptor()); + assert.equal(block.className, 'data-toggle-slider'); + assert.equal(block.dataset.sliderId, 'min-frp'); + const input = block.querySelector('.data-toggle-slider-input'); + assert.equal(input.type, 'range'); + assert.equal(input.max, '1000'); + assert.equal(input.value, '0'); + assert.equal( + input.getAttribute('aria-label'), + 'Minimum fire radiative power', + ); + assert.equal(input.getAttribute('aria-valuetext'), 'All'); + assert.match(input.style['--slider-track'], /^linear-gradient/); + assert.equal( + block.querySelector('.data-toggle-slider-label').textContent, + 'MIN FRP', + ); + assert.equal( + block.querySelector('.data-toggle-slider-value').textContent, + 'All', + ); + const ticks = block.querySelector('.data-toggle-slider-ticks'); + assert.deepEqual( + ticks.children.map((node) => [node.textContent, node.style['--at']]), + [ + ['0', '0.00%'], + ['5', '23.30%'], + ['300', '82.60%'], + ], + ); + + const again = syncSliderBlock( + container, + sliderDescriptor({ value: 434, valueText: '20 MW' }), + ); + assert.equal(again, block, 'same node — a refresh never drops the thumb'); + assert.equal(input.value, '434'); + assert.equal( + block.querySelector('.data-toggle-slider-value').textContent, + '20 MW', + ); + assert.equal(container.children.length, 1); + + // A thumb under the pointer is never moved by a refresh. + input.focus(); + syncSliderBlock(container, sliderDescriptor({ value: 0 })); + assert.equal(input.value, '434'); + + syncSliderBlock(container, null); + assert.equal(container.children.length, 0); +}); + +test('the segmented group is a radiogroup whose active option reads checked, reconciled by id', () => { + const f = railFixture(); + const container = f.document.createElement('div'); + const group = syncSegmentedGroup(container, segmentedDescriptor('all')); + assert.equal(group.getAttribute('role'), 'radiogroup'); + assert.equal(group.getAttribute('aria-label'), 'Detection confidence'); + assert.ok(group.className.split(' ').includes('gev-segmented')); + const buttons = group.children; + assert.deepEqual( + buttons.map((b) => [ + b.dataset.chipId, + b.textContent, + b.getAttribute('aria-checked'), + b.getAttribute('tabindex'), + ]), + [ + ['confidence-all', 'ALL', 'true', '0'], + ['confidence-nominal', 'NOMINAL', 'false', '-1'], + ['confidence-high', 'HIGH', 'false', '-1'], + ], + ); + assert.equal(buttons[0].getAttribute('role'), 'radio'); + assert.equal(buttons[1].title, 'nominal title'); + + syncSegmentedGroup(container, segmentedDescriptor('high')); + assert.equal(group.children[0], buttons[0], 'buttons are reused'); + assert.equal(buttons[0].getAttribute('aria-checked'), 'false'); + assert.equal(buttons[2].getAttribute('aria-checked'), 'true'); + assert.ok(buttons[2].classList.contains('active')); + assert.equal(container.children.length, 1); + + syncSegmentedGroup(container, null); + assert.equal(container.children.length, 0); +}); + +test('a layer row dispatches slider input and switch clicks as user params', async () => { + const { LayerPanel } = await import('./layerPanel.js'); + const f = railFixture(); + const previousDocument = globalThis.document; + globalThis.document = f.document; + const calls = []; + let controls = { + slider: sliderDescriptor(), + segmented: segmentedDescriptor('all'), + info: '12 of 40 shown', + }; + const panel = new LayerPanel({ + getLayers: () => [ + { + id: 'local-firms', + name: 'Active Fires', + icon: '▲', + source: 'NASA FIRMS', + enabled: true, + showInTogglePanel: true, + stats: {}, + }, + ], + isEnabled: () => true, + setEnabled: () => {}, + setLayerParams: (...args) => calls.push(args), + hasRowControls: () => true, + subscribeRowControls: () => {}, + getRowControls: () => controls, + }); + try { + panel.mount(f.container); + const row = f.find((n) => n.dataset.layerId === 'local-firms'); + const container = row.querySelector('.data-toggle-controls'); + assert.equal(container.hidden, false); + assert.equal( + row.querySelector('.data-toggle-controls-info').textContent, + '12 of 40 shown', + ); + // Order: switch, slider, info. + assert.deepEqual( + container.children.map((node) => node.className.split(' ')[0]), + ['gev-segmented', 'data-toggle-slider', 'data-toggle-controls-info'], + ); + + const input = row.querySelector('.data-toggle-slider-input'); + input.value = '434'; + const inputEvent = new Event('input'); + Object.defineProperty(inputEvent, 'target', { value: input }); + container.dispatchEvent(inputEvent); + assert.deepEqual(calls.at(-1), [ + 'local-firms', + { minFrp: 434 }, + { origin: 'user' }, + ]); + + const high = row + .querySelector('.data-toggle-segmented') + .children.find((b) => b.dataset.chipId === 'confidence-high'); + high.click(); + assert.deepEqual(calls.at(-1), [ + 'local-firms', + { confidence: 'high' }, + { origin: 'user' }, + ]); + + // A stale descriptor never applies: the live one decides. + controls = { ...controls, segmented: segmentedDescriptor('high') }; + // The fixture DOM has no attribute selectors, so sync the row directly. + panel._syncRowControls(container, { id: 'local-firms', enabled: true }); + assert.equal(high.getAttribute('aria-checked'), 'true'); + assert.equal( + row.querySelector('.data-toggle-slider-input'), + input, + 'the slider survives a refresh', + ); + } finally { + panel.destroy(); + globalThis.document = previousDocument; + } +}); diff --git a/src/ui/styles/radio.css b/src/ui/styles/radio.css index 483321992..5047ae6f4 100644 --- a/src/ui/styles/radio.css +++ b/src/ui/styles/radio.css @@ -1035,6 +1035,108 @@ html:not([lang='en']) .data-row-list-text { padding-top: 3px; overflow-wrap: anywhere; } + +/* Optional per-layer one-of-many switch (Active Fires confidence). The shared + .gev-segmented surface, sized to the chip row. */ +.data-toggle-controls .data-toggle-segmented { + flex-basis: 100%; + max-width: 220px; +} + +.data-toggle-controls .data-toggle-segment { + min-height: 20px; + padding: 3px 6px; + font-size: 8px; + letter-spacing: 0.8px; +} + +/* Optional per-layer quantitative slider (Active Fires minimum FRP). The + track is painted by the layer (`--slider-track`, a stepped color ramp), so + the slider doubles as the marker legend; ticks sit under their thresholds. */ +.data-toggle-slider { + flex-basis: 100%; + display: grid; + gap: 2px; + padding-top: 2px; +} + +.data-toggle-slider-head { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 8px; + font-family: var(--font-mono); + font-size: 8px; + letter-spacing: 1.2px; + color: var(--text-dim); + text-transform: uppercase; +} + +.data-toggle-slider-value { + min-width: 0; + font-size: 9px; + letter-spacing: 0.5px; + text-transform: none; +} + +.data-toggle-slider-input { + height: 14px; +} + +.data-toggle-slider-input::-webkit-slider-runnable-track { + height: 5px; + border-radius: 3px; + background: var(--slider-track, rgba(255, 255, 255, 0.08)); + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.35); +} + +.data-toggle-slider-input::-moz-range-track, +.data-toggle-slider-input::-moz-range-progress { + height: 5px; + border-radius: 3px; + background: var(--slider-track, rgba(255, 255, 255, 0.08)); +} + +.data-toggle-slider-input::-webkit-slider-thumb { + width: 11px; + height: 11px; + margin-top: -3px; + background: var(--text-primary); + box-shadow: + 0 0 0 1px rgba(0, 0, 0, 0.6), + 0 0 6px rgba(255, 255, 255, 0.35); +} + +.data-toggle-slider-input::-moz-range-thumb { + width: 11px; + height: 11px; + background: var(--text-primary); + box-shadow: + 0 0 0 1px rgba(0, 0, 0, 0.6), + 0 0 6px rgba(255, 255, 255, 0.35); +} + +.data-toggle-slider-ticks { + position: relative; + height: 10px; + margin: 0 5px; + font-family: var(--font-mono); + font-size: 7px; + letter-spacing: 0.4px; + color: var(--text-dim); +} + +.data-toggle-slider-tick { + position: absolute; + top: 0; + left: var(--at); + transform: translateX(-50%); + white-space: nowrap; +} + +.data-toggle-slider-tick:first-child { + transform: none; +} /* ── Local RTL-SDR card (FM + Local ADS-B receiver) ── */ .sdr-radio-card { display: grid; From 22a87500c6b3296da1003a4101ca0ed80ba1f992 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 27 Oct 2026 18:30:58 +0400 Subject: [PATCH 013/156] docs(fires): current-state note and changelog fragment for the intensity filter (#1426) Describe the six-stop FRP ramp, the slider / confidence switch / counter, the single filter point, the share-token options and the tool arguments in docs/CURRENT-STATE.md, and add changelog.d/1426-fire-intensity-filter.md as CONTRIBUTING asks for runtime changes. Co-Authored-By: Claude Fable 5.1 --- changelog.d/1426-fire-intensity-filter.md | 8 ++++++++ docs/CURRENT-STATE.md | 24 +++++++++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 changelog.d/1426-fire-intensity-filter.md diff --git a/changelog.d/1426-fire-intensity-filter.md b/changelog.d/1426-fire-intensity-filter.md new file mode 100644 index 000000000..fad9051e9 --- /dev/null +++ b/changelog.d/1426-fire-intensity-filter.md @@ -0,0 +1,8 @@ +- Color every Active Fires detection by fire radiative power on a six-stop + cold → hot ramp (blue · teal · lime · amber · orange · red at 0 · 5 · 20 · + 50 · 100 · 300 MW), and add a log-scale minimum-FRP slider whose track is the + ramp, an All / Nominal+ / High confidence switch and an "N of M shown" + counter to the layer row. The filter applies once before rendering, so + markers, cards, heat cells, context and the top-fires query agree; share + links keep it (`w` token: `f`, `c`), and `get_active_fires` takes matching + `min_frp_mw` and `confidence` arguments. Default 0 MW / All (gekh, #1426). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 0cb46940c..61699bda9 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2054,6 +2054,30 @@ Generated style sliders use the same name as their visible parameter label. The first-run suppression checkbox keeps its native wrapping label, so its accessible name remains "Don't show this again". Control behavior is unchanged. +## Active Fires intensity filter and color ramp + +Every FIRMS detection is colored by fire radiative power on a six-stop cold → +hot ramp: blue from 0 MW, teal from 5, lime from 20, amber from 50, orange from +100 and red from 300 MW (`FRP_RAMP` in `src/data/firmsIntensity.js`). Heat +cells, glow sprites and card accents read the same ramp; confidence and +brightness do not affect the color. + +The Active Fires row carries a minimum-FRP slider on a log scale whose track is +painted with the ramp and ticked at the same six values, an All / Nominal+ / +High confidence switch, and an "N of M shown" counter. The default is 0 MW and +All, because many of the strongest detections are rated low confidence. The +filter runs once in `ingestion.applyFilter()` before rendering, so markers, +cards, heat cells, the shared context store, the top-fires query and the +analyst records all see one filtered set; a selected fire that no longer passes +is deselected. The layer state keeps both options under the `w` (`local-firms`) +share token as `f` (whole megawatts, 0–10,000) and `c` (`a`, `n`, `h`), +including the short share codec; a link without them restores the defaults. + +The `get_active_fires` tool accepts its own `min_frp_mw` and `confidence` +(`all`, `nominal`, `high`) arguments, independent of the panel's setting, and +names the applied criteria in its notes. Stale-snapshot and missing-source +reporting are unchanged. + ## FIRMS source status The FIRMS CSV parser rejects impossible acquisition calendar dates before the proxy filters records to the trailing 24 hours, while retaining valid UTC acquisition times. From 1dad6b658a77b80465204cd10c5247cb4c2c084c Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 27 Oct 2026 18:50:03 +0400 Subject: [PATCH 014/156] feat(fires): caption the confidence switch and name the slider "Fire intensity (FRP)" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The switch had no visible word saying what ALL / NOMINAL+ / HIGH refer to, and "Min FRP" assumed the reader knows the acronym. The segmented row descriptor gains an optional visible `label` (a caption above the group); the fires row uses "Confidence" and "Fire intensity (FRP)", and the slider value reads "≥ N MW" so the threshold is still clear. Locales updated. Co-Authored-By: Claude Fable 5.1 --- docs/CURRENT-STATE.md | 7 ++++--- src/i18n/locales/es.json | 5 +++-- src/i18n/locales/ru.json | 5 +++-- src/i18n/locales/zh-CN.json | 5 +++-- src/i18n/same-as-english.json | 4 ++-- src/layers/firms/queries.js | 5 +++-- src/ui/rowFilterControls.js | 24 ++++++++++++++++++++++-- src/ui/rowFilterControls.test.mjs | 23 ++++++++++++++++++++--- src/ui/styles/radio.css | 10 ++++++++++ 9 files changed, 70 insertions(+), 18 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 61699bda9..2132a910f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2062,9 +2062,10 @@ hot ramp: blue from 0 MW, teal from 5, lime from 20, amber from 50, orange from cells, glow sprites and card accents read the same ramp; confidence and brightness do not affect the color. -The Active Fires row carries a minimum-FRP slider on a log scale whose track is -painted with the ramp and ticked at the same six values, an All / Nominal+ / -High confidence switch, and an "N of M shown" counter. The default is 0 MW and +The Active Fires row carries a "Fire intensity (FRP)" slider on a log scale +whose track is painted with the ramp and ticked at the same six values (its +value reads "All" or "≥ N MW"), a "Confidence" switch with All / Nominal+ / +High, and an "N of M shown" counter. The default is 0 MW and All, because many of the strongest detections are rated low confidence. The filter runs once in `ingestion.applyFilter()` before rendering, so markers, cards, heat cells, the shared context store, the top-fires query and the diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index ffee7fb87..babea8bfb 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -3008,9 +3008,10 @@ "Every detection — many of the strongest fires are rated low confidence": "Todas las detecciones — muchos de los incendios más intensos tienen confianza baja", "Nominal and high confidence detections": "Detecciones de confianza nominal y alta", "High confidence detections only": "Solo detecciones de confianza alta", - "Min FRP": "FRP mín.", + "Fire intensity (FRP)": "Intensidad (FRP)", + "≥ {value} MW": "≥ {value} MW", + "Confidence": "Confianza", "Minimum fire radiative power, megawatts": "Potencia radiativa mínima del fuego, en megavatios", - "{value} MW": "{value} MW", "fire intensity filter|All": "Todo", "Detection confidence": "Confianza de la detección", "{shown} of {total} shown": "{shown} de {total} mostrados" diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 1cd88757d..6289ad38c 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3077,9 +3077,10 @@ "Every detection — many of the strongest fires are rated low confidence": "Все детекции — у многих сильнейших пожаров достоверность низкая", "Nominal and high confidence detections": "Детекции средней и высокой достоверности", "High confidence detections only": "Только детекции высокой достоверности", - "Min FRP": "Мин. FRP", + "Fire intensity (FRP)": "Интенсивность (FRP)", + "≥ {value} MW": "≥ {value} МВт", + "Confidence": "Достоверность", "Minimum fire radiative power, megawatts": "Минимальная мощность излучения пожара, мегаватты", - "{value} MW": "{value} МВт", "fire intensity filter|All": "Все", "Detection confidence": "Достоверность детекции", "{shown} of {total} shown": "показано {shown} из {total}" diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 3044c45f8..110f7a77c 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -2870,9 +2870,10 @@ "Every detection — many of the strongest fires are rated low confidence": "所有检测——许多最强的火点置信度为低", "Nominal and high confidence detections": "标称和高置信度的检测", "High confidence detections only": "仅高置信度的检测", - "Min FRP": "最小 FRP", + "Fire intensity (FRP)": "火势强度 (FRP)", + "≥ {value} MW": "≥ {value} MW", + "Confidence": "置信度", "Minimum fire radiative power, megawatts": "最小火辐射功率(兆瓦)", - "{value} MW": "{value} MW", "fire intensity filter|All": "全部", "Detection confidence": "检测置信度", "{shown} of {total} shown": "显示 {shown} / {total}" diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index 5f1b14f8a..5d0313965 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -121,7 +121,7 @@ "{value} KM", "{value} KM/H", "{value} KM/S", - "{value} MW", + "≥ {value} MW", "ΔE", "ΔN", "▲ {frp} MW" @@ -197,7 +197,7 @@ "{type} SPD: {speed} HDG: {heading}", "{value} KM", "{value} KM/S", - "{value} MW", + "≥ {value} MW", "ΔE", "ΔN", "▲ {frp} MW" diff --git a/src/layers/firms/queries.js b/src/layers/firms/queries.js index 220012133..44ce8573d 100644 --- a/src/layers/firms/queries.js +++ b/src/layers/firms/queries.js @@ -135,11 +135,11 @@ export function createQueries({ return { slider: { id: 'min-frp', - label: t('Min FRP'), + label: t('Fire intensity (FRP)'), ariaLabel: t('Minimum fire radiative power, megawatts'), valueText: minFrp > 0 - ? t('{value} MW', { value: frpLabel(minFrp) }) + ? t('≥ {value} MW', { value: frpLabel(minFrp) }) : t('All', { context: 'fire intensity filter' }), min: 0, max: SLIDER_STEPS, @@ -156,6 +156,7 @@ export function createQueries({ }, segmented: { id: 'confidence', + label: t('Confidence'), ariaLabel: t('Detection confidence'), options: FIRMS_CONFIDENCE_MODES.map((mode) => ({ id: `confidence-${mode}`, diff --git a/src/ui/rowFilterControls.js b/src/ui/rowFilterControls.js index b0299d24b..87a3e0bc6 100644 --- a/src/ui/rowFilterControls.js +++ b/src/ui/rowFilterControls.js @@ -104,8 +104,9 @@ export function syncSliderBlock(container, slider, { before = null } = {}) { * Buttons carry `data-chip-id` like chips so the row's delegated click handler * can resolve them; the caller matches `.data-toggle-segment`. * - * Descriptor: `{ id, ariaLabel, options: [{ id, label, title, active, - * disabled, params }] }`. + * Descriptor: `{ id, label, ariaLabel, options: [{ id, label, title, active, + * disabled, params }] }` — `label` is an optional visible caption above the + * buttons (the group itself is a flex item, so the caption is a sibling). * @param {HTMLElement} container The row's `.data-toggle-controls` node. * @param {?object} segmented Descriptor, or null to remove the group. * @param {{before?: ?Node}} [options] Insertion anchor for a new group. @@ -121,19 +122,38 @@ export function syncSegmentedGroup( const existing = [...container.children].find( (node) => node.dataset?.segmentedId, ); + const existingCaption = [...container.children].find( + (node) => node.dataset?.segmentedCaptionFor, + ); if (!segmented) { existing?.remove(); + existingCaption?.remove(); return null; } let group = existing; if (!group || group.dataset.segmentedId !== segmented.id) { existing?.remove(); + existingCaption?.remove(); group = document.createElement('div'); group.className = 'gev-segmented data-toggle-segmented'; group.dataset.segmentedId = segmented.id; group.setAttribute('role', 'radiogroup'); container.insertBefore(group, before); } + let caption = [...container.children].find( + (node) => node.dataset?.segmentedCaptionFor === segmented.id, + ); + if (segmented.label) { + if (!caption) { + caption = document.createElement('span'); + caption.className = 'data-toggle-segmented-label'; + caption.dataset.segmentedCaptionFor = segmented.id; + container.insertBefore(caption, group); + } + set(caption, 'textContent', segmented.label); + } else { + caption?.remove(); + } if (segmented.ariaLabel) attribute(group, 'aria-label', segmented.ariaLabel); const stale = new Map(); for (const node of [...group.children]) diff --git a/src/ui/rowFilterControls.test.mjs b/src/ui/rowFilterControls.test.mjs index 9cc8fb1d4..ac5d2c1d0 100644 --- a/src/ui/rowFilterControls.test.mjs +++ b/src/ui/rowFilterControls.test.mjs @@ -24,6 +24,7 @@ const sliderDescriptor = (overrides = {}) => ({ const segmentedDescriptor = (active = 'all') => ({ id: 'confidence', + label: 'Confidence', ariaLabel: 'Detection confidence', options: ['all', 'nominal', 'high'].map((mode) => ({ id: `confidence-${mode}`, @@ -96,6 +97,9 @@ test('the segmented group is a radiogroup whose active option reads checked, rec assert.equal(group.getAttribute('role'), 'radiogroup'); assert.equal(group.getAttribute('aria-label'), 'Detection confidence'); assert.ok(group.className.split(' ').includes('gev-segmented')); + const caption = container.children[container.children.indexOf(group) - 1]; + assert.equal(caption.className, 'data-toggle-segmented-label'); + assert.equal(caption.textContent, 'Confidence'); const buttons = group.children; assert.deepEqual( buttons.map((b) => [ @@ -118,10 +122,18 @@ test('the segmented group is a radiogroup whose active option reads checked, rec assert.equal(buttons[0].getAttribute('aria-checked'), 'false'); assert.equal(buttons[2].getAttribute('aria-checked'), 'true'); assert.ok(buttons[2].classList.contains('active')); + assert.equal(container.children.length, 2, 'caption and group'); + + syncSegmentedGroup(container, { ...segmentedDescriptor('high'), label: '' }); + assert.equal( + container.querySelector('.data-toggle-segmented-label'), + null, + 'an empty label removes the caption', + ); assert.equal(container.children.length, 1); syncSegmentedGroup(container, null); - assert.equal(container.children.length, 0); + assert.equal(container.children.length, 0, 'null removes group and caption'); }); test('a layer row dispatches slider input and switch clicks as user params', async () => { @@ -163,10 +175,15 @@ test('a layer row dispatches slider input and switch clicks as user params', asy row.querySelector('.data-toggle-controls-info').textContent, '12 of 40 shown', ); - // Order: switch, slider, info. + // Order: switch caption, switch, slider, info. assert.deepEqual( container.children.map((node) => node.className.split(' ')[0]), - ['gev-segmented', 'data-toggle-slider', 'data-toggle-controls-info'], + [ + 'data-toggle-segmented-label', + 'gev-segmented', + 'data-toggle-slider', + 'data-toggle-controls-info', + ], ); const input = row.querySelector('.data-toggle-slider-input'); diff --git a/src/ui/styles/radio.css b/src/ui/styles/radio.css index 5047ae6f4..e005c5288 100644 --- a/src/ui/styles/radio.css +++ b/src/ui/styles/radio.css @@ -1043,6 +1043,16 @@ html:not([lang='en']) .data-row-list-text { max-width: 220px; } +.data-toggle-segmented-label { + flex-basis: 100%; + padding-top: 2px; + font-family: var(--font-mono); + font-size: 8px; + letter-spacing: 1.2px; + color: var(--text-dim); + text-transform: uppercase; +} + .data-toggle-controls .data-toggle-segment { min-height: 20px; padding: 3px 6px; From 887b261195fe4a425e9cd8dfdd5027172b7c5342 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 27 Oct 2026 19:19:40 +0400 Subject: [PATCH 015/156] fix(fires): paint aggregated heat cells warm by density, not by strongest FRP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coloring each far-zoom cell with the ramp stop of its strongest detection turned a burning region into a blue / teal / lime mosaic: neighbouring cells flipped hue on one outlier and the cold stops did not read as fire. Cells say where it burns, so they go back to the heat-normalized score (density, night share, max FRP) on the warm end of the ramp — lime, amber, red — with the cell card accent matching. Single detections keep the full six-stop FRP ramp. Co-Authored-By: Claude Fable 5.1 --- changelog.d/1426-fire-intensity-filter.md | 3 +- docs/CURRENT-STATE.md | 9 ++++-- src/layers/firms/model.js | 37 +++++++++++++++++------ src/layers/firms/rendering.js | 5 +-- 4 files changed, 39 insertions(+), 15 deletions(-) diff --git a/changelog.d/1426-fire-intensity-filter.md b/changelog.d/1426-fire-intensity-filter.md index fad9051e9..09e3c3a7d 100644 --- a/changelog.d/1426-fire-intensity-filter.md +++ b/changelog.d/1426-fire-intensity-filter.md @@ -2,7 +2,8 @@ cold → hot ramp (blue · teal · lime · amber · orange · red at 0 · 5 · 20 · 50 · 100 · 300 MW), and add a log-scale minimum-FRP slider whose track is the ramp, an All / Nominal+ / High confidence switch and an "N of M shown" - counter to the layer row. The filter applies once before rendering, so + counter to the layer row. Far-zoom heat cells keep a warm density fill + (lime · amber · red). The filter applies once before rendering, so markers, cards, heat cells, context and the top-fires query agree; share links keep it (`w` token: `f`, `c`), and `get_active_fires` takes matching `min_frp_mw` and `confidence` arguments. Default 0 MW / All (gekh, #1426). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 2132a910f..7e29d8675 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2058,9 +2058,12 @@ accessible name remains "Don't show this again". Control behavior is unchanged. Every FIRMS detection is colored by fire radiative power on a six-stop cold → hot ramp: blue from 0 MW, teal from 5, lime from 20, amber from 50, orange from -100 and red from 300 MW (`FRP_RAMP` in `src/data/firmsIntensity.js`). Heat -cells, glow sprites and card accents read the same ramp; confidence and -brightness do not affect the color. +100 and red from 300 MW (`FRP_RAMP` in `src/data/firmsIntensity.js`). Glow +sprites and fire-card accents read that ramp; confidence and brightness do +not affect the color. Aggregated heat cells (far zoom) instead take the warm +end of the ramp — lime, amber or red — from their heat-normalized score +(density, night share and strongest FRP), so a region reads as one field +rather than a per-cell mosaic; the cell card's accent matches its fill. The Active Fires row carries a "Fire intensity (FRP)" slider on a log scale whose track is painted with the ramp and ticked at the same six values (its diff --git a/src/layers/firms/model.js b/src/layers/firms/model.js index a90066cc6..83f573c98 100644 --- a/src/layers/firms/model.js +++ b/src/layers/firms/model.js @@ -137,15 +137,33 @@ export function createModel({ } /** - * Fill color for an aggregated heat cell: the ramp color of its strongest - * detection, so cells, markers and the slider legend read on one scale. - * @param {number} maxFrp - Strongest FRP in the cell, megawatts. + * Severity stop for an aggregated heat cell from its heat-normalized score + * (density, night share and strongest FRP together). Cells say *where* it + * burns, so they stay on the warm end of the ramp — lime, amber, red — and + * read as one field instead of a per-cell mosaic; the cold stops belong to + * single detections, which say *how hard* each one burns. + * @param {number} normalized - 0..1 heat score (sqrt-normalized to the view). + * @returns {{name: string, minFrp: number, color: Cesium.Color}} + */ + + function cellColorStop(normalized) { + const name = + normalized > 0.72 ? 'red' : normalized > 0.42 ? 'amber' : 'lime'; + return ( + DETECTION_COLOR_STOPS.find((stop) => stop.name === name) || + DETECTION_COLOR_STOPS[0] + ); + } + + /** + * Fill color for an aggregated heat cell. + * @param {number} normalized - 0..1 heat score (see {@link cellColorStop}). * @param {number} alpha - Fill alpha (heat-normalized by the caller). * @returns {Cesium.Color} */ - function heatColor(maxFrp, alpha) { - return frpColorStop(maxFrp).color.withAlpha(alpha); + function heatColor(normalized, alpha) { + return cellColorStop(normalized).color.withAlpha(alpha); } /** @@ -476,14 +494,14 @@ export function createModel({ } /** - * Cell card accent: the ramp stop of the cell's strongest detection, + * Cell card accent: the warm severity stop of the cell's heat score, * mirroring heatColor. - * @param {number} maxFrp - Strongest FRP in the cell, megawatts. + * @param {number} normalized - 0..1 heat score. * @returns {string} "r, g, b" accent string. */ - function cellAccent(maxFrp) { - return accentForSeverity(frpColorStop(maxFrp).name); + function cellAccent(normalized) { + return accentForSeverity(cellColorStop(normalized).name); } /** Coordinate line like "30.512°N 75.831°E". */ @@ -590,6 +608,7 @@ export function createModel({ cellIntersectsBounds, heatScore, heatColor, + cellColorStop, frpColorStop, detectionColorStop, frpPixelSize, diff --git a/src/layers/firms/rendering.js b/src/layers/firms/rendering.js index 757fd4cf7..8ae445f91 100644 --- a/src/layers/firms/rendering.js +++ b/src/layers/firms/rendering.js @@ -144,7 +144,7 @@ export function createRendering({ const score = components.model.heatScore(cell); const normalized = Math.min(1, Math.sqrt(score / maxScore)); const alpha = 0.16 + normalized * 0.5; - const color = components.model.heatColor(cell.maxFrp, alpha); + const color = components.model.heatColor(normalized, alpha); const centerLon = cell.lonCell + lod.gridDegrees / 2; const centerLat = cell.latCell + lod.gridDegrees / 2; const position = Cesium.Cartesian3.fromDegrees(centerLon, centerLat, 0); @@ -170,12 +170,13 @@ export function createRendering({ }); // Cells arrive heat-sorted, so candidate order doubles as label priority. + // Accent is resolved here because `normalized` only exists in this walk. layerState._labelCandidates.push({ position, cullPosition: components.model.cellCullPosition(centerLon, centerLat), fire: null, cell, - accent: components.model.cellAccent(cell.maxFrp), + accent: components.model.cellAccent(normalized), }); } From 6d6ad20ad3d171ade71911b9542d94d72855194d Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Thu, 29 Oct 2026 00:41:30 -0700 Subject: [PATCH 016/156] fix(infrastructure): ground-sample only stem bases the camera sees scene.sampleHeight reads only surface rendered for the current view. For a data centre or dam base outside the frustum or over the horizon it read through the Earth (about -6,344 km over Ashburn on Esri), failed the 9,000 m check and retried every 2 s indefinitely, one offscreen render per record. Over Ashburn that held rest frames at 58-589 ms. Unseen bases are now skipped without being stamped, so each is sampled on the first walk that sees it, and they no longer arm the retry render. Co-Authored-By: Claude Opus 5.5 --- docs/CURRENT-STATE.md | 4 ++- src/data/localGeojson.test.mjs | 53 +++++++++++++++++++++++++++++++++- src/data/localGeojsonCore.js | 44 +++++++++++++++++++++++++--- 3 files changed, 95 insertions(+), 6 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 09ae87f1a..e5202e55c 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1959,7 +1959,9 @@ during continuous motion. Disable/re-enable resets selection cleanly. Close-up stem sizing uses camera-to-feature distance without a 5 km minimum. Unchanged overlay entries do not request another frame; moved positions, changed membership and re-enabling still publish. Ground sampling waits for -visible globe tiles to settle and uses valid loaded terrain as a floor while +visible globe tiles to settle, samples only markers whose base is in the +camera's view and above the horizon (an unseen base reads through the Earth +and would retry every 2 s), and uses valid loaded terrain as a floor while retaining rooftop and below-sea-level elevations. A hidden globe does not constrain photoreal geometry, and retry work remains bounded. Already sampled nearby markers follow higher settled terrain on existing bounded walks, without diff --git a/src/data/localGeojson.test.mjs b/src/data/localGeojson.test.mjs index 13cde65a5..89de41529 100644 --- a/src/data/localGeojson.test.mjs +++ b/src/data/localGeojson.test.mjs @@ -90,6 +90,8 @@ async function createRealLocalLayerHarness({ globalThis.window = { dispatchEvent() {} }; let sampleHeightImpl = sampleHeight; const sampleCalls = { count: 0 }; + // The camera frustum, reduced to the one answer ground sampling asks of it. + let baseVisibility = Cesium.Intersect.INSIDE; const overlayHost = { setVisible: (...args) => hostCalls.push(['visible', ...args]), setEntries: (...args) => hostCalls.push(['entries', ...args]), @@ -107,7 +109,14 @@ async function createRealLocalLayerHarness({ }, camera: { positionWC: Cesium.Cartesian3.fromDegrees(-97.695, 30.205, 100_000), - frustum: { fov: Math.PI / 3 }, + directionWC: new Cesium.Cartesian3(), + upWC: new Cesium.Cartesian3(), + frustum: { + fov: Math.PI / 3, + computeCullingVolume: () => ({ + computeVisibility: () => baseVisibility, + }), + }, moveEnd, flyTo() {}, }, @@ -151,6 +160,10 @@ async function createRealLocalLayerHarness({ sampleCalls, /** Swap scene.sampleHeight mid-test (e.g. tiles finally arrive). */ setSampleHeight(next) { sampleHeightImpl = next; }, + /** Put the feature's base inside (true) or outside the camera frustum. */ + setBaseInView(inView) { + baseVisibility = inView ? Cesium.Intersect.INSIDE : Cesium.Intersect.OUTSIDE; + }, cleanup() { if (originalWindow === undefined) delete globalThis.window; else globalThis.window = originalWindow; @@ -736,6 +749,44 @@ test('ground sampling waits for visible globe tiles before caching a height', as assert.ok(Math.abs(baseHeightM(env) - 117) < 0.01); }); +test('an unseen base is not sampled and arms no retry until it is in view', async (t) => { + // Out of view the scene has no surface under the base, so the depth sample + // would pass through the Earth (about -6,344 km) and fail on every retry. + const env = await createRealLocalLayerHarness({ + sampleHeightSupported: true, sampleHeight: () => 117, + }); + _resetRenderGovernorForTest(); + const governorScene = { renders: 0, requestRender() { this.renders += 1; } }; + installRenderGovernor({ scene: governorScene }); + governorScene.renders = 0; // discard the governor's own install settling frame + t.mock.timers.enable({ apis: ['setTimeout'] }); + const clock = installFakeClock(t); + t.after(() => { + env.layer.destroy(env.viewer); + env.cleanup(); + _resetRenderGovernorForTest(); + }); + env.viewer.scene.globe = { show: true, tilesLoaded: true, getHeight: () => 117 }; + setCameraAltitude(env, 20_000); + env.setBaseInView(false); + runArmedRetryChain(env, t, clock, 5); + assert.equal(env.sampleCalls.count, 0, 'an off-screen base must not be sampled'); + assert.equal(governorScene.renders, 0, 'an off-screen base must not keep a parked camera rendering'); + assert.ok(Math.abs(baseHeightM(env)) < 0.01); + + // In the frustum but over the horizon: 50 m up, about 67 km away. + env.setBaseInView(true); + env.viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees(-97.0, 30.205, 50); + runArmedRetryChain(env, t, clock, 5); + assert.equal(env.sampleCalls.count, 0, 'a base over the horizon must not be sampled'); + assert.equal(governorScene.renders, 0); + + setCameraAltitude(env, 20_000); + env.preRender.raise(); + assert.equal(env.sampleCalls.count, 1, 'the base is sampled on the first walk that sees it'); + assert.ok(Math.abs(baseHeightM(env) - 117) < 0.01); +}); + test('settled terrain refinement lifts an already sampled stem without another GPU sample', async (t) => { const env = await createRealLocalLayerHarness({ sampleHeightSupported: true, sampleHeight: () => 117, diff --git a/src/data/localGeojsonCore.js b/src/data/localGeojsonCore.js index 1d04fa46c..9d97df61c 100644 --- a/src/data/localGeojsonCore.js +++ b/src/data/localGeojsonCore.js @@ -1000,9 +1000,9 @@ export function createLocalGeoJsonLayer( // governor the retry's preRender never arrives on a parked camera // and the stem stays at ellipsoid height (buried/floating) until // the user happens to move. Schedule the frame the retry needs. - // Gated on a sampleable scene and in-range records only, so a far - // camera (or a keyless scene) stays fully idle; the distance is - // only computed for still-unsampled stems. + // Gated on a sampleable scene and in-range, in-view records only, + // so a far camera (or a keyless scene) stays fully idle; the + // distance is only computed for still-unsampled stems. if ( canSampleGround && !record.groundSampled && @@ -1010,7 +1010,8 @@ export function createLocalGeoJsonLayer( Cesium.Cartesian3.distance( viewer.camera.positionWC, record.base, - ) < GROUND_SAMPLE_MAX_DISTANCE_M + ) < GROUND_SAMPLE_MAX_DISTANCE_M && + groundSampleInView(viewer, record) ) { groundRetryPending = true; } @@ -1130,9 +1131,44 @@ function refreshLocalTerrainFloor(viewer, record) { return true; } +const groundSampleSphere = new Cesium.BoundingSphere( + new Cesium.Cartesian3(), + 1, +); + +const groundSampleOccluder = new Cesium.EllipsoidalOccluder( + Cesium.Ellipsoid.WGS84, +); + +/** + * Whether the camera sees the record's base: inside the view frustum and + * above the horizon. scene.sampleHeight reads only surface rendered for the + * current view, so a sample of an unseen base passes through the Earth (about + * -6,344 km near Ashburn on Esri) and fails on every 2 s retry, each one a + * full offscreen render: 110 unseen data centres there held the page near 2 s + * a frame indefinitely. An unseen base keeps its stem at the unsampled height + * and is sampled on the first walk that sees it. + */ +function groundSampleInView(viewer, record) { + const camera = viewer.camera; + const culling = camera.frustum.computeCullingVolume( + camera.positionWC, + camera.directionWC, + camera.upWC, + ); + Cesium.Cartesian3.clone(record.base, groundSampleSphere.center); + if ( + culling.computeVisibility(groundSampleSphere) === Cesium.Intersect.OUTSIDE + ) + return false; + groundSampleOccluder.cameraPosition = camera.positionWC; + return groundSampleOccluder.isPointVisible(record.base); +} + function sampleLocalGroundHeight(viewer, record, now) { if (record.groundSampled || !viewer.scene.sampleHeightSupported) return false; if (now - record.lastGroundSampleMs < GROUND_SAMPLE_RETRY_MS) return false; + if (!groundSampleInView(viewer, record)) return false; record.lastGroundSampleMs = now; const globe = viewer.scene.globe; if (globe?.show && globe.tilesLoaded === false) return false; From 189b30b5459a1a476fed3833325d2f460ce07862 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Thu, 29 Oct 2026 00:43:40 -0700 Subject: [PATCH 017/156] docs(changelog): add the infrastructure ground-sampling entry Co-Authored-By: Claude Opus 5.5 --- changelog.d/1464-infrastructure-ground-sample-in-view.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelog.d/1464-infrastructure-ground-sample-in-view.md diff --git a/changelog.d/1464-infrastructure-ground-sample-in-view.md b/changelog.d/1464-infrastructure-ground-sample-in-view.md new file mode 100644 index 000000000..8ab5d1d88 --- /dev/null +++ b/changelog.d/1464-infrastructure-ground-sample-in-view.md @@ -0,0 +1,4 @@ +- Ground-sample Data Centers and Dams markers only when the camera sees their + base. An off-screen base read through the Earth and retried every 2 s, which + held the page near 2 s a frame over Ashburn, Virginia on the Esri map + (quanticsoul4772, #1464). From ecc0ced47c4f765627910bf089e303251cd5e9b4 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Thu, 29 Oct 2026 01:25:57 -0700 Subject: [PATCH 018/156] fix(infrastructure): test the ground, not the ellipsoid, for in-view sampling An unsampled base sits at ellipsoid height 0. Over high terrain seen at an angle that point projects below the screen while the ground above it is in view, so the gate skipped ground the camera could see and never sampled it. The gate now tests loaded globe terrain when there is some, and otherwise the whole span of heights a sample can accept (0 to 9,000 m). Co-Authored-By: Claude Opus 5.5 --- ...64-infrastructure-ground-sample-in-view.md | 6 +- docs/CURRENT-STATE.md | 8 ++- src/data/localGeojson.test.mjs | 58 +++++++++++++++++++ src/data/localGeojsonCore.js | 49 +++++++++++++--- 4 files changed, 106 insertions(+), 15 deletions(-) diff --git a/changelog.d/1464-infrastructure-ground-sample-in-view.md b/changelog.d/1464-infrastructure-ground-sample-in-view.md index 8ab5d1d88..b563c86b4 100644 --- a/changelog.d/1464-infrastructure-ground-sample-in-view.md +++ b/changelog.d/1464-infrastructure-ground-sample-in-view.md @@ -1,4 +1,4 @@ -- Ground-sample Data Centers and Dams markers only when the camera sees their - base. An off-screen base read through the Earth and retried every 2 s, which - held the page near 2 s a frame over Ashburn, Virginia on the Esri map +- Ground-sample Data Centers and Dams markers only when the camera sees the + ground under them. Unseen ground read through the Earth and retried every + 2 s; over Ashburn, Virginia on the Esri map, slow frames reached 2 s (quanticsoul4772, #1464). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index e5202e55c..065d67112 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1959,9 +1959,11 @@ during continuous motion. Disable/re-enable resets selection cleanly. Close-up stem sizing uses camera-to-feature distance without a 5 km minimum. Unchanged overlay entries do not request another frame; moved positions, changed membership and re-enabling still publish. Ground sampling waits for -visible globe tiles to settle, samples only markers whose base is in the -camera's view and above the horizon (an unseen base reads through the Earth -and would retry every 2 s), and uses valid loaded terrain as a floor while +visible globe tiles to settle, samples only markers whose ground is in the +camera's view and above the horizon (unseen ground reads through the Earth +and would retry every 2 s; until it is seen, a stem keeps its unsampled +height, which can show for a stem whose base is just off screen), and uses +valid loaded terrain as a floor while retaining rooftop and below-sea-level elevations. A hidden globe does not constrain photoreal geometry, and retry work remains bounded. Already sampled nearby markers follow higher settled terrain on existing bounded walks, without diff --git a/src/data/localGeojson.test.mjs b/src/data/localGeojson.test.mjs index 89de41529..1c47cb8c1 100644 --- a/src/data/localGeojson.test.mjs +++ b/src/data/localGeojson.test.mjs @@ -787,6 +787,64 @@ test('an unseen base is not sampled and arms no retry until it is in view', asyn assert.ok(Math.abs(baseHeightM(env) - 117) < 0.01); }); +for (const [label, globe] of [ + ['loaded terrain', { show: true, tilesLoaded: true, getHeight: () => 2_500 }], + ['a hidden globe', { show: false, tilesLoaded: true, getHeight: () => undefined }], +]) { + test(`high ground in view is sampled with ${label} although its unsampled base projects off screen`, async (t) => { + // A base on 2,500 m terrain, seen from 3 km away at a shallow angle: its + // ellipsoid-height point is far below the bottom of the screen. + const env = await createRealLocalLayerHarness({ + sampleHeightSupported: true, sampleHeight: () => 2_500, + }); + const clock = installFakeClock(t); + t.after(() => { env.layer.destroy(env.viewer); env.cleanup(); }); + env.viewer.scene.globe = globe; + const entity = env.dataSources[0].entities.values[0]; + const base = Cesium.Cartographic.fromCartesian(entity.__localBaseCartesian); + const ground = Cesium.Cartesian3.fromRadians(base.longitude, base.latitude, 2_500); + const position = Cesium.Cartesian3.fromRadians( + base.longitude, + base.latitude - 3_000 / 6_371_000, + 2_500 + 1_000, + ); + const direction = Cesium.Cartesian3.normalize( + Cesium.Cartesian3.subtract(ground, position, new Cesium.Cartesian3()), + new Cesium.Cartesian3(), + ); + const right = Cesium.Cartesian3.normalize( + Cesium.Cartesian3.cross( + direction, + Cesium.Ellipsoid.WGS84.geodeticSurfaceNormal(position), + new Cesium.Cartesian3(), + ), + new Cesium.Cartesian3(), + ); + const up = Cesium.Cartesian3.cross(right, direction, new Cesium.Cartesian3()); + const frustum = new Cesium.PerspectiveFrustum({ + fov: Math.PI / 3, + aspectRatio: 800 / 600, + near: 1, + far: 1e8, + }); + Object.assign(env.viewer.camera, { + positionWC: position, + directionWC: direction, + upWC: up, + frustum, + }); + const offScreen = frustum + .computeCullingVolume(position, direction, up) + .computeVisibility(new Cesium.BoundingSphere(entity.__localBaseCartesian, 1)); + assert.equal(offScreen, Cesium.Intersect.OUTSIDE, 'precondition: the ellipsoid-height base is off screen'); + + clock.advance(2_100); + env.preRender.raise(); + assert.equal(env.sampleCalls.count, 1, 'the ground in view is sampled'); + assert.ok(Math.abs(baseHeightM(env) - 2_500) < 0.01); + }); +} + test('settled terrain refinement lifts an already sampled stem without another GPU sample', async (t) => { const env = await createRealLocalLayerHarness({ sampleHeightSupported: true, sampleHeight: () => 117, diff --git a/src/data/localGeojsonCore.js b/src/data/localGeojsonCore.js index 9d97df61c..ec8fc824e 100644 --- a/src/data/localGeojsonCore.js +++ b/src/data/localGeojsonCore.js @@ -1140,29 +1140,60 @@ const groundSampleOccluder = new Cesium.EllipsoidalOccluder( Cesium.Ellipsoid.WGS84, ); +const groundSampleCarto = new Cesium.Cartographic(); +const groundSampleTop = new Cesium.Cartesian3(); + /** - * Whether the camera sees the record's base: inside the view frustum and - * above the horizon. scene.sampleHeight reads only surface rendered for the - * current view, so a sample of an unseen base passes through the Earth (about - * -6,344 km near Ashburn on Esri) and fails on every 2 s retry, each one a - * full offscreen render: 110 unseen data centres there held the page near 2 s - * a frame indefinitely. An unseen base keeps its stem at the unsampled height - * and is sampled on the first walk that sees it. + * Whether the camera can see the ground under the record: inside the view + * frustum and above the horizon. scene.sampleHeight reads only surface + * rendered for the current view, so a sample of unseen ground passes through + * the Earth (about -6,344 km near Ashburn on Esri) and fails on every 2 s + * retry, each one a full offscreen render; 110 unseen data centres there held + * rest frames at 58-589 ms. Unsampled bases sit on the ellipsoid, so the test + * uses loaded globe terrain when there is some, and otherwise every height a + * sample can accept (0 to GROUND_SAMPLE_MAX_ABS_HEIGHT_M): high ground in + * view is never mistaken for an unseen base. Unseen ground is sampled on the + * first walk that sees it. */ function groundSampleInView(viewer, record) { const camera = viewer.camera; + const globe = viewer.scene.globe; + const terrain = globe?.show ? globe.getHeight?.(record.carto) : undefined; + const known = + Number.isFinite(terrain) && + Math.abs(terrain) <= GROUND_SAMPLE_MAX_ABS_HEIGHT_M; + const span = GROUND_SAMPLE_MAX_ABS_HEIGHT_M / 2; + groundSampleCarto.longitude = record.carto.longitude; + groundSampleCarto.latitude = record.carto.latitude; + groundSampleCarto.height = known ? terrain : span; + Cesium.Cartographic.toCartesian( + groundSampleCarto, + Cesium.Ellipsoid.WGS84, + groundSampleSphere.center, + ); + groundSampleSphere.radius = known ? 1 : span; const culling = camera.frustum.computeCullingVolume( camera.positionWC, camera.directionWC, camera.upWC, ); - Cesium.Cartesian3.clone(record.base, groundSampleSphere.center); if ( culling.computeVisibility(groundSampleSphere) === Cesium.Intersect.OUTSIDE ) return false; + // The highest ground the sample could find is the last to drop below the + // horizon. + if (!known) groundSampleCarto.height = GROUND_SAMPLE_MAX_ABS_HEIGHT_M; groundSampleOccluder.cameraPosition = camera.positionWC; - return groundSampleOccluder.isPointVisible(record.base); + return groundSampleOccluder.isPointVisible( + known + ? groundSampleSphere.center + : Cesium.Cartographic.toCartesian( + groundSampleCarto, + Cesium.Ellipsoid.WGS84, + groundSampleTop, + ), + ); } function sampleLocalGroundHeight(viewer, record, now) { From f392eecbd969a0f8941b6ee2748bc58362a51865 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Thu, 29 Oct 2026 01:47:05 -0700 Subject: [PATCH 019/156] fix(infrastructure): test ground below 0 m and unknown terrain correctly The ellipsoidal horizon test hid ground below the ellipsoid (low-geoid coasts, the Dead Sea) from a camera near it, so it is now tested at 0 m. With the globe hidden the ground is tested as the vertical from 0 to 9,000 m against the frustum planes, in place of a 4.5 km sphere that let in much unseen ground. Co-Authored-By: Claude Opus 5.5 --- docs/CURRENT-STATE.md | 11 +-- src/data/localGeojson.test.mjs | 128 +++++++++++++++++++++------------ src/data/localGeojsonCore.js | 77 +++++++++++--------- 3 files changed, 130 insertions(+), 86 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 065d67112..4815e0fd1 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1959,11 +1959,12 @@ during continuous motion. Disable/re-enable resets selection cleanly. Close-up stem sizing uses camera-to-feature distance without a 5 km minimum. Unchanged overlay entries do not request another frame; moved positions, changed membership and re-enabling still publish. Ground sampling waits for -visible globe tiles to settle, samples only markers whose ground is in the -camera's view and above the horizon (unseen ground reads through the Earth -and would retry every 2 s; until it is seen, a stem keeps its unsampled -height, which can show for a stem whose base is just off screen), and uses -valid loaded terrain as a floor while +visible globe tiles to settle, and skips markers whose ground the camera +cannot see (unseen ground reads through the Earth and would retry every 2 s). +Ground is tested at loaded globe terrain, or across 0-9,000 m when the globe +is hidden, so photoreal views skip only ground well out of view. Until it is +seen, a stem keeps its unsampled height, which can show for a stem whose base +is just off screen. Sampling uses valid loaded terrain as a floor while retaining rooftop and below-sea-level elevations. A hidden globe does not constrain photoreal geometry, and retry work remains bounded. Already sampled nearby markers follow higher settled terrain on existing bounded walks, without diff --git a/src/data/localGeojson.test.mjs b/src/data/localGeojson.test.mjs index 1c47cb8c1..3e104592f 100644 --- a/src/data/localGeojson.test.mjs +++ b/src/data/localGeojson.test.mjs @@ -113,8 +113,13 @@ async function createRealLocalLayerHarness({ upWC: new Cesium.Cartesian3(), frustum: { fov: Math.PI / 3, + // No planes: everything is in view. One plane no point satisfies: + // nothing is. computeCullingVolume: () => ({ - computeVisibility: () => baseVisibility, + planes: + baseVisibility === Cesium.Intersect.OUTSIDE + ? [new Cesium.Cartesian4(0, 0, 0, -1)] + : [], }), }, moveEnd, @@ -787,64 +792,95 @@ test('an unseen base is not sampled and arms no retry until it is in view', asyn assert.ok(Math.abs(baseHeightM(env) - 117) < 0.01); }); -for (const [label, globe] of [ - ['loaded terrain', { show: true, tilesLoaded: true, getHeight: () => 2_500 }], - ['a hidden globe', { show: false, tilesLoaded: true, getHeight: () => undefined }], +/** + * Give the harness a real Cesium camera `southM` metres south of the base at + * `cameraM` metres above the ellipsoid, looking at the base's ground at + * `groundM` (or directly away from it). + */ +function aimRealCamera(env, { groundM, cameraM, southM, away = false }) { + const entity = env.dataSources[0].entities.values[0]; + const base = Cesium.Cartographic.fromCartesian(entity.__localBaseCartesian); + const ground = Cesium.Cartesian3.fromRadians(base.longitude, base.latitude, groundM); + const position = Cesium.Cartesian3.fromRadians( + base.longitude, + base.latitude - southM / 6_371_000, + cameraM, + ); + const direction = Cesium.Cartesian3.normalize( + away + ? Cesium.Cartesian3.subtract(position, ground, new Cesium.Cartesian3()) + : Cesium.Cartesian3.subtract(ground, position, new Cesium.Cartesian3()), + new Cesium.Cartesian3(), + ); + const right = Cesium.Cartesian3.normalize( + Cesium.Cartesian3.cross( + direction, + Cesium.Ellipsoid.WGS84.geodeticSurfaceNormal(position), + new Cesium.Cartesian3(), + ), + new Cesium.Cartesian3(), + ); + const up = Cesium.Cartesian3.cross(right, direction, new Cesium.Cartesian3()); + const frustum = new Cesium.PerspectiveFrustum({ + fov: Math.PI / 3, + aspectRatio: 800 / 600, + near: 1, + far: 1e8, + }); + Object.assign(env.viewer.camera, { + positionWC: position, + directionWC: direction, + upWC: up, + frustum, + }); + return frustum + .computeCullingVolume(position, direction, up) + .computeVisibility(new Cesium.BoundingSphere(entity.__localBaseCartesian, 1)); +} + +const loadedTerrain = (height) => ({ show: true, tilesLoaded: true, getHeight: () => height }); +const hiddenGlobe = { show: false, tilesLoaded: true, getHeight: () => undefined }; + +for (const { label, globe, groundM, cameraM, southM, baseOffScreen } of [ + // 2,500 m terrain from 3 km away at a shallow angle: the ellipsoid-height + // base is far below the bottom of the screen. + { label: 'high ground with loaded terrain', globe: loadedTerrain(2_500), groundM: 2_500, cameraM: 3_500, southM: 3_000, baseOffScreen: true }, + { label: 'high ground under a hidden globe', globe: hiddenGlobe, groundM: 2_500, cameraM: 3_500, southM: 3_000, baseOffScreen: true }, + // A low-geoid coast (about -63 m near Mumbai) from a camera 50 m above the + // ellipsoid: the ellipsoidal horizon would hide the ground itself. + { label: 'ground below the ellipsoid', globe: loadedTerrain(-63), groundM: -63, cameraM: 50, southM: 2_000, baseOffScreen: false }, ]) { - test(`high ground in view is sampled with ${label} although its unsampled base projects off screen`, async (t) => { - // A base on 2,500 m terrain, seen from 3 km away at a shallow angle: its - // ellipsoid-height point is far below the bottom of the screen. + test(`${label} in view is sampled`, async (t) => { const env = await createRealLocalLayerHarness({ - sampleHeightSupported: true, sampleHeight: () => 2_500, + sampleHeightSupported: true, sampleHeight: () => groundM, }); const clock = installFakeClock(t); t.after(() => { env.layer.destroy(env.viewer); env.cleanup(); }); env.viewer.scene.globe = globe; - const entity = env.dataSources[0].entities.values[0]; - const base = Cesium.Cartographic.fromCartesian(entity.__localBaseCartesian); - const ground = Cesium.Cartesian3.fromRadians(base.longitude, base.latitude, 2_500); - const position = Cesium.Cartesian3.fromRadians( - base.longitude, - base.latitude - 3_000 / 6_371_000, - 2_500 + 1_000, - ); - const direction = Cesium.Cartesian3.normalize( - Cesium.Cartesian3.subtract(ground, position, new Cesium.Cartesian3()), - new Cesium.Cartesian3(), - ); - const right = Cesium.Cartesian3.normalize( - Cesium.Cartesian3.cross( - direction, - Cesium.Ellipsoid.WGS84.geodeticSurfaceNormal(position), - new Cesium.Cartesian3(), - ), - new Cesium.Cartesian3(), - ); - const up = Cesium.Cartesian3.cross(right, direction, new Cesium.Cartesian3()); - const frustum = new Cesium.PerspectiveFrustum({ - fov: Math.PI / 3, - aspectRatio: 800 / 600, - near: 1, - far: 1e8, - }); - Object.assign(env.viewer.camera, { - positionWC: position, - directionWC: direction, - upWC: up, - frustum, - }); - const offScreen = frustum - .computeCullingVolume(position, direction, up) - .computeVisibility(new Cesium.BoundingSphere(entity.__localBaseCartesian, 1)); - assert.equal(offScreen, Cesium.Intersect.OUTSIDE, 'precondition: the ellipsoid-height base is off screen'); + const baseVisibility = aimRealCamera(env, { groundM, cameraM, southM }); + if (baseOffScreen) + assert.equal(baseVisibility, Cesium.Intersect.OUTSIDE, 'precondition: the ellipsoid-height base is off screen'); clock.advance(2_100); env.preRender.raise(); assert.equal(env.sampleCalls.count, 1, 'the ground in view is sampled'); - assert.ok(Math.abs(baseHeightM(env) - 2_500) < 0.01); + assert.ok(Math.abs(baseHeightM(env) - groundM) < 0.01); }); } +test('under a hidden globe, ground behind the camera is not sampled', async (t) => { + const env = await createRealLocalLayerHarness({ + sampleHeightSupported: true, sampleHeight: () => 2_500, + }); + const clock = installFakeClock(t); + t.after(() => { env.layer.destroy(env.viewer); env.cleanup(); }); + env.viewer.scene.globe = hiddenGlobe; + aimRealCamera(env, { groundM: 2_500, cameraM: 3_500, southM: 3_000, away: true }); + clock.advance(2_100); + env.preRender.raise(); + assert.equal(env.sampleCalls.count, 0); +}); + test('settled terrain refinement lifts an already sampled stem without another GPU sample', async (t) => { const env = await createRealLocalLayerHarness({ sampleHeightSupported: true, sampleHeight: () => 117, diff --git a/src/data/localGeojsonCore.js b/src/data/localGeojsonCore.js index ec8fc824e..596b78ed9 100644 --- a/src/data/localGeojsonCore.js +++ b/src/data/localGeojsonCore.js @@ -1131,17 +1131,25 @@ function refreshLocalTerrainFloor(viewer, record) { return true; } -const groundSampleSphere = new Cesium.BoundingSphere( - new Cesium.Cartesian3(), - 1, -); - const groundSampleOccluder = new Cesium.EllipsoidalOccluder( Cesium.Ellipsoid.WGS84, ); const groundSampleCarto = new Cesium.Cartographic(); -const groundSampleTop = new Cesium.Cartesian3(); +const groundSampleLow = new Cesium.Cartesian3(); +const groundSampleHigh = new Cesium.Cartesian3(); + +/** The record's vertical at `height` metres above the ellipsoid. */ +function groundSamplePoint(record, height, result) { + groundSampleCarto.longitude = record.carto.longitude; + groundSampleCarto.latitude = record.carto.latitude; + groundSampleCarto.height = height; + return Cesium.Cartographic.toCartesian( + groundSampleCarto, + Cesium.Ellipsoid.WGS84, + result, + ); +} /** * Whether the camera can see the ground under the record: inside the view @@ -1150,10 +1158,10 @@ const groundSampleTop = new Cesium.Cartesian3(); * the Earth (about -6,344 km near Ashburn on Esri) and fails on every 2 s * retry, each one a full offscreen render; 110 unseen data centres there held * rest frames at 58-589 ms. Unsampled bases sit on the ellipsoid, so the test - * uses loaded globe terrain when there is some, and otherwise every height a - * sample can accept (0 to GROUND_SAMPLE_MAX_ABS_HEIGHT_M): high ground in - * view is never mistaken for an unseen base. Unseen ground is sampled on the - * first walk that sees it. + * uses loaded globe terrain when there is some. Otherwise (a hidden globe) it + * tests the vertical from 0 to GROUND_SAMPLE_MAX_ABS_HEIGHT_M, every height a + * sample can accept, so high ground in view is never mistaken for an unseen + * base. Unseen ground is sampled on the first walk that sees it. */ function groundSampleInView(viewer, record) { const camera = viewer.camera; @@ -1162,37 +1170,36 @@ function groundSampleInView(viewer, record) { const known = Number.isFinite(terrain) && Math.abs(terrain) <= GROUND_SAMPLE_MAX_ABS_HEIGHT_M; - const span = GROUND_SAMPLE_MAX_ABS_HEIGHT_M / 2; - groundSampleCarto.longitude = record.carto.longitude; - groundSampleCarto.latitude = record.carto.latitude; - groundSampleCarto.height = known ? terrain : span; - Cesium.Cartographic.toCartesian( - groundSampleCarto, - Cesium.Ellipsoid.WGS84, - groundSampleSphere.center, - ); - groundSampleSphere.radius = known ? 1 : span; - const culling = camera.frustum.computeCullingVolume( + const low = groundSamplePoint(record, known ? terrain : 0, groundSampleLow); + const high = known + ? low + : groundSamplePoint( + record, + GROUND_SAMPLE_MAX_ABS_HEIGHT_M, + groundSampleHigh, + ); + // The vertical is unseen only when both ends are outside the same plane. + const { planes } = camera.frustum.computeCullingVolume( camera.positionWC, camera.directionWC, camera.upWC, ); - if ( - culling.computeVisibility(groundSampleSphere) === Cesium.Intersect.OUTSIDE - ) - return false; - // The highest ground the sample could find is the last to drop below the - // horizon. - if (!known) groundSampleCarto.height = GROUND_SAMPLE_MAX_ABS_HEIGHT_M; + for (const plane of planes) { + if ( + Cesium.Cartesian3.dot(plane, low) + plane.w < 0 && + Cesium.Cartesian3.dot(plane, high) + plane.w < 0 + ) + return false; + } + // The ellipsoidal occluder hides every point below the ellipsoid from a + // camera near it, so ground below 0 m (low-geoid coasts, the Dead Sea) is + // tested at 0 m; unknown ground at the top of the vertical. + const horizonHeight = known + ? Math.max(terrain, 0) + : GROUND_SAMPLE_MAX_ABS_HEIGHT_M; groundSampleOccluder.cameraPosition = camera.positionWC; return groundSampleOccluder.isPointVisible( - known - ? groundSampleSphere.center - : Cesium.Cartographic.toCartesian( - groundSampleCarto, - Cesium.Ellipsoid.WGS84, - groundSampleTop, - ), + groundSamplePoint(record, horizonHeight, groundSampleHigh), ); } From b778a43f1d7538a10d29f89b0611c29e3dabfe6e Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 29 Oct 2026 17:05:14 +0400 Subject: [PATCH 020/156] feat(fires): Snap Map-style heat raster and its globe imagery drape Detections are binned on a 2048x1024 equirectangular grid weighted 1 + log10(1 + FRP), Gaussian-blurred (longitude wraps), normalized on a log scale against the 99.5th percentile and mapped blue -> red with an alpha ramp that is fully transparent below the floor. heatImagery drapes the bytes as one full-globe SingleTileImageryProvider on the active imagery host. Co-Authored-By: Claude Opus 5.5 --- src/data/firmsHeatRaster.js | 176 ++++++++++++++++++++++++++ src/data/firmsHeatRaster.test.mjs | 73 +++++++++++ src/layers/firms/heatImagery.js | 71 +++++++++++ src/layers/firms/heatImagery.test.mjs | 87 +++++++++++++ 4 files changed, 407 insertions(+) create mode 100644 src/data/firmsHeatRaster.js create mode 100644 src/data/firmsHeatRaster.test.mjs create mode 100644 src/layers/firms/heatImagery.js create mode 100644 src/layers/firms/heatImagery.test.mjs diff --git a/src/data/firmsHeatRaster.js b/src/data/firmsHeatRaster.js new file mode 100644 index 000000000..52a020671 --- /dev/null +++ b/src/data/firmsHeatRaster.js @@ -0,0 +1,176 @@ +/** + * Snap Map–style heat raster for the Active Fires far-zoom bands: detections + * are binned on an equirectangular grid (row 0 = north, column 0 = 180°W), + * blurred, normalized on a log scale and mapped through HEAT_PALETTE. Pure: + * no Cesium, no DOM, so the whole pipeline is unit-tested in node. + */ + +/** Below this normalized heat nothing is painted: empty land stays empty. */ +export const HEAT_FLOOR = 0.03; + +export const HEAT_PALETTE = Object.freeze([ + { at: 0.0, rgb: [43, 108, 255], alpha: 0 }, + { at: 0.08, rgb: [43, 108, 255], alpha: 0.35 }, + { at: 0.25, rgb: [25, 195, 255], alpha: 0.55 }, + { at: 0.45, rgb: [47, 224, 122], alpha: 0.65 }, + { at: 0.65, rgb: [245, 230, 66], alpha: 0.75 }, + { at: 0.82, rgb: [255, 138, 31], alpha: 0.82 }, + { at: 1.0, rgb: [255, 42, 26], alpha: 0.88 }, +]); + +export function heatColorAt(t) { + if (!(t > HEAT_FLOOR)) return [0, 0, 0, 0]; + const v = Math.min(1, t); + let i = 1; + while (i < HEAT_PALETTE.length - 1 && HEAT_PALETTE[i].at < v) i += 1; + const a = HEAT_PALETTE[i - 1]; + const b = HEAT_PALETTE[i]; + const k = (v - a.at) / (b.at - a.at || 1); + const mix = (p, q) => Math.round(p + (q - p) * k); + return [ + mix(a.rgb[0], b.rgb[0]), + mix(a.rgb[1], b.rgb[1]), + mix(a.rgb[2], b.rgb[2]), + Math.round((a.alpha + (b.alpha - a.alpha) * k) * 255), + ]; +} + +const defaultWeight = (fire) => + 1 + Math.log10(1 + Math.max(0, Number(fire.frp) || 0)); + +export function binFires(fires, { width, height, weight = defaultWeight }) { + const grid = new Float32Array(width * height); + for (const fire of fires) { + if (!Number.isFinite(fire.lat) || !Number.isFinite(fire.lon)) continue; + const x = Math.min( + width - 1, + Math.max(0, Math.floor(((fire.lon + 180) / 360) * width)), + ); + const y = Math.min( + height - 1, + Math.max(0, Math.floor(((90 - fire.lat) / 180) * height)), + ); + grid[y * width + x] += weight(fire); + } + return grid; +} + +function kernel(sigma) { + const radius = Math.max(1, Math.ceil(sigma * 3)); + const k = new Float32Array(radius * 2 + 1); + let sum = 0; + for (let i = -radius; i <= radius; i++) { + k[i + radius] = Math.exp(-(i * i) / (2 * sigma * sigma)); + sum += k[i + radius]; + } + for (let i = 0; i < k.length; i++) k[i] /= sum; + return { k, radius }; +} + +export function blurGaussian(grid, width, height, sigma) { + const { k, radius } = kernel(sigma); + const tmp = new Float32Array(grid.length); + const out = new Float32Array(grid.length); + for (let y = 0; y < height; y++) { + const row = y * width; + for (let x = 0; x < width; x++) { + const v = grid[row + x]; + if (v === 0) continue; + for (let i = -radius; i <= radius; i++) { + const xx = (((x + i) % width) + width) % width; // longitude wraps + tmp[row + xx] += v * k[i + radius]; + } + } + } + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const v = tmp[y * width + x]; + if (v === 0) continue; + for (let i = -radius; i <= radius; i++) { + const yy = Math.min(height - 1, Math.max(0, y + i)); // poles clamp + out[yy * width + x] += v * k[i + radius]; + } + } + } + return out; +} + +/** Bins of the log-density histogram used to find the reference quantile. */ +const QUANTILE_BINS = 4096; + +/** + * The p-quantile of the non-zero values, from a histogram of log1p(value): + * one linear pass instead of sorting ~350k burning pixels (a sort cost + * 200 ms+ and dominated the build). Bin width on the log axis is + * max/4096, far finer than the 256-step palette it feeds. + */ +function percentileOfNonZero(values, p) { + let count = 0; + let max = 0; + for (let i = 0; i < values.length; i++) { + const v = values[i]; + if (v > 0) { + count += 1; + if (v > max) max = v; + } + } + if (!count) return 0; + const logMax = Math.log1p(max); + const bins = new Uint32Array(QUANTILE_BINS); + const toBin = (QUANTILE_BINS - 1) / logMax; + for (let i = 0; i < values.length; i++) { + const v = values[i]; + if (v > 0) bins[Math.floor(Math.log1p(v) * toBin)] += 1; + } + const target = Math.floor(p * (count - 1)); + let seen = 0; + for (let b = 0; b < QUANTILE_BINS; b++) { + seen += bins[b]; + if (seen > target) return Math.expm1((b + 1) / toBin); + } + return max; +} + +/** HEAT_PALETTE sampled at 256 steps, so the per-pixel walk is one lookup. */ +const LUT_STEPS = 256; +let paletteLut = null; +function heatLut() { + if (!paletteLut) { + paletteLut = new Uint8ClampedArray(LUT_STEPS * 4); + for (let i = 0; i < LUT_STEPS; i++) { + paletteLut.set(heatColorAt(i / (LUT_STEPS - 1)), i * 4); + } + } + return paletteLut; +} + +export function buildHeatRaster( + fires, + { width, height, sigmaPx, percentile = 0.995 }, +) { + const density = blurGaussian( + binFires(fires, { width, height }), + width, + height, + sigmaPx, + ); + const reference = percentileOfNonZero(density, percentile); + const rgba = new Uint8ClampedArray(width * height * 4); + if (reference > 0) { + const lut = heatLut(); + const scale = (LUT_STEPS - 1) / Math.log1p(reference); + for (let i = 0; i < density.length; i++) { + const d = density[i]; + if (d <= 0) continue; + const step = Math.min(LUT_STEPS - 1, Math.round(Math.log1p(d) * scale)); + const l = step * 4; + if (lut[l + 3] === 0) continue; + const o = i * 4; + rgba[o] = lut[l]; + rgba[o + 1] = lut[l + 1]; + rgba[o + 2] = lut[l + 2]; + rgba[o + 3] = lut[l + 3]; + } + } + return { width, height, rgba, reference }; +} diff --git a/src/data/firmsHeatRaster.test.mjs b/src/data/firmsHeatRaster.test.mjs new file mode 100644 index 000000000..704c83427 --- /dev/null +++ b/src/data/firmsHeatRaster.test.mjs @@ -0,0 +1,73 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + HEAT_PALETTE, + heatColorAt, + binFires, + blurGaussian, + buildHeatRaster, +} from './firmsHeatRaster.js'; + +const fire = (lat, lon, frp = 10) => ({ lat, lon, frp }); + +test('palette runs cold to hot and starts fully transparent', () => { + assert.equal(HEAT_PALETTE[0].at, 0); + assert.equal(HEAT_PALETTE.at(-1).at, 1); + assert.deepEqual(heatColorAt(0), [0, 0, 0, 0]); + assert.equal(heatColorAt(0.01)[3], 0, 'below the floor nothing is painted'); + const low = heatColorAt(0.15); + const high = heatColorAt(1); + assert.ok(low[2] > low[0], 'low heat is blue'); + assert.ok(high[0] > high[2], 'the core is red'); + assert.ok(high[3] > low[3], 'hotter is more opaque'); +}); + +test('binFires drops each detection into its equirectangular pixel', () => { + const grid = binFires([fire(89.9, -179.9, 0), fire(-89.9, 179.9, 0)], { + width: 4, + height: 2, + }); + assert.equal(grid[0], 1, 'north-west corner'); + assert.equal(grid[7], 1, 'south-east corner'); + assert.equal( + grid.reduce((a, b) => a + b, 0), + 2, + ); +}); + +test('binFires weights strong fires slightly more', () => { + const grid = binFires([fire(0.1, 0.1, 999)], { width: 4, height: 2 }); + assert.ok(grid[2] > 3 && grid[2] < 5, String(grid[2])); +}); + +test('blurGaussian keeps mass and wraps across the antimeridian', () => { + const w = 16; + const h = 8; + const grid = new Float32Array(w * h); + grid[3 * w + 0] = 1; + const out = blurGaussian(grid, w, h, 1.5); + const sum = out.reduce((a, b) => a + b, 0); + assert.ok(Math.abs(sum - 1) < 1e-3, `mass ${sum}`); + assert.ok(out[3 * w + (w - 1)] > 0, 'spills to the east edge'); +}); + +test('buildHeatRaster is transparent where there are no fires', () => { + const r = buildHeatRaster([fire(40, -120), fire(40.1, -120.1)], { + width: 360, + height: 180, + sigmaPx: 2, + }); + assert.equal(r.rgba.length, 360 * 180 * 4); + const alphaAt = (lat, lon) => { + const x = Math.floor(((lon + 180) / 360) * 360); + const y = Math.floor(((90 - lat) / 180) * 180); + return r.rgba[(y * 360 + x) * 4 + 3]; + }; + assert.ok(alphaAt(40, -120) > 0, 'blob over the fires'); + assert.equal(alphaAt(-30, 60), 0, 'empty far away'); +}); + +test('buildHeatRaster with no fires is fully transparent', () => { + const r = buildHeatRaster([], { width: 8, height: 4, sigmaPx: 1 }); + assert.ok(r.rgba.every((v) => v === 0)); +}); diff --git a/src/layers/firms/heatImagery.js b/src/layers/firms/heatImagery.js new file mode 100644 index 000000000..f2f99fbe6 --- /dev/null +++ b/src/layers/firms/heatImagery.js @@ -0,0 +1,71 @@ +/** + * Drapes the Active Fires heat raster over the globe as one full-extent + * imagery tile on the active imagery host (globe or 3D-tiles), like the wind + * layer's scalar field. Owns the canvas and the imagery layer; nothing else + * in the fires layer touches imagery. + */ + +const defaultCanvas = (width, height) => { + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + return canvas; +}; + +export function createHeatImagery({ + cesium, + getHost, + createCanvas = defaultCanvas, +}) { + let raster = null; + let layer = null; + let collection = null; + let show = true; + + function detach() { + if (layer && collection) collection.remove(layer, true); + layer = null; + collection = null; + } + + function attach() { + detach(); + if (!raster) return; + const host = getHost?.() ?? { collection: null, kind: 'none' }; + if (!host.collection) return; + const canvas = createCanvas(raster.width, raster.height); + const ctx = canvas.getContext('2d'); + const image = ctx.createImageData(raster.width, raster.height); + image.data.set(raster.rgba); + ctx.putImageData(image, 0, 0); + const provider = new cesium.SingleTileImageryProvider({ + url: canvas.toDataURL('image/png'), + tileWidth: raster.width, + tileHeight: raster.height, + rectangle: cesium.Rectangle.MAX_VALUE, + }); + collection = host.collection; + layer = collection.addImageryProvider(provider); + layer.show = show; + collection.raiseToTop?.(layer); + } + + return { + setRaster(next) { + raster = next; + attach(); + }, + setShow(value) { + show = Boolean(value); + if (layer) layer.show = show; + }, + rehome() { + const host = getHost?.(); + if ((host?.collection ?? null) !== collection) attach(); + }, + destroy() { + detach(); + raster = null; + }, + }; +} diff --git a/src/layers/firms/heatImagery.test.mjs b/src/layers/firms/heatImagery.test.mjs new file mode 100644 index 000000000..0fca7b97f --- /dev/null +++ b/src/layers/firms/heatImagery.test.mjs @@ -0,0 +1,87 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createHeatImagery } from './heatImagery.js'; + +function harness() { + const layers = []; + const removed = []; + const collection = { + addImageryProvider(provider) { + const layer = { provider, show: true, alpha: 1 }; + layers.push(layer); + return layer; + }, + remove(layer, destroy) { + layers.splice(layers.indexOf(layer), 1); + removed.push({ layer, destroy }); + }, + raiseToTop() {}, + }; + let host = { collection, kind: 'globe' }; + const cesium = { + Rectangle: { MAX_VALUE: { max: true } }, + SingleTileImageryProvider: class { + constructor(options) { + this.options = options; + } + }, + }; + const createCanvas = (w, h) => ({ + width: w, + height: h, + getContext: () => ({ + createImageData: (cw, ch) => ({ + data: new Uint8ClampedArray(cw * ch * 4), + }), + putImageData() {}, + }), + toDataURL: () => 'data:image/png;base64,AAAA', + }); + const imagery = createHeatImagery({ + cesium, + getHost: () => host, + createCanvas, + }); + return { imagery, layers, removed, collection, setHost: (h) => (host = h) }; +} + +const raster = { width: 4, height: 2, rgba: new Uint8ClampedArray(32) }; + +test('setRaster drapes one full-globe tile and replaces it on the next build', () => { + const h = harness(); + h.imagery.setRaster(raster); + assert.equal(h.layers.length, 1); + assert.deepEqual(h.layers[0].provider.options.rectangle, { max: true }); + assert.equal(h.layers[0].provider.options.tileWidth, 4); + h.imagery.setRaster(raster); + assert.equal(h.layers.length, 1, 'never two heat layers'); + assert.equal(h.removed[0].destroy, true); +}); + +test('setShow toggles without rebuilding', () => { + const h = harness(); + h.imagery.setRaster(raster); + const layer = h.layers[0]; + h.imagery.setShow(false); + assert.equal(layer.show, false); + h.imagery.setShow(true); + assert.equal(h.layers[0], layer); + assert.equal(layer.show, true); +}); + +test('no imagery host keeps nothing draped; rehome brings it back', () => { + const h = harness(); + h.setHost({ collection: null, kind: 'none' }); + h.imagery.setRaster(raster); + assert.equal(h.layers.length, 0); + h.setHost({ collection: h.collection, kind: 'globe' }); + h.imagery.rehome(); + assert.equal(h.layers.length, 1); +}); + +test('destroy removes the layer', () => { + const h = harness(); + h.imagery.setRaster(raster); + h.imagery.destroy(); + assert.equal(h.layers.length, 0); +}); From de87aaf86acfbf3fd358833c6478e93d9718d3f8 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 29 Oct 2026 17:11:32 +0400 Subject: [PATCH 021/156] feat(fires): far zoom shows a Snap Map-style heatmap instead of heat squares The global and regional LOD bands drape the heat raster on the active imagery host and draw no rectangle entities; the 2/1 degree aggregation now only feeds the "N FIRES" cards, whose accent is the strongest fire's FRP color. Markers below ~750 km hide the heatmap, as do disable and destroy. A stale raster is rebuilt immediately on first paint and with a 150 ms trailing debounce afterwards, so a slider drag does not re-encode on every step. The shell hands the layer its imagery host, so the heatmap follows globe and 3D-tiles map stacks. Co-Authored-By: Claude Opus 5.5 --- src/data/firmsInteraction.test.mjs | 75 ++++++++++++++++++ src/layers/firms/heatImagery.js | 4 + src/layers/firms/ingestion.js | 7 +- src/layers/firms/lifecycle.js | 24 ++++++ src/layers/firms/model.js | 48 ++---------- src/layers/firms/policy.js | 32 ++++++-- src/layers/firms/queries.js | 15 +++- src/layers/firms/rendering.js | 119 +++++++++++++++++++---------- src/layers/firms/state.js | 19 +++++ src/ui/layerBindings.js | 6 ++ 10 files changed, 257 insertions(+), 92 deletions(-) diff --git a/src/data/firmsInteraction.test.mjs b/src/data/firmsInteraction.test.mjs index d735df3f8..39f0382c8 100644 --- a/src/data/firmsInteraction.test.mjs +++ b/src/data/firmsInteraction.test.mjs @@ -392,3 +392,78 @@ test('disable cancels a pending source and a late response cannot repopulate the assert.equal(h.layer.getStats().loading, false); } finally { h.layer.destroy(h.viewer); h.cleanup(); } }); + +test('far zoom drapes one heatmap and draws no rectangles; markers and disable hide it', () => { + // A canvas stub for the heat drape and the detection glow sprites. + const priorDocument = globalThis.document; + const restoreDocument = () => { + if (priorDocument === undefined) delete globalThis.document; + else globalThis.document = priorDocument; + }; + globalThis.document = { + createElement: () => ({ + getContext: () => ({ + createImageData: (w, hh) => ({ + data: new Uint8ClampedArray(w * hh * 4), + }), + putImageData() {}, + createRadialGradient: () => ({ addColorStop() {} }), + fillRect() {}, + }), + toDataURL: () => 'data:image/png;base64,AAAA', + }), + }; + const h = harness({ + fires: [makeFire(), makeFire({ index: 8, lat: 30.6, lon: -98.3, frp: 12 })], + withDataSource: true, + cameraPosition: Cesium.Cartesian3.fromDegrees(-98, 30, 12_000_000), + }); + const added = []; + const removed = []; + h.viewer.scene.primitives = { + add() {}, + remove() {}, + raiseToTop() {}, + contains: () => true, + }; + h.viewer.scene.frameState = { mode: Cesium.SceneMode.SCENE3D }; + try { + const collection = { + addImageryProvider: (provider) => { + const layer = { provider, show: true }; + added.push(layer); + return layer; + }, + remove: (layer, destroy) => removed.push({ layer, destroy }), + raiseToTop() {}, + }; + h.layer.attachShellServices({ + imageryHost: () => ({ kind: 'globe', collection }), + }); + h.layer._renderLodForTest('global'); + assert.equal(added.length, 1, 'one heat imagery layer'); + assert.equal(added[0].show, true); + assert.equal(h.layer._entityCountForTest(), 0, 'no rectangle entities'); + assert.ok(h.published.length > 0, 'the far band still paints cards'); + + h.layer._renderLodForTest('regional'); + assert.equal(added.length, 1, 'switching far bands reuses the raster'); + + h.layer._renderLodForTest('close'); + assert.equal(added[0].show, false, 'the markers band hides the heatmap'); + + h.layer._renderLodForTest('global'); + assert.equal(added[0].show, true); + h.layer.disable(); + assert.equal(added[0].show, false, 'disable hides the heatmap'); + + h.layer.destroy(h.viewer); + assert.deepEqual( + removed.map((entry) => [entry.layer, entry.destroy]), + [[added[0], true]], + ); + } finally { + h.cleanup(); + restoreDocument(); + } +}); diff --git a/src/layers/firms/heatImagery.js b/src/layers/firms/heatImagery.js index f2f99fbe6..31fbf1f69 100644 --- a/src/layers/firms/heatImagery.js +++ b/src/layers/firms/heatImagery.js @@ -55,6 +55,10 @@ export function createHeatImagery({ raster = next; attach(); }, + /** True once a raster was handed over (painted or waiting for a host). */ + hasRaster() { + return raster !== null; + }, setShow(value) { show = Boolean(value); if (layer) layer.show = show; diff --git a/src/layers/firms/ingestion.js b/src/layers/firms/ingestion.js index 215c35da3..cb9dbd72f 100644 --- a/src/layers/firms/ingestion.js +++ b/src/layers/firms/ingestion.js @@ -89,14 +89,15 @@ export function createIngestion({ /** * Run the intensity filter over the loaded payload. This is the ONE place * the shown set is derived: `_fires` / `_firesByFrp` feed markers, cards, - * heat cells, the context store, the top-fires query and the analyst - * records, so none of them can disagree about which fires exist. Cells are - * aggregated per shown set, so the cache goes with it. + * the heatmap, the context store, the top-fires query and the analyst + * records, so none of them can disagree about which fires exist. Cells and + * the heat raster are built per shown set, so their caches go with it. */ function applyFilter() { layerState._fires = filterFires(layerState._allFires, layerState._filter); layerState._cellCacheByGrid.clear(); + layerState._heatRaster = null; layerState._firesByFrp = filterFires( layerState._allFiresByFrp, layerState._filter, diff --git a/src/layers/firms/lifecycle.js b/src/layers/firms/lifecycle.js index 1138504dd..72434ad46 100644 --- a/src/layers/firms/lifecycle.js +++ b/src/layers/firms/lifecycle.js @@ -1,5 +1,6 @@ import * as Cesium from 'cesium'; import { FIRMS_OVERLAY_SOURCE_ID } from '../../data/firmsLabels.js'; +import { LOD_LEVELS } from './policy.js'; export function createLifecycle({ layerState, @@ -14,6 +15,20 @@ export function createLifecycle({ const { id, overlayHost } = config; const methods = { + /** + * Shell services: `imageryHost()` resolves where draped imagery goes on + * the active map stack (globe or 3D tiles, src/maps/imageryHost.js). The + * heatmap moves with it; without a host it drapes on viewer.imageryLayers. + * @param {{imageryHost?: () => {collection: ?Object, kind: string}}|null} services + */ + attachShellServices(services) { + layerState._imageryHost = + typeof services?.imageryHost === 'function' + ? services.imageryHost + : null; + layerState._heatImagery?.rehome(); + }, + init(viewer) { if (layerState._destroyed) return; layerState._viewer = viewer; @@ -40,6 +55,8 @@ export function createLifecycle({ layerState._billboards.show = true; } overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, true); + if (LOD_LEVELS[layerState._currentLodIndex]?.mode === 'heat') + layerState._heatImagery?.setShow(true); components.viewport.installLodWatcher(); components.viewport.installMoveEndWatcher(); components.selection.installClickHandler(); @@ -64,6 +81,7 @@ export function createLifecycle({ components.selection.clearFireSelection(); if (layerState._dataSource) layerState._dataSource.show = false; if (layerState._billboards) layerState._billboards.show = false; + layerState._heatImagery?.setShow(false); overlayHost.clearSource(FIRMS_OVERLAY_SOURCE_ID); overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, false); clearSelectedEntityContextForLayer(id); @@ -96,6 +114,12 @@ export function createLifecycle({ layerState._fires = []; layerState._firesByFrp = []; layerState._cellCacheByGrid.clear(); + clearTimeout(layerState._heatRebuildTimer); + layerState._heatRebuildTimer = null; + layerState._heatImagery?.destroy(); + layerState._heatImagery = null; + layerState._heatRaster = null; + layerState._imageryHost = null; layerState._count = 0; layerState._totalCount = 0; layerState._rowControlsListener = null; diff --git a/src/layers/firms/model.js b/src/layers/firms/model.js index 83f573c98..c3d3be71a 100644 --- a/src/layers/firms/model.js +++ b/src/layers/firms/model.js @@ -136,36 +136,6 @@ export function createModel({ ); } - /** - * Severity stop for an aggregated heat cell from its heat-normalized score - * (density, night share and strongest FRP together). Cells say *where* it - * burns, so they stay on the warm end of the ramp — lime, amber, red — and - * read as one field instead of a per-cell mosaic; the cold stops belong to - * single detections, which say *how hard* each one burns. - * @param {number} normalized - 0..1 heat score (sqrt-normalized to the view). - * @returns {{name: string, minFrp: number, color: Cesium.Color}} - */ - - function cellColorStop(normalized) { - const name = - normalized > 0.72 ? 'red' : normalized > 0.42 ? 'amber' : 'lime'; - return ( - DETECTION_COLOR_STOPS.find((stop) => stop.name === name) || - DETECTION_COLOR_STOPS[0] - ); - } - - /** - * Fill color for an aggregated heat cell. - * @param {number} normalized - 0..1 heat score (see {@link cellColorStop}). - * @param {number} alpha - Fill alpha (heat-normalized by the caller). - * @returns {Cesium.Color} - */ - - function heatColor(normalized, alpha) { - return cellColorStop(normalized).color.withAlpha(alpha); - } - /** * Pick a sprite color stop for one detection from its FRP on the shared ramp. * @param {Object} fire - Detection record. @@ -432,8 +402,8 @@ export function createModel({ /** * Card model for an aggregated heat cell, e.g. title "14 FIRES", detail - * "max 210 MW · new 3h". Accent comes from the candidate (heat-normalized - * score is only known at renderCells time). Exported for unit tests. + * "max 210 MW · new 3h". Accent comes from the candidate (renderHeat sets + * it with {@link cellCardAccent}). Exported for unit tests. * @param {{cell: Object, position: Cesium.Cartesian3, accent: string}} candidate * @param {number} nowMs - Current epoch milliseconds. * @returns {Object} firmsLabels entry. @@ -494,14 +464,14 @@ export function createModel({ } /** - * Cell card accent: the warm severity stop of the cell's heat score, - * mirroring heatColor. - * @param {number} normalized - 0..1 heat score. + * Cell card accent: the cell's strongest fire on the FRP ramp, the same + * color its markers take once the camera is close enough to show them. + * @param {{maxFrp?: number}} cell - Aggregated cell. * @returns {string} "r, g, b" accent string. */ - function cellAccent(normalized) { - return accentForSeverity(cellColorStop(normalized).name); + function cellCardAccent(cell) { + return accentForSeverity(frpColorStop(cell?.maxFrp ?? 0).name); } /** Coordinate line like "30.512°N 75.831°E". */ @@ -607,8 +577,6 @@ export function createModel({ boundsContainPoint, cellIntersectsBounds, heatScore, - heatColor, - cellColorStop, frpColorStop, detectionColorStop, frpPixelSize, @@ -623,7 +591,7 @@ export function createModel({ buildFireCard, buildCellCard, applyFirmsOverlayPolicy, - cellAccent, + cellCardAccent, formatLatLon, formatFrp, formatAge, diff --git a/src/layers/firms/policy.js b/src/layers/firms/policy.js index 18ea88238..eb235bc81 100644 --- a/src/layers/firms/policy.js +++ b/src/layers/firms/policy.js @@ -7,9 +7,10 @@ import * as Cesium from 'cesium'; export const REFRESH_INTERVAL_MS = 600_000; /** - * LOD bands keyed by camera height. `cells` bands render aggregated - * ground-clamped heat rectangles; `detections` bands (< ~750km plus the - * local band) render individual fire detections as glow-sprite billboards. + * LOD bands keyed by camera height. `heat` bands (above ~3,000 km) drape a + * Snap Map–style heatmap over the globe (firmsHeatRaster.js); their + * `gridDegrees` aggregation only feeds the "N FIRES" cards. `detections` + * bands render individual fire detections as glow-sprite billboards. * Labels in every band go through the shared screen-space greedy declutter * (hard cap MAX_AMBIENT_LABELS) — there are no per-band label knobs. */ @@ -18,7 +19,7 @@ export const LOD_LEVELS = [ { id: 'global', minHeight: 9000000, - mode: 'cells', + mode: 'heat', gridDegrees: 2.0, maxCells: 1800, labelDistance: 12000000, @@ -26,7 +27,7 @@ export const LOD_LEVELS = [ { id: 'regional', minHeight: 3000000, - mode: 'cells', + mode: 'heat', gridDegrees: 1.0, maxCells: 3600, labelDistance: 8500000, @@ -47,6 +48,25 @@ export const LOD_LEVELS = [ }, ]; +/** + * Global heat raster for the `heat` bands: ≈0.18° per pixel, Gaussian σ in + * pixels. Built once per data refresh or filter change, never per camera move. + */ + +export const HEAT_RASTER = Object.freeze({ + width: 2048, + height: 1024, + sigmaPx: 3, +}); + +/** + * Trailing delay before a stale heatmap is rebuilt while one is already on + * screen: a slider drag fires many filter changes, and a build plus PNG + * encode costs a few hundred milliseconds. The first paint is immediate. + */ + +export const HEAT_REBUILD_MS = 150; + export const LOD_CHECK_MS = 650; /** +/-10% hysteresis on LOD band edges so slow zooms don't thrash rebuilds. */ @@ -90,7 +110,7 @@ export const CULL_LIFT_THRESHOLD_M = 10; export const CULL_LIFT_M = 12; /** - * Color stops shared by cell heat fills and detection glow sprites: the + * Color stops for detection glow sprites and card accents: the * six-stop cold → hot FRP ramp (firmsIntensity.js), coldest first, as Cesium * colors. Stop names key the glow-sprite cache and the card accents. */ diff --git a/src/layers/firms/queries.js b/src/layers/firms/queries.js index 44ce8573d..797734597 100644 --- a/src/layers/firms/queries.js +++ b/src/layers/firms/queries.js @@ -9,7 +9,7 @@ import { sliderPositionForFrp, } from '../../data/firmsIntensity.js'; import { formatNumber, t } from '../../i18n/index.js'; -import { REFRESH_INTERVAL_MS } from './policy.js'; +import { LOD_LEVELS, REFRESH_INTERVAL_MS } from './policy.js'; /** Slider resolution: whole-percent positions along the log axis. */ const SLIDER_STEPS = 1000; @@ -309,6 +309,19 @@ export function createQueries({ return result; }, + /** Test seam: render one named LOD band against a near-global view. */ + _renderLodForTest(lodId) { + const lod = LOD_LEVELS.find((level) => level.id === lodId); + if (lod.mode === 'detections') + components.rendering.renderDetections(lod, null); + else components.rendering.renderHeat(lod, null); + }, + + /** Test seam: entities drawn by the layer's data source. */ + _entityCountForTest() { + return layerState._dataSource?.entities?.values?.length ?? 0; + }, + /** Test seam that binds the production click path and indexes. */ _bindInteractionForTest(viewer, fires = []) { layerState._viewer = viewer; diff --git a/src/layers/firms/rendering.js b/src/layers/firms/rendering.js index 8ae445f91..84a6eed36 100644 --- a/src/layers/firms/rendering.js +++ b/src/layers/firms/rendering.js @@ -1,7 +1,14 @@ import * as Cesium from 'cesium'; import { FIRMS_OVERLAY_SOURCE_ID } from '../../data/firmsLabels.js'; import { horizonOccluder } from '../../data/iconOrientation.js'; -import { LOD_LEVELS, CONTEXT_TOP_N } from './policy.js'; +import { buildHeatRaster } from '../../data/firmsHeatRaster.js'; +import { + LOD_LEVELS, + CONTEXT_TOP_N, + HEAT_RASTER, + HEAT_REBUILD_MS, +} from './policy.js'; +import { createHeatImagery } from './heatImagery.js'; export function createRendering({ layerState, @@ -29,8 +36,11 @@ export function createRendering({ !layerState._dataSource || !layerState._fires.length || !layerState._viewer - ) + ) { + // A filter that leaves nothing shown must not leave the old heat behind. + if (!layerState._fires.length) layerState._heatImagery?.setShow(false); return false; + } const lodIndex = components.viewport.selectLodIndex( components.viewport.cameraHeight(), ); @@ -54,7 +64,7 @@ export function createRendering({ if (lod.mode === 'detections') { renderDetections(lod, bounds); } else { - renderCells(aggregateFires(lod, bounds), lod, bounds); + renderHeat(lod, bounds); } return true; } @@ -118,65 +128,89 @@ export function createRendering({ return clipped; } + /** The heat imagery adapter, created on first use. */ + + function heatImagery() { + if (!layerState._heatImagery) { + layerState._heatImagery = createHeatImagery({ + cesium: Cesium, + getHost: () => + layerState._imageryHost?.() ?? { + collection: layerState._viewer?.imageryLayers ?? null, + kind: 'globe', + }, + }); + } + return layerState._heatImagery; + } + + /** Build the heat raster from the shown set and drape it. */ + + function rebuildHeatRaster() { + clearTimeout(layerState._heatRebuildTimer); + layerState._heatRebuildTimer = null; + if (layerState._destroyed) return; + layerState._heatRaster = buildHeatRaster(layerState._fires, HEAT_RASTER); + heatImagery().setRaster(layerState._heatRaster); + } + /** - * Render aggregated heat cells (global/regional bands) as ground-clamped - * rectangles. Cell labels do NOT live on the entities anymore — they go - * through the same unclamped, screen-space-decluttered label pipeline as - * detections (see {@link rebuildAmbientLabels}). - * @param {Array} cells - Aggregated cells, heat-sorted descending. + * Make sure the draped heatmap matches the shown set. The first paint is + * immediate; later rebuilds (filter drags, refreshes) trail by + * HEAT_REBUILD_MS and keep the previous heatmap on screen meanwhile. + */ + + function ensureHeatRaster() { + if (layerState._heatRaster) return; + if (!heatImagery().hasRaster()) { + rebuildHeatRaster(); + return; + } + clearTimeout(layerState._heatRebuildTimer); + layerState._heatRebuildTimer = setTimeout( + rebuildHeatRaster, + HEAT_REBUILD_MS, + ); + } + + /** + * Far bands (global/regional): a Snap Map–style heatmap draped over the + * globe, transparent where nothing burns, plus the decluttered "N FIRES" + * cards from the coarse aggregation. No entities and no sprites are drawn; + * cards go through the same screen-space label pipeline as detections + * (see {@link rebuildAmbientLabels}). * @param {Object} lod - Active LOD descriptor. * @param {?Object} bounds - Padded view bounds in degrees, or null. */ - function renderCells(cells, lod, bounds) { + function renderHeat(lod, bounds) { ensureDetectionCollections(); layerState._dataSource.entities.removeAll(); if (layerState._billboards) layerState._billboards.removeAll(); layerState._pickIndexById.clear(); layerState._cullPositions.length = 0; - layerState._cellCount = cells.length; - const maxScore = Math.max(1, ...cells.map(components.model.heatScore)); + const imagery = heatImagery(); + // The map stack may have changed since the last far render. + imagery.rehome(); + ensureHeatRaster(); + imagery.setShow(layerState._enabled); + + const cells = aggregateFires(lod, bounds); + layerState._cellCount = cells.length; layerState._labelCandidates = []; layerState._labelLodDistance = lod.labelDistance; + // Cells arrive heat-sorted, so candidate order doubles as label priority. for (const cell of cells) { - const score = components.model.heatScore(cell); - const normalized = Math.min(1, Math.sqrt(score / maxScore)); - const alpha = 0.16 + normalized * 0.5; - const color = components.model.heatColor(normalized, alpha); const centerLon = cell.lonCell + lod.gridDegrees / 2; const centerLat = cell.latCell + lod.gridDegrees / 2; - const position = Cesium.Cartesian3.fromDegrees(centerLon, centerLat, 0); - - layerState._dataSource.entities.add({ - rectangle: { - coordinates: Cesium.Rectangle.fromDegrees( - cell.lonCell, - cell.latCell, - cell.lonCell + lod.gridDegrees, - cell.latCell + lod.gridDegrees, - ), - material: new Cesium.ColorMaterialProperty(color), - heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, - }, - position, - properties: { - count: cell.count, - intensity: score, - maxFrp: cell.maxFrp, - night: cell.night, - }, - }); - - // Cells arrive heat-sorted, so candidate order doubles as label priority. - // Accent is resolved here because `normalized` only exists in this walk. layerState._labelCandidates.push({ - position, + position: Cesium.Cartesian3.fromDegrees(centerLon, centerLat, 0), cullPosition: components.model.cellCullPosition(centerLon, centerLat), fire: null, cell, - accent: components.model.cellAccent(normalized), + accent: components.model.cellCardAccent(cell), }); } @@ -202,6 +236,7 @@ export function createRendering({ */ function renderDetections(lod, bounds) { + layerState._heatImagery?.setShow(false); ensureDetectionCollections(); layerState._dataSource.entities.removeAll(); if (!layerState._billboards) return; @@ -345,7 +380,7 @@ export function createRendering({ return { renderCurrentLod, aggregateFires, - renderCells, + renderHeat, renderDetections, ensureDetectionCollections, removeDetectionCollections, diff --git a/src/layers/firms/state.js b/src/layers/firms/state.js index d84ee2026..9a0b031b2 100644 --- a/src/layers/firms/state.js +++ b/src/layers/firms/state.js @@ -131,6 +131,25 @@ export function createFirmsState({ services, config }) { layerState._cellCacheByGrid = new Map(); + /** + * Heat raster for the far bands (firmsHeatRaster.js); null = stale, rebuilt + * on the next far render. Cleared with the cell cache on every new shown set. + */ + + layerState._heatRaster = null; + + /** Pending debounced heat rebuild (see HEAT_REBUILD_MS). */ + + layerState._heatRebuildTimer = null; + + /** Imagery adapter (heatImagery.js), created on the first far render. */ + + layerState._heatImagery = null; + + /** Shell-provided imagery host resolver; falls back to viewer.imageryLayers. */ + + layerState._imageryHost = null; + /** Camera idle snapshot so the throttled LOD check is ~free when parked. */ layerState._camSnapValid = false; diff --git a/src/ui/layerBindings.js b/src/ui/layerBindings.js index fb8f29119..60128f8e0 100644 --- a/src/ui/layerBindings.js +++ b/src/ui/layerBindings.js @@ -141,6 +141,12 @@ export class LayerBindings { }); this._weatherShellModules.push(layer); } + // Active Fires drapes its far-zoom heatmap on the same imagery host. + const fires = this._dataManager?.layers?.get('local-firms')?.module; + if (typeof fires?.attachShellServices === 'function') { + fires.attachShellServices({ imageryHost: this.services.imageryHost }); + this._weatherShellModules.push(fires); + } } _persistAwarenessSelection(event, cleared = false) { From 52e428955f4c56249bec1573a542169026561ed2 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 29 Oct 2026 17:17:06 +0400 Subject: [PATCH 022/156] docs(fires): describe the far-zoom heatmap Co-Authored-By: Claude Opus 5.5 --- changelog.d/1426-fire-intensity-filter.md | 6 +++--- docs/CURRENT-STATE.md | 19 ++++++++++++++----- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/changelog.d/1426-fire-intensity-filter.md b/changelog.d/1426-fire-intensity-filter.md index 09e3c3a7d..188f964ad 100644 --- a/changelog.d/1426-fire-intensity-filter.md +++ b/changelog.d/1426-fire-intensity-filter.md @@ -2,8 +2,8 @@ cold → hot ramp (blue · teal · lime · amber · orange · red at 0 · 5 · 20 · 50 · 100 · 300 MW), and add a log-scale minimum-FRP slider whose track is the ramp, an All / Nominal+ / High confidence switch and an "N of M shown" - counter to the layer row. Far-zoom heat cells keep a warm density fill - (lime · amber · red). The filter applies once before rendering, so - markers, cards, heat cells, context and the top-fires query agree; share + counter to the layer row. Far zoom shows a Snap Map–style heatmap instead + of squares, empty where nothing burns. The filter applies once before + rendering, so markers, cards, the heatmap, context and the top-fires query agree; share links keep it (`w` token: `f`, `c`), and `get_active_fires` takes matching `min_frp_mw` and `confidence` arguments. Default 0 MW / All (gekh, #1426). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 7e29d8675..336b32132 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2060,10 +2060,19 @@ Every FIRMS detection is colored by fire radiative power on a six-stop cold → hot ramp: blue from 0 MW, teal from 5, lime from 20, amber from 50, orange from 100 and red from 300 MW (`FRP_RAMP` in `src/data/firmsIntensity.js`). Glow sprites and fire-card accents read that ramp; confidence and brightness do -not affect the color. Aggregated heat cells (far zoom) instead take the warm -end of the ramp — lime, amber or red — from their heat-normalized score -(density, night share and strongest FRP), so a region reads as one field -rather than a per-cell mosaic; the cell card's accent matches its fill. +not affect the color. + +At far zoom (camera above ~3,000 km) the layer drapes a Snap Map–style +heatmap instead of individual markers: the shown detections are binned on a +global 2048 × 1024 grid, each weighted `1 + log10(1 + FRP)`, Gaussian-blurred, +normalized on a log scale against the 99.5th percentile and mapped from blue +through cyan, green, yellow and orange to red. Nothing is painted where +nothing burns (`src/data/firmsHeatRaster.js`). It is one full-globe imagery +tile on the active imagery host (`src/layers/firms/heatImagery.js`), rebuilt +on each data refresh or filter change (the first paint at once, later ones +150 ms after the last change) and hidden once markers take over below +~750 km. "N FIRES" cards from a 2° / 1° aggregation still label the hottest +areas; their accent is the strongest fire's FRP color. The Active Fires row carries a "Fire intensity (FRP)" slider on a log scale whose track is painted with the ramp and ticked at the same six values (its @@ -2071,7 +2080,7 @@ value reads "All" or "≥ N MW"), a "Confidence" switch with All / Nominal+ / High, and an "N of M shown" counter. The default is 0 MW and All, because many of the strongest detections are rated low confidence. The filter runs once in `ingestion.applyFilter()` before rendering, so markers, -cards, heat cells, the shared context store, the top-fires query and the +cards, the heatmap, the shared context store, the top-fires query and the analyst records all see one filtered set; a selected fire that no longer passes is deselected. The layer state keeps both options under the `w` (`local-firms`) share token as `f` (whole megawatts, 0–10,000) and `c` (`a`, `n`, `h`), From 2c422d2f3820707cf86de8c0f0618d57aade8a84 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 29 Oct 2026 17:26:23 +0400 Subject: [PATCH 023/156] build(boundaries): register the fires heat raster and imagery modules Co-Authored-By: Claude Opus 5.5 --- scripts/package-boundaries.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 17772b58e..894e70a68 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1174,6 +1174,7 @@ "modules": [ "src/data/firmsAdapt.js", "src/data/firmsIntensity.js", + "src/data/firmsHeatRaster.js", "src/data/firmsLabels.js", "src/data/iconOrientation.js", "src/layers/firms/anchors.js", @@ -1185,6 +1186,7 @@ "src/layers/firms/policy.js", "src/layers/firms/queries.js", "src/layers/firms/rendering.js", + "src/layers/firms/heatImagery.js", "src/layers/firms/selection.js", "src/layers/firms/source.js", "src/layers/firms/state.js", @@ -1916,6 +1918,7 @@ "src/data/firmsAdapt.js", "src/data/firmsHeatmap.js", "src/data/firmsIntensity.js", + "src/data/firmsHeatRaster.js", "src/data/firmsLabels.js", "src/data/flights.js", "src/data/flowMatch.js", @@ -2076,6 +2079,7 @@ "src/layers/firms/policy.js", "src/layers/firms/queries.js", "src/layers/firms/rendering.js", + "src/layers/firms/heatImagery.js", "src/layers/firms/selection.js", "src/layers/firms/source.js", "src/layers/firms/state.js", @@ -2760,6 +2764,7 @@ "src/data/detectionPolicy.js", "src/data/firmsAdapt.js", "src/data/firmsIntensity.js", + "src/data/firmsHeatRaster.js", "src/data/firmsLabels.js", "src/data/flowMatch.js", "src/data/focusDeemphasis.js", @@ -2890,6 +2895,7 @@ "src/layers/firms/policy.js", "src/layers/firms/queries.js", "src/layers/firms/rendering.js", + "src/layers/firms/heatImagery.js", "src/layers/firms/selection.js", "src/layers/firms/source.js", "src/layers/firms/state.js", From d32a4f62d33ef6dfe78d8e2b6f9373625c14e8e0 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 29 Oct 2026 17:36:09 +0400 Subject: [PATCH 024/156] i18n(zh-Hant): fire filter strings follow zh-CN wording (s2tw) Co-Authored-By: Claude Opus 5.5 --- src/i18n/locales/zh-Hant.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index c71924233..9dadb3a50 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -2868,16 +2868,16 @@ "routing busy — too many routes at once": "路線服務繁忙:同時請求的路線過多", "route proxy error": "路線代理出錯", "fire confidence filter|All": "全部", - "fire confidence filter|Nominal+": "中+", + "fire confidence filter|Nominal+": "標稱+", "fire confidence filter|High": "高", - "Every detection — many of the strongest fires are rated low confidence": "所有探測——許多最強的火點可信度為低", - "Nominal and high confidence detections": "中、高可信度的探測", - "High confidence detections only": "僅高可信度的探測", + "Every detection — many of the strongest fires are rated low confidence": "所有檢測——許多最強的火點置信度為低", + "Nominal and high confidence detections": "標稱和高置信度的檢測", + "High confidence detections only": "僅高置信度的檢測", "Fire intensity (FRP)": "火勢強度 (FRP)", "≥ {value} MW": "≥ {value} MW", - "Confidence": "可信度", - "Minimum fire radiative power, megawatts": "最小火輻射功率(百萬瓦)", + "Confidence": "置信度", + "Minimum fire radiative power, megawatts": "最小火輻射功率(兆瓦)", "fire intensity filter|All": "全部", - "Detection confidence": "探測可信度", + "Detection confidence": "檢測置信度", "{shown} of {total} shown": "顯示 {shown} / {total}" } From bef47ee7c7cca963c3eeb145da5aad0bb08263bf Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 29 Oct 2026 18:25:33 +0400 Subject: [PATCH 025/156] fix(fires): weight the far-zoom heatmap by fire radiative power Heat was a detection count, so raising the minimum-FRP slider removed most detections and the hot spots faded to blue even where strong fires still burned. Each detection now adds its FRP in megawatts (0.5 MW floor), so weak fires carry little heat and filtering them out keeps strong-fire areas hot. On the live payload, hot spots that stay yellow or hotter at >=100 MW rise from 7% to 17% and their median heat from 0.10 to 0.38. --- docs/CURRENT-STATE.md | 4 +++- src/data/firmsHeatRaster.js | 11 ++++++++++- src/data/firmsHeatRaster.test.mjs | 33 +++++++++++++++++++++++++++---- 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index b34636004..1bd9c82cd 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2096,7 +2096,9 @@ not affect the color. At far zoom (camera above ~3,000 km) the layer drapes a Snap Map–style heatmap instead of individual markers: the shown detections are binned on a -global 2048 × 1024 grid, each weighted `1 + log10(1 + FRP)`, Gaussian-blurred, +global 2048 × 1024 grid, each weighted by its FRP in megawatts (so heat is +radiated power, and raising the FRP slider does not dim areas where strong fires +burn), Gaussian-blurred, normalized on a log scale against the 99.5th percentile and mapped from blue through cyan, green, yellow and orange to red. Nothing is painted where nothing burns (`src/data/firmsHeatRaster.js`). It is one full-globe imagery diff --git a/src/data/firmsHeatRaster.js b/src/data/firmsHeatRaster.js index 52a020671..516c7676f 100644 --- a/src/data/firmsHeatRaster.js +++ b/src/data/firmsHeatRaster.js @@ -35,8 +35,17 @@ export function heatColorAt(t) { ]; } +/** Weight of a detection with no or zero FRP, in megawatts. */ +export const HEAT_MIN_WEIGHT_MW = 0.5; + +/** + * Heat is radiated power, not a head count: each detection adds its FRP in + * megawatts. Weak fires carry little power, so raising the minimum-FRP slider + * removes most detections without dimming the areas where strong fires burn + * (a count-based heat collapsed there and the hot spots faded). + */ const defaultWeight = (fire) => - 1 + Math.log10(1 + Math.max(0, Number(fire.frp) || 0)); + Math.max(HEAT_MIN_WEIGHT_MW, Number(fire.frp) || 0); export function binFires(fires, { width, height, weight = defaultWeight }) { const grid = new Float32Array(width * height); diff --git a/src/data/firmsHeatRaster.test.mjs b/src/data/firmsHeatRaster.test.mjs index 704c83427..2b51785e6 100644 --- a/src/data/firmsHeatRaster.test.mjs +++ b/src/data/firmsHeatRaster.test.mjs @@ -1,6 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { + HEAT_MIN_WEIGHT_MW, HEAT_PALETTE, heatColorAt, binFires, @@ -23,7 +24,7 @@ test('palette runs cold to hot and starts fully transparent', () => { }); test('binFires drops each detection into its equirectangular pixel', () => { - const grid = binFires([fire(89.9, -179.9, 0), fire(-89.9, 179.9, 0)], { + const grid = binFires([fire(89.9, -179.9, 1), fire(-89.9, 179.9, 1)], { width: 4, height: 2, }); @@ -35,9 +36,33 @@ test('binFires drops each detection into its equirectangular pixel', () => { ); }); -test('binFires weights strong fires slightly more', () => { - const grid = binFires([fire(0.1, 0.1, 999)], { width: 4, height: 2 }); - assert.ok(grid[2] > 3 && grid[2] < 5, String(grid[2])); +test('binFires weights each detection by its radiative power', () => { + const grid = binFires( + [fire(0.1, 0.1, 250), fire(0.2, 0.2, 0), fire(0.3, 0.3, Number.NaN)], + { width: 4, height: 2 }, + ); + assert.equal(grid[2], 250 + 2 * HEAT_MIN_WEIGHT_MW); +}); + +test('filtering out weak fires keeps a strong fire hot', () => { + const strong = fire(0.1, 0.1, 300); + const weak = Array.from({ length: 40 }, () => fire(0.1, 0.1, 2)); + const reference = Array.from({ length: 10 }, () => fire(20.1, 20.1, 100)); + const options = { width: 360, height: 180, sigmaPx: 2 }; + const pixel = ((90 - 0.1) | 0) * 360 + ((0.1 + 180) | 0); + const red = (r) => r.rgba[pixel * 4]; + const alpha = (r) => r.rgba[pixel * 4 + 3]; + const unfiltered = buildHeatRaster([strong, ...weak, ...reference], options); + const filtered = buildHeatRaster([strong, ...reference], options); + assert.ok(alpha(filtered) > 0); + assert.ok( + alpha(filtered) >= alpha(unfiltered) * 0.9, + `alpha ${alpha(unfiltered)} -> ${alpha(filtered)}`, + ); + assert.ok( + red(filtered) >= red(unfiltered) * 0.9, + `red ${red(unfiltered)} -> ${red(filtered)}`, + ); }); test('blurGaussian keeps mass and wraps across the antimeridian', () => { 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 026/156] 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 027/156] 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 096383b43266fb6b13d3cc471be4b80f74f7876a Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Fri, 30 Oct 2026 21:00:13 +0400 Subject: [PATCH 028/156] Translate the fire filter strings into de, fr and ja --- src/i18n/locales/de.json | 15 ++++++++++++++- src/i18n/locales/fr.json | 15 ++++++++++++++- src/i18n/locales/ja.json | 15 ++++++++++++++- src/i18n/same-as-english.json | 3 +++ 4 files changed, 45 insertions(+), 3 deletions(-) diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 8e383aded..f18b5ca67 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -2926,5 +2926,18 @@ "Malformed cyclone snapshot": "Fehlerhafter Wirbelsturm-Snapshot", "STALE · Roads: {source} · Flow: cached TomTom tiles": "VERALTET · Straßen: {source} · Verkehrsfluss: TomTom-Kacheln aus dem Cache", "routing busy — too many routes at once": "Routing-Dienst ausgelastet – zu viele Routen gleichzeitig", - "route proxy error": "Fehler des Routen-Proxys" + "route proxy error": "Fehler des Routen-Proxys", + "fire confidence filter|All": "Alle", + "fire confidence filter|Nominal+": "Mittel+", + "fire confidence filter|High": "Hoch", + "Every detection — many of the strongest fires are rated low confidence": "Alle Erkennungen — viele der stärksten Brände haben eine niedrige Konfidenz", + "Nominal and high confidence detections": "Erkennungen mit mittlerer und hoher Konfidenz", + "High confidence detections only": "Nur Erkennungen mit hoher Konfidenz", + "Fire intensity (FRP)": "Intensität (FRP)", + "≥ {value} MW": "≥ {value} MW", + "Confidence": "Konfidenz", + "Minimum fire radiative power, megawatts": "Minimale Strahlungsleistung des Brandes in Megawatt", + "fire intensity filter|All": "Alle", + "Detection confidence": "Konfidenz der Erkennung", + "{shown} of {total} shown": "{shown} von {total} angezeigt" } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 931e7c3ff..59641d143 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -2996,5 +2996,18 @@ "ESC to dismiss": "ESC pour fermer", "Tip: the GEV MIC button in the dock lets you talk to the map.": "Astuce : le bouton GEV MIC du dock permet de parler à la carte.", "Malformed cyclone snapshot": "Instantané cyclonique mal formé", - "Cyclone request timed out": "Délai dépassé pour la requête cyclonique" + "Cyclone request timed out": "Délai dépassé pour la requête cyclonique", + "fire confidence filter|All": "Toutes", + "fire confidence filter|Nominal+": "Nominale+", + "fire confidence filter|High": "Élevée", + "Every detection — many of the strongest fires are rated low confidence": "Toutes les détections — beaucoup des feux les plus intenses ont une confiance faible", + "Nominal and high confidence detections": "Détections de confiance nominale et élevée", + "High confidence detections only": "Détections de confiance élevée uniquement", + "Fire intensity (FRP)": "Intensité (FRP)", + "≥ {value} MW": "≥ {value} MW", + "Confidence": "Confiance", + "Minimum fire radiative power, megawatts": "Puissance radiative minimale du feu, en mégawatts", + "fire intensity filter|All": "Toutes", + "Detection confidence": "Confiance de la détection", + "{shown} of {total} shown": "{shown} sur {total} affichées" } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 5a48dc886..85757c1dd 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -2856,5 +2856,18 @@ "routing busy — too many routes at once": "ルート検索サービスが混雑しています。同時に要求されたルートが多すぎます", "route proxy error": "ルート検索プロキシでエラーが発生しました", "Malformed cyclone snapshot": "サイクロンのスナップショットの形式が不正です", - "Cyclone request timed out": "サイクロンのリクエストがタイムアウトしました" + "Cyclone request timed out": "サイクロンのリクエストがタイムアウトしました", + "fire confidence filter|All": "すべて", + "fire confidence filter|Nominal+": "中以上", + "fire confidence filter|High": "高", + "Every detection — many of the strongest fires are rated low confidence": "すべての検知 — 最も強い火災の多くは信頼度が低と判定されています", + "Nominal and high confidence detections": "信頼度が中・高の検知", + "High confidence detections only": "信頼度が高の検知のみ", + "Fire intensity (FRP)": "強度(FRP)", + "≥ {value} MW": "≥ {value} MW", + "Confidence": "信頼度", + "Minimum fire radiative power, megawatts": "火災放射強度の最小値(メガワット)", + "fire intensity filter|All": "すべて", + "Detection confidence": "検知の信頼度", + "{shown} of {total} shown": "{total}件中{shown}件を表示" } diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index dd0df9990..390b56e58 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -163,6 +163,7 @@ "{value} KM/S", "ΔN", "Ⅱ PAUSE", + "≥ {value} MW", "▲ {frp} MW" ], "es": [ @@ -469,6 +470,7 @@ "ΔE", "ΔN", "Ⅱ PAUSE", + "≥ {value} MW", "▲ {frp} MW" ], "it": [ @@ -688,6 +690,7 @@ "{type} SPD: {speed} HDG: {heading}", "ΔE", "ΔN", + "≥ {value} MW", "▲ {frp} MW" ], "pt": [ From 7a39d2d29b4ecd186b0c80c408b488d304c189cc Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Thu, 29 Oct 2026 06:51:42 -0700 Subject: [PATCH 029/156] fix(traffic): give paired TomTom sides of one street a two-way share of dots TomTom draws a two-way street whose directions differ as two one_side lines on one centreline, and each got a one-way road's full share of simulated vehicles, so the street drew twice the dots of the same street as one full-coverage line or a two-way OSM road. A one_side line with an opposite-direction one_side line within 5 m of at least two of three points along it now gets a two-way direction's half share. On the decoded Austin flow fixture every one_side line is paired, and road length weighted by direction share falls from 553 to 417 km. Pairing a 16-tile area takes about 41 ms per road pass. Refs #1455. Co-Authored-By: Claude Opus 5.5 --- docs/CURRENT-STATE.md | 5 ++ src/layers/traffic/roadModes.js | 77 ++++++++++++++++++++++++--- src/layers/traffic/roadModes.test.mjs | 47 ++++++++++++++++ 3 files changed, 121 insertions(+), 8 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 766823550..e882ef822 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5897,6 +5897,11 @@ easier to meet (detection is now on more often), but does not create it. TomTom draws only flow-tile lines with their own flow; roads without flow are not drawn and the status says so. A `full`-coverage flow line carries both travel directions, a `one_side` line the direction it is drawn in. + When an opposite-direction `one_side` line runs within 5 m of at least two + of three points a quarter, half and three quarters along a `one_side` line, + the two are one street's directions and each + gets a two-way direction's half share of dots, so the street is as dense as + one `full` line (#1455). OSM draws OpenFreeMap roads with flow matched onto them (below). Hybrid draws eligible TomTom motor-road lines and adds OpenFreeMap roads only where no eligible TomTom line runs within 35 m in the same travel direction (30 degrees), tested every diff --git a/src/layers/traffic/roadModes.js b/src/layers/traffic/roadModes.js index 05b7ba4f7..e9220b635 100644 --- a/src/layers/traffic/roadModes.js +++ b/src/layers/traffic/roadModes.js @@ -35,6 +35,14 @@ const MIN_FILL_M = 40; * Same-class overlap keeps the full 35 m matcher radius. */ const CLASS_MISMATCH_RADIUS_M = 15; +/** + * Opposite-direction TomTom lines this close share one street: TomTom draws + * both directions of a two-way street a metre or two apart, and a divided + * carriageway's sides further apart. + */ +const PAIRED_SIDE_RADIUS_M = 5; +/** Arc-length positions sampled along a `one_side` line for its partner. */ +const PAIRED_SIDE_SAMPLES = [0.25, 0.5, 0.75]; const CONTROLLED_ACCESS_FLOW = new Set(['Motorway', 'International road']); const CONTROLLED_ACCESS_OSM = new Set(['motorway', 'trunk']); @@ -85,20 +93,65 @@ export function isDrivableFlowSegment(segment) { ); } +/** + * Share of a `one_side` line's sample points (PAIRED_SIDE_SAMPLES) with an + * opposite-direction TomTom line beside them: TomTom draws a two-way street + * whose directions differ as two `one_side` lines on one centreline, while a + * divided carriageway's other side is further away than PAIRED_SIDE_RADIUS_M. + * @returns {number} Paired share in [0, 1]. + */ +function pairedSampleShare(segment, index) { + const xs = []; + const ys = []; + const cum = [0]; + for (const [lon, lat] of segment.coords) { + const [x, y] = index.project(lon, lat); + const i = xs.push(x) - 1; + ys.push(y); + if (i) cum.push(cum[i - 1] + Math.hypot(x - xs[i - 1], y - ys[i - 1])); + } + const length = cum[cum.length - 1]; + if (!(length > 0)) return 0; + let paired = 0; + let i = 1; + for (const share of PAIRED_SIDE_SAMPLES) { + const target = share * length; + while (i < cum.length - 1 && cum[i] < target) i++; + const dx = xs[i] - xs[i - 1]; + const dy = ys[i] - ys[i - 1]; + const t = (target - cum[i - 1]) / (cum[i] - cum[i - 1] || 1); + const reverse = (Math.atan2(-dx, -dy) * 180) / Math.PI; + if ( + index.nearest( + xs[i - 1] + dx * t, + ys[i - 1] + dy * t, + reverse, + () => PAIRED_SIDE_RADIUS_M, + ).best + ) + paired++; + } + return paired / PAIRED_SIDE_SAMPLES.length; +} + /** * Convert decoded TomTom flow lines into road records that carry their own * flow. A line with `coverage: 'full'` describes both travel directions; - * `one_side` lines describe the direction they are drawn in. + * `one_side` lines describe the direction they are drawn in. A `one_side` + * line paired with the opposite direction on the same street gets that + * direction's half share of dots, as a two-way road's direction does. * @param {Array<{coords:number[][], trafficLevel:number, roadType:string, closure:boolean, coverage?:string}>} segments * @returns {Array} Road records (`directFlow: true`). */ export function flowSegmentsToRoads(segments) { - return (segments || []) - .filter( - (segment) => - segment?.coords?.length >= 2 && isDrivableFlowSegment(segment), - ) - .map((segment) => ({ + const drivable = (segments || []).filter( + (segment) => segment?.coords?.length >= 2 && isDrivableFlowSegment(segment), + ); + const sides = createFlowSegmentIndex( + drivable.filter((segment) => segment.coverage !== 'full'), + ); + return drivable.map((segment) => { + const road = { coordinates: segment.coords, type: FLOW_ROAD_TYPES[segment.roadType], drivable: true, @@ -110,7 +163,15 @@ export function flowSegmentsToRoads(segments) { oneway: segment.coverage === 'full' ? 0 : 1, flow: { level: segment.trafficLevel, closure: segment.closure === true }, directFlow: true, - })); + }; + if ( + segment.coverage !== 'full' && + sides && + pairedSampleShare(segment, sides) >= 0.5 + ) + road.densityWeight = 0.5; + return road; + }); } /** Cut a projected polyline to the arc-length interval [from, to]. */ diff --git a/src/layers/traffic/roadModes.test.mjs b/src/layers/traffic/roadModes.test.mjs index 1d1cd4986..465d41496 100644 --- a/src/layers/traffic/roadModes.test.mjs +++ b/src/layers/traffic/roadModes.test.mjs @@ -116,6 +116,53 @@ test('a full-coverage TomTom line is two-way and covers both OSM directions', () assert.equal(roads.length, 1); assert.equal(roads[0].directFlow, true); }); +test("opposite TomTom sides of one street share a two-way street's dots", () => { + // About 1 m and 20 m north of `coordinates`, drawn westbound. + const back = (dLat) => ({ + ...flow, + coords: coordinates.map(([lon, lat]) => [lon, lat + dLat]).reverse(), + }); + const weights = (segments) => + flowSegmentsToRoads(segments).map((road) => road.densityWeight ?? 1); + assert.deepEqual(weights([flow, back(0.00001)]), [0.5, 0.5]); + assert.deepEqual(weights([flow]), [1], 'a lone side keeps the one-way share'); + assert.deepEqual( + weights([flow, back(0.00018)]), + [1, 1], + 'a divided carriageway keeps one-way shares', + ); + assert.deepEqual( + weights([ + flow, + { + ...flow, + coords: coordinates.map(([lon, lat]) => [lon, lat + 0.00001]), + }, + ]), + [1, 1], + 'same-direction lines are not a pair', + ); + assert.deepEqual( + weights([{ ...flow, coverage: 'full' }, back(0.00001)]), + [1, 1], + 'a full-coverage line is not a side, and pairs with nothing', + ); +}); +test('a TomTom side paired along only part of its length keeps its share', () => { + // Westbound partners about 1 m north, from the start of `flow` to 30% and + // to 60% of its length: one or two of its three sample points. + const partner = (toLon) => ({ + ...flow, + coords: [ + [toLon, 30.26701], + [-97.744, 30.26701], + ], + }); + const weightOfFlow = (other) => + flowSegmentsToRoads([flow, other])[0].densityWeight ?? 1; + assert.equal(weightOfFlow(partner(-97.7434)), 1); + assert.equal(weightOfFlow(partner(-97.7428)), 0.5); +}); test('bearing and distance reject nearby crossing and separate OSM roads', () => { const parallel = { ...main, From 1cd4dde26df2f4bf33909fbf3894696399c8a339 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Sun, 1 Nov 2026 06:30:45 -0800 Subject: [PATCH 030/156] docs(changelog): credit #1495 in the fragment Co-Authored-By: Claude Opus 5.5 --- changelog.d/1495-traffic-paired-sides.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelog.d/1495-traffic-paired-sides.md diff --git a/changelog.d/1495-traffic-paired-sides.md b/changelog.d/1495-traffic-paired-sides.md new file mode 100644 index 000000000..4007daf8e --- /dev/null +++ b/changelog.d/1495-traffic-paired-sides.md @@ -0,0 +1,4 @@ +- Street Traffic gives the two TomTom `one_side` lines of one two-way street + a two-way direction's half share of simulated vehicles each, so the street + is no longer drawn twice as dense as the same street from OpenStreetMap + (quanticsoul4772, #1495). From 9caa0947b69489cc6975248245152074f8e275f6 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Sun, 1 Nov 2026 07:43:22 -0800 Subject: [PATCH 031/156] fix(traffic): keep a TomTom side from pairing with its own pieces A one_side line that doubles back within 5 m of itself, such as a turnaround drawn as one line, matched its own return leg and took a two-way half share with no partner. Flow index pieces now carry their source line, and a radiusFor of 0 leaves a piece out of nearest(), so pairing skips the line's own pieces. Co-Authored-By: Claude Opus 5.5 --- src/data/flowMatch.js | 6 ++++-- src/layers/traffic/roadModes.js | 3 ++- src/layers/traffic/roadModes.test.mjs | 8 ++++++++ 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/data/flowMatch.js b/src/data/flowMatch.js index c050f6917..cc4fe2280 100644 --- a/src/data/flowMatch.js +++ b/src/data/flowMatch.js @@ -139,6 +139,7 @@ export function createFlowSegmentIndex(flowSegments) { roadType, level, closure, + flow, }; const key = cellKey( Math.floor((seg.ax + seg.bx) / 2 / CELL_SIZE_M), @@ -158,7 +159,8 @@ export function createFlowSegmentIndex(flowSegments) { const radius2 = MATCH_RADIUS_M * MATCH_RADIUS_M; /** * Nearest direction-compatible segment within 35 m of a projected point. - * `radiusFor(segment)` may tighten the radius per segment (never widen it). + * `radiusFor(segment)` may tighten the radius per segment (never widen it); + * 0 leaves the segment out. */ function nearest(px, py, bearing, radiusFor = null) { const cx = Math.floor(px / CELL_SIZE_M); @@ -177,7 +179,7 @@ export function createFlowSegmentIndex(flowSegments) { candidate = true; // within radius, bearing not yet checked if (radiusFor) { const radius = Math.min(MATCH_RADIUS_M, radiusFor(seg)); - if (d2 > radius * radius) continue; + if (!(radius > 0) || d2 > radius * radius) continue; } const diff = bearingDiffDeg(seg.bearing, bearing); if ( diff --git a/src/layers/traffic/roadModes.js b/src/layers/traffic/roadModes.js index e9220b635..a6eecc5b5 100644 --- a/src/layers/traffic/roadModes.js +++ b/src/layers/traffic/roadModes.js @@ -126,7 +126,8 @@ function pairedSampleShare(segment, index) { xs[i - 1] + dx * t, ys[i - 1] + dy * t, reverse, - () => PAIRED_SIDE_RADIUS_M, + // A line that doubles back beside itself is not its own pair. + (piece) => (piece.flow === segment ? 0 : PAIRED_SIDE_RADIUS_M), ).best ) paired++; diff --git a/src/layers/traffic/roadModes.test.mjs b/src/layers/traffic/roadModes.test.mjs index 465d41496..406dc934b 100644 --- a/src/layers/traffic/roadModes.test.mjs +++ b/src/layers/traffic/roadModes.test.mjs @@ -163,6 +163,14 @@ test('a TomTom side paired along only part of its length keeps its share', () => assert.equal(weightOfFlow(partner(-97.7434)), 1); assert.equal(weightOfFlow(partner(-97.7428)), 0.5); }); +test('a TomTom side that doubles back beside itself is not its own pair', () => { + // East along `coordinates`, then back west about 1 m north. + const hairpin = { + ...flow, + coords: [...coordinates, [-97.742, 30.26701], [-97.744, 30.26701]], + }; + assert.equal(flowSegmentsToRoads([hairpin])[0].densityWeight ?? 1, 1); +}); test('bearing and distance reject nearby crossing and separate OSM roads', () => { const parallel = { ...main, From 18d55f58b9a756cb758996eb2593e6b2967a956d Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Mon, 2 Nov 2026 13:48:28 +0400 Subject: [PATCH 032/156] Color fires from ember to white-hot instead of blue to red About 90% of detections are under 20 MW, so the old blue and teal stops painted most of the map in cold colors that do not read as fire. The ramp now runs ember, red, flame, orange, gold and white-hot at the same thresholds, each stop lighter than the one below, so the brightest marks are the strongest fires. The heatmap uses the same colors. --- changelog.d/1426-fire-intensity-filter.md | 12 ++--- docs/CURRENT-STATE.md | 17 ++++--- src/data/firmsCards.test.mjs | 26 +++++----- src/data/firmsHeatRaster.js | 18 ++++--- src/data/firmsHeatRaster.test.mjs | 10 ++-- src/data/firmsIntensity.js | 27 +++++----- src/data/firmsIntensity.test.mjs | 62 ++++++++++++++--------- src/data/firmsLabels.js | 4 +- src/data/firmsLabels.test.mjs | 20 ++++---- src/layers/firms/model.js | 2 +- src/layers/firms/policy.js | 2 +- src/ui/layerPanel/layerRows.test.mjs | 2 +- 12 files changed, 115 insertions(+), 87 deletions(-) diff --git a/changelog.d/1426-fire-intensity-filter.md b/changelog.d/1426-fire-intensity-filter.md index 188f964ad..c1d06643e 100644 --- a/changelog.d/1426-fire-intensity-filter.md +++ b/changelog.d/1426-fire-intensity-filter.md @@ -1,9 +1,9 @@ - Color every Active Fires detection by fire radiative power on a six-stop - cold → hot ramp (blue · teal · lime · amber · orange · red at 0 · 5 · 20 · - 50 · 100 · 300 MW), and add a log-scale minimum-FRP slider whose track is the - ramp, an All / Nominal+ / High confidence switch and an "N of M shown" - counter to the layer row. Far zoom shows a Snap Map–style heatmap instead - of squares, empty where nothing burns. The filter applies once before - rendering, so markers, cards, the heatmap, context and the top-fires query agree; share + ember → white-hot ramp (ember · red · flame · orange · gold · white-hot at + 0 · 5 · 20 · 50 · 100 · 300 MW, brighter as power rises), and add a + log-scale minimum-FRP slider whose track is the ramp, an All / Nominal+ / + High confidence switch and an "N of M shown" counter to the layer row. Far + zoom shows a Snap Map–style heatmap in the same colors instead of squares, + empty where nothing burns. The filter applies once before rendering, so markers, cards, the heatmap, context and the top-fires query agree; share links keep it (`w` token: `f`, `c`), and `get_active_fires` takes matching `min_frp_mw` and `confidence` arguments. Default 0 MW / All (gekh, #1426). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index a7d941001..5644960bc 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2175,19 +2175,22 @@ accessible name remains "Don't show this again". Control behavior is unchanged. ## Active Fires intensity filter and color ramp -Every FIRMS detection is colored by fire radiative power on a six-stop cold → -hot ramp: blue from 0 MW, teal from 5, lime from 20, amber from 50, orange from -100 and red from 300 MW (`FRP_RAMP` in `src/data/firmsIntensity.js`). Glow -sprites and fire-card accents read that ramp; confidence and brightness do -not affect the color. +Every FIRMS detection is colored by fire radiative power on a six-stop ember → +white-hot ramp: dark ember from 0 MW, red from 5, flame from 20, orange from +50, gold from 100 and pale white-hot from 300 MW (`FRP_RAMP` in +`src/data/firmsIntensity.js`). Each stop is lighter than the one below and +none is a cold hue, so the brightest marks are the strongest fires; ~90% of +detections are under 20 MW and stay dim red. Glow sprites and fire-card +accents read that ramp; confidence and brightness do not affect the color. At far zoom (camera above ~3,000 km) the layer drapes a Snap Map–style heatmap instead of individual markers: the shown detections are binned on a global 2048 × 1024 grid, each weighted by its FRP in megawatts (so heat is radiated power, and raising the FRP slider does not dim areas where strong fires burn), Gaussian-blurred, -normalized on a log scale against the 99.5th percentile and mapped from blue -through cyan, green, yellow and orange to red. Nothing is painted where +normalized on a log scale against the 99.5th percentile and mapped through the +same colors, from a faint ember edge through red, orange and gold to a pale +white-hot core. Nothing is painted where nothing burns (`src/data/firmsHeatRaster.js`). It is one full-globe imagery tile on the active imagery host (`src/layers/firms/heatImagery.js`), rebuilt on each data refresh or filter change (the first paint at once, later ones diff --git a/src/data/firmsCards.test.mjs b/src/data/firmsCards.test.mjs index d0ca7bd70..9d6157324 100644 --- a/src/data/firmsCards.test.mjs +++ b/src/data/firmsCards.test.mjs @@ -45,7 +45,7 @@ test('buildFireCard: title carries FRP, detail carries conf/age/satellite', () = assert.equal(card.details.length, 1); assert.equal(card.details[0], 'high · 2h · N20'); assert.equal(card.selected, false); - assert.equal(card.accent, accentForSeverity('red'), 'FRP 1520 is red-hot'); + assert.equal(card.accent, accentForSeverity('white-hot'), 'FRP 1520 is white-hot'); assert.deepEqual(card.position, { x: 1, y: 2, z: 3 }, 'uses the candidate position untouched'); }); @@ -69,26 +69,26 @@ test('buildFireCard: power that rounds to 10 MW drops the decimal', () => { assert.equal(selected.title, 'FIRE · 10 MW'); }); -test('buildFireCard: SNPP satellite code renders as SNPP, weak fire is blue', () => { +test('buildFireCard: SNPP satellite code renders as SNPP, weak fire is an ember', () => { const card = buildFireCard({ fire: fire({ satellite: 'N', frp: 0.8, confidence: 0.3 }), position: {} }, NOW); assert.match(card.details[0], /SNPP$/); assert.equal(card.title, '▲ 0.8 MW'); - assert.equal(card.accent, accentForSeverity('blue')); + assert.equal(card.accent, accentForSeverity('ember')); }); test('buildFireCard: accent steps through the FRP ramp at 5 · 20 · 50 · 100 · 300 MW', () => { const accentAt = (frp) => buildFireCard({ fire: fire({ frp }), position: {} }, NOW).accent; - assert.equal(accentAt(4.9), accentForSeverity('blue')); - assert.equal(accentAt(5), accentForSeverity('teal')); - assert.equal(accentAt(20), accentForSeverity('lime')); - assert.equal(accentAt(50), accentForSeverity('amber')); - assert.equal(accentAt(100), accentForSeverity('orange')); - assert.equal(accentAt(300), accentForSeverity('red')); + assert.equal(accentAt(4.9), accentForSeverity('ember')); + assert.equal(accentAt(5), accentForSeverity('red')); + assert.equal(accentAt(20), accentForSeverity('flame')); + assert.equal(accentAt(50), accentForSeverity('orange')); + assert.equal(accentAt(100), accentForSeverity('gold')); + assert.equal(accentAt(300), accentForSeverity('white-hot')); // Confidence never moves the color: a low-confidence 864 MW fire stays red. assert.equal( buildFireCard({ fire: fire({ frp: 864, confidence: 0.3 }), position: {} }, NOW).accent, - accentForSeverity('red'), + accentForSeverity('white-hot'), ); }); @@ -109,12 +109,12 @@ test('buildCellCard: plural noun, max FRP and newest age, accent passthrough', ( const candidate = { cell: { count: 14, maxFrp: 210.4, newestAcqMs: NOW - 3 * H, night: 2 }, position: { x: 0, y: 0, z: 0 }, - accent: accentForSeverity('orange'), + accent: accentForSeverity('gold'), }; const card = buildCellCard(candidate, NOW); assert.equal(card.title, '14 FIRES'); assert.equal(card.details[0], 'max 210 MW · new 3h'); - assert.equal(card.accent, accentForSeverity('orange')); + assert.equal(card.accent, accentForSeverity('gold')); }); test('buildCellCard: the count picks the plural form of the language', () => { @@ -166,7 +166,7 @@ test('buildCellCard: singular noun and missing-age omission', () => { const card = buildCellCard(candidate, NOW); assert.equal(card.title, '1 FIRE'); assert.equal(card.details[0], 'max 9.9 MW'); - assert.equal(card.accent, accentForSeverity('blue'), 'missing accent defaults to the coldest stop'); + assert.equal(card.accent, accentForSeverity('ember'), 'missing accent defaults to the weakest stop'); }); test('FIRMS host policy keeps ambient cards bounded and selected cards protected', () => { diff --git a/src/data/firmsHeatRaster.js b/src/data/firmsHeatRaster.js index 516c7676f..985699d7e 100644 --- a/src/data/firmsHeatRaster.js +++ b/src/data/firmsHeatRaster.js @@ -8,14 +8,18 @@ /** Below this normalized heat nothing is painted: empty land stays empty. */ export const HEAT_FLOOR = 0.03; +/** + * The FRP marker ramp as a continuous glow: faint ember at the edges, a + * pale white-hot core where the most power burns. + */ export const HEAT_PALETTE = Object.freeze([ - { at: 0.0, rgb: [43, 108, 255], alpha: 0 }, - { at: 0.08, rgb: [43, 108, 255], alpha: 0.35 }, - { at: 0.25, rgb: [25, 195, 255], alpha: 0.55 }, - { at: 0.45, rgb: [47, 224, 122], alpha: 0.65 }, - { at: 0.65, rgb: [245, 230, 66], alpha: 0.75 }, - { at: 0.82, rgb: [255, 138, 31], alpha: 0.82 }, - { at: 1.0, rgb: [255, 42, 26], alpha: 0.88 }, + { at: 0.0, rgb: [158, 42, 27], alpha: 0 }, + { at: 0.08, rgb: [158, 42, 27], alpha: 0.35 }, + { at: 0.25, rgb: [210, 64, 31], alpha: 0.55 }, + { at: 0.45, rgb: [240, 106, 40], alpha: 0.65 }, + { at: 0.65, rgb: [251, 154, 46], alpha: 0.75 }, + { at: 0.82, rgb: [253, 200, 74], alpha: 0.82 }, + { at: 1.0, rgb: [255, 243, 176], alpha: 0.9 }, ]); export function heatColorAt(t) { diff --git a/src/data/firmsHeatRaster.test.mjs b/src/data/firmsHeatRaster.test.mjs index 2b51785e6..a5cf7eccf 100644 --- a/src/data/firmsHeatRaster.test.mjs +++ b/src/data/firmsHeatRaster.test.mjs @@ -11,16 +11,20 @@ import { const fire = (lat, lon, frp = 10) => ({ lat, lon, frp }); -test('palette runs cold to hot and starts fully transparent', () => { +test('palette runs ember to white-hot and starts fully transparent', () => { assert.equal(HEAT_PALETTE[0].at, 0); assert.equal(HEAT_PALETTE.at(-1).at, 1); assert.deepEqual(heatColorAt(0), [0, 0, 0, 0]); assert.equal(heatColorAt(0.01)[3], 0, 'below the floor nothing is painted'); const low = heatColorAt(0.15); const high = heatColorAt(1); - assert.ok(low[2] > low[0], 'low heat is blue'); - assert.ok(high[0] > high[2], 'the core is red'); + assert.ok(low[0] > low[1] && low[1] > low[2], 'low heat is an ember red'); + assert.ok(high[1] > low[1] && high[2] > low[2], 'the core is paler'); assert.ok(high[3] > low[3], 'hotter is more opaque'); + for (let i = 1; i <= 100; i++) { + const [r, , b] = heatColorAt(i / 100); + assert.ok(r >= b, `no cold hue at ${i}%`); + } }); test('binFires drops each detection into its equirectangular pixel', () => { diff --git a/src/data/firmsIntensity.js b/src/data/firmsIntensity.js index 417566346..0b14dd3bd 100644 --- a/src/data/firmsIntensity.js +++ b/src/data/firmsIntensity.js @@ -1,28 +1,31 @@ /** * @module firmsIntensity * @description Fire intensity policy shared by the Active Fires layer and the - * agent fire tool: the six-stop cold → hot FRP color ramp, the log-scale + * agent fire tool: the six-stop ember → white-hot FRP color ramp, the log-scale * slider that doubles as its legend, and the one filter every consumer * (markers, cards, heat cells, the top-fires query, `get_active_fires`) runs * before anything is rendered or selected. Pure — no Cesium, no DOM. * - * Hue order follows the Portolan FIRMS explorer (`FRP_RAMP`, stepped, not - * interpolated). The thresholds are GEV's own, set on a real week's - * percentiles so every color appears: Portolan's 0/10/50/200/500/1000 MW - * leaves one red fire a week (HAL-675). + * The ramp (`FRP_RAMP`, stepped, not interpolated) runs like a fire cooling + * down: a dim ember for weak fires through red, orange and gold to a pale + * white-hot core, lightness rising with power, so the brightest marks are the + * strongest fires. No cold hues: ~90% of detections are under 20 MW and a + * blue start painted most of the map blue. The thresholds are GEV's own, set + * on a real week's percentiles so every color appears: Portolan's + * 0/10/50/200/500/1000 MW leaves one hottest fire a week (HAL-675). */ /** - * Stepped FRP ramp, coldest first. A detection takes the LAST stop whose + * Stepped FRP ramp, weakest first. A detection takes the LAST stop whose * `minFrp` it reaches. Names key the glow-sprite cache and the card accents. */ export const FRP_RAMP = Object.freeze([ - Object.freeze({ name: 'blue', minFrp: 0, hex: '#4a5bd4' }), - Object.freeze({ name: 'teal', minFrp: 5, hex: '#39a0a8' }), - Object.freeze({ name: 'lime', minFrp: 20, hex: '#c9cf4a' }), - Object.freeze({ name: 'amber', minFrp: 50, hex: '#f2b134' }), - Object.freeze({ name: 'orange', minFrp: 100, hex: '#e8722c' }), - Object.freeze({ name: 'red', minFrp: 300, hex: '#d1382a' }), + Object.freeze({ name: 'ember', minFrp: 0, hex: '#9e2a1b' }), + Object.freeze({ name: 'red', minFrp: 5, hex: '#d2401f' }), + Object.freeze({ name: 'flame', minFrp: 20, hex: '#f06a28' }), + Object.freeze({ name: 'orange', minFrp: 50, hex: '#fb9a2e' }), + Object.freeze({ name: 'gold', minFrp: 100, hex: '#fdc84a' }), + Object.freeze({ name: 'white-hot', minFrp: 300, hex: '#fff3b0' }), ]); /** Slider ticks: the ramp thresholds, so slider, legend and markers agree. */ diff --git a/src/data/firmsIntensity.test.mjs b/src/data/firmsIntensity.test.mjs index 558068e67..c1d22948b 100644 --- a/src/data/firmsIntensity.test.mjs +++ b/src/data/firmsIntensity.test.mjs @@ -18,40 +18,54 @@ import { sliderPositionForFrp, } from './firmsIntensity.js'; -test('the ramp has six cold → hot stops at 0 · 5 · 20 · 50 · 100 · 300 MW in Portolan hue order', () => { +test('the ramp has six ember → white-hot stops at 0 · 5 · 20 · 50 · 100 · 300 MW', () => { assert.deepEqual( FRP_RAMP.map((stop) => [stop.name, stop.minFrp, stop.hex]), [ - ['blue', 0, '#4a5bd4'], - ['teal', 5, '#39a0a8'], - ['lime', 20, '#c9cf4a'], - ['amber', 50, '#f2b134'], - ['orange', 100, '#e8722c'], - ['red', 300, '#d1382a'], + ['ember', 0, '#9e2a1b'], + ['red', 5, '#d2401f'], + ['flame', 20, '#f06a28'], + ['orange', 50, '#fb9a2e'], + ['gold', 100, '#fdc84a'], + ['white-hot', 300, '#fff3b0'], ], ); assert.deepEqual([...FRP_SLIDER_TICKS], [0, 5, 20, 50, 100, 300]); assert.ok(Object.isFrozen(FRP_RAMP)); }); +test('every stop is warm and each is lighter than the one below', () => { + const luma = ([r, g, b]) => 0.2126 * r + 0.7152 * g + 0.0722 * b; + let previous = -1; + for (const stop of FRP_RAMP) { + const rgb = hexToRgb(stop.hex); + assert.ok(rgb[0] >= rgb[2], `${stop.name} has no cold hue`); + assert.ok( + luma(rgb) > previous, + `${stop.name} is lighter than the stop below`, + ); + previous = luma(rgb); + } +}); + test('a detection takes the last stop it reaches; garbage reads as 0 MW', () => { - assert.equal(frpRampStop(0).name, 'blue'); - assert.equal(frpRampStop(4.99).name, 'blue'); - assert.equal(frpRampStop(5).name, 'teal'); - assert.equal(frpRampStop(19.9).name, 'teal'); - assert.equal(frpRampStop(20).name, 'lime'); - assert.equal(frpRampStop(50).name, 'amber'); - assert.equal(frpRampStop(100).name, 'orange'); - assert.equal(frpRampStop(299.9).name, 'orange'); - assert.equal(frpRampStop(300).name, 'red'); - assert.equal(frpRampStop(1246).name, 'red'); - assert.equal(frpRampStop(NaN).name, 'blue'); - assert.equal(frpRampStop(-3).name, 'blue'); - assert.equal(frpRampStop(undefined).name, 'blue'); + assert.equal(frpRampStop(0).name, 'ember'); + assert.equal(frpRampStop(4.99).name, 'ember'); + assert.equal(frpRampStop(5).name, 'red'); + assert.equal(frpRampStop(19.9).name, 'red'); + assert.equal(frpRampStop(20).name, 'flame'); + assert.equal(frpRampStop(50).name, 'orange'); + assert.equal(frpRampStop(100).name, 'gold'); + assert.equal(frpRampStop(299.9).name, 'gold'); + assert.equal(frpRampStop(300).name, 'white-hot'); + assert.equal(frpRampStop(1246).name, 'white-hot'); + assert.equal(frpRampStop(NaN).name, 'ember'); + assert.equal(frpRampStop(-3).name, 'ember'); + assert.equal(frpRampStop(undefined).name, 'ember'); }); test('hex stops convert to the "r, g, b" accents the cards paint', () => { - assert.deepEqual(hexToRgb('#4a5bd4'), [74, 91, 212]); + assert.deepEqual(hexToRgb('#9e2a1b'), [158, 42, 27]); assert.deepEqual(hexToRgb('d1382a'), [209, 56, 42]); }); @@ -84,9 +98,9 @@ test('the log slider pins 0 MW at the left and runs 1 → 1000 MW; values round- test('the track gradient is the stepped ramp on the log axis, so it doubles as the legend', () => { const gradient = rampTrackGradient(); assert.match(gradient, /^linear-gradient\(90deg, /); - assert.match(gradient, /#4a5bd4 0\.00% 23\.3\d%/); - assert.match(gradient, /#39a0a8 23\.3\d% 43\.3\d%/); - assert.match(gradient, /#d1382a 82\.5\d% 100\.00%\)$/); + assert.match(gradient, /#9e2a1b 0\.00% 23\.3\d%/); + assert.match(gradient, /#d2401f 23\.3\d% 43\.3\d%/); + assert.match(gradient, /#fff3b0 82\.5\d% 100\.00%\)$/); assert.equal(gradient.match(/#/g).length, FRP_RAMP.length); }); diff --git a/src/data/firmsLabels.js b/src/data/firmsLabels.js index 3af6a15a5..bf97982c2 100644 --- a/src/data/firmsLabels.js +++ b/src/data/firmsLabels.js @@ -36,8 +36,8 @@ const ACCENT_RGB = Object.freeze( ); /** - * Severity stop name → "r, g, b" accent string (defaults to the coldest stop). - * @param {string} stopName - A FRP_RAMP stop name ('blue' … 'red'). + * Severity stop name → "r, g, b" accent string (defaults to the weakest stop). + * @param {string} stopName - A FRP_RAMP stop name ('ember' … 'white-hot'). * @returns {string} */ export function accentForSeverity(stopName) { diff --git a/src/data/firmsLabels.test.mjs b/src/data/firmsLabels.test.mjs index 62234bb2d..a141d0d17 100644 --- a/src/data/firmsLabels.test.mjs +++ b/src/data/firmsLabels.test.mjs @@ -9,16 +9,16 @@ import { formatFrpMw, } from './firmsLabels.js'; -test('FIRMS card accents are the six FRP ramp stops, coldest as the fallback', () => { - // Portolan hue order; blue #4a5bd4 … red #d1382a (HAL-675). - assert.equal(accentForSeverity('blue'), '74, 91, 212'); - assert.equal(accentForSeverity('teal'), '57, 160, 168'); - assert.equal(accentForSeverity('lime'), '201, 207, 74'); - assert.equal(accentForSeverity('amber'), '242, 177, 52'); - assert.equal(accentForSeverity('orange'), '232, 114, 44'); - assert.equal(accentForSeverity('red'), '209, 56, 42'); - assert.equal(accentForSeverity('yellow'), accentForSeverity('blue')); - assert.equal(accentForSeverity('chartreuse'), accentForSeverity('blue')); +test('FIRMS card accents are the six FRP ramp stops, weakest as the fallback', () => { + // Ember #9e2a1b … white-hot #fff3b0, lightness rising with power. + assert.equal(accentForSeverity('ember'), '158, 42, 27'); + assert.equal(accentForSeverity('red'), '210, 64, 31'); + assert.equal(accentForSeverity('flame'), '240, 106, 40'); + assert.equal(accentForSeverity('orange'), '251, 154, 46'); + assert.equal(accentForSeverity('gold'), '253, 200, 74'); + assert.equal(accentForSeverity('white-hot'), '255, 243, 176'); + assert.equal(accentForSeverity('blue'), accentForSeverity('ember')); + assert.equal(accentForSeverity('chartreuse'), accentForSeverity('ember')); }); test('FIRMS power that rounds to 10 MW drops the decimal', () => { diff --git a/src/layers/firms/model.js b/src/layers/firms/model.js index 330d237e1..aefb94b21 100644 --- a/src/layers/firms/model.js +++ b/src/layers/firms/model.js @@ -121,7 +121,7 @@ export function createModel({ } /** - * The ramp stop for a fire radiative power (stepped, coldest first). + * The ramp stop for a fire radiative power (stepped, weakest first). * Intensity alone drives the color: confidence feeds the card text and the * switch, brightness is unreliable (saturates at 367 K) — see HAL-675. * @param {number} frp - Megawatts. diff --git a/src/layers/firms/policy.js b/src/layers/firms/policy.js index eb235bc81..faa55bbc3 100644 --- a/src/layers/firms/policy.js +++ b/src/layers/firms/policy.js @@ -111,7 +111,7 @@ export const CULL_LIFT_M = 12; /** * Color stops for detection glow sprites and card accents: the - * six-stop cold → hot FRP ramp (firmsIntensity.js), coldest first, as Cesium + * six-stop ember → white-hot FRP ramp (firmsIntensity.js), weakest first, as Cesium * colors. Stop names key the glow-sprite cache and the card accents. */ diff --git a/src/ui/layerPanel/layerRows.test.mjs b/src/ui/layerPanel/layerRows.test.mjs index 5e1d543d0..966c41cb6 100644 --- a/src/ui/layerPanel/layerRows.test.mjs +++ b/src/ui/layerPanel/layerRows.test.mjs @@ -75,7 +75,7 @@ const fireRow = (value, active = 'all') => ({ max: 1000, step: 1, value, - track: 'linear-gradient(90deg, #4a5bd4, #d1382a)', + track: 'linear-gradient(90deg, #9e2a1b, #fff3b0)', ticks: [ { at: 0, label: '0' }, { at: 0.5, label: '30' }, From 314292a006e6fda186f55c8434c9fdf97ff2cb54 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Mon, 2 Nov 2026 14:10:57 +0400 Subject: [PATCH 033/156] Brighten the heatmap edge so weak fire regions stay visible The heat scale is set by the hottest regions, so most of the world sits at the low end. The ember edge was a dark red at 35% opacity and nearly vanished into forest and savanna imagery: over the Americas the spots were there but hard to see. The edge is now a bright red starting at 58% opacity, which reads at least as well as the old blue did. --- docs/CURRENT-STATE.md | 5 +++-- src/data/firmsHeatRaster.js | 21 ++++++++++++--------- src/data/firmsHeatRaster.test.mjs | 3 ++- 3 files changed, 17 insertions(+), 12 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 5644960bc..dabc074ae 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2189,8 +2189,9 @@ global 2048 × 1024 grid, each weighted by its FRP in megawatts (so heat is radiated power, and raising the FRP slider does not dim areas where strong fires burn), Gaussian-blurred, normalized on a log scale against the 99.5th percentile and mapped through the -same colors, from a faint ember edge through red, orange and gold to a pale -white-hot core. Nothing is painted where +same hues, from a bright red edge through orange and gold to a pale +white-hot core. The edge starts more than half opaque, so regions far below +the hottest ones still read over forest and savanna imagery. Nothing is painted where nothing burns (`src/data/firmsHeatRaster.js`). It is one full-globe imagery tile on the active imagery host (`src/layers/firms/heatImagery.js`), rebuilt on each data refresh or filter change (the first paint at once, later ones diff --git a/src/data/firmsHeatRaster.js b/src/data/firmsHeatRaster.js index 985699d7e..f6aadff3b 100644 --- a/src/data/firmsHeatRaster.js +++ b/src/data/firmsHeatRaster.js @@ -9,17 +9,20 @@ export const HEAT_FLOOR = 0.03; /** - * The FRP marker ramp as a continuous glow: faint ember at the edges, a - * pale white-hot core where the most power burns. + * The FRP marker ramp as a continuous glow, from a red edge through orange + * and gold to a pale white-hot core. The edge is a bright red, not the + * markers' dark ember, and starts more than half opaque: most of the world + * sits low on a scale set by the hottest regions, and a dark translucent red + * vanishes into forest and savanna imagery. */ export const HEAT_PALETTE = Object.freeze([ - { at: 0.0, rgb: [158, 42, 27], alpha: 0 }, - { at: 0.08, rgb: [158, 42, 27], alpha: 0.35 }, - { at: 0.25, rgb: [210, 64, 31], alpha: 0.55 }, - { at: 0.45, rgb: [240, 106, 40], alpha: 0.65 }, - { at: 0.65, rgb: [251, 154, 46], alpha: 0.75 }, - { at: 0.82, rgb: [253, 200, 74], alpha: 0.82 }, - { at: 1.0, rgb: [255, 243, 176], alpha: 0.9 }, + { at: 0.0, rgb: [232, 58, 30], alpha: 0 }, + { at: 0.08, rgb: [232, 58, 30], alpha: 0.58 }, + { at: 0.25, rgb: [242, 86, 34], alpha: 0.7 }, + { at: 0.45, rgb: [248, 122, 40], alpha: 0.76 }, + { at: 0.65, rgb: [252, 166, 52], alpha: 0.82 }, + { at: 0.82, rgb: [254, 208, 92], alpha: 0.87 }, + { at: 1.0, rgb: [255, 245, 190], alpha: 0.92 }, ]); export function heatColorAt(t) { diff --git a/src/data/firmsHeatRaster.test.mjs b/src/data/firmsHeatRaster.test.mjs index a5cf7eccf..25c8b61fb 100644 --- a/src/data/firmsHeatRaster.test.mjs +++ b/src/data/firmsHeatRaster.test.mjs @@ -18,7 +18,8 @@ test('palette runs ember to white-hot and starts fully transparent', () => { assert.equal(heatColorAt(0.01)[3], 0, 'below the floor nothing is painted'); const low = heatColorAt(0.15); const high = heatColorAt(1); - assert.ok(low[0] > low[1] && low[1] > low[2], 'low heat is an ember red'); + assert.ok(low[0] > low[1] && low[1] > low[2], 'low heat is red'); + assert.ok(low[3] >= 0.5 * 255, 'low heat stays visible on any imagery'); assert.ok(high[1] > low[1] && high[2] > low[2], 'the core is paler'); assert.ok(high[3] > low[3], 'hotter is more opaque'); for (let i = 1; i <= 100; i++) { From af461febae51c176770163c4ec104f8f981287e8 Mon Sep 17 00:00:00 2001 From: manjunath22466 <301209272+manjunath22466@users.noreply.github.com> Date: Mon, 2 Nov 2026 18:39:44 +0530 Subject: [PATCH 034/156] feat(voice): add typed AI chat beside voice Chat sits next to the mic in the command dock. Replies are silent and never request the microphone; the first message connects with the selected provider and runs the same map actions as voice through each provider's text API (OpenAI OPENAI_CHAT_MODEL, Gemini GEMINI_CHAT_MODEL via generateContent, which never starts Live). Only one mode runs at a time. - Chat panel rendered by the voice control presenter: conversation, recent and quick-command suggestions, Up/Down history, attachments (map-layer references or small UTF-8 text files, sent only with the message) - Chat and voice panels sit above the dock or dock into the right rail's column; minimized bars sit centred above the dock, and height animates on swaps - A centred panel steps down to its bar for the Location and Visual Presets trays without moving focus, rests above an open tray, and docks right while expanded over one; it returns to the centre once the trays close - Minimized voice previews the last sentence from each side; the empty Voice Preview suggests things to say - Settings opened from chat keep chat's frame and show the shared AI provider and quick commands; voice Settings show Back to voice - Closing chat ends a typed session; the voice panel's close turns voice off - The dock widens its voice control for the chat button and restyles its wings, which show icons only at 900px and below; assistant panels share the left panels' glass, and Cyber keeps its own surfaces - Server text-chat handlers with body, time and per-IP limits (GEV_RATELIMIT_*_CHAT_PER_MIN), documented in SECURITY.md; tests and all eleven locales Co-authored-by: Darren Dignam <22195071+darrendignam@users.noreply.github.com> --- .env.example | 11 + README.md | 2 + SECURITY.md | 3 +- docs/CURRENT-STATE.md | 30 + scripts/format-scope.json | 20 +- scripts/package-boundaries.json | 35 + server/providers/common/textChat.js | 136 ++ server/providers/common/textInstructions.js | 31 + server/providers/common/textTools.js | 54 + server/providers/gemini.js | 5 + server/providers/gemini/chat.js | 172 ++ server/providers/openai.js | 5 + server/providers/openai/chat.js | 139 ++ server/providers/openai/instructions.js | 12 +- src/geminiChatServer.test.mjs | 162 ++ src/geminiComposition.test.mjs | 3 +- src/geminiServer.test.mjs | 5 +- src/i18n/locales/ar.json | 64 +- src/i18n/locales/de.json | 64 +- src/i18n/locales/es.json | 64 +- src/i18n/locales/fr.json | 64 +- src/i18n/locales/it.json | 64 +- src/i18n/locales/ja.json | 64 +- src/i18n/locales/pt.json | 64 +- src/i18n/locales/ru.json | 64 +- src/i18n/locales/uk.json | 64 +- src/i18n/locales/zh-CN.json | 64 +- src/i18n/locales/zh-Hant.json | 64 +- src/i18n/same-as-english.json | 10 + src/textChatServer.test.mjs | 276 ++++ src/ui/stores/voice.js | 22 +- src/ui/styles/ai-chat.css | 1656 +++++++++++++++++++ src/ui/styles/command-dock-sliding.css | 3 +- src/ui/views/panels/ChatPanel.vue | 92 ++ src/ui/views/panels/PanelLayoutButtons.vue | 18 + src/ui/views/panels/VoiceControl.vue | 45 +- src/voice/chatAttachments.js | 137 ++ src/voice/chatControl.js | 387 +++++ src/voice/chatPreferences.js | 113 ++ src/voice/control.js | 81 +- src/voice/geminiSession.js | 32 +- src/voice/geminiTextChat.js | 5 + src/voice/geminiTextChat.test.mjs | 492 ++++++ src/voice/panel/host.test.mjs | 356 +++- src/voice/panel/manualPanel.js | 27 +- src/voice/panel/manualPanel.test.mjs | 9 +- src/voice/panelLayout.js | 384 +++++ src/voice/providerCommands.js | 8 +- src/voice/realtimeInput.js | 7 +- src/voice/realtimeSession.js | 41 +- src/voice/session.test.mjs | 45 + src/voice/sessionCommands.js | 91 +- src/voice/textChat.js | 383 +++++ src/voice/textProtocol.js | 153 ++ src/voice/voiceMiniPreview.js | 68 + src/voice/voiceMiniPreview.test.mjs | 92 ++ style.css | 1 + 57 files changed, 6500 insertions(+), 63 deletions(-) create mode 100644 server/providers/common/textChat.js create mode 100644 server/providers/common/textInstructions.js create mode 100644 server/providers/common/textTools.js create mode 100644 server/providers/gemini/chat.js create mode 100644 server/providers/openai/chat.js create mode 100644 src/geminiChatServer.test.mjs create mode 100644 src/textChatServer.test.mjs create mode 100644 src/ui/styles/ai-chat.css create mode 100644 src/ui/views/panels/ChatPanel.vue create mode 100644 src/ui/views/panels/PanelLayoutButtons.vue create mode 100644 src/voice/chatAttachments.js create mode 100644 src/voice/chatControl.js create mode 100644 src/voice/chatPreferences.js create mode 100644 src/voice/geminiTextChat.js create mode 100644 src/voice/geminiTextChat.test.mjs create mode 100644 src/voice/panelLayout.js create mode 100644 src/voice/textChat.js create mode 100644 src/voice/textProtocol.js create mode 100644 src/voice/voiceMiniPreview.js create mode 100644 src/voice/voiceMiniPreview.test.mjs diff --git a/.env.example b/.env.example index c9700c5be..08b0df8f2 100644 --- a/.env.example +++ b/.env.example @@ -61,6 +61,12 @@ CESIUM_ION_TOKEN= # ChatGPT/Codex OAuth session; the browser still receives only a short-lived # Realtime client secret. Do not prefix with VITE_. OPENAI_API_KEY= +# Typed chat uses Chat Completions, independently of the voice model. It always +# bills OPENAI_API_KEY; GEV_PREFER_CODEX_OAUTH below applies to voice only. +OPENAI_CHAT_MODEL=gpt-4.1-mini +# Typed chat requests (/api/openai/chat) per IP per minute, separate from the +# voice throttle; 0 disables it. Not a billing cap. +# GEV_RATELIMIT_OPENAI_CHAT_PER_MIN=60 # the default # Optional: forbid API-key voice sessions. Select ChatGPT OAuth in Provider # Settings; missing or expired Codex sign-in then fails closed. # GEV_PREFER_CODEX_OAUTH=true @@ -269,3 +275,8 @@ OVERPASS_UPSTREAMS= # frame-ancestors sources such as "https://example.com", or * for any page. # Unset, no page may frame it. Other pages of the app can never be framed. # GEV_EMBED_FRAME_ANCESTORS= + +# Gemini typed chat uses generateContent; voice continues to use Gemini Live. +GEMINI_CHAT_MODEL=gemini-3.5-flash-lite +# Typed chat requests (/api/gemini/chat) per IP per minute; 0 disables it. +# GEV_RATELIMIT_GEMINI_CHAT_PER_MIN=60 # the default diff --git a/README.md b/README.md index 3a4c5fc43..d308e9b15 100644 --- a/README.md +++ b/README.md @@ -327,6 +327,8 @@ The standalone app offers **OpenAI** and **Gemini** in the GEV MIC provider choo Changing provider ends the current conversation. If voice is on, the new provider starts with the same open-mic or push-to-talk mode; a held Space key is not carried over. Gemini uses the same map actions and captions, but has no OpenAI cost meter or MINI tier. When Gemini reports that its session has expired, start voice again for a new conversation. The provider chooser does not change the OpenAI-backed AI HUD summary. +Next to the mic, **CHAT** opens a typed chat with silent replies. It uses the selected provider and the same map actions, and never asks for the microphone. OpenAI chat needs an API key (`OPENAI_CHAT_MODEL`); Gemini chat uses a separate text model (`GEMINI_CHAT_MODEL`). Chat and voice panels sit above the dock or dock to the right. + > Voice can use an **OpenAI API key** or, on the same machine, an opt-in signed-in **ChatGPT/Codex OAuth** session. The API-key path remains the default. Selecting ChatGPT OAuth in Provider Settings reuses an existing local sign-in or starts the Codex browser sign-in flow and selects OAuth when it completes. The OpenAI key still drives the **AI HUD summary**: a terse, five-word intelligence-style readout of the current view that regenerates as you move. The OAuth option is experimental. OpenAI documents ChatGPT plan usage for the [Responses API](https://developers.openai.com/siwc/token-sharing-open-source/models-and-inference); a supported third-party Realtime contract for reusing Codex tokens has not been confirmed. A successful token mint does not establish billing or support terms. diff --git a/SECURITY.md b/SECURITY.md index 4423eead3..b32c5b60f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -85,6 +85,7 @@ The data proxies under `server/providers/` are written so the browser cannot tur Allowing `maps.googleapis.com` in `script-src` also trusts its JSONP endpoints, which can bypass the script allowlist if an HTML-injection bug exists; this is an accepted limitation of loading the Maps JavaScript API, alongside the existing `'unsafe-eval'` allowance. - **Full Content-Security-Policy.** The dev and preview servers send a real CSP (`script-src 'self' 'unsafe-eval' blob:` plus the YouTube, Facebook and X scripts the Nepal event's embedded media loads, with a matching `frame-src` for their players and posts: no inline script and no other script origin; `blob:` covers the object URLs Cesium's bundled workers bootstrap through in the built app), alongside the existing `X-Frame-Options: DENY` and `frame-ancestors 'none'`. `'unsafe-eval'` stays because Knockout, bundled inside Cesium's widgets package, resolves the global object with `eval` at load time and the globe does not initialize without it; styles permit `'unsafe-inline'` and Google Fonts because Vite's dev client injects ` diff --git a/src/ui/wildlifeSightingCard.js b/src/ui/wildlifeSightingCard.js new file mode 100644 index 000000000..390aedc47 --- /dev/null +++ b/src/ui/wildlifeSightingCard.js @@ -0,0 +1,270 @@ +import { h, nextTick, shallowRef } from 'vue'; +import WildlifeSightingCard from './views/panels/WildlifeSightingCard.vue'; +import { + panWildlifeSighting, + placeWildlifeSightingCard, + projectWildlifeSighting, + readWildlifeCardSafeArea, + WILDLIFE_SELECTED_CARD_WIDTH, + WILDLIFE_CARD_CHROME_SELECTORS, +} from './wildlifeSightingPlacement.js'; + +/** Anchor the selected sighting's photo and source link clear of map chrome. */ +export function createWildlifeSightingCard({ + layer, + ui, + viewer = null, + documentRef = globalThis.document, + windowRef = globalThis.window, + now = () => Date.now(), + project = projectWildlifeSighting, + pan = panWildlifeSighting, +}) { + const selected = shallowRef(null); + const host = documentRef.createElement('div'); + host.dataset.wildlifeSightingCardHost = ''; + Object.assign(host.style, { + position: 'fixed', + zIndex: '89', + display: 'none', + width: `${WILDLIFE_SELECTED_CARD_WIDTH}px`, + }); + documentRef.body.append(host); + let destroyed = false; + let moving = false; + let removeFrame = null; + let returnFocus = null; + let panForId = null; + const removers = []; + const observed = new Set(); + const resizeObserver = windowRef?.ResizeObserver + ? new windowRef.ResizeObserver(() => position()) + : null; + const mutationObserver = windowRef?.MutationObserver + ? new windowRef.MutationObserver(() => position()) + : null; + + const chromeSelector = WILDLIFE_CARD_CHROME_SELECTORS.join(','); + const structureObserver = windowRef?.MutationObserver + ? new windowRef.MutationObserver((records) => { + if (destroyed || !selected.value) return; + const hasChromeChange = records.some((record) => + [...record.addedNodes, ...record.removedNodes].some( + (node) => + node.nodeType === 1 && + !host.contains(node) && + (node.matches(chromeSelector) || + node.querySelector(chromeSelector)), + ), + ); + if (!hasChromeChange) return; + // Newly mounted chrome can cover a previously clear anchor. + panForId = null; + position({ allowPan: true }); + }) + : null; + + function observeLayout(elements) { + const next = new Set( + [host.firstElementChild, viewer?.scene?.canvas, ...elements].filter( + Boolean, + ), + ); + let changed = false; + for (const element of observed) { + if (next.has(element)) continue; + resizeObserver?.unobserve(element); + observed.delete(element); + changed = true; + } + for (const element of next) { + if (observed.has(element)) continue; + observed.add(element); + resizeObserver?.observe(element); + changed = true; + } + if (!changed) return; + mutationObserver?.disconnect(); + for (const element of observed) { + // Ignore HUD clock/text changes and the card's own positioning styles. + if (!host.contains(element)) + mutationObserver?.observe(element, { + attributes: true, + attributeFilter: ['class', 'style', 'hidden'], + }); + } + } + + function position({ allowPan = false } = {}) { + if (destroyed || !selected.value || !host.firstElementChild) return; + const point = project(viewer, selected.value, layer); + if (!point || !Number.isFinite(point.x) || !Number.isFinite(point.y)) { + host.style.display = 'none'; + return; + } + const safe = readWildlifeCardSafeArea({ documentRef, viewer, windowRef }); + observeLayout(safe.elements); + host.style.width = `${Math.min(WILDLIFE_SELECTED_CARD_WIDTH, safe.bounds.width)}px`; + host.style.maxHeight = `${safe.bounds.height}px`; + host.style.display = 'block'; + const rect = host.firstElementChild.getBoundingClientRect(); + const result = placeWildlifeSightingCard( + point, + { + width: + rect.width || + Math.min(WILDLIFE_SELECTED_CARD_WIDTH, safe.bounds.width), + height: + rect.height || + (selected.value.photo && + !selected.value.obscured && + selected.value.origin !== 'acartia' + ? 300 + : 132), + }, + safe, + ); + if (!result) { + host.style.display = 'none'; + return; + } + if ( + (result.pan.x || result.pan.y) && + allowPan && + panForId !== selected.value.stableId + ) { + panForId = selected.value.stableId; + const reduced = + windowRef?.matchMedia?.('(prefers-reduced-motion: reduce)').matches === + true; + pan(viewer, point, result.anchor, reduced); + } + host.style.left = `${result.left}px`; + host.style.top = `${result.top}px`; + host.dataset.placement = result.placement; + } + + const unmount = ui.mount( + { + setup: () => () => + selected.value + ? h(WildlifeSightingCard, { + sighting: selected.value, + nowMs: now(), + taxa: layer.getParams?.()?.taxa, + onClose: () => { + if (!destroyed) layer.clearSightingSelection(); + }, + onResize: () => position(), + }) + : null, + }, + host, + ); + + function restoreFocus() { + if (returnFocus?.isConnected) returnFocus.focus?.({ preventScroll: true }); + returnFocus = null; + } + const unsubscribe = layer.subscribeSelection((row, metadata) => { + if (destroyed) return; + const fresh = row?.stableId !== selected.value?.stableId; + if (row && !selected.value) { + returnFocus = documentRef.activeElement; + structureObserver?.observe(documentRef.body, { + childList: true, + subtree: true, + }); + } + selected.value = row; + if (!row) { + host.style.display = 'none'; + panForId = null; + removeFrame?.(); + removeFrame = null; + resizeObserver?.disconnect(); + mutationObserver?.disconnect(); + structureObserver?.disconnect(); + observed.clear(); + restoreFocus(); + return; + } + if (fresh) panForId = null; + nextTick(() => { + if (destroyed || !selected.value) return; + position({ allowPan: fresh || metadata?.explicitSelection === true }); + if (moving) startFrameUpdates(); + // Focus the container so keyboard navigation starts inside the card. + if (fresh) + host + .querySelector('[data-wildlife-sighting-card]') + ?.focus?.({ preventScroll: true }); + }); + }); + + const keydown = (event) => { + if (!selected.value || event.key !== 'Escape') return; + event.preventDefault(); + event.stopImmediatePropagation(); + layer.clearSightingSelection(); + }; + const outside = (event) => { + if (!selected.value || host.contains(event.target)) return; + // The layer selects on pointer-up; its following click must keep the card open. + const canvas = viewer?.scene?.canvas; + if (canvas && (event.target === canvas || canvas.contains?.(event.target))) + return; + layer.clearSightingSelection(); + }; + documentRef.addEventListener('keydown', keydown, true); + documentRef.addEventListener('click', outside, true); + const onResize = () => position(); + windowRef?.addEventListener('resize', onResize); + + function listen(event, callback) { + const remove = event?.addEventListener?.(callback); + if (typeof remove === 'function') removers.push(remove); + else if (event?.removeEventListener) + removers.push(() => event.removeEventListener(callback)); + } + function startFrameUpdates() { + if (!selected.value || removeFrame) return; + // Installed only for camera motion; an idle card has zero per-frame work. + removeFrame = + viewer?.scene?.preRender?.addEventListener?.(() => position()) || null; + } + listen(viewer?.camera?.moveStart, () => { + moving = true; + startFrameUpdates(); + }); + listen(viewer?.camera?.moveEnd, () => { + moving = false; + removeFrame?.(); + removeFrame = null; + position(); + }); + listen(viewer?.camera?.changed, () => { + if (!moving) position(); + }); + + return { + /** Release the card, selection subscription and placement listeners. */ + destroy() { + if (destroyed) return; + destroyed = true; + unsubscribe?.(); + for (const remove of removers) remove(); + removeFrame?.(); + resizeObserver?.disconnect(); + mutationObserver?.disconnect(); + structureObserver?.disconnect(); + documentRef.removeEventListener('keydown', keydown, true); + documentRef.removeEventListener('click', outside, true); + windowRef?.removeEventListener('resize', onResize); + unmount(); + host.remove(); + selected.value = null; + restoreFocus(); + }, + }; +} diff --git a/src/ui/wildlifeSightingCard.test.mjs b/src/ui/wildlifeSightingCard.test.mjs new file mode 100644 index 000000000..d7837af7f --- /dev/null +++ b/src/ui/wildlifeSightingCard.test.mjs @@ -0,0 +1,767 @@ +import '../testSupport/registerDom.mjs'; +import assert from 'node:assert/strict'; +import test from 'node:test'; +import * as Cesium from 'cesium'; +import { nextTick } from 'vue'; +import { readFileSync } from 'node:fs'; +import { getLocale, setLocale, t } from '../i18n/index.js'; +import { LOCALES } from '../i18n/locale.js'; +import { createSelection } from '../layers/wildlifeSightings/selection.js'; +import { buildWildlifeThumbnailEntry } from '../layers/wildlifeSightings/cards.js'; +import { createWildlifeSightingCard } from './wildlifeSightingCard.js'; +import { + panWildlifeSighting, + placeWildlifeSightingCard, + readWildlifeCardSafeArea, +} from './wildlifeSightingPlacement.js'; +import { createUiRuntime } from './vueRuntime.js'; +import { PANEL_RAIL_ITEMS } from './panelRailCatalog.js'; +import { sheetForPanelId } from './mobileShell.js'; + +const NOW = Date.parse('2026-11-04T13:00:00Z'); +const square = + 'https://inaturalist-open-data.s3.amazonaws.com/photos/456/square.jpg'; +const row = { + stableId: 'inat:456', + origin: 'inaturalist', + lat: 0, + lon: 0, + commonName: 'Synthetic bird', + scientificName: 'Avis imaginaria', + iconic: 'Aves', + observedAt: NOW - 3_600_000, + observedOn: '2026-11-04', + obscured: false, + photo: { + square, + attribution: '(c) Synthetic Photographer, some rights reserved (CC BY-SA)', + licence: 'cc-by-sa', + }, + link: 'https://www.inaturalist.org/observations/456', +}; +const rect = (left, top, width, height) => ({ + left, + top, + width, + height, + right: left + width, + bottom: top + height, +}); +const events = () => { + const listeners = new Set(); + return { + addEventListener(fn) { + listeners.add(fn); + return () => listeners.delete(fn); + }, + emit() { + for (const fn of listeners) fn(); + }, + size: () => listeners.size, + }; +}; +const settle = async () => { + await nextTick(); + await Promise.resolve(); + await nextTick(); +}; + +// All scene rows, media events and rectangles are synthetic. Browser image +// requests are disabled; decode resolution below proves the photo is exposed +// only after it has loaded into the compact card. +function fixture( + context, + initialRow = row, + point = { x: 600, y: 450 }, + extraWindow = {}, +) { + document.body.replaceChildren(); + const opener = document.body.appendChild(document.createElement('button')); + opener.textContent = 'Globe'; + opener.focus(); + const canvas = document.body.appendChild(document.createElement('canvas')); + canvas.getBoundingClientRect = () => rect(0, 0, 1200, 900); + const windowRef = { + innerWidth: 1200, + innerHeight: 900, + getComputedStyle: window.getComputedStyle.bind(window), + addEventListener() {}, + removeEventListener() {}, + matchMedia: () => ({ matches: false }), + ...extraWindow, + }; + const ui = createUiRuntime(); + const overlays = new Map(); + const moveStart = events(); + const moveEnd = events(); + const changed = events(); + const preRender = events(); + const viewer = { + scene: { canvas, preRender, pick: () => picked }, + camera: { moveStart, moveEnd, changed }, + }; + const state = { + enabled: true, + destroyed: false, + selectedId: null, + selectedRow: null, + ambientRows: [], + rowById: new Map([[initialRow.stableId, initialRow]]), + positions: new Map([[initialRow.stableId, { x: 1, y: 2, z: 3 }]]), + contextIds: new Set(), + viewer, + }; + let picked = null; + let hit = null; + let onClick; + let clears = 0; + let staleListener; + const subscriptions = new Set(); + const flights = []; + const pans = []; + const selection = createSelection({ + state, + services: { + now: () => NOW, + cesium: { ScreenSpaceEventType: { LEFT_CLICK: 0 } }, + overlayHost: { + setEntries: (id, entries) => overlays.set(id, entries), + hitTest: (_x, _y, { sourceId }) => + hit?.sourceId === sourceId ? hit : null, + }, + screenSpaceEventHandlerFactory: () => ({ + setInputAction(fn) { + onClick = fn; + }, + destroy() {}, + }), + focus: { requestWorldFocus: (value) => flights.push(value) }, + }, + parts: { + rendering: { refreshSelection() {}, requestRender() {} }, + }, + }); + const layer = { + getParams: () => ({ taxa: 'all' }), + subscribeSelection(listener) { + staleListener = listener; + subscriptions.add(listener); + const off = selection.subscribe(listener); + return () => { + subscriptions.delete(listener); + off(); + }; + }, + clearSightingSelection() { + clears++; + selection.clearAndRebuild(); + }, + }; + let currentPoint = point; + let projections = 0; + const card = createWildlifeSightingCard({ + layer, + ui, + viewer, + documentRef: document, + windowRef, + now: () => NOW, + project: () => { + projections++; + return currentPoint; + }, + pan: (_viewer, from, to, reduced) => { + pans.push({ from, to, reduced }); + currentPoint = to; + return true; + }, + }); + selection.install(); + context.after(() => { + card.destroy(); + selection.destroy(); + ui.destroy(); + }); + return { + state, + selection, + layer, + card, + viewer, + windowRef, + overlays, + flights, + pans, + projections: () => projections, + subscriptions, + opener, + canvas, + moveStart, + moveEnd, + preRender, + cardElement: () => document.querySelector('[data-wildlife-sighting-card]'), + host: () => document.querySelector('[data-wildlife-sighting-card-host]'), + staleListener: () => staleListener, + clears: () => clears, + async click(kind = 'dot', target = initialRow) { + picked = + kind === 'icon' + ? { primitive: { id: `wildlife:${target.stableId}` } } + : { id: `wildlife:${target.stableId}` }; + hit = + kind === 'ambient photo card' + ? { + sourceId: 'wildlife-sightings', + entry: buildWildlifeThumbnailEntry(target, NOW, { + select: selection.selectAndFocus, + }), + } + : null; + onClick({ position: { x: 50, y: 50 } }); + await settle(); + }, + async publish(next) { + if (next) { + state.rowById.set(next.stableId, next); + if (state.selectedId === next.stableId) state.selectedRow = next; + } + selection.notify(); + await settle(); + }, + obstacle(selector, bounds) { + const element = document.body.appendChild(document.createElement('div')); + if (selector.startsWith('#')) element.id = selector.slice(1); + else element.className = selector.slice(1); + element.getBoundingClientRect = () => bounds; + return element; + }, + setPoint(next) { + currentPoint = next; + }, + }; +} + +test('dot, icon and ambient photo clicks open one contained medium-photo card at the sighting', async (context) => { + for (const kind of ['dot', 'icon', 'ambient photo card']) + await context.test(kind, async (context) => { + const f = fixture(context); + await f.click(kind); + const card = f.cardElement(); + const image = card.querySelector('img'); + assert.equal(f.state.selectedId, row.stableId); + assert.equal( + document.querySelector('dialog'), + null, + 'there is no fullscreen or modal dialog', + ); + assert.equal(image.src, square.replace('/square.', '/medium.')); + assert.equal(image.referrerPolicy, 'no-referrer'); + assert.equal(card.querySelector('h2').textContent, row.commonName); + assert.equal(card.querySelector('i').textContent, row.scientificName); + assert.equal(card.querySelector('time').textContent, 'observed 1h ago'); + assert.ok( + card.textContent.includes('© Synthetic Photographer · CC BY-SA'), + ); + const link = card.querySelector('a'); + assert.equal(link.href, row.link); + assert.equal(link.target, '_blank'); + assert.equal(link.rel, 'noopener noreferrer'); + assert.equal( + link.getAttribute('aria-label'), + `Open ${row.commonName} on iNaturalist`, + ); + assert.equal( + link.querySelector('.wildlife-sighting-card-source').textContent, + 'iNaturalist ↗', + ); + assert.ok( + link.contains(image), + 'the photograph and text are one external action', + ); + assert.equal(f.flights.length, 1); + assert.equal(f.flights[0].kind, 'sighting'); + assert.deepEqual( + f.flights[0].position, + f.state.positions.get(row.stableId), + ); + assert.equal(f.pans.length, 0); + assert.equal( + f.preRender.size(), + 0, + 'an idle card installs no frame work', + ); + let decoded; + image.decode = () => + new Promise((resolve) => { + decoded = resolve; + }); + image.dispatchEvent(new Event('load')); + await settle(); + assert.equal( + image.classList.contains('loaded'), + false, + 'a request alone does not expose an empty frame', + ); + decoded(); + await settle(); + assert.equal( + image.classList.contains('loaded'), + true, + 'the loaded medium photo is painted inside the card', + ); + assert.equal(card.querySelector('[role=status]'), null); + const style = readFileSync( + new URL('./views/panels/WildlifeSightingCard.vue', import.meta.url), + 'utf8', + ); + assert.match(style, /object-fit: contain/); + assert.match(style, /height: 180px/); + }); +}); + +test('missing, restricted, obscured and Acartia photos all use the same compact text card', async (context) => { + for (const [label, extra] of [ + ['missing', { photo: null }], + ['restricted', { photo: { ...row.photo, licence: 'cc-by-nd' } }], + [ + 'untrusted', + { photo: { ...row.photo, square: 'https://other.example/square.jpg' } }, + ], + ['uncredited', { photo: { ...row.photo, attribution: '' } }], + ['obscured', { obscured: true }], + [ + 'Acartia', + { + origin: 'acartia', + stableId: 'acartia:456', + provider: { name: 'Synthetic network' }, + link: 'https://acartia.io', + }, + ], + ]) + await context.test(label, async (context) => { + const f = fixture(context, { ...row, ...extra }); + await f.click(); + const card = f.cardElement(); + assert.equal(card.querySelector('img'), null); + assert.equal(card.querySelector('[role=status]'), null); + assert.equal(card.querySelector('h2').textContent, row.commonName); + assert.equal(card.querySelector('i').textContent, row.scientificName); + assert.equal(document.querySelector('dialog'), null); + if (extra.obscured) { + assert.doesNotMatch(card.textContent, /ago|Photographer/); + assert.ok( + card.textContent.includes('Location obscured to a 0.2° cell'), + ); + assert.equal( + card.querySelector('time').getAttribute('datetime'), + row.observedOn, + ); + } + if (extra.origin === 'acartia') { + assert.ok(card.textContent.includes('seen 1h ago')); + assert.ok(card.textContent.includes('Reported by Synthetic network')); + assert.ok( + card.textContent.includes('Acartia · CC BY 4.0 · Salish Sea'), + ); + assert.ok(card.querySelector('a[href="https://www.bewhalewise.org"]')); + assert.equal( + card.querySelector('a').getAttribute('aria-label'), + `Open ${row.commonName} on Acartia`, + ); + } + }); +}); + +test('safe placements near each viewport edge exclude actual HUD, toolbar and rail rects', () => { + const obstacles = [ + rect(0, 0, 260, 160), + rect(450, 0, 300, 80), + rect(1050, 200, 150, 500), + rect(0, 760, 300, 140), + ]; + const safe = { bounds: rect(6, 6, 1188, 888), obstacles }; + for (const point of [ + { x: 7, y: 450 }, + { x: 1193, y: 750 }, + { x: 850, y: 7 }, + { x: 600, y: 893 }, + ]) { + const result = placeWildlifeSightingCard( + point, + { width: 258, height: 300 }, + safe, + ); + assert.ok(result); + assert.deepEqual(result.pan, { x: 0, y: 0 }); + assert.ok( + result.left >= safe.bounds.left && result.right <= safe.bounds.right, + ); + assert.ok( + result.top >= safe.bounds.top && result.bottom <= safe.bounds.bottom, + ); + assert.ok( + obstacles.every( + (r) => + !( + result.left < r.right && + result.right > r.left && + result.top < r.bottom && + result.bottom > r.top + ), + ), + ); + } +}); + +test('a selected dot flies to its true position and its card pans clear of chrome', async (context) => { + const f = fixture(context, row, { x: 100, y: 100 }); + const hud = f.obstacle('.hud-top-left', rect(0, 0, 260, 160)); + f.obstacle('#right-context-rail', rect(1040, 200, 160, 700)); + f.windowRef.matchMedia = () => ({ matches: true }); + await f.click(); + assert.equal(f.pans.length, 1); + assert.equal(f.pans[0].reduced, true); + assert.deepEqual( + f.pans[0].to, + { x: 100, y: 178 }, + 'nearest clear point follows the measured HUD bottom', + ); + const safe = readWildlifeCardSafeArea({ + documentRef: document, + viewer: f.viewer, + windowRef: f.windowRef, + }); + assert.equal( + safe.obstacles[0].bottom, + hud.getBoundingClientRect().bottom + 6, + ); + assert.equal(f.flights.length, 1); + assert.deepEqual(f.flights[0].position, f.state.positions.get(row.stableId)); + f.moveEnd.emit(); + await settle(); + assert.equal(f.pans.length, 1); + f.layer.clearSightingSelection(); + await settle(); + f.setPoint({ x: -20, y: 400 }); + await f.click(); + assert.equal(f.pans.length, 2); + assert.deepEqual(f.pans[1].to, { x: 18, y: 400 }); +}); + +test('the camera correction preserves altitude and orientation and honors reduced motion', () => { + const flights = []; + const camera = { + positionWC: Cesium.Cartesian3.fromDegrees(0, 0, 250_000), + positionCartographic: { height: 250_000 }, + heading: 0.2, + pitch: -1.2, + roll: 0.1, + pickEllipsoid: (p) => Cesium.Cartesian3.fromDegrees(p.x / 100, p.y / 100), + flyTo: (value) => flights.push(value), + }; + const viewer = { + camera, + scene: { + canvas: { getBoundingClientRect: () => rect(0, 0, 1200, 900) }, + globe: { ellipsoid: Cesium.Ellipsoid.WGS84 }, + }, + }; + for (const reduced of [false, true]) { + assert.equal( + panWildlifeSighting(viewer, { x: 10, y: 10 }, { x: 30, y: 50 }, reduced), + true, + ); + const flight = flights.at(-1); + assert.ok( + Math.abs( + Cesium.Cartographic.fromCartesian(flight.destination).height - 250_000, + ) < 0.001, + ); + assert.deepEqual(flight.orientation, { + heading: 0.2, + pitch: -1.2, + roll: 0.1, + }); + assert.equal(flight.duration, reduced ? 0 : 0.22); + } +}); + +test('the browser click that follows a globe selection does not close the card it opened', async (context) => { + const f = fixture(context); + await f.click(); + assert.ok(f.cardElement(), 'the globe click opened the card'); + assert.equal( + document.activeElement, + f.cardElement(), + 'focus lands on the card, not as a ring on its close button', + ); + // Cesium selects on pointer-up; the DOM click event lands on the canvas + // afterwards and must not count as an outside click. + f.canvas.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await settle(); + assert.ok(f.cardElement(), 'the card stays open'); + assert.equal(f.clears(), 0); + document.body.click(); + await settle(); + assert.equal(f.cardElement(), null, 'a real outside click still closes it'); +}); + +test('one card at a time, Escape, outside clicks and close all clear the selection', async (context) => { + const f = fixture(context); + await f.click(); + const second = { + ...row, + stableId: 'inat:789', + commonName: 'Another synthetic bird', + }; + f.state.rowById.set(second.stableId, second); + await f.click('dot', second); + assert.equal( + document.querySelectorAll('[data-wildlife-sighting-card]').length, + 1, + ); + assert.equal( + f.cardElement().querySelector('h2').textContent, + second.commonName, + ); + for (const close of ['Escape', 'outside', 'button']) { + if (close === 'Escape') + document.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Escape', + bubbles: true, + cancelable: true, + }), + ); + if (close === 'outside') document.body.click(); + if (close === 'button') f.cardElement().querySelector('button').click(); + await settle(); + assert.equal(f.cardElement(), null); + assert.equal(f.state.selectedId, null); + assert.deepEqual(f.overlays.get('wildlife-sightings-selected'), []); + assert.equal(document.activeElement, f.opener); + await f.click(); + } + assert.equal(f.clears(), 3); +}); + +test('camera movement repositions the same card and removes frame work at rest', async (context) => { + const f = fixture(context); + await f.click(); + const card = f.cardElement(); + const before = f.host().style.left; + f.moveStart.emit(); + assert.equal(f.preRender.size(), 1); + f.setPoint({ x: 800, y: 450 }); + f.preRender.emit(); + assert.equal(f.cardElement(), card); + assert.notEqual(f.host().style.left, before); + f.moveEnd.emit(); + assert.equal(f.preRender.size(), 0); + await f.publish({ ...row, commonName: 'Updated synthetic bird' }); + assert.equal(f.cardElement(), card); + assert.equal(card.querySelector('h2').textContent, 'Updated synthetic bird'); + f.state.rowById.delete(row.stableId); + await f.publish(null); + assert.equal( + f.cardElement(), + card, + 'a refreshed sample cannot close the selected card', + ); + f.layer.clearSightingSelection(); + await settle(); + assert.equal(f.cardElement(), null); +}); + +test('replacement photo loads cannot expose the previous image and failures retain the text card', async (context) => { + const f = fixture(context); + await f.click(); + const previous = f.cardElement().querySelector('img'); + let decoded; + previous.decode = () => + new Promise((resolve) => { + decoded = resolve; + }); + previous.dispatchEvent(new Event('load')); + await f.publish({ + ...row, + photo: { ...row.photo, square: square.replace('/456/', '/789/') }, + }); + const current = f.cardElement().querySelector('img'); + decoded(); + await settle(); + assert.equal(current.classList.contains('loaded'), false); + current.dispatchEvent(new Event('error')); + await settle(); + assert.equal( + f.cardElement().querySelector('[role=status]').textContent, + 'Photo unavailable', + ); + assert.equal(current.style.display, 'none'); + assert.equal(f.cardElement().querySelector('a').href, row.link); +}); + +test('teardown removes subscriptions, placement listeners and host, and ignores stale notifications', async (context) => { + const f = fixture(context); + await f.click(); + f.moveStart.emit(); + const stale = f.staleListener(); + f.card.destroy(); + assert.equal(f.cardElement(), null); + assert.equal(f.host(), null); + assert.equal(f.subscriptions.size, 0); + assert.equal(f.preRender.size(), 0); + stale(row); + await f.click(); + assert.equal(f.cardElement(), null); + assert.equal(document.activeElement, f.opener); +}); + +test('locale changes translate the card without replacing the image or credit', async (context) => { + const before = getLocale(); + context.after(() => setLocale(before)); + const f = fixture(context); + await f.click(); + const card = f.cardElement(); + const image = card.querySelector('img'); + for (const locale of LOCALES) { + setLocale(locale); + await settle(); + assert.equal(f.cardElement(), card); + assert.equal(card.querySelector('img'), image); + assert.equal( + card.querySelector('button').getAttribute('aria-label'), + t('Close sighting'), + ); + assert.ok(card.textContent.includes('© Synthetic Photographer · CC BY-SA')); + } +}); + +test('date-only and unsafe-link rows remain readable, and no sighting rail or lightbox is shipped', async (context) => { + const f = fixture(context, { + ...row, + observedAt: null, + photo: null, + link: 'javascript:alert(1)', + }); + await f.click(); + assert.equal(f.cardElement().querySelector('a'), null); + assert.equal( + f.cardElement().querySelector('time').getAttribute('datetime'), + row.observedOn, + ); + assert.equal(PANEL_RAIL_ITEMS['wildlife-sighting-panel'], undefined); + assert.equal(sheetForPanelId('wildlife-sighting-panel'), null); + const source = readFileSync( + new URL('./wildlifeSightingCard.js', import.meta.url), + 'utf8', + ); + assert.doesNotMatch( + source, + /Lightbox|showModal|requestAnimationFrame|setInterval/, + ); +}); + +test('WHALES cards retain compact whale guidance outside the observation action', async (context) => { + const f = fixture(context); + f.layer.getParams = () => ({ taxa: 'whales' }); + await f.click(); + const guidance = f + .cardElement() + .querySelector('a[href="https://www.bewhalewise.org"]'); + assert.equal(guidance.textContent, 'Be Whale Wise'); + assert.equal( + guidance.parentElement.closest('a'), + null, + 'the external guidance link never nests inside the observation action', + ); +}); + +test('opening during camera motion tracks the card and closing stops frame work immediately', async (context) => { + const f = fixture(context); + f.moveStart.emit(); + assert.equal(f.preRender.size(), 0); + await f.click(); + assert.equal(f.preRender.size(), 1); + f.layer.clearSightingSelection(); + await settle(); + assert.equal(f.preRender.size(), 0); +}); + +test('newly mounted chrome repositions the open card without camera events or idle text work', async (context) => { + const observers = []; + class MutationObserver { + constructor(callback) { + this.callback = callback; + this.targets = []; + observers.push(this); + } + observe(target, options) { + this.targets.push({ target, options }); + } + disconnect() { + this.targets = []; + } + } + const f = fixture(context, row, { x: 600, y: 450 }, { MutationObserver }); + await f.click(); + const structural = observers.find((observer) => + observer.targets.some( + ({ target, options }) => + target === document.body && options.childList && options.subtree, + ), + ); + assert.ok( + structural, + 'structural observation exists only while a card is open', + ); + const original = f.cardElement(); + const before = f.host().style.top; + const hudWrapper = document.body.appendChild(document.createElement('div')); + const hud = hudWrapper.appendChild(document.createElement('div')); + hud.className = 'hud-top-left'; + hud.getBoundingClientRect = () => rect(500, 350, 200, 200); + structural.callback([{ addedNodes: [hudWrapper], removedNodes: [] }]); + await settle(); + assert.equal( + f.pans.length, + 1, + 'chrome newly covering the dot triggers one minimal pan', + ); + assert.equal(f.cardElement(), original); + assert.notEqual(f.host().style.top, before); + assert.equal(f.preRender.size(), 0); + const placed = rect( + Number.parseFloat(f.host().style.left), + Number.parseFloat(f.host().style.top), + 258, + 300, + ); + assert.ok( + placed.right <= 494 || + placed.left >= 706 || + placed.bottom <= 344 || + placed.top >= 556, + 'the card clears the measured HUD rect', + ); + const afterChrome = f.projections(); + // The observer ignores text nodes, ordinary clock wrappers and the card's + // own image/caption patches even though body observation sees their records. + const clock = hud.appendChild(document.createElement('span')); + const text = clock.appendChild(document.createTextNode('13:00:01')); + structural.callback([ + { + addedNodes: [clock, text, f.cardElement().querySelector('img')], + removedNodes: [], + }, + ]); + assert.equal(f.projections(), afterChrome); + f.layer.clearSightingSelection(); + await settle(); + assert.equal( + structural.targets.length, + 0, + 'closing disconnects structural observation', + ); +}); diff --git a/src/ui/wildlifeSightingPlacement.js b/src/ui/wildlifeSightingPlacement.js new file mode 100644 index 000000000..e638b9ccb --- /dev/null +++ b/src/ui/wildlifeSightingPlacement.js @@ -0,0 +1,274 @@ +import * as Cesium from 'cesium'; +import { WORLD_OVERLAY_OCCLUDER_SELECTORS } from '../overlays/worldOverlayChrome.js'; + +// DOM cards also avoid the HUD edges and responsive sheet containers. +export const WILDLIFE_CARD_CHROME_SELECTORS = Object.freeze([ + ...WORLD_OVERLAY_OCCLUDER_SELECTORS, + '.hud-left-edge', + '.hud-right-edge', + '#right-panel-stack', + '.gev-panel-rail', + '#location-bar', + '#control-panel', + '#param-slider-panel', + '#cockpit-hud .cockpit-topline', + '[data-mobile-sheet]', +]); +const GAP = 12; +const EDGE = 6; +export const WILDLIFE_SELECTED_CARD_WIDTH = 258; + +const overlaps = (a, b) => + a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top; +const contains = (r, p) => + p.x >= r.left && p.x <= r.right && p.y >= r.top && p.y <= r.bottom; +const clamp = (value, low, high) => Math.max(low, Math.min(high, value)); +const rectAt = (left, top, width, height) => ({ + left, + top, + right: left + width, + bottom: top + height, + width, + height, +}); + +/** Read visible chrome rectangles inside the viewer's usable screen area. */ +export function readWildlifeCardSafeArea({ + documentRef, + viewer, + windowRef = globalThis.window, +}) { + const canvasRect = viewer?.scene?.canvas?.getBoundingClientRect?.(); + const width = + windowRef?.innerWidth || documentRef.documentElement.clientWidth; + const height = + windowRef?.innerHeight || documentRef.documentElement.clientHeight; + const left = Math.max(0, canvasRect?.left || 0) + EDGE; + const top = Math.max(0, canvasRect?.top || 0) + EDGE; + const right = + Math.min(width, canvasRect?.width > 0 ? canvasRect.right : width) - EDGE; + const bottom = + Math.min(height, canvasRect?.height > 0 ? canvasRect.bottom : height) - + EDGE; + const bounds = rectAt( + left, + top, + Math.max(0, right - left), + Math.max(0, bottom - top), + ); + const obstacles = []; + const elements = [ + ...documentRef.querySelectorAll(WILDLIFE_CARD_CHROME_SELECTORS.join(',')), + ]; + for (const element of elements) { + let hidden = false; + for ( + let node = element; + node && node !== documentRef.body; + node = node.parentElement + ) { + const style = windowRef?.getComputedStyle?.(node); + if ( + node.hidden || + style?.display === 'none' || + style?.visibility === 'hidden' || + style?.opacity === '0' + ) { + hidden = true; + break; + } + } + if (hidden) continue; + const rect = element.getBoundingClientRect(); + if (!(rect.width > 0 && rect.height > 0) || !overlaps(rect, bounds)) + continue; + obstacles.push( + rectAt( + rect.left - EDGE, + rect.top - EDGE, + rect.width + EDGE * 2, + rect.height + EDGE * 2, + ), + ); + } + return { bounds, obstacles, elements }; +} + +function nearestSafeAnchor(point, { bounds, obstacles }) { + const xs = [clamp(point.x, bounds.left + GAP, bounds.right - GAP)]; + const ys = [clamp(point.y, bounds.top + GAP, bounds.bottom - GAP)]; + for (const rect of obstacles) { + xs.push( + clamp(rect.left - GAP, bounds.left + GAP, bounds.right - GAP), + clamp(rect.right + GAP, bounds.left + GAP, bounds.right - GAP), + ); + ys.push( + clamp(rect.top - GAP, bounds.top + GAP, bounds.bottom - GAP), + clamp(rect.bottom + GAP, bounds.top + GAP, bounds.bottom - GAP), + ); + } + return ( + xs + .flatMap((x) => ys.map((y) => ({ x, y }))) + .filter( + (p) => contains(bounds, p) && !obstacles.some((r) => contains(r, p)), + ) + .sort( + (a, b) => + Math.hypot(a.x - point.x, a.y - point.y) - + Math.hypot(b.x - point.x, b.y - point.y), + )[0] || null + ); +} + +/** Four anchored placements, clamped along their free axis. Chrome is a set of + * actual DOM rectangles, so corner HUDs do not erase otherwise clear lanes. */ +export function placeWildlifeSightingCard(point, size, safeArea) { + const { bounds, obstacles } = safeArea; + const anchor = + contains(bounds, point) && !obstacles.some((r) => contains(r, point)) + ? point + : nearestSafeAnchor(point, safeArea); + if (!anchor) return null; + const width = Math.min(size.width, bounds.width); + const height = Math.min(size.height, bounds.height); + const centeredX = clamp( + anchor.x - width / 2, + bounds.left, + bounds.right - width, + ); + const centeredY = clamp( + anchor.y - height / 2, + bounds.top, + bounds.bottom - height, + ); + const candidates = [ + { + placement: 'above', + ...rectAt(centeredX, anchor.y - GAP - height, width, height), + }, + { placement: 'below', ...rectAt(centeredX, anchor.y + GAP, width, height) }, + { placement: 'right', ...rectAt(anchor.x + GAP, centeredY, width, height) }, + { + placement: 'left', + ...rectAt(anchor.x - GAP - width, centeredY, width, height), + }, + ]; + const fits = (rect) => + rect.left >= bounds.left && + rect.right <= bounds.right && + rect.top >= bounds.top && + rect.bottom <= bounds.bottom && + !obstacles.some((r) => overlaps(rect, r)); + let chosen = candidates.find(fits); + if (!chosen) { + // Crowded chrome may require sliding a side placement farther from its + // anchor. Search only obstacle/bounds edges; no pixels or idle frame loop. + const xs = [bounds.left, bounds.right - width, centeredX]; + const ys = [bounds.top, bounds.bottom - height, centeredY]; + for (const r of obstacles) { + xs.push(r.left - width, r.right); + ys.push(r.top - height, r.bottom); + } + const alternatives = xs + .flatMap((x) => + ys.map((y) => ({ + placement: + y + height <= anchor.y + ? 'above' + : y >= anchor.y + ? 'below' + : x >= anchor.x + ? 'right' + : 'left', + ...rectAt(x, y, width, height), + })), + ) + .filter((r) => fits(r) && !contains(r, anchor)); + alternatives.sort( + (a, b) => + Math.hypot( + clamp(anchor.x, a.left, a.right) - anchor.x, + clamp(anchor.y, a.top, a.bottom) - anchor.y, + ) - + Math.hypot( + clamp(anchor.x, b.left, b.right) - anchor.x, + clamp(anchor.y, b.top, b.bottom) - anchor.y, + ), + ); + chosen = alternatives[0]; + } + return chosen + ? { + ...chosen, + anchor, + pan: { x: anchor.x - point.x, y: anchor.y - point.y }, + } + : null; +} + +/** Project a front-side sighting into viewport coordinates. */ +export function projectWildlifeSighting(viewer, row, layer) { + if ( + !viewer?.scene || + !Number.isFinite(row?.lat) || + !Number.isFinite(row?.lon) + ) + return null; + const position = + layer?.getSightingPosition?.(row.stableId) || + Cesium.Cartesian3.fromDegrees(row.lon, row.lat); + const ellipsoid = viewer.scene.globe?.ellipsoid || Cesium.Ellipsoid.WGS84; + if ( + viewer.camera?.positionWC && + new Cesium.EllipsoidalOccluder( + ellipsoid, + viewer.camera.positionWC, + ).isPointVisible(position) !== true + ) + return null; + const point = Cesium.SceneTransforms.worldToWindowCoordinates( + viewer.scene, + position, + ); + if (!point) return null; + const rect = viewer.scene.canvas?.getBoundingClientRect?.(); + return { x: point.x + (rect?.left || 0), y: point.y + (rect?.top || 0) }; +} + +/** Translate the camera by the smallest screen correction and preserve its + * altitude/orientation. A card must never zoom merely to reveal its anchor. */ +export function panWildlifeSighting(viewer, from, to, reducedMotion = false) { + const camera = viewer?.camera; + if (!camera?.pickEllipsoid || !camera.flyTo) return false; + const rect = viewer.scene.canvas.getBoundingClientRect(); + const local = (p) => new Cesium.Cartesian2(p.x - rect.left, p.y - rect.top); + const ellipsoid = viewer.scene.globe?.ellipsoid || Cesium.Ellipsoid.WGS84; + const start = camera.pickEllipsoid(local(from), ellipsoid); + const finish = camera.pickEllipsoid(local(to), ellipsoid); + if (!start || !finish || !camera.positionWC) return false; + const delta = Cesium.Cartesian3.subtract( + start, + finish, + new Cesium.Cartesian3(), + ); + const destination = Cesium.Cartesian3.add( + camera.positionWC, + delta, + new Cesium.Cartesian3(), + ); + const cartographic = ellipsoid.cartesianToCartographic(destination); + if (!cartographic) return false; + cartographic.height = camera.positionCartographic.height; + camera.flyTo({ + destination: ellipsoid.cartographicToCartesian(cartographic), + orientation: { + heading: camera.heading, + pitch: camera.pitch, + roll: camera.roll, + }, + duration: reducedMotion ? 0 : 0.22, + easingFunction: Cesium.EasingFunction.QUADRATIC_OUT, + }); + return true; +} diff --git a/src/voice/layerManifest.js b/src/voice/layerManifest.js index 1234fb61e..9b5684243 100644 --- a/src/voice/layerManifest.js +++ b/src/voice/layerManifest.js @@ -453,10 +453,12 @@ const layers = [ export const VOICE_OFF_LAYERS = Object.freeze({ 'bhote-koshi-2026': 'scene-driven event layer (control_scene)', 'bhote-koshi-locator': 'scene-driven event layer (control_scene)', - // The Realtime prompt is at its 52,000-character budget; voice toggle and + // The Realtime prompt is at its 55,500-character budget; voice toggle and // query for this layer wait until there is room. 'drone-airspace': 'informational airspace layer; no room in the prompt budget', + 'wildlife-sightings': + 'community observation layer; no room in the prompt budget', }); const TYPE_NAMES = Object.freeze({ diff --git a/src/worldFocus.js b/src/worldFocus.js index 5b5ca7a8e..6ed3a3b32 100644 --- a/src/worldFocus.js +++ b/src/worldFocus.js @@ -9,6 +9,12 @@ export const WORLD_CLICK_FOCUS_DURATION_SEC = 1.9; export const WORLD_FOCUS_FRAMING = Object.freeze({ vessel: Object.freeze({ radiusM: 150, rangeM: 1200, pitchDeg: -30 }), fire: Object.freeze({ radiusM: 400, rangeM: 3000, pitchDeg: -35 }), + sighting: Object.freeze({ radiusM: 250, rangeM: 2500, pitchDeg: -40 }), + 'sighting-area': Object.freeze({ + radiusM: 11000, + rangeM: 60000, + pitchDeg: -55, + }), }); /** Validate a layer-owned focus target before camera policy can release tracking. */ @@ -23,7 +29,7 @@ export function isValidWorldFocusTarget(detail) { !Number.isFinite(position.z) ) return false; - // Vessel and fire targets are surface-anchored Earth positions. Merely + // These targets are surface-anchored Earth positions. Merely // finite coordinates near the ECEF origin cannot be flown to, and must be // rejected before the camera policy releases a current follow owner. const magnitude = Cesium.Cartesian3.magnitude(position); diff --git a/src/worldFocus.test.mjs b/src/worldFocus.test.mjs index f5e8146db..3a10b75eb 100644 --- a/src/worldFocus.test.mjs +++ b/src/worldFocus.test.mjs @@ -21,16 +21,28 @@ function stubCamera() { return { calls, heading: Cesium.Math.toRadians(45), - cancelFlight() { calls.cancelFlight += 1; }, - flyToBoundingSphere(sphere, options) { calls.flights.push({ sphere, options }); }, + cancelFlight() { + calls.cancelFlight += 1; + }, + flyToBoundingSphere(sphere, options) { + calls.flights.push({ sphere, options }); + }, }; } test('a focus request carries the clicked target to any registered listener', () => { const target = new EventTarget(); const seen = []; - const dispose = registerWorldFocusRequestListener(target, (event) => seen.push(event.detail)); - assert.equal(requestWorldFocus({ kind: 'vessel', position: POSITION, id: '123' }, target), true); + const dispose = registerWorldFocusRequestListener(target, (event) => + seen.push(event.detail), + ); + assert.equal( + requestWorldFocus( + { kind: 'vessel', position: POSITION, id: '123' }, + target, + ), + true, + ); assert.equal(seen.length, 1); assert.equal(seen[0].kind, 'vessel'); assert.equal(seen[0].id, '123'); @@ -44,7 +56,9 @@ test('a focus request carries the clicked target to any registered listener', () test('requests without a kind or a position are dropped, never dispatched', () => { const target = new EventTarget(); let count = 0; - target.addEventListener(WORLD_FOCUS_REQUEST_EVENT, () => { count += 1; }); + target.addEventListener(WORLD_FOCUS_REQUEST_EVENT, () => { + count += 1; + }); assert.equal(requestWorldFocus({ position: POSITION }, target), false); assert.equal(requestWorldFocus({ kind: 'vessel' }, target), false); assert.equal(requestWorldFocus(null, target), false); @@ -74,51 +88,94 @@ test('a refusing policy (cockpit owns the camera) never reaches the flight', () const result = routeWorldFocusRequest( { detail: { kind: 'vessel', id: '123', position: POSITION } }, () => false, - () => { flights += 1; }, + () => { + flights += 1; + }, ); assert.equal(result, false); assert.equal(flights, 0); }); test('malformed requests are inert', () => { - assert.equal(routeWorldFocusRequest(null, () => 'x', () => 'y'), false); - assert.equal(routeWorldFocusRequest({ detail: { kind: 'vessel' } }, () => 'x', () => 'y'), false); + assert.equal( + routeWorldFocusRequest( + null, + () => 'x', + () => 'y', + ), + false, + ); + assert.equal( + routeWorldFocusRequest( + { detail: { kind: 'vessel' } }, + () => 'x', + () => 'y', + ), + false, + ); }); // A request that cannot fly must never cost the user their tracking: the UI // policy releases the follow camera, so validation happens BEFORE it runs. test('an unflyable request never reaches the release policy', () => { const unflyable = [ - { kind: 'plane', position: POSITION }, // no framing for this kind - { kind: 'vessel', position: { x: NaN, y: 0, z: 0 } }, // malformed position - { kind: 'vessel', position: { x: 0, y: 0 } }, // truthy but incomplete + { kind: 'plane', position: POSITION }, // no framing for this kind + { kind: 'vessel', position: { x: NaN, y: 0, z: 0 } }, // malformed position + { kind: 'vessel', position: { x: 0, y: 0 } }, // truthy but incomplete { kind: 'vessel', id: '123', position: { x: 0, y: 0, z: 0 } }, // ECEF origin { kind: 'fire', id: 'fire-1', position: { x: 1, y: 1, z: 1 } }, // finite but inside Earth { kind: 'vessel', position: 'somewhere' }, - { kind: 'vessel', position: POSITION }, // missing owner id + { kind: 'vessel', position: POSITION }, // missing owner id { position: POSITION }, ]; for (const detail of unflyable) { - assert.equal(isValidWorldFocusTarget(detail), false, JSON.stringify(detail)); + assert.equal( + isValidWorldFocusTarget(detail), + false, + JSON.stringify(detail), + ); let policyRuns = 0; const result = routeWorldFocusRequest( { detail }, - () => { policyRuns += 1; return 'released'; }, + () => { + policyRuns += 1; + return 'released'; + }, () => 'flew', ); assert.equal(result, false, JSON.stringify(detail)); - assert.equal(policyRuns, 0, `the policy must not run for ${JSON.stringify(detail)}`); + assert.equal( + policyRuns, + 0, + `the policy must not run for ${JSON.stringify(detail)}`, + ); // Nor may such a request be dispatched in the first place. assert.equal(requestWorldFocus(detail, new EventTarget()), false); } - assert.equal(isValidWorldFocusTarget({ kind: 'vessel', id: '123', position: POSITION }), true); - assert.equal(isValidWorldFocusTarget({ kind: 'fire', id: 'fire-1', position: POSITION }), true); + assert.equal( + isValidWorldFocusTarget({ kind: 'vessel', id: '123', position: POSITION }), + true, + ); + assert.equal( + isValidWorldFocusTarget({ kind: 'fire', id: 'fire-1', position: POSITION }), + true, + ); }); test('the transfer flight supersedes any flight in progress and keeps the operator heading', () => { const camera = stubCamera(); - assert.equal(flyToWorldTarget({ camera }, { kind: 'vessel', id: '123', position: POSITION }), true); - assert.equal(camera.calls.cancelFlight, 1, 'a prior flight must be cancelled, not queued'); + assert.equal( + flyToWorldTarget( + { camera }, + { kind: 'vessel', id: '123', position: POSITION }, + ), + true, + ); + assert.equal( + camera.calls.cancelFlight, + 1, + 'a prior flight must be cancelled, not queued', + ); assert.equal(camera.calls.flights.length, 1); const { sphere, options } = camera.calls.flights[0]; assert.equal(sphere.radius, WORLD_FOCUS_FRAMING.vessel.radiusM); @@ -131,39 +188,82 @@ test('the transfer flight supersedes any flight in progress and keeps the operat test('fires frame wider than vessels — a fire is read by its surroundings', () => { const camera = stubCamera(); - flyToWorldTarget({ camera }, { kind: 'fire', id: 'fire-1', position: POSITION }); + flyToWorldTarget( + { camera }, + { kind: 'fire', id: 'fire-1', position: POSITION }, + ); const { sphere, options } = camera.calls.flights[0]; assert.equal(sphere.radius, WORLD_FOCUS_FRAMING.fire.radiusM); assert.equal(options.offset.range, WORLD_FOCUS_FRAMING.fire.rangeM); - assert.ok(WORLD_FOCUS_FRAMING.fire.rangeM > WORLD_FOCUS_FRAMING.vessel.rangeM); - assert.ok(WORLD_FOCUS_FRAMING.fire.radiusM > WORLD_FOCUS_FRAMING.vessel.radiusM); + assert.ok( + WORLD_FOCUS_FRAMING.fire.rangeM > WORLD_FOCUS_FRAMING.vessel.rangeM, + ); + assert.ok( + WORLD_FOCUS_FRAMING.fire.radiusM > WORLD_FOCUS_FRAMING.vessel.radiusM, + ); }); test('every framing is a real oblique standoff, not a nadir or an inside-out sphere', () => { const kinds = Object.keys(WORLD_FOCUS_FRAMING); - assert.deepEqual(kinds.sort(), ['fire', 'vessel']); + assert.deepEqual(kinds.sort(), [ + 'fire', + 'sighting', + 'sighting-area', + 'vessel', + ]); for (const kind of kinds) { const framing = WORLD_FOCUS_FRAMING[kind]; // Looking DOWN at the target, but obliquely — a nadir drop reads as a map. - assert.ok(framing.pitchDeg < 0, `${kind}: pitch must look down, got ${framing.pitchDeg}`); - assert.ok(framing.pitchDeg > -80, `${kind}: pitch must stay oblique, got ${framing.pitchDeg}`); + assert.ok( + framing.pitchDeg < 0, + `${kind}: pitch must look down, got ${framing.pitchDeg}`, + ); + assert.ok( + framing.pitchDeg > -80, + `${kind}: pitch must stay oblique, got ${framing.pitchDeg}`, + ); // Standing off outside the framed sphere, at a human-readable distance. assert.ok(framing.radiusM > 0, `${kind}: radius must be positive`); assert.ok( framing.rangeM > framing.radiusM, `${kind}: range ${framing.rangeM} must stand off outside radius ${framing.radiusM}`, ); - assert.ok(framing.rangeM <= 10000, `${kind}: range must stay close-in, got ${framing.rangeM}`); + // An obscured sighting frames its entire 0.2° privacy cell. + const maximumRange = kind === 'sighting-area' ? 60000 : 10000; + assert.ok( + framing.rangeM <= maximumRange, + `${kind}: range must fit its surface extent, got ${framing.rangeM}`, + ); } // Exact shipped values — a silent retune must show up as a failing test. - assert.deepEqual({ ...WORLD_FOCUS_FRAMING.vessel }, { radiusM: 150, rangeM: 1200, pitchDeg: -30 }); - assert.deepEqual({ ...WORLD_FOCUS_FRAMING.fire }, { radiusM: 400, rangeM: 3000, pitchDeg: -35 }); + assert.deepEqual( + { ...WORLD_FOCUS_FRAMING.vessel }, + { radiusM: 150, rangeM: 1200, pitchDeg: -30 }, + ); + assert.deepEqual( + { ...WORLD_FOCUS_FRAMING.fire }, + { radiusM: 400, rangeM: 3000, pitchDeg: -35 }, + ); + assert.deepEqual( + { ...WORLD_FOCUS_FRAMING.sighting }, + { radiusM: 250, rangeM: 2500, pitchDeg: -40 }, + ); + assert.deepEqual( + { ...WORLD_FOCUS_FRAMING['sighting-area'] }, + { radiusM: 11000, rangeM: 60000, pitchDeg: -55 }, + ); }); test('unknown kinds and missing viewers issue no flight', () => { const camera = stubCamera(); - assert.equal(flyToWorldTarget({ camera }, { kind: 'plane', position: POSITION }), false); + assert.equal( + flyToWorldTarget({ camera }, { kind: 'plane', position: POSITION }), + false, + ); assert.equal(flyToWorldTarget({ camera }, { kind: 'vessel' }), false); - assert.equal(flyToWorldTarget(null, { kind: 'vessel', position: POSITION }), false); + assert.equal( + flyToWorldTarget(null, { kind: 'vessel', position: POSITION }), + false, + ); assert.equal(camera.calls.flights.length, 0); }); From ecbe1d99e67f09f622f1afe6a8bb450edddc0909 Mon Sep 17 00:00:00 2001 From: Yehe Liu Date: Thu, 5 Nov 2026 16:18:27 -0500 Subject: [PATCH 132/156] docs: number the wildlife sightings changelog fragment (#1591) Co-Authored-By: Claude Opus 5.5 --- .../{0000-wildlife-sightings.md => 1591-wildlife-sightings.md} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename changelog.d/{0000-wildlife-sightings.md => 1591-wildlife-sightings.md} (95%) diff --git a/changelog.d/0000-wildlife-sightings.md b/changelog.d/1591-wildlife-sightings.md similarity index 95% rename from changelog.d/0000-wildlife-sightings.md rename to changelog.d/1591-wildlife-sightings.md index 75abdd8dd..64f4e7c54 100644 --- a/changelog.d/0000-wildlife-sightings.md +++ b/changelog.d/1591-wildlife-sightings.md @@ -9,4 +9,4 @@ Acartia comments are excluded. Attribution appears in the layer row and Data attribution popover. Cached progressive tiles, bounded image/billboard work and a paced queue preserve iNaturalist's 60/minute and 8,000/day limits. - All 11 locales supported. (lyehe, #0000) + All 11 locales supported. (lyehe, #1591) From 63fd559496fa8af42d144a0cc0e5bb8d1071b90c Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 5 Nov 2026 14:01:17 -0800 Subject: [PATCH 133/156] Keep the ridden aircraft's layer when Contacts ends, and clear its rows from Live Signals Riding an aircraft now adopts its layer the way a direct selection does, and Contacts gives up a dependency the user adopts, so ending Contacts mid-session (one of its layers off, or Contacts itself) no longer turns off the ridden layer and ends the Cockpit. When Contacts goes off, Live Signals drops the rows built from its snapshot instead of leaving stale distances marked CURRENT, and the feed-off warning is no longer duplicated. --- changelog.d/1556-cockpit-own-layer-entry.md | 3 +- docs/CURRENT-STATE.md | 15 +- src/data/militaryAwareness.test.mjs | 50 ++++ src/layers/awareness/controls.js | 1 + src/layers/awareness/dependencies.js | 20 ++ src/layers/awareness/lifecycle.js | 1 + src/layers/awareness/state.js | 2 + src/ui/cockpitContext.js | 3 + src/ui/cockpitContext.test.mjs | 63 +++++ src/ui/cockpitController.js | 6 + src/ui/cockpitCoordinator.js | 17 ++ src/ui/cockpitEntryGate.test.mjs | 253 +++++++++++++++++++- src/ui/cockpitSignals.js | 21 ++ src/ui/cockpitTrackingController.js | 2 + 14 files changed, 441 insertions(+), 16 deletions(-) diff --git a/changelog.d/1556-cockpit-own-layer-entry.md b/changelog.d/1556-cockpit-own-layer-entry.md index ca5210120..d88f475f1 100644 --- a/changelog.d/1556-cockpit-own-layer-entry.md +++ b/changelog.d/1556-cockpit-own-layer-entry.md @@ -1,5 +1,6 @@ - Enter Cockpit with only the ridden aircraft's layer on. Contacts is no longer - required; turning off the aircraft's own feed ends the view, a missing other + required, and ending Contacts mid-session keeps the view and the layer it + rides; turning off the aircraft's own feed ends the view, a missing other feed reads `… FEED OFF · PARTIAL PICTURE`, and double-clicking the followed aircraft or the entry beside its readout enters it (Denis Groshev, @ZloyS, #1556). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index ad0dc30e4..87617f531 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -6021,11 +6021,16 @@ apply to `public/` assets or built-preview serving. 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. + 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. Riding an aircraft + while Contacts is on adopts its layer as the user's own, as a direct selection + does (`adoptLayerVisibility`), so Contacts ending, by its own switch or one of + its layers going off, restores the pre-Contacts layers but keeps the ridden + one and the view; Live Signals then drops the rows Contacts built and shows + `CONTEXT STANDBY`. 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. diff --git a/src/data/militaryAwareness.test.mjs b/src/data/militaryAwareness.test.mjs index 05fd97dc0..98743b03c 100644 --- a/src/data/militaryAwareness.test.mjs +++ b/src/data/militaryAwareness.test.mjs @@ -98,6 +98,7 @@ function installAwarenessRuntime({ // status its module never emits guards nothing. See the settled-but-fetching // tests below, which use militaryInstallations' genuine first-fetch readout. layerStats = {}, + subscribe = undefined, } = {}) { const originalWindow = globalThis.window; const originalDocument = globalThis.document; @@ -215,6 +216,7 @@ function installAwarenessRuntime({ setEnabled, getLayerLifecycleState, layers, + ...(subscribe ? { subscribe } : {}), }); militaryAwarenessLayer.setParams({ passive: true }); militaryAwarenessLayer.init(viewer); @@ -383,6 +385,54 @@ test('Contacts does not claim a dependency already enabling for the user', async } }); +test('a dependency the user adopts stays on when Contacts turns off', async () => { + // A direct selection, or the Cockpit riding one of its aircraft, promotes a + // layer Contacts turned on into the user's own (adoptLayerVisibility). The + // coordinator must give it up, or its OFF release tears the layer down — + // and the track or the Cockpit with it — before Context restores it. + const calls = []; + const subscribers = new Set(); + const runtime = installAwarenessRuntime({ + isEnabled: () => false, + isEffectivelyEnabled: () => false, + setEnabled: (layerId, enabled) => { + calls.push({ layerId, enabled }); + return Promise.resolve(true); + }, + subscribe: (listener) => { + subscribers.add(listener); + return () => subscribers.delete(listener); + }, + }); + + try { + militaryAwarenessLayer.setParams({ passive: false }); + await nextTurn(); + for (const listener of subscribers) + listener({ + type: 'visibility', + layerId: 'flights', + enabled: true, + origin: 'user', + adopted: true, + adoptedFromSelection: true, + }); + await militaryAwarenessLayer.disable(); + const released = calls + .filter(({ enabled }) => !enabled) + .map(({ layerId }) => layerId) + .sort(); + assert.deepEqual( + released, + AWARENESS_DEPENDENCIES.filter((layerId) => layerId !== 'flights').sort(), + 'the adopted layer is the user\'s now; the rest are still released', + ); + } finally { + runtime.restore(); + } + assert.equal(subscribers.size, 0, 'destroy drops the manager subscription'); +}); + test('operational awareness becomes ready after aircraft while deferred sources keep loading', async () => { let enabled = false; let releaseAircraft; diff --git a/src/layers/awareness/controls.js b/src/layers/awareness/controls.js index 38c273c05..ed543e138 100644 --- a/src/layers/awareness/controls.js +++ b/src/layers/awareness/controls.js @@ -23,6 +23,7 @@ export function createControls({ state: layerState, services, parts, source }) { attachDataManager(dataManager) { layerState.dataManager = dataManager; + parts.dependencies.watchAdoptions(dataManager); }, setParams(params = {}) { diff --git a/src/layers/awareness/dependencies.js b/src/layers/awareness/dependencies.js index 15a23e455..80cffad1c 100644 --- a/src/layers/awareness/dependencies.js +++ b/src/layers/awareness/dependencies.js @@ -79,6 +79,25 @@ export function createDependencies({ }); } + /** + * Give up a dependency the user adopts. A direct selection, or the Cockpit + * riding one of its aircraft, promotes a layer Contacts turned on into the + * user's own (`DataLayerManager.adoptLayerVisibility`). Releasing it when + * Contacts goes off would tear down that track or Cockpit before the + * Context restore could put the layer back. + */ + function relinquishAdoptedDependency(change) { + if (change?.type === 'visibility' && change.adopted && change.enabled) + layerState.ownedDependencies.delete(change.layerId); + } + + /** Follow one manager's adoptions; null stops following. */ + function watchAdoptions(dataManager) { + layerState.unwatchAdoptions?.(); + layerState.unwatchAdoptions = + dataManager?.subscribe?.(relinquishAdoptedDependency) || null; + } + function activateOperationalContext() { const activationId = ++layerState.activationId; layerState.autoFocusRetryPending = false; @@ -113,6 +132,7 @@ export function createDependencies({ refreshAfterDeferredDependency, enableDependencies, releaseOwnedDependencies, + watchAdoptions, activateOperationalContext, }; } diff --git a/src/layers/awareness/lifecycle.js b/src/layers/awareness/lifecycle.js index b2e7dfe30..7b6b84610 100644 --- a/src/layers/awareness/lifecycle.js +++ b/src/layers/awareness/lifecycle.js @@ -340,6 +340,7 @@ export function createLifecycle({ layerState.clearListener = null; layerState.subjectClearListener = null; layerState.viewer = null; + parts.dependencies.watchAdoptions(null); layerState.dataManager = null; }, }; diff --git a/src/layers/awareness/state.js b/src/layers/awareness/state.js index 4d7d0d6e7..adc896943 100644 --- a/src/layers/awareness/state.js +++ b/src/layers/awareness/state.js @@ -26,6 +26,8 @@ export function createState({ services }) { // layers activate only after Contacts is explicitly selected. passive: true, ownedDependencies: new Set(), + /** Stops following the manager's adoptions (see watchAdoptions). */ + unwatchAdoptions: null, subject: null, results: null, visual: null, diff --git a/src/ui/cockpitContext.js b/src/ui/cockpitContext.js index a343bbb91..cfb142e50 100644 --- a/src/ui/cockpitContext.js +++ b/src/ui/cockpitContext.js @@ -75,6 +75,9 @@ export function updateContext(info, heading) { const readout = resolveCockpitContextReadout({ snapshot, info }); const partial = readPartialPicture.call(this); if (!readout.visible) { + // Contacts just went off: the rows its snapshot built stop being + // measured, so they go before the standby rows replace them. + if (this.presence.context) this.dropContextSnapshotSignals?.(); this.showContextCard(false); this.contextLayoutStamp = null; this.pushCockpitSignal( diff --git a/src/ui/cockpitContext.test.mjs b/src/ui/cockpitContext.test.mjs index 4896d238e..ca86701ab 100644 --- a/src/ui/cockpitContext.test.mjs +++ b/src/ui/cockpitContext.test.mjs @@ -12,6 +12,13 @@ import { updateContext, } from './cockpitContext.js'; import { createContextState } from './cockpitContextState.js'; +import { + dropCockpitSignal, + dropContextSnapshotSignals, + publishCockpitSignals, + pushCockpitSignal, + updateCockpitSignals, +} from './cockpitSignals.js'; import { syncWeatherToggle } from './cockpitInstruments.js'; import { setContextCollapsed } from './cockpitLayout.js'; import { useCockpitStore } from './stores/cockpit.js'; @@ -261,6 +268,62 @@ test('without Contacts, a feed that is off adds a signal until it is back', asyn assert.deepEqual(dropped, ['feed-status']); }); +/** The fixture's owner with the real Live Signals presenters. */ +function withSignals(owner) { + const published = []; + Object.assign(owner, { + signalItems: [], + signalSignatures: new Map(), + publishSignals: (rows) => published.push(rows), + publishCockpitSignals, + pushCockpitSignal, + dropCockpitSignal, + dropContextSnapshotSignals, + updateCockpitSignals, + }); + return () => published.at(-1).map((row) => row.title); +} + +test('Contacts going off clears its contact rows and leaves standby', async (t) => { + const { owner } = fixture(t, snapshot()); + const rows = withSignals(owner); + owner.services.isLayerEnabled = () => true; + updateContext.call(owner, INFO, 0); + assert.ok( + owner.signalItems.some((item) => item.key.startsWith('flight:')), + 'Contacts fills the rows', + ); + assert.ok(owner.signalItems.some((item) => item.key === 'input-status')); + // Both feeds stay on; only Contacts goes. + owner.snapshot = null; + updateContext.call(owner, INFO, 0); + assert.deepEqual(rows(), ['CONTEXT STANDBY']); + updateContext.call(owner, INFO, 0); + assert.deepEqual(rows(), ['CONTEXT STANDBY'], 'standby holds still'); +}); + +test('Contacts going off with a feed off keeps the feed row beside standby', async (t) => { + const { owner } = fixture(t, snapshot()); + const rows = withSignals(owner); + owner.services.isLayerEnabled = (layerId) => layerId !== 'military'; + updateContext.call(owner, INFO, 0); + owner.snapshot = null; + updateContext.call(owner, INFO, 0); + assert.deepEqual(rows(), [ + 'MILITARY FEED OFF · PARTIAL PICTURE', + 'CONTEXT STANDBY', + ]); +}); + +test('standby without Contacts keeps the rows the session already shows', async (t) => { + const { owner } = fixture(t, null); + const rows = withSignals(owner); + owner.services.isLayerEnabled = () => true; + owner.pushCockpitSignal('track', 'track', 'TRACK ACQUIRED', 'UAL123'); + updateContext.call(owner, INFO, 0); + assert.deepEqual(rows(), ['CONTEXT STANDBY', 'TRACK ACQUIRED']); +}); + 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 1e69146e3..6010c3ce7 100644 --- a/src/ui/cockpitController.js +++ b/src/ui/cockpitController.js @@ -51,6 +51,7 @@ import { } from './cockpitBriefing.js'; import { dropCockpitSignal, + dropContextSnapshotSignals, handleSignalClick, publishCockpitSignals, pushCockpitSignal, @@ -82,6 +83,7 @@ export class CockpitViewController { isUtilityDisclosureOpen = null, onEntered = null, onExited = null, + onRide = null, restoreTrackingFrame = null, publish = {}, } = {}, @@ -121,6 +123,7 @@ export class CockpitViewController { this.onCameraTakeover = onCameraTakeover; this.onEntered = onEntered; this.onExited = onExited; + this.onRide = onRide; this.restoreTrackingFrame = typeof restoreTrackingFrame === 'function' ? restoreTrackingFrame @@ -394,6 +397,9 @@ export class CockpitViewController { dropCockpitSignal(key) { return dropCockpitSignal.call(this, key); } + dropContextSnapshotSignals() { + return dropContextSnapshotSignals.call(this); + } updateCockpitSignals(snapshot, unknownCount, partialPicture) { return updateCockpitSignals.call( this, diff --git a/src/ui/cockpitCoordinator.js b/src/ui/cockpitCoordinator.js index 7140f3172..3839aa3af 100644 --- a/src/ui/cockpitCoordinator.js +++ b/src/ui/cockpitCoordinator.js @@ -97,6 +97,7 @@ export class CockpitCoordinator { isUtilityDisclosureOpen: () => !!this.readUtilityDisclosureOpen?.(), onEntered: () => this.enterPanels(), onExited: () => this.exitPanels(), + onRide: (layerId) => this._adoptRiddenLayer(layerId), restoreTrackingFrame: (entity) => { const [layerId, ...idParts] = String(entity?.gevTrackedId || '').split( ':', @@ -127,6 +128,22 @@ export class CockpitCoordinator { _isLayerEnabled(layerId) { return Boolean(layerId) && !!this._dataManager?.isEnabled(layerId); } + /** + * Make the ridden aircraft's layer the user's own while Contacts is on. + * Contacts may have turned that layer on, and it releases the layers it + * owns when it goes off; the Cockpit must outlive Contacts, so riding an + * aircraft adopts its layer as a direct selection does. Contacts then stops + * owning it and the Context restore keeps it (session `userAdded`). + * @param {string|null} layerId The ridden aircraft's layer. + */ + _adoptRiddenLayer(layerId) { + if (!COCKPIT_AIRCRAFT_LAYER_IDS.includes(layerId)) return; + if (!this._isLayerEnabled('military-awareness')) return; + this._dataManager?.adoptLayerVisibility?.(layerId, true, { + origin: 'user', + adoptedFromSelection: true, + }); + } /** Whether either aircraft layer is on, so there is an aircraft to ride. */ get _aircraftLayerEnabled() { return COCKPIT_AIRCRAFT_LAYER_IDS.some((layerId) => diff --git a/src/ui/cockpitEntryGate.test.mjs b/src/ui/cockpitEntryGate.test.mjs index 5931ca5b2..834f1866f 100644 --- a/src/ui/cockpitEntryGate.test.mjs +++ b/src/ui/cockpitEntryGate.test.mjs @@ -1,13 +1,17 @@ // 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. +// during a session, turning the ridden layer off ends it, Contacts ending +// keeps the ridden layer it had turned on, 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 { DataLayerManager } from '../data/manager.js'; +import { createDependencies } from '../layers/awareness/dependencies.js'; import { CockpitCoordinator } from './cockpitCoordinator.js'; +import { ContextControls } from './contextControls.js'; import { _handleContextLayerChange } from './contextLayerChanges.js'; import { useCockpitStore } from './stores/cockpit.js'; import { useHudStore } from './stores/hud.js'; @@ -34,6 +38,8 @@ function fixture( tracked = null, contextMode = null, changing = false, + dataManager: suppliedManager = null, + readContext = null, } = {}, ) { document.body.replaceChildren(); @@ -45,22 +51,29 @@ function fixture( app.mount(host); const store = useCockpitStore(pinia); const layers = new Set(enabled); - const dataManager = { isEnabled: (layerId) => layers.has(layerId) }; + const dataManager = suppliedManager || { + 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 = { + tracked?.layerId === layerId && dataManager.isEnabled(layerId) + ? tracked.info + : null; + const entityFor = (subject) => ({ position: { getValue: () => null }, show: true, - gevTrackedId: tracked ? `${tracked.layerId}:${tracked.info.icao24}` : '', - }; + gevTrackedId: subject ? `${subject.layerId}:${subject.info.icao24}` : '', + }); + const entities = new Set(); + const entity = entityFor(tracked); + entities.add(entity); const listeners = () => ({ addEventListener: () => () => {} }); const viewer = { trackedEntity: tracked ? entity : undefined, clock: { currentTime: null }, camera: { cancelFlight() {}, frustum: { fov: 1, aspectRatio: 1 } }, - entities: { contains: (candidate) => candidate === entity }, + entities: { contains: (candidate) => entities.has(candidate) }, scene: { preUpdate: listeners(), screenSpaceCameraController: { enableInputs: true }, @@ -86,7 +99,7 @@ function fixture( tracked ? { layerId: tracked.layerId, id: tracked.info.icao24 } : null, }, readDataManager: () => dataManager, - readContext: () => context, + readContext: readContext || (() => context), readActiveStyle: () => 'normal', readUtilityDisclosureOpen: () => false, enterPanels() {}, @@ -125,8 +138,14 @@ function fixture( { type: 'visibility', layerId, enabled: false, origin: 'user' }, ); }; + /** Track another aircraft, as Contacts navigation does mid-session. */ + const retrack = (subject) => { + tracked = subject; + viewer.trackedEntity = entityFor(subject); + entities.add(viewer.trackedEntity); + }; const $ = (selector) => document.querySelector(selector); - return { $, coordinator, cockpit, store, layers, disable, pinia }; + return { $, coordinator, cockpit, store, layers, disable, retrack, pinia }; } test('a civilian aircraft enters with Live Flights alone: no Contacts, no Military', (t) => { @@ -232,3 +251,217 @@ test('the readout offers the same entry, labelled by kind, and enters on click', assert.equal(cockpit.active, true); assert.equal(readoutEntry.hidden, true, 'inside, there is nothing to enter'); }); + +const AIR_AND_SEA = [ + 'flights', + 'military', + 'ais-live-vessels', + 'military-installations', +]; + +/** A source layer that settles at once, as the data manager drives it. */ +function sourceLayer(id) { + return { + id, + name: id, + icon: '', + source: 'test', + updateInterval: -1, + init() {}, + enable() {}, + disable() {}, + update() {}, + getStats: () => ({ count: 0, lastUpdate: null }), + }; +} + +/** + * Contacts as the manager sees it: a coordinator whose real dependency + * ownership (src/layers/awareness/dependencies.js) turns on the air and sea + * layers it finds off and releases them when it goes off. + */ +function contactsCoordinator() { + const state = { + dataManager: null, + enabled: false, + passive: true, + activationId: 0, + ownedDependencies: new Set(), + subject: null, + autoFocusRetryPending: false, + }; + const dependencies = createDependencies({ + state, + services: {}, + parts: { subject: {}, focus: {} }, + source: null, + }); + return { + id: 'military-awareness', + name: 'Contacts', + icon: '', + source: 'test', + showInTogglePanel: false, + updateInterval: -1, + attachDataManager(dataManager) { + state.dataManager = dataManager; + dependencies.watchAdoptions(dataManager); + }, + setParams(params = {}) { + if (typeof params.passive === 'boolean') state.passive = params.passive; + }, + getParams: () => ({ passive: state.passive }), + init() {}, + enable() { + state.enabled = true; + if (!state.passive) return dependencies.enableDependencies(); + return true; + }, + disable() { + state.enabled = false; + state.passive = true; + return dependencies.releaseOwnedDependencies(++state.activationId); + }, + update() {}, + getStats: () => ({ count: 0, lastUpdate: null }), + }; +} + +/** + * The Cockpit beside the real Context owner and data manager, so a Contacts + * exit runs its whole restore: the coordinator's releases, then the snapshot. + */ +async function liveFixture(t, { enabled = [], tracked = null } = {}) { + const manager = new DataLayerManager({}); + for (const layerId of AIR_AND_SEA) manager.register(sourceLayer(layerId)); + const coordinatorModule = contactsCoordinator(); + manager.register(coordinatorModule); + coordinatorModule.attachDataManager(manager); + for (const layerId of enabled) await manager.setEnabled(layerId, true); + let cockpit = null; + const controls = new ContextControls({ + elements: {}, + installations: {}, + actions: { + getCockpit: () => cockpit, + claimVisualAuthority() {}, + showToast() {}, + setClearBusy() {}, + syncDetection() {}, + scheduleLayout() {}, + refreshRadio() {}, + setPanelCollapsed() {}, + }, + }); + controls.connect(manager); + t.after(() => { + controls.stop(); + controls.disconnect(); + }); + const base = fixture(t, { + tracked, + dataManager: manager, + readContext: () => controls.getContextModeState(), + }); + cockpit = base.cockpit; + /** Let every Context reaction the last change started settle. */ + const settle = async () => { + for (let turn = 0; turn < 5; turn += 1) { + await controls._waitForContextLayerSettlement(); + await new Promise((resolve) => setImmediate(resolve)); + } + }; + return { ...base, manager, controls, settle }; +} + +const on = (manager) => + [...AIR_AND_SEA, 'military-awareness'].filter((layerId) => + manager.isEnabled(layerId), + ); + +test('Contacts ending in a Cockpit session keeps the ridden layer it had turned on', async (t) => { + for (const [label, endContacts] of [ + [ + 'Military Flights off', + ({ manager }) => + manager.setEnabled('military', false, { origin: 'user' }), + ], + [ + 'Contacts off by its own layer', + ({ manager }) => + manager.setEnabled('military-awareness', false, { origin: 'user' }), + ], + [ + 'Contacts off from its chooser', + ({ controls }) => controls._selectContextMode(null), + ], + ]) { + await t.test(label, async (t) => { + // Live Flights is off on the map; Contacts turns it on as its own. + const live = await liveFixture(t, { + tracked: { layerId: 'flights', info: CIVIL }, + }); + const { manager, controls, cockpit, settle } = live; + assert.equal(await controls._selectContextMode('flights'), true); + await settle(); + assert.deepEqual(on(manager), [...AIR_AND_SEA, 'military-awareness']); + assert.equal(cockpit.enter(), true); + await endContacts(live); + await settle(); + assert.equal(controls.getContextModeState().mode, null, 'Contacts ended'); + assert.equal(cockpit.active, true, 'the Cockpit rides on'); + assert.deepEqual( + on(manager), + ['flights'], + 'only the ridden layer outlives the pre-Contacts restore', + ); + }); + } +}); + +test('a ride handed to an aircraft on a layer Contacts turned on keeps that layer', async (t) => { + const live = await liveFixture(t, { + enabled: ['flights'], + tracked: { layerId: 'flights', info: CIVIL }, + }); + const { manager, controls, cockpit, retrack, settle } = live; + assert.equal(await controls._selectContextMode('flights'), true); + await settle(); + assert.equal(cockpit.enter(), true); + // NEXT hands the view to a military aircraft; Contacts turned its layer on. + retrack({ layerId: 'military', info: MILITARY }); + assert.equal(cockpit._adoptTrackedEntity(performance.now()), true); + assert.equal(cockpit.readSubjectLayerId(), 'military'); + await manager.setEnabled('ais-live-vessels', false, { origin: 'user' }); + await settle(); + assert.equal(controls.getContextModeState().mode, null); + assert.equal(cockpit.active, true); + assert.deepEqual(on(manager), ['flights', 'military']); +}); + +test('the ridden layer still ends the Cockpit while Contacts is on', async (t) => { + const live = await liveFixture(t, { + tracked: { layerId: 'flights', info: CIVIL }, + }); + const { manager, controls, cockpit, settle } = live; + assert.equal(await controls._selectContextMode('flights'), true); + await settle(); + assert.equal(cockpit.enter(), true); + await manager.setEnabled('flights', false, { origin: 'user' }); + await settle(); + assert.equal(cockpit.active, false); + assert.equal(manager.isEnabled('flights'), false); +}); + +test('without a Cockpit, ending Contacts restores the layers it found', async (t) => { + const live = await liveFixture(t, { + tracked: { layerId: 'flights', info: CIVIL }, + }); + const { manager, controls, settle } = live; + assert.equal(await controls._selectContextMode('flights'), true); + await settle(); + await manager.setEnabled('military', false, { origin: 'user' }); + await settle(); + assert.equal(controls.getContextModeState().mode, null); + assert.deepEqual(on(manager), [], 'Live Flights goes back off'); +}); diff --git a/src/ui/cockpitSignals.js b/src/ui/cockpitSignals.js index 395db8116..32f25bdc8 100644 --- a/src/ui/cockpitSignals.js +++ b/src/ui/cockpitSignals.js @@ -74,6 +74,27 @@ export function dropCockpitSignal(key) { this.publishCockpitSignals(); } +/** Whether a row is one a Contacts snapshot builds, with its status rows. */ +const isContextSnapshotRow = (key) => + key.startsWith('flight:') || + ['input-status', 'feed-status', 'context-status'].includes(key); + +/** + * Take off every row a Contacts snapshot built, as when Contacts goes off: + * its contacts, distances and CURRENT would otherwise read as live. Rows the + * session made without Contacts, such as TRACK ACQUIRED, stay. + */ +export function dropContextSnapshotSignals() { + for (const key of [...this.signalSignatures.keys()]) + if (isContextSnapshotRow(key)) this.signalSignatures.delete(key); + const kept = this.signalItems.filter( + (item) => !isContextSnapshotRow(item.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. diff --git a/src/ui/cockpitTrackingController.js b/src/ui/cockpitTrackingController.js index 3ad6267ff..8675f3908 100644 --- a/src/ui/cockpitTrackingController.js +++ b/src/ui/cockpitTrackingController.js @@ -155,6 +155,7 @@ export function _adoptTrackedEntity(nowMs, suppliedInfo = null) { this.lastContextUpdateMs = 0; this.lastCameraUpdateMs = 0; this.contextNavigationDeadlineMs = 0; + this.onRide?.(info.layerId); this.dispatchCockpitModeChanged(true, info); return true; } @@ -230,6 +231,7 @@ export function enter() { this.mapViewButton?.focus({ preventScroll: true }); }); this.onEntered?.(); + this.onRide?.(info.layerId); this.dispatchCockpitModeChanged(true, info); return true; } From 395eba4548c1b7a7004f528760dff16c16909ab2 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 5 Nov 2026 15:38:32 -0800 Subject: [PATCH 134/156] Fit the tilt wording under the Realtime prompt ceiling With main merged, the Overpass prompt measured 55,549 against the 55,500 ceiling. The pitchDeg and direction descriptions now read 'Absolute; -89 top-down.' and 'Needed for pan/rotate/tilt steps', bringing it to 55,497. The schema pin and the measured-size note follow. --- server/providers/openai/toolDescriptions.js | 5 ++--- src/radioMarkup.test.mjs | 6 +++--- src/voice/actionSchemas.test.mjs | 2 +- src/voice/pointAndAsk.test.mjs | 7 ++++--- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/server/providers/openai/toolDescriptions.js b/server/providers/openai/toolDescriptions.js index 1deff2335..f4000ddad 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/rotate and a tilt nudge; orbit/rotate_globe default right.', + 'Needed for pan/rotate/tilt steps; orbit/rotate_globe default right.', $position: 2, }, mode: { @@ -532,8 +532,7 @@ export const ACTION_DESCRIPTIONS = { $position: 2, }, pitchDeg: { - description: - 'Absolute; steps use direction. -45 oblique (default), -89 top-down.', + description: 'Absolute; -89 top-down.', $position: 1, }, }, diff --git a/src/radioMarkup.test.mjs b/src/radioMarkup.test.mjs index 6e72d6318..3c59d989d 100644 --- a/src/radioMarkup.test.mjs +++ b/src/radioMarkup.test.mjs @@ -232,14 +232,14 @@ 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 + // Tilt to an angle adds an absolute pitchDeg and says tilt steps need a // direction; check them, then restore the legacy fields for the pin. assert.equal( camera.parameters.properties.direction.description, - 'Required for pan/rotate and a tilt nudge; orbit/rotate_globe default right.', + 'Needed for pan/rotate/tilt steps; orbit/rotate_globe default right.', ); assert.equal(camera.parameters.properties.pitchDeg.type, 'number'); - assert.match(camera.parameters.properties.pitchDeg.description, /-45 oblique/); + assert.match(camera.parameters.properties.pitchDeg.description, /^Absolute;/); 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.'; diff --git a/src/voice/actionSchemas.test.mjs b/src/voice/actionSchemas.test.mjs index 13f593204..fa9d40bec 100644 --- a/src/voice/actionSchemas.test.mjs +++ b/src/voice/actionSchemas.test.mjs @@ -58,7 +58,7 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea // control_cockpit states the own-layer entry rule (Contacts not required). // move_camera tilt takes an optional pitchDeg angle, described as // absolute, with steps sent as a direction. - '8698aee3cf691351f5c5a68e115a5913ec0f048067ecdf34b252a31afeeec737', + '17e54c1a04054edf48f2eddcf88c60ac66ec7c885fb48596b778bfee0be8a544', ); }); diff --git a/src/voice/pointAndAsk.test.mjs b/src/voice/pointAndAsk.test.mjs index 1f1993a07..c413477b8 100644 --- a/src/voice/pointAndAsk.test.mjs +++ b/src/voice/pointAndAsk.test.mjs @@ -350,9 +350,10 @@ test('deixis prompt lint: one short rule, and every sentinel it names exists in // the analyst area scopes) raised the ceiling to 55,500; tightening the // AREAS rule and the area tool descriptions left it at 55,103 as measured // below (every tool, osm_query included); main's Sea Marks and Ski Pistes - // layers bring it to 55,210, and the tilt pitchDeg argument to 55,335. - // integrationContract's Overpass prompt is the tightest, at 55,495 (5 - // under). A new tool or rule has to fit under this ceiling. + // layers bring it to 55,210, and the tilt pitchDeg argument with main's + // cockpit entry rule to 55,337. integrationContract's Overpass prompt is + // the tightest, at 55,497 (3 under). A new tool or rule has to fit under + // this ceiling. const size = instructions.length + JSON.stringify(GEV_REALTIME_TOOLS).length; assert.ok(size <= 55_500, `prompt size ${size}`); }); From c4910d24d03713056affaad92dd67f72606daa98 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 5 Nov 2026 15:43:51 -0800 Subject: [PATCH 135/156] Keep the place centred when a far tilt reaches the horizon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 550 to 1,000 km of range the camera's -5° limit lies past the place's own horizon. The swing ran into its 150 m clearance, let go of the pivot and tilted the rest in place, which lifted the crosshair onto the sky (28 to 63 km up from 600 to 900 km), and a return to top-down then landed hundreds of km from the place. The swing now hands over to an in-place tilt only where the clearance is what stops it (within about 17 km). Farther out it stops with the camera 0.5° above the place's horizon, still pivoting: a tilt to an angle reports the angle it reached with a note, a continuous tilt stops there, and a further tilt up is refused as at the limit. Tests check that the crosshair stays on the place, frame by frame for a continuous tilt, and that top-down returns over it. --- docs/CURRENT-STATE.md | 2 +- src/cameraTilt.test.mjs | 61 ++++++++++++++++++- src/cameraVerbs.js | 132 +++++++++++++++++++++++++++++++++------- 3 files changed, 169 insertions(+), 26 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 4b322618c..3166ab976 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5590,7 +5590,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. Angles and limits are the camera's own pitch; at regional range the pivot's pitch is up to about 8° shallower (the Earth curves away), so the swing solves for the pivot pitch that gives the camera the angle asked for. 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 cap: 1,000 km for a tilt, where pivoting stops, and 5,000 km for an orbit) 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. An angle the camera already holds answers `unchanged` but still takes navigation, so it stops a running motion and releases the follow camera. A tilt left or right 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 (moving to a later fly-to in the same reply as soon as it begins, whatever the earlier one reports), 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: it runs at once, and the fly-to's result names the motion it stopped (`stoppedMotion`). 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. Angles and limits are the camera's own pitch; at regional range the pivot's pitch is up to about 8° shallower (the Earth curves away), so the swing solves for the pivot pitch that gives the camera the angle asked for. 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. Beyond about 17 km of range the swing never hands over to an in-place tilt (that would lift the crosshair onto the sky); it keeps the camera at least 0.5° above the place's own horizon, which from roughly 500 km stops it short of -5° with the place still centred: a tilt to an angle beyond that stops at the angle reached and says so (`pitchDeg` is the angle reached, with a `note`), a continuous tilt stops there, and a further tilt up is refused as at the limit. 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 cap: 1,000 km for a tilt, where pivoting stops, and 5,000 km for an orbit) 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. An angle the camera already holds answers `unchanged` but still takes navigation, so it stops a running motion and releases the follow camera. A tilt left or right 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 (moving to a later fly-to in the same reply as soon as it begins, whatever the earlier one reports), 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: it runs at once, and the fly-to's result names the motion it stopped (`stoppedMotion`). 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 a82b2ce37..a67ecee86 100644 --- a/src/cameraTilt.test.mjs +++ b/src/cameraTilt.test.mjs @@ -460,10 +460,10 @@ test('from globe range a tilt happens in place', (t) => { assert.ok(Cesium.Cartesian3.distance(h.camera.positionWC, before) < 1e-3); }); -for (const height of [100_000, 400_000, 900_000]) +for (const height of [100_000, 400_000]) test(`from ${height / 1000} km a tilt to -5° ends with the camera at -5°`, (t) => { - // The pivot pitch is shallower than the camera's by up to 8° at this - // range; the camera reached only -5.9° to -12.9° when the swing stopped + // The pivot pitch is shallower than the camera's by up to 4° at this + // range; the camera reached only -5.9° and -8.6° when the swing stopped // at a pivot pitch of -5°. const h = harness(t, { height }); const result = moveCamera({ motion: 'tilt', pitchDeg: 5 }); @@ -479,6 +479,61 @@ for (const height of [100_000, 400_000, 900_000]) assert.match(nudge.error, /upper tilt limit/); }); +// From 550 km out the camera's -5° lies beyond the place's own horizon: the +// swing ran into its clearance, let go of the pivot and tilted the rest in +// place, which put the crosshair on the sky (28 to 63 km up), and a return +// to top-down then landed 600 to 900 km from the place. +for (const height of [600_000, 700_000, 900_000]) { + test(`from ${height / 1000} km a tilt to -5° keeps the place centred`, (t) => { + const h = harness(t, { height }); + const result = moveCamera({ motion: 'tilt', pitchDeg: 5 }); + assert.equal(result.ok, true); + h.finish(); + assert.ok(h.viewTarget(), 'the crosshair is on the sky'); + assert.ok(centreDrift(h) < 100, `centre drifted ${centreDrift(h)} m`); + assert.ok(heightM(h.camera) > 149, `camera at ${heightM(h.camera)} m`); + // The reply names the angle the camera reached, not the one asked for. + assert.ok(result.pitchDeg < -5, `reported ${result.pitchDeg}°`); + assert.ok( + Math.abs(pitchDeg(h.camera) - result.pitchDeg) < 0.5, + `camera at ${pitchDeg(h.camera)}°, reported ${result.pitchDeg}°`, + ); + assert.match(result.note, /horizon/); + const nudge = moveCamera({ motion: 'tilt', direction: 'up' }); + assert.equal(nudge.ok, false); + assert.match(nudge.error, /upper tilt limit/); + assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -89 }).ok, true); + h.finish(); + assert.ok(centreDrift(h) < 100, `centre drifted ${centreDrift(h)} m`); + // Back over the place at the range it began with (-89° is ~1° off nadir). + const range = Cesium.Cartesian3.distance(h.camera.positionWC, h.target); + assert.ok( + Math.abs(range - height) < height * 0.01, + `top-down ${range} m from the place`, + ); + }); + + test(`a continuous tilt up from ${height / 1000} km stops with the place centred`, (t) => { + const h = harness(t, { height }); + moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' }); + for (let i = 0; i < 1200 && getActiveCameraMotion(); i += 1) { + h.tick(); + assert.ok(h.viewTarget(), `the crosshair is on the sky at frame ${i}`); + } + assert.equal(getActiveCameraMotion(), null, 'tilt must stop at its limit'); + assert.ok(centreDrift(h) < 100, `centre drifted ${centreDrift(h)} m`); + }); +} + +test('a pitchDeg tilt toward the horizon limit says it is already there', (t) => { + const h = harness(t, { height: 900_000 }); + moveCamera({ motion: 'tilt', pitchDeg: 5 }); + h.finish(); + const again = moveCamera({ motion: 'tilt', pitchDeg: 5 }); + assert.equal(again.ok, false); + assert.match(again.error, /upper tilt limit/); +}); + test('a regional tilt-up nudge goes to the camera horizon limit', (t) => { // 20 km up at -7°: the pivot 186 km out sits at -5.3°, but the camera can // still tilt up 2°. diff --git a/src/cameraVerbs.js b/src/cameraVerbs.js index 70b83eff5..4618a434d 100644 --- a/src/cameraVerbs.js +++ b/src/cameraVerbs.js @@ -62,6 +62,13 @@ const TILT_MIN_CLEARANCE_M = 150; * crosshair point, so the tilt happens in place instead. */ const TILT_PIVOT_MAX_RANGE_M = 1_000_000; +/** + * The shallowest a pivot swing goes when its clearance is not what stops it. + * From several hundred km the camera's own horizon limit lies past the + * place's horizon; tilting the rest in place there would lift the crosshair + * onto the sky, so the swing stops with the place this far above it. + */ +const TILT_MIN_PIVOT_ELEVATION = Cesium.Math.toRadians(0.5); /* ── Route dolly: cinematic tuning ────────────────────────────────────────── * Every knob an owner may want to retune lives in this block. The shaping is @@ -1273,7 +1280,10 @@ function onTick() { * 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. * A pivot swing toward the horizon lowers the camera, so it stops at - * TILT_MIN_CLEARANCE_M and the rest tilts in place. + * TILT_MIN_CLEARANCE_M and the rest tilts in place. Farther out it stops with + * the camera TILT_MIN_PIVOT_ELEVATION above the place's horizon instead, and + * keeps the place centred: tilting in place there would lift the crosshair + * onto the sky. */ function tickTilt(cam, m, scale, dt) { const from = cam.pitch; @@ -1288,11 +1298,13 @@ function tickTilt(cam, m, scale, dt) { const limited = up ? Math.min(want, Math.max(from, PITCH_MAX)) : Math.max(want, Math.min(from, PITCH_MIN)); + let atLimit = false; if (m.hpr) { // Apply a range a zoom set since the last frame, even when the pitch // then has nowhere to go (a zoom in to the clearance ends the pivot). cam.lookAt(m.target, m.hpr); - // Up toward the horizon the swing lowers the camera: hold the clearance. + // Up toward the horizon the swing lowers the camera: hold the clearance, + // and keep the place below the horizon. const cap = up ? Math.max(m.hpr.pitch, tiltPivotPitchMax(m.hpr.range)) : Infinity; @@ -1309,21 +1321,77 @@ function tickTilt(cam, m, scale, dt) { // this last step re-took it. if (_active !== m) clearLookAt(); if (up && m.hpr.pitch >= cap && cam.pitch < limited - 1e-6) { - // 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; + if (tiltClearanceBinds(m.hpr.range)) { + // 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; + } else { + // The place is at its horizon limit: stop here, still centred. + atLimit = true; + } } } if (!m.hpr) cam.lookUp(limited - cam.pitch); - if (m.mode !== 'once' && limited !== want) + if (m.mode !== 'once' && (limited !== want || atLimit)) interruptCameraMotion('tilt-clamp'); } +/** The refusal for a tilt up the camera has no room left for. */ +function tiltUpLimitRefusal() { + return { + ok: false, + action: 'move_camera', + error: + 'Already at the upper tilt limit (near the horizon) — tilt down, or zoom/pan instead.', + }; +} + /** Shallowest pivot pitch that keeps the clearance at this range. */ function tiltPivotPitchMax(range) { - return -Math.asin(Math.min(1, TILT_MIN_CLEARANCE_M / range)); + return Math.min( + -Math.asin(Math.min(1, TILT_MIN_CLEARANCE_M / range)), + -TILT_MIN_PIVOT_ELEVATION, + ); +} + +/** + * Whether the clearance, not the horizon, stops a swing at this range. Only + * then does the rest of the tilt happen in place: that close in (under ~17 + * km) the camera's -5° limit stays well below its own horizon. + */ +function tiltClearanceBinds(range) { + return TILT_MIN_CLEARANCE_M / range >= Math.sin(TILT_MIN_PIVOT_ELEVATION); +} + +/** + * The shallowest camera pitch a tilt starting from this frame can reach with + * the place under the crosshair still in view: PITCH_MAX, or less where a + * far pivot meets its horizon limit first. + */ +function tiltUpLimit({ target, pivot }) { + if (!pivot || tiltClearanceBinds(pivot.range)) return PITCH_MAX; + const pitch = Math.max(pivot.pitch, tiltPivotPitchMax(pivot.range)); + // The camera's position for that pivot pitch, as lookAt places it. + const offset = new Cesium.Cartesian3( + -pivot.range * Math.cos(pitch) * Math.sin(pivot.heading), + -pivot.range * Math.cos(pitch) * Math.cos(pivot.heading), + -pivot.range * Math.sin(pitch), + ); + const eye = Cesium.Matrix4.multiplyByPoint( + Cesium.Transforms.eastNorthUpToFixedFrame(target), + offset, + new Cesium.Cartesian3(), + ); + const look = Cesium.Cartesian3.subtract(target, eye, new Cesium.Cartesian3()); + Cesium.Cartesian3.normalize(look, look); + const ellipsoid = _viewer.scene.globe?.ellipsoid ?? Cesium.Ellipsoid.WGS84; + const upAtEye = ellipsoid.geodeticSurfaceNormal(eye, new Cesium.Cartesian3()); + return Math.min( + PITCH_MAX, + Math.asin(Cesium.Math.clamp(Cesium.Cartesian3.dot(look, upAtEye), -1, 1)), + ); } /** @@ -1710,17 +1778,22 @@ export function moveCamera(args = {}, runNavigation = null) { 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.`, }; } + if ( + towards === 'up' && + from >= + tiltUpLimit(tiltStartFrame(_viewer.camera)) - Cesium.Math.toRadians(0.5) + ) + return tiltUpLimitRefusal(); } 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; - if (direction === 'up' && pitch >= PITCH_MAX - Cesium.Math.toRadians(0.5)) { - return { - ok: false, - action: 'move_camera', - error: - 'Already at the upper tilt limit (near the horizon) — tilt down, or zoom/pan instead.', - }; + if ( + direction === 'up' && + pitch >= + tiltUpLimit(tiltStartFrame(_viewer.camera)) - Cesium.Math.toRadians(0.5) + ) { + return tiltUpLimitRefusal(); } if ( direction === 'down' && @@ -1816,6 +1889,7 @@ export function moveCamera(args = {}, runNavigation = null) { error: 'Globe rotation requires a clear ellipsoid bounding sphere.', }; } + let horizonLimited = null; const start = () => { interruptCameraMotion('replaced'); const state = { @@ -1825,27 +1899,36 @@ export function moveCamera(args = {}, runNavigation = null) { mode, }; if (motion === 'tilt') { - const { target, pivot, from } = tiltStartFrame(_viewer.camera); + const frame = tiltStartFrame(_viewer.camera); + const { target, pivot, from } = frame; if (pivot) { state.target = target; state.hpr = pivot; } state.startPitch = from; + const upLimit = tiltUpLimit(frame); if (tiltToPitch !== null) { + // From far out the place reaches its horizon before the camera + // reaches the asked angle: stop there, and say so. + const goal = + tiltToPitch > from + ? Math.min(tiltToPitch, Math.max(from, upLimit)) + : tiltToPitch; state.mode = 'once'; - state.direction = tiltToPitch > from ? 'up' : 'down'; - state.goalPitch = tiltToPitch; + state.direction = goal > from ? 'up' : 'down'; + state.goalPitch = goal; state.durationS = Math.max( ONCE.durationS, - Math.abs(Cesium.Math.toDegrees(tiltToPitch - from)) / TILT_TO_DEG_S, + Math.abs(Cesium.Math.toDegrees(goal - from)) / TILT_TO_DEG_S, ); + if (goal !== tiltToPitch) horizonLimited = goal; } else { // A nudge stops at the limit it travels toward. const step = Cesium.Math.toRadians(ONCE.tiltDeg) * (direction === 'up' ? 1 : -1); state.goalPitch = step > 0 - ? Math.min(from + step, Math.max(from, PITCH_MAX)) + ? Math.min(from + step, Math.max(from, upLimit)) : Math.max(from + step, Math.min(from, PITCH_MIN)); } } @@ -1937,7 +2020,12 @@ export function moveCamera(args = {}, runNavigation = null) { speed, mode: state.mode, ...(tiltToPitch !== null && { - pitchDeg: Math.round(Cesium.Math.toDegrees(tiltToPitch)), + pitchDeg: Math.round( + Cesium.Math.toDegrees(horizonLimited ?? tiltToPitch), + ), + }), + ...(horizonLimited !== null && { + note: `Stopped at ${Math.round(Cesium.Math.toDegrees(horizonLimited))}°: from this range a shallower angle puts the place past the horizon. Zoom in to tilt further.`, }), }; }; From 1eef14454ae7bb2b3868578978897c00ed6383a3 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 5 Nov 2026 15:48:21 -0800 Subject: [PATCH 136/156] Drop a queued voice motion when another navigation takes the camera mid-flight A motion queued behind a voice fly-to checked only that its reply was current and that the user had not touched the map. A location-panel search, share link or file import stamps a deferred navigation without stopping the flight, so the flight landed, the motion took navigation, and the search's reassert then failed: the search was silently dropped while the camera kept orbiting. The runner now counts camera handoffs from subscribeCameraHandoff, less the ones voice fly-tos make themselves, and each voice flight records that count when it starts. A motion whose flight saw another navigation since then is not run and says so. Voice fly-tos' own stamps do not count, so a later fly-to in the same reply, including a lookup that finds nothing, is still followed as before. The test harness now publishes handoffs and stamps its voice lookups, as the shell does. --- docs/CURRENT-STATE.md | 2 +- src/voice/gevActions.js | 26 ++++++++++ src/voice/gevActions.test.mjs | 91 +++++++++++++++++++++++++++++++++-- 3 files changed, 113 insertions(+), 6 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 3166ab976..2f269ee48 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5590,7 +5590,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. Angles and limits are the camera's own pitch; at regional range the pivot's pitch is up to about 8° shallower (the Earth curves away), so the swing solves for the pivot pitch that gives the camera the angle asked for. 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. Beyond about 17 km of range the swing never hands over to an in-place tilt (that would lift the crosshair onto the sky); it keeps the camera at least 0.5° above the place's own horizon, which from roughly 500 km stops it short of -5° with the place still centred: a tilt to an angle beyond that stops at the angle reached and says so (`pitchDeg` is the angle reached, with a `note`), a continuous tilt stops there, and a further tilt up is refused as at the limit. 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 cap: 1,000 km for a tilt, where pivoting stops, and 5,000 km for an orbit) 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. An angle the camera already holds answers `unchanged` but still takes navigation, so it stops a running motion and releases the follow camera. A tilt left or right 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 (moving to a later fly-to in the same reply as soon as it begins, whatever the earlier one reports), 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: it runs at once, and the fly-to's result names the motion it stopped (`stoppedMotion`). 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. Angles and limits are the camera's own pitch; at regional range the pivot's pitch is up to about 8° shallower (the Earth curves away), so the swing solves for the pivot pitch that gives the camera the angle asked for. 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. Beyond about 17 km of range the swing never hands over to an in-place tilt (that would lift the crosshair onto the sky); it keeps the camera at least 0.5° above the place's own horizon, which from roughly 500 km stops it short of -5° with the place still centred: a tilt to an angle beyond that stops at the angle reached and says so (`pitchDeg` is the angle reached, with a `note`), a continuous tilt stops there, and a further tilt up is refused as at the limit. 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 cap: 1,000 km for a tilt, where pivoting stops, and 5,000 km for an orbit) 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. An angle the camera already holds answers `unchanged` but still takes navigation, so it stops a running motion and releases the follow camera. A tilt left or right 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 (moving to a later fly-to in the same reply as soon as it begins, whatever the earlier one reports), so it neither cancels the flight nor gets replaced by it; if the flight is cancelled, finds nothing or times out, the user grabs, touches or scrolls the map during it, or another navigation (a location-panel search, share link or file import, which leave the flight flying) takes the camera 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: it runs at once, and the fly-to's result names the motion it stopped (`stoppedMotion`). 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/voice/gevActions.js b/src/voice/gevActions.js index 5a30a8cf3..045969a4f 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -296,6 +296,16 @@ export function createGevActionRunner({ let voiceFlight = null; // Waiters to tell when a newer voice flight begins. const voiceFlightWatchers = new Set(); + // Camera handoffs (navigation stamps) seen, and how many of them voice + // fly-tos made. A search, share link or import that stamps during a flight + // leaves it flying; the motion queued behind it then yields to that one. + let cameraHandoffs = 0; + let voiceFlightHandoffs = 0; + const otherHandoffs = () => cameraHandoffs - voiceFlightHandoffs; + const unsubscribeCameraHandoff = + styleManager?.subscribeCameraHandoff?.(() => { + cameraHandoffs += 1; + }) ?? null; let analystEngine; // Area handles live for this runner (the page): resolve_area, the // analyst's region/area/drawn scopes, find_imagery and osm_query all read @@ -1155,6 +1165,8 @@ export function createGevActionRunner({ prior && !prior.done ? prior.inputCount : undefined, prior, ); + // Navigation by anything but a voice fly-to since this one began. + flight.handoffs = otherHandoffs(); voiceFlight = flight; for (const notify of [...voiceFlightWatchers]) notify(); // A motion already running (one the model sent before this fly-to) is @@ -1164,9 +1176,13 @@ export function createGevActionRunner({ (hook) => (...hookArgs) => { const before = getActiveCameraMotion(); + const handoffsBefore = cameraHandoffs; try { return hook(...hookArgs); } finally { + // A voice fly-to's own stamps are not another navigation, even + // for the flight it replaces (a later lookup may find nothing). + voiceFlightHandoffs += cameraHandoffs - handoffsBefore; if ( before && !stoppedMotion && @@ -1383,6 +1399,15 @@ export function createGevActionRunner({ motion, error: `Not run: the user moved the camera during the flight, so the ${motion} was dropped.`, }; + // A search, share link or import that took navigation during the + // flight leaves it flying; running now would cancel that navigation. + if (otherHandoffs() !== flight.handoffs) + return { + ok: false, + action: 'move_camera', + motion, + error: `Not run: another navigation took the camera during the flight, so the ${motion} was dropped.`, + }; } return moveCamera(args, (navigate, releaseOptions) => runManagedVoiceNavigation( @@ -1712,6 +1737,7 @@ export function createGevActionRunner({ if (disposed) return; runVoiceAction.resetConversation(); disposed = true; + unsubscribeCameraHandoff?.(); voiceAreas.dispose(); }; return runVoiceAction; diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index b2cdbedc4..f94eb554b 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -143,6 +143,14 @@ test('track_entity runner narrates a callsign-less aircraft by its registration' function createVoiceNavigationHarness({ cockpitActive = false } = {}) { const order = []; let generation = 0; + // Camera handoffs, published on every navigation stamp as the shell does. + const handoffListeners = new Set(); + const stamp = (noun) => { + generation += 1; + order.push(`stamp:${noun}`); + for (const listener of [...handoffListeners]) listener({ generation }); + return generation; + }; const position = Cesium.Cartesian3.fromDegrees(-97.74, 30.26, 500); const viewer = { trackedEntity: { id: 'prior-aircraft' }, @@ -179,11 +187,7 @@ function createVoiceNavigationHarness({ cockpitActive = false } = {}) { return runExplicitNavigation({ cockpitActive, noun, - stamp: () => { - generation += 1; - order.push(`stamp:${noun}`); - return generation; - }, + stamp: () => stamp(noun), release: () => { order.push('release'); viewer.trackedEntity = undefined; @@ -195,12 +199,21 @@ function createVoiceNavigationHarness({ cockpitActive = false } = {}) { }); }, getDetectionState: () => ({ detectionMode: 'DENSE' }), + subscribeCameraHandoff(listener) { + handoffListeners.add(listener); + return () => handoffListeners.delete(listener); + }, + // A voice lookup stamps when it begins and leaves any flight flying. + beginDeferredLocationNavigation: () => stamp('location-lookup'), + reassertDeferredLocationNavigation: (stamped) => stamped === generation, }; return { order, viewer, styleManager, currentGeneration: () => generation, + // A location-panel search: a deferred stamp that leaves a flight flying. + panelSearch: () => stamp('panel-search'), }; } @@ -921,6 +934,74 @@ test('a motion follows a newer fly-to that lands while its own lookup stalls', a assert.equal(result.motion, 'orbit'); }); +// "Fly to Paris and orbit it", then a location-panel search while the camera +// flies: the search stamps a deferred navigation without stopping the flight. +// Run where the flight landed, the orbit took navigation and the search's +// reassert failed, so the search was silently dropped. +test('a motion queued behind a fly-to yields to a search started during the flight', async (t) => { + t.after(() => interruptCameraMotion('test-cleanup')); + const { viewer, styleManager, flights, panelSearch, currentGeneration } = + 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' }); + await new Promise((resolve) => setTimeout(resolve, 0)); + const search = panelSearch(); + flights[0].complete(); + const result = await moved; + assert.equal(result.ok, false); + assert.match(result.error, /Not run: another navigation took the camera/); + assert.equal(getActiveCameraMotion(), null); + assert.equal(currentGeneration(), search, 'the search still owns navigation'); +}); + +test('a motion behind a geocoded fly-to yields to a search started while it flies', async (t) => { + t.after(() => interruptCameraMotion('test-cleanup')); + const { viewer, styleManager, panelSearch } = 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 fly = runner('fly_to_location', { query: 'Place A' }); + const moved = runner('move_camera', { motion: 'orbit' }); + await new Promise((resolve) => setTimeout(resolve, 0)); + searches[0].resolve({ label: 'Place A', navigationMode: 'city-overview' }); + assert.equal((await fly).ok, true); + panelSearch(); + searches[0].options.onComplete(); + const result = await moved; + assert.equal(result.ok, false); + assert.match(result.error, /another navigation took the camera/); + assert.equal(getActiveCameraMotion(), null); +}); + +test('a search before the fly-to does not drop the motion queued behind it', async (t) => { + t.after(() => interruptCameraMotion('test-cleanup')); + const { viewer, styleManager, flights, panelSearch } = tiltAfterFlightHarness(); + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + }); + panelSearch(); + assert.equal((await runner('fly_to_location', { locationId: 'austin' })).ok, true); + const moved = runner('move_camera', { motion: 'tilt', pitchDeg: -45 }); + await new Promise((resolve) => setTimeout(resolve, 0)); + flights[0].complete(); + const result = await moved; + assert.equal(result.ok, true); + assert.equal(result.motion, 'tilt'); +}); + test('a fly-to that stops a running motion says so', async (t) => { t.after(() => interruptCameraMotion('test-cleanup')); const { viewer, styleManager } = tiltAfterFlightHarness(); From dae95e1c7f866d07dba57f10c5bd0675378eba0c Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 5 Nov 2026 15:51:14 -0800 Subject: [PATCH 137/156] Refuse a motion after its reply's fly-to found nothing, also on Gemini Gemini runs one turn's calls one after another. By the time "pan left" ran beside "fly to Xyzzyville", the lookup had already found nothing, so there was no flight to wait on and the pan moved the old view with ok: true. OpenAI's parallel calls refused the same reply. The runner now remembers, for the latest reply that sent a voice fly-to, whether one moved the camera and whether one found nothing. A tilt, orbit, pan or rotate of that reply with no flight left to wait on is refused when its fly-to found nothing and none moved. The reply is Gemini's turn, passed as a per-turn reply object, or OpenAI's authorityGroup. A motion in a later reply, or beside a fly-to of the same reply that did land, runs as before. --- docs/CURRENT-STATE.md | 2 +- src/voice/geminiSession.js | 9 ++++ src/voice/geminiSession.test.mjs | 28 ++++++++++++ src/voice/gevActions.js | 41 ++++++++++++++++++ src/voice/gevActions.test.mjs | 74 ++++++++++++++++++++++++++++++++ 5 files changed, 153 insertions(+), 1 deletion(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 2f269ee48..9036db387 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5590,7 +5590,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. Angles and limits are the camera's own pitch; at regional range the pivot's pitch is up to about 8° shallower (the Earth curves away), so the swing solves for the pivot pitch that gives the camera the angle asked for. 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. Beyond about 17 km of range the swing never hands over to an in-place tilt (that would lift the crosshair onto the sky); it keeps the camera at least 0.5° above the place's own horizon, which from roughly 500 km stops it short of -5° with the place still centred: a tilt to an angle beyond that stops at the angle reached and says so (`pitchDeg` is the angle reached, with a `note`), a continuous tilt stops there, and a further tilt up is refused as at the limit. 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 cap: 1,000 km for a tilt, where pivoting stops, and 5,000 km for an orbit) 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. An angle the camera already holds answers `unchanged` but still takes navigation, so it stops a running motion and releases the follow camera. A tilt left or right 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 (moving to a later fly-to in the same reply as soon as it begins, whatever the earlier one reports), so it neither cancels the flight nor gets replaced by it; if the flight is cancelled, finds nothing or times out, the user grabs, touches or scrolls the map during it, or another navigation (a location-panel search, share link or file import, which leave the flight flying) takes the camera 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: it runs at once, and the fly-to's result names the motion it stopped (`stoppedMotion`). 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. Angles and limits are the camera's own pitch; at regional range the pivot's pitch is up to about 8° shallower (the Earth curves away), so the swing solves for the pivot pitch that gives the camera the angle asked for. 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. Beyond about 17 km of range the swing never hands over to an in-place tilt (that would lift the crosshair onto the sky); it keeps the camera at least 0.5° above the place's own horizon, which from roughly 500 km stops it short of -5° with the place still centred: a tilt to an angle beyond that stops at the angle reached and says so (`pitchDeg` is the angle reached, with a `note`), a continuous tilt stops there, and a further tilt up is refused as at the limit. 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 cap: 1,000 km for a tilt, where pivoting stops, and 5,000 km for an orbit) 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. An angle the camera already holds answers `unchanged` but still takes navigation, so it stops a running motion and releases the follow camera. A tilt left or right 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 (moving to a later fly-to in the same reply as soon as it begins, whatever the earlier one reports), so it neither cancels the flight nor gets replaced by it; if the flight is cancelled, finds nothing or times out, the user grabs, touches or scrolls the map during it, or another navigation (a location-panel search, share link or file import, which leave the flight flying) takes the camera during it, the motion is not run and says so. A motion that runs after a fly-to of the same reply has already found nothing (Gemini runs one turn's calls one by one, and passes its turn as the reply) is refused the same way rather than moving the old view; a motion in a later reply runs. `stop` and `rotate_globe` do not wait, and a motion sent before its fly-to is not reordered: it runs at once, and the fly-to's result names the motion it stopped (`stoppedMotion`). 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/voice/geminiSession.js b/src/voice/geminiSession.js index da93b4bb6..8996774b3 100644 --- a/src/voice/geminiSession.js +++ b/src/voice/geminiSession.js @@ -184,6 +184,9 @@ export class GeminiSessionController { this.model = null; this.epoch = 0; this.turn = 0; + // Identifies one turn's tool calls to the runner as one reply; a new + // object each turn, so no two sessions or turns share it. + this.turnReply = {}; this.globeToolsBlocked = false; this.globeResetFailed = false; this.inputGeneration = 0; @@ -971,6 +974,7 @@ export class GeminiSessionController { const tools = [...this.tools.values()]; this.turnAbort = new AbortController(); this.turn++; + this.turnReply = {}; this.typedIntent = null; this.radioStrongerCommit = null; this.pruneControls(); @@ -1544,6 +1548,7 @@ export class GeminiSessionController { ) throw new Error(msg('Gemini requested too many simultaneous actions')); const turn = this.turn; + const reply = this.turnReply; this.userTurnPending = false; this.modelComplete = false; // Admit each valid native call ID once per connection. @@ -1565,6 +1570,7 @@ export class GeminiSessionController { const action = new AbortController(); action.call = { id: call.id, name: call.name }; action.sequence = ++this.toolSequence; + action.reply = reply; this.tools.set(call.id, action); const priorTools = this.toolQueue; const args = @@ -1674,6 +1680,9 @@ export class GeminiSessionController { this.runAction(call.name, args, { signal: toolSignal, callId: call.id, + // Calls run one by one, so a motion can follow a fly-to of the + // same reply that already found nothing. + reply: action.reply, progress: (update) => { if (actionCurrent()) this.narration.progress(call.id, update); }, diff --git a/src/voice/geminiSession.test.mjs b/src/voice/geminiSession.test.mjs index 9ce8d351a..82f403ef7 100644 --- a/src/voice/geminiSession.test.mjs +++ b/src/voice/geminiSession.test.mjs @@ -1241,6 +1241,34 @@ test('successful stronger Radio stop still invalidates the prepared group', asyn h.c.stop(); }); +test('Gemini tools of one turn share a reply identity, and a new turn gets another', async () => { + const replies = []; + const h = harness({ + runAction: (name, _args, next) => { + replies.push([name, next.reply]); + return { ok: true, action: name }; + }, + }); + await h.c.start({ pushToTalk: true }); + h.c.sendTextCommand('Fly to Xyzzyville and pan left'); + const ws = h.sockets[0]; + requestTool(ws, 'fly', 'fly_to_location', { query: 'Xyzzyville' }); + requestTool(ws, 'pan', 'move_camera', { motion: 'pan', direction: 'left' }); + await h.c.toolQueue; + assert.equal(replies.length, 2); + assert.ok(replies[0][1], 'a sequentially run call carries its reply'); + assert.equal(replies[1][1], replies[0][1]); + drainRadioReply(h); + await tick(); + assert.equal(h.c.sendTextCommand('Pan left'), true); + requestTool(ws, 'pan-2', 'move_camera', { motion: 'pan', direction: 'left' }); + await h.c.toolQueue; + assert.equal(replies.length, 3); + assert.ok(replies[2][1]); + assert.notEqual(replies[2][1], replies[0][1]); + h.c.stop(); +}); + test('slow Gemini tools reuse shared progress timing without injecting a conversation turn', async () => { const pending = deferred(); let options; diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index 045969a4f..a01e3328e 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -299,6 +299,11 @@ export function createGevActionRunner({ // Camera handoffs (navigation stamps) seen, and how many of them voice // fly-tos made. A search, share link or import that stamps during a flight // leaves it flying; the motion queued behind it then yields to that one. + // The voice fly-tos of the latest reply that sent one: whether any moved + // the camera, and whether one found nothing. A motion of that reply run + // after its fly-to found nothing (Gemini runs a reply's calls one by one) + // has no flight to wait on, and must not move the old view instead. + let replyFlights = null; let cameraHandoffs = 0; let voiceFlightHandoffs = 0; const otherHandoffs = () => cameraHandoffs - voiceFlightHandoffs; @@ -1168,6 +1173,10 @@ export function createGevActionRunner({ // Navigation by anything but a voice fly-to since this one began. flight.handoffs = otherHandoffs(); voiceFlight = flight; + const reply = voiceReplyOf(runOptions); + if (reply !== null && replyFlights?.reply !== reply) + replyFlights = { reply, moved: false, foundNothing: false }; + const replyRecord = reply !== null ? replyFlights : null; for (const notify of [...voiceFlightWatchers]) notify(); // A motion already running (one the model sent before this fly-to) is // stopped when the flight takes navigation; the result says so. @@ -1239,6 +1248,13 @@ export function createGevActionRunner({ flight.settle('failed'); else notStarted(); }, notStarted); + // After the outcome above, which marks a lookup that moved nothing. + const noteReply = (result) => { + if (!replyRecord) return; + if (result?.ok === true) replyRecord.moved = true; + else if (flight.movedNothing) replyRecord.foundNothing = true; + }; + flown.then(noteReply, () => noteReply(null)); return flown.then((result) => stoppedMotion && result && typeof result === 'object' ? { ...result, stoppedMotion } @@ -1349,6 +1365,21 @@ export function createGevActionRunner({ // 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); + const reply = voiceReplyOf(runOptions); + if ( + VOICE_FLIGHT_WAITING_MOTIONS.has(motion) && + !(pending && !pending.done) && + reply !== null && + replyFlights?.reply === reply && + replyFlights.foundNothing && + !replyFlights.moved + ) + return { + ok: false, + action: 'move_camera', + motion, + error: `Not run: the fly-to in this reply found nothing, so the ${motion} was not run at the old view.`, + }; if ( VOICE_FLIGHT_WAITING_MOTIONS.has(motion) && pending && @@ -1724,6 +1755,7 @@ export function createGevActionRunner({ * forget the analyst's "last answer". Area handles stay (page lifetime). */ runVoiceAction.resetConversation = () => { + replyFlights = null; conversationAbort.abort(); conversationAbort = new AbortController(); conversation++; @@ -3012,6 +3044,15 @@ async function frameOverhead(viewer, dataManager, styleManager, args = {}) { let voiceFlightCount = 0; +/** + * The model reply a voice call belongs to: Gemini's turn (`reply`), or the + * OpenAI response its sibling calls share (`authorityGroup`). Null when the + * caller names none. + */ +function voiceReplyOf(options) { + return options?.reply ?? options?.authorityGroup ?? null; +} + /** * A voice flight's end: 'arrived', 'cancelled' or 'failed', settled once. * `replaced` is the last flight that moved when this one began (past lookups diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index f94eb554b..f4be07148 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -938,6 +938,80 @@ test('a motion follows a newer fly-to that lands while its own lookup stalls', a // flies: the search stamps a deferred navigation without stopping the flight. // Run where the flight landed, the orbit took navigation and the search's // reassert failed, so the search was silently dropped. +// Gemini runs one reply's calls one after another: by the time "pan left" +// runs, "fly to Xyzzyville" has already found nothing, so there is no flight +// to wait on, and the pan moved the old view with ok: true. +test('a motion run after its reply\'s fly-to found nothing is not run', async (t) => { + t.after(() => interruptCameraMotion('test-cleanup')); + const { viewer, styleManager } = tiltAfterFlightHarness(); + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + searchNavigation: async () => null, + }); + const reply = { turn: 1 }; + const fly = await runner('fly_to_location', { query: 'Xyzzyville' }, { reply }); + assert.equal(fly.ok, false); + const pan = await runner( + 'move_camera', + { motion: 'pan', direction: 'left' }, + { reply }, + ); + assert.equal(pan.ok, false); + assert.match(pan.error, /Not run: the fly-to in this reply found nothing/); + assert.equal(getActiveCameraMotion(), null); + // The same holds for OpenAI's reply identity. + const group = 'response-1'; + await runner('fly_to_location', { query: 'Xyzzyville' }, { authorityGroup: group }); + const tilt = await runner( + 'move_camera', + { motion: 'tilt', pitchDeg: -45 }, + { authorityGroup: group }, + ); + assert.equal(tilt.ok, false); + assert.match(tilt.error, /found nothing/); +}); + +test('a motion in a later reply runs after a fly-to that found nothing', async (t) => { + t.after(() => interruptCameraMotion('test-cleanup')); + const { viewer, styleManager } = tiltAfterFlightHarness(); + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + searchNavigation: async () => null, + }); + await runner('fly_to_location', { query: 'Xyzzyville' }, { reply: { turn: 1 } }); + const pan = await runner( + 'move_camera', + { motion: 'pan', direction: 'left' }, + { reply: { turn: 2 } }, + ); + assert.equal(pan.ok, true, pan.error); +}); + +test('a motion runs at a flight of its reply that landed after another lookup found nothing', async (t) => { + t.after(() => interruptCameraMotion('test-cleanup')); + const { viewer, styleManager, flights } = tiltAfterFlightHarness(); + const runner = createGevActionRunner({ + viewer, + styleManager, + dataManager: { layers: new Map(), getAll: () => [] }, + searchNavigation: async () => null, + }); + const reply = { turn: 1 }; + await runner('fly_to_location', { query: 'Xyzzyville' }, { reply }); + assert.equal((await runner('fly_to_location', { locationId: 'austin' }, { reply })).ok, true); + flights[0].complete(); + const pan = await runner( + 'move_camera', + { motion: 'pan', direction: 'left' }, + { reply }, + ); + assert.equal(pan.ok, true, pan.error); +}); + test('a motion queued behind a fly-to yields to a search started during the flight', async (t) => { t.after(() => interruptCameraMotion('test-cleanup')); const { viewer, styleManager, flights, panelSearch, currentGeneration } = From a20076fc5ef8976b1007170a05a3fc91765c48d3 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 5 Nov 2026 15:55:52 -0800 Subject: [PATCH 138/156] fix(camera): judge a voice tilt's frame limit by the way its pitch moves The pre-check picked the in-place look from args.direction alone, while the tilt itself turns down for any direction but up. A tilt sent as left or right at the frame limit was checked as a turn left or right, refused as losing the globe, though it turns down toward the planet. A rotate sent as up or down was checked as a look up or down, though it turns right. A tilt's look now comes from its goal pitch against the current pitch (tiltLook), and the tick tilts in place through tiltInPlaceWithinFrame(cam, m, goalPitch). Neither depends on the direction word, so a tilt to an absolute angle, or a tilt with no direction, can use them as they are. --- src/cameraGlobeRotation.test.mjs | 20 ++++++++++++++++ src/cameraVerbs.js | 40 +++++++++++++++++++++++++------- 2 files changed, 51 insertions(+), 9 deletions(-) diff --git a/src/cameraGlobeRotation.test.mjs b/src/cameraGlobeRotation.test.mjs index 543c01bff..09fc93f00 100644 --- a/src/cameraGlobeRotation.test.mjs +++ b/src/cameraGlobeRotation.test.mjs @@ -1426,6 +1426,26 @@ test('voice tilt from orbit stops with the planet still on screen and then says assert.ok(camera.pitch > radians(-68.5) && camera.pitch < radians(-66)); }); +// The frame limit judges a tilt by the way its pitch moves, not by the +// direction word: a tilt sent as left or right turns down. +for (const direction of ['left', 'right']) { + test(`a tilt ${direction} from the frame limit is judged as the look down it is`, (t) => { + withScope(t, false); + const { camera, finishOnce } = harness(t, { + pitch: -80, + viewportWidth: 1600, + viewportHeight: 900, + }); + assert.equal(moveCamera({ motion: 'tilt', direction: 'up' }).ok, true); + finishOnce(); + const atLimit = camera.pitch; + const result = moveCamera({ motion: 'tilt', direction }); + assert.equal(result.ok, true, result.error); + finishOnce(); + assert.ok(camera.pitch < atLimit - radians(5), 'the tilt turned down'); + }); +} + 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 }); diff --git a/src/cameraVerbs.js b/src/cameraVerbs.js index 3c8424b82..9715e24f7 100644 --- a/src/cameraVerbs.js +++ b/src/cameraVerbs.js @@ -1090,6 +1090,23 @@ function lookWithinFrame(cam, m, look, angle) { if (allowed < angle) interruptCameraMotion('globe-out-of-frame'); } +/** + * The in-place look a pitch change from `fromPitch` to `goalPitch` makes: + * 'lookUp', 'lookDown', or null when it does not move. A tilt is judged by + * the way its pitch moves, whatever form or direction word it came in. + */ +function tiltLook(fromPitch, goalPitch) { + if (!Number.isFinite(fromPitch) || !Number.isFinite(goalPitch)) return null; + if (goalPitch > fromPitch) return 'lookUp'; + return goalPitch < fromPitch ? 'lookDown' : null; +} + +/** Tilt `m` in place to `goalPitch`, as far as the frame limit allows. */ +function tiltInPlaceWithinFrame(cam, m, goalPitch) { + const look = tiltLook(cam.pitch, goalPitch); + if (look) lookWithinFrame(cam, m, look, Math.abs(goalPitch - cam.pitch)); +} + /** True when the pre-check proved a half-degree `look` from `cam`'s pose. */ function lookProven(cam, look) { return ( @@ -1358,7 +1375,7 @@ function onTick() { interruptCameraMotion('tilt-clamp'); return; } - lookWithinFrame(cam, m, up ? 'lookUp' : 'lookDown', stepRad); + tiltInPlaceWithinFrame(cam, m, next); } else { lookWithinFrame( cam, @@ -1662,15 +1679,20 @@ export function moveCamera(args = {}, runNavigation = null) { }; } } + // A tilt is judged by the way its pitch will move (any direction other + // than up tilts down); a rotate turns left only when told to, as it runs. const look = - motion === 'tilt' || motion === 'rotate' - ? { - up: 'lookUp', - down: 'lookDown', - left: 'lookLeft', - right: 'lookRight', - }[direction] - : null; + motion === 'tilt' + ? tiltLook( + _viewer.camera.pitch, + _viewer.camera.pitch + + Cesium.Math.toRadians(ONCE.tiltDeg) * (direction === 'up' ? 1 : -1), + ) + : motion === 'rotate' + ? direction === 'left' + ? 'lookLeft' + : 'lookRight' + : 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 From 44106b2bfbc1634d5ea91175cc530f83c381cae5 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 5 Nov 2026 15:55:52 -0800 Subject: [PATCH 139/156] fix(camera): hold the globe recovery while a pointer is down Holding a drag still for half a second on a view that already showed no surface looked like a camera at rest, so the recovery turned the view down under the user's hand. The recovery now defers while a pointer pressed on the canvas is held, and runs once it is released, as for a flight or a playing scene. --- docs/CURRENT-STATE.md | 5 +++-- src/app/application.test.mjs | 33 +++++++++++++++++++++++++++++++++ src/app/viewer.js | 31 +++++++++++++++++++++++++------ 3 files changed, 61 insertions(+), 8 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 27c31f4ce..545c4efd8 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -103,8 +103,9 @@ 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. 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, a camera flight in -progress, a Director scene while it plays (holds included) and the 2D and -Columbus modes; a check a flight or scene defers runs once it ends. A Director +progress, a Director scene while it plays (holds included), a pointer held +down on the globe (a drag held still) and the 2D and Columbus modes; a check a +flight, scene or held pointer defers runs once it ends. A Director shot loaded or replayed outside a run is not a playing scene. A view that loses its surface without a camera move (a resize, or turning the scope on) is left as it is until the next move. A voice turn requested while the camera is tracked is diff --git a/src/app/application.test.mjs b/src/app/application.test.mjs index da01ff61c..e25b2ce11 100644 --- a/src/app/application.test.mjs +++ b/src/app/application.test.mjs @@ -778,6 +778,39 @@ test('a check a scene or flight defers runs once it ends', () => { } }); +// Holding a drag still for half a second on a view that already showed no +// surface looked like a settled camera, and the view was turned down under +// the user's hand. +test('the recovery waits while a pointer is held on the globe', () => { + const timers = fakeTimers(); + const { camera, scene, viewer } = realCamera({ width: 1225, height: 923 }); + const document = new EventTarget(); + scene.canvas = Object.assign(new EventTarget(), { + clientWidth: 1225, + clientHeight: 923, + ownerDocument: document, + }); + const dispose = installGlobeInViewRecovery(viewer, timers); + aim(camera, reportedStuckView); + scene.canvas.dispatchEvent(new Event('pointerdown')); + camera.moveStart.raiseEvent(); + for (let i = 0; i < 8; i += 1) timers.tick(); + camera.moveEnd.raiseEvent(); + for (let i = 0; i < 8; i += 1) timers.tick(); + assert.equal(globeOutOfFrame(camera), true, 'left alone while held'); + document.dispatchEvent(new Event('pointerup')); + for (let i = 0; i < 3; i += 1) timers.tick(); + assert.ok( + Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6), + 'recovered once the pointer is released', + ); + dispose(); + aim(camera, reportedStuckView); + scene.canvas.dispatchEvent(new Event('pointerdown')); + document.dispatchEvent(new Event('pointerup')); + assert.equal(timers.pending(), 0, 'disposed recovery holds no listeners'); +}); + function fakeTimers() { let next = 1; const pending = new Map(); diff --git a/src/app/viewer.js b/src/app/viewer.js index ef36feb8a..12ef67269 100644 --- a/src/app/viewer.js +++ b/src/app/viewer.js @@ -172,9 +172,10 @@ const SETTLE_GIVE_UP_MS = 5 * 60_000; * catches a saved or shared view that looked away, or any other move that ends * looking away from the planet. * Heading and position are kept. Tracked cameras, locked ones (non-identity - * transform), a camera flight in progress, the 2D and Columbus modes, and any - * time `paused` returns true (an authored scene playing) are left alone; a - * check they defer runs once they end, however long they last. + * transform), a camera flight in progress, a pointer held down on the canvas + * (a drag held still), the 2D and Columbus modes, and any time `paused` + * returns true (an authored scene playing) are left alone; a check they defer + * runs once they end, however long they last. * `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. @@ -205,12 +206,27 @@ export function installGlobeInViewRecovery( if (!scene || !camera?.moveEnd || !camera?.moveStart) throw new TypeError('A complete Cesium viewer is required'); - // Returns false while a flight or a paused scene defers the check, so the - // caller can try again once it ends. + // A pointer held on the globe is a gesture in progress, even when it holds + // still for half a second: the check waits for its release. + const canvas = scene.canvas; + const releaseTarget = canvas?.ownerDocument ?? canvas; + let pointerHeld = false; + const onPointerDown = () => { + pointerHeld = true; + }; + const onPointerUp = () => { + pointerHeld = false; + }; + canvas?.addEventListener?.('pointerdown', onPointerDown); + releaseTarget?.addEventListener?.('pointerup', onPointerUp); + releaseTarget?.addEventListener?.('pointercancel', onPointerUp); + + // Returns false while a flight, a paused scene or a held pointer defers the + // check, so the caller can try again once it ends. const recover = () => { if (!isFreeGlobeCamera(viewer)) return true; // Cesium has no public running-flight flag. - if (camera._currentFlight || paused()) return false; + if (camera._currentFlight || paused() || pointerHeld) return false; const height = camera.positionCartographic?.height; if ( !(height > minHeight) || @@ -294,6 +310,9 @@ export function installGlobeInViewRecovery( stopSettling(); removeMoveEnd(); removeMoveStart(); + canvas?.removeEventListener?.('pointerdown', onPointerDown); + releaseTarget?.removeEventListener?.('pointerup', onPointerUp); + releaseTarget?.removeEventListener?.('pointercancel', onPointerUp); }; } From 82138478d0ff25c57f5f906e494ed0beca723437 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 5 Nov 2026 16:03:52 -0800 Subject: [PATCH 140/156] fix(wildlife): treat a valid empty Acartia snapshot as a fresh success A quiet or fully filtered feed raised invalid_snapshot, so the proxy answered 502 with a 15 s cooldown and re-polled about four times a minute. Only a non-array payload is malformed now; an empty projection caches for the normal ten minutes and clears earlier rows. --- docs/CURRENT-STATE.md | 5 +- server/providers/acartia.js | 4 +- server/providers/acartia.test.mjs | 84 ++++++++++++++++++++++--------- 3 files changed, 68 insertions(+), 25 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1b1b73d2c..b615bb70d 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -123,7 +123,10 @@ known `Retry-After`. Failures keep filters and drawn rows, reporting DEGRADED with retry timing; logs exclude request URLs and upstream exception text. `/api/acartia/sightings` adds Salish Sea marine mammals within 45.5–50.5° N, -126–121° W, cached ten minutes and stale for at most 24 h. WHALES accepts only +126–121° W, cached ten minutes and stale for at most 24 h. A valid empty +snapshot, including one whose rows all fall outside coverage or the species +checks, is a fresh success that clears earlier rows; only a malformed or failed +upstream reply serves stale and starts a retry cooldown. WHALES accepts only recognized cetaceans; other identified marine mammals appear under ALL or MAMMALS. Sensitive iNaturalist locations remain 0.2° privacy cells; Acartia positions are rounded to 0.01° on the server. Acartia comments are never retained. diff --git a/server/providers/acartia.js b/server/providers/acartia.js index e3dafd220..c8f8b90d7 100644 --- a/server/providers/acartia.js +++ b/server/providers/acartia.js @@ -100,7 +100,9 @@ export function acartiaProxy({ signal, ); const rows = normalizeAcartiaSightings(payload, now()); - if (!rows?.length) throw new Error('invalid_snapshot'); + // Only a non-array is malformed. A quiet feed, or one whose rows all fall + // outside coverage or the species checks, is a valid empty snapshot. + if (!rows) throw new Error('invalid_snapshot'); return { fetchedAt: now(), rows }; } catch (error) { if (error.status !== 429) coolDown(502, FAILURE_COOLDOWN_MS); diff --git a/server/providers/acartia.test.mjs b/server/providers/acartia.test.mjs index bf61d987c..e7b3cd32e 100644 --- a/server/providers/acartia.test.mjs +++ b/server/providers/acartia.test.mjs @@ -221,43 +221,81 @@ test('one global snapshot single-flights and stays fresh for ten minutes', async assert.equal(calls, 2); }); -test('empty, unprojectable and invented wrapped snapshots never replace the cache', async () => { - for (const bad of [ +test('a valid empty or fully filtered snapshot is a fresh success that clears old rows', async () => { + for (const quiet of [ [], [null], [{ ...sighting(), data_source_name: '' }], - { results: [sighting()] }, + [{ ...sighting(), latitude: 37.8 }], + [{ ...sighting(), type: 'Unknown' }], ]) { let at = NOW; - let fail = false; + let calls = 0; + let empty = false; const cache = new Map(); const request = install({ cache, now: () => at, - fetchImpl: async () => Response.json(fail ? bad : [sighting()]), + fetchImpl: async () => { + calls += 1; + return Response.json(empty ? quiet : [sighting()]); + }, }); - const fresh = await request(); - const retained = cache.get('sightings'); - fail = true; + assert.equal((await request()).body.rows.length, 1); + empty = true; at += 600_001; - const stale = await request(); - assert.equal(stale.status, 200); - assert.equal(stale.headers['X-Data-Stale'], 'true'); - assert.equal(stale.body.stale, true); - assert.equal(stale.body.fetchedAt, fresh.body.fetchedAt); - assert.equal(cache.get('sightings'), retained); - const coldCache = new Map(); - const cold = install({ - cache: coldCache, - fetchImpl: async () => Response.json(bad), - }); - const result = await cold(); - assert.equal(result.status, 502); - assert.equal(result.body.error, 'acartia_unavailable'); - assert.equal(coldCache.size, 0); + const result = await request(); + assert.equal(result.status, 200); + assert.deepEqual(result.body, { fetchedAt: at, rows: [] }); + assert.equal(result.headers['X-Data-Stale'], undefined); + assert.equal(result.headers['Retry-After'], undefined); + assert.deepEqual(cache.get('sightings').value.rows, []); + // The quiet feed keeps the normal ten-minute cadence, not a failure retry. + at += 599_999; + assert.equal((await request()).status, 200); + assert.equal(calls, 2); + at += 1; + await request(); + assert.equal(calls, 3); + const cold = install({ fetchImpl: async () => Response.json(quiet) }); + const coldResult = await cold(); + assert.equal(coldResult.status, 200); + assert.deepEqual(coldResult.body, { fetchedAt: NOW, rows: [] }); } }); +test('an invented wrapped snapshot never replaces the cache', async () => { + const bad = { results: [sighting()] }; + let at = NOW; + let fail = false; + const cache = new Map(); + const request = install({ + cache, + now: () => at, + fetchImpl: async () => Response.json(fail ? bad : [sighting()]), + }); + const fresh = await request(); + const retained = cache.get('sightings'); + fail = true; + at += 600_001; + const stale = await request(); + assert.equal(stale.status, 200); + assert.equal(stale.headers['X-Data-Stale'], 'true'); + assert.equal(stale.body.stale, true); + assert.equal(stale.body.fetchedAt, fresh.body.fetchedAt); + assert.equal(cache.get('sightings'), retained); + const coldCache = new Map(); + const cold = install({ + cache: coldCache, + fetchImpl: async () => Response.json(bad), + }); + const result = await cold(); + assert.equal(result.status, 502); + assert.equal(result.body.error, 'acartia_unavailable'); + assert.equal(result.headers['Retry-After'], '15'); + assert.equal(coldCache.size, 0); +}); + test('failure serves stale for at most 24 hours and never reports a cold success', async () => { let at = NOW; let fail = false; From 3cb30c3f975e8dfb87a3e7105a650c509d93b263 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 5 Nov 2026 16:05:02 -0800 Subject: [PATCH 141/156] fix(wildlife): point the Acartia card link only at acartia.io The card's "Acartia" link was built from the contributor-supplied photo_url, so any https site could appear labelled as Acartia. Acartia has no documented per-sighting page, so the source link is now always the Acartia site, on the server projection and again in the card. The separate "Open on Acartia" fallback link is no longer needed. --- docs/CURRENT-STATE.md | 3 +- src/i18n/locales/ar.json | 1 - src/i18n/locales/de.json | 1 - src/i18n/locales/es.json | 1 - src/i18n/locales/fr.json | 1 - src/i18n/locales/it.json | 1 - src/i18n/locales/ja.json | 1 - src/i18n/locales/pt.json | 1 - src/i18n/locales/ru.json | 1 - src/i18n/locales/uk.json | 1 - src/i18n/locales/zh-CN.json | 1 - src/i18n/locales/zh-Hant.json | 1 - src/layers/wildlifeSightings/records.js | 17 +++-------- src/layers/wildlifeSightings/records.test.mjs | 8 +++-- src/ui/views/panels/WildlifeSightingCard.vue | 5 ++-- src/ui/wildlifeSightingCard.test.mjs | 30 +++++++++++++++++++ 16 files changed, 45 insertions(+), 29 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index b615bb70d..fbbaa19b0 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -174,7 +174,8 @@ compact anchored photo card. Selection stays pinned across camera, filter and sample changes until explicit deselection or layer disable. Its medium image uses `object-fit: contain`, with names, compact observed age and creator/licence. The card is the single source link to iNaturalist. Obscured sightings also stay -text-only; Acartia stays text-only with provider credit and whale guidance. +text-only; Acartia stays text-only with provider credit and whale guidance, +and its source link is always `https://acartia.io/`, never a contributed URL. Placement avoids actual HUD, toolbar and rail rectangles, panning only when necessary while preserving zoom. Escape, close or an outside click closes the card and clears selection. Camera/layout changes reposition the card without diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index da291e035..b491c3c93 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -3453,7 +3453,6 @@ "Loading photo...": "جارٍ تحميل الصورة…", "Photo unavailable": "الصورة غير متاحة", "Location obscured to a 0.2° cell": "الموقع مخفي ضمن خلية بمقدار 0.2°", - "Open on Acartia ↗": "فتح على Acartia ↗", "Photo of {name}": "صورة {name}", "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 69b039fc5..d01ff292f 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -3153,7 +3153,6 @@ "Loading photo...": "Foto wird geladen…", "Photo unavailable": "Foto nicht verfügbar", "Location obscured to a 0.2° cell": "Standort auf eine 0,2°-Rasterzelle verschleiert", - "Open on Acartia ↗": "Auf Acartia öffnen ↗", "Photo of {name}": "Foto von {name}", "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index a34a9bf39..aa403f743 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -3228,7 +3228,6 @@ "Loading photo...": "Cargando foto…", "Photo unavailable": "Foto no disponible", "Location obscured to a 0.2° cell": "Ubicación oculta en una celda de 0,2°", - "Open on Acartia ↗": "Abrir en Acartia ↗", "Photo of {name}": "Foto de {name}", "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index a09806cfa..b3ae72189 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -3228,7 +3228,6 @@ "Loading photo...": "Chargement de la photo…", "Photo unavailable": "Photo indisponible", "Location obscured to a 0.2° cell": "Localisation masquée dans une cellule de 0,2°", - "Open on Acartia ↗": "Ouvrir sur Acartia ↗", "Photo of {name}": "Photo de {name}", "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index b800b010c..4c69e1d4c 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -3228,7 +3228,6 @@ "Loading photo...": "Caricamento della foto…", "Photo unavailable": "Foto non disponibile", "Location obscured to a 0.2° cell": "Posizione oscurata in una cella di 0,2°", - "Open on Acartia ↗": "Apri su Acartia ↗", "Photo of {name}": "Foto di {name}", "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index fbfc9dd96..9a053f869 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -3078,7 +3078,6 @@ "Loading photo...": "写真を読み込み中…", "Photo unavailable": "写真を表示できません", "Location obscured to a 0.2° cell": "位置は0.2°のセル内に秘匿されています", - "Open on Acartia ↗": "Acartiaで開く ↗", "Photo of {name}": "{name}の写真", "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index a57f4eeaf..79c507759 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -3228,7 +3228,6 @@ "Loading photo...": "Carregando foto…", "Photo unavailable": "Foto indisponível", "Location obscured to a 0.2° cell": "Localização ocultada em uma célula de 0,2°", - "Open on Acartia ↗": "Abrir no Acartia ↗", "Photo of {name}": "Foto de {name}", "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 2946ba482..11fddb3e9 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3303,7 +3303,6 @@ "Loading photo...": "Загрузка фото…", "Photo unavailable": "Фото недоступно", "Location obscured to a 0.2° cell": "Местоположение скрыто в ячейке 0,2°", - "Open on Acartia ↗": "Открыть на Acartia ↗", "Photo of {name}": "Фото {name}", "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index 7f6c8c3e2..c0fab8533 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -3303,7 +3303,6 @@ "Loading photo...": "Завантаження фото…", "Photo unavailable": "Фото недоступне", "Location obscured to a 0.2° cell": "Місцеположення приховано в комірці 0,2°", - "Open on Acartia ↗": "Відкрити на Acartia ↗", "Photo of {name}": "Фото {name}", "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 28854ce5d..8687af790 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -3078,7 +3078,6 @@ "Loading photo...": "正在加载照片…", "Photo unavailable": "照片不可用", "Location obscured to a 0.2° cell": "位置已模糊至0.2°网格", - "Open on Acartia ↗": "在Acartia上打开 ↗", "Photo of {name}": "{name}的照片", "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index 63088293a..a95e71dd5 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -3078,7 +3078,6 @@ "Loading photo...": "正在載入照片…", "Photo unavailable": "照片無法使用", "Location obscured to a 0.2° cell": "位置已模糊至0.2°網格", - "Open on Acartia ↗": "在Acartia上開啟 ↗", "Photo of {name}": "{name}的照片", "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", diff --git a/src/layers/wildlifeSightings/records.js b/src/layers/wildlifeSightings/records.js index a1e50f52c..1af1cc296 100644 --- a/src/layers/wildlifeSightings/records.js +++ b/src/layers/wildlifeSightings/records.js @@ -247,18 +247,9 @@ export function normalizeInatObservations( return rows; } -function acartiaLink(value) { - if (typeof value !== 'string' || value.length > 500) - return 'https://acartia.io'; - try { - const url = new URL(value); - return url.protocol === 'https:' && !url.username && !url.password - ? url.href - : 'https://acartia.io'; - } catch { - return 'https://acartia.io'; - } -} +// Acartia has no documented per-sighting page, and photo_url is supplied by the +// contributor. The source link is therefore always the Acartia site itself. +export const ACARTIA_SITE = 'https://acartia.io/'; /** Acartia documents flat rows only. Name and entity are text fields; unknown * shapes are skipped rather than guessing nested provider data. */ @@ -315,7 +306,7 @@ export function normalizeAcartiaSightings(payload, nowMs) { cetacean: speciesGroup === 'cetacean', threatened: false, provider: { name, entity: cleanText(raw.data_source_entity, 60) }, - link: acartiaLink(raw.photo_url), + link: ACARTIA_SITE, photo: null, favesCount: 0, identificationAgreements: 0, diff --git a/src/layers/wildlifeSightings/records.test.mjs b/src/layers/wildlifeSightings/records.test.mjs index 18a1693d8..9909338a1 100644 --- a/src/layers/wildlifeSightings/records.test.mjs +++ b/src/layers/wildlifeSightings/records.test.mjs @@ -330,7 +330,7 @@ test('Acartia requires flat provider text, handles trust, and sanitizes type', ( ); }); -test('Acartia rejects invalid times/positions and unsafe photo links become the canonical page', () => { +test('Acartia rejects invalid times/positions and its link is always the Acartia site', () => { for (const overrides of [ { created: new Date(now - 8 * 24 * hour - 1).toISOString() }, { created: new Date(now + 10 * 60_000 + 1).toISOString() }, @@ -341,6 +341,10 @@ test('Acartia rejects invalid times/positions and unsafe photo links become the ]) assert.deepEqual(normalizeAcartiaSightings([sighting(overrides)], now), []); for (const photo_url of [ + // A contributor-supplied URL is never labelled as the Acartia source. + 'https://provider.example/photo.jpg', + 'https://phishing.example/acartia.io/login', + 'https://acartia.io.attacker.example/', 'http://provider.example/p.jpg', 'https://user:secret@provider.example/p.jpg', 'javascript:bad()', @@ -349,7 +353,7 @@ test('Acartia rejects invalid times/positions and unsafe photo links become the ]) { assert.equal( normalizeAcartiaSightings([sighting({ photo_url })], now)[0].link, - 'https://acartia.io', + 'https://acartia.io/', ); } }); diff --git a/src/ui/views/panels/WildlifeSightingCard.vue b/src/ui/views/panels/WildlifeSightingCard.vue index fc92c7ee4..9caac8fdd 100644 --- a/src/ui/views/panels/WildlifeSightingCard.vue +++ b/src/ui/views/panels/WildlifeSightingCard.vue @@ -2,7 +2,7 @@ import { computed, shallowRef, useTemplateRef, watch } from 'vue'; import { formatDate, t, translateKnown } from '../../../i18n/index.js'; import { photoCreator, formatAge } from '../../../layers/wildlifeSightings/photoText.js'; -import { inatLicenceLabel, inatPhotoUrl, isOpenPhoto } from '../../../layers/wildlifeSightings/records.js'; +import { ACARTIA_SITE, inatLicenceLabel, inatPhotoUrl, isOpenPhoto } from '../../../layers/wildlifeSightings/records.js'; const props = defineProps({ sighting: { type: Object, required: true }, @@ -36,6 +36,8 @@ const age = computed(() => { }); const observedDate = computed(() => observedDay.value ? formatDate(Date.parse(`${observedDay.value}T00:00:00Z`), { year: 'numeric', month: 'short', day: 'numeric', timeZone: 'UTC' }) : ''); const link = computed(() => { + // The "Acartia" label only ever points at the Acartia site, never a row URL. + if (acartia.value) return ACARTIA_SITE; try { const url = new URL(props.sighting.link); return url.protocol === 'https:' && !url.username && !url.password ? url.href : null; @@ -89,7 +91,6 @@ watch([() => props.sighting.stableId, () => photo.value?.src], () => { loaded.va

- {{ t('Open on Acartia ↗') }} {{ t('Be Whale Wise') }}

diff --git a/src/ui/wildlifeSightingCard.test.mjs b/src/ui/wildlifeSightingCard.test.mjs index d7837af7f..f685c75e0 100644 --- a/src/ui/wildlifeSightingCard.test.mjs +++ b/src/ui/wildlifeSightingCard.test.mjs @@ -373,6 +373,36 @@ test('missing, restricted, obscured and Acartia photos all use the same compact }); }); +test('an Acartia card labels only the Acartia site as Acartia', async (context) => { + for (const link of [ + 'https://provider.example/photos/synthetic.jpg', + 'https://acartia.io.attacker.example/', + 'https://acartia.io/', + ]) + await context.test(link, async (context) => { + const f = fixture(context, { + ...row, + origin: 'acartia', + stableId: 'acartia:456', + provider: { name: 'Synthetic network' }, + link, + }); + await f.click(); + const card = f.cardElement(); + const hrefs = [...card.querySelectorAll('a')].map((a) => + a.getAttribute('href'), + ); + assert.deepEqual(hrefs, [ + 'https://acartia.io/', + 'https://www.bewhalewise.org', + ]); + assert.equal( + card.querySelector('.wildlife-sighting-card-source').textContent, + 'Acartia ↗', + ); + }); +}); + test('safe placements near each viewport edge exclude actual HUD, toolbar and rail rects', () => { const obstacles = [ rect(0, 0, 260, 160), From 5391687d4649c3fd9e7f0d0c46624caaf6c7582e Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 5 Nov 2026 16:06:41 -0800 Subject: [PATCH 142/156] fix(wildlife): draw what arrived when one sighting source fails An iNaturalist rejection discarded a successful Acartia reply, and the antimeridian Promise.all lost both halves when one failed. Each box now settles independently; the failed part is reported on sources.inaturalist (error and failed boxes), the layer keeps that area's last iNaturalist rows and shows DEGRADED with retry timing, and the update fails outright only when every requested source failed. --- docs/CURRENT-STATE.md | 4 + src/layers/wildlifeSightings/lifecycle.js | 16 +++- .../wildlifeSightings/ownership.test.mjs | 41 +++++++++++ src/layers/wildlifeSightings/source.js | 26 ++++++- src/layers/wildlifeSightings/source.test.mjs | 73 +++++++++++++++++++ 5 files changed, 155 insertions(+), 5 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index fbbaa19b0..2449130f4 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -121,6 +121,10 @@ Provider 429 cooldown is shared; 5xx cooldown is resource-specific. Failed tiles retain good cells for at most one hour. Stale replies carry `X-Data-Stale` and known `Retry-After`. Failures keep filters and drawn rows, reporting DEGRADED with retry timing; logs exclude request URLs and upstream exception text. +A partial failure still draws what arrived: a failed iNaturalist request, or +one failed half of an antimeridian view, keeps a good Acartia reply and the +other half, retains that area's last iNaturalist rows and reports DEGRADED. +The update fails outright only when every requested source failed. `/api/acartia/sightings` adds Salish Sea marine mammals within 45.5–50.5° N, 126–121° W, cached ten minutes and stale for at most 24 h. A valid empty diff --git a/src/layers/wildlifeSightings/lifecycle.js b/src/layers/wildlifeSightings/lifecycle.js index e092a6ef1..6da425c7a 100644 --- a/src/layers/wildlifeSightings/lifecycle.js +++ b/src/layers/wildlifeSightings/lifecycle.js @@ -345,8 +345,18 @@ export function createLifecycle({ state, services, parts }) { state.loadedScope = query.scope; state.pendingClear = false; // A successful page replaces this queried area, including a healthy - // empty page. Keep only other visited areas in the bounded LRU. + // empty page. Keep only other visited areas in the bounded LRU. Areas + // whose iNaturalist request failed keep their last iNaturalist rows. + const inat = snapshot.sources?.inaturalist; + const failedInatBoxes = + inat?.ok === false ? inat.failedBoxes || query.boxes : null; for (const [id, row] of state.rows) { + if ( + failedInatBoxes && + row.origin === 'inaturalist' && + rowInWildlifeBoxes(row, failedInatBoxes, 0) + ) + continue; if (rowInWildlifeBoxes(row, query.boxes, 0)) state.rows.delete(id); } for (const row of snapshot.rows) { @@ -397,6 +407,10 @@ export function createLifecycle({ state, services, parts }) { const sourceError = gridError || staleError || + (snapshot.sources?.inaturalist?.ok === false + ? snapshot.sources.inaturalist.error || + new Error(msg('Wildlife sightings source unavailable')) + : null) || (snapshot.sources?.acartia?.ok === false ? new Error(msg('Acartia feed unavailable')) : null); diff --git a/src/layers/wildlifeSightings/ownership.test.mjs b/src/layers/wildlifeSightings/ownership.test.mjs index c8bfaac62..e118a87bc 100644 --- a/src/layers/wildlifeSightings/ownership.test.mjs +++ b/src/layers/wildlifeSightings/ownership.test.mjs @@ -1568,6 +1568,47 @@ test('provider credits sit in the layer row, never on the globe; Acartia failure h.layer.destroy(); }); +test('an iNaturalist failure beside a good Acartia reply keeps both and reports degraded', async () => { + let inatHealthy = true; + const acartia = { + ...base, + stableId: 'acartia:1', + origin: 'acartia', + iconic: 'Mammalia', + lon: -122.3, + provider: { name: 'Synthetic Provider', entity: null }, + }; + const failure = Object.assign(new Error('iNaturalist HTTP 502'), { + status: 502, + retryAfterSec: 30, + }); + const h = harness({ + getSnapshot: async () => [], + getSnapshotDetail: async ({ boxes }) => ({ + rows: inatHealthy ? [base, acartia] : [acartia], + sources: { + inaturalist: inatHealthy + ? { ok: true } + : { ok: false, error: failure, failedBoxes: boxes }, + acartia: { ok: true }, + }, + }), + }); + await h.update(); + assert.equal(h.layer.getStats().count, 2); + inatHealthy = false; + await h.update(); + // The failed iNaturalist area keeps its last rows; Acartia still refreshes. + assert.equal(h.layer.getStats().count, 2); + assert.equal(h.layer.getStats().error, 'iNaturalist HTTP 502'); + assert.equal(layerFeedState(h.layer.getStats()), 'degraded'); + assert.equal( + h.layer.getRowControls().info, + 'iNaturalist contributors · Acartia 1 (Salish Sea) · licences per record', + ); + h.layer.destroy(); +}); + test('ambient cards prefilter the horizon, spread over screen regions and retain visible slots', async () => { const rows = Array.from({ length: 32 }, (_, i) => ({ ...photographed, diff --git a/src/layers/wildlifeSightings/source.js b/src/layers/wildlifeSightings/source.js index 7af6d1e72..8bd41bf1d 100644 --- a/src/layers/wildlifeSightings/source.js +++ b/src/layers/wildlifeSightings/source.js @@ -454,7 +454,9 @@ export function createWildlifeSightingsSource({ ['all', 'Mammalia', 'whales'].includes(preset) && requestBoxes.some((bounds) => boxIntersects(bounds, ACARTIA_GUARD_BOX)); - const inatRequest = Promise.all( + // Each box settles independently: one failed antimeridian half must not + // discard the other half, and neither may discard a good Acartia reply. + const inatRequest = Promise.allSettled( requestBoxes.map((bounds) => { const params = new URLSearchParams({ scope: scope === 'global' ? 'global' : 'view', @@ -480,8 +482,23 @@ export function createWildlifeSightingsSource({ acartiaRequest, ]); signal?.throwIfAborted(); - if (inatResult.status === 'rejected') throw inatResult.reason; - const inatSnapshots = inatResult.value; + const inatSnapshots = []; + const failedBoxes = []; + let inatError = null; + inatResult.value.forEach((result, index) => { + if (result.status === 'fulfilled') inatSnapshots.push(result.value); + else { + failedBoxes.push(requestBoxes[index]); + inatError ??= result.reason; + } + }); + // Fail outright only when nothing succeeded; otherwise draw what arrived + // and report the failed source in its per-source status. + if ( + !inatSnapshots.length && + (!useAcartia || acartiaResult.status !== 'fulfilled') + ) + throw inatError; const inatRows = inatSnapshots.flatMap((snapshot) => snapshot.rows); const acartiaSnapshot = acartiaResult.status === 'fulfilled' ? acartiaResult.value : null; @@ -529,10 +546,11 @@ export function createWildlifeSightingsSource({ ...(retryAfter.length ? { retryAfterSec: Math.max(...retryAfter) } : {}), sources: { inaturalist: { - ok: true, + ok: !inatError, stale: inatStale, fetchedAt: inatFetchedAt, count: rows.filter((row) => row.origin === 'inaturalist').length, + ...(inatError ? { error: inatError, failedBoxes } : {}), }, acartia: { ok: !useAcartia || acartiaResult.status === 'fulfilled', diff --git a/src/layers/wildlifeSightings/source.test.mjs b/src/layers/wildlifeSightings/source.test.mjs index 7c1191e4b..2a21ca372 100644 --- a/src/layers/wildlifeSightings/source.test.mjs +++ b/src/layers/wildlifeSightings/source.test.mjs @@ -148,6 +148,79 @@ test('Acartia failure preserves iNat rows with an explicit failed source', async assert.equal(result.sources.inaturalist.ok, true); }); +test('iNat failure keeps a successful Acartia result and reports iNat degraded', async () => { + const source = createWildlifeSightingsSource({ + now: () => now, + fetchImpl: async (url) => + url.startsWith('/api/acartia') + ? Response.json(acartiaEnvelope()) + : new Response('', { + status: 502, + headers: { 'X-Upstream-Status': '503', 'Retry-After': '30' }, + }), + }); + const result = await source.getSnapshotDetail({ box: seattle }); + assert.deepEqual(result.rows, [acartia]); + assert.equal(result.totalResults, 1); + assert.equal(result.fetchedAt, now); + assert.equal(result.sources.acartia.ok, true); + assert.equal(result.sources.inaturalist.ok, false); + assert.equal(result.sources.inaturalist.count, 0); + assert.equal( + result.sources.inaturalist.error.message, + 'iNaturalist HTTP 502', + ); + assert.equal(result.sources.inaturalist.error.retryAfterSec, 30); + assert.deepEqual(result.sources.inaturalist.failedBoxes, [seattle]); +}); + +test('observations reject only when every requested source failed', async () => { + const source = createWildlifeSightingsSource({ + now: () => now, + fetchImpl: async (url) => + new Response('', { status: url.startsWith('/api/acartia') ? 502 : 503 }), + }); + await assert.rejects(source.getSnapshotDetail({ box: seattle }), { + message: 'iNaturalist HTTP 503', + }); + const antimeridian = createWildlifeSightingsSource({ + now: () => now, + fetchImpl: async () => new Response('', { status: 503 }), + }); + await assert.rejects( + antimeridian.getSnapshotDetail({ + boxes: [ + { south: 10, west: 179, north: 12, east: 180 }, + { south: 10, west: -180, north: 12, east: -178 }, + ], + }), + { message: 'iNaturalist HTTP 503' }, + ); +}); + +test('one failed antimeridian half keeps the other half and reports the failed box', async () => { + const east = { south: 10, west: 179, north: 12, east: 180 }; + const west = { south: 10, west: -180, north: 12, east: -178 }; + const eastRow = { ...inat, stableId: 'inat:east', lat: 11, lon: 179.5 }; + const source = createWildlifeSightingsSource({ + now: () => now, + fetchImpl: async (url) => + new URL(url, 'https://example.test').searchParams.get('west') === '179' + ? Response.json(inatEnvelope({ rows: [eastRow], totalResults: 3 })) + : new Response('', { status: 503 }), + }); + const result = await source.getSnapshotDetail({ boxes: [east, west] }); + assert.deepEqual(result.rows, [eastRow]); + assert.equal(result.totalResults, 3); + assert.equal(result.sources.inaturalist.ok, false); + assert.equal(result.sources.inaturalist.count, 1); + assert.equal( + result.sources.inaturalist.error.message, + 'iNaturalist HTTP 503', + ); + assert.deepEqual(result.sources.inaturalist.failedBoxes, [west]); +}); + test('proxy staleness and original fetch dates pass through with per-source counts', async () => { const fetchedAt = now - 50 * 60_000; const source = createWildlifeSightingsSource({ From fb0a58ef3c55dd03eae038df8d510140c71f16c0 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 5 Nov 2026 16:07:48 -0800 Subject: [PATCH 143/156] docs(wildlife): cite the documented iNaturalist UTFGrid endpoint and Acartia's key request /grid/{z}/{x}/{y}.grid.json is documented in the v1 and v2 API docs as Grid Tiles UTFGrid; only its per-cell records are unspecified, which is what the schema guard covers. DATA_SOURCES.md now also notes that Acartia's README asks API users to create an account and an API key, although /sightings/current answers without one today. --- DATA_SOURCES.md | 4 ++-- docs/CURRENT-STATE.md | 3 ++- docs/KNOWN-ISSUES.md | 8 +++++--- src/layers/wildlifeSightings/docs.test.mjs | 18 ++++++++++++++++++ 4 files changed, 27 insertions(+), 6 deletions(-) diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 1153015b8..f2b002428 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -198,8 +198,8 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **OSRM on the FOSSGIS routing servers.** Keyless (no API key). Every route is requested by a person: clicking A and B in the Directions layer, or a voice "route from A to B". The server-side `/api/route` proxy sends an identifying `User-Agent`, pins the upstream host, accepts 2-12 coordinates, rejects any leg over 600 km or a total over 2,500 km, caps the buffered response at 8 MB, times the upstream out at 12 s, rate-limits per client, caches each route for 10 minutes, and coalesces identical requests that are in flight at the same time. Turn-by-turn maneuvers are asked for only when a caller wants them (`steps=1`), so the callers that do not read them send the smaller request. The "fix the map" link the policy asks for is in the Data attribution popover beside the OpenStreetMap credit. - **FAA Special Use Airspace.** Keyless. `/api/drone-airspace` queries the public FAA AIS FeatureServer for prohibited (P) and restricted (R) zones only — not MOAs, TFRs or other SUA types — with simplified geometry, a body cap, paging, a one-hour cache and stale-on-error. The layer is US coverage, informational only, and is not a substitute for B4UFLY, LAANC or a preflight briefing. Data is fetched at request time and never stored or redistributed. US government public domain; courtesy attribution is registered in the Data attribution popover. - **iNaturalist.** Keyless. `/api/inaturalist/observations` returns cached random view samples (newest first globally), at most 200 per request. Recency uses observation time: the v1 `d1` filter and a rolling 48 h cutoff (whales 7 d); date-only rows qualify when their day starts within the window. Observation caches last 5 min (global 10 min), with stale fallback ≤1 h. `/api/inaturalist/grid` supplies density counts, cached 15 min. Both share a paced queue capped at 60/min and 8,000/day; only request counters persist in `.gev-cache/`. The server caches projected records, never raw responses. Private records are excluded; obscured coordinates become 0.2° cells. Observer identity is retained only for photo attribution. Eligible photos load directly from `inaturalist-open-data.s3.amazonaws.com`, with creator credit on ambient cards and creator/licence on selection. Per-record licences and noncommercial restrictions apply as listed above. Attribution appears in the layer row and Data attribution popover. Coverage follows community observations, not a census; Acartia adds Salish Sea marine mammals. -- **iNaturalist grid (undocumented endpoint risk).** `/api/inaturalist/grid` shares observation-date, taxa, quality, pacing and budget rules with observations. A strict 64×64 UTFGrid schema guard fails closed; only logical-cell keys, counts and coordinates survive projection. Raw tiles, observation identifiers and free text are not retained. Projected tiles use a 400-entry LRU, 15-minute freshness and at most one hour of stale fallback. Views request at most six tiles; cached and adjacent-zoom coverage publishes before network waits, independently of observation pages. Cells supply a noninteractive density glow ranked within the view, from teal to amber with mono/CRT support. Every sighting badge comes from a real observation at its public position. Counts describe observations, not a population census. The endpoint is outside the documented v1 contract; schema drift disables grids until revalidated. See `docs/KNOWN-ISSUES.md`. -- **Acartia.** Keyless. `/api/acartia/sightings` reads the current 7-day marine-mammal feed for ALL, MAMMALS and WHALES views intersecting 45.5–50.5° N, 126–121° W in the Salish Sea. WHALES accepts recognized cetaceans; other identified marine mammals appear under ALL/MAMMALS. The server rounds positions to 0.01° and drops comments before caching projected records (10 min fresh, stale ≤24 h); raw responses are not retained. Selected text cards credit the reporting provider under CC BY 4.0 and show Be Whale Wise guidance. Attribution also appears in the layer row and Data attribution popover. Opportunistic sightings, not a census. +- **iNaturalist grid (cell schema risk).** `/api/inaturalist/grid` shares observation-date, taxa, quality, pacing and budget rules with observations. A strict 64×64 UTFGrid schema guard fails closed; only logical-cell keys, counts and coordinates survive projection. Raw tiles, observation identifiers and free text are not retained. Projected tiles use a 400-entry LRU, 15-minute freshness and at most one hour of stale fallback. Views request at most six tiles; cached and adjacent-zoom coverage publishes before network waits, independently of observation pages. Cells supply a noninteractive density glow ranked within the view, from teal to amber with mono/CRT support. Every sighting badge comes from a real observation at its public position. Counts describe observations, not a population census. The endpoint is documented in the v1 and v2 API docs as “Grid Tiles UTFGrid” (`/grid/{z}/{x}/{y}.grid.json`), but its per-cell records are not specified; schema drift disables grids until revalidated. See `docs/KNOWN-ISSUES.md`. +- **Acartia.** Keyless. `/api/acartia/sightings` reads the current 7-day marine-mammal feed for ALL, MAMMALS and WHALES views intersecting 45.5–50.5° N, 126–121° W in the Salish Sea. WHALES accepts recognized cetaceans; other identified marine mammals appear under ALL/MAMMALS. The server rounds positions to 0.01° and drops comments before caching projected records (10 min fresh, stale ≤24 h); raw responses are not retained. Selected text cards credit the reporting provider under CC BY 4.0 and show Be Whale Wise guidance. `/sightings/current` answers without a key today, but Acartia's README asks API users to create an account and generate an API key on the [Acartia dashboard](https://acartia.io/) and to follow its [community guidelines](https://acartia.io/register). Attribution also appears in the layer row and Data attribution popover. Opportunistic sightings, not a census. - **Mapped installations.** OpenFreeMap military polygons use a bundled Overture/OSM name index; wide views show named points. Optional operator-configured Overpass and explicit Google Places searches keep their own source paths. Mapped data does not establish current activity or complete coverage. - **Place search.** Named-place fly-to uses Google Geocoding when a Maps key is configured, then Photon (`PHOTON_URL`; unset uses the public instance, empty disables it), then `/api/geocode` (Nominatim search) when neither answers. `/api/geocode` and the voice outline route `/api/geocode/outline` share one Nominatim queue (`NOMINATIM_URL`; unset, empty and invalid values disable it; an operator must explicitly select an authorized endpoint): one request at a time at least 1.1 s apart, an identifying `User-Agent` and `Referer`, a short queue that refuses a burst, a pause after 429/403/5xx answers (honouring `Retry-After` in full), at most one retry after a network failure, and a per-install daily cap on public-instance requests (`NOMINATIM_DAILY_CAP`, default 50). Processes using the same installation state file hold one lock through the actual fetch invocation and recording the next public start, with the daily slot persisted before invocation; restarts share that count, spacing and any pause. An unfinished dispatch marker fails closed until operator recovery; it is never automatically reclaimed. Public access fails closed if the state file cannot be read, locked, written or semantically validated. This coordination does not span independent installations or state directories and does not establish an application-wide public-service allowance or authorize public use. An operator-selected non-public endpoint is outside the public cap, spacing and durable-state requirement; an upstream cooldown is kept with the endpoint that returned it rather than leaking across operator URLs. Answers are cached on disk (outlines 90 days, places 30 days, no-results 24 hours); failures are not cached. Outlines are looked up only for an explicit ask, after bundled data and any configured Overpass, and only results whose type suits the ask are used. Administrative outline responses expose country/admin-1/admin-2 identity only when the selected Nominatim row has a recognized, unambiguous address type consistent with its own name; numeric rank, ambiguous types, missing fields and contradictory names do not manufacture identity. It is not used for systematic or bulk queries. - **Street and building outlines.** Voice street highlights, pointed-at buildings and grounds use the OpenFreeMap vector tiles the traffic layer already loads. Traffic and outlines share one bounded pending fetch/decode owner per immutable XYZ tile; materialized outline geometry is charged to the retained-byte ceiling and capped by coordinate count. Consumer result caches and request lifetimes remain independent, while eviction or explicit teardown cancels queued idle projections and releases their decoded owner. No Overpass request is made unless `OVERPASS_UPSTREAMS` is configured. Named grounds require a returned name matching the selected place, except that an ask whose head noun names a kind of grounds (a school, university or college, hospital, cemetery, stadium or zoo) may use the part of the smallest unnamed tile area of that class that holds the resolved place; a differently named area never answers. Other anonymous landcover polygons cannot establish identity, so unsupported grounds stay unavailable rather than borrowing a nearby POI name. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 2449130f4..96d85431f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -160,7 +160,8 @@ adjacent-zoom tiles publish before network waits; each arriving tile replaces its covered area, including healthy empty tiles. Grid tiles publish even while the independent observation request is pending or failed. Enable waits for one camera settle; camera acquisition uses moveEnd with a 600 ms debounce. The -undocumented UTFGrid endpoint has a schema guard; see `KNOWN-ISSUES.md`. +documented UTFGrid endpoint's unspecified cell records have a schema guard; +see `KNOWN-ISSUES.md`. Ambient cards use a fixed 96 px image width and a short creator name credit, with a bounded frame cache. New cards fill free screen regions; visible cards diff --git a/docs/KNOWN-ISSUES.md b/docs/KNOWN-ISSUES.md index ccd3ed906..3fdcbe67b 100644 --- a/docs/KNOWN-ISSUES.md +++ b/docs/KNOWN-ISSUES.md @@ -10,12 +10,14 @@ For the roadmap and open backlog, see the repository issue tracker. ## Open -### Wildlife grid is an undocumented iNaturalist endpoint +### Wildlife grid relies on the iNaturalist UTFGrid cell schema Status: Open (guarded fallback) -The operational `/v1/grid/{z}/{x}/{y}.grid.json` endpoint is not part of the -published v1 Swagger contract. Its 64×64 UTFGrid shape, keys and cell records +The `/v1/grid/{z}/{x}/{y}.grid.json` endpoint is documented in the v1 and v2 +API docs (“Grid Tiles UTFGrid”), but the documented response only defines the +UTFGrid `grid`, `keys` and `data` members; the per-cell records are not +specified. Its 64×64 UTFGrid shape, keys and cell records are validated in full at the allow-list proxy. A schema change or unavailable tile retains its last good cells for at most one hour; a cold unavailable grid uses sampled observations with the same badges and screen buckets, without diff --git a/src/layers/wildlifeSightings/docs.test.mjs b/src/layers/wildlifeSightings/docs.test.mjs index 3f84357d3..794bd62b5 100644 --- a/src/layers/wildlifeSightings/docs.test.mjs +++ b/src/layers/wildlifeSightings/docs.test.mjs @@ -109,3 +109,21 @@ test('Wildlife docs describe one source link, name declutter and globe glow limi assert.match(section, /5% radius margin/); assert.match(section, /add no per-frame work/); }); + +test('Wildlife docs cite the documented UTFGrid endpoint and the Acartia key request', () => { + for (const path of [ + 'docs/KNOWN-ISSUES.md', + 'DATA_SOURCES.md', + 'docs/CURRENT-STATE.md', + ]) { + const text = read(path); + assert.doesNotMatch(text, /undocumented (iNaturalist|UTFGrid) endpoint/i); + assert.doesNotMatch(text, /grid \(undocumented/i); + } + for (const path of ['docs/KNOWN-ISSUES.md', 'DATA_SOURCES.md']) + assert.match(read(path), /Grid Tiles UTFGrid/); + assert.match( + read('DATA_SOURCES.md'), + /create an account and generate an API key/, + ); +}); From 6479167b500357004ca2f0a11aaee1ae23d33a82 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 5 Nov 2026 16:14:27 -0800 Subject: [PATCH 144/156] Pivot a tilt at exactly 1,000 km and report where a zoom now ends it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pivot range rebuilt from the camera's position came back a hair over the 1,000 km cutoff when the view sat exactly on it, so a tilt from 1,000 km ran in place and lifted the crosshair onto the sky. The cutoff now allows 1 m of slack. A zoom during a tilt to an angle changes the range the pivot holds, and with it the place's horizon limit, but the tilt kept its old goal and its reply its old angle: from 400 km, a tilt to -5° zoomed out x2.5 reported -5° with no note and ended at -9.4°. The zoom now re-judges the goal at the new range, eases the tilt from where it is to it, and its result names the angle the tilt now ends at, with a note. --- docs/CURRENT-STATE.md | 2 +- src/cameraTilt.test.mjs | 76 +++++++++++++++++++++++++++++++++++++++++ src/cameraVerbs.js | 64 ++++++++++++++++++++++++++++++---- src/voice/gevActions.js | 5 +++ 4 files changed, 139 insertions(+), 8 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 9036db387..d41c435f1 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5590,7 +5590,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. Angles and limits are the camera's own pitch; at regional range the pivot's pitch is up to about 8° shallower (the Earth curves away), so the swing solves for the pivot pitch that gives the camera the angle asked for. 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. Beyond about 17 km of range the swing never hands over to an in-place tilt (that would lift the crosshair onto the sky); it keeps the camera at least 0.5° above the place's own horizon, which from roughly 500 km stops it short of -5° with the place still centred: a tilt to an angle beyond that stops at the angle reached and says so (`pitchDeg` is the angle reached, with a `note`), a continuous tilt stops there, and a further tilt up is refused as at the limit. 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 cap: 1,000 km for a tilt, where pivoting stops, and 5,000 km for an orbit) 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. An angle the camera already holds answers `unchanged` but still takes navigation, so it stops a running motion and releases the follow camera. A tilt left or right 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 (moving to a later fly-to in the same reply as soon as it begins, whatever the earlier one reports), so it neither cancels the flight nor gets replaced by it; if the flight is cancelled, finds nothing or times out, the user grabs, touches or scrolls the map during it, or another navigation (a location-panel search, share link or file import, which leave the flight flying) takes the camera during it, the motion is not run and says so. A motion that runs after a fly-to of the same reply has already found nothing (Gemini runs one turn's calls one by one, and passes its turn as the reply) is refused the same way rather than moving the old view; a motion in a later reply runs. `stop` and `rotate_globe` do not wait, and a motion sent before its fly-to is not reordered: it runs at once, and the fly-to's result names the motion it stopped (`stoppedMotion`). 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. Angles and limits are the camera's own pitch; at regional range the pivot's pitch is up to about 8° shallower (the Earth curves away), so the swing solves for the pivot pitch that gives the camera the angle asked for. 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. Beyond about 17 km of range the swing never hands over to an in-place tilt (that would lift the crosshair onto the sky); it keeps the camera at least 0.5° above the place's own horizon, which from roughly 500 km stops it short of -5° with the place still centred: a tilt to an angle beyond that stops at the angle reached and says so (`pitchDeg` is the angle reached, with a `note`), a continuous tilt stops there, and a further tilt up is refused as at the limit. 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 cap: 1,000 km for a tilt, where pivoting stops, and 5,000 km for an orbit) is reported as not zoomed. A zoom during a tilt to an angle re-judges the place's horizon limit at the new range: when that moves where the tilt ends, the tilt eases from where it is to the new end, and the zoom's result names that angle (`pitchDeg`) with a `note`. 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. An angle the camera already holds answers `unchanged` but still takes navigation, so it stops a running motion and releases the follow camera. A tilt left or right 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 (moving to a later fly-to in the same reply as soon as it begins, whatever the earlier one reports), so it neither cancels the flight nor gets replaced by it; if the flight is cancelled, finds nothing or times out, the user grabs, touches or scrolls the map during it, or another navigation (a location-panel search, share link or file import, which leave the flight flying) takes the camera during it, the motion is not run and says so. A motion that runs after a fly-to of the same reply has already found nothing (Gemini runs one turn's calls one by one, and passes its turn as the reply) is refused the same way rather than moving the old view; a motion in a later reply runs. `stop` and `rotate_globe` do not wait, and a motion sent before its fly-to is not reordered: it runs at once, and the fly-to's result names the motion it stopped (`stoppedMotion`). 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 a67ecee86..0dcb2d1b0 100644 --- a/src/cameraTilt.test.mjs +++ b/src/cameraTilt.test.mjs @@ -610,3 +610,79 @@ for (const direction of ['left', 'right']) assert.match(angled.error, /pitchDeg alone/); assert.equal(getActiveCameraMotion(), null); }); + +// The 1,000 km pivot cutoff includes its boundary: at exactly 1,000 km the +// pivot range came back a hair over it, so the tilt ran in place and put the +// crosshair on the sky. +test('from exactly 1,000 km a tilt to -5° still pivots about the place', (t) => { + const h = harness(t, { height: 1_000_000 }); + const result = moveCamera({ motion: 'tilt', pitchDeg: 5 }); + assert.equal(result.ok, true); + h.finish(); + assert.ok(h.viewTarget(), 'the crosshair is on the sky'); + assert.ok(centreDrift(h) < 100, `centre drifted ${centreDrift(h)} m`); + assert.ok( + Math.abs(pitchDeg(h.camera) - result.pitchDeg) < 0.5, + `camera at ${pitchDeg(h.camera)}°, reported ${result.pitchDeg}°`, + ); + assert.match(result.note, /horizon/); +}); + +// A zoom mid-tilt moves the pivot's horizon limit: the zoom's reply names the +// angle the tilt now ends at, and the camera ends there. +test('a zoom out during a tilt to an angle reports the angle it now ends at', (t) => { + const h = harness(t, { height: 400_000 }); + const result = moveCamera({ motion: 'tilt', pitchDeg: -5 }); + assert.equal(result.pitchDeg, -5); + assert.equal(result.note, undefined); + for (let i = 0; i < 10; i += 1) h.tick(); + const zoom = adjustOrbitRange(2.5); + assert.equal(zoom.kind, 'tilt'); + assert.equal(zoom.changed, true); + h.finish(); + assert.ok(h.viewTarget(), 'the crosshair is on the sky'); + assert.ok(zoom.pitchDeg < -5, `zoom reported ${zoom.pitchDeg}°`); + assert.ok( + Math.abs(pitchDeg(h.camera) - zoom.pitchDeg) < 0.5, + `camera at ${pitchDeg(h.camera)}°, zoom reported ${zoom.pitchDeg}°`, + ); + assert.match(zoom.note, /horizon/); +}); + +test('a zoom in during a horizon-limited tilt reports the angle it now reaches', (t) => { + const h = harness(t, { height: 900_000 }); + const result = moveCamera({ motion: 'tilt', pitchDeg: -5 }); + assert.ok(result.pitchDeg < -5, `reported ${result.pitchDeg}°`); + for (let i = 0; i < 10; i += 1) h.tick(); + const zoom = adjustOrbitRange(1 / 2.4); + assert.equal(zoom.changed, true); + h.finish(); + assert.equal(zoom.pitchDeg, -5); + assert.ok( + Math.abs(pitchDeg(h.camera) - -5) < 0.5, + `camera at ${pitchDeg(h.camera)}°`, + ); +}); + +test('a voice zoom during a tilt to an angle passes on the new end angle', async (t) => { + const h = harness(t, { height: 400_000 }); + const runner = createGevActionRunner({ + viewer: h.viewer, + styleManager: {}, + dataManager: { layers: new Map(), getAll: () => [] }, + }); + moveCamera({ motion: 'tilt', pitchDeg: -5 }); + for (let i = 0; i < 10; i += 1) h.tick(); + const zoom = await runner('adjust_camera_zoom', { + direction: 'out', + amount: 'lot', + }); + assert.equal(zoom.ok, true); + assert.equal(zoom.tiltRangeAdjusted, true); + h.finish(); + assert.ok( + Math.abs(pitchDeg(h.camera) - zoom.pitchDeg) < 0.5, + `camera at ${pitchDeg(h.camera)}°, zoom reported ${zoom.pitchDeg}°`, + ); + assert.match(zoom.note, /horizon/); +}); diff --git a/src/cameraVerbs.js b/src/cameraVerbs.js index 4618a434d..0681d6a2b 100644 --- a/src/cameraVerbs.js +++ b/src/cameraVerbs.js @@ -62,6 +62,12 @@ const TILT_MIN_CLEARANCE_M = 150; * crosshair point, so the tilt happens in place instead. */ const TILT_PIVOT_MAX_RANGE_M = 1_000_000; +/** + * A pivot range rebuilt from the camera's position comes back a hair off the + * range it was set at: a range this close past the cutoff still counts as on + * it. + */ +const TILT_PIVOT_RANGE_SLACK_M = 1; /** * The shallowest a pivot swing goes when its clearance is not what stops it. * From several hundred km the camera's own horizon limit lies past the @@ -1403,10 +1409,20 @@ function tiltUpLimit({ target, pivot }) { function tiltStartFrame(cam) { const target = _viewer.trackedEntity ? null : _getTarget?.(_viewer); let pivot = target ? tiltPivot(cam, target) : null; - if (pivot && pivot.range > TILT_PIVOT_MAX_RANGE_M) pivot = null; + if (pivot && !tiltPivotInReach(pivot.range)) pivot = null; return { target, pivot, from: cam.pitch }; } +/** Whether a tilt may pivot at this range: up to and including the cutoff. */ +function tiltPivotInReach(range) { + return range <= TILT_PIVOT_MAX_RANGE_M + TILT_PIVOT_RANGE_SLACK_M; +} + +/** The reply's note for a tilt the place's horizon stops short of its angle. */ +function tiltHorizonNote(pitch) { + return `Stopped at ${Math.round(Cesium.Math.toDegrees(pitch))}°: from this range a shallower angle puts the place past the horizon. Zoom in to tilt further.`; +} + /** Ground pivot for a tilt: heading, pitch and range in the target's own frame. */ function tiltPivot(cam, target) { const local = Cesium.Matrix4.multiplyByPoint( @@ -1917,6 +1933,9 @@ export function moveCamera(args = {}, runNavigation = null) { state.mode = 'once'; state.direction = goal > from ? 'up' : 'down'; state.goalPitch = goal; + // A zoom mid-tilt moves the horizon limit; it re-judges the goal + // against the angle asked for. + state.askedPitch = tiltToPitch; state.durationS = Math.max( ONCE.durationS, Math.abs(Cesium.Math.toDegrees(goal - from)) / TILT_TO_DEG_S, @@ -2024,9 +2043,7 @@ export function moveCamera(args = {}, runNavigation = null) { Cesium.Math.toDegrees(horizonLimited ?? tiltToPitch), ), }), - ...(horizonLimited !== null && { - note: `Stopped at ${Math.round(Cesium.Math.toDegrees(horizonLimited))}°: from this range a shallower angle puts the place past the horizon. Zoom in to tilt further.`, - }), + ...(horizonLimited !== null && { note: tiltHorizonNote(horizonLimited) }), }; }; const preserveCameraFlight = @@ -2043,8 +2060,10 @@ 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 {{kind: string, changed: boolean}|null} the motion that consumed - * the zoom, and whether its range moved; null when none did. + * @returns {{kind: string, changed: boolean, pitchDeg?: number, + * note?: string}|null} the motion that consumed the zoom, and whether its + * range moved; null when none did. A tilt to an angle whose end the new + * range moves also names the angle it now ends at (pitchDeg), with a note. */ export function adjustOrbitRange(factor) { if (!_active || !['orbit', 'tilt'].includes(_active.kind) || !_active.hpr) @@ -2066,7 +2085,38 @@ export function adjustOrbitRange(factor) { Math.min(Math.max(ceiling, range), range * factor), ); // Under half a metre counts as not moved, as for a plain zoom. - return { kind, changed: Math.abs(hpr.range - range) >= 0.5 }; + const changed = Math.abs(hpr.range - range) >= 0.5; + return { kind, changed, ...(changed && retargetTiltAfterZoom(_active)) }; +} + +/** + * A zoom changed the range a tilt to an angle holds, and with it the place's + * horizon limit: aim the tilt at the angle asked for or the new limit, + * whichever comes first, from where the camera is now. Returns the end angle + * and a note for the reply when that end moved, otherwise nothing. + */ +function retargetTiltAfterZoom(m) { + if (m.kind !== 'tilt' || m.mode !== 'once' || m.askedPitch === undefined) + return {}; + const from = _viewer.camera.pitch; + if (!(m.askedPitch > from)) return {}; + const limit = tiltUpLimit({ target: m.target, pivot: m.hpr }); + const goal = Math.min(m.askedPitch, Math.max(from, limit)); + if (Math.abs(goal - m.goalPitch) < 1e-6) return {}; + m.goalPitch = goal; + m.startPitch = from; + m.elapsed = 0; + m.durationS = Math.max( + ONCE.durationS, + Math.abs(Cesium.Math.toDegrees(goal - from)) / TILT_TO_DEG_S, + ); + const limited = goal !== m.askedPitch; + return { + pitchDeg: Math.round(Cesium.Math.toDegrees(goal)), + note: limited + ? tiltHorizonNote(goal) + : `The tilt now reaches ${Math.round(Cesium.Math.toDegrees(goal))}°.`, + }; } /** diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index a01e3328e..9692edf94 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -471,6 +471,11 @@ export function createGevActionRunner({ amount: amt, [held.kind === 'tilt' ? 'tiltRangeAdjusted' : 'orbitRadiusAdjusted']: true, + // A tilt to an angle names the angle the new range now ends it at. + ...(held.pitchDeg !== undefined && { + pitchDeg: held.pitchDeg, + note: held.note, + }), }; } } From 198c2faf4f9cf11f266e145afaa68b00157b8dff Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 5 Nov 2026 16:14:27 -0800 Subject: [PATCH 145/156] =?UTF-8?q?Say=20the=20-45=C2=B0=20tilt=20default?= =?UTF-8?q?=20in=20the=20pitchDeg=20description=20again?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The trim that fitted the tilt wording under the Realtime prompt ceiling dropped the -45° default from pitchDeg, so the model no longer read why a bare tilt goes oblique. pitchDeg reads 'Absolute; -45 default, -89 top-down.' again, and the direction description becomes 'Pan/rotate/tilt steps; orbit/rotate_globe default right.' to pay for it: the Overpass prompt measures 55,499, 1 under the 55,500 ceiling. The schema pin and the measured-size note follow. --- server/providers/openai/toolDescriptions.js | 4 ++-- src/radioMarkup.test.mjs | 8 +++++--- src/voice/actionSchemas.test.mjs | 4 ++-- src/voice/pointAndAsk.test.mjs | 8 ++++---- 4 files changed, 13 insertions(+), 11 deletions(-) diff --git a/server/providers/openai/toolDescriptions.js b/server/providers/openai/toolDescriptions.js index f4000ddad..c7e78c7d6 100644 --- a/server/providers/openai/toolDescriptions.js +++ b/server/providers/openai/toolDescriptions.js @@ -523,7 +523,7 @@ export const ACTION_DESCRIPTIONS = { properties: { direction: { description: - 'Needed for pan/rotate/tilt steps; orbit/rotate_globe default right.', + 'Pan/rotate/tilt steps; orbit/rotate_globe default right.', $position: 2, }, mode: { @@ -532,7 +532,7 @@ export const ACTION_DESCRIPTIONS = { $position: 2, }, pitchDeg: { - description: 'Absolute; -89 top-down.', + description: 'Absolute; -45 default, -89 top-down.', $position: 1, }, }, diff --git a/src/radioMarkup.test.mjs b/src/radioMarkup.test.mjs index 3c59d989d..324adceb5 100644 --- a/src/radioMarkup.test.mjs +++ b/src/radioMarkup.test.mjs @@ -232,14 +232,16 @@ 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 an absolute pitchDeg and says tilt steps need a - // direction; check them, then restore the legacy fields for the pin. + // Tilt to an angle adds an absolute pitchDeg with its -45° default, and + // names tilt steps among the direction's uses; check them, then restore + // the legacy fields for the pin. assert.equal( camera.parameters.properties.direction.description, - 'Needed for pan/rotate/tilt steps; orbit/rotate_globe default right.', + 'Pan/rotate/tilt steps; orbit/rotate_globe default right.', ); assert.equal(camera.parameters.properties.pitchDeg.type, 'number'); assert.match(camera.parameters.properties.pitchDeg.description, /^Absolute;/); + assert.match(camera.parameters.properties.pitchDeg.description, /-45 default/); 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.'; diff --git a/src/voice/actionSchemas.test.mjs b/src/voice/actionSchemas.test.mjs index fa9d40bec..296ae0251 100644 --- a/src/voice/actionSchemas.test.mjs +++ b/src/voice/actionSchemas.test.mjs @@ -57,8 +57,8 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea // tightened area tool wording follow review. // control_cockpit states the own-layer entry rule (Contacts not required). // move_camera tilt takes an optional pitchDeg angle, described as - // absolute, with steps sent as a direction. - '17e54c1a04054edf48f2eddcf88c60ac66ec7c885fb48596b778bfee0be8a544', + // absolute with its -45 default, with steps sent as a direction. + '6a0d154e68beb787c4e20f12d29a5c86577dc343f3e71d8b5651998ea45d8c3c', ); }); diff --git a/src/voice/pointAndAsk.test.mjs b/src/voice/pointAndAsk.test.mjs index c413477b8..5d6d0fdd1 100644 --- a/src/voice/pointAndAsk.test.mjs +++ b/src/voice/pointAndAsk.test.mjs @@ -350,10 +350,10 @@ test('deixis prompt lint: one short rule, and every sentinel it names exists in // the analyst area scopes) raised the ceiling to 55,500; tightening the // AREAS rule and the area tool descriptions left it at 55,103 as measured // below (every tool, osm_query included); main's Sea Marks and Ski Pistes - // layers bring it to 55,210, and the tilt pitchDeg argument with main's - // cockpit entry rule to 55,337. integrationContract's Overpass prompt is - // the tightest, at 55,497 (3 under). A new tool or rule has to fit under - // this ceiling. + // layers bring it to 55,210, and the tilt pitchDeg argument (with its -45 + // default) and main's cockpit entry rule to 55,339. integrationContract's + // Overpass prompt is the tightest, at 55,499 (1 under). A new tool or rule + // has to fit under this ceiling. const size = instructions.length + JSON.stringify(GEV_REALTIME_TOOLS).length; assert.ok(size <= 55_500, `prompt size ${size}`); }); From f332150689a889718b1389dc094470d44ca46bd9 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 5 Nov 2026 16:16:05 -0800 Subject: [PATCH 146/156] fix(camera): hold the globe recovery until every pointer is released MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The held-pointer guard was one flag, so with two fingers down, lifting one cleared it and the recovery turned the view down under the other (pitch -42° to -90°). It now tracks each pressed pointer by id and defers until the last is released. A release outside the window that the page never sees could leave the recovery deferred for good. A lost window focus, a hidden page or a lost pointer capture now releases every held pointer, and a pointer that moves with no button down is released. --- docs/CURRENT-STATE.md | 8 ++- src/app/application.test.mjs | 96 ++++++++++++++++++++++++++++++++++++ src/app/viewer.js | 43 +++++++++++----- 3 files changed, 132 insertions(+), 15 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 545c4efd8..08cdf3a49 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -104,8 +104,12 @@ other ways a move can end looking away, such as a saved or shared view that did. 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, a camera flight in progress, a Director scene while it plays (holds included), a pointer held -down on the globe (a drag held still) and the 2D and Columbus modes; a check a -flight, scene or held pointer defers runs once it ends. A Director +down on the globe (a drag held still; with several fingers down, until the +last is released) and the 2D and Columbus modes; a check a flight, scene or +held pointer defers runs once it ends. A release outside the window that the +page never sees counts as given once the window loses focus, the page is +hidden, the canvas loses the pointer's capture, or the pointer moves with no +button down. A Director shot loaded or replayed outside a run is not a playing scene. A view that loses its surface without a camera move (a resize, or turning the scope on) is left as it is until the next move. A voice turn requested while the camera is tracked is diff --git a/src/app/application.test.mjs b/src/app/application.test.mjs index e25b2ce11..c01878d89 100644 --- a/src/app/application.test.mjs +++ b/src/app/application.test.mjs @@ -811,6 +811,102 @@ test('the recovery waits while a pointer is held on the globe', () => { assert.equal(timers.pending(), 0, 'disposed recovery holds no listeners'); }); +/** A held-pointer recovery over a canvas in a document in a window. */ +function heldPointerRecovery() { + const timers = fakeTimers(); + const { camera, scene, viewer } = realCamera({ width: 1225, height: 923 }); + const window = new EventTarget(); + const document = Object.assign(new EventTarget(), { + defaultView: window, + visibilityState: 'visible', + }); + scene.canvas = Object.assign(new EventTarget(), { + clientWidth: 1225, + clientHeight: 923, + ownerDocument: document, + }); + const dispose = installGlobeInViewRecovery(viewer, timers); + const pointer = (target, type, pointerId, init = {}) => + target.dispatchEvent( + Object.assign(new Event(type), { pointerId, ...init }), + ); + /** Press, move and hold still: the check defers while a pointer is held. */ + const holdStill = () => { + aim(camera, reportedStuckView); + camera.moveStart.raiseEvent(); + for (let i = 0; i < 8; i += 1) timers.tick(); + camera.moveEnd.raiseEvent(); + for (let i = 0; i < 8; i += 1) timers.tick(); + }; + const settle = () => { + for (let i = 0; i < 3; i += 1) timers.tick(); + }; + const recovered = () => + Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6); + return { + camera, + document, + window, + dispose, + pointer, + holdStill, + settle, + recovered, + canvas: scene.canvas, + }; +} + +// Releasing one of two fingers cleared the held flag, and the recovery +// turned the view down under the finger still on the glass. +test('the recovery waits until every held pointer is released', () => { + const h = heldPointerRecovery(); + h.pointer(h.canvas, 'pointerdown', 1); + h.pointer(h.canvas, 'pointerdown', 2); + h.holdStill(); + h.pointer(h.document, 'pointerup', 1); + h.settle(); + assert.equal(h.recovered(), false, 'left alone under the second finger'); + assert.equal(globeOutOfFrame(h.camera), true); + h.pointer(h.document, 'pointercancel', 2); + h.settle(); + assert.equal(h.recovered(), true, 'recovered once both are released'); + h.dispose(); +}); + +// A release the page never sees (the pointer let go outside the window) +// must not hold the recovery off for good. +for (const [label, lose] of [ + ['the window loses focus', (h) => h.window.dispatchEvent(new Event('blur'))], + [ + 'the page is hidden', + (h) => { + h.document.visibilityState = 'hidden'; + h.document.dispatchEvent(new Event('visibilitychange')); + }, + ], + [ + 'the canvas loses the pointer capture', + (h) => h.pointer(h.canvas, 'lostpointercapture', 1), + ], + [ + 'the pointer moves with no button down', + (h) => h.pointer(h.document, 'pointermove', 1, { buttons: 0 }), + ], +]) { + test(`a held pointer whose release is lost stops deferring when ${label}`, () => { + const h = heldPointerRecovery(); + h.pointer(h.canvas, 'pointerdown', 1); + h.holdStill(); + h.pointer(h.document, 'pointermove', 1, { buttons: 1 }); + h.settle(); + assert.equal(h.recovered(), false, 'left alone while held'); + lose(h); + h.settle(); + assert.equal(h.recovered(), true, 'recovered once the hold is let go'); + h.dispose(); + }); +} + function fakeTimers() { let next = 1; const pending = new Map(); diff --git a/src/app/viewer.js b/src/app/viewer.js index 12ef67269..fc75c0498 100644 --- a/src/app/viewer.js +++ b/src/app/viewer.js @@ -207,26 +207,43 @@ export function installGlobeInViewRecovery( throw new TypeError('A complete Cesium viewer is required'); // A pointer held on the globe is a gesture in progress, even when it holds - // still for half a second: the check waits for its release. + // still for half a second: the check waits until every pointer pressed on + // it (each finger of a pinch) is released. A release the page never sees, + // outside the window, is taken as given when the window loses focus, the + // page is hidden, the canvas loses the pointer's capture, or the pointer + // moves with no button down. const canvas = scene.canvas; const releaseTarget = canvas?.ownerDocument ?? canvas; - let pointerHeld = false; - const onPointerDown = () => { - pointerHeld = true; + const releaseWindow = canvas?.ownerDocument?.defaultView; + const heldPointers = new Set(); + const onPointerDown = (event) => heldPointers.add(event?.pointerId); + const onPointerUp = (event) => heldPointers.delete(event?.pointerId); + const onPointerMove = (event) => { + if (event?.buttons === 0) heldPointers.delete(event.pointerId); }; - const onPointerUp = () => { - pointerHeld = false; + const releaseAll = () => heldPointers.clear(); + const onVisibilityChange = () => { + if (canvas.ownerDocument.visibilityState === 'hidden') releaseAll(); }; - canvas?.addEventListener?.('pointerdown', onPointerDown); - releaseTarget?.addEventListener?.('pointerup', onPointerUp); - releaseTarget?.addEventListener?.('pointercancel', onPointerUp); + const listeners = [ + [canvas, 'pointerdown', onPointerDown], + [canvas, 'lostpointercapture', onPointerUp], + [releaseTarget, 'pointerup', onPointerUp], + [releaseTarget, 'pointercancel', onPointerUp], + [releaseTarget, 'pointermove', onPointerMove], + [canvas?.ownerDocument, 'visibilitychange', onVisibilityChange], + [releaseWindow, 'blur', releaseAll], + ]; + for (const [target, type, listener] of listeners) + target?.addEventListener?.(type, listener); // Returns false while a flight, a paused scene or a held pointer defers the // check, so the caller can try again once it ends. const recover = () => { if (!isFreeGlobeCamera(viewer)) return true; // Cesium has no public running-flight flag. - if (camera._currentFlight || paused() || pointerHeld) return false; + if (camera._currentFlight || paused() || heldPointers.size > 0) + return false; const height = camera.positionCartographic?.height; if ( !(height > minHeight) || @@ -310,9 +327,9 @@ export function installGlobeInViewRecovery( stopSettling(); removeMoveEnd(); removeMoveStart(); - canvas?.removeEventListener?.('pointerdown', onPointerDown); - releaseTarget?.removeEventListener?.('pointerup', onPointerUp); - releaseTarget?.removeEventListener?.('pointercancel', onPointerUp); + for (const [target, type, listener] of listeners) + target?.removeEventListener?.(type, listener); + heldPointers.clear(); }; } From 42fa974ee38a07293df64b70feabc45787737ed5 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 5 Nov 2026 16:27:19 -0800 Subject: [PATCH 147/156] fix(wildlife): keep each failed source's retry timing after a partial failure A partial failure reset the layer's cooldown, so a camera move retried at once. Acartia's error was discarded, an absent X-Upstream-Status hid the HTTP 503 (the proxy's daily-budget reply), and a grid schema error masked the observation 503. Each failed source now keeps its status and Retry-After, the HTTP status counts when no upstream status is sent, and the layer waits for the longest 429/5xx cooldown among them. The shown error is the one that explains the cooldown. --- docs/CURRENT-STATE.md | 6 +- .../wildlifeSightings/acquisition.test.mjs | 136 +++++++++++++++++- src/layers/wildlifeSightings/lifecycle.js | 79 ++++++---- src/layers/wildlifeSightings/source.js | 41 +++++- 4 files changed, 229 insertions(+), 33 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 96d85431f..5166e4a2a 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -124,7 +124,11 @@ with retry timing; logs exclude request URLs and upstream exception text. A partial failure still draws what arrived: a failed iNaturalist request, or one failed half of an antimeridian view, keeps a good Acartia reply and the other half, retains that area's last iNaturalist rows and reports DEGRADED. -The update fails outright only when every requested source failed. +The update fails outright only when every requested source failed. Each failed +source keeps its HTTP status and `Retry-After` (the HTTP status counts when no +`X-Upstream-Status` is sent), and the next request waits out the longest 429 or +5xx cooldown among them. One update requests both sources, so the cooldown +holds the whole layer; a fully successful refresh clears it. `/api/acartia/sightings` adds Salish Sea marine mammals within 45.5–50.5° N, 126–121° W, cached ten minutes and stale for at most 24 h. A valid empty diff --git a/src/layers/wildlifeSightings/acquisition.test.mjs b/src/layers/wildlifeSightings/acquisition.test.mjs index ea1d6fc1a..d343ad9b0 100644 --- a/src/layers/wildlifeSightings/acquisition.test.mjs +++ b/src/layers/wildlifeSightings/acquisition.test.mjs @@ -833,10 +833,10 @@ test('an empty tile retires its old zoom cells while uncovered density survives assert.equal(h.state.gridTotal, 2); }); -test('only marked upstream failures create cooldown; local pacing, network and schema errors can retry on the next view', async () => { +test('only 429 and 5xx failures create cooldown; client, network and schema errors can retry on the next view', async () => { for (const error of [ - Object.assign(new Error('iNaturalist HTTP 503'), { - status: 503, + Object.assign(new Error('iNaturalist HTTP 400'), { + status: 400, upstreamStatus: 0, retryAfterSec: 30, }), @@ -873,6 +873,19 @@ test('only marked upstream failures create cooldown; local pacing, network and s 2, 'actual Retry-After is honored without imposing a 30s local floor', ); + // The proxy's daily-budget 503 has no upstream status. Its HTTP status and + // Retry-After still govern the next request. + const budget = harness({ + getSnapshot: async () => { + throw Object.assign(new Error('iNaturalist HTTP 503'), { + status: 503, + upstreamStatus: 0, + retryAfterSec: 3600, + }); + }, + }); + await budget.layer.update(); + assert.equal(budget.layer.getStats().retryInSec, 3600); }); test('stale cached tiles with local pacing metadata keep their display and do not enter cooldown', async () => { @@ -942,3 +955,120 @@ test('an explicit upstream Retry-After zero retries immediately while a missing } } }); + +const salishSea = { south: 47.5, west: -122.5, north: 47.7, east: -122.2 }; +const inatRow = { + stableId: 'inat:301', + origin: 'inaturalist', + lat: 47.6, + lon: -122.3, + observedAt: NOW - 3_600_000, + iconic: 'Mammalia', +}; +const acartiaRow = { + stableId: 'acartia:301', + origin: 'acartia', + lat: 47.6, + lon: -122.35, + observedAt: NOW - 3_600_000, + cetacean: true, + quality: 'research', + provider: { name: 'Provider', entity: null }, +}; +const emptyGrid = () => + Response.json({ cells: [], totalResults: 0, fetchedAt: NOW }); +const unavailable = (headers = {}) => + new Response('', { + status: 503, + headers: { 'Retry-After': '120', ...headers }, + }); + +// Each case runs the real source and lifecycle. A healthy source draws its +// rows while the failed source's Retry-After holds the next acquisition. +for (const [name, respond] of [ + [ + 'Acartia alone returns 503', + (url) => + url.startsWith('/api/acartia') + ? unavailable() + : url.startsWith('/api/inaturalist/grid?') + ? emptyGrid() + : Response.json({ + rows: [inatRow], + totalResults: 1, + fetchedAt: NOW, + }), + ], + [ + 'iNaturalist returns 503 without X-Upstream-Status', + (url) => + url.startsWith('/api/acartia') + ? Response.json({ rows: [acartiaRow], fetchedAt: NOW }) + : url.startsWith('/api/inaturalist/grid?') + ? emptyGrid() + : unavailable(), + ], + [ + 'iNaturalist returns 503 while every grid tile fails its schema', + (url) => + url.startsWith('/api/acartia') + ? Response.json({ rows: [acartiaRow], fetchedAt: NOW }) + : url.startsWith('/api/inaturalist/grid?') + ? Response.json({ cells: 'malformed' }) + : unavailable({ 'X-Upstream-Status': '503' }), + ], +]) { + test(`a partial failure keeps its Retry-After: ${name}`, async () => { + let healthy = false; + let requests = 0; + const source = createWildlifeSightingsSource({ + now: () => NOW, + fetchImpl: async (url) => { + if (!url.startsWith('/api/inaturalist/grid?')) requests++; + if (healthy) + return url.startsWith('/api/acartia') + ? Response.json({ rows: [acartiaRow], fetchedAt: NOW }) + : url.startsWith('/api/inaturalist/grid?') + ? emptyGrid() + : Response.json({ + rows: [inatRow], + totalResults: 1, + fetchedAt: NOW, + }); + return respond(url); + }, + }); + const h = harness(source); + h.bounds(salishSea); + h.state.enabled = false; + h.layer.enable(); + const first = h.layer.update(); + await h.advance(300); + await first; + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(h.state.renderedRows.length, 1, 'the healthy source draws'); + assert.ok(h.state.error, 'the failed source is reported'); + // The source clock stays at NOW; the layer settled 300 ms of camera time. + assert.equal(h.state.retryAt, NOW + 300 + 120_000); + assert.equal(h.layer.getStats().retryInSec, 120); + const attempted = requests; + h.bounds({ ...salishSea, north: 47.8 }); + h.moveEnd(); + assert.equal([...h.timers.values()][0]?.delay, 120_000); + await h.advance(119_999); + await h.layer.update(); + assert.equal( + requests, + attempted, + 'neither a camera move nor manager polling skips the cooldown', + ); + healthy = true; + await h.advance(1); + await new Promise((resolve) => setImmediate(resolve)); + assert.ok(requests > attempted, 'acquisition resumes at the deadline'); + assert.equal(h.state.error, null); + assert.equal(h.state.retryAt, 0, 'a fully successful refresh clears it'); + assert.equal(h.layer.getStats().retryInSec, undefined); + assert.equal(h.state.renderedRows.length, 2); + }); +} diff --git a/src/layers/wildlifeSightings/lifecycle.js b/src/layers/wildlifeSightings/lifecycle.js index 6da425c7a..3b2a2999a 100644 --- a/src/layers/wildlifeSightings/lifecycle.js +++ b/src/layers/wildlifeSightings/lifecycle.js @@ -1,6 +1,9 @@ import { msg } from '../../i18n/marker.js'; import { getWildlifeHeightBand, rowInWildlifeBoxes } from './model.js'; -import { combineWildlifeGridSnapshots } from './source.js'; +import { + combineWildlifeGridSnapshots, + wildlifeFailureRetryable, +} from './source.js'; import { wildlifeMeasure } from './performance.js'; import { isRecentWildlifeSighting } from './records.js'; import { @@ -92,25 +95,26 @@ export function createLifecycle({ state, services, parts }) { state.pendingParams = null; } - function applyRetry(error, retryAfterSec = null) { - // Only upstream failures impose cooldown; network/schema errors retry on pan. - const status = - error?.upstreamStatus ?? - error?.status ?? - Number(/HTTP (\d{3})/.exec(error?.message || '')?.[1]); - const retryDelays = [error?.retryAfterSec, retryAfterSec].filter( - (value) => Number.isFinite(value) && value >= 0, - ); - const retryFromSnapshot = - Number.isFinite(retryAfterSec) && retryAfterSec >= 0; - if (!(status === 429 || status >= 500) && !retryFromSnapshot) { + function applyRetry(errors, retryAfterSec = null) { + // Only upstream failures impose cooldown; network/schema errors retry on + // pan. Several sources can fail together: wait for the longest of them. + const failures = [errors].flat().filter(wildlifeFailureRetryable); + const snapshotDelay = + Number.isFinite(retryAfterSec) && retryAfterSec >= 0 + ? retryAfterSec * 1000 + : null; + if (!failures.length && snapshotDelay === null) { state.failures = 0; state.retryAt = 0; return; } - const backoff = retryDelays.length - ? Math.max(...retryDelays) * 1000 - : retryDelayMs(state.failures || 0); + const delays = failures.map((error) => + Number.isFinite(error.retryAfterSec) && error.retryAfterSec >= 0 + ? error.retryAfterSec * 1000 + : retryDelayMs(state.failures || 0), + ); + if (snapshotDelay !== null) delays.push(snapshotDelay); + const backoff = Math.max(...delays); state.failures = backoff; state.retryAt = services.now() + backoff; } @@ -404,16 +408,39 @@ export function createLifecycle({ state, services, parts }) { }, ) : null; - const sourceError = - gridError || - staleError || - (snapshot.sources?.inaturalist?.ok === false - ? snapshot.sources.inaturalist.error || + const inat = snapshot.sources?.inaturalist; + const acartia = snapshot.sources?.acartia; + // Keep each failed source's status and Retry-After; the displayed error + // is the one that explains the cooldown. + const sourceErrors = [ + gridError, + staleError, + inat?.ok === false + ? inat.error || new Error(msg('Wildlife sightings source unavailable')) - : null) || - (snapshot.sources?.acartia?.ok === false - ? new Error(msg('Acartia feed unavailable')) - : null); + : null, + acartia?.ok === false + ? Object.assign(new Error(msg('Acartia feed unavailable')), { + ...(acartia.error?.status + ? { status: acartia.error.status } + : {}), + ...(acartia.error?.upstreamStatus + ? { upstreamStatus: acartia.error.upstreamStatus } + : {}), + ...(Number.isFinite(acartia.error?.retryAfterSec) + ? { retryAfterSec: acartia.error.retryAfterSec } + : {}), + }) + : null, + ].filter(Boolean); + const sourceError = + sourceErrors.find( + (error) => + wildlifeFailureRetryable(error) || + Number.isFinite(error.retryAfterSec), + ) || + sourceErrors[0] || + null; state.stale = !sourceError && (snapshot.stale === true || grid?.stale === true); const fetchedAt = [snapshot.fetchedAt, grid?.fetchedAt].filter( @@ -438,7 +465,7 @@ export function createLifecycle({ state, services, parts }) { if (sourceError) { failParams(params); applyRetry( - sourceError, + sourceErrors, retryAfter.length ? Math.max(...retryAfter) : null, ); } else { diff --git a/src/layers/wildlifeSightings/source.js b/src/layers/wildlifeSightings/source.js index 8bd41bf1d..aa218f34e 100644 --- a/src/layers/wildlifeSightings/source.js +++ b/src/layers/wildlifeSightings/source.js @@ -102,6 +102,36 @@ function validProjectedRows(rows, origin) { ); } +/** Return the status a failure reports. The proxy marks upstream failures + * with X-Upstream-Status; without it the HTTP status is the signal. */ +function wildlifeFailureStatus(error) { + return ( + error?.upstreamStatus || + error?.status || + Number(/HTTP (\d{3})/.exec(error?.message || '')?.[1]) || + 0 + ); +} + +/** Whether a failure asks clients to wait before the next request. */ +export function wildlifeFailureRetryable(error) { + const status = wildlifeFailureStatus(error); + return status === 429 || status >= 500; +} + +/** Prefer the failure that carries retry timing, then the longest wait. */ +export function preferredWildlifeFailure(errors) { + const failures = errors.filter(Boolean); + const retryable = failures.filter(wildlifeFailureRetryable); + if (!retryable.length) return failures[0] || null; + return retryable.reduce((best, error) => + (Number.isFinite(error.retryAfterSec) ? error.retryAfterSec : -1) > + (Number.isFinite(best.retryAfterSec) ? best.retryAfterSec : -1) + ? error + : best, + ); +} + /** Merge occupied cells incrementally; neighboring UTFGrid hit tiles can * report the same geographic cell, so source ids remain the dedupe key. */ export function combineWildlifeGridSnapshots(snapshots, error = null) { @@ -421,7 +451,7 @@ export function createWildlifeSightingsSource({ ), ); signal?.throwIfAborted(); - const failure = failures.find(Boolean); + const failure = preferredWildlifeFailure(failures); if (!snapshots.some(Boolean) && failure) throw failure; return combineWildlifeGridSnapshots(snapshots.filter(Boolean), failure); } @@ -494,11 +524,15 @@ export function createWildlifeSightingsSource({ }); // Fail outright only when nothing succeeded; otherwise draw what arrived // and report the failed source in its per-source status. + const acartiaError = + useAcartia && acartiaResult.status === 'rejected' + ? acartiaResult.reason + : null; if ( !inatSnapshots.length && (!useAcartia || acartiaResult.status !== 'fulfilled') ) - throw inatError; + throw preferredWildlifeFailure([inatError, acartiaError]); const inatRows = inatSnapshots.flatMap((snapshot) => snapshot.rows); const acartiaSnapshot = acartiaResult.status === 'fulfilled' ? acartiaResult.value : null; @@ -553,12 +587,13 @@ export function createWildlifeSightingsSource({ ...(inatError ? { error: inatError, failedBoxes } : {}), }, acartia: { - ok: !useAcartia || acartiaResult.status === 'fulfilled', + ok: !acartiaError, stale: acartiaStale, fetchedAt: Number.isFinite(acartiaSnapshot?.fetchedAt) ? acartiaSnapshot.fetchedAt : null, count: drawnAcartia, + ...(acartiaError ? { error: acartiaError } : {}), }, }, }; From 3438a88cc7780058fb971ec3b087addcdbdc404e Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 5 Nov 2026 16:31:46 -0800 Subject: [PATCH 148/156] Ease a late zoomed tilt to its new limit instead of jumping there MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 400 km, a tilt to -5° that a zoom out x2.5 caught at -8.0005° reported -8°, and the next frame jumped to -9.407° and ended there. The zoom kept the camera's pitch as the goal when it was already past the new range's horizon limit, but at the same pivot pitch the longer range looks down more steeply, so the next lookAt dropped the camera 1.4° at once. The zoom now re-solves the pivot pitch so the new range keeps the camera's pitch, and aims the tilt at the asked angle or the new limit, even when that limit is below where the camera stands: the tilt eases down to it. The zoom's reply names that end, and the camera reaches it with no frame moving more than 0.1°. --- docs/CURRENT-STATE.md | 2 +- src/cameraTilt.test.mjs | 29 ++++++++++++++++++++++ src/cameraVerbs.js | 54 ++++++++++++++++++++++++++++++++--------- 3 files changed, 72 insertions(+), 13 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index d41c435f1..d5ef4af05 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5590,7 +5590,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. Angles and limits are the camera's own pitch; at regional range the pivot's pitch is up to about 8° shallower (the Earth curves away), so the swing solves for the pivot pitch that gives the camera the angle asked for. 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. Beyond about 17 km of range the swing never hands over to an in-place tilt (that would lift the crosshair onto the sky); it keeps the camera at least 0.5° above the place's own horizon, which from roughly 500 km stops it short of -5° with the place still centred: a tilt to an angle beyond that stops at the angle reached and says so (`pitchDeg` is the angle reached, with a `note`), a continuous tilt stops there, and a further tilt up is refused as at the limit. 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 cap: 1,000 km for a tilt, where pivoting stops, and 5,000 km for an orbit) is reported as not zoomed. A zoom during a tilt to an angle re-judges the place's horizon limit at the new range: when that moves where the tilt ends, the tilt eases from where it is to the new end, and the zoom's result names that angle (`pitchDeg`) with a `note`. 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. An angle the camera already holds answers `unchanged` but still takes navigation, so it stops a running motion and releases the follow camera. A tilt left or right 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 (moving to a later fly-to in the same reply as soon as it begins, whatever the earlier one reports), so it neither cancels the flight nor gets replaced by it; if the flight is cancelled, finds nothing or times out, the user grabs, touches or scrolls the map during it, or another navigation (a location-panel search, share link or file import, which leave the flight flying) takes the camera during it, the motion is not run and says so. A motion that runs after a fly-to of the same reply has already found nothing (Gemini runs one turn's calls one by one, and passes its turn as the reply) is refused the same way rather than moving the old view; a motion in a later reply runs. `stop` and `rotate_globe` do not wait, and a motion sent before its fly-to is not reordered: it runs at once, and the fly-to's result names the motion it stopped (`stoppedMotion`). 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. Angles and limits are the camera's own pitch; at regional range the pivot's pitch is up to about 8° shallower (the Earth curves away), so the swing solves for the pivot pitch that gives the camera the angle asked for. 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. Beyond about 17 km of range the swing never hands over to an in-place tilt (that would lift the crosshair onto the sky); it keeps the camera at least 0.5° above the place's own horizon, which from roughly 500 km stops it short of -5° with the place still centred: a tilt to an angle beyond that stops at the angle reached and says so (`pitchDeg` is the angle reached, with a `note`), a continuous tilt stops there, and a further tilt up is refused as at the limit. 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 cap: 1,000 km for a tilt, where pivoting stops, and 5,000 km for an orbit) is reported as not zoomed. A zoom during a tilt to an angle re-judges the place's horizon limit at the new range: when that moves where the tilt ends, the tilt eases from where it is to the new end (holding the camera's pitch through the range change, and easing back down to a limit it is already past rather than jumping there), and the zoom's result names that angle (`pitchDeg`) with a `note`. 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. An angle the camera already holds answers `unchanged` but still takes navigation, so it stops a running motion and releases the follow camera. A tilt left or right 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 (moving to a later fly-to in the same reply as soon as it begins, whatever the earlier one reports), so it neither cancels the flight nor gets replaced by it; if the flight is cancelled, finds nothing or times out, the user grabs, touches or scrolls the map during it, or another navigation (a location-panel search, share link or file import, which leave the flight flying) takes the camera during it, the motion is not run and says so. A motion that runs after a fly-to of the same reply has already found nothing (Gemini runs one turn's calls one by one, and passes its turn as the reply) is refused the same way rather than moving the old view; a motion in a later reply runs. `stop` and `rotate_globe` do not wait, and a motion sent before its fly-to is not reordered: it runs at once, and the fly-to's result names the motion it stopped (`stoppedMotion`). 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 0dcb2d1b0..738c71e88 100644 --- a/src/cameraTilt.test.mjs +++ b/src/cameraTilt.test.mjs @@ -686,3 +686,32 @@ test('a voice zoom during a tilt to an angle passes on the new end angle', async ); assert.match(zoom.note, /horizon/); }); + +// Late in the tilt the camera is already past the angle the new range's +// horizon allows (from 400 km at -8°, a zoom out x2.5 reported -8° and the +// next frame jumped to -9.4°): it eases to that limit with no jump, and the +// zoom's reply names the angle it ends at, not the one it stood at. +test('a late zoom out during a tilt eases to the new limit and reports it', (t) => { + const h = harness(t, { height: 400_000 }); + moveCamera({ motion: 'tilt', pitchDeg: -5 }); + for (let i = 0; i < 70; i += 1) h.tick(); + const zoom = adjustOrbitRange(2.5); + assert.equal(zoom.changed, true); + let last = pitchDeg(h.camera); + let maxStep = 0; + for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) { + h.tick(); + maxStep = Math.max(maxStep, Math.abs(pitchDeg(h.camera) - last)); + last = pitchDeg(h.camera); + } + assert.equal(getActiveCameraMotion(), null, 'tilt must finish'); + assert.ok(maxStep < 0.5, `pitch jumped ${maxStep}° in one frame`); + assert.ok(h.viewTarget(), 'the crosshair is on the sky'); + // Replies give whole degrees: the reported angle is the reached one rounded. + assert.equal( + zoom.pitchDeg, + Math.round(pitchDeg(h.camera)), + `camera at ${pitchDeg(h.camera)}°, zoom reported ${zoom.pitchDeg}°`, + ); + assert.match(zoom.note, /horizon/); +}); diff --git a/src/cameraVerbs.js b/src/cameraVerbs.js index 0681d6a2b..437688d7a 100644 --- a/src/cameraVerbs.js +++ b/src/cameraVerbs.js @@ -1378,12 +1378,22 @@ function tiltClearanceBinds(range) { */ function tiltUpLimit({ target, pivot }) { if (!pivot || tiltClearanceBinds(pivot.range)) return PITCH_MAX; - const pitch = Math.max(pivot.pitch, tiltPivotPitchMax(pivot.range)); - // The camera's position for that pivot pitch, as lookAt places it. + return Math.min( + PITCH_MAX, + pivotCameraPitch( + target, + pivot, + Math.max(pivot.pitch, tiltPivotPitchMax(pivot.range)), + ), + ); +} + +/** The camera pitch lookAt gives for this pivot at the given pivot pitch. */ +function pivotCameraPitch(target, { heading, range }, pitch) { const offset = new Cesium.Cartesian3( - -pivot.range * Math.cos(pitch) * Math.sin(pivot.heading), - -pivot.range * Math.cos(pitch) * Math.cos(pivot.heading), - -pivot.range * Math.sin(pitch), + -range * Math.cos(pitch) * Math.sin(heading), + -range * Math.cos(pitch) * Math.cos(heading), + -range * Math.sin(pitch), ); const eye = Cesium.Matrix4.multiplyByPoint( Cesium.Transforms.eastNorthUpToFixedFrame(target), @@ -1394,9 +1404,8 @@ function tiltUpLimit({ target, pivot }) { Cesium.Cartesian3.normalize(look, look); const ellipsoid = _viewer.scene.globe?.ellipsoid ?? Cesium.Ellipsoid.WGS84; const upAtEye = ellipsoid.geodeticSurfaceNormal(eye, new Cesium.Cartesian3()); - return Math.min( - PITCH_MAX, - Math.asin(Cesium.Math.clamp(Cesium.Cartesian3.dot(look, upAtEye), -1, 1)), + return Math.asin( + Cesium.Math.clamp(Cesium.Cartesian3.dot(look, upAtEye), -1, 1), ); } @@ -2092,16 +2101,37 @@ export function adjustOrbitRange(factor) { /** * A zoom changed the range a tilt to an angle holds, and with it the place's * horizon limit: aim the tilt at the angle asked for or the new limit, - * whichever comes first, from where the camera is now. Returns the end angle - * and a note for the reply when that end moved, otherwise nothing. + * whichever comes first, from where the camera is now. The pivot pitch is + * re-solved so the new range keeps the camera's pitch, and a limit the camera + * is already past is eased down to rather than jumped to. Returns the end + * angle and a note for the reply when that end moved, otherwise nothing. */ function retargetTiltAfterZoom(m) { if (m.kind !== 'tilt' || m.mode !== 'once' || m.askedPitch === undefined) return {}; const from = _viewer.camera.pitch; if (!(m.askedPitch > from)) return {}; - const limit = tiltUpLimit({ target: m.target, pivot: m.hpr }); - const goal = Math.min(m.askedPitch, Math.max(from, limit)); + if (m.hpr) { + // At the same pivot pitch a longer range looks down more steeply (the + // Earth curves away): find the pivot pitch that holds this camera pitch. + for (let i = 0; i < 4; i += 1) { + const error = from - pivotCameraPitch(m.target, m.hpr, m.hpr.pitch); + if (Math.abs(error) < 1e-7) break; + m.hpr.pitch = Cesium.Math.clamp( + m.hpr.pitch + error, + -Cesium.Math.PI_OVER_TWO, + 0, + ); + } + } + const limit = + m.hpr && !tiltClearanceBinds(m.hpr.range) + ? Math.min( + PITCH_MAX, + pivotCameraPitch(m.target, m.hpr, tiltPivotPitchMax(m.hpr.range)), + ) + : PITCH_MAX; + const goal = Math.min(m.askedPitch, limit); if (Math.abs(goal - m.goalPitch) < 1e-6) return {}; m.goalPitch = goal; m.startPitch = from; From dac9b5627d629655304f11eddd5e45f861f512b9 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 5 Nov 2026 16:42:45 -0800 Subject: [PATCH 149/156] fix(wildlife): wait for the longest effective delay among every failed request One error was chosen before each failure's delay was known. An antimeridian box 400 hid the other box's 503 Retry-After; a grid tile 400 with Retry-After set a cooldown through the snapshot metadata; a short Retry-After on one tile undercut a sibling 5xx's default backoff; and a huge Retry-After made the deadline Infinity. Every failed request now keeps its status and Retry-After. One function gives each failure's delay: 4xx other than 429 none, 429 and 5xx their Retry-After or the default backoff. The layer waits for the longest, and shows the failure that set it. Retry-After is capped at one day, the proxy's own daily-budget deadline, and non-finite values are ignored. Each publish of one acquisition derives the default backoff from the backoff before it started, so repeated tile publishes no longer double it. --- .../wildlifeSightings/acquisition.test.mjs | 95 +++++++++++++++++++ src/layers/wildlifeSightings/lifecycle.js | 74 ++++++++------- src/layers/wildlifeSightings/policy.js | 3 + src/layers/wildlifeSightings/source.js | 89 +++++++++++++---- 4 files changed, 209 insertions(+), 52 deletions(-) diff --git a/src/layers/wildlifeSightings/acquisition.test.mjs b/src/layers/wildlifeSightings/acquisition.test.mjs index d343ad9b0..b4ac47813 100644 --- a/src/layers/wildlifeSightings/acquisition.test.mjs +++ b/src/layers/wildlifeSightings/acquisition.test.mjs @@ -1072,3 +1072,98 @@ for (const [name, respond] of [ assert.equal(h.state.renderedRows.length, 2); }); } + +// Every failed request keeps its own timing. The layer waits for the longest +// effective delay: 4xx other than 429 never waits, 429/5xx wait for their +// Retry-After or the default backoff. The shown error explains that wait. +async function settleThroughSource(fetchImpl, bounds = salishSea) { + const h = harness( + createWildlifeSightingsSource({ now: () => NOW, fetchImpl }), + ); + h.bounds(bounds); + h.state.enabled = false; + h.layer.enable(); + const first = h.layer.update(); + await h.advance(300); + await first; + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); + return h; +} +const tileX = (url) => + Number(new URL(url, 'https://example.test').searchParams.get('x')); +const observations = () => + Response.json({ rows: [inatRow], totalResults: 1, fetchedAt: NOW }); + +test('an antimeridian box 400 never hides the other box 503 Retry-After', async () => { + for (const westFails of [true, false]) { + const h = await settleThroughSource( + async (url) => { + if (url.startsWith('/api/inaturalist/grid?')) return emptyGrid(); + const west = + Number( + new URL(url, 'https://example.test').searchParams.get('west'), + ) > 0; + return west === westFails + ? new Response('', { status: 400 }) + : unavailable(); + }, + { south: 10, west: 179, north: 12, east: -179 }, + ); + assert.equal(h.state.error, 'iNaturalist HTTP 503'); + assert.equal(h.state.retryAt, NOW + 300 + 120_000); + assert.equal(h.layer.getStats().retryInSec, 120); + } +}); + +test('a grid tile 400 with Retry-After beside successful tiles creates no cooldown', async () => { + let tiles = new Set(); + const h = await settleThroughSource(async (url) => { + if (!url.startsWith('/api/inaturalist/grid?')) return observations(); + tiles.add(tileX(url)); + return tileX(url) === Math.min(...tiles) && tiles.size === 1 + ? new Response('', { status: 400, headers: { 'Retry-After': '120' } }) + : emptyGrid(); + }); + assert.ok(tiles.size > 1, 'the view spans several grid tiles'); + assert.equal(h.state.error, 'iNaturalist HTTP 400'); + assert.equal(h.state.retryAt, 0); + assert.equal(h.layer.getStats().retryInSec, undefined); +}); + +test('a short grid Retry-After never undercuts a sibling 5xx default backoff', async () => { + let failed = 0; + const h = await settleThroughSource(async (url) => { + if (!url.startsWith('/api/inaturalist/grid?')) return observations(); + failed++; + if (failed === 1) + return new Response('', { status: 503, headers: { 'Retry-After': '2' } }); + if (failed === 2) return new Response('', { status: 502 }); + return emptyGrid(); + }); + assert.ok(failed >= 2); + assert.equal(h.state.error, 'iNaturalist HTTP 502'); + assert.equal(h.state.retryAt, NOW + 300 + 30_000); + assert.equal(h.layer.getStats().retryInSec, 30); +}); + +test('an enormous Retry-After is capped at one day and a non-finite one uses the default backoff', async () => { + for (const [header, delay] of [ + ['1' + '0'.repeat(306), 86_400_000], + ['1' + '0'.repeat(400), 30_000], + ]) { + const h = await settleThroughSource(async (url) => + url.startsWith('/api/inaturalist/grid?') + ? emptyGrid() + : url.startsWith('/api/acartia') + ? Response.json({ rows: [], fetchedAt: NOW }) + : new Response('', { + status: 503, + headers: { 'Retry-After': header }, + }), + ); + assert.equal(h.state.error, 'iNaturalist HTTP 503'); + assert.ok(Number.isFinite(h.state.retryAt)); + assert.equal(h.state.retryAt, NOW + 300 + delay, header.length); + } +}); diff --git a/src/layers/wildlifeSightings/lifecycle.js b/src/layers/wildlifeSightings/lifecycle.js index 3b2a2999a..0fe430fbe 100644 --- a/src/layers/wildlifeSightings/lifecycle.js +++ b/src/layers/wildlifeSightings/lifecycle.js @@ -2,7 +2,10 @@ import { msg } from '../../i18n/marker.js'; import { getWildlifeHeightBand, rowInWildlifeBoxes } from './model.js'; import { combineWildlifeGridSnapshots, - wildlifeFailureRetryable, + preferredWildlifeFailure, + wildlifeFailureDelayMs, + wildlifeFailures, + wildlifeRetryAfterMs, } from './source.js'; import { wildlifeMeasure } from './performance.js'; import { isRecentWildlifeSighting } from './records.js'; @@ -12,11 +15,7 @@ import { wildlifeGridTiles, wildlifeGridCellId, } from './gridViewport.js'; -import { - wildlifeViewportBoxes, - snapWildlifeBox, - retryDelayMs, -} from './viewport.js'; +import { wildlifeViewportBoxes, snapWildlifeBox } from './viewport.js'; import { WILDLIFE_DEBOUNCE_MS, WILDLIFE_MAX_ROWS, @@ -95,25 +94,25 @@ export function createLifecycle({ state, services, parts }) { state.pendingParams = null; } - function applyRetry(errors, retryAfterSec = null) { - // Only upstream failures impose cooldown; network/schema errors retry on - // pan. Several sources can fail together: wait for the longest of them. - const failures = [errors].flat().filter(wildlifeFailureRetryable); - const snapshotDelay = - Number.isFinite(retryAfterSec) && retryAfterSec >= 0 - ? retryAfterSec * 1000 - : null; - if (!failures.length && snapshotDelay === null) { + function applyRetry( + errors, + retryAfterSec = null, + previousMs = state.failures || 0, + ) { + // Only 429/5xx failures impose cooldown; client, network and schema + // errors retry on pan. Several requests can fail together: wait for the + // longest effective delay among them. + const delays = [ + ...wildlifeFailures(errors).map((error) => + wildlifeFailureDelayMs(error, previousMs), + ), + wildlifeRetryAfterMs(retryAfterSec), + ].filter((delay) => delay !== null); + if (!delays.length) { state.failures = 0; state.retryAt = 0; return; } - const delays = failures.map((error) => - Number.isFinite(error.retryAfterSec) && error.retryAfterSec >= 0 - ? error.retryAfterSec * 1000 - : retryDelayMs(state.failures || 0), - ); - if (snapshotDelay !== null) delays.push(snapshotDelay); const backoff = Math.max(...delays); state.failures = backoff; state.retryAt = services.now() + backoff; @@ -282,6 +281,7 @@ export function createLifecycle({ state, services, parts }) { request, signal, epoch, + previousFailures, replaceRows = true, }) { const current = currentQuery(); @@ -398,10 +398,14 @@ export function createLifecycle({ state, services, parts }) { value.upstreamStatus === 429 || value.upstreamStatus >= 500), ); + // The filter above admits only 429/5xx or an unmarked stale answer. const staleError = failedStale.length ? Object.assign( new Error(msg('Wildlife sightings source unavailable')), { + upstreamStatus: Math.max( + ...failedStale.map((value) => value.upstreamStatus || 503), + ), retryAfterSec: Math.max( ...failedStale.map((value) => value.retryAfterSec), ), @@ -410,8 +414,8 @@ export function createLifecycle({ state, services, parts }) { : null; const inat = snapshot.sources?.inaturalist; const acartia = snapshot.sources?.acartia; - // Keep each failed source's status and Retry-After; the displayed error - // is the one that explains the cooldown. + // Keep each failed request's status and Retry-After; the displayed + // error is the one whose effective delay sets the cooldown. const sourceErrors = [ gridError, staleError, @@ -433,14 +437,10 @@ export function createLifecycle({ state, services, parts }) { }) : null, ].filter(Boolean); - const sourceError = - sourceErrors.find( - (error) => - wildlifeFailureRetryable(error) || - Number.isFinite(error.retryAfterSec), - ) || - sourceErrors[0] || - null; + const sourceError = preferredWildlifeFailure( + sourceErrors, + previousFailures, + ); state.stale = !sourceError && (snapshot.stale === true || grid?.stale === true); const fetchedAt = [snapshot.fetchedAt, grid?.fetchedAt].filter( @@ -467,12 +467,13 @@ export function createLifecycle({ state, services, parts }) { applyRetry( sourceErrors, retryAfter.length ? Math.max(...retryAfter) : null, + previousFailures, ); } else { state.pendingParams = null; state.failedParams = null; state.retryAt = retryAfter.length - ? services.now() + Math.max(...retryAfter) * 1000 + ? services.now() + wildlifeRetryAfterMs(Math.max(...retryAfter)) : 0; state.failures = 0; } @@ -632,6 +633,9 @@ export function createLifecycle({ state, services, parts }) { state.request?.abort(); const request = new AbortController(); const requestLifetime = lifetimeEpoch; + // One acquisition publishes several times (tiles, rows, final grid). + // Each derives its default backoff from the backoff before it started. + const previousFailures = state.failures || 0; const supersededResult = () => state.enabled && !state.destroyed && @@ -699,6 +703,7 @@ export function createLifecycle({ state, services, parts }) { request, signal, epoch: requestLifetime, + previousFailures, }); } function finishTiles() { @@ -804,11 +809,12 @@ export function createLifecycle({ state, services, parts }) { continueTiles(); state.stale = false; state.error = - error?.message || msg('Wildlife sightings source unavailable'); + preferredWildlifeFailure(error, previousFailures)?.message || + msg('Wildlife sightings source unavailable'); failParams(params); state.lastQueryKey = null; requestQueryKey = null; - applyRetry(error); + applyRetry(error, null, previousFailures); console.warn('[Data:WildlifeSightings] Update failed:', state.error); return true; } finally { diff --git a/src/layers/wildlifeSightings/policy.js b/src/layers/wildlifeSightings/policy.js index 4af9d7d17..f1b6df277 100644 --- a/src/layers/wildlifeSightings/policy.js +++ b/src/layers/wildlifeSightings/policy.js @@ -74,6 +74,9 @@ export { WILDLIFE_MAX_VIEW_DEGREES } from './bounds.js'; export const WILDLIFE_VIEW_PADDING = 0.3; export const WILDLIFE_RETRY_MIN_MS = 30_000; export const WILDLIFE_RETRY_MAX_MS = 240_000; +// Cap a Retry-After so one reply can never stall refresh forever. The proxy's +// daily-budget reply waits until the next UTC midnight, so allow one day. +export const WILDLIFE_RETRY_AFTER_MAX_SEC = 86_400; // Faster polling would reread the proxy's five-minute cache. export const WILDLIFE_UPDATE_INTERVAL_MS = 5 * 60_000; diff --git a/src/layers/wildlifeSightings/source.js b/src/layers/wildlifeSightings/source.js index aa218f34e..e2e3f1b80 100644 --- a/src/layers/wildlifeSightings/source.js +++ b/src/layers/wildlifeSightings/source.js @@ -1,8 +1,12 @@ import { readResponseJsonCapped } from '../../sources/httpBody.js'; import { mergeSightings } from './records.js'; import { wildlifeMeasure } from './performance.js'; -import { ACARTIA_GUARD_BOX, TAXA_PRESETS } from './policy.js'; -import { boxIntersects } from './viewport.js'; +import { + ACARTIA_GUARD_BOX, + TAXA_PRESETS, + WILDLIFE_RETRY_AFTER_MAX_SEC, +} from './policy.js'; +import { boxIntersects, retryDelayMs } from './viewport.js'; import { WILDLIFE_GRID_MAX_ZOOM, WILDLIFE_GRID_TILE_CAP, @@ -72,14 +76,19 @@ function retryAfterSeconds(value, nowMs) { const text = value.trim(); if (/^\d+(?:\.\d+)?$/.test(text)) { const seconds = Number(text); - return Number.isFinite(seconds) ? Math.ceil(seconds) : null; + return Number.isFinite(seconds) + ? Math.min(WILDLIFE_RETRY_AFTER_MAX_SEC, Math.ceil(seconds)) + : null; } // Reject malformed numeric values rather than letting Date.parse interpret // them as calendar dates. HTTP dates include a named weekday or month. if (!/[a-z]/i.test(text)) return null; const deadline = Date.parse(text); return Number.isFinite(deadline) && Number.isFinite(nowMs) - ? Math.max(0, Math.ceil((deadline - nowMs) / 1000)) + ? Math.min( + WILDLIFE_RETRY_AFTER_MAX_SEC, + Math.max(0, Math.ceil((deadline - nowMs) / 1000)), + ) : null; } @@ -119,17 +128,56 @@ export function wildlifeFailureRetryable(error) { return status === 429 || status >= 500; } -/** Prefer the failure that carries retry timing, then the longest wait. */ -export function preferredWildlifeFailure(errors) { - const failures = errors.filter(Boolean); - const retryable = failures.filter(wildlifeFailureRetryable); - if (!retryable.length) return failures[0] || null; - return retryable.reduce((best, error) => - (Number.isFinite(error.retryAfterSec) ? error.retryAfterSec : -1) > - (Number.isFinite(best.retryAfterSec) ? best.retryAfterSec : -1) - ? error - : best, - ); +/** Return a Retry-After in milliseconds, capped at one day, or null when + * it is missing, negative or not finite. */ +export function wildlifeRetryAfterMs(seconds) { + return Number.isFinite(seconds) && seconds >= 0 + ? Math.min(WILDLIFE_RETRY_AFTER_MAX_SEC, seconds) * 1000 + : null; +} + +/** Return how long a failure asks the layer to wait, or null for none: + * 429 and 5xx wait for their Retry-After, else the default backoff after + * `previousMs`; other failures may retry on the next view. */ +export function wildlifeFailureDelayMs(error, previousMs = 0) { + if (!error || !wildlifeFailureRetryable(error)) return null; + return wildlifeRetryAfterMs(error.retryAfterSec) ?? retryDelayMs(previousMs); +} + +/** Flatten failures, expanding a group into every request that failed. */ +export function wildlifeFailures(errors) { + return [errors] + .flat() + .filter(Boolean) + .flatMap((error) => + Array.isArray(error.failures) && error.failures.length + ? error.failures + : [error], + ); +} + +/** Return the failure with the longest effective delay (the first failure + * when none waits), carrying every failure so the caller can recompute. */ +export function preferredWildlifeFailure(errors, previousMs = 0) { + const failures = wildlifeFailures(errors); + if (!failures.length) return null; + let chosen = failures[0]; + let longest = wildlifeFailureDelayMs(chosen, previousMs) ?? -1; + for (const error of failures.slice(1)) { + const delay = wildlifeFailureDelayMs(error, previousMs) ?? -1; + if (delay > longest) [chosen, longest] = [error, delay]; + } + if (failures.length === 1) return chosen; + // Grid errors are shared between consumers of one tile request; describe + // the group with a new error rather than mutating any of them. + return Object.assign(new Error(chosen.message, { cause: chosen }), { + ...(chosen.status ? { status: chosen.status } : {}), + ...(chosen.upstreamStatus ? { upstreamStatus: chosen.upstreamStatus } : {}), + ...(Number.isFinite(chosen.retryAfterSec) + ? { retryAfterSec: chosen.retryAfterSec } + : {}), + failures, + }); } /** Merge occupied cells incrementally; neighboring UTFGrid hit tiles can @@ -145,9 +193,12 @@ export function combineWildlifeGridSnapshots(snapshots, error = null) { const totalResults = cells.reduce((sum, cell) => sum + cell.cellCount, 0); if (!Number.isSafeInteger(totalResults)) throw new Error('Malformed iNaturalist grid response'); + // A failure's Retry-After counts only when its status asks for a wait. const retryAfter = [ ...snapshots.map((snapshot) => snapshot.retryAfterSec), - error?.retryAfterSec, + ...wildlifeFailures(error) + .filter(wildlifeFailureRetryable) + .map((failure) => failure.retryAfterSec), ].filter(Number.isFinite); const stale = snapshots.some((snapshot) => snapshot.stale === true); return { @@ -514,14 +565,16 @@ export function createWildlifeSightingsSource({ signal?.throwIfAborted(); const inatSnapshots = []; const failedBoxes = []; - let inatError = null; + const inatErrors = []; inatResult.value.forEach((result, index) => { if (result.status === 'fulfilled') inatSnapshots.push(result.value); else { failedBoxes.push(requestBoxes[index]); - inatError ??= result.reason; + inatErrors.push(result.reason); } }); + // Keep every failed box; the layer waits for the longest of them. + const inatError = preferredWildlifeFailure(inatErrors); // Fail outright only when nothing succeeded; otherwise draw what arrived // and report the failed source in its per-source status. const acartiaError = From 6d7fe53570465f0fabf19a678ae1ba3795cdf23b Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 5 Nov 2026 16:55:54 -0800 Subject: [PATCH 150/156] Let a shell without its navigation owner report no camera handoffs The voice runner subscribes to camera handoffs as it is created. The Cyber sonar test builds its runner on a StyleManager made from the prototype, which has no navigation owner, so the facade's delegation threw and both Node jobs failed. The facade now returns nothing there, like its other optional owners; the real shell builds its navigation owner before voice creates a runner. --- scripts/format-scope.json | 1 + src/ui/shellFacade.js | 2 +- src/ui/shellFacade.test.mjs | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 src/ui/shellFacade.test.mjs diff --git a/scripts/format-scope.json b/scripts/format-scope.json index a28a8c53c..40bca019a 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1128,6 +1128,7 @@ "src/dockerTrustedPeers.test.mjs", "src/cameraGlobeRotation.test.mjs", "src/cameraTilt.test.mjs", + "src/ui/shellFacade.test.mjs", "src/voice/globeResetConfirmation.test.mjs", "src/hudDms.test.mjs", "src/hudSummaryGate.test.mjs", diff --git a/src/ui/shellFacade.js b/src/ui/shellFacade.js index 95bf791d4..678b69c79 100644 --- a/src/ui/shellFacade.js +++ b/src/ui/shellFacade.js @@ -6,7 +6,7 @@ export class ShellFacade { /** Observe camera ownership transfers through the navigation owner. */ subscribeCameraHandoff(listener) { - return this._navigation.subscribeCameraHandoff(listener); + return this._navigation?.subscribeCameraHandoff(listener); } get _models3dEnabled() { diff --git a/src/ui/shellFacade.test.mjs b/src/ui/shellFacade.test.mjs new file mode 100644 index 000000000..abb892b37 --- /dev/null +++ b/src/ui/shellFacade.test.mjs @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { ShellFacade } from './shellFacade.js'; + +// A shell assembled without its navigation owner (as unit tests build one +// from the prototype) has no handoffs to report, like the facade's other +// optional owners; the voice runner subscribes as it is created. +test('camera handoffs without a navigation owner subscribe to nothing', () => { + const shell = Object.create(ShellFacade.prototype); + assert.equal( + shell.subscribeCameraHandoff(() => {}), + undefined, + ); +}); + +test('camera handoffs reach listeners through the navigation owner', () => { + const listeners = new Set(); + const shell = Object.create(ShellFacade.prototype); + shell._navigation = { + subscribeCameraHandoff(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }; + let handoffs = 0; + const unsubscribe = shell.subscribeCameraHandoff(() => { + handoffs += 1; + }); + for (const listener of listeners) listener(); + assert.equal(handoffs, 1); + unsubscribe(); + assert.equal(listeners.size, 0); +}); From 495139399719ad56fe38606931987331387ffdf9 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 5 Nov 2026 16:55:54 -0800 Subject: [PATCH 151/156] Keep the place in view when a late zoom out catches a tilt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 400 km, a tilt to -5° that a zoom out x2.5 caught 70 frames in eased down from the camera's old pitch at the new range. That put the camera below the place's horizon: the first frame's crosshair hit the ground 186 km from London, and London stayed hidden for about 11 frames. The re-solve also left the pivot pitch at 0°, so a zoom in before the next frame computed an infinite clearance floor, was refused, and stayed at 1,000 km. The re-solved pivot pitch now stops at the place's horizon limit, or where the pivot already was. A camera that the zoom leaves past that limit steps down to it with the zoom and holds it, and the zoom still reports that angle. A zoom in straight after brings the range back to 400 km. --- docs/CURRENT-STATE.md | 2 +- src/cameraTilt.test.mjs | 66 +++++++++++++++++++++++++++++++++++++---- src/cameraVerbs.js | 13 ++++++-- 3 files changed, 72 insertions(+), 9 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index d5ef4af05..390c61c4a 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5590,7 +5590,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. Angles and limits are the camera's own pitch; at regional range the pivot's pitch is up to about 8° shallower (the Earth curves away), so the swing solves for the pivot pitch that gives the camera the angle asked for. 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. Beyond about 17 km of range the swing never hands over to an in-place tilt (that would lift the crosshair onto the sky); it keeps the camera at least 0.5° above the place's own horizon, which from roughly 500 km stops it short of -5° with the place still centred: a tilt to an angle beyond that stops at the angle reached and says so (`pitchDeg` is the angle reached, with a `note`), a continuous tilt stops there, and a further tilt up is refused as at the limit. 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 cap: 1,000 km for a tilt, where pivoting stops, and 5,000 km for an orbit) is reported as not zoomed. A zoom during a tilt to an angle re-judges the place's horizon limit at the new range: when that moves where the tilt ends, the tilt eases from where it is to the new end (holding the camera's pitch through the range change, and easing back down to a limit it is already past rather than jumping there), and the zoom's result names that angle (`pitchDeg`) with a `note`. 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. An angle the camera already holds answers `unchanged` but still takes navigation, so it stops a running motion and releases the follow camera. A tilt left or right 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 (moving to a later fly-to in the same reply as soon as it begins, whatever the earlier one reports), so it neither cancels the flight nor gets replaced by it; if the flight is cancelled, finds nothing or times out, the user grabs, touches or scrolls the map during it, or another navigation (a location-panel search, share link or file import, which leave the flight flying) takes the camera during it, the motion is not run and says so. A motion that runs after a fly-to of the same reply has already found nothing (Gemini runs one turn's calls one by one, and passes its turn as the reply) is refused the same way rather than moving the old view; a motion in a later reply runs. `stop` and `rotate_globe` do not wait, and a motion sent before its fly-to is not reordered: it runs at once, and the fly-to's result names the motion it stopped (`stoppedMotion`). 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. Angles and limits are the camera's own pitch; at regional range the pivot's pitch is up to about 8° shallower (the Earth curves away), so the swing solves for the pivot pitch that gives the camera the angle asked for. 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. Beyond about 17 km of range the swing never hands over to an in-place tilt (that would lift the crosshair onto the sky); it keeps the camera at least 0.5° above the place's own horizon, which from roughly 500 km stops it short of -5° with the place still centred: a tilt to an angle beyond that stops at the angle reached and says so (`pitchDeg` is the angle reached, with a `note`), a continuous tilt stops there, and a further tilt up is refused as at the limit. 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 cap: 1,000 km for a tilt, where pivoting stops, and 5,000 km for an orbit) is reported as not zoomed. A zoom during a tilt to an angle re-judges the place's horizon limit at the new range: when that moves where the tilt ends, the tilt eases from where it is to the new end (holding the camera's pitch through the range change; a camera the zoom leaves past the new range's limit steps down to it with the zoom, since past it the place would drop behind the Earth), and the zoom's result names that angle (`pitchDeg`) with a `note`. 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. An angle the camera already holds answers `unchanged` but still takes navigation, so it stops a running motion and releases the follow camera. A tilt left or right 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 (moving to a later fly-to in the same reply as soon as it begins, whatever the earlier one reports), so it neither cancels the flight nor gets replaced by it; if the flight is cancelled, finds nothing or times out, the user grabs, touches or scrolls the map during it, or another navigation (a location-panel search, share link or file import, which leave the flight flying) takes the camera during it, the motion is not run and says so. A motion that runs after a fly-to of the same reply has already found nothing (Gemini runs one turn's calls one by one, and passes its turn as the reply) is refused the same way rather than moving the old view; a motion in a later reply runs. `stop` and `rotate_globe` do not wait, and a motion sent before its fly-to is not reordered: it runs at once, and the fly-to's result names the motion it stopped (`stoppedMotion`). 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 738c71e88..8a3d1447f 100644 --- a/src/cameraTilt.test.mjs +++ b/src/cameraTilt.test.mjs @@ -688,15 +688,18 @@ test('a voice zoom during a tilt to an angle passes on the new end angle', async }); // Late in the tilt the camera is already past the angle the new range's -// horizon allows (from 400 km at -8°, a zoom out x2.5 reported -8° and the -// next frame jumped to -9.4°): it eases to that limit with no jump, and the -// zoom's reply names the angle it ends at, not the one it stood at. -test('a late zoom out during a tilt eases to the new limit and reports it', (t) => { +// horizon allows (from 400 km at -8°, a zoom out x2.5 reported -8°): past it +// the place is behind the Earth, so the camera steps down to that limit with +// the zoom, then holds it with no further jump, and the zoom's reply names +// the angle it ends at, not the one it stood at. +test('a late zoom out during a tilt steps to the new limit and reports it', (t) => { const h = harness(t, { height: 400_000 }); moveCamera({ motion: 'tilt', pitchDeg: -5 }); for (let i = 0; i < 70; i += 1) h.tick(); const zoom = adjustOrbitRange(2.5); assert.equal(zoom.changed, true); + h.tick(); + assert.equal(zoom.pitchDeg, Math.round(pitchDeg(h.camera))); let last = pitchDeg(h.camera); let maxStep = 0; for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) { @@ -705,7 +708,7 @@ test('a late zoom out during a tilt eases to the new limit and reports it', (t) last = pitchDeg(h.camera); } assert.equal(getActiveCameraMotion(), null, 'tilt must finish'); - assert.ok(maxStep < 0.5, `pitch jumped ${maxStep}° in one frame`); + assert.ok(maxStep < 0.1, `pitch jumped ${maxStep}° in one frame`); assert.ok(h.viewTarget(), 'the crosshair is on the sky'); // Replies give whole degrees: the reported angle is the reached one rounded. assert.equal( @@ -715,3 +718,56 @@ test('a late zoom out during a tilt eases to the new limit and reports it', (t) ); assert.match(zoom.note, /horizon/); }); + +// Easing from the camera's old pitch at the zoomed-out range put the camera +// below the place's horizon: the first frame's crosshair hit the ground +// 186 km from London, and London stayed hidden for ~11 frames. +test('a late zoom out during a tilt keeps the place under the crosshair', (t) => { + const h = harness(t, { height: 400_000 }); + moveCamera({ motion: 'tilt', pitchDeg: -5 }); + for (let i = 0; i < 70; i += 1) h.tick(); + const zoom = adjustOrbitRange(2.5); + assert.equal(zoom.changed, true); + for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) { + h.tick(); + const hit = h.viewTarget(); + assert.ok(hit, `frame ${i}: the crosshair is on the sky`); + const drift = centreDrift(h); + assert.ok( + drift < 100, + `frame ${i}: crosshair ${Math.round(drift)} m off the place`, + ); + } + assert.equal(getActiveCameraMotion(), null, 'tilt must finish'); + assert.ok( + Math.abs(pivotRange(h) - 1_000_000) < 1, + `range ${pivotRange(h)} m`, + ); + assert.equal( + zoom.pitchDeg, + Math.round(pitchDeg(h.camera)), + `camera at ${pitchDeg(h.camera)}°, zoom reported ${zoom.pitchDeg}°`, + ); + assert.match(zoom.note, /horizon/); +}); + +// The zoom out left the pivot pitch level with the place, so the zoom in's +// clearance floor came out infinite and it was refused at 1,000 km. +test('a zoom in right after a late zoom out brings the tilt back in', (t) => { + const h = harness(t, { height: 400_000 }); + moveCamera({ motion: 'tilt', pitchDeg: -5 }); + for (let i = 0; i < 70; i += 1) h.tick(); + const range = pivotRange(h); + assert.equal(adjustOrbitRange(2.5).changed, true); + const zoomIn = adjustOrbitRange(0.4); + assert.equal(zoomIn.changed, true, 'the zoom in must not be refused'); + h.finish(); + assert.ok( + Math.abs(pivotRange(h) - range) < 1, + `range ${pivotRange(h)} m, wanted ${range} m`, + ); + assert.ok( + centreDrift(h) < 100, + `crosshair ${centreDrift(h)} m off the place`, + ); +}); diff --git a/src/cameraVerbs.js b/src/cameraVerbs.js index 437688d7a..d7127c2f7 100644 --- a/src/cameraVerbs.js +++ b/src/cameraVerbs.js @@ -2109,20 +2109,27 @@ export function adjustOrbitRange(factor) { function retargetTiltAfterZoom(m) { if (m.kind !== 'tilt' || m.mode !== 'once' || m.askedPitch === undefined) return {}; - const from = _viewer.camera.pitch; + let from = _viewer.camera.pitch; if (!(m.askedPitch > from)) return {}; if (m.hpr) { // At the same pivot pitch a longer range looks down more steeply (the // Earth curves away): find the pivot pitch that holds this camera pitch. + // It stays below the place's horizon limit (or where the pivot already + // was): past it the place drops behind the Earth, so a camera the zoom + // leaves past that limit steps down to it with the zoom. + const cap = Math.max(m.hpr.pitch, tiltPivotPitchMax(m.hpr.range)); for (let i = 0; i < 4; i += 1) { const error = from - pivotCameraPitch(m.target, m.hpr, m.hpr.pitch); if (Math.abs(error) < 1e-7) break; - m.hpr.pitch = Cesium.Math.clamp( + const pitch = Cesium.Math.clamp( m.hpr.pitch + error, -Cesium.Math.PI_OVER_TWO, - 0, + cap, ); + if (pitch === m.hpr.pitch) break; + m.hpr.pitch = pitch; } + from = Math.min(from, pivotCameraPitch(m.target, m.hpr, m.hpr.pitch)); } const limit = m.hpr && !tiltClearanceBinds(m.hpr.range) From 58adbb11c7f3cfa33cefe85cc17952f4c923fef5 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 5 Nov 2026 21:21:26 -0800 Subject: [PATCH 152/156] fix(traffic): name an account's traffic limit instead of a daily budget A failed flow tile now carries the server's reason. A hosted deployment that limits each account answers USAGE_LIMIT_REACHED or RATE_LIMITED, which the traffic panel labels as the monthly allowance or a rate limit; a 503 with another reason reads as the traffic service being unavailable, not a missing key. The local proxy's no_key and daily budget labels are unchanged. --- src/data/traffic.test.mjs | 22 +++++++++++++++++++++ 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 | 3 +++ src/i18n/locales/pt.json | 3 +++ src/i18n/locales/ru.json | 3 +++ src/i18n/locales/uk.json | 3 +++ src/i18n/locales/zh-CN.json | 3 +++ src/i18n/locales/zh-Hant.json | 3 +++ src/layers/traffic/flow.js | 8 ++++++++ src/layers/traffic/flowSource.js | 31 +++++++++++++++++++++++++++++- src/layers/traffic/source.test.mjs | 26 +++++++++++++++++++++++++ 15 files changed, 119 insertions(+), 1 deletion(-) diff --git a/src/data/traffic.test.mjs b/src/data/traffic.test.mjs index 23924448a..0e8ada30a 100644 --- a/src/data/traffic.test.mjs +++ b/src/data/traffic.test.mjs @@ -30,6 +30,28 @@ test('a superseded flow fetch is not an outage', () => { assert.equal(deriveTrafficFlowError(undefined), null); }); +test('hosted account limits and service failures name their own reason', () => { + const failure = (status, code) => + deriveTrafficFlowError( + Object.assign(new Error(`TomTom flow unavailable (HTTP ${status})`), { + status, + code, + }), + ); + assert.equal( + failure(429, 'USAGE_LIMIT_REACHED'), + 'Monthly traffic allowance used', + ); + assert.equal(failure(429, 'RATE_LIMITED'), 'Traffic rate limit reached'); + assert.equal(failure(429, 'budget'), 'TomTom daily budget reached'); + assert.equal(failure(503, 'no_key'), 'TomTom key unavailable'); + assert.equal(failure(503, null), 'TomTom key unavailable'); + assert.equal( + failure(503, 'USAGE_UNAVAILABLE'), + 'Traffic service unavailable', + ); +}); + test('flow failures map onto short, specific reasons', () => { const reason = (message) => deriveTrafficFlowError(new Error(message)); assert.equal(reason('flow tile 12/1/1: HTTP 503'), 'TomTom key unavailable'); diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index b491c3c93..8a2b81e20 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -1749,6 +1749,9 @@ "Hybrid": "هجين", "TomTom key unavailable": "مفتاح TomTom غير متاح", "TomTom daily budget reached": "بلغت الحصة اليومية لـ TomTom", + "Monthly traffic allowance used": "تم استهلاك حصة حركة المرور الشهرية", + "Traffic rate limit reached": "تم بلوغ حد طلبات حركة المرور", + "Traffic service unavailable": "خدمة حركة المرور غير متاحة", "TomTom upstream unreachable": "تعذّر الوصول إلى خادم TomTom", "TomTom flow unavailable": "تدفق TomTom غير متاح", "SIMULATED — {reason}": "محاكى — {reason}", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index d01ff292f..837a87471 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -1667,6 +1667,9 @@ "Hybrid": "Hybrid", "TomTom key unavailable": "TomTom-Schlüssel nicht verfügbar", "TomTom daily budget reached": "TomTom-Tagesbudget erreicht", + "Monthly traffic allowance used": "Monatliches Verkehrskontingent aufgebraucht", + "Traffic rate limit reached": "Verkehrs-Anfragelimit erreicht", + "Traffic service unavailable": "Verkehrsdienst nicht verfügbar", "TomTom upstream unreachable": "TomTom-Upstream nicht erreichbar", "TomTom flow unavailable": "TomTom-Verkehrsfluss nicht verfügbar", "SIMULATED — {reason}": "SIMULIERT – {reason}", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index aa403f743..57aa09eb7 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -2274,6 +2274,9 @@ "SIMULATED — {reason}": "SIMULADO — {reason}", "TomTom key unavailable": "Clave de TomTom no disponible", "TomTom daily budget reached": "Se alcanzó el límite diario de TomTom", + "Monthly traffic allowance used": "Cupo mensual de tráfico agotado", + "Traffic rate limit reached": "Límite de solicitudes de tráfico alcanzado", + "Traffic service unavailable": "Servicio de tráfico no disponible", "TomTom upstream unreachable": "Servidor de TomTom inaccesible", "TomTom flow unavailable": "Flujo de TomTom no disponible", "API key rejected — check AISSTREAM_API_KEY": "Clave de API rechazada — revisa AISSTREAM_API_KEY", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index b3ae72189..bfd2a5975 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1105,6 +1105,9 @@ "Hybrid": "Hybride", "TomTom key unavailable": "Clé TomTom indisponible", "TomTom daily budget reached": "Quota quotidien TomTom atteint", + "Monthly traffic allowance used": "Quota mensuel de trafic épuisé", + "Traffic rate limit reached": "Limite de requêtes de trafic atteinte", + "Traffic service unavailable": "Service de trafic indisponible", "TomTom upstream unreachable": "Serveur TomTom injoignable", "TomTom flow unavailable": "Flux TomTom indisponible", "SIMULATED — {reason}": "SIMULÉ — {reason}", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 4c69e1d4c..74c0d01aa 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -1105,6 +1105,9 @@ "Hybrid": "Ibrido", "TomTom key unavailable": "Chiave TomTom non disponibile", "TomTom daily budget reached": "Budget giornaliero TomTom esaurito", + "Monthly traffic allowance used": "Quota mensile del traffico esaurita", + "Traffic rate limit reached": "Limite di richieste del traffico raggiunto", + "Traffic service unavailable": "Servizio traffico non disponibile", "TomTom upstream unreachable": "Servizio TomTom irraggiungibile", "TomTom flow unavailable": "Flusso TomTom non disponibile", "SIMULATED — {reason}": "SIMULATO: {reason}", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 9a053f869..932d851e2 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1073,6 +1073,9 @@ "Hybrid": "ハイブリッド", "TomTom key unavailable": "TomTomキーを利用できません", "TomTom daily budget reached": "TomTomの1日の上限に達しました", + "Monthly traffic allowance used": "今月の交通情報の上限に達しました", + "Traffic rate limit reached": "交通情報のリクエスト上限に達しました", + "Traffic service unavailable": "交通情報サービスを利用できません", "TomTom upstream unreachable": "TomTomのサービスに接続できません", "TomTom flow unavailable": "TomTomの交通流を利用できません", "SIMULATED — {reason}": "シミュレーション:{reason}", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 79c507759..e71f144d5 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -1103,6 +1103,9 @@ "Hybrid": "Híbrido", "TomTom key unavailable": "Chave do TomTom indisponível", "TomTom daily budget reached": "Limite diário do TomTom atingido", + "Monthly traffic allowance used": "Cota mensal de trânsito esgotada", + "Traffic rate limit reached": "Limite de solicitações de trânsito atingido", + "Traffic service unavailable": "Serviço de trânsito indisponível", "TomTom upstream unreachable": "Servidor do TomTom inacessível", "TomTom flow unavailable": "Fluxo do TomTom indisponível", "SIMULATED — {reason}": "SIMULADO — {reason}", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 11fddb3e9..6573ad11b 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -2313,6 +2313,9 @@ "SIMULATED — {reason}": "СИМУЛЯЦИЯ — {reason}", "TomTom key unavailable": "Ключ TomTom недоступен", "TomTom daily budget reached": "Исчерпан дневной лимит TomTom", + "Monthly traffic allowance used": "Месячный лимит данных о трафике исчерпан", + "Traffic rate limit reached": "Достигнут лимит запросов данных о трафике", + "Traffic service unavailable": "Сервис данных о трафике недоступен", "TomTom upstream unreachable": "Сервер TomTom не отвечает", "TomTom flow unavailable": "Поток TomTom недоступен", "API key rejected — check AISSTREAM_API_KEY": "Ключ API отклонён — проверьте AISSTREAM_API_KEY", diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index c0fab8533..18ea8dc4f 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -1120,6 +1120,9 @@ "Hybrid": "Гібрид", "TomTom key unavailable": "Ключ TomTom недоступний", "TomTom daily budget reached": "Вичерпано денний ліміт TomTom", + "Monthly traffic allowance used": "Місячний ліміт даних про трафік вичерпано", + "Traffic rate limit reached": "Досягнуто ліміту запитів даних про трафік", + "Traffic service unavailable": "Сервіс даних про трафік недоступний", "TomTom upstream unreachable": "Сервер TomTom не відповідає", "TomTom flow unavailable": "Потік TomTom недоступний", "SIMULATED — {reason}": "ІМІТАЦІЯ — {reason}", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 8687af790..8d09b74a3 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1049,6 +1049,9 @@ "Hybrid": "混合", "TomTom key unavailable": "TomTom 密钥不可用", "TomTom daily budget reached": "已达 TomTom 每日配额", + "Monthly traffic allowance used": "本月交通流量额度已用完", + "Traffic rate limit reached": "已达到交通数据请求频率上限", + "Traffic service unavailable": "交通服务不可用", "TomTom upstream unreachable": "TomTom 上游服务无法连接", "TomTom flow unavailable": "TomTom 路况不可用", "SIMULATED — {reason}": "模拟——{reason}", diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index a95e71dd5..30801f0f9 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -1044,6 +1044,9 @@ "Hybrid": "混合", "TomTom key unavailable": "TomTom 密鑰不可用", "TomTom daily budget reached": "已達 TomTom 每日配額", + "Monthly traffic allowance used": "本月交通流量額度已用完", + "Traffic rate limit reached": "已達交通資料請求頻率上限", + "Traffic service unavailable": "交通服務無法使用", "TomTom upstream unreachable": "TomTom 上游服務無法連接", "TomTom flow unavailable": "TomTom 路況不可用", "SIMULATED — {reason}": "模擬——{reason}", diff --git a/src/layers/traffic/flow.js b/src/layers/traffic/flow.js index 2064c10ce..caf8aede2 100644 --- a/src/layers/traffic/flow.js +++ b/src/layers/traffic/flow.js @@ -38,7 +38,15 @@ export function createFlow({ state: layerState, services, parts, source }) { const status = Number.isFinite(error.status) ? error.status : Number(message.match(/HTTP (\d{3})/)?.[1]); + const code = typeof error.code === 'string' ? error.code : null; // English in state; trafficFeedPresentation translates it for the panel. + // A hosted deployment limits each account (code from the server); the + // local proxy has one daily budget (`budget`) and answers `no_key`. + if (code === 'USAGE_LIMIT_REACHED') + return msg('Monthly traffic allowance used'); + if (code === 'RATE_LIMITED') return msg('Traffic rate limit reached'); + if (status === 503 && code && code !== 'no_key') + return msg('Traffic service unavailable'); if (status === 503) return msg('TomTom key unavailable'); if (status === 429) return msg('TomTom daily budget reached'); if (status === 502 || status === 504) diff --git a/src/layers/traffic/flowSource.js b/src/layers/traffic/flowSource.js index 12e746e43..e5402cf12 100644 --- a/src/layers/traffic/flowSource.js +++ b/src/layers/traffic/flowSource.js @@ -11,6 +11,26 @@ const FLOW_TILE_PATH = (z, x, y) => `/api/tomtom/flow/${z}/${x}/${y}.pbf`; * (STALE-BUDGET once the daily budget is spent, STALE-ERROR when TomTom * fails), and when the proxy fetched it. */ +/** + * The machine-readable reason a failed tile carries: `code` (hosted usage, + * e.g. USAGE_LIMIT_REACHED or RATE_LIMITED) or the local proxy's `error` + * (`no_key`, `budget`). Only short identifiers are kept; prose is ignored. + */ +async function failureCode(response) { + if (!/json/i.test(response.headers?.get?.('content-type') || '')) return null; + try { + const text = await response.text(); + if (text.length > 4096) return null; + const body = JSON.parse(text); + const code = body?.code ?? body?.error; + return typeof code === 'string' && /^[A-Za-z_]{1,40}$/.test(code) + ? code + : null; + } catch { + return null; + } +} + function tileFreshness(response) { const status = response.headers?.get?.('x-tomtom-cache') || ''; const fetchedAt = Number(response.headers?.get?.('x-tomtom-fetched-at')); @@ -60,7 +80,16 @@ export function createFlowTileSource({ }, fetchImpl: async (url, init) => { const response = await fetchImpl(url, init); - if (response.ok && !init.signal.aborted) { + if (!response.ok) { + const code = await failureCode(response); + await response.body?.cancel().catch(() => {}); + throw Object.assign( + // Classified for display by deriveTrafficFlowError. + new Error(`TomTom flow unavailable (HTTP ${response.status})`), + { status: response.status, code }, + ); + } + if (!init.signal.aborted) { const path = String(url); const entry = { mark: tileFreshness(response), diff --git a/src/layers/traffic/source.test.mjs b/src/layers/traffic/source.test.mjs index 779164b7c..e45f5ef0f 100644 --- a/src/layers/traffic/source.test.mjs +++ b/src/layers/traffic/source.test.mjs @@ -1050,3 +1050,29 @@ test('cancelling a flow body releases its freshness before a new request', async assert.equal(getEventListeners(requestSignal, 'abort').length, 0); assert.equal(source.getFlowSessionStats().cacheEntries, 0); }); + +test('a failed flow tile carries the server reason for its label', async () => { + for (const [status, body, code] of [ + [ + 429, + { error: 'Monthly usage limit reached', code: 'USAGE_LIMIT_REACHED' }, + 'USAGE_LIMIT_REACHED', + ], + [ + 429, + { error: 'Request rate limit reached', code: 'RATE_LIMITED' }, + 'RATE_LIMITED', + ], + [503, { error: 'no_key' }, 'no_key'], + [503, { error: 'Usage enforcement is temporarily unavailable' }, null], + ]) { + const source = createFlowTileSource({ + fetchImpl: async () => Response.json(body, { status }), + }); + await assert.rejects(source.fetchFlowForBounds(bounds), (error) => { + assert.equal(error.status, status); + assert.equal(error.code, code); + return true; + }); + } +}); From 5d8f2b878f5ef204b5efb4519a7fbbbc3699b59f Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Fri, 6 Nov 2026 15:47:36 +0400 Subject: [PATCH 153/156] chore(datacenters): drop the one-off Norway refresh script The data refresh is committed and its provenance (extract, timestamp, MD5, filter) stays in the dataset README. The script needed osmium-tool and could only re-verify while Geofabrik still listed that extract, so it goes with its test. --- changelog.d/953-datacenters-norway-refresh.md | 6 +- scripts/build-datacenters-norway.py | 259 ------------------ src/data/datacentersNorwayRefresh.test.mjs | 95 ------- src/data/local_data/datacenters/README.md | 12 +- 4 files changed, 3 insertions(+), 369 deletions(-) delete mode 100644 scripts/build-datacenters-norway.py delete mode 100644 src/data/datacentersNorwayRefresh.test.mjs diff --git a/changelog.d/953-datacenters-norway-refresh.md b/changelog.d/953-datacenters-norway-refresh.md index 48a019605..5db897234 100644 --- a/changelog.d/953-datacenters-norway-refresh.md +++ b/changelog.d/953-datacenters-norway-refresh.md @@ -1,6 +1,4 @@ - 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). + Norwegian sites pick up current OSM tags; no site moved or was removed + (trygvehhh, #953). diff --git a/scripts/build-datacenters-norway.py b/scripts/build-datacenters-norway.py deleted file mode 100644 index 3c5eb92dd..000000000 --- a/scripts/build-datacenters-norway.py +++ /dev/null @@ -1,259 +0,0 @@ -#!/usr/bin/env python3 -"""Maintainer-only Norway refresh of the bundled datacenter extract (osmium-tool). - -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 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. -""" -import argparse -import hashlib -import json -import math -from pathlib import Path -import shutil -import subprocess -import sys -import urllib.request - -ROOT = Path(__file__).resolve().parent.parent -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, timeout=TIMEOUT_SECONDS) 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(encoding='utf-8').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, 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 = {} - for line in exported.read_text(encoding='utf-8').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, tagged - - -def same_geometry(old, new): - """Same type and rings within tolerance; ring start, winding and part or hole order may differ.""" - if old['type'] != new['type']: - 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) - - 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): - 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, tagged): - out, known, sequence = [], 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: - 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 - 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) - continue - out.append(line) - for key in sorted(set(fresh) - known, key=lambda k: (k[0], int(k[1:]))): - 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 - - -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) - 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, 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, tagged) - 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'): - raise SystemExit('datacenters.geojsonl is not the %s merge' % extract) - else: - 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})) - - -if __name__ == '__main__': - main() diff --git a/src/data/datacentersNorwayRefresh.test.mjs b/src/data/datacentersNorwayRefresh.test.mjs deleted file mode 100644 index 99278769e..000000000 --- a/src/data/datacentersNorwayRefresh.test.mjs +++ /dev/null @@ -1,95 +0,0 @@ -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)] -fresh = {'w13': {'geometry': {'type': 'Polygon', 'coordinates': square}, 'tags': {'building': 'data_center'}}} -out, report = merge(lines, fresh, norway, {'w7', 'w12', 'w13'}) -assert out[0] == lines[0] and len(out) == 2, out -assert report['unexported'] == ['w7'] and report['removed'] == ['w8'], report -assert report['skipped'] == ['w12'] and report['added'] == ['w13'], 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 86f573c61..c360ee527 100644 --- a/src/data/local_data/datacenters/README.md +++ b/src/data/local_data/datacenters/README.md @@ -26,14 +26,4 @@ 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 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, - 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`. -- 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. +- Contact tags are dropped as above. From a69d0492d0cda16a1eef15d022629f4949f293b5 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Fri, 6 Nov 2026 15:54:53 +0400 Subject: [PATCH 154/156] docs(datacenters): tighten the Norway refresh notes --- changelog.d/953-datacenters-norway-refresh.md | 6 ++---- docs/INFRASTRUCTURE-LAYERS.md | 2 +- src/data/local_data/datacenters/README.md | 6 ++---- 3 files changed, 5 insertions(+), 9 deletions(-) diff --git a/changelog.d/953-datacenters-norway-refresh.md b/changelog.d/953-datacenters-norway-refresh.md index 5db897234..d60d4ac00 100644 --- a/changelog.d/953-datacenters-norway-refresh.md +++ b/changelog.d/953-datacenters-norway-refresh.md @@ -1,4 +1,2 @@ -- 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 - (trygvehhh, #953). +- Add Troll Housing to the Datacenters layer (4,352 features) by refreshing + Norway from OpenStreetMap data of 2026-10-05 (trygvehhh, #953). diff --git a/docs/INFRASTRUCTURE-LAYERS.md b/docs/INFRASTRUCTURE-LAYERS.md index aa8cd0558..798ff09af 100644 --- a/docs/INFRASTRUCTURE-LAYERS.md +++ b/docs/INFRASTRUCTURE-LAYERS.md @@ -59,4 +59,4 @@ globals. Dataset files and source/license notices remain under `src/data/local_data/`. 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. +different measurements. diff --git a/src/data/local_data/datacenters/README.md b/src/data/local_data/datacenters/README.md index c360ee527..a89894fdb 100644 --- a/src/data/local_data/datacenters/README.md +++ b/src/data/local_data/datacenters/README.md @@ -19,11 +19,9 @@ 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. +Features inside Geofabrik's Norway boundary (`norway.poly`, with Svalbard) come +from a newer extract than the rest of the file. Contact tags are dropped as above. - 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. From c058ecf21b98f3097adea6cd512b4931cb864723 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Fri, 6 Nov 2026 15:57:27 +0400 Subject: [PATCH 155/156] docs(infrastructure): keep the dataset scope note --- docs/INFRASTRUCTURE-LAYERS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/INFRASTRUCTURE-LAYERS.md b/docs/INFRASTRUCTURE-LAYERS.md index 798ff09af..aa8cd0558 100644 --- a/docs/INFRASTRUCTURE-LAYERS.md +++ b/docs/INFRASTRUCTURE-LAYERS.md @@ -59,4 +59,4 @@ globals. Dataset files and source/license notices remain under `src/data/local_data/`. 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. +different measurements. This change does not refresh or relicense the datasets. From 141181f83a0cc2fa6379a4c77af04469aee2df92 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Fri, 6 Nov 2026 12:02:30 +0000 Subject: [PATCH 156/156] docs(flights): changelog names the one-in-13-ticks rotation, about once a second --- changelog.d/870-hidden-contact-dr.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/changelog.d/870-hidden-contact-dr.md b/changelog.d/870-hidden-contact-dr.md index f88084d7a..aab55229e 100644 --- a/changelog.d/870-hidden-contact-dr.md +++ b/changelog.d/870-hidden-contact-dr.md @@ -1,4 +1,4 @@ - Reduce dead-reckoning work for flights already hidden behind the horizon. - Hidden contacts update on a staggered one-in-12-ticks rotation while visible - contacts and contacts with fleet models retain the full update path - (Wilmer Alzate, #870). + Hidden contacts update on a staggered one-in-13-ticks rotation (about once a + second) while visible contacts and contacts with fleet models retain the full + update path (Wilmer Alzate, #870).