Merge branch 'main' into fix/director-scene-results-971

This commit is contained in:
Sameh Khamis committed 2026-10-16 16:04:26 -07:00
commit ef86796e4d
31 files changed
+993 -59

No files matched your search

+12
View File
@@ -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);
}
+252
View File
@@ -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) => `<gml:featureMember>
<cite:cameres fid="cameres.1">
<gml:coordinates>${coordinates}</gml:coordinates>
<cite:link>https://example.org/cam.jpg</cite:link>
</cite:cameres>
</gml:featureMember>`;
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);
});
+26
View File
@@ -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
+9
View File
@@ -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');
});
+121
View File
@@ -2,6 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { gstime } from 'satellite.js';
import { createModel } from '../layers/launches/model.js';
import rocketLaunchesLayer, {
_setRocketMissionOverlayHostForTest,
_setSelectedRocketMissionForTest,
@@ -728,6 +729,126 @@ test('normalizes detailed payload and stage recovery records', () => {
assert.equal(launches[0].recoveryStages[0].lon, -76.2);
});
test('finiteCoordinate normalizes finite numbers and rejects non-numeric or non-finite inputs', () => {
const model = createModel({});
assert.equal(model.finiteCoordinate(42.5), 42.5);
assert.equal(model.finiteCoordinate('28.5619'), 28.5619);
assert.equal(model.finiteCoordinate('-80.5772'), -80.5772);
assert.equal(model.finiteCoordinate(0), 0);
assert.equal(model.finiteCoordinate('0'), 0);
assert.equal(model.finiteCoordinate(undefined), null);
assert.equal(model.finiteCoordinate('invalid'), null);
assert.equal(model.finiteCoordinate('TBD'), null);
assert.equal(model.finiteCoordinate(Number.NaN), null);
assert.equal(model.finiteCoordinate(Number.POSITIVE_INFINITY), null);
assert.equal(model.finiteCoordinate(Number.NEGATIVE_INFINITY), null);
});
test('normalizes recovery stage coordinates, downrange distance, and flight numbers with non-finite fallbacks', () => {
const launches = normalizeRocketLaunches(
{
results: [
{
id: 'recovery-fallbacks',
name: 'Falcon 9 | Edge Case Stage Normalization',
net: '2026-07-20T10:00:00Z',
pad: { latitude: '28.608', longitude: '-80.604' },
rocket: {
launcher_stage: [
{
id: 88,
type: { name: 'Booster' },
launcher_flight_number: 'invalid-flight',
landing: {
attempt: true,
success: false,
downrange_distance: 'TBD',
landing_location: {
name: 'Ocean Impact',
latitude: 'N/A',
longitude: undefined,
},
},
},
{
id: 89,
type: { name: 'Expended Stage' },
landing: {
attempt: false,
type: { name: 'Expendable' },
},
},
],
},
},
],
},
NOW,
);
assert.equal(launches[0].recoveryStages.length, 2);
const booster = launches[0].recoveryStages[0];
assert.equal(booster.status, 'LOST');
assert.equal(booster.success, false);
assert.equal(booster.attempted, true);
assert.equal(booster.flightNumber, null);
assert.equal(booster.downrangeKm, null);
assert.equal(booster.lat, null);
assert.equal(booster.lon, null);
const expendable = launches[0].recoveryStages[1];
assert.equal(expendable.status, 'EXPENDABLE');
assert.equal(expendable.attempted, false);
assert.equal(expendable.success, null);
});
test('normalizes payload amounts and fractional string masses', () => {
const launches = normalizeRocketLaunches(
{
results: [
{
id: 'payload-edge-cases',
name: 'Electron | Mixed Payloads',
net: '2026-07-20T10:00:00Z',
pad: { latitude: '-39.26', longitude: '177.86' },
rocket: {
payloads: [
{
id: 601,
amount: 'not-a-number',
payload: { id: 11, name: 'Unknown Amount Payload' },
},
{
id: 602,
amount: 3,
payload: { id: 12, name: 'Triple Payload' },
},
{
id: 603,
payload: {
id: 13,
name: 'Valid Fractional Mass Payload',
mass: '12.75',
},
},
],
},
},
],
},
NOW,
);
// Blank, negative and non-numeric masses are pinned by "missing payload
// details stay unknown without inventing mass or classification".
const payloads = launches[0].payloads;
assert.equal(payloads.length, 3);
assert.equal(payloads[0].amount, 1);
assert.equal(payloads[1].amount, 3);
assert.equal(payloads[2].massKg, 12.75);
});
test('keeps payload and recovery collections empty when LL2 does not disclose them', () => {
const launches = normalizeRocketLaunches({ results: [{
id: 'undisclosed',
+11
View File
@@ -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;
}
+1 -6
View File
@@ -13,9 +13,4 @@ export const MISSING_POLL_LIMIT = 3;
export const ERROR_BACKOFF_INTERVAL = 20000;
/** Local equirectangular distance used for viewer-proximate floor sampling. */
export function approxDistanceKm(lat1, lon1, lat2, lon2) {
const dLat = (lat2 - lat1) * 111.32;
const dLon =
(lon2 - lon1) * 111.32 * Math.cos((((lat1 + lat2) / 2) * Math.PI) / 180);
return Math.hypot(dLat, dLon);
}
export { approxDistanceKm } from '../../data/approxDistance.js';
+18
View File
@@ -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);
}
});
+5 -4
View File
@@ -134,15 +134,16 @@ export function createIngestion({
let placesError = null;
if (layerState.googleSearchRequested) {
layerState.googleSearchRequested = false;
// A view across 180° has west > east; its width runs through 180°.
const lonSpan = box.east - box.west + (box.east < box.west ? 360 : 0);
const latitude = (box.south + box.north) / 2;
const longitude = (box.west + box.east) / 2;
let longitude = box.west + lonSpan / 2;
if (longitude > 180) longitude -= 360;
const radiusM = Math.min(
50000,
Math.max(
1000,
Math.round(
Math.max(box.north - box.south, box.east - box.west) * 55_000,
),
Math.round(Math.max(box.north - box.south, lonSpan) * 55_000),
),
);
try {
@@ -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);
});
+18 -9
View File
@@ -11,20 +11,26 @@ export function containmentAccent(containedPct) {
return '#8bc34a';
}
/** Shoelace area (degree², sign dropped) — relative sizes only. */
/** Shoelace area along short longitude edges (degree², 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];
let [x1, y1] = ring[0];
for (let i = 1; i < ring.length; i++) {
let delta = ring[i][0] - ring[i - 1][0];
if (delta > 180) delta -= 360;
else if (delta < -180) delta += 360;
const x2 = x1 + delta;
const y2 = ring[i][1];
sum += x1 * y2 - x2 * y1;
x1 = x2;
y1 = y2;
}
return Math.abs(sum / 2);
}
/**
* Anchor point for the incident card: the centroid of the largest polygon's
* outer ring. Degrees, so callers own the Cartesian conversion.
* Anchor point for the incident card: the vertex mean of the largest polygon's
* outer ring, with circular longitude. Callers own the Cartesian conversion.
* @param {Array<Array<Array<[number, number]>>>} polygons - Normalized rows' polygons.
* @returns {{lon: number, lat: number}}
*/
@@ -41,14 +47,17 @@ export function perimeterAnchorDegrees(polygons) {
}
// Vertex mean over the closed ring (first == last point dropped): perimeters
// are compact enough that this stays inside or near the polygon.
let lon = 0;
let sinLon = 0;
let cosLon = 0;
let lat = 0;
const count = best.length - 1;
const toRad = Math.PI / 180;
for (let i = 0; i < count; i++) {
lon += best[i][0];
sinLon += Math.sin(best[i][0] * toRad);
cosLon += Math.cos(best[i][0] * toRad);
lat += best[i][1];
}
return { lon: lon / count, lat: lat / count };
return { lon: Math.atan2(sinLon, cosLon) / toRad, lat: lat / count };
}
/**
+68 -1
View File
@@ -16,12 +16,79 @@ const tiny = [
[-107.01, 34.0],
];
test('the card anchors at the centroid of the largest polygon', () => {
test('the card anchors at the vertex mean of the largest polygon', () => {
const anchor = perimeterAnchorDegrees([[tiny], [square]]);
assert.ok(Math.abs(anchor.lon - -108.1) < 1e-9);
assert.ok(Math.abs(anchor.lat - 35.1) < 1e-9);
});
test('incident cards stay at the dateline for closed, reversed and rotated outlines', () => {
const vertices = [
[179, 10],
[-179, 10],
[-179, 12],
[179, 12],
];
for (let start = 0; start < vertices.length; start++) {
const rotated = [...vertices.slice(start), ...vertices.slice(0, start)];
for (const points of [rotated, [...rotated].reverse()]) {
const anchor = perimeterAnchorDegrees([[points.concat([points[0]])]]);
assert.ok(Math.abs(Math.abs(anchor.lon) - 180) < 1e-9);
assert.ok(Math.abs(anchor.lat - 11) < 1e-9);
}
}
});
test('incident card longitude treats vertices at either sign of 180 as the same meridian', () => {
for (const boundary of [-180, 180]) {
const ring = [
[179, 10],
[boundary, 10],
[-179, 10],
[-179, 12],
[179, 12],
[179, 10],
];
const anchor = perimeterAnchorDegrees([[ring]]);
assert.ok(Math.abs(Math.abs(anchor.lon) - 180) < 1e-9);
assert.ok(Math.abs(anchor.lat - 10.8) < 1e-9);
}
});
test('multipart cards compare polygon areas along short dateline edges', () => {
const ordinary = [
[10, 20],
[11, 20],
[11, 21],
[10, 21],
[10, 20],
];
for (const width of [0.2, 2]) {
const crossing = [
[180 - width / 2, 10],
[-180 + width / 2, 10],
[-180 + width / 2, 10 + width],
[180 - width / 2, 10 + width],
[180 - width / 2, 10],
];
for (const ring of [crossing, [...crossing].reverse()]) {
for (const polygons of [
[[ring], [ordinary]],
[[ordinary], [ring]],
]) {
const anchor = perimeterAnchorDegrees(polygons);
if (width < 1) {
assert.ok(Math.abs(anchor.lon - 10.5) < 1e-9);
assert.ok(Math.abs(anchor.lat - 20.5) < 1e-9);
} else {
assert.ok(Math.abs(Math.abs(anchor.lon) - 180) < 1e-9);
assert.ok(Math.abs(anchor.lat - 11) < 1e-9);
}
}
}
}
});
test('a full incident row renders name, size, containment, and ages', () => {
const now = 1758000000000;
const card = buildIncidentCard(
@@ -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;
@@ -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();
});
+18 -11
View File
@@ -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);
+3 -11
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
import { approxDistanceKm } from '../data/approxDistance.js';
/** Construct an instance-owned meshFloorSampler service with explicit dependencies. */
export function createMeshFloorSampler({
@@ -79,16 +80,6 @@ export function createMeshFloorSampler({
return false;
}
/**
* Equirectangular distance (km) — same approximation the flights clamp uses.
*/
function _approxKm(lat1, lon1, lat2, lon2) {
const dLat = (lat2 - lat1) * 111.32;
const dLon =
(lon2 - lon1) * 111.32 * Math.cos((((lat1 + lat2) / 2) * Math.PI) / 180);
return Math.hypot(dLat, dLon);
}
/**
* Samples the rendered mesh height for the coarse cells containing `points`,
* one-shot per cell, budget-capped, viewer-proximity-gated. Call ONCE per
@@ -133,7 +124,8 @@ export function createMeshFloorSampler({
if (
Number.isFinite(viewerLat) &&
Number.isFinite(viewerLon) &&
_approxKm(viewerLat, viewerLon, cell.lat, cell.lon) > MAX_SAMPLE_DIST_KM
approxDistanceKm(viewerLat, viewerLon, cell.lat, cell.lon) >
MAX_SAMPLE_DIST_KM
) {
continue; // too far: tiles not streamed there, probe would be a guaranteed miss
}
+21 -4
View File
@@ -323,13 +323,30 @@ export function lineTouchesArea(coords, area) {
/** Whether a point lies inside a ring of `[lon, lat]` vertices (ray casting). */
function ringContains(ring, point) {
if (ring.length < 3) return false;
// Keep adjacent vertices on the same longitude sheet, as the edge tests do.
// Wrapping each vertex around the query point would split a dateline ring
// when queried from Greenwich and mistake its complement for its interior.
let previous = ring[0][0];
let west = previous;
let east = previous;
const vertices = ring.map(([lon, lat]) => {
if (Math.abs(lon - previous) > 180)
lon += 360 * Math.round((previous - lon) / 360);
previous = lon;
west = Math.min(west, lon);
east = Math.max(east, lon);
return [lon, lat];
});
const middle = (west + east) / 2;
const lon = point.lon + 360 * Math.round((middle - point.lon) / 360);
let inside = false;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i, i += 1) {
const [xi, yi] = ring[i];
const [xj, yj] = ring[j];
for (let i = 0, j = vertices.length - 1; i < vertices.length; j = i, i += 1) {
const [xi, yi] = vertices[i];
const [xj, yj] = vertices[j];
if (
yi > point.lat !== yj > point.lat &&
point.lon < ((xj - xi) * (point.lat - yi)) / (yj - yi) + xi
lon < ((xj - xi) * (point.lat - yi)) / (yj - yi) + xi
)
inside = !inside;
}
+75
View File
@@ -262,3 +262,78 @@ test('a box radius reaches every point of the box, however large', async () => {
const small = await resolveArea({ bbox: [-97.94, 30.1, -97.56, 30.52] });
assert.ok(areaRadiusKm(small) < 30);
});
test('polygon containment follows the short edges across the antimeridian', async () => {
const ring = [
[179, -2],
[-179, -2],
[-179, 2],
[179, 2],
];
const areas = [
[{ bbox: [179.4, -0.1, 179.6, 0.1] }, true],
[{ bbox: [-179.6, -0.1, -179.4, 0.1] }, true],
[{ bbox: [179.8, -0.1, -179.8, 0.1] }, true],
[{ lat: 0, lon: 179.5, radius_km: 10 }, true],
[{ lat: 0, lon: -179.5, radius_km: 10 }, true],
[{ lat: 0, lon: 180, radius_km: 10 }, true],
[{ lat: 0, lon: -180, radius_km: 10 }, true],
[{ bbox: [-0.1, -0.1, 0.1, 0.1] }, false],
[{ lat: 0, lon: 0, radius_km: 10 }, false],
];
for (const vertices of [
ring,
[...ring, [...ring[0]]],
[...ring].reverse(),
[...ring.slice(2), ...ring.slice(0, 2)],
]) {
for (const [input, expected] of areas) {
assert.equal(
polygonsTouchArea([[vertices]], await resolveArea(input)),
expected,
JSON.stringify({ vertices, input }),
);
}
}
});
test('antimeridian polygons keep holes empty and include their boundaries', async () => {
const outer = [
[178, -3],
[-178, -3],
[-178, 3],
[178, 3],
];
const hole = [
[179, -1],
[-179, -1],
[-179, 1],
[179, 1],
];
for (const input of [
{ bbox: [179.4, -0.1, 179.6, 0.1] },
{ bbox: [-179.6, -0.1, -179.4, 0.1] },
{ lat: 0, lon: 180, radius_km: 10 },
]) {
const area = await resolveArea(input);
assert.ok(polygonsTouchArea([[outer]], area));
assert.ok(!polygonsTouchArea([[outer, hole]], area));
assert.ok(!polygonsTouchArea([[outer, [...hole].reverse()]], area));
}
const solid = await resolveArea({ bbox: [178.4, -0.1, 178.6, 0.1] });
assert.ok(polygonsTouchArea([[outer, hole]], solid));
const boundary = await resolveArea({ bbox: [178.9, -0.1, 179.1, 0.1] });
assert.ok(polygonsTouchArea([[outer, hole]], boundary));
});
test('polygon containment retains rings spanning more than half the globe', async () => {
const longitudes = [-170, -110, -50, 10, 70, 130];
const ring = [
...longitudes.map((lon) => [lon, -5]),
...[...longitudes].reverse().map((lon) => [lon, 5]),
];
const inside = await resolveArea({ bbox: [99, -1, 101, 1] });
const outside = await resolveArea({ bbox: [169, -1, 171, 1] });
assert.ok(polygonsTouchArea([[ring]], inside));
assert.ok(!polygonsTouchArea([[ring]], outside));
});
+9 -4
View File
@@ -288,20 +288,25 @@ export const getFirePerimeters = defineTool({
},
});
/** The average vertex of a perimeter's outer rings, enough to place it in an area. */
/** Average outer-ring vertices, using a circular longitude mean at the dateline. */
function polygonCenter(polygons) {
let lat = 0;
let lon = 0;
let sinLon = 0;
let cosLon = 0;
let count = 0;
const toRad = Math.PI / 180;
for (const polygon of polygons || []) {
for (const [x, y] of polygon?.[0] || []) {
if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
lon += x;
sinLon += Math.sin(x * toRad);
cosLon += Math.cos(x * toRad);
lat += y;
count += 1;
}
}
return count ? { lat: lat / count, lon: lon / count } : null;
return count
? { lat: lat / count, lon: Math.atan2(sinLon, cosLon) / toRad }
: null;
}
export const getTerrainHeight = defineTool({
+91
View File
@@ -227,6 +227,97 @@ test('fire perimeters are placed by their outline and sorted by size', async ()
});
});
test('fire perimeter queries include dateline interiors and exclude the opposite meridian', async () => {
const perimeters = {
getSnapshot: async () => [
{
stableId: 'dateline',
name: 'Dateline Fire',
acres: 100,
polygons: [
[
[
[179, -2],
[-179, -2],
[-179, 2],
[179, 2],
[179, -2],
],
],
],
},
],
};
const catalog = catalogWith({ perimeters });
for (const area of [
{ bbox: [179.4, -0.1, 179.6, 0.1] },
{ bbox: [-179.6, -0.1, -179.4, 0.1] },
{ lat: 0, lon: 180, radius_km: 10 },
]) {
const result = await catalog.call('get_fire_perimeters', { area });
assert.deepEqual(
result.data.rows.map((row) => row.id),
['dateline'],
);
}
const elsewhere = await catalog.call('get_fire_perimeters', {
area: { bbox: [-0.1, -0.1, 0.1, 0.1] },
});
assert.equal(elsewhere.data.total, 0);
});
test('fire perimeter coordinates stay at the dateline for closed and multipart outlines', async () => {
const perimeters = {
getSnapshot: async () => [
{
stableId: 'closed',
acres: 100,
polygons: [
[
[
[179, -2],
[-179, -2],
[-179, 2],
[179, 2],
[179, -2],
],
],
],
},
{
stableId: 'multipart',
acres: 50,
polygons: [
[
[
[179, -1],
[179.5, -1],
[179.5, 1],
[179, 1],
],
],
[
[
[-179.5, -1],
[-179, -1],
[-179, 1],
[-179.5, 1],
],
],
],
},
],
};
const result = await catalogWith({ perimeters }).call('get_fire_perimeters', {
area: { bbox: [178, -3, -178, 3] },
});
assert.equal(result.data.total, 2);
for (const row of result.data.rows) {
assert.ok(Math.abs(row.lon) > 179, `${row.id}: ${row.lon}`);
assert.ok(Math.abs(row.lon) <= 180);
}
});
test('terrain heights are returned in request order', async () => {
const terrain = {
async getHeights(points) {
+2 -4
View File
@@ -344,7 +344,6 @@ export function createRecentImageryPanel({
const detailsHost = el('div', 'ri-details', root);
detailsHost.id = 'ri-details';
let detailsOpen = false;
let detailsShownOpen = false;
const details = createRailCards({
container: detailsHost,
document,
@@ -352,6 +351,8 @@ export function createRecentImageryPanel({
onOpen: () => {
detailsOpen = !detailsOpen;
render();
// After the render: preserveScroll() puts the old position back.
if (detailsOpen) revealCard(container, detailsHost.children[0]);
},
});
@@ -875,9 +876,6 @@ export function createRecentImageryPanel({
],
},
]);
if (detailsOpen && !detailsShownOpen)
revealCard(container, detailsHost.children[0]);
detailsShownOpen = detailsOpen;
}
// ---- divider ----------------------------------------------------------
+23
View File
@@ -1147,6 +1147,29 @@ test('the body scroll position survives renders even when DOM mutations reset it
assert.equal(f.body.scrollTop, 120);
});
test('opening DETAILS scrolls its card into view, and closing it does not scroll', async () => {
const f = fixture();
await f.ready();
// The body is 100 px high and scrolled 40 px; the card opens 150 px down.
f.body.scrollTop = 40;
f.body.clientHeight = 100;
f.body.getBoundingClientRect = () => ({ top: 0 });
const header = f
.byId('ri-details')
.find((node) => node.classList.contains('rail-card-header'));
const card = header.parentNode;
card.getBoundingClientRect = () => ({
top: 150,
height: card.dataset.open === 'true' ? 60 : 0,
});
header.click();
assert.equal(card.dataset.open, 'true');
assert.equal(f.body.scrollTop, 150);
header.click();
assert.equal(card.dataset.open, 'false');
assert.equal(f.body.scrollTop, 150);
});
test('destroy returns every listener, the divider and the panel state', async () => {
const f = fixture();
await f.ready();