mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
Merge branch 'main' into codex/fix-antimeridian-polygon-containment
This commit is contained in:
commit
83f7e2f686
28 files changed
+724
-53
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 ----------------------------------------------------------
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user