From 4399febaa08884e4eeca841e99496e7ee2720b54 Mon Sep 17 00:00:00 2001
From: omerbberk <121737271+omerbberk@users.noreply.github.com>
Date: Fri, 16 Oct 2026 00:19:43 +0000
Subject: [PATCH 01/14] fix(cctv): keep missing numbers unknown instead of
reading them as zero
Number() reads null, blank strings and booleans as 0 (or 1), and
/api/cctv/sources sends an unknown number as JSON null. A source-pack
camera that left out its pose therefore reached the browser as zeros
clamped to each field's floor (north, pitch -2, FOV 20, range 120 m,
mount 6 m, ground 0 m) instead of taking the defaults, and a null or
blank position put the camera at 0,0.
toFiniteNumber (server) and safeNumber (browser) now return the fallback
for absent, blank and boolean values, as validateBox already does for
Recent Imagery. The same conversion fixes the per-pack paths: a Tallinn
row with a null heading no longer reads as a high-confidence north-facing
camera, a Caltrans camera with a blank elevation keeps its 150 m ground
prior, and TfL or Catalonia cameras with a null or blank coordinate are
dropped instead of kept at 0,0 or on the zero meridian.
---
CHANGELOG.md | 14 ++
docs/CURRENT-STATE.md | 11 ++
scripts/format-scope.json | 1 +
server/providers/cctv/normalize.js | 10 ++
src/data/cctvMissingNumbers.test.mjs | 252 +++++++++++++++++++++++++++
src/layers/cctv/model.js | 11 ++
6 files changed, 299 insertions(+)
create mode 100644 src/data/cctvMissingNumbers.test.mjs
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1f39f8d96..86f26a481 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,19 @@
# Changelog
+- Keep missing CCTV numbers unknown instead of reading them as zero. `Number`
+ turns null, blank strings and booleans into 0 or 1, and the camera source
+ list sends an unknown number as JSON null, so a source-pack camera that left
+ out its pose reached the browser as zeros clamped to each field's floor —
+ facing north, pitch −2°, FOV 20°, range 120 m, mount 6 m and ground 0 m —
+ instead of the defaults (id-hash heading, −17°, 74°, 700 m, 24 m and the
+ city's ground); a null or blank position placed the camera at 0,0. The
+ server's `toFiniteNumber` and the browser's `safeNumber` now treat absent,
+ blank and boolean values as missing. The same conversion also stops a
+ Tallinn row with a null heading reading as a confident north-facing camera,
+ a Caltrans camera with a blank elevation losing its 150 m ground prior, and
+ a TfL or Catalonia camera with a null or blank coordinate being kept at 0,0
+ or on the zero meridian.
+
- Cyclone advisories no longer go dark while NHC publishes an advisory ahead
of its nominal time: advisory and position times up to 90 minutes ahead of
the server clock are accepted (previously 5 minutes), so the whole feed is
diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md
index 0af075a6d..1060f8c60 100644
--- a/docs/CURRENT-STATE.md
+++ b/docs/CURRENT-STATE.md
@@ -1314,6 +1314,17 @@ Node-only package entries expose both provider factories. Routes, payloads,
fallback behavior and existing dev/preview hook registration are preserved;
browser rendering is unchanged.
+## CCTV missing numeric fields
+
+CCTV numbers are read as missing when they are absent, null, blank or boolean,
+on the server (`toFiniteNumber`, every pack and the file/env source packs) and
+in the browser (`safeNumber`). `/api/cctv/sources` sends an unknown number as
+JSON null; the browser then applies the field's default — id-hash heading,
+pitch −17°, FOV 74°, range 700 m, mount 24 m, the city's ground elevation —
+rather than reading zero and clamping it to the field's floor. A camera with a
+missing latitude or longitude is skipped by the browser catalog instead of
+being placed at 0,0. Real zeros, including `"0"`, are kept.
+
## Terrain, traffic, fire and bike-share provider modules
Local composition now imports separate Node modules for Re:Earth heights,
diff --git a/scripts/format-scope.json b/scripts/format-scope.json
index d956d09e8..9e7f7da80 100644
--- a/scripts/format-scope.json
+++ b/scripts/format-scope.json
@@ -117,6 +117,7 @@
"server/providers/cctv/constants.js",
"server/providers/cctv/media.js",
"server/providers/cctv/normalize.js",
+ "src/data/cctvMissingNumbers.test.mjs",
"server/providers/cctv/sources.js",
"server/providers/radio.js",
"server/providers/radio/catalog.js",
diff --git a/server/providers/cctv/normalize.js b/server/providers/cctv/normalize.js
index 6c54622e9..2d4e16e11 100644
--- a/server/providers/cctv/normalize.js
+++ b/server/providers/cctv/normalize.js
@@ -39,11 +39,21 @@ export function escapeXml(text) {
/**
* Coerce a value to a finite number, returning fallback if NaN/Infinity.
*
+ * Absent, blank and boolean values take the fallback too: `Number` reads
+ * null, '' and false as 0, which would turn a missing coordinate, heading or
+ * elevation into a real zero.
+ *
* @param {*} value
* @param {number} [fallback=NaN]
* @returns {number}
*/
export function toFiniteNumber(value, fallback = NaN) {
+ if (
+ value == null ||
+ typeof value === 'boolean' ||
+ (typeof value === 'string' && !value.trim())
+ )
+ return fallback;
const num = Number(value);
return Number.isFinite(num) ? num : fallback;
}
diff --git a/src/data/cctvMissingNumbers.test.mjs b/src/data/cctvMissingNumbers.test.mjs
new file mode 100644
index 000000000..68911110f
--- /dev/null
+++ b/src/data/cctvMissingNumbers.test.mjs
@@ -0,0 +1,252 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+import {
+ fallbackHeadingFromId,
+ isLikelyCataloniaCoordinate,
+ toFiniteNumber,
+} from '../../server/providers/cctv/normalize.js';
+import {
+ loadCaltransSourcesFromOpenData,
+ loadTallinnSourcesFromCatalog,
+ loadTflSourcesFromOpenData,
+ parseCataloniaXml,
+} from '../../server/providers/cctv/sources.js';
+import { cctvProxy } from '../../server/providers/cctv.js';
+import { createCatalog } from '../layers/cctv/catalog.js';
+import { createModel } from '../layers/cctv/model.js';
+
+/** Values `Number` reads as 0 (or 1) although they carry no number. */
+const ABSENT = [null, undefined, '', ' ', false, true];
+
+/** Set env vars for one test and restore them afterwards. */
+function withEnv(t, env) {
+ for (const [name, value] of Object.entries(env)) {
+ const previous = process.env[name];
+ t.after(() => {
+ if (previous === undefined) delete process.env[name];
+ else process.env[name] = previous;
+ });
+ if (value === undefined) delete process.env[name];
+ else process.env[name] = value;
+ }
+}
+
+/** Silence the loaders' progress and failure logging. */
+function quiet(t) {
+ t.mock.method(console, 'log', () => {});
+ t.mock.method(console, 'warn', () => {});
+}
+
+/** The production browser model; only the catalog's pose pass is stubbed. */
+function browserModel() {
+ const parts = {};
+ parts.model = createModel({
+ state: {},
+ services: { focus: {} },
+ parts,
+ source: {},
+ });
+ return parts.model;
+}
+
+/** The browser catalog over the production model. */
+function browserCatalog(model, cityPois = {}) {
+ return createCatalog({
+ state: {},
+ services: { locations: { CITY_POIS: cityPois } },
+ parts: { model: { ...model, ensureCameraPose: () => {} } },
+ source: {},
+ });
+}
+
+/** GET /api/cctv/sources through the real proxy middleware. */
+async function requestSources(plugin) {
+ let handler;
+ plugin.configureServer({
+ middlewares: {
+ use(_route, fn) {
+ handler = fn;
+ },
+ },
+ });
+ const res = {
+ writeHead(status, headers) {
+ Object.assign(this, { status, headers });
+ },
+ end(body) {
+ this.body = body;
+ },
+ };
+ await handler({ url: '/sources', method: 'GET' }, res);
+ assert.equal(res.status, 200);
+ return JSON.parse(res.body);
+}
+
+test('absent, blank and boolean values take the fallback on both sides of the source list', () => {
+ const { safeNumber } = browserModel();
+ for (const value of ABSENT) {
+ assert.ok(Number.isNaN(toFiniteNumber(value)), `server ${String(value)}`);
+ assert.equal(toFiniteNumber(value, 15), 15, `server ${String(value)}`);
+ assert.ok(Number.isNaN(safeNumber(value)), `browser ${String(value)}`);
+ assert.equal(safeNumber(value, 74), 74, `browser ${String(value)}`);
+ }
+ for (const [value, expected] of [
+ [0, 0],
+ ['0', 0],
+ [-17, -17],
+ [' 12.5 ', 12.5],
+ ]) {
+ assert.equal(toFiniteNumber(value, 99), expected);
+ assert.equal(safeNumber(value, 99), expected);
+ }
+});
+
+test('a source-pack camera that omits or nulls its pose gets the browser defaults, not the clamp floors', async (t) => {
+ quiet(t);
+ const pose = {
+ headingDeg: null,
+ pitchDeg: null,
+ fovDeg: null,
+ rangeM: null,
+ mountHeightM: null,
+ groundElevationM: null,
+ };
+ const at = { cityId: 'austin', lat: 30.2672, lon: -97.7431 };
+ withEnv(t, {
+ CCTV_SOURCES_FILE: path.join(tmpdir(), 'gev-no-such-pack.json'),
+ CCTV_SOURCES_JSON: JSON.stringify([
+ { id: 'pack-pose-omitted', ...at, feedType: 'image' },
+ { id: 'pack-pose-null', ...at, ...pose, feedType: 'image' },
+ { id: 'pack-coords-null', lat: null, lon: null, feedType: 'image' },
+ { id: 'pack-coords-blank', lat: '', lon: ' ', feedType: 'image' },
+ ]),
+ });
+
+ const body = await requestSources(cctvProxy({ sourceRoot: tmpdir() }));
+ const model = browserModel();
+ const cameras = browserCatalog(model, {
+ austin: { name: 'Austin', groundElevation: 150 },
+ }).buildCatalogFromSources(body.sources);
+ const byId = new Map(cameras.map((camera) => [camera.id, camera]));
+
+ for (const id of ['pack-pose-omitted', 'pack-pose-null']) {
+ const camera = byId.get(id);
+ assert.ok(camera, id);
+ assert.equal(camera.headingDeg, model.headingFromId(id), id);
+ assert.equal(camera.pitchDeg, -17, id);
+ assert.equal(camera.fovDeg, 74, id);
+ assert.equal(camera.rangeM, 700, id);
+ assert.equal(camera.mountHeightM, 24, id);
+ assert.equal(camera.groundElevationM, 150, id);
+ }
+ // No position is not a camera in the Gulf of Guinea.
+ assert.equal(byId.has('pack-coords-null'), false);
+ assert.equal(byId.has('pack-coords-blank'), false);
+});
+
+test('Tallinn rows with a null heading or ground keep the low-confidence prior', (t) => {
+ quiet(t);
+ const root = mkdtempSync(path.join(tmpdir(), 'gev-tallinn-'));
+ t.after(() => rmSync(root, { recursive: true, force: true }));
+ const file = path.join(root, 'tallinn.json');
+ writeFileSync(
+ file,
+ JSON.stringify([
+ {
+ id: 'tln-null-heading',
+ lat: 59.437,
+ lon: 24.753,
+ headingDeg: null,
+ groundElevationM: null,
+ url: 'https://ristmikud.tallinn.ee/last/cam001.jpg',
+ },
+ ]),
+ );
+ withEnv(t, { CCTV_TALLINN_SOURCES_FILE: file });
+
+ const [camera] = loadTallinnSourcesFromCatalog({ sourceRoot: root });
+ assert.equal(camera.headingConfidence, 'low');
+ assert.equal(camera.headingDeg, fallbackHeadingFromId('tln-null-heading'));
+ assert.equal(camera.groundElevationM, 15);
+});
+
+test('TfL JamCams with null coordinates are dropped instead of placed at 0,0', async (t) => {
+ quiet(t);
+ const jamCam = (id, lat, lon) => ({
+ id: `JamCams_${id}`,
+ commonName: `JamCam ${id}`,
+ lat,
+ lon,
+ additionalProperties: [
+ { key: 'available', value: 'true' },
+ {
+ key: 'imageUrl',
+ value: `https://s3-eu-west-1.amazonaws.com/jamcams.tfl.gov.uk/${id}.jpg`,
+ },
+ ],
+ });
+ t.mock.method(globalThis, 'fetch', async () =>
+ Response.json([
+ jamCam('00001.00001', 51.5072, -0.1276),
+ jamCam('00001.00002', null, null),
+ jamCam('00001.00003', 51.5072, ''),
+ ]),
+ );
+
+ const cameras = await loadTflSourcesFromOpenData();
+ assert.deepEqual(
+ cameras.map((camera) => camera.id),
+ ['tfl-00001.00001'],
+ );
+});
+
+test('a Catalonia feature with a blank longitude is not read as the zero meridian', () => {
+ const feature = (coordinates) => `
+
+ ${coordinates}
+ https://example.org/cam.jpg
+
+ `;
+ const [blank, valid] = parseCataloniaXml(
+ feature(',41.5') + feature('2.17,41.39'),
+ );
+ // 0 °E at 41.5 °N is inside the Catalonia box, so a zero would pass it.
+ assert.ok(Number.isNaN(blank.lon));
+ assert.equal(isLikelyCataloniaCoordinate(blank.lat, blank.lon), false);
+ assert.equal(isLikelyCataloniaCoordinate(valid.lat, valid.lon), true);
+});
+
+test('a Caltrans camera with a blank elevation keeps the 150 m ground prior', async (t) => {
+ quiet(t);
+ withEnv(t, { CCTV_CALTRANS_DISTRICTS: '4' });
+ t.mock.method(globalThis, 'fetch', async () =>
+ Response.json({
+ data: [
+ {
+ cctv: {
+ inService: 'true',
+ location: {
+ latitude: '37.8044',
+ longitude: '-122.2712',
+ elevation: '',
+ locationName: 'TV101 -- I-880 : Test Ave',
+ },
+ imageData: {
+ static: {
+ currentImageURL:
+ 'https://cwwp2.dot.ca.gov/data/d4/cctv/image/tv101/tv101.jpg',
+ },
+ },
+ },
+ },
+ ],
+ }),
+ );
+
+ const [camera] = await loadCaltransSourcesFromOpenData();
+ assert.ok(camera);
+ assert.equal(camera.groundElevationM, 150);
+});
diff --git a/src/layers/cctv/model.js b/src/layers/cctv/model.js
index 48c8cddd4..bdf350b4e 100644
--- a/src/layers/cctv/model.js
+++ b/src/layers/cctv/model.js
@@ -97,12 +97,23 @@ export function createModel({ state: layerState, services, parts, source }) {
/**
* Coerces a value to a finite number or returns the fallback.
+ *
+ * Absent, blank and boolean values return the fallback as well. The source
+ * list serializes an unknown number (NaN) as JSON null, and `Number(null)`
+ * is 0, so without this an unknown pose field arrived as zero and was
+ * clamped to the field's floor instead of taking its default.
* @param {*} value
* @param {number} [fallback=NaN]
* @returns {number}
*/
function safeNumber(value, fallback = NaN) {
+ if (
+ value == null ||
+ typeof value === 'boolean' ||
+ (typeof value === 'string' && !value.trim())
+ )
+ return fallback;
const n = Number(value);
return Number.isFinite(n) ? n : fallback;
}
From bd48533b03aba73d5eca76717d2c35e1785d4e1b Mon Sep 17 00:00:00 2001
From: Aditya <205600203+Rohilalala@users.noreply.github.com>
Date: Fri, 16 Oct 2026 05:43:24 +0530
Subject: [PATCH 02/14] fix(director): land on the requested shot
A shot action seeks to the target shot's startProgress, which
sceneTimingForShot computes as start / total. sceneSeekState multiplied
that back into seconds and compared it with each shot's end, but
(start / total) * total can round one ulp below start. The comparison
then put the seek at the very end of the previous shot. With five equal
3.9 s shots, "go to" shots 2, 3 and 5 landed on shots 1, 2 and 4.
Compare the requested progress against each shot's end / total instead,
the same division the caller used, so a shot's own start always selects
that shot. Exact boundaries still select the next shot, and anything
below a start still selects the previous one.
---
CHANGELOG.md | 4 ++++
docs/CURRENT-STATE.md | 2 ++
src/director/timeline.js | 16 +++++++++++----
src/director/timeline.test.mjs | 36 ++++++++++++++++++++++++++++++++++
4 files changed, 54 insertions(+), 4 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index dd76d7a25..da7866816 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,9 @@
# Changelog
+- Director shot actions land on the requested shot. A seek to a shot's start
+ could round a hair below it and select the end of the previous shot for some
+ shot counts and durations.
+
- Add Barcelona's bike share to the GBFS registry: Bicing (543 stations, the
city's PBSC system) and AMBici (237 e-bike stations across the metropolitan
area, run by nextbike). Both are keyless GBFS 2.3 feeds listed in
diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md
index 4c06dd3cb..29f82501d 100644
--- a/docs/CURRENT-STATE.md
+++ b/docs/CURRENT-STATE.md
@@ -427,6 +427,8 @@ reference. Legacy projects retain their ordinary flights, content and edits.
Seeking between ordinary camera poses uses the shortest longitude arc across the
dateline while preserving both authored endpoint longitudes, and turns heading
and roll the short way for any authored angles.
+A seek to a shot's start, as a shot action makes, selects that shot rather than
+the end of the previous one.
See [camera directions](DIRECTOR-CAMERA.md).
Realtime voice composes separate connection, response/tool, Radio handoff, input
diff --git a/src/director/timeline.js b/src/director/timeline.js
index f7cffcece..5bcb42db6 100644
--- a/src/director/timeline.js
+++ b/src/director/timeline.js
@@ -72,19 +72,27 @@ export function sceneSeekState(scene, progress, durationForShot, holdForShot) {
let shotIndex = scene.shots.length - 1;
for (let index = 0; index < scene.shots.length; index += 1) {
const endElapsedSec = startElapsedSec + durations[index];
- if (targetSec < endElapsedSec || index === scene.shots.length - 1) {
+ // Compare in progress units: a shot's startProgress (start / total)
+ // times total can round below its start and select the previous shot.
+ if (
+ normalized < endElapsedSec / totalSec ||
+ index === scene.shots.length - 1
+ ) {
shotIndex = index;
break;
}
startElapsedSec = endElapsedSec;
}
+ // Keep the elapsed time inside the selected shot, so it agrees with
+ // shotIndex when targetSec rounded below the shot's start.
+ const elapsedSec = Math.max(targetSec, startElapsedSec);
const shot = scene.shots[shotIndex];
const flightDurationSec = shot.durationSec || DEFAULT_SHOT_DURATION_SEC;
const holdDurationSec = holdForShot(scene, shot);
const shotDurationSec = Math.max(0.001, flightDurationSec + holdDurationSec);
const shotElapsedSec = Math.max(
0,
- Math.min(shotDurationSec, targetSec - startElapsedSec),
+ Math.min(shotDurationSec, elapsedSec - startElapsedSec),
);
const cameraProgress = clamp01(
shotElapsedSec / Math.max(0.001, flightDurationSec),
@@ -99,8 +107,8 @@ export function sceneSeekState(scene, progress, durationForShot, holdForShot) {
);
const move = resolveCameraMove(scene, shot);
return {
- sceneProgress: totalSec > 0 ? targetSec / totalSec : 0,
- sceneElapsedSec: targetSec,
+ sceneProgress: totalSec > 0 ? elapsedSec / totalSec : 0,
+ sceneElapsedSec: elapsedSec,
sceneDurationSec: totalSec,
shotIndex,
shot,
diff --git a/src/director/timeline.test.mjs b/src/director/timeline.test.mjs
index 1950eeef0..69a370c98 100644
--- a/src/director/timeline.test.mjs
+++ b/src/director/timeline.test.mjs
@@ -49,3 +49,39 @@ test('camera seeking preserves cubic easing and shortest-angle orientation', ()
);
assert.deepEqual(cameraAtProgress(null, camera(20, 350), 0), camera(20, 350));
});
+
+test('seeking to a shot start lands in that shot for every shot count and duration', () => {
+ const misses = [];
+ for (let count = 2; count <= 12; count += 1) {
+ for (let flightTenths = 1; flightTenths <= 100; flightTenths += 1) {
+ for (let holdTenths = 0; holdTenths <= 50; holdTenths += 1) {
+ const shots = Array.from({ length: count }, (_, index) => ({
+ id: `s${index}`,
+ // Every third shot is longer, so boundaries are not all equal steps.
+ durationSec: flightTenths / 10 + (index % 3 === 2 ? 1.7 : 0),
+ holdSec: holdTenths / 10,
+ camera: camera(20, 0),
+ }));
+ const mixed = { shots };
+ for (const [index, shot] of shots.entries()) {
+ const { startProgress, startElapsedSec } = sceneTimingForShot(
+ mixed,
+ shot,
+ duration,
+ );
+ const seek = sceneSeekState(mixed, startProgress, duration, hold);
+ // One or two ulps earlier still belongs to the previous shot.
+ const before = startProgress * (1 - Number.EPSILON);
+ const previous = sceneSeekState(mixed, before, duration, hold);
+ if (
+ seek.shotIndex !== index ||
+ seek.sceneElapsedSec < startElapsedSec ||
+ previous.shotIndex !== Math.max(0, index - 1)
+ )
+ misses.push(`${count}x${flightTenths}/${holdTenths}#${index}`);
+ }
+ }
+ }
+ }
+ assert.deepEqual(misses.slice(0, 5), []);
+});
From 2c61d9e2b72ff85c9b5a69bea78799cfd099cc65 Mon Sep 17 00:00:00 2001
From: Aditya <205600203+Rohilalala@users.noreply.github.com>
Date: Fri, 16 Oct 2026 05:52:28 +0530
Subject: [PATCH 03/14] fix(geo): wrap longitude differences across the
antimeridian
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The flights ground-floor pipeline subtracted longitudes without wrapping.
approxDistanceKm, and its copy in the mesh-floor sampler, measured two
points 0.2° apart either side of 180° (21 km at Fiji) as about 38,000 km,
so low and grounded aircraft just across the line from the viewer were
never floored or mesh-sampled. corridorFloorCells walked a segment across
180° the long way round, filling its cell budget with ground behind the
contact and never requesting the cells it was about to cross, and
stickyFloorCell dropped its hysteresis for a contact on the seam. Each now
takes the short way round, and corridor points are normalised back into
[-180, 180].
SEARCH NEARBY SITES centred its Google Places search on the mean of west
and east, which for a view across 180° (west > east) is longitude 0, and
sized the radius from the view's height alone. It now measures the view's
width through 180° and searches its real centre.
---
CHANGELOG.md | 11 +++++
src/data/groundFloor.test.mjs | 26 +++++++++++
src/data/meshFloorSampler.test.mjs | 9 ++++
src/layers/flights/recordPolicy.js | 5 +-
src/layers/flights/recordPolicy.test.mjs | 18 ++++++++
src/layers/installations/ingestion.js | 9 ++--
src/layers/installations/ingestion.test.mjs | 51 +++++++++++++++++++++
src/services/groundFloor.js | 29 +++++++-----
src/services/meshFloorSampler.js | 5 +-
9 files changed, 146 insertions(+), 17 deletions(-)
create mode 100644 src/layers/flights/recordPolicy.test.mjs
create mode 100644 src/layers/installations/ingestion.test.mjs
diff --git a/CHANGELOG.md b/CHANGELOG.md
index dd76d7a25..b1d6cec0f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,16 @@
# Changelog
+- Flights' ground floor works across the antimeridian. The viewer-distance
+ check behind the 150 km floor radius and its copy in the photoreal mesh-floor
+ sampler subtracted longitudes without wrapping, so two points 21 km apart
+ across 180° measured about 38,000 km and low or grounded aircraft just across
+ the line from the viewer were never floored. A contact's display corridor
+ walked a segment across 180° the long way round, spending its cell budget on
+ ground behind the contact, and a contact on the seam lost its cell hysteresis.
+ SEARCH NEARBY SITES on a view across 180° searched Google Places around
+ longitude 0, on the far side of the globe, with a radius sized from the view's
+ height alone; it now searches the view.
+
- Add Barcelona's bike share to the GBFS registry: Bicing (543 stations, the
city's PBSC system) and AMBici (237 e-bike stations across the metropolitan
area, run by nextbike). Both are keyless GBFS 2.3 feeds listed in
diff --git a/src/data/groundFloor.test.mjs b/src/data/groundFloor.test.mjs
index e118549c3..4644f57ca 100644
--- a/src/data/groundFloor.test.mjs
+++ b/src/data/groundFloor.test.mjs
@@ -257,6 +257,32 @@ test('stickyFloorCell does not stick to a far-away stale cell', () => {
assert.deepEqual(stickyFloorCell(30.2004, -97.6604, prev), { lat: 30.2, lon: -97.66 });
});
+// --- Antimeridian ------------------------------------------------------------
+// Raw longitude arithmetic walks a segment across 180° the long way round and
+// reads a contact on the seam as half a world from its previous cell.
+
+test('corridorFloorCells walks across the antimeridian, not the long way round', () => {
+ for (const [from, to, across, behind] of [
+ [179.999, -179.998, -179.999, 179.998],
+ [-179.999, 179.998, 179.999, -179.998],
+ ]) {
+ const cells = corridorFloorCells([{ lat: -16.69, lon: from }, { lat: -16.69, lon: to }]);
+ const lons = cells.map((c) => c.lon);
+ assert.equal(lons[0], from);
+ assert.equal(lons.at(-1), to);
+ assert.ok(lons.includes(across), `crosses through ${across}: ${lons}`);
+ assert.ok(!lons.includes(behind), `never walks back through ${behind}: ${lons}`);
+ assert.ok(lons.every((lon) => Math.abs(lon) <= 180), `normalised: ${lons}`);
+ }
+});
+
+test('stickyFloorCell holds across the antimeridian', () => {
+ for (const [prevLon, lon] of [[180, -179.9998], [-180, 179.9998]]) {
+ const prev = { lat: -16.69, lon: prevLon };
+ assert.deepEqual(stickyFloorCell(-16.69, lon, prev), prev);
+ }
+});
+
// --- F2: corridor budget fairness ------------------------------------------
// The first cut spent the budget in Map insertion order and decremented it
// BEFORE deduping, so parked contacts (whose corridor is the fix cell the poll
diff --git a/src/data/meshFloorSampler.test.mjs b/src/data/meshFloorSampler.test.mjs
index 8c629e50f..f651d72f8 100644
--- a/src/data/meshFloorSampler.test.mjs
+++ b/src/data/meshFloorSampler.test.mjs
@@ -128,3 +128,12 @@ test('sampler: outside the google-3d regime nothing is sampled at all', () => {
setMeshFloorPreferred(true);
assert.equal(cachedMeshFloor(cell.lat, cell.lon), null);
});
+
+test('sampler: a cell just across the antimeridian is still near the viewer', () => {
+ reset();
+ let calls = 0;
+ const scene = fakeScene(() => { calls += 1; return undefined; });
+ sampleMeshFloorCells(scene, [{ lat: -16.8, lon: -179.999 }], { viewerLat: -16.8, viewerLon: 179.999 });
+ sampleMeshFloorCells(scene, [{ lat: -16.81, lon: 179.999 }], { viewerLat: -16.81, viewerLon: -179.999 });
+ assert.equal(calls, 2, 'about 200 m apart, so both cells are probed');
+});
diff --git a/src/layers/flights/recordPolicy.js b/src/layers/flights/recordPolicy.js
index ea284cb11..2172ffdc1 100644
--- a/src/layers/flights/recordPolicy.js
+++ b/src/layers/flights/recordPolicy.js
@@ -15,7 +15,10 @@ export const ERROR_BACKOFF_INTERVAL = 20000;
/** Local equirectangular distance used for viewer-proximate floor sampling. */
export function approxDistanceKm(lat1, lon1, lat2, lon2) {
const dLat = (lat2 - lat1) * 111.32;
+ // The short way round, so points either side of 180° stay close.
+ let dLonDeg = lon2 - lon1;
+ dLonDeg -= 360 * Math.round(dLonDeg / 360);
const dLon =
- (lon2 - lon1) * 111.32 * Math.cos((((lat1 + lat2) / 2) * Math.PI) / 180);
+ dLonDeg * 111.32 * Math.cos((((lat1 + lat2) / 2) * Math.PI) / 180);
return Math.hypot(dLat, dLon);
}
diff --git a/src/layers/flights/recordPolicy.test.mjs b/src/layers/flights/recordPolicy.test.mjs
new file mode 100644
index 000000000..4cf74d3cf
--- /dev/null
+++ b/src/layers/flights/recordPolicy.test.mjs
@@ -0,0 +1,18 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {
+ approxDistanceKm,
+ GROUND_FLOOR_CLAMP_RADIUS_KM,
+} from './recordPolicy.js';
+
+test('approximate distance takes the short way across the antimeridian', () => {
+ // 0.2° of longitude at Taveuni, Fiji (16.8° S) is about 21 km.
+ for (const [lon1, lon2] of [
+ [179.9, -179.9],
+ [-179.9, 179.9],
+ ]) {
+ const km = approxDistanceKm(-16.8, lon1, -16.8, lon2);
+ assert.ok(Math.abs(km - 21.31) < 0.01, `${lon1} → ${lon2}: ${km} km`);
+ assert.ok(km <= GROUND_FLOOR_CLAMP_RADIUS_KM);
+ }
+});
diff --git a/src/layers/installations/ingestion.js b/src/layers/installations/ingestion.js
index 57cda2710..c1d3101c7 100644
--- a/src/layers/installations/ingestion.js
+++ b/src/layers/installations/ingestion.js
@@ -133,15 +133,16 @@ export function createIngestion({
let placesError = null;
if (layerState.googleSearchRequested) {
layerState.googleSearchRequested = false;
+ // A view across 180° has west > east; its width runs through 180°.
+ const lonSpan = box.east - box.west + (box.east < box.west ? 360 : 0);
const latitude = (box.south + box.north) / 2;
- const longitude = (box.west + box.east) / 2;
+ let longitude = box.west + lonSpan / 2;
+ if (longitude > 180) longitude -= 360;
const radiusM = Math.min(
50000,
Math.max(
1000,
- Math.round(
- Math.max(box.north - box.south, box.east - box.west) * 55_000,
- ),
+ Math.round(Math.max(box.north - box.south, lonSpan) * 55_000),
),
);
try {
diff --git a/src/layers/installations/ingestion.test.mjs b/src/layers/installations/ingestion.test.mjs
new file mode 100644
index 000000000..fbb90534c
--- /dev/null
+++ b/src/layers/installations/ingestion.test.mjs
@@ -0,0 +1,51 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { createIngestion } from './ingestion.js';
+
+// Taveuni, Fiji: Cesium reports a view across 180° with west > east. Each view
+// is 0.4° wide and 0.2° tall; the last one does not cross 180°.
+for (const [west, east, centre] of [
+ [179.7, -179.9, 179.9],
+ [179.9, -179.7, -179.9],
+ [179.5, 179.9, 179.7],
+])
+ test(`a nearby search is centred on the view at ${centre}`, async () => {
+ const box = { south: -17, west, north: -16.8, east };
+ const searches = [];
+ const state = {
+ enabled: true,
+ viewer: {},
+ records: [],
+ googleSearchRequested: true,
+ };
+ const ingestion = createIngestion({
+ state,
+ source: {
+ getMappedSites: async () => ({ records: [], wide: true }),
+ searchNearby: async (query) => {
+ searches.push(query);
+ return { places: [] };
+ },
+ },
+ services: {
+ render: { governorRequestRender() {} },
+ ground: { resolveGroundFloorCellsBounded: async () => {} },
+ },
+ parts: {
+ viewport: {
+ loadArea: () => ({ box, coverage: {} }),
+ clearUnavailableRetry() {},
+ scheduleUnavailableRetry() {},
+ },
+ model: { installationWithinViewport: () => true },
+ rendering: { renderRecords() {}, warmInstallationFloors() {} },
+ },
+ });
+ await ingestion.loadInstallations();
+ assert.equal(searches.length, 1);
+ const { latitude, longitude, radiusM } = searches[0];
+ assert.ok(Math.abs(latitude + 16.9) < 1e-9);
+ assert.ok(Math.abs(longitude - centre) < 1e-9, `longitude ${longitude}`);
+ // The width, not the height, sets the radius.
+ assert.equal(radiusM, 22000);
+ });
diff --git a/src/services/groundFloor.js b/src/services/groundFloor.js
index 950c1b94e..27b7dc5e4 100644
--- a/src/services/groundFloor.js
+++ b/src/services/groundFloor.js
@@ -204,12 +204,16 @@ export function createGroundFloor({ terrain, signal }) {
* @returns {{lat: number, lon: number}} The cell to read.
*/
function stickyFloorCell(lat, lon, previousCell) {
- if (
- previousCell &&
- Math.abs(lat - previousCell.lat) <= 0.0005 + CELL_HYSTERESIS_DEG &&
- Math.abs(lon - previousCell.lon) <= 0.0005 + CELL_HYSTERESIS_DEG
- ) {
- return previousCell;
+ if (previousCell) {
+ // The short way round, so a contact on the 180° seam keeps its cell.
+ let dLon = lon - previousCell.lon;
+ dLon -= 360 * Math.round(dLon / 360);
+ if (
+ Math.abs(lat - previousCell.lat) <= 0.0005 + CELL_HYSTERESIS_DEG &&
+ Math.abs(dLon) <= 0.0005 + CELL_HYSTERESIS_DEG
+ ) {
+ return previousCell;
+ }
}
return coarseFloorCoord(lat, lon);
}
@@ -269,17 +273,20 @@ export function createGroundFloor({ terrain, signal }) {
const a = points[s];
const b = points[s + 1];
if (![a?.lat, a?.lon, b?.lat, b?.lon].every(Number.isFinite)) continue;
+ // The short way round, so a segment across 180° walks across it.
+ let dLon = b.lon - a.lon;
+ dLon -= 360 * Math.round(dLon / 360);
const steps = Math.ceil(
- Math.max(Math.abs(b.lat - a.lat), Math.abs(b.lon - a.lon)) /
+ Math.max(Math.abs(b.lat - a.lat), Math.abs(dLon)) /
CORRIDOR_WALK_STEP_DEG,
);
for (let i = 1; i <= steps; i++) {
if (out.length >= prefixLimit) break walk;
const t = i / steps;
- const cell = coarseFloorCoord(
- a.lat + (b.lat - a.lat) * t,
- a.lon + (b.lon - a.lon) * t,
- );
+ let lon = a.lon + dLon * t;
+ if (lon > 180) lon -= 360;
+ else if (lon < -180) lon += 360;
+ const cell = coarseFloorCoord(a.lat + (b.lat - a.lat) * t, lon);
const key = `${cell.lat},${cell.lon}`;
if (seen.has(key) || key === destKey) continue;
seen.add(key);
diff --git a/src/services/meshFloorSampler.js b/src/services/meshFloorSampler.js
index b1cf65f29..34a62fd27 100644
--- a/src/services/meshFloorSampler.js
+++ b/src/services/meshFloorSampler.js
@@ -84,8 +84,11 @@ export function createMeshFloorSampler({
*/
function _approxKm(lat1, lon1, lat2, lon2) {
const dLat = (lat2 - lat1) * 111.32;
+ // The short way round, so points either side of 180° stay close.
+ let dLonDeg = lon2 - lon1;
+ dLonDeg -= 360 * Math.round(dLonDeg / 360);
const dLon =
- (lon2 - lon1) * 111.32 * Math.cos((((lat1 + lat2) / 2) * Math.PI) / 180);
+ dLonDeg * 111.32 * Math.cos((((lat1 + lat2) / 2) * Math.PI) / 180);
return Math.hypot(dLat, dLon);
}
From a316e12323d2b862a92cc00fec2c6620710badea Mon Sep 17 00:00:00 2001
From: Aditya <205600203+Rohilalala@users.noreply.github.com>
Date: Tue, 13 Oct 2026 14:15:45 +0530
Subject: [PATCH 04/14] fix(overpass): read failure phrases from the remark,
not element tags
overpassLooksRuntimeError and overpassLooksRateLimited searched the whole
200 body, element tags included. A result containing a place named
"Timed Out Escape Rooms" (or any tag with "runtime error", "out of
memory", "too many requests" or "rate_limited") was refused as an
upstream failure, and the endpoint was put on cooldown for every other
query.
Parse the body once. A data reply (one with an elements array) reports
failure in its remark, so the phrase checks run on the remark. Any other
body, an error page or JSON without elements, is matched whole as
before.
---
CHANGELOG.md | 5 +++++
docs/CURRENT-STATE.md | 2 +-
server/providers/overpass/transport.js | 22 ++++++++++++++++++----
src/overpassProxy.test.mjs | 26 ++++++++++++++++++++++++++
4 files changed, 50 insertions(+), 5 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index dd76d7a25..c2b02f0b0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,10 @@
# Changelog
+- Overpass data whose element tags contain a failure phrase ("timed out",
+ "runtime error", "too many requests" and similar, e.g. a place named "Timed
+ Out Escape Rooms") is returned as data instead of being refused and putting
+ the endpoint on cooldown; upstream failures are read from the remark.
+
- Add Barcelona's bike share to the GBFS registry: Bicing (543 stations, the
city's PBSC system) and AMBici (237 e-bike stations across the metropolitan
area, run by nextbike). Both are keyless GBFS 2.3 feeds listed in
diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md
index 4c06dd3cb..3424560eb 100644
--- a/docs/CURRENT-STATE.md
+++ b/docs/CURRENT-STATE.md
@@ -525,7 +525,7 @@ are protected from fallback writes. Version 6 is the export format, with
zero pitch, low camera heights, scope and detection edits preserved. See [the document contract](SCENE-DOCUMENT.md);
see [Director](DIRECTOR.md) for camera, pack, action and sharing support.
-Search framing and annotations request semantic map features from an explicit source. The Overpass adapter owns bounded queries, member/tag decoding and request deadlines; callers retain candidate ranking, outline caching, deferred retries and scene placement. Empty, unavailable, transient and throttled outcomes remain distinct. Traffic sources return road records and installation sources return mapped records with freshness/saturation metadata, so their layers no longer decode upstream elements. ALPR already normalizes its records in the source. Traffic uses OpenFreeMap roads with optional matched TomTom congestion; ALPR uses an hourly OpenStreetMap extract. Keyless installations use military-area tiles. Detailed feature queries require operator-configured Overpass.
+Search framing and annotations request semantic map features from an explicit source. The Overpass adapter owns bounded queries, member/tag decoding and request deadlines; callers retain candidate ranking, outline caching, deferred retries and scene placement. Empty, unavailable, transient and throttled outcomes remain distinct; a data reply is refused only for its `remark`, never for text in element tags. Traffic sources return road records and installation sources return mapped records with freshness/saturation metadata, so their layers no longer decode upstream elements. ALPR already normalizes its records in the source. Traffic uses OpenFreeMap roads with optional matched TomTom congestion; ALPR uses an hourly OpenStreetMap extract. Keyless installations use military-area tiles. Detailed feature queries require operator-configured Overpass.
Nepal media preloads survive repeated camera-flight updates, but Stop, event disable and replacement remove abandoned frames and revoke pending Facebook sessions. Fallback evidence-card clicks respect the shared drawing-tool pointer lease.
diff --git a/server/providers/overpass/transport.js b/server/providers/overpass/transport.js
index 38c31bb08..e3776e291 100644
--- a/server/providers/overpass/transport.js
+++ b/server/providers/overpass/transport.js
@@ -14,7 +14,7 @@ import { simplifyOverpassPayloadBody } from './geometry.js';
* Checks for known rate-limit phrases in the body text regardless of
* HTTP status code, since some mirrors return 200 with an error payload.
*
- * @param {string} bodyText - Upstream response body.
+ * @param {string} bodyText - The remark of a data reply, or a non-data body.
* @returns {boolean} True if the body looks rate-limited.
*/
function overpassLooksRateLimited(bodyText) {
@@ -41,6 +41,14 @@ function overpassLooksRuntimeError(bodyText) {
);
}
+function parseJson(text) {
+ try {
+ return JSON.parse(text);
+ } catch {
+ return undefined;
+ }
+}
+
/**
* True only for an upstream response that is actually Overpass data.
*
@@ -151,15 +159,22 @@ async function fetchOverpassPayload(
signal: controller.signal,
});
const responseBody = await readBody(upstream, maxResponseBytes);
+ const parsed = parseJson(responseBody);
+ // An Overpass data reply reports failure in its remark; its element
+ // tags are data and can contain any phrase. Any other body (an error
+ // page, or JSON without elements) is matched whole.
+ const failureText = Array.isArray(parsed?.elements)
+ ? String(parsed.remark || '')
+ : responseBody;
const rateLimited =
upstream.status === 429 ||
upstream.status === 406 ||
- overpassLooksRateLimited(responseBody);
+ overpassLooksRateLimited(failureText);
if (
rateLimited ||
upstream.status < 200 ||
upstream.status >= 300 ||
- overpassLooksRuntimeError(responseBody)
+ overpassLooksRuntimeError(failureText)
) {
const failures = (previous?.failures || 0) + 1;
const delay = retryDelay(
@@ -178,7 +193,6 @@ async function fetchOverpassPayload(
failure = refusal(status, delay);
continue;
}
- const parsed = JSON.parse(responseBody);
if (!Array.isArray(parsed?.elements) || parsed.remark)
throw new Error('Malformed Overpass response');
cooldowns.delete(endpoint);
diff --git a/src/overpassProxy.test.mjs b/src/overpassProxy.test.mjs
index ed4539fda..788846db7 100644
--- a/src/overpassProxy.test.mjs
+++ b/src/overpassProxy.test.mjs
@@ -126,6 +126,32 @@ test('production reader rejects oversized, malformed and runtime-failure bodies'
}
});
+test('element data that mentions a failure phrase is data, and sets no cooldown', async () => {
+ const names = ['Timed Out Escape Rooms', 'Runtime Error Bar', 'Out of Memory Lane', 'Too Many Requests Cafe', 'rate_limited'];
+ for (const [i, name] of names.entries()) {
+ const body = JSON.stringify({ elements: [{ type: 'node', id: 1, lat: 1, lon: 2, tags: { name } }] });
+ let count = 0;
+ const options = { endpoints: [`https://phrase-${i}.example/`], fetchImpl: async () => {
+ count++; return new Response(body);
+ } };
+ const payload = await fetchOverpassPayload('first', 1024, options);
+ assert.equal(payload.status, 200, name);
+ assert.ok(overpassPayloadIsData(payload), name);
+ assert.ok(payload.body.includes(name), name);
+ await fetchOverpassPayload('different', 1024, options);
+ assert.equal(count, 2, name);
+ }
+ const remark = JSON.stringify({ elements: [], remark: 'runtime error: rate_limited' });
+ const limited = await fetchOverpassPayload('x', 1024, { endpoints: ['https://remark.example/'],
+ fetchImpl: async () => new Response(remark, { headers: { 'Retry-After': '90' } }) });
+ assert.equal(limited.status, 429);
+ assert.equal(limited.retryAfterMs, 90_000);
+ const error = await fetchOverpassPayload('x', 1024, { endpoints: ['https://error.example/'],
+ fetchImpl: async () => new Response('{"error":"rate_limited"}', { headers: { 'Retry-After': '120' } }) });
+ assert.equal(error.status, 429);
+ assert.equal(error.retryAfterMs, 120_000);
+});
+
function proxyHandler() {
const plugin = createViteConfig({ mode: 'test' }).plugins.find(p => p.name === 'overpass-proxy');
const routes = new Map();
From 557d3bb41e7cc706d030177ac89d54f864f3ab00 Mon Sep 17 00:00:00 2001
From: Aditya <205600203+Rohilalala@users.noreply.github.com>
Date: Tue, 13 Oct 2026 14:24:09 +0530
Subject: [PATCH 05/14] fix(military): hold the last track and speed when a
poll omits them
MilitaryFlightRecords coerced a missing course to 0 (`courseDeg || 0`)
and a missing speed to 0 (`speedMps ?? 0`) before stickyNumber, so the
hold-last-known-good merge never ran. When an adsb.lol row arrived
without `track` or `gs`, the aircraft's icon swung to north and the
position history got a 0 kt / 0 degree fix, so dead reckoning stopped
or turned it north. The civil FlightRecords holds the last value for
the same input.
Pass the raw values to stickyNumber, with 0 as the fallback only until a
value is known, as before. A grounded row without a speed still reads
0, so a parked aircraft doesn't coast at its landing speed.
---
CHANGELOG.md | 4 ++++
docs/CURRENT-STATE.md | 4 +++-
src/layers/military/records.js | 13 ++++++----
src/layers/military/records.test.mjs | 36 ++++++++++++++++++++++++++++
4 files changed, 52 insertions(+), 5 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index dd76d7a25..df39cef89 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,9 @@
# Changelog
+- Military aircraft keep their last heading and speed when an adsb.lol row omits
+ `track` or `gs`, instead of turning north and stopping; a grounded row without
+ a speed still reads 0.
+
- Add Barcelona's bike share to the GBFS registry: Bicing (543 stations, the
city's PBSC system) and AMBici (237 e-bike stations across the metropolitan
area, run by nextbike). Both are keyless GBFS 2.3 feeds listed in
diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md
index 4c06dd3cb..b4a531482 100644
--- a/docs/CURRENT-STATE.md
+++ b/docs/CURRENT-STATE.md
@@ -584,7 +584,9 @@ pinning, first-position grace, source time and sea-surface placement are unchang
Military flights also separate portable records and source acquisition from
Cesium snapshot application. The metadata owner retains aviation feet, source
-fix times, sticky fields and partial-feed retention. Rendering retains ground
+fix times, sticky fields and partial-feed retention. A row without track or
+speed holds the last known value, as civil records do; a grounded row without
+speed reads 0. Rendering retains ground
model ownership, motion history, military registration and tracking cleanup.
Civil flights separate source acquisition, portable record reconciliation and
diff --git a/src/layers/military/records.js b/src/layers/military/records.js
index 9cd6754e6..05d91ccf0 100644
--- a/src/layers/military/records.js
+++ b/src/layers/military/records.js
@@ -39,8 +39,6 @@ export class MilitaryFlightRecords {
? prevMeta.altitudeFt * 0.3048
: 0
: 3048);
- const track = aircraft.courseDeg || 0;
- const speedMps = aircraft.speedMps ?? 0;
const verticalRateMps = aircraft.verticalRateMps;
const callsign = aircraft.callsign;
const type = aircraft.typeCode;
@@ -148,8 +146,15 @@ export class MilitaryFlightRecords {
// altitudeFt/baro).
geoAltitudeM,
renderAltitudeM,
- speedMps: stickyNumber(speedMps, prevMeta?.speedMps, null),
- track: stickyNumber(track, prevMeta?.track, null),
+ // A poll that omits track/speed holds the last values, like the civil
+ // records; 0 only until one is known. A grounded row without a speed
+ // reads 0, so a parked plane never coasts at its landing speed.
+ speedMps: stickyNumber(
+ aircraft.speedMps,
+ onGround ? 0 : prevMeta?.speedMps,
+ 0,
+ ),
+ track: stickyNumber(aircraft.courseDeg, prevMeta?.track, 0),
// Analyst seam (additive): sticky like the other kinematics.
verticalRateMps: stickyNumber(
verticalRateMps,
diff --git a/src/layers/military/records.test.mjs b/src/layers/military/records.test.mjs
index 5054fb28a..ce00c7761 100644
--- a/src/layers/military/records.test.mjs
+++ b/src/layers/military/records.test.mjs
@@ -114,3 +114,39 @@ test('military emergency status follows the latest poll instead of sticking', ()
);
assert.equal(next.meta.emergency, null);
});
+
+test('military records hold the last track and speed when a poll omits them', () => {
+ const records = store();
+ records.receive(observation({ courseDeg: 270, speedMps: 200 }), context());
+ const missing = records.receive(
+ observation({ courseDeg: null, speedMps: null }),
+ context(),
+ );
+ assert.equal(missing.meta.track, 270);
+ assert.equal(missing.meta.speedMps, 200);
+ const north = records.receive(
+ observation({ courseDeg: 0, speedMps: 0 }),
+ context(),
+ );
+ assert.equal(north.meta.track, 0);
+ assert.equal(north.meta.speedMps, 0);
+ const unseen = store().receive(
+ observation({ courseDeg: undefined, speedMps: undefined }),
+ context(),
+ );
+ assert.equal(unseen.meta.track, 0);
+ assert.equal(unseen.meta.speedMps, 0);
+ const landing = store();
+ landing.receive(observation({ courseDeg: 90, speedMps: 70 }), context());
+ const parked = landing.receive(
+ observation({
+ onGround: true,
+ baroAltitudeM: null,
+ courseDeg: null,
+ speedMps: null,
+ }),
+ context(),
+ );
+ assert.equal(parked.meta.speedMps, 0);
+ assert.equal(parked.meta.track, 90);
+});
From 1d62cdd805ea6cc9d479dbd7ef756e27a5867f44 Mon Sep 17 00:00:00 2001
From: Sameh Khamis
Date: Fri, 16 Oct 2026 14:16:32 -0700
Subject: [PATCH 06/14] Move the changelog entry into changelog.d
Main now takes release notes as one fragment per pull request (#787).
---
CHANGELOG.md | 4 ----
changelog.d/1096-military-hold-track-speed.md | 3 +++
2 files changed, 3 insertions(+), 4 deletions(-)
create mode 100644 changelog.d/1096-military-hold-track-speed.md
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e689a9d40..8ee00d839 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,9 +1,5 @@
# Changelog
-- Military aircraft keep their last heading and speed when an adsb.lol row omits
- `track` or `gs`, instead of turning north and stopping; a grounded row without
- a speed still reads 0.
-
- Local FM seek finds stations after wrapping around 87.5–108 MHz, covers both
band edges, skips the starting station and rejects spectra from an earlier
tune (Aditya, @Rohilalala, #1174).
diff --git a/changelog.d/1096-military-hold-track-speed.md b/changelog.d/1096-military-hold-track-speed.md
new file mode 100644
index 000000000..adc1793c4
--- /dev/null
+++ b/changelog.d/1096-military-hold-track-speed.md
@@ -0,0 +1,3 @@
+- Military aircraft keep their last heading and speed when an adsb.lol row omits
+ `track` or `gs`, instead of turning north and stopping; a grounded row without
+ a speed still reads 0 (Aditya, @Rohilalala, #1096).
From aa7943a07bfbce6fae9fd8325712e09346583f48 Mon Sep 17 00:00:00 2001
From: Sameh Khamis
Date: Fri, 16 Oct 2026 14:16:34 -0700
Subject: [PATCH 07/14] Move the changelog entry into changelog.d
Main now takes release notes as one fragment per pull request (#787).
---
CHANGELOG.md | 5 -----
changelog.d/1097-overpass-remark-failure-phrases.md | 5 +++++
2 files changed, 5 insertions(+), 5 deletions(-)
create mode 100644 changelog.d/1097-overpass-remark-failure-phrases.md
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 873522b98..8ee00d839 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,10 +1,5 @@
# Changelog
-- Overpass data whose element tags contain a failure phrase ("timed out",
- "runtime error", "too many requests" and similar, e.g. a place named "Timed
- Out Escape Rooms") is returned as data instead of being refused and putting
- the endpoint on cooldown; upstream failures are read from the remark.
-
- Local FM seek finds stations after wrapping around 87.5–108 MHz, covers both
band edges, skips the starting station and rejects spectra from an earlier
tune (Aditya, @Rohilalala, #1174).
diff --git a/changelog.d/1097-overpass-remark-failure-phrases.md b/changelog.d/1097-overpass-remark-failure-phrases.md
new file mode 100644
index 000000000..4543710da
--- /dev/null
+++ b/changelog.d/1097-overpass-remark-failure-phrases.md
@@ -0,0 +1,5 @@
+- Overpass data whose element tags contain a failure phrase ("timed out",
+ "runtime error", "too many requests" and similar, e.g. a place named "Timed
+ Out Escape Rooms") is returned as data instead of being refused and putting
+ the endpoint on cooldown; upstream failures are read from the remark (Aditya,
+ @Rohilalala, #1097).
From 41be10f320aa7668e7b9a1dc31e66418e4ae7a08 Mon Sep 17 00:00:00 2001
From: Sameh Khamis
Date: Fri, 16 Oct 2026 14:16:36 -0700
Subject: [PATCH 08/14] Move the changelog entry into changelog.d
Main now takes release notes as one fragment per pull request (#787).
---
CHANGELOG.md | 4 ----
changelog.d/1171-director-goto-shot.md | 3 +++
2 files changed, 3 insertions(+), 4 deletions(-)
create mode 100644 changelog.d/1171-director-goto-shot.md
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 24fb0df5c..8ee00d839 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,9 +1,5 @@
# Changelog
-- Director shot actions land on the requested shot. A seek to a shot's start
- could round a hair below it and select the end of the previous shot for some
- shot counts and durations.
-
- Local FM seek finds stations after wrapping around 87.5–108 MHz, covers both
band edges, skips the starting station and rejects spectra from an earlier
tune (Aditya, @Rohilalala, #1174).
diff --git a/changelog.d/1171-director-goto-shot.md b/changelog.d/1171-director-goto-shot.md
new file mode 100644
index 000000000..4c08351ef
--- /dev/null
+++ b/changelog.d/1171-director-goto-shot.md
@@ -0,0 +1,3 @@
+- Director shot actions land on the requested shot. A seek to a shot's start
+ could round a hair below it and select the end of the previous shot for some
+ shot counts and durations (Aditya, @Rohilalala, #1171).
From fa8e3e989ff2c28e13c883c8570ea994e645b6ae Mon Sep 17 00:00:00 2001
From: Rudy Mizrahi Celekli <47457359+rudycelekli@users.noreply.github.com>
Date: Fri, 16 Oct 2026 17:53:50 -0400
Subject: [PATCH 09/14] fix(tools): exclude unknown altitude from requested
aircraft bands (#1115)
aircraft_in_area compared a nullable altitude, so a contact with neither barometric nor geometric altitude matched max_altitude_m as though it were at zero. Require a reported finite altitude only when a band is requested; unfiltered queries keep the contact.
---
.../1115-aircraft-altitude-band-unknown.md | 2 +
docs/CURRENT-STATE.md | 3 +
src/tools/queries/aviation.js | 2 +
src/tools/queries/aviation.test.mjs | 59 +++++++++++++++++++
4 files changed, 66 insertions(+)
create mode 100644 changelog.d/1115-aircraft-altitude-band-unknown.md
diff --git a/changelog.d/1115-aircraft-altitude-band-unknown.md b/changelog.d/1115-aircraft-altitude-band-unknown.md
new file mode 100644
index 000000000..a6b69472d
--- /dev/null
+++ b/changelog.d/1115-aircraft-altitude-band-unknown.md
@@ -0,0 +1,2 @@
+- Exclude aircraft without a reported altitude from requested altitude bands;
+ unfiltered aircraft queries still retain them (rudycelekli, #1115).
diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md
index df92c0d50..1bdca7670 100644
--- a/docs/CURRENT-STATE.md
+++ b/docs/CURRENT-STATE.md
@@ -100,6 +100,9 @@ shared `area` argument (place name, bounding box, or point and radius) and cap
lists at 25 rows by default. Voice offers the same queries next to its app
actions: the session lists them, and the browser runs them through the same
catalog, loaded on first use. See [tools and the MCP server](TOOLS.md).
+Aircraft altitude-band queries require a reported barometric or geometric altitude
+for each match, so a grounded contact that reports none is left out of a band too.
+Queries without an altitude band retain unknown-altitude contacts.
Catalog calls check their cancellation signal immediately before executing a
tool. Already-cancelled queries do not start; cancellation after execution starts
diff --git a/src/tools/queries/aviation.js b/src/tools/queries/aviation.js
index 0b8b54b79..235f542e1 100644
--- a/src/tools/queries/aviation.js
+++ b/src/tools/queries/aviation.js
@@ -111,6 +111,8 @@ export const aircraftInArea = defineTool({
(record) =>
areaContains(area, { lat: record.latitude, lon: record.longitude }) &&
!(args.airborne_only && record.onGround) &&
+ ((args.min_altitude_m == null && args.max_altitude_m == null) ||
+ Number.isFinite(altitude(record))) &&
(args.min_altitude_m == null ||
altitude(record) >= args.min_altitude_m) &&
(args.max_altitude_m == null ||
diff --git a/src/tools/queries/aviation.test.mjs b/src/tools/queries/aviation.test.mjs
index 20ec833aa..d0e979ed6 100644
--- a/src/tools/queries/aviation.test.mjs
+++ b/src/tools/queries/aviation.test.mjs
@@ -388,3 +388,62 @@ test('the real OpenSky source marks an hour-old snapshot stale for searches', as
assert.deepEqual(result.data.stale_feeds, ['civil']);
assert.match(result.summary, /may be stale\.$/);
});
+
+test('altitude bands require a reported altitude from normalized OpenSky records', async () => {
+ const { normalizeOpenSkyAircraft } =
+ await import('../../sources/live/aircraft.js');
+ const state = (id, baro, geometric) =>
+ normalizeOpenSkyAircraft([
+ id,
+ 'TEST',
+ 'Test',
+ 1767225600,
+ 1767225600,
+ 0,
+ 0,
+ baro,
+ false,
+ 10,
+ 90,
+ 0,
+ [],
+ geometric,
+ ]);
+ const records = [
+ state('abc001', null, null),
+ state('abc002', 0, null),
+ state('abc003', null, 50),
+ state('abc004', 500, null),
+ ];
+ const catalog = composeCatalog({
+ tools: coreTools,
+ services: {
+ aircraft: { getSnapshot: async () => ({ records }) },
+ },
+ });
+ const area = { bbox: [-1, -1, 1, 1] };
+ assert.equal(
+ (await catalog.call('aircraft_in_area', { area })).data.total,
+ 4,
+ 'unfiltered answers retain unknown altitude',
+ );
+ for (const band of [
+ { max_altitude_m: 100 },
+ { min_altitude_m: -500, max_altitude_m: 100 },
+ ]) {
+ const result = await catalog.call('aircraft_in_area', { area, ...band });
+ assert.deepEqual(
+ result.data.rows.map((row) => row.id),
+ ['abc002', 'abc003'],
+ );
+ assert.deepEqual(
+ result.data.rows.map((row) => row.altitude_m),
+ [0, 50],
+ );
+ }
+ const minimum = await catalog.call('aircraft_in_area', {
+ area,
+ min_altitude_m: -500,
+ });
+ assert.equal(minimum.data.total, 3);
+});
From c6f0f9db0a9d2999001eb06d0938007d8d8fba29 Mon Sep 17 00:00:00 2001
From: Sameh Khamis
Date: Fri, 16 Oct 2026 15:01:29 -0700
Subject: [PATCH 10/14] Share one antimeridian-safe distance and move the
changelog entry
The flights records and the mesh-floor sampler kept identical copies of the equirectangular distance; both now import src/data/approxDistance.js. The changelog entry moves to changelog.d (#787), and CURRENT-STATE notes the antimeridian behaviour.
---
CHANGELOG.md | 11 -----------
changelog.d/1173-antimeridian-ground-floor.md | 10 ++++++++++
docs/CURRENT-STATE.md | 8 ++++++--
scripts/package-boundaries.json | 9 ++++++++-
src/data/approxDistance.js | 12 ++++++++++++
src/layers/flights/recordPolicy.js | 10 +---------
src/services/meshFloorSampler.js | 16 ++--------------
7 files changed, 39 insertions(+), 37 deletions(-)
create mode 100644 changelog.d/1173-antimeridian-ground-floor.md
create mode 100644 src/data/approxDistance.js
diff --git a/CHANGELOG.md b/CHANGELOG.md
index dde7209b4..8ee00d839 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,16 +1,5 @@
# Changelog
-- Flights' ground floor works across the antimeridian. The viewer-distance
- check behind the 150 km floor radius and its copy in the photoreal mesh-floor
- sampler subtracted longitudes without wrapping, so two points 21 km apart
- across 180° measured about 38,000 km and low or grounded aircraft just across
- the line from the viewer were never floored. A contact's display corridor
- walked a segment across 180° the long way round, spending its cell budget on
- ground behind the contact, and a contact on the seam lost its cell hysteresis.
- SEARCH NEARBY SITES on a view across 180° searched Google Places around
- longitude 0, on the far side of the globe, with a radius sized from the view's
- height alone; it now searches the view.
-
- Local FM seek finds stations after wrapping around 87.5–108 MHz, covers both
band edges, skips the starting station and rejects spectra from an earlier
tune (Aditya, @Rohilalala, #1174).
diff --git a/changelog.d/1173-antimeridian-ground-floor.md b/changelog.d/1173-antimeridian-ground-floor.md
new file mode 100644
index 000000000..939559bb5
--- /dev/null
+++ b/changelog.d/1173-antimeridian-ground-floor.md
@@ -0,0 +1,10 @@
+- Flights' ground floor works across the antimeridian. The viewer-distance
+ check behind the 150 km floor radius and its copy in the photoreal mesh-floor
+ sampler subtracted longitudes without wrapping, so two points 21 km apart
+ across 180° measured about 38,000 km and low or grounded aircraft just across
+ the line from the viewer were never floored. A contact's display corridor
+ walked a segment across 180° the long way round, spending its cell budget on
+ ground behind the contact, and a contact on the seam lost its cell hysteresis.
+ SEARCH NEARBY SITES on a view across 180° searched Google Places around
+ longitude 0, on the far side of the globe, with a radius sized from the view's
+ height alone; it now searches the view (Aditya, @Rohilalala, #1173).
diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md
index 1bdca7670..fd05fb943 100644
--- a/docs/CURRENT-STATE.md
+++ b/docs/CURRENT-STATE.md
@@ -3206,7 +3206,11 @@ test:track` 43 tracking invariants · headless QA harnesses under
> metres low. `qa-floor-verify.mjs` now **exits non-zero on FAIL** (1 = FAIL,
> 2 = INCONCLUSIVE); it previously exited 0 on every verdict, which is how the
> burial stayed invisible. It also honours `QA_BASE_URL` and puppeteer's
-> pinned Chrome-for-Testing, like the other harnesses.
+> pinned Chrome-for-Testing, like the other harnesses. The viewer-distance
+> gate, the corridor walk and cell hysteresis take the short way across 180°
+> (`src/data/approxDistance.js` is the one distance both the flights records
+> and the mesh-floor sampler use), so contacts just across the antimeridian
+> are floored like any other.
> - **2026-07-16:** the FIRMS Active Fires layer is **LIVE** —
> the bundled 2026-05-25 snapshot (58 MB) is deleted; a new `/api/firms` proxy
> (vite.config.js) merges VIIRS NOAA-20/NOAA-21/Suomi-NPP NRT world CSVs
@@ -4231,7 +4235,7 @@ silently demoting every later lookup for the session.
- The internal Context coordinator is available in every visual style. Its dedicated right-side `CONTEXT` chooser exposes the neutral shell; the coordinator is not duplicated in Data Layers and does not enable a live-data dependency until a mode is selected.
- The expanded `CONTEXT` view offers mutually exclusive `CONTACTS` and `SPACE MISSIONS` modes. Selecting `CONTACTS` enables the context-owned Flights, Military Flights, AIS Vessels, and Mapped Installations dependencies only when they are not already user-enabled; selecting `SPACE MISSIONS` enables the recent-launch layer and its Satellite dependency. `CONTACTS` cycles the nearest supported contact of whatever type is selected. Satellites are deliberately excluded from those Awareness cohorts and keep their own tracking UX. Selecting the active mode again returns to the neutral chooser and releases only mode-owned dependencies.
- If a civilian or military aircraft is already tracked when `CONTACTS` becomes operational, that source-owned track is adopted as the Context subject before nearest-contact autofocus. Context rechecks the tracker after its dependencies settle, so a newer selection wins, while an explicit clear during activation prevents fallback from silently selecting a replacement. Cockpit entry remains unavailable until that Context transaction has settled, so its camera takeover cannot clear Cesium tracking before adoption. Adoption does not recreate tracking or transfer camera ownership; it initializes the normal 250 km ring, history, proximity results, and Cockpit Previous/Next state for the original aircraft.
-- `SEARCH NEARBY SITES` retains the bounded OSM results and makes one user-initiated, view-biased Google Maps Places text search for “military installation.” Google results are source-stamped, deduplicated against OSM by rounded location/name, and remain mapped context rather than operational claims. If Places is unavailable or the API is not enabled for the supplied key, OSM context remains available.
+- `SEARCH NEARBY SITES` retains the bounded OSM results and makes one user-initiated, view-biased Google Maps Places text search for “military installation.” Google results are source-stamped, deduplicated against OSM by rounded location/name, and remain mapped context rather than operational claims. If Places is unavailable or the API is not enabled for the supplied key, OSM context remains available. A view across the antimeridian is searched around its own centre, with a radius from its real width.
- The expanded desktop header omits the redundant `ON` label; the active mode button carries state. Expanded Contact results also omit the duplicate `GLOBAL CONTEXT` / `CONTEXT ONLY` status row and begin with the selected subject and its 250 km scope. Global Context does not fabricate a selected-entity model preview: the provisional hand-authored aircraft wireframe was removed because it was not geometry extracted from the selected entity's actual asset.
- Dependency ownership is reversible: disabling Global Context releases only dependencies it enabled, while user-enabled layers remain on. This also removes the Military-layer suppression handoff when Global Context owned Military, allowing an already-enabled civilian Flights layer to resume its normal mixed rendering. If OpenSky is unavailable and has no last-good cache, Flights requests a capped 250 nm adsb.lol point snapshot around the current view anchor and labels that provenance explicitly; it never relabels military-feed rows as civilian data. If both inputs fail, Flights remains `UNKNOWN`.
- Space Missions is replay-isolated: Rocket Launches and Satellites are the only Data Layers permitted while the mode is active. Direct UI and voice entry capture the same pre-entry snapshot; internal dependency and restoration enables do not create a user-owned Context session. Entry waits for incompatible layers to shut down, direct incompatible enables are blocked before lifecycle work, and the entry gate remains active through the complete Rocket Launches enable. A newer same-target ON request takes ownership of the pending entry without releasing its isolation snapshot, including when it arrives while the prior request is awaiting the adoption guard. A caller abort, resource cancellation, newer OFF, or layer teardown waits for exact manager settlement and restores that snapshot without resurrecting Rocket Launches. If an abort lands after only part of a restore settles, Context completes the same exact target without the stale caller signal and then replays newer explicit layer intent. Dedicated Voice Context cancellation reports a stable cancelled result plus the current Context state; generic layer visibility additionally exposes manager phase, reason, successor, and lifecycle details. Once an exact voice visibility intent commits, a newer voice turn cannot relabel it as cancelled while Context settlement completes; pre-commit aborts remain cancellable and final lifecycle mismatches remain failures. Clear Selected Layers reserves its complete captured OFF set before sequential teardown; a newer absolute request of any origin supersedes only its layer reservation and remains authoritative. A rejected layer teardown retains the truthful enabled state, rolls already-stopped siblings back to the captured pre-entry set, and aborts replay; rapid exit/re-entry serializes the full Satellite enabled-state and parameter restore before a new snapshot is taken. Contacts remains additive and restores user-enabled layers normally.
diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json
index a9c49f6c3..75ed60f18 100644
--- a/scripts/package-boundaries.json
+++ b/scripts/package-boundaries.json
@@ -901,6 +901,7 @@
"src/data/aircraftIcons.js",
"src/data/aircraftMeta.js",
"src/data/aircraftNearbyPolicy.js",
+ "src/data/approxDistance.js",
"src/data/cockpitAirLod.js",
"src/data/cockpitContactDot.js",
"src/data/contactMatch.js",
@@ -1649,6 +1650,7 @@
"src/ui/radioPanelPlacement.js",
"src/cyberSonarGpu.js",
"src/scenes/nepalEvidencePack.js",
+ "src/data/approxDistance.js",
"src/data/bhoteKoshiEvent.js",
"src/ui/imagerySplit.js",
"src/data/bhoteKoshiEmbeddedMedia.js",
@@ -2500,6 +2502,7 @@
"exports": ["./application/layers"],
"modules": [
"src/cyberSonar.js",
+ "src/data/approxDistance.js",
"src/data/bhoteKoshiEvent.js",
"src/ui/imagerySplit.js",
"src/maps/imageryComparison.js",
@@ -3067,6 +3070,7 @@
"src/app/operations.js",
"src/sources/sourceSlot.js",
"src/app/surfaceServices.js",
+ "src/data/approxDistance.js",
"src/data/dataCredits.js",
"src/data/geoid.js",
"src/data/local_data/natural_earth/marine.json",
@@ -3226,6 +3230,7 @@
"src/layers/flights/records.js",
"src/layers/flights/recordPolicy.js",
"src/data/aircraftMeta.js",
+ "src/data/approxDistance.js",
"src/data/renderAltitude.js",
"src/data/aircraftClass.js",
"src/data/aircraftEmergency.js"
@@ -3236,7 +3241,8 @@
"exports": ["./layers/flights/ingestion"],
"modules": [
"src/layers/flights/ingestion.js",
- "src/layers/flights/recordPolicy.js"
+ "src/layers/flights/recordPolicy.js",
+ "src/data/approxDistance.js"
],
"external": []
},
@@ -3547,6 +3553,7 @@
"src/cyberSonar.js",
"src/app/sourceSlot.js",
"src/celestialRing.js",
+ "src/data/approxDistance.js",
"src/data/cameraSensitivity.js",
"src/data/contactPlayback.js",
"src/data/contactTrailRenderer.js",
diff --git a/src/data/approxDistance.js b/src/data/approxDistance.js
new file mode 100644
index 000000000..d581589a5
--- /dev/null
+++ b/src/data/approxDistance.js
@@ -0,0 +1,12 @@
+/**
+ * Local equirectangular distance in km, for viewer-proximity gates. Takes the
+ * short way round, so points either side of 180° stay close.
+ */
+export function approxDistanceKm(lat1, lon1, lat2, lon2) {
+ const dLat = (lat2 - lat1) * 111.32;
+ let dLonDeg = lon2 - lon1;
+ dLonDeg -= 360 * Math.round(dLonDeg / 360);
+ const dLon =
+ dLonDeg * 111.32 * Math.cos((((lat1 + lat2) / 2) * Math.PI) / 180);
+ return Math.hypot(dLat, dLon);
+}
diff --git a/src/layers/flights/recordPolicy.js b/src/layers/flights/recordPolicy.js
index 2172ffdc1..e269639d0 100644
--- a/src/layers/flights/recordPolicy.js
+++ b/src/layers/flights/recordPolicy.js
@@ -13,12 +13,4 @@ export const MISSING_POLL_LIMIT = 3;
export const ERROR_BACKOFF_INTERVAL = 20000;
/** Local equirectangular distance used for viewer-proximate floor sampling. */
-export function approxDistanceKm(lat1, lon1, lat2, lon2) {
- const dLat = (lat2 - lat1) * 111.32;
- // The short way round, so points either side of 180° stay close.
- let dLonDeg = lon2 - lon1;
- dLonDeg -= 360 * Math.round(dLonDeg / 360);
- const dLon =
- dLonDeg * 111.32 * Math.cos((((lat1 + lat2) / 2) * Math.PI) / 180);
- return Math.hypot(dLat, dLon);
-}
+export { approxDistanceKm } from '../../data/approxDistance.js';
diff --git a/src/services/meshFloorSampler.js b/src/services/meshFloorSampler.js
index 34a62fd27..4f748b592 100644
--- a/src/services/meshFloorSampler.js
+++ b/src/services/meshFloorSampler.js
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
+import { approxDistanceKm } from '../data/approxDistance.js';
/** Construct an instance-owned meshFloorSampler service with explicit dependencies. */
export function createMeshFloorSampler({
@@ -79,19 +80,6 @@ export function createMeshFloorSampler({
return false;
}
- /**
- * Equirectangular distance (km) — same approximation the flights clamp uses.
- */
- function _approxKm(lat1, lon1, lat2, lon2) {
- const dLat = (lat2 - lat1) * 111.32;
- // The short way round, so points either side of 180° stay close.
- let dLonDeg = lon2 - lon1;
- dLonDeg -= 360 * Math.round(dLonDeg / 360);
- const dLon =
- dLonDeg * 111.32 * Math.cos((((lat1 + lat2) / 2) * Math.PI) / 180);
- return Math.hypot(dLat, dLon);
- }
-
/**
* Samples the rendered mesh height for the coarse cells containing `points`,
* one-shot per cell, budget-capped, viewer-proximity-gated. Call ONCE per
@@ -136,7 +124,7 @@ export function createMeshFloorSampler({
if (
Number.isFinite(viewerLat) &&
Number.isFinite(viewerLon) &&
- _approxKm(viewerLat, viewerLon, cell.lat, cell.lon) > MAX_SAMPLE_DIST_KM
+ approxDistanceKm(viewerLat, viewerLon, cell.lat, cell.lon) > MAX_SAMPLE_DIST_KM
) {
continue; // too far: tiles not streamed there, probe would be a guaranteed miss
}
From a7cd9982012e0bdbbd4f783a64352b58cacb7b6b Mon Sep 17 00:00:00 2001
From: Sameh Khamis
Date: Fri, 16 Oct 2026 15:01:46 -0700
Subject: [PATCH 11/14] Move the changelog entry into changelog.d and note seed
and datasheet fallbacks
Main takes release notes as one fragment per pull request (#787). A missing value still falls back to a matching seed camera, and a datasheet can still set the field of view.
---
CHANGELOG.md | 14 --------------
changelog.d/1172-cctv-missing-numbers.md | 13 +++++++++++++
docs/CURRENT-STATE.md | 12 +++++++-----
3 files changed, 20 insertions(+), 19 deletions(-)
create mode 100644 changelog.d/1172-cctv-missing-numbers.md
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 05e3cff23..8ee00d839 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,19 +1,5 @@
# Changelog
-- Keep missing CCTV numbers unknown instead of reading them as zero. `Number`
- turns null, blank strings and booleans into 0 or 1, and the camera source
- list sends an unknown number as JSON null, so a source-pack camera that left
- out its pose reached the browser as zeros clamped to each field's floor —
- facing north, pitch −2°, FOV 20°, range 120 m, mount 6 m and ground 0 m —
- instead of the defaults (id-hash heading, −17°, 74°, 700 m, 24 m and the
- city's ground); a null or blank position placed the camera at 0,0. The
- server's `toFiniteNumber` and the browser's `safeNumber` now treat absent,
- blank and boolean values as missing. The same conversion also stops a
- Tallinn row with a null heading reading as a confident north-facing camera,
- a Caltrans camera with a blank elevation losing its 150 m ground prior, and
- a TfL or Catalonia camera with a null or blank coordinate being kept at 0,0
- or on the zero meridian.
-
- Local FM seek finds stations after wrapping around 87.5–108 MHz, covers both
band edges, skips the starting station and rejects spectra from an earlier
tune (Aditya, @Rohilalala, #1174).
diff --git a/changelog.d/1172-cctv-missing-numbers.md b/changelog.d/1172-cctv-missing-numbers.md
new file mode 100644
index 000000000..d401021e9
--- /dev/null
+++ b/changelog.d/1172-cctv-missing-numbers.md
@@ -0,0 +1,13 @@
+- Keep missing CCTV numbers unknown instead of reading them as zero. `Number`
+ turns null, blank strings and booleans into 0 or 1, and the camera source
+ list sends an unknown number as JSON null, so a source-pack camera that left
+ out its pose reached the browser as zeros clamped to each field's floor —
+ facing north, pitch −2°, FOV 20°, range 120 m, mount 6 m and ground 0 m —
+ instead of the defaults (id-hash heading, −17°, 74°, 700 m, 24 m and the
+ city's ground); a null or blank position placed the camera at 0,0. The
+ server's `toFiniteNumber` and the browser's `safeNumber` now treat absent,
+ blank and boolean values as missing. The same conversion also stops a
+ Tallinn row with a null heading reading as a confident north-facing camera,
+ a Caltrans camera with a blank elevation losing its 150 m ground prior, and
+ a TfL or Catalonia camera with a null or blank coordinate being kept at 0,0
+ or on the zero meridian (omerbberk, #1172).
diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md
index 38f9249b2..d63467981 100644
--- a/docs/CURRENT-STATE.md
+++ b/docs/CURRENT-STATE.md
@@ -1355,11 +1355,13 @@ browser rendering is unchanged.
CCTV numbers are read as missing when they are absent, null, blank or boolean,
on the server (`toFiniteNumber`, every pack and the file/env source packs) and
in the browser (`safeNumber`). `/api/cctv/sources` sends an unknown number as
-JSON null; the browser then applies the field's default — id-hash heading,
-pitch −17°, FOV 74°, range 700 m, mount 24 m, the city's ground elevation —
-rather than reading zero and clamping it to the field's floor. A camera with a
-missing latitude or longitude is skipped by the browser catalog instead of
-being placed at 0,0. Real zeros, including `"0"`, are kept.
+JSON null; the browser then uses the bundled seed camera's value when one
+shares the id, else the field's default — id-hash heading, pitch −17°, FOV 74°
+(which a matched datasheet can still replace), range 700 m, mount 24 m, the
+city's ground elevation — rather than reading zero and clamping it to the
+field's floor. A camera with a missing latitude or longitude and no seed
+position is skipped by the browser catalog instead of being placed at 0,0.
+Real zeros, including `"0"`, are kept.
## Terrain, traffic, fire and bike-share provider modules
From 34dabd1e8848a5ae985682727156e937e51f1354 Mon Sep 17 00:00:00 2001
From: Sameh Khamis
Date: Fri, 16 Oct 2026 15:22:39 -0700
Subject: [PATCH 12/14] Format the mesh-floor sampler
---
src/services/meshFloorSampler.js | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/src/services/meshFloorSampler.js b/src/services/meshFloorSampler.js
index 4f748b592..e1416d288 100644
--- a/src/services/meshFloorSampler.js
+++ b/src/services/meshFloorSampler.js
@@ -124,7 +124,8 @@ export function createMeshFloorSampler({
if (
Number.isFinite(viewerLat) &&
Number.isFinite(viewerLon) &&
- approxDistanceKm(viewerLat, viewerLon, cell.lat, cell.lon) > MAX_SAMPLE_DIST_KM
+ approxDistanceKm(viewerLat, viewerLon, cell.lat, cell.lon) >
+ MAX_SAMPLE_DIST_KM
) {
continue; // too far: tiles not streamed there, probe would be a guaranteed miss
}
From 3c3ea269a946a18adc14f3553e2cd57c242130bd Mon Sep 17 00:00:00 2001
From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com>
Date: Fri, 16 Oct 2026 15:28:39 -0700
Subject: [PATCH 13/14] fix(setup): stop Node's DEP0190 warning when npm runs
on Windows (#1057)
Windows runs npm.cmd through a shell, and passing an argument array with `shell: true` is deprecated in Node 24 (DEP0190). Every `npm run doctor` and every Pinokio install on Windows printed the warning. npmProcessSpec now takes the npm arguments and, on Windows, joins them into the command string; the arguments are fixed subcommands (`--version`, `ci`). Unix behavior is unchanged.
---
changelog.d/1057-windows-npm-dep0190.md | 4 ++++
scripts/pinokio-install.mjs | 4 ++--
scripts/setup-doctor.mjs | 17 +++++++++++------
src/setupDoctor.test.mjs | 18 ++++++++++++++----
4 files changed, 31 insertions(+), 12 deletions(-)
create mode 100644 changelog.d/1057-windows-npm-dep0190.md
diff --git a/changelog.d/1057-windows-npm-dep0190.md b/changelog.d/1057-windows-npm-dep0190.md
new file mode 100644
index 000000000..1e8e1da55
--- /dev/null
+++ b/changelog.d/1057-windows-npm-dep0190.md
@@ -0,0 +1,4 @@
+- On Windows, `npm run doctor` and the Pinokio install no longer print Node's
+ DEP0190 warning ("Passing args to a child process with shell option true can
+ lead to security vulnerabilities"): npm.cmd's fixed subcommand is now part of
+ the shell command string instead of an argument array (quanticsoul4772, #1057).
diff --git a/scripts/pinokio-install.mjs b/scripts/pinokio-install.mjs
index 01a26ccce..4e52bf3af 100644
--- a/scripts/pinokio-install.mjs
+++ b/scripts/pinokio-install.mjs
@@ -24,8 +24,8 @@ export function runChecked(command, args, { shell = false } = {}) {
export function installPinokioDependencies() {
applyPinokioEnvironment();
rmSync(READY_FILE, { force: true });
- const npm = npmProcessSpec();
- runChecked(npm.command, ['ci'], { shell: npm.shell });
+ const npm = npmProcessSpec(['ci']);
+ runChecked(npm.command, npm.args, { shell: npm.shell });
// Pinokio starts Vite directly and loads only its ENVIRONMENT file plus the
// normal dotenv ladder. Unlike dev-fresh.sh, it does not import macOS
diff --git a/scripts/setup-doctor.mjs b/scripts/setup-doctor.mjs
index 49c6722c2..01e048091 100644
--- a/scripts/setup-doctor.mjs
+++ b/scripts/setup-doctor.mjs
@@ -76,10 +76,15 @@ export function hasRequiredDependencies(rootDir = ROOT) {
}
}
-/** Return the npm command and spawn mode required by the target platform. */
-export function npmProcessSpec(platform = process.platform) {
- const windows = platform === 'win32';
- return { command: windows ? 'npm.cmd' : 'npm', shell: windows };
+/**
+ * Return the npm command, arguments and spawn mode for the target platform.
+ * Windows must run npm.cmd through a shell, and Node 24 deprecates passing an
+ * argument array with `shell: true` (DEP0190), so there the arguments, which
+ * are fixed npm subcommands, join the command string.
+ */
+export function npmProcessSpec(args, platform = process.platform) {
+ if (platform !== 'win32') return { command: 'npm', args, shell: false };
+ return { command: ['npm.cmd', ...args].join(' '), args: [], shell: true };
}
/** Read one key from Vite's dotenv file ladder without depending on Vite. */
@@ -190,8 +195,8 @@ export function buildCapabilitySummary(
export function inspectSetup({ includeKeychain = true, authoritativeEnvironment = false, rootDir = ROOT } = {}) {
const node = classifyNodeVersion();
- const npm = npmProcessSpec();
- const npmResult = spawnSync(npm.command, ['--version'], {
+ const npm = npmProcessSpec(['--version']);
+ const npmResult = spawnSync(npm.command, npm.args, {
encoding: 'utf8',
shell: npm.shell,
});
diff --git a/src/setupDoctor.test.mjs b/src/setupDoctor.test.mjs
index 71c6e44af..26d695ba1 100644
--- a/src/setupDoctor.test.mjs
+++ b/src/setupDoctor.test.mjs
@@ -69,12 +69,22 @@ test('placeholder values are never counted as configured credentials', () => {
});
test('doctor selects a Windows-safe npm process without changing Unix behavior', () => {
- assert.deepEqual(npmProcessSpec('win32'), {
- command: 'npm.cmd',
+ // Windows passes no argument array with shell: true (Node DEP0190).
+ assert.deepEqual(npmProcessSpec(['ci'], 'win32'), {
+ command: 'npm.cmd ci',
+ args: [],
shell: true,
});
- assert.deepEqual(npmProcessSpec('darwin'), { command: 'npm', shell: false });
- assert.deepEqual(npmProcessSpec('linux'), { command: 'npm', shell: false });
+ assert.deepEqual(npmProcessSpec(['--version'], 'darwin'), {
+ command: 'npm',
+ args: ['--version'],
+ shell: false,
+ });
+ assert.deepEqual(npmProcessSpec(['ci'], 'linux'), {
+ command: 'npm',
+ args: ['ci'],
+ shell: false,
+ });
});
test('doctor recognizes every OpenSky OAuth keychain alias used by dev-fresh', () => {
From fb1088984178fe29ea4d3b53035d2393cb37d695 Mon Sep 17 00:00:00 2001
From: Rudy Mizrahi Celekli <47457359+rudycelekli@users.noreply.github.com>
Date: Fri, 16 Oct 2026 18:34:26 -0400
Subject: [PATCH 14/14] fix(street-level): release superseded and unmounted
Google viewer work (#1117)
A superseded open wait cleared the shared cancelShown slot that belonged to the newer wait, leaving its auth listener and timer alive after close; open() now settles the previous wait first. A lookup whose viewer unmounted while the Street View service loaded still sent a billed getPanorama; describe() now rechecks the lookup after acquisition.
---
changelog.d/1117-google-viewer-lifecycle.md | 3 +
docs/CURRENT-STATE.md | 5 +-
.../streetLevel/providers/google/viewer.js | 3 +
.../providers/google/viewer.test.mjs | 66 +++++++++++++++++++
4 files changed, 76 insertions(+), 1 deletion(-)
create mode 100644 changelog.d/1117-google-viewer-lifecycle.md
diff --git a/changelog.d/1117-google-viewer-lifecycle.md b/changelog.d/1117-google-viewer-lifecycle.md
new file mode 100644
index 000000000..66d73d4a2
--- /dev/null
+++ b/changelog.d/1117-google-viewer-lifecycle.md
@@ -0,0 +1,3 @@
+- Release superseded Google Street View open waits and suppress metadata
+ requests whose viewer was unmounted while the service loaded (rudycelekli,
+ #1117).
diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md
index f1c04711e..f425d6ebe 100644
--- a/docs/CURRENT-STATE.md
+++ b/docs/CURRENT-STATE.md
@@ -3465,7 +3465,10 @@ controls (the address goes in the caption); a pose goes out at once and
again when the panorama's month, photographer and address arrive (one
successful lookup per panorama per session, seeded by the nearest lookup;
a details lookup that fails or takes over ten seconds waits five seconds
-before a view event can retry, and an unmount drops lookups in flight). A key
+before a view event can retry, and an unmount drops lookups in flight). Opening
+another panorama settles the previous open before registering its wait; closing
+then releases the current wait immediately. A details lookup released while its
+service loads makes no panorama request after that service becomes available. A key
Google refuses (`gm_authFailure`: the API not enabled, a referrer it does
not allow, billing off) reads KEY REJECTED and fails a pending open at
once; a lookup or open Google never answers fails after 10 or 20 s. In CI
diff --git a/src/layers/streetLevel/providers/google/viewer.js b/src/layers/streetLevel/providers/google/viewer.js
index 422f52c0c..25e0b08fe 100644
--- a/src/layers/streetLevel/providers/google/viewer.js
+++ b/src/layers/streetLevel/providers/google/viewer.js
@@ -97,6 +97,8 @@ export function createGoogleViewer({ loader, getService, render } = {}) {
service ||= getService
? await getService()
: new (await ensureLibrary()).StreetViewService();
+ if (!current())
+ throw new Error('Street View details lookup was released');
return service.getPanorama({ pano: panoId });
})().then(resolve, reject);
});
@@ -236,6 +238,7 @@ export function createGoogleViewer({ loader, getService, render } = {}) {
const panoId = String(imageId);
if (!host) throw new Error('Street View is not mounted');
if (loader.authFailed()) throw new Error(KEY_REJECTED_MESSAGE);
+ cancelShown?.();
pendingOpen = panoId;
const instance = await ensurePanorama(host);
if (pendingOpen !== panoId) return;
diff --git a/src/layers/streetLevel/providers/google/viewer.test.mjs b/src/layers/streetLevel/providers/google/viewer.test.mjs
index a3d746a34..e1d4a187c 100644
--- a/src/layers/streetLevel/providers/google/viewer.test.mjs
+++ b/src/layers/streetLevel/providers/google/viewer.test.mjs
@@ -403,3 +403,69 @@ test('a remount looks up again a panorama whose lookup hung', async () => {
await settle();
assert.equal(poses.length, count);
});
+
+test('superseded panorama waits cannot steal the current close cleanup', async (t) => {
+ t.mock.timers.enable({ apis: ['setTimeout'] });
+ const { OPEN_TIMEOUT_MS } = await import('./policy.js');
+ const flush = async () => {
+ for (let i = 0; i < 10; i++) await Promise.resolve();
+ };
+ const { fake, loader, viewer, host } = setup();
+ await viewer.mount(host);
+ const [panorama] = fake.built;
+ panorama.setPano = (id) => {
+ panorama.pano = id;
+ };
+ const first = viewer.open('pano-a');
+ await flush();
+ const second = viewer.open('pano-b');
+ await flush();
+ panorama.fire('position_changed'); // new panorama has not reached OK
+ await flush();
+ viewer.close();
+ const remaining = loader.listenerCount();
+ t.mock.timers.tick(OPEN_TIMEOUT_MS);
+ await Promise.allSettled([first, second]);
+ assert.equal(
+ remaining,
+ 0,
+ 'close disposes every pending open without waiting 20 seconds',
+ );
+ assert.equal(panorama.visible, false);
+});
+
+test('unmount during details-service acquisition makes no later billed lookup', async () => {
+ const fake = fakeStreetViewLibrary({ panoramas: PANORAMAS });
+ const loader = fakeMapsLoader(fake.library);
+ const { createGoogleProvider } = await import('./index.js');
+ let release;
+ const acquiring = new Promise((resolve) => {
+ release = resolve;
+ });
+ const importLibrary = loader.importLibrary.bind(loader);
+ let imports = 0;
+ loader.importLibrary = async (name) => {
+ if (++imports > 1) await acquiring;
+ return importLibrary(name);
+ };
+ const provider = createGoogleProvider({
+ getApiKey: () => 'fixture-key',
+ loader,
+ });
+ const instance = provider.create({
+ notify() {},
+ actions: { reportError() {} },
+ });
+ const viewer = instance.viewer;
+ await viewer.mount(fakeHost());
+ await viewer.open('pano-a');
+ viewer.unmount();
+ release();
+ for (let i = 0; i < 10; i++) await Promise.resolve();
+ assert.equal(
+ fake.lookups.length,
+ 0,
+ 'teardown invalidates the request before calling getPanorama',
+ );
+ instance.destroy();
+});