mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Merge branch 'main' into fix/location-resolution
This commit is contained in:
commit
3d75b9e7ee
151 files changed
+7630
-17053
No files matched your search
@@ -32,21 +32,11 @@ function defaultProviders(sources) {
|
||||
* including when the list is empty.
|
||||
*/
|
||||
export function createApplicationStreetLevel({
|
||||
surface,
|
||||
sources = {},
|
||||
providers = defaultProviders(sources),
|
||||
}) {
|
||||
return createStreetLevelLayer({
|
||||
providers,
|
||||
services: {
|
||||
sprites,
|
||||
picking,
|
||||
input,
|
||||
scenePick,
|
||||
render,
|
||||
ground: surface?.groundFloor ?? null,
|
||||
meshFloor: surface?.meshFloor ?? null,
|
||||
terrain: surface?.terrain ?? null,
|
||||
},
|
||||
services: { sprites, picking, input, scenePick, render },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
hkCameraName,
|
||||
hkCameraToSource,
|
||||
hkImageUrlFromKey,
|
||||
loadHkSourcesFromOpenData,
|
||||
normalizeHkImageUrl,
|
||||
parseHkCameraLocationsXml,
|
||||
} from '../../server/providers/cctv/sources.js';
|
||||
import {
|
||||
DEFAULT_CCTV_MAX_SOURCES,
|
||||
DEFAULT_HK_CAMERAS_URL,
|
||||
DEFAULT_HK_MAX_SOURCES,
|
||||
HK_ANCHORS,
|
||||
HK_IMAGE_ORIGIN,
|
||||
HK_MAX_CATALOG_BYTES,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import {
|
||||
CAMERA_CODE_MAX_CHARS,
|
||||
prioritizeSources,
|
||||
} from '../../server/providers/cctv/normalize.js';
|
||||
import { allocateSourceCap } from '../../server/providers/cctv/cap.js';
|
||||
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
|
||||
|
||||
/**
|
||||
* A response whose body is a live stream, plus a flag that flips when the
|
||||
* stream is cancelled. A rejection path that returns without cancelling holds
|
||||
* the transport open, so the flag is what the refusal tests actually assert.
|
||||
*/
|
||||
const streamingResponse = (init = {}) => {
|
||||
const state = { cancelled: false };
|
||||
const body = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode('<image-list>'));
|
||||
},
|
||||
cancel() {
|
||||
state.cancelled = true;
|
||||
},
|
||||
});
|
||||
return { response: new Response(body, init), state };
|
||||
};
|
||||
|
||||
/** One Transport Department `<image>` block as a parsed row. */
|
||||
const row = (overrides = {}) => ({
|
||||
key: 'BC101F',
|
||||
region: 'New Territories',
|
||||
district: 'North',
|
||||
description: 'San Tin Highway near Lok Ma Chau [BC101F]',
|
||||
latitude: '22.508',
|
||||
longitude: '114.078',
|
||||
url: 'https://tdcctv.data.one.gov.hk/BC101F.JPG',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
/** Fixture XML matching the published English locations schema. */
|
||||
const fixtureXml = (...records) => {
|
||||
const images = records
|
||||
.map(
|
||||
(record) => `
|
||||
<image>
|
||||
<key>${record.key}</key>
|
||||
<region>${record.region}</region>
|
||||
<district>${record.district}</district>
|
||||
<description>${record.description}</description>
|
||||
<latitude>${record.latitude}</latitude>
|
||||
<longitude>${record.longitude}</longitude>
|
||||
<url>${record.url}</url>
|
||||
</image>`,
|
||||
)
|
||||
.join('');
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>\n<image-list>${images}\n</image-list>\n`;
|
||||
};
|
||||
|
||||
test('an HK row maps to a source on the pinned HTTPS frame host', () => {
|
||||
const source = hkCameraToSource(row());
|
||||
assert.equal(source.id, 'hk-bc101f');
|
||||
assert.equal(source.name, 'San Tin Highway near Lok Ma Chau [BC101F]');
|
||||
assert.equal(source.cityId, 'hk');
|
||||
assert.equal(source.provider, 'Transport Department');
|
||||
assert.equal(source.lat, 22.508);
|
||||
assert.equal(source.lon, 114.078);
|
||||
assert.equal(source.feedType, 'image');
|
||||
assert.equal(source.sourceKind, 'hk-td-open-data');
|
||||
assert.equal(
|
||||
source.license,
|
||||
'Hong Kong Transport Department / DATA.GOV.HK — attribution required',
|
||||
);
|
||||
assert.equal(source.headingConfidence, 'low');
|
||||
assert.ok(Number.isFinite(source.headingDeg));
|
||||
assert.equal(source.url, 'https://tdcctv.data.one.gov.hk/BC101F.JPG');
|
||||
assert.equal(source.url, source.snapshotUrl);
|
||||
assert.ok(source.url.startsWith(HK_IMAGE_ORIGIN));
|
||||
});
|
||||
|
||||
test('rows outside Hong Kong, off-host frames and unusable keys are dropped', () => {
|
||||
assert.equal(
|
||||
hkCameraToSource(row({ latitude: '43.6532', longitude: '-79.3832' })),
|
||||
null,
|
||||
);
|
||||
assert.equal(hkCameraToSource(row({ latitude: '0', longitude: '0' })), null);
|
||||
assert.equal(hkCameraToSource(row({ key: '' })), null);
|
||||
assert.equal(hkCameraToSource(row({ key: 'bad key!' })), null);
|
||||
assert.equal(
|
||||
hkCameraToSource(row({ url: 'https://evil.example/BC101F.JPG' })),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
hkCameraToSource(
|
||||
row({
|
||||
url: 'https://tdcctv.data.one.gov.hk.evil.test/BC101F.JPG',
|
||||
}),
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(hkCameraToSource(row({ url: 'file:///etc/passwd' })), null);
|
||||
assert.equal(hkCameraToSource(null), null);
|
||||
});
|
||||
|
||||
test('frame URLs are rebuilt from the key and pinned to the TD host', () => {
|
||||
assert.equal(
|
||||
hkImageUrlFromKey('bc101f'),
|
||||
'https://tdcctv.data.one.gov.hk/BC101F.JPG',
|
||||
);
|
||||
assert.equal(hkImageUrlFromKey(''), null);
|
||||
assert.equal(hkImageUrlFromKey('bad/key'), null);
|
||||
|
||||
assert.equal(
|
||||
normalizeHkImageUrl('http://tdcctv.data.one.gov.hk/H429F.JPG'),
|
||||
'https://tdcctv.data.one.gov.hk/H429F.JPG',
|
||||
);
|
||||
assert.equal(
|
||||
normalizeHkImageUrl('https://tdcctv.data.one.gov.hk/H429F.JPG'),
|
||||
'https://tdcctv.data.one.gov.hk/H429F.JPG',
|
||||
);
|
||||
assert.equal(normalizeHkImageUrl(''), null);
|
||||
assert.equal(normalizeHkImageUrl('not a url'), null);
|
||||
assert.equal(normalizeHkImageUrl('https://evil.example/H429F.JPG'), null);
|
||||
});
|
||||
|
||||
test('a nameless row still gets a label', () => {
|
||||
assert.equal(
|
||||
hkCameraName(
|
||||
{ description: ' Aberdeen Praya Road near Fish Market [H429F] ' },
|
||||
'hk-h429f',
|
||||
),
|
||||
'Aberdeen Praya Road near Fish Market [H429F]',
|
||||
);
|
||||
assert.equal(hkCameraName({ key: 'H429F' }, 'hk-h429f'), 'H429F');
|
||||
assert.equal(hkCameraName({}, 'hk-h429f'), 'HK Camera h429f');
|
||||
});
|
||||
|
||||
test('the XML parser reads each image block', () => {
|
||||
const rows = parseHkCameraLocationsXml(
|
||||
fixtureXml(
|
||||
row(),
|
||||
row({
|
||||
key: 'H429F',
|
||||
region: 'Hong Kong Island',
|
||||
district: 'Southern',
|
||||
description: 'Aberdeen Praya Road near Fish Market [H429F]',
|
||||
latitude: '22.24845',
|
||||
longitude: '114.1505',
|
||||
url: 'https://tdcctv.data.one.gov.hk/H429F.JPG',
|
||||
}),
|
||||
),
|
||||
);
|
||||
assert.equal(rows.length, 2);
|
||||
assert.equal(rows[0].key, 'BC101F');
|
||||
assert.equal(rows[1].key, 'H429F');
|
||||
assert.equal(rows[1].latitude, '22.24845');
|
||||
});
|
||||
|
||||
test('the loader reads the keyless catalog and collapses duplicate ids', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
const requested = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
requested.push(String(url));
|
||||
return new Response(
|
||||
fixtureXml(
|
||||
row(),
|
||||
row({ description: 'San Tin Highway near Lok Ma Chau (duplicate)' }),
|
||||
row({
|
||||
key: 'H210F',
|
||||
region: 'Hong Kong Island',
|
||||
district: 'Wan Chai',
|
||||
description: 'Aberdeen Tunnel - Wan Chai Side [H210F]',
|
||||
latitude: '22.2747',
|
||||
longitude: '114.17936',
|
||||
url: 'https://tdcctv.data.one.gov.hk/H210F.JPG',
|
||||
}),
|
||||
row({ url: 'https://evil.example/x.jpg' }),
|
||||
),
|
||||
{ headers: { 'Content-Type': 'application/xml' } },
|
||||
);
|
||||
});
|
||||
const cameras = await loadHkSourcesFromOpenData();
|
||||
assert.deepEqual(requested, [DEFAULT_HK_CAMERAS_URL]);
|
||||
assert.deepEqual(
|
||||
cameras.map((camera) => camera.id),
|
||||
// Nearest Central/TST first: Wan Chai before the northern New Territories.
|
||||
['hk-h210f', 'hk-bc101f'],
|
||||
);
|
||||
assert.equal(cameras[0].name, 'Aberdeen Tunnel - Wan Chai Side [H210F]');
|
||||
});
|
||||
|
||||
test('an upstream failure yields an empty pack and releases the response', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const failed = streamingResponse({ status: 503 });
|
||||
t.mock.method(globalThis, 'fetch', async () => failed.response);
|
||||
assert.deepEqual(await loadHkSourcesFromOpenData(), []);
|
||||
assert.equal(failed.state.cancelled, true, 'the failed body is cancelled');
|
||||
|
||||
t.mock.restoreAll();
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
throw new Error('network down');
|
||||
});
|
||||
assert.deepEqual(await loadHkSourcesFromOpenData(), []);
|
||||
});
|
||||
|
||||
test('the unselected label is the description, trimmed to the code width', () => {
|
||||
assert.equal(
|
||||
hkCameraToSource(row({ description: 'San Tin Highway [BC101F]' })).code,
|
||||
'SAN TIN HIGHWAY [BC101F]',
|
||||
);
|
||||
const long = hkCameraToSource(
|
||||
row({
|
||||
description:
|
||||
'Bow Trail & Old Banff Coach Rd SW / Strathcona Blvd SW [BC101F]',
|
||||
}),
|
||||
);
|
||||
assert.equal(long.code.length, CAMERA_CODE_MAX_CHARS);
|
||||
assert.ok(long.code.endsWith('…'));
|
||||
assert.ok(long.code.startsWith('BOW TRAIL & OLD BANFF'));
|
||||
});
|
||||
|
||||
test('the catalog fetch refuses redirects and oversized bodies', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const seen = [];
|
||||
const redirected = streamingResponse({
|
||||
status: 302,
|
||||
headers: { location: 'https://evil.example/cameras.xml' },
|
||||
});
|
||||
t.mock.method(globalThis, 'fetch', async (url, init) => {
|
||||
seen.push([String(url), init.redirect]);
|
||||
return redirected.response;
|
||||
});
|
||||
assert.deepEqual(await loadHkSourcesFromOpenData(), []);
|
||||
assert.deepEqual(seen, [[DEFAULT_HK_CAMERAS_URL, 'manual']]);
|
||||
assert.equal(
|
||||
redirected.state.cancelled,
|
||||
true,
|
||||
'the redirect body is cancelled',
|
||||
);
|
||||
|
||||
t.mock.restoreAll();
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
const body = fixtureXml(row());
|
||||
return new Response(body, {
|
||||
headers: {
|
||||
'Content-Type': 'application/xml',
|
||||
'content-length': String(HK_MAX_CATALOG_BYTES + 1),
|
||||
},
|
||||
});
|
||||
});
|
||||
assert.deepEqual(await loadHkSourcesFromOpenData(), []);
|
||||
|
||||
t.mock.restoreAll();
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
const oversized = 'x'.repeat(HK_MAX_CATALOG_BYTES + 1024);
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(oversized));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
{ headers: { 'Content-Type': 'application/xml' } },
|
||||
);
|
||||
});
|
||||
assert.deepEqual(await loadHkSourcesFromOpenData(), []);
|
||||
});
|
||||
|
||||
test('a reduced catalog cap thins every pack instead of dropping HK', () => {
|
||||
const lane = (name, count) => ({
|
||||
name,
|
||||
sources: Array.from({ length: count }, (_, i) => ({ id: `${name}-${i}` })),
|
||||
});
|
||||
const { sources, packs } = allocateSourceCap(
|
||||
[
|
||||
lane('austin', 250),
|
||||
lane('calgary', 215),
|
||||
lane('hk', DEFAULT_HK_MAX_SOURCES),
|
||||
],
|
||||
30,
|
||||
);
|
||||
assert.equal(sources.length, 30);
|
||||
assert.deepEqual(
|
||||
packs.map((p) => [p.name, p.kept]),
|
||||
[
|
||||
['austin', 10],
|
||||
['calgary', 10],
|
||||
['hk', 10],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
sources.filter((s) => s.id.startsWith('hk-')).map((s) => s.id),
|
||||
Array.from({ length: 10 }, (_, i) => `hk-${i}`),
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Serve the HK catalog to the HK endpoint and an empty payload to every other
|
||||
* pack, so one catalog refresh exercises the registration without reaching the
|
||||
* network. Returns the URLs that were requested.
|
||||
*/
|
||||
const runCatalogWithMockedUpstreams = async (t) => {
|
||||
const requested = [];
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
const href = String(url);
|
||||
requested.push(href);
|
||||
if (href.includes('Traffic_Camera_Locations_En.xml')) {
|
||||
return new Response(
|
||||
fixtureXml(
|
||||
row(),
|
||||
row({
|
||||
key: 'H210F',
|
||||
region: 'Hong Kong Island',
|
||||
district: 'Wan Chai',
|
||||
description: 'Aberdeen Tunnel - Wan Chai Side [H210F]',
|
||||
latitude: '22.2747',
|
||||
longitude: '114.17936',
|
||||
url: 'https://tdcctv.data.one.gov.hk/H210F.JPG',
|
||||
}),
|
||||
),
|
||||
{ headers: { 'Content-Type': 'application/xml' } },
|
||||
);
|
||||
}
|
||||
return Response.json([]);
|
||||
});
|
||||
const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
|
||||
return { requested, sources };
|
||||
};
|
||||
|
||||
test('the HK lane is wired into the catalog and its loader runs', async (t) => {
|
||||
const saved = { ...process.env };
|
||||
try {
|
||||
delete process.env.CCTV_SOURCES_FILE;
|
||||
delete process.env.CCTV_SOURCES_JSON;
|
||||
delete process.env.CCTV_HK_ENABLED;
|
||||
const { requested, sources } = await runCatalogWithMockedUpstreams(t);
|
||||
assert.ok(
|
||||
requested.includes(DEFAULT_HK_CAMERAS_URL),
|
||||
'the catalog refresh invokes the HK loader',
|
||||
);
|
||||
assert.deepEqual(
|
||||
sources.filter((s) => s.cityId === 'hk').map((s) => s.id),
|
||||
['hk-h210f', 'hk-bc101f'],
|
||||
'HK cameras reach the served catalog through the registered lane',
|
||||
);
|
||||
} finally {
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (!(key in saved)) delete process.env[key];
|
||||
}
|
||||
Object.assign(process.env, saved);
|
||||
}
|
||||
});
|
||||
|
||||
test('CCTV_HK_ENABLED=0 keeps the lane from being loaded at all', async (t) => {
|
||||
const saved = { ...process.env };
|
||||
try {
|
||||
delete process.env.CCTV_SOURCES_FILE;
|
||||
delete process.env.CCTV_SOURCES_JSON;
|
||||
process.env.CCTV_HK_ENABLED = '0';
|
||||
const { requested, sources } = await runCatalogWithMockedUpstreams(t);
|
||||
assert.equal(
|
||||
requested.includes(DEFAULT_HK_CAMERAS_URL),
|
||||
false,
|
||||
'the disabled lane never reaches its upstream',
|
||||
);
|
||||
assert.deepEqual(
|
||||
sources.filter((s) => s.cityId === 'hk'),
|
||||
[],
|
||||
);
|
||||
} finally {
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (!(key in saved)) delete process.env[key];
|
||||
}
|
||||
Object.assign(process.env, saved);
|
||||
}
|
||||
});
|
||||
|
||||
test('the shipped catalog ceiling is not raised to make room for this pack', () => {
|
||||
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 4000);
|
||||
});
|
||||
|
||||
test('the HK pack default sits above the full TD catalog and anchors the New Territories', () => {
|
||||
assert.ok(
|
||||
DEFAULT_HK_MAX_SOURCES >= 1013,
|
||||
'default cap should keep nearly all published TD cameras',
|
||||
);
|
||||
assert.ok(
|
||||
DEFAULT_HK_MAX_SOURCES <= DEFAULT_CCTV_MAX_SOURCES,
|
||||
'a single pack must not outgrow the catalog-wide ceiling alone',
|
||||
);
|
||||
const places = HK_ANCHORS.map(
|
||||
(a) => `${a.lat.toFixed(2)},${a.lon.toFixed(2)}`,
|
||||
);
|
||||
assert.ok(places.includes('22.38,114.19'), 'Sha Tin anchor');
|
||||
assert.ok(places.includes('22.37,114.11'), 'Tsuen Wan anchor');
|
||||
assert.ok(places.includes('22.45,114.17'), 'Tai Po anchor');
|
||||
assert.ok(places.includes('22.49,114.14'), 'Fanling anchor');
|
||||
assert.ok(places.includes('22.39,113.98'), 'Tuen Mun anchor');
|
||||
assert.ok(places.includes('22.44,114.02'), 'Yuen Long anchor');
|
||||
assert.ok(places.includes('22.31,114.26'), 'Tseung Kwan O anchor');
|
||||
assert.ok(places.includes('22.28,114.16'), 'Central anchor');
|
||||
assert.ok(places.includes('22.30,114.17'), 'Tsim Sha Tsui anchor');
|
||||
});
|
||||
|
||||
test('a lowered HK cap still keeps Sha Tin when New Territories anchors are set', () => {
|
||||
const cameras = [
|
||||
hkCameraToSource(
|
||||
row({
|
||||
key: 'H001F',
|
||||
region: 'Hong Kong Island',
|
||||
district: 'Central & Western',
|
||||
description: 'Central harbour [H001F]',
|
||||
latitude: '22.2819',
|
||||
longitude: '114.158',
|
||||
}),
|
||||
),
|
||||
hkCameraToSource(
|
||||
row({
|
||||
key: 'ST101F',
|
||||
region: 'New Territories',
|
||||
district: 'Sha Tin',
|
||||
description: 'Sha Tin Road [ST101F]',
|
||||
latitude: '22.3827',
|
||||
longitude: '114.19',
|
||||
}),
|
||||
),
|
||||
hkCameraToSource(
|
||||
row({
|
||||
key: 'K999F',
|
||||
region: 'Kowloon',
|
||||
district: 'Yau Tsim Mong',
|
||||
description: 'Far Kowloon filler [K999F]',
|
||||
latitude: '22.32',
|
||||
longitude: '114.18',
|
||||
}),
|
||||
),
|
||||
].filter(Boolean);
|
||||
const kept = prioritizeSources(cameras, 2, HK_ANCHORS);
|
||||
assert.deepEqual(kept.map((camera) => camera.id).sort(), [
|
||||
'hk-h001f',
|
||||
'hk-st101f',
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,342 @@
|
||||
import { afterEach, beforeEach, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
loadTaiwanSources,
|
||||
parseTaiwanCctvXml,
|
||||
resetTaiwanSourcesCache,
|
||||
taiwanCameraToSource,
|
||||
taiwanFrame,
|
||||
TAIWAN_FREEWAY_CCTV_URL,
|
||||
TAIWAN_MAX_CATALOG_BYTES,
|
||||
TAIWAN_RETRY_AFTER_FAILURE_MS,
|
||||
} from '../../server/providers/cctv/taiwan.js';
|
||||
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
|
||||
|
||||
// Settings that would steer the loader or the catalog away from the mocks.
|
||||
const ISOLATED_ENV = [
|
||||
'CCTV_SOURCES_FILE',
|
||||
'CCTV_SOURCES_JSON',
|
||||
'CCTV_PREFER_AUSTIN',
|
||||
'CCTV_FORCE_AUSTIN',
|
||||
'CCTV_TAIWAN_ENABLED',
|
||||
'CCTV_TAIWAN_URL',
|
||||
'CCTV_TAIWAN_MAX_SOURCES',
|
||||
];
|
||||
|
||||
let savedEnv = {};
|
||||
|
||||
beforeEach(() => {
|
||||
resetTaiwanSourcesCache();
|
||||
savedEnv = Object.fromEntries(
|
||||
ISOLATED_ENV.map((key) => [key, process.env[key]]),
|
||||
);
|
||||
for (const key of ISOLATED_ENV) delete process.env[key];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const [key, value] of Object.entries(savedEnv)) {
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
function cctvXml(...blocks) {
|
||||
return `<?xml version='1.0' encoding='utf-8'?>
|
||||
<CCTVList xmlns="http://traffic.transportdata.tw/standard/traffic/schema/">
|
||||
<AuthorityCode>NFB</AuthorityCode>
|
||||
<CCTVs>${blocks.join('')}</CCTVs>
|
||||
</CCTVList>`;
|
||||
}
|
||||
|
||||
function block({
|
||||
id = 'CCTV-N1-S-0.000-M',
|
||||
stream = 'https://cctvn.freeway.gov.tw/abs2mjpg/bmjpg?camera=10000',
|
||||
lat = '25.1229931',
|
||||
lon = '121.735695',
|
||||
} = {}) {
|
||||
return `<CCTV>
|
||||
<CCTVID>${id}</CCTVID>
|
||||
<VideoStreamURL>${stream}</VideoStreamURL>
|
||||
<PositionLon>${lon}</PositionLon>
|
||||
<PositionLat>${lat}</PositionLat>
|
||||
<RoadName>國道1號</RoadName>
|
||||
<RoadDirection>S</RoadDirection>
|
||||
<RoadSection><Start>基隆端</Start><End>基隆交流道</End></RoadSection>
|
||||
<LocationMile>0K+000</LocationMile>
|
||||
</CCTV>`;
|
||||
}
|
||||
|
||||
test('parses the MOTC 2.0 table and registers the same camera still', () => {
|
||||
const [row] = parseTaiwanCctvXml(cctvXml(block()));
|
||||
const camera = taiwanCameraToSource(row);
|
||||
assert.equal(camera.id, 'tw-freeway-cctvn-10000');
|
||||
assert.equal(camera.lat, 25.1229931);
|
||||
assert.equal(camera.lon, 121.735695);
|
||||
assert.equal(camera.name, '國道1號 S 0K+000 基隆端→基隆交流道');
|
||||
assert.equal(camera.cityId, 'taiwan');
|
||||
assert.equal(camera.feedType, 'image');
|
||||
assert.equal(camera.headingConfidence, 'low');
|
||||
assert.equal(
|
||||
camera.url,
|
||||
'https://cctvn.freeway.gov.tw/abs2mjpg/jpg?camera=10000',
|
||||
);
|
||||
assert.equal(camera.snapshotUrl, camera.url);
|
||||
assert.match(camera.license, /Open Government Data License, Taiwan/);
|
||||
});
|
||||
|
||||
test('each camera server maps its stream path to its own still path only', () => {
|
||||
assert.equal(
|
||||
taiwanFrame(
|
||||
'https://cctvs.freeway.gov.tw/live-view/mjpg/video.cgi?camera=373',
|
||||
).frameUrl,
|
||||
'https://cctvs.freeway.gov.tw/live-view/jpg/image.cgi?camera=373',
|
||||
);
|
||||
assert.equal(
|
||||
taiwanFrame(
|
||||
'https://cctvn5.freeway.gov.tw/abs2mjpg/bmjpg?camera=c3a580c9-0232-46a1-bfc6-927590758ab4',
|
||||
).frameUrl,
|
||||
'https://cctvn5.freeway.gov.tw/abs2mjpg/jpg?camera=c3a580c9-0232-46a1-bfc6-927590758ab4',
|
||||
);
|
||||
assert.equal(
|
||||
taiwanFrame(
|
||||
'https://cctvc.freeway.gov.tw/abs2mjpg/bmjpg?camera=495&extra=1',
|
||||
).frameUrl,
|
||||
'https://cctvc.freeway.gov.tw/abs2mjpg/jpg?camera=495',
|
||||
);
|
||||
});
|
||||
|
||||
test('off-host, downgraded or malformed stream URLs never enter the proxy registry', () => {
|
||||
for (const stream of [
|
||||
'http://cctvn.freeway.gov.tw/abs2mjpg/bmjpg?camera=10000',
|
||||
'https://cctvn.freeway.gov.tw.evil.test/abs2mjpg/bmjpg?camera=10000',
|
||||
'https://user:[email protected]/abs2mjpg/bmjpg?camera=10000',
|
||||
'https://cctvn.freeway.gov.tw:8443/abs2mjpg/bmjpg?camera=10000',
|
||||
'https://cctvn.freeway.gov.tw/live-view/mjpg/video.cgi?camera=10000',
|
||||
'https://cctvs.freeway.gov.tw/abs2mjpg/bmjpg?camera=373',
|
||||
'https://cctvn.freeway.gov.tw/abs2mjpg/bmjpg?camera=../../admin',
|
||||
'https://cctvs.freeway.gov.tw/live-view/mjpg/video.cgi?cacamera=373',
|
||||
'http://127.0.0.1/private',
|
||||
'',
|
||||
]) {
|
||||
assert.equal(taiwanFrame(stream), null, stream);
|
||||
const [row] = parseTaiwanCctvXml(cctvXml(block({ stream })));
|
||||
assert.equal(taiwanCameraToSource(row), null, stream);
|
||||
}
|
||||
});
|
||||
|
||||
test('coordinates outside Taiwan, swapped or missing are rejected', () => {
|
||||
for (const [lat, lon] of [
|
||||
['121.735695', '25.1229931'],
|
||||
['35.68', '139.76'],
|
||||
['', '121.7'],
|
||||
['NaN', '121.7'],
|
||||
]) {
|
||||
const [row] = parseTaiwanCctvXml(cctvXml(block({ lat, lon })));
|
||||
assert.equal(taiwanCameraToSource(row), null, `${lat},${lon}`);
|
||||
}
|
||||
assert.equal(taiwanCameraToSource(null), null);
|
||||
});
|
||||
|
||||
test('Chinese place names in CCTVID stay in the record without shaping the key', () => {
|
||||
const [row] = parseTaiwanCctvXml(
|
||||
cctvXml(block({ id: 'CCTV-N1-N-55-R-中壢服務區&(北)' })),
|
||||
);
|
||||
const camera = taiwanCameraToSource(row);
|
||||
assert.equal(camera.id, 'tw-freeway-cctvn-10000');
|
||||
assert.equal(row.cctvId, 'CCTV-N1-N-55-R-中壢服務區&(北)');
|
||||
});
|
||||
|
||||
function mockCatalog(t, xml) {
|
||||
const requested = [];
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async (url, options) => {
|
||||
requested.push(String(url));
|
||||
if (String(url) === TAIWAN_FREEWAY_CCTV_URL) {
|
||||
assert.equal(options.redirect, 'manual');
|
||||
return new Response(xml, {
|
||||
headers: { 'content-type': 'application/xml' },
|
||||
});
|
||||
}
|
||||
return Response.json([]);
|
||||
});
|
||||
return requested;
|
||||
}
|
||||
|
||||
test('loader deduplicates cameras that share a camera number', async (t) => {
|
||||
mockCatalog(
|
||||
t,
|
||||
cctvXml(
|
||||
block(),
|
||||
block({ id: 'CCTV-N1-S-0.000-M-dup' }),
|
||||
block({
|
||||
stream: 'https://cctvc.freeway.gov.tw/abs2mjpg/bmjpg?camera=495',
|
||||
lat: '24.1',
|
||||
lon: '120.6',
|
||||
}),
|
||||
),
|
||||
);
|
||||
assert.deepEqual((await loadTaiwanSources()).map((c) => c.id).sort(), [
|
||||
'tw-freeway-cctvc-495',
|
||||
'tw-freeway-cctvn-10000',
|
||||
]);
|
||||
});
|
||||
|
||||
test('a redirected or failed catalog contributes an empty pack', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
for (const status of [302, 503]) {
|
||||
t.mock.method(
|
||||
globalThis,
|
||||
'fetch',
|
||||
async () =>
|
||||
new Response(cctvXml(block()), {
|
||||
status,
|
||||
headers: status === 302 ? { location: 'https://evil.test/' } : {},
|
||||
}),
|
||||
);
|
||||
resetTaiwanSourcesCache();
|
||||
assert.deepEqual(await loadTaiwanSources(), []);
|
||||
}
|
||||
});
|
||||
|
||||
async function runCatalogWithTaiwanFlag(t, flag) {
|
||||
const requested = mockCatalog(t, cctvXml(block()));
|
||||
if (flag !== undefined) process.env.CCTV_TAIWAN_ENABLED = flag;
|
||||
const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
|
||||
return { requested, sources };
|
||||
}
|
||||
|
||||
test('the Taiwan pack is in the catalog by default', async (t) => {
|
||||
const { sources } = await runCatalogWithTaiwanFlag(t, undefined);
|
||||
assert.equal(
|
||||
sources.find((c) => c.id === 'tw-freeway-cctvn-10000')?.cityId,
|
||||
'taiwan',
|
||||
);
|
||||
});
|
||||
|
||||
test('CCTV_TAIWAN_ENABLED=0 makes no Freeway Bureau request', async (t) => {
|
||||
const { requested, sources } = await runCatalogWithTaiwanFlag(t, '0');
|
||||
assert.ok(requested.length > 0, 'the other packs still refreshed');
|
||||
assert.equal(
|
||||
requested.some((href) => href.includes('freeway.gov.tw')),
|
||||
false,
|
||||
);
|
||||
assert.ok(!sources.some((c) => c.cityId === 'taiwan'));
|
||||
});
|
||||
|
||||
test('a catalog over the byte cap or a failed request contributes an empty pack', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const padding = `<!--${'x'.repeat(TAIWAN_MAX_CATALOG_BYTES)}-->`;
|
||||
t.mock.method(
|
||||
globalThis,
|
||||
'fetch',
|
||||
async () => new Response(cctvXml(block()) + padding),
|
||||
);
|
||||
assert.deepEqual(await loadTaiwanSources(), []);
|
||||
resetTaiwanSourcesCache();
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
throw new TypeError('fetch failed');
|
||||
});
|
||||
assert.deepEqual(await loadTaiwanSources(), []);
|
||||
});
|
||||
|
||||
test('a failed download is not retried until the backoff ends', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
let calls = 0;
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
calls += 1;
|
||||
throw new TypeError('fetch failed');
|
||||
});
|
||||
t.mock.method(Date, 'now', () => 1_000_000);
|
||||
assert.deepEqual(await loadTaiwanSources(), []);
|
||||
assert.deepEqual(await loadTaiwanSources(), []);
|
||||
assert.equal(calls, 1, 'the second refresh made no request');
|
||||
Date.now.mock.mockImplementation(
|
||||
() => 1_000_000 + TAIWAN_RETRY_AFTER_FAILURE_MS + 1,
|
||||
);
|
||||
await loadTaiwanSources();
|
||||
assert.equal(calls, 2, 'the backoff ends after an hour');
|
||||
});
|
||||
|
||||
test('a failed download serves the last good table', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const fetchMock = t.mock.method(
|
||||
globalThis,
|
||||
'fetch',
|
||||
async () => new Response(cctvXml(block())),
|
||||
);
|
||||
const first = await loadTaiwanSources();
|
||||
assert.deepEqual(
|
||||
first.map((c) => c.id),
|
||||
['tw-freeway-cctvn-10000'],
|
||||
);
|
||||
fetchMock.mock.mockImplementation(
|
||||
async () => new Response('', { status: 503 }),
|
||||
);
|
||||
assert.deepEqual(
|
||||
(await loadTaiwanSources()).map((c) => c.id),
|
||||
['tw-freeway-cctvn-10000'],
|
||||
);
|
||||
});
|
||||
|
||||
test('CCTV_TAIWAN_MAX_SOURCES is clamped to 1-2000', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
const rows = Array.from({ length: 3 }, (_, i) =>
|
||||
block({
|
||||
stream: `https://cctvn.freeway.gov.tw/abs2mjpg/bmjpg?camera=${i + 1}`,
|
||||
}),
|
||||
);
|
||||
t.mock.method(
|
||||
globalThis,
|
||||
'fetch',
|
||||
async () => new Response(cctvXml(...rows)),
|
||||
);
|
||||
for (const [value, expected] of [
|
||||
['0', 1],
|
||||
['2', 2],
|
||||
['5000', 3],
|
||||
['abc', 3],
|
||||
]) {
|
||||
resetTaiwanSourcesCache();
|
||||
process.env.CCTV_TAIWAN_MAX_SOURCES = value;
|
||||
const sources = await loadTaiwanSources();
|
||||
assert.equal(sources.length, expected, value);
|
||||
assert.equal(sources.available, 3, value);
|
||||
}
|
||||
});
|
||||
|
||||
test('names fall back to the published ID when the row has no road details', () => {
|
||||
const xml = cctvXml(block({ id: 'CCTV-N3-N-100-M' }))
|
||||
.replace('<RoadName>國道1號</RoadName>', '<RoadName></RoadName>')
|
||||
.replace('<LocationMile>0K+000</LocationMile>', '')
|
||||
.replace('<Start>基隆端</Start><End>基隆交流道</End>', '');
|
||||
const camera = taiwanCameraToSource(parseTaiwanCctvXml(xml)[0]);
|
||||
assert.equal(camera.name, 'CCTV-N3-N-100-M');
|
||||
});
|
||||
|
||||
test('numeric character references in the table are decoded', () => {
|
||||
const xml = cctvXml(block()).replace(
|
||||
'<RoadName>國道1號</RoadName>',
|
||||
'<RoadName>國道1號</RoadName>',
|
||||
);
|
||||
assert.equal(parseTaiwanCctvXml(xml)[0].roadName, '國道1號');
|
||||
});
|
||||
|
||||
test('camera numbers that are UUIDs keep their case in the frame URL only', () => {
|
||||
const camera = taiwanCameraToSource(
|
||||
parseTaiwanCctvXml(
|
||||
cctvXml(
|
||||
block({
|
||||
stream:
|
||||
'https://cctvn5.freeway.gov.tw/abs2mjpg/bmjpg?camera=C3A580C9-0232-46A1-BFC6-927590758AB4',
|
||||
}),
|
||||
),
|
||||
)[0],
|
||||
);
|
||||
assert.equal(
|
||||
camera.id,
|
||||
'tw-freeway-cctvn5-c3a580c9-0232-46a1-bfc6-927590758ab4',
|
||||
);
|
||||
assert.match(camera.url, /camera=C3A580C9-0232-46A1-BFC6-927590758AB4$/);
|
||||
});
|
||||
+33
-1
@@ -239,7 +239,8 @@ export const DATA_CREDITS = [
|
||||
html:
|
||||
'CCTV cameras & frames (Ontario): ' +
|
||||
'<a href="https://511on.ca/" target="_blank" rel="noopener">Ontario 511</a> ' +
|
||||
'(<a href="https://www.ontario.ca/page/open-government-licence-ontario" target="_blank" rel="noopener">Open Government Licence - Ontario</a>)',
|
||||
'(<a href="https://www.ontario.ca/page/open-government-licence-ontario" target="_blank" rel="noopener">Open Government Licence - Ontario</a>) ' +
|
||||
'· provided as is, without warranty; actual road conditions may differ',
|
||||
},
|
||||
{
|
||||
key: 'fintraffic-cctv',
|
||||
@@ -257,6 +258,14 @@ export const DATA_CREDITS = [
|
||||
'Road-camera stills (Lithuania, when enabled): ' +
|
||||
'<a href="https://eismoinfo.lt/" target="_blank" rel="noopener">Via Lietuva / Eismoinfo</a>; periodically updated images',
|
||||
},
|
||||
{
|
||||
key: 'taiwan-cctv',
|
||||
html:
|
||||
'Freeway camera stills (Taiwan): ' +
|
||||
'<a href="https://www.freeway.gov.tw/" target="_blank" rel="noopener">Freeway Bureau, MOTC</a>, ' +
|
||||
'CCTV static data (MOTC 2.0), released under the ' +
|
||||
'<a href="https://data.gov.tw/license" target="_blank" rel="noopener">Open Government Data License, Taiwan (Version 1.0)</a>',
|
||||
},
|
||||
{
|
||||
key: 'calgary-cctv',
|
||||
html:
|
||||
@@ -288,6 +297,13 @@ export const DATA_CREDITS = [
|
||||
'<a href="https://kingcounty.gov/en/dept/kcit/data-information-services/gis-center/about/terms-conditions-copyrights" target="_blank" rel="noopener">KCGIS Center terms</a>. ' +
|
||||
'Partner-city cameras hosted on the county image handler (e.g. Newcastle) name their owner in the CCTV panel.',
|
||||
},
|
||||
{
|
||||
key: 'hk-cctv',
|
||||
html:
|
||||
'Traffic snapshot cameras (Hong Kong): ' +
|
||||
'<a href="https://data.gov.hk/en-data/dataset/hk-td-tis_2-traffic-snapshot-images" target="_blank" rel="noopener">Transport Department / DATA.GOV.HK</a> ' +
|
||||
'(<a href="https://data.gov.hk/en/terms-and-conditions" target="_blank" rel="noopener">Terms of Use</a> — attribution required)',
|
||||
},
|
||||
{
|
||||
key: 'vegvesen-cctv',
|
||||
html:
|
||||
@@ -311,6 +327,22 @@ export const DATA_CREDITS = [
|
||||
'Traffic cameras (Toronto): contains information licensed under the ' +
|
||||
'<a href="https://www.toronto.ca/city-government/data-research-maps/open-data/open-data-licence/" target="_blank" rel="noopener">Open Government Licence – Toronto</a>',
|
||||
},
|
||||
{
|
||||
key: 'mapillary',
|
||||
html: 'Street Level: imagery © <a href="https://www.mapillary.com" target="_blank" rel="noopener">Mapillary</a> contributors, <a href="https://creativecommons.org/licenses/by-sa/4.0/" target="_blank" rel="noopener">CC BY-SA 4.0</a>',
|
||||
},
|
||||
{
|
||||
key: 'google-street-view',
|
||||
html: 'Street Level: Street View imagery © <a href="https://www.google.com/streetview/" target="_blank" rel="noopener">Google</a>',
|
||||
},
|
||||
{
|
||||
key: 'kartaview',
|
||||
html: 'Street Level: imagery © <a href="https://kartaview.org" target="_blank" rel="noopener">KartaView</a> contributors, <a href="https://creativecommons.org/licenses/by-sa/4.0/" target="_blank" rel="noopener">CC BY-SA 4.0</a>',
|
||||
},
|
||||
{
|
||||
key: 'panoramax',
|
||||
html: 'Street Level: imagery © <a href="https://panoramax.fr" target="_blank" rel="noopener">Panoramax</a> contributors, licence per picture',
|
||||
},
|
||||
{
|
||||
key: 'gbfs',
|
||||
html:
|
||||
|
||||
@@ -128,3 +128,16 @@ test('the NYC camera credit carries NYC DOT’s requested disclaimer', () => {
|
||||
assert.match(credit.html, /make no representation as to the accuracy/);
|
||||
assert.match(credit.html, /observe posted signage/);
|
||||
});
|
||||
|
||||
test('every Street Level imagery source is in the attribution lightbox', () => {
|
||||
for (const key of [
|
||||
'mapillary',
|
||||
'google-street-view',
|
||||
'kartaview',
|
||||
'panoramax',
|
||||
])
|
||||
assert.ok(
|
||||
DATA_CREDITS.some((entry) => entry.key === key),
|
||||
`${key} has no credit`,
|
||||
);
|
||||
});
|
||||
@@ -33,6 +33,18 @@ export function crossTrackKm(lat, lon, lat1, lon1, lat2, lon2) {
|
||||
return Math.asin(Math.sin(d13) * Math.sin(b13 - b12)) * EARTH_RADIUS_KM;
|
||||
}
|
||||
|
||||
/** Signed distance along the great circle from the origin toward the destination. */
|
||||
function alongTrackKm(position, origin, destination) {
|
||||
const distance = greatCircleKm(origin, position) / EARTH_RADIUS_KM;
|
||||
const direction =
|
||||
bearingRad(origin.lat, origin.lon, position.lat, position.lon) -
|
||||
bearingRad(origin.lat, origin.lon, destination.lat, destination.lon);
|
||||
return (
|
||||
Math.atan2(Math.sin(distance) * Math.cos(direction), Math.cos(distance)) *
|
||||
EARTH_RADIUS_KM
|
||||
);
|
||||
}
|
||||
|
||||
const NEAR_ENDPOINT_KM = 130;
|
||||
const CROSS_TRACK_KM = 200;
|
||||
const LOW_ALT_M = 3700; // ~12 000 ft
|
||||
@@ -66,6 +78,18 @@ export function routePlausible({
|
||||
const near = (point) => greatCircleKm(position, point) < NEAR_ENDPOINT_KM;
|
||||
let geomOk = (haveO && near(origin)) || (haveD && near(destination));
|
||||
if (!geomOk && haveO && haveD) {
|
||||
const routeKm = greatCircleKm(origin, destination);
|
||||
const alongKm = alongTrackKm(position, origin, destination);
|
||||
// atan2 returns the principal arc in [-πR, πR]. Near an antipodal
|
||||
// destination, its allowed forward extension can cross that branch cut.
|
||||
const segmentAlongKm =
|
||||
alongKm < -CROSS_TRACK_KM
|
||||
? alongKm + 2 * Math.PI * EARTH_RADIUS_KM
|
||||
: alongKm;
|
||||
// Coincident or antipodal endpoints do not define one minor arc. Keep the
|
||||
// existing loose check when the segment cannot be judged reliably.
|
||||
const segmentKnown =
|
||||
routeKm > 0.001 && routeKm < Math.PI * EARTH_RADIUS_KM - 0.001;
|
||||
geomOk =
|
||||
Math.abs(
|
||||
crossTrackKm(
|
||||
@@ -76,7 +100,10 @@ export function routePlausible({
|
||||
destination.lat,
|
||||
destination.lon,
|
||||
),
|
||||
) < CROSS_TRACK_KM;
|
||||
) < CROSS_TRACK_KM &&
|
||||
(!segmentKnown ||
|
||||
(segmentAlongKm >= -CROSS_TRACK_KM &&
|
||||
segmentAlongKm <= routeKm + CROSS_TRACK_KM));
|
||||
} else if (!geomOk) {
|
||||
geomOk = true; // only one endpoint known and not near — cannot judge, allow
|
||||
}
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import * as Cesium from 'cesium';
|
||||
import { routePlausible } from './routePlausible.js';
|
||||
import { createTracking } from '../layers/flights/tracking.js';
|
||||
|
||||
const SFO = { lat: 37.6188, lon: -122.3754, code: 'SFO' };
|
||||
const LAX = { lat: 33.9416, lon: -118.4085, code: 'LAX' };
|
||||
const beyond = { lat: -35.818227816411174, lon: -66.16647396367095 };
|
||||
const behind = { lat: 35.818227816411174, lon: 113.83352603632906 };
|
||||
const antipode = { lat: -SFO.lat, lon: SFO.lon + 180 };
|
||||
const midpoint = { lat: 35.8, lon: -120.4 };
|
||||
|
||||
const plausible = (position, origin = SFO, destination = LAX) =>
|
||||
routePlausible({
|
||||
latDeg: position.lat,
|
||||
lonDeg: position.lon,
|
||||
altitudeM: 10000,
|
||||
verticalRateMps: 0,
|
||||
origin,
|
||||
destination,
|
||||
});
|
||||
|
||||
// Independent spherical oracle: native unit-vector angular distances, without
|
||||
// the production bearing/cross-track/along-track calculations.
|
||||
const unit = ({ lat, lon }) => {
|
||||
const latitude = Cesium.Math.toRadians(lat);
|
||||
const longitude = Cesium.Math.toRadians(lon);
|
||||
return new Cesium.Cartesian3(
|
||||
Math.cos(latitude) * Math.cos(longitude),
|
||||
Math.cos(latitude) * Math.sin(longitude),
|
||||
Math.sin(latitude),
|
||||
);
|
||||
};
|
||||
const angle = (a, b) => Cesium.Cartesian3.angleBetween(unit(a), unit(b));
|
||||
const excess = (position, origin, destination) =>
|
||||
angle(origin, position) +
|
||||
angle(position, destination) -
|
||||
angle(origin, destination);
|
||||
|
||||
test('a short airport route cannot describe a plane thousands of kilometres beyond its destination', () => {
|
||||
assert.ok(excess(beyond, SFO, LAX) > 2.9);
|
||||
assert.equal(plausible(beyond), false);
|
||||
});
|
||||
|
||||
test('backwards and antipodal extensions are not the scheduled airport leg', () => {
|
||||
for (const position of [behind, antipode]) {
|
||||
assert.ok(excess(position, SFO, LAX) > 3);
|
||||
assert.equal(plausible(position), false);
|
||||
}
|
||||
});
|
||||
|
||||
test('ordinary route offsets and generous endpoint extensions stay plausible', () => {
|
||||
assert.ok(excess(midpoint, SFO, LAX) < 0.001);
|
||||
for (const position of [midpoint, SFO, LAX, { lat: 36.5, lon: -119.5 }]) {
|
||||
assert.equal(plausible(position), true);
|
||||
}
|
||||
// ~167 km past either endpoint is outside the 130 km endpoint circle,
|
||||
// but remains inside the 200 km coarse-route tolerance.
|
||||
const origin = { lat: 0, lon: 0 };
|
||||
const destination = { lat: 0, lon: 10 };
|
||||
assert.equal(plausible({ lat: 0, lon: -1.5 }, origin, destination), true);
|
||||
assert.equal(plausible({ lat: 0, lon: 11.5 }, origin, destination), true);
|
||||
});
|
||||
|
||||
test('the generous endpoint tolerance still excludes a clearly different leg', () => {
|
||||
const origin = { lat: 0, lon: 0 };
|
||||
const destination = { lat: 0, lon: 10 };
|
||||
assert.equal(plausible({ lat: 0, lon: -2.5 }, origin, destination), false);
|
||||
assert.equal(plausible({ lat: 0, lon: 12.5 }, origin, destination), false);
|
||||
});
|
||||
|
||||
test('minor-arc admission works across the antimeridian and near the pole', () => {
|
||||
const dateline = [
|
||||
{ lat: 10, lon: 170 },
|
||||
{ lat: 10, lon: -170 },
|
||||
];
|
||||
assert.equal(plausible({ lat: 10.15, lon: 180 }, ...dateline), true);
|
||||
assert.equal(plausible({ lat: -10.15, lon: 0 }, ...dateline), false);
|
||||
const polar = [
|
||||
{ lat: 80, lon: -60 },
|
||||
{ lat: 80, lon: 60 },
|
||||
];
|
||||
const top = { lat: 84.96163122670251, lon: 0 };
|
||||
assert.ok(excess(top, ...polar) < 1e-8);
|
||||
assert.equal(plausible(top, ...polar), true);
|
||||
assert.equal(plausible({ lat: -top.lat, lon: 180 }, ...polar), false);
|
||||
});
|
||||
|
||||
test('the forward tolerance survives the signed-angle branch cut in both directions', () => {
|
||||
const origin = { lat: 0, lon: 0 };
|
||||
for (const direction of [-1, 1]) {
|
||||
const destination = { lat: 0, lon: direction * 179.9 };
|
||||
const within = { lat: 0, lon: direction * -178.8 };
|
||||
const endpointGapKm = angle(destination, within) * 6371.0088;
|
||||
assert.ok(endpointGapKm > 130 && endpointGapKm < 200);
|
||||
assert.equal(plausible(within, origin, destination), true);
|
||||
assert.equal(
|
||||
plausible({ lat: 0, lon: direction * -178 }, origin, destination),
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('unjudgeable endpoint geometry and missing coordinates keep the loose contract', () => {
|
||||
assert.equal(
|
||||
plausible({ lat: 0, lon: -90 }, { lat: 0, lon: 0 }, { lat: 0, lon: 180 }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
plausible({ lat: 45, lon: 0 }, { lat: 0, lon: 0 }, { lat: 0, lon: 0 }),
|
||||
true,
|
||||
);
|
||||
assert.equal(plausible(beyond, SFO, null), true);
|
||||
assert.equal(plausible(beyond, null, null), true);
|
||||
});
|
||||
|
||||
test('the actual native tracked label hides the wrong leg and keeps a healthy route', (t) => {
|
||||
const collection = new Cesium.BillboardCollection();
|
||||
t.after(() => collection.destroy());
|
||||
const flightState = {
|
||||
records: { data: new Map(), missingPolls: new Map() },
|
||||
feed: { _backoff: false },
|
||||
_billboards: new Map(),
|
||||
_scratchCarto: new Cesium.Cartographic(),
|
||||
};
|
||||
// Only application host services are supplied. Route admission, WGS84
|
||||
// billboard conversion and tracked-label formatting are the actual owners.
|
||||
const services = Object.fromEntries(
|
||||
[
|
||||
'context',
|
||||
'trails',
|
||||
'geoid',
|
||||
'groundFloor',
|
||||
'focus',
|
||||
'militaryRegistry',
|
||||
'readout',
|
||||
'camera',
|
||||
'picking',
|
||||
].map((name) => [name, {}]),
|
||||
);
|
||||
services.aircraftPresentation = { isTr3b: () => false };
|
||||
const parts = { queries: { _contactLabel: (_, info) => info.callsign } };
|
||||
const tracking = createTracking({ flightState, services, parts });
|
||||
for (const [position, expected] of [
|
||||
[beyond, false],
|
||||
[midpoint, true],
|
||||
]) {
|
||||
const id = 'abc001';
|
||||
const route = { origin: SFO, destination: LAX };
|
||||
flightState.records.data.set(id, {
|
||||
callsign: 'OWNED001',
|
||||
altitude: 10000,
|
||||
verticalRate: 0,
|
||||
route,
|
||||
});
|
||||
flightState._billboards.set(
|
||||
id,
|
||||
collection.add({
|
||||
id,
|
||||
position: Cesium.Cartesian3.fromDegrees(
|
||||
position.lon,
|
||||
position.lat,
|
||||
10000,
|
||||
),
|
||||
}),
|
||||
);
|
||||
assert.equal(
|
||||
tracking._trackedLabelText(id).includes('SFO → LAX'),
|
||||
expected,
|
||||
);
|
||||
assert.equal(tracking._routeIsPlausible(id, route), expected);
|
||||
}
|
||||
});
|
||||
@@ -908,9 +908,7 @@
|
||||
"Red": "Rojo",
|
||||
"Clear": "Borrar",
|
||||
"Remove every mark from the board": "Quitar todas las marcas del tablero",
|
||||
"Point the camera at the globe for street-level coverage": "Apunta la cámara al globo para ver la cobertura a pie de calle",
|
||||
"No street-level imagery providers configured": "No hay proveedores de imágenes a pie de calle configurados",
|
||||
"Selected": "Seleccionada",
|
||||
"Live Vessels": "Buques en vivo",
|
||||
"Bike Share": "Bicis compartidas",
|
||||
"Cameras": "Cámaras",
|
||||
@@ -920,7 +918,6 @@
|
||||
"Active Fires": "Incendios activos",
|
||||
"Street Level could not start": "No se pudo iniciar A pie de calle",
|
||||
"Street Level toggle failed": "No se pudo activar o desactivar la capa A pie de calle",
|
||||
"Street-level image": "Imagen a pie de calle",
|
||||
"SHRINK": "REDUCIR",
|
||||
"EXPAND": "AMPLIAR",
|
||||
"Shrink": "Reducir",
|
||||
@@ -954,9 +951,6 @@
|
||||
"many": "{n} calles a la vista · haz clic en una línea para su foto más cercana",
|
||||
"other": "{n} calles a la vista · haz clic en una línea para su foto más cercana"
|
||||
},
|
||||
"Camera follows view: move the globe camera wherever the street-level view looks": "La cámara sigue la vista: mueve la cámara del globo hacia donde mira la vista a pie de calle",
|
||||
"Camera follow needs the Google 3D map: choose Google 3D under MAP SOURCE": "Seguir con la cámara requiere el mapa Google 3D: elige Google 3D en FUENTE DEL MAPA",
|
||||
"Switch a provider on to draw its coverage.": "Activa un proveedor para dibujar su cobertura.",
|
||||
"Loading this sequence…": "Cargando esta secuencia…",
|
||||
"{n} images in this sequence · Esc clears": {
|
||||
"one": "{n} imagen en esta secuencia · Esc quita la selección",
|
||||
@@ -969,14 +963,11 @@
|
||||
"other": "{n} secuencias a la vista · haz clic en una línea para ver sus fotos"
|
||||
},
|
||||
"STREET LEVEL": "A PIE DE CALLE",
|
||||
"Expand the street-level view (Esc shrinks it)": "Ampliar la vista a pie de calle (Esc la reduce)",
|
||||
"Image fit": "Ajuste de la imagen",
|
||||
"FIT": "AJUSTAR",
|
||||
"Fit: show the whole photo, with dark bars where its shape differs from the frame": "Ajustar: muestra la foto entera, con barras oscuras donde su forma no coincide con el marco",
|
||||
"FILL": "RELLENAR",
|
||||
"Fill: fill the frame edge to edge, cropping the photo": "Rellenar: llena el marco de borde a borde, recortando la foto",
|
||||
"Camera follows view": "La cámara sigue la vista",
|
||||
"FOLLOW": "SEGUIR",
|
||||
"Close the image and deselect it on the map": "Cerrar la imagen y quitar la selección en el mapa",
|
||||
"Close the street-level image": "Cerrar la imagen a pie de calle",
|
||||
"Street-level viewer": "Visor a pie de calle",
|
||||
@@ -989,7 +980,6 @@
|
||||
"ALL": "TODAS",
|
||||
"FLAT": "PLANAS",
|
||||
"SINCE": "DESDE",
|
||||
"Coverage colours": "Colores de cobertura",
|
||||
"That shape already has {count} points — finish it or press Backspace.": "Esa forma ya tiene {count} puntos — termínala o pulsa Backspace.",
|
||||
"Could not place the shape: {error}": "No se pudo colocar la forma: {error}",
|
||||
"{owner} is using the pointer — close it first.": "Puntero en uso: {owner}. Ciérralo primero.",
|
||||
@@ -1506,7 +1496,6 @@
|
||||
"DISCONNECT": "DESCONECTAR",
|
||||
"CONNECT": "CONECTAR",
|
||||
"LOCATING…": "UBICANDO…",
|
||||
"Drag to resize · double-click the header to snap back": "Arrastra para cambiar el tamaño · doble clic en el encabezado para restaurarlo",
|
||||
"CH {index} / {total}": "CANAL {index} / {total}",
|
||||
"Station unavailable after directory refresh": "Emisora no disponible tras actualizar el directorio",
|
||||
"Clear reading": "Borrar lectura",
|
||||
@@ -2443,7 +2432,6 @@
|
||||
"Exit cockpit to fly to a scene": "Sal de la cabina para volar a una escena",
|
||||
"Go to expanded Radio section": "Ir a la sección de Radio expandida",
|
||||
"Expand Radio section in Context": "Expandir la sección de Radio en Contexto",
|
||||
"Double-click the header to snap the panel back": "Haz doble clic en el encabezado para devolver el panel a su sitio",
|
||||
"New scene name": "Nombre de la nueva escena",
|
||||
"Delete scene \"{scene}\" and all shots?": "¿Eliminar la escena \"{scene}\" y todos sus planos?",
|
||||
"Detailed outline unavailable": "Contorno detallado no disponible",
|
||||
@@ -2891,6 +2879,14 @@
|
||||
"SDR decoder did not start; receiver stopped": "El decodificador SDR no arrancó; receptor detenido",
|
||||
"SDR decoder stopped responding; receiver stopped": "El decodificador SDR dejó de responder; receptor detenido",
|
||||
"SDR decoder fell behind; receiver stopped": "El decodificador SDR se quedó atrás; receptor detenido",
|
||||
"Zoom in to see street-level coverage": "Acércate para ver la cobertura a pie de calle",
|
||||
"Point the camera at the ground for street-level coverage": "Apunta la cámara al suelo para ver la cobertura a pie de calle",
|
||||
"Sequence images unavailable": "Las imágenes de la secuencia no están disponibles",
|
||||
"Switch Street Level on to draw its coverage.": "Activa A pie de calle para dibujar su cobertura.",
|
||||
"Every provider is switched off: light a chip to draw its coverage.": "Todos los proveedores están apagados: enciende uno para dibujar su cobertura.",
|
||||
"No sequences in view match the filter": "Ninguna secuencia a la vista coincide con el filtro",
|
||||
"No coverage in view": "No hay cobertura a la vista",
|
||||
"Show the street-level view full screen (Esc shrinks it)": "Mostrar la vista a pie de calle a pantalla completa (Esc la reduce)",
|
||||
"Gemini returned an oversized audio chunk": "Gemini devolvió un fragmento de audio demasiado grande",
|
||||
"Gemini returned an unsupported audio format": "Gemini devolvió un formato de audio no compatible",
|
||||
"Gemini returned an invalid audio sample rate": "Gemini devolvió una frecuencia de muestreo de audio no válida",
|
||||
|
||||
@@ -916,9 +916,7 @@
|
||||
"Red": "Красный",
|
||||
"Clear": "Очистить",
|
||||
"Remove every mark from the board": "Убрать все пометки с доски",
|
||||
"Point the camera at the globe for street-level coverage": "Наведите камеру на глобус, чтобы увидеть покрытие уличных снимков",
|
||||
"No street-level imagery providers configured": "Источники уличных снимков не настроены",
|
||||
"Selected": "Выбрано",
|
||||
"Live Vessels": "Суда онлайн",
|
||||
"Bike Share": "Прокат велосипедов",
|
||||
"Cameras": "Камеры",
|
||||
@@ -928,7 +926,6 @@
|
||||
"Active Fires": "Активные пожары",
|
||||
"Street Level could not start": "Не удалось запустить «Уличные снимки»",
|
||||
"Street Level toggle failed": "Не удалось переключить «Уличные снимки»",
|
||||
"Street-level image": "Уличный снимок",
|
||||
"SHRINK": "СВЕРНУТЬ",
|
||||
"EXPAND": "РАЗВЕРНУТЬ",
|
||||
"Shrink": "Свернуть",
|
||||
@@ -964,9 +961,6 @@
|
||||
"many": "{n} улиц в кадре · щёлкните линию, чтобы открыть ближайшую фотографию",
|
||||
"other": "{n} улицы в кадре · щёлкните линию, чтобы открыть ближайшую фотографию"
|
||||
},
|
||||
"Camera follows view: move the globe camera wherever the street-level view looks": "Камера следует за видом: камера глобуса поворачивается туда, куда смотрит уличный снимок",
|
||||
"Camera follow needs the Google 3D map: choose Google 3D under MAP SOURCE": "Слежению камеры нужна карта Google 3D: выберите Google 3D в разделе «ИСТОЧНИК КАРТЫ»",
|
||||
"Switch a provider on to draw its coverage.": "Включите источник, чтобы показать его покрытие.",
|
||||
"Loading this sequence…": "Загрузка последовательности…",
|
||||
"{n} images in this sequence · Esc clears": {
|
||||
"one": "{n} снимок в последовательности · Esc — сбросить",
|
||||
@@ -981,14 +975,11 @@
|
||||
"other": "{n} последовательности в кадре · щёлкните по линии, чтобы открыть снимки"
|
||||
},
|
||||
"STREET LEVEL": "УЛИЧНЫЕ СНИМКИ",
|
||||
"Expand the street-level view (Esc shrinks it)": "Развернуть уличный снимок (Esc сворачивает)",
|
||||
"Image fit": "Масштаб снимка",
|
||||
"FIT": "ВПИСАТЬ",
|
||||
"Fit: show the whole photo, with dark bars where its shape differs from the frame": "Вписать: показать снимок целиком, с тёмными полосами там, где его форма не совпадает с рамкой",
|
||||
"FILL": "ЗАПОЛНИТЬ",
|
||||
"Fill: fill the frame edge to edge, cropping the photo": "Заполнить: заполнить рамку целиком, обрезав края снимка",
|
||||
"Camera follows view": "Камера следует за видом",
|
||||
"FOLLOW": "СЛЕДИТЬ",
|
||||
"Close the image and deselect it on the map": "Закрыть снимок и снять выделение на карте",
|
||||
"Close the street-level image": "Закрыть уличный снимок",
|
||||
"Street-level viewer": "Просмотр уличных снимков",
|
||||
@@ -1001,7 +992,6 @@
|
||||
"ALL": "ВСЕ",
|
||||
"FLAT": "ПЛОСКИЕ",
|
||||
"SINCE": "С ДАТЫ",
|
||||
"Coverage colours": "Цвета покрытия",
|
||||
"That shape already has {count} points — finish it or press Backspace.": "В этой фигуре уже точек: {count} — завершите её или нажмите Backspace.",
|
||||
"Could not place the shape: {error}": "Не удалось разместить фигуру: {error}",
|
||||
"{owner} is using the pointer — close it first.": "Указатель занят: «{owner}». Закройте его.",
|
||||
@@ -1531,7 +1521,6 @@
|
||||
"DISCONNECT": "ОТКЛЮЧИТЬ",
|
||||
"CONNECT": "ПОДКЛЮЧИТЬ",
|
||||
"LOCATING…": "ОПРЕДЕЛЯЮ…",
|
||||
"Drag to resize · double-click the header to snap back": "Перетащите, чтобы изменить размер · двойной щелчок по заголовку — вернуть на место",
|
||||
"CH {index} / {total}": "КАН. {index} / {total}",
|
||||
"Station unavailable after directory refresh": "Станция недоступна после обновления каталога",
|
||||
"Clear reading": "Очистить показания",
|
||||
@@ -2484,7 +2473,6 @@
|
||||
"Exit cockpit to fly to a scene": "Выйдите из кабины, чтобы перелететь к сцене",
|
||||
"Go to expanded Radio section": "Перейти к развёрнутому разделу радио",
|
||||
"Expand Radio section in Context": "Развернуть раздел «Радио» в панели «Контекст»",
|
||||
"Double-click the header to snap the panel back": "Двойной щелчок по заголовку — вернуть панель на место",
|
||||
"New scene name": "Название новой сцены",
|
||||
"Delete scene \"{scene}\" and all shots?": "Удалить сцену «{scene}» и все кадры?",
|
||||
"Detailed outline unavailable": "Подробный контур недоступен",
|
||||
@@ -2958,6 +2946,14 @@
|
||||
"SDR decoder did not start; receiver stopped": "Декодер SDR не запустился; приёмник остановлен",
|
||||
"SDR decoder stopped responding; receiver stopped": "Декодер SDR перестал отвечать; приёмник остановлен",
|
||||
"SDR decoder fell behind; receiver stopped": "Декодер SDR не успевает; приёмник остановлен",
|
||||
"Zoom in to see street-level coverage": "Приблизьтесь, чтобы увидеть покрытие уличных снимков",
|
||||
"Point the camera at the ground for street-level coverage": "Направьте камеру на землю, чтобы увидеть покрытие уличных снимков",
|
||||
"Sequence images unavailable": "Снимки последовательности недоступны",
|
||||
"Switch Street Level on to draw its coverage.": "Включите «Уличные снимки», чтобы показать покрытие.",
|
||||
"Every provider is switched off: light a chip to draw its coverage.": "Все источники выключены: включите один, чтобы показать его покрытие.",
|
||||
"No sequences in view match the filter": "Ни одна последовательность в кадре не подходит под фильтр",
|
||||
"No coverage in view": "В кадре нет покрытия",
|
||||
"Show the street-level view full screen (Esc shrinks it)": "Показать уличный снимок на весь экран (Esc сворачивает)",
|
||||
"Gemini returned an oversized audio chunk": "Gemini вернул слишком большой фрагмент аудио",
|
||||
"Gemini returned an unsupported audio format": "Gemini вернул неподдерживаемый формат аудио",
|
||||
"Gemini returned an invalid audio sample rate": "Gemini вернул недопустимую частоту дискретизации аудио",
|
||||
|
||||
@@ -139,6 +139,7 @@ export function createCatalog({ state: layerState, services, parts, source }) {
|
||||
});
|
||||
layerState._points.set(noradId, point);
|
||||
layerState._denseIds.push(noradId);
|
||||
layerState._denseStale ||= Boolean(res.stale);
|
||||
added++;
|
||||
}
|
||||
// Yield to the event loop between chunks.
|
||||
@@ -226,6 +227,7 @@ export function createCatalog({ state: layerState, services, parts, source }) {
|
||||
layerState._catalog.delete(noradId);
|
||||
}
|
||||
layerState._denseIds = [];
|
||||
layerState._denseStale = false;
|
||||
layerState._denseCursor = 0;
|
||||
layerState._count = layerState._points.size;
|
||||
layerState._catalogRevision++;
|
||||
|
||||
Vendored
+5
-2
@@ -545,16 +545,19 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
},
|
||||
|
||||
getStats() {
|
||||
const stale = Boolean(layerState._coreStale || layerState._denseStale);
|
||||
return {
|
||||
count: layerState._count,
|
||||
lastUpdate: layerState._lastUpdate,
|
||||
stale: false,
|
||||
stale,
|
||||
status:
|
||||
layerState._lastError === 'CelesTrak unreachable'
|
||||
? 'unavailable'
|
||||
: layerState._lastError
|
||||
? 'degraded'
|
||||
: 'nominal',
|
||||
: stale
|
||||
? 'stale'
|
||||
: 'nominal',
|
||||
error: layerState._lastError,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -36,7 +36,12 @@ export function createIngestion({
|
||||
if (!res.ok) return { ...groupDef, entries: [], ok: false };
|
||||
const entries = parts.orbits.parseTLE(res.text);
|
||||
updateSignal.throwIfAborted();
|
||||
return { ...groupDef, entries, ok: entries.length > 0 };
|
||||
return {
|
||||
...groupDef,
|
||||
entries,
|
||||
ok: entries.length > 0,
|
||||
stale: Boolean(res.stale),
|
||||
};
|
||||
} catch (error) {
|
||||
if (updateSignal.aborted || error?.name === 'AbortError')
|
||||
throw error;
|
||||
@@ -91,6 +96,7 @@ export function createIngestion({
|
||||
// catalog or those labels stay stale for the life of the session.
|
||||
layerState._detectionObjects.clear();
|
||||
layerState._denseIds = [];
|
||||
layerState._denseStale = false;
|
||||
layerState._denseCursor = 0;
|
||||
layerState._denseLoadController?.abort();
|
||||
layerState._denseLoadController = null;
|
||||
@@ -102,11 +108,13 @@ export function createIngestion({
|
||||
// Process all TLE entries in CATALOG_GROUPS order
|
||||
const allEntries = [];
|
||||
for (const r of results) {
|
||||
for (const e of r.entries) allEntries.push({ ...e, group: r.tag });
|
||||
for (const e of r.entries)
|
||||
allEntries.push({ ...e, group: r.tag, stale: r.stale });
|
||||
}
|
||||
|
||||
// Deduplicate by NORAD ID — first (most specific) group tag wins
|
||||
const seen = new Set();
|
||||
let coreStale = false;
|
||||
|
||||
for (const entry of allEntries) {
|
||||
const satrec = twoline2satrec(entry.line1, entry.line2);
|
||||
@@ -123,6 +131,8 @@ export function createIngestion({
|
||||
group: entry.group,
|
||||
});
|
||||
|
||||
coreStale ||= entry.stale;
|
||||
|
||||
// Propagate initial position
|
||||
const pos = parts.orbits.propagatePosition(satrec, now);
|
||||
if (!pos) continue;
|
||||
@@ -157,6 +167,7 @@ export function createIngestion({
|
||||
parts.labels._syncIssOverlay();
|
||||
}
|
||||
|
||||
layerState._coreStale = coreStale;
|
||||
layerState._count = layerState._points.size;
|
||||
layerState._catalogRevision++;
|
||||
layerState._lastUpdate = Date.now();
|
||||
|
||||
@@ -24,6 +24,8 @@ export function createLifecycle({
|
||||
layerState._orbitPaths = new Map();
|
||||
layerState._count = 0;
|
||||
layerState._lastUpdate = null;
|
||||
layerState._coreStale = false;
|
||||
layerState._denseStale = false;
|
||||
layerState._trackedNorad = null;
|
||||
parts.tracking._cancelPendingTrackingRestore();
|
||||
layerState._trackedEntity = null;
|
||||
@@ -162,6 +164,8 @@ export function createLifecycle({
|
||||
layerState._rowControlsListener = null;
|
||||
layerState._count = 0;
|
||||
layerState._lastUpdate = null;
|
||||
layerState._coreStale = false;
|
||||
layerState._denseStale = false;
|
||||
layerState._lastError = null;
|
||||
layerState._lastFocusUpdate = 0;
|
||||
layerState._activeFocusCount = 0;
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import http from 'node:http';
|
||||
import { EventEmitter, once } from 'node:events';
|
||||
import test from 'node:test';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createSatelliteSource } from './source.js';
|
||||
import { createState } from './state.js';
|
||||
import { createControls } from './controls.js';
|
||||
import { createOrbits } from './orbits.js';
|
||||
import { createLabels } from './labels.js';
|
||||
import { createTracking } from './tracking.js';
|
||||
import { createRendering } from './rendering.js';
|
||||
import { createCatalog } from './catalog.js';
|
||||
import { createLifecycle } from './lifecycle.js';
|
||||
import { createInteraction } from './interaction.js';
|
||||
import { createIngestion } from './ingestion.js';
|
||||
import { layerFeedState } from '../../data/feedState.js';
|
||||
|
||||
const GROUPS = [
|
||||
'stations',
|
||||
'visual',
|
||||
'gps-ops',
|
||||
'glo-ops',
|
||||
'galileo',
|
||||
'geo',
|
||||
'starlink',
|
||||
];
|
||||
|
||||
function tle(id, invalid = false) {
|
||||
// Owned synthetic catalog IDs over one valid element set; never live requests.
|
||||
const motion = invalid ? ' 0.00000000' : '15.48899044';
|
||||
return `OWNED ORBIT FIXTURE ${id}\n1 ${id}U 98067A 26289.78971908 .00007803 00000+0 15012-3 0 9995\n2 ${id} 51.6326 58.3278 0006748 271.9059 88.1156 ${motion}590607`;
|
||||
}
|
||||
|
||||
async function fixture(t) {
|
||||
const policy = {
|
||||
stale: new Set(),
|
||||
invalid: new Set(),
|
||||
ids: {},
|
||||
status: 200,
|
||||
hold: false,
|
||||
};
|
||||
const held = new EventEmitter();
|
||||
let heldCount = 0;
|
||||
const server = http.createServer((req, res) => {
|
||||
const group = req.url.split('/').at(-1);
|
||||
if (policy.hold) {
|
||||
held.emit('request', ++heldCount);
|
||||
return;
|
||||
}
|
||||
res.writeHead(policy.status, {
|
||||
'Content-Type': 'text/plain',
|
||||
'X-TLE-Cache': policy.stale.has(group) ? 'STALE-ERROR' : 'MISS',
|
||||
});
|
||||
res.end(
|
||||
tle(
|
||||
policy.ids[group] ?? 25545 + GROUPS.indexOf(group),
|
||||
policy.invalid.has(group),
|
||||
),
|
||||
);
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const source = createSatelliteSource({
|
||||
fetchImpl: (path, options) =>
|
||||
fetch(`http://127.0.0.1:${server.address().port}${path}`, options),
|
||||
});
|
||||
// Application host services and native EventTargets; actual Cesium collections,
|
||||
// input handler, events, SGP4, ingestion and lifecycle run without a WebGL scene.
|
||||
const noop = () => {};
|
||||
const services = {
|
||||
overlays: {
|
||||
setOverlayEntries: noop,
|
||||
setOverlaySourceVisible: noop,
|
||||
clearOverlaySource: noop,
|
||||
},
|
||||
layerState: { isExplicitLayerStateOrigin: () => false },
|
||||
focus: {
|
||||
clearFocusTarget: noop,
|
||||
publishFocusTargetFromCachedPosition: noop,
|
||||
getFocusTarget: () => null,
|
||||
},
|
||||
context: {
|
||||
clearTrackedSubjectContext: noop,
|
||||
getContextStore: () => ({ entities: new Map() }),
|
||||
refreshTrackedSubjectContext: noop,
|
||||
selectTrackedSubjectContext: noop,
|
||||
},
|
||||
readout: { refreshTrackedReadout: noop },
|
||||
render: { holdContinuousRender: noop, releaseContinuousRender: noop },
|
||||
picking: { registerPickOwner: noop, unregisterPickOwner: noop },
|
||||
};
|
||||
const previousDocument = globalThis.document;
|
||||
globalThis.document = new EventTarget();
|
||||
const state = createState({ services });
|
||||
const parts = {};
|
||||
const context = { state, services, parts, source };
|
||||
for (const [name, factory] of Object.entries({
|
||||
controls: createControls,
|
||||
orbits: createOrbits,
|
||||
labels: createLabels,
|
||||
tracking: createTracking,
|
||||
rendering: createRendering,
|
||||
catalog: createCatalog,
|
||||
interaction: createInteraction,
|
||||
lifecycle: createLifecycle,
|
||||
ingestion: createIngestion,
|
||||
})) {
|
||||
parts[name] = factory(context);
|
||||
}
|
||||
const viewer = {
|
||||
scene: {
|
||||
canvas: new EventTarget(),
|
||||
primitives: new Cesium.PrimitiveCollection(),
|
||||
preRender: new Cesium.Event(),
|
||||
},
|
||||
trackedEntityChanged: new Cesium.Event(),
|
||||
};
|
||||
await parts.lifecycle.methods.init(viewer);
|
||||
t.after(async () => {
|
||||
if (state._pointCollection) parts.lifecycle.methods.destroy(viewer);
|
||||
viewer.scene.primitives.destroy();
|
||||
if (previousDocument === undefined) delete globalThis.document;
|
||||
else globalThis.document = previousDocument;
|
||||
server.closeAllConnections();
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
});
|
||||
return {
|
||||
state,
|
||||
parts,
|
||||
viewer,
|
||||
policy,
|
||||
update: (options) => parts.ingestion.methods.update(viewer, options),
|
||||
stats: () => parts.controls.methods.getStats(),
|
||||
feed: () => layerFeedState(parts.controls.methods.getStats()),
|
||||
async waitHeld(count) {
|
||||
while (heldCount < count)
|
||||
await once(held, 'request', { signal: AbortSignal.timeout(3000) });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('stale core records surface through actual HTTP, ingestion and feed state, then recover', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.update();
|
||||
assert.equal(f.feed(), 'nominal');
|
||||
assert.equal(f.stats().count, 6);
|
||||
f.policy.stale = new Set(GROUPS.slice(0, 6));
|
||||
await f.update();
|
||||
assert.equal(f.stats().count, 6);
|
||||
assert.equal(f.stats().stale, true);
|
||||
assert.equal(f.feed(), 'stale');
|
||||
f.policy.stale = new Set(['visual']);
|
||||
await f.update();
|
||||
assert.equal(f.feed(), 'stale');
|
||||
f.policy.stale.clear();
|
||||
await f.update();
|
||||
assert.equal(f.stats().stale, false);
|
||||
assert.equal(f.feed(), 'nominal');
|
||||
});
|
||||
|
||||
test('a stale duplicate cannot taint its earlier fresh winning record', async (t) => {
|
||||
const f = await fixture(t);
|
||||
f.policy.stale.add('visual');
|
||||
f.policy.ids.visual = 25545;
|
||||
await f.update();
|
||||
assert.equal(f.stats().count, 5);
|
||||
assert.equal(f.state._catalog.get(25545).group, 'stations');
|
||||
assert.equal(f.stats().stale, false);
|
||||
assert.equal(f.feed(), 'nominal');
|
||||
f.policy.stale = new Set(['stations']);
|
||||
await f.update();
|
||||
assert.equal(f.stats().count, 5);
|
||||
assert.equal(
|
||||
f.stats().stale,
|
||||
true,
|
||||
'a later fresh duplicate does not replace the stale winner',
|
||||
);
|
||||
});
|
||||
|
||||
test('invalid stale orbital elements are not admitted as provenance', async (t) => {
|
||||
const f = await fixture(t);
|
||||
f.policy.stale.add('visual');
|
||||
f.policy.invalid.add('visual');
|
||||
await f.update();
|
||||
assert.equal(f.stats().count, 5);
|
||||
assert.equal(f.state._catalog.has(25546), false);
|
||||
assert.equal(f.stats().stale, false);
|
||||
});
|
||||
|
||||
test('failed and aborted core refreshes keep provenance of retained records', async (t) => {
|
||||
const f = await fixture(t);
|
||||
f.policy.stale.add('stations');
|
||||
await f.update();
|
||||
const priorTime = f.stats().lastUpdate;
|
||||
f.policy.status = 503;
|
||||
await f.update();
|
||||
assert.equal(f.stats().count, 6);
|
||||
assert.equal(f.stats().stale, true);
|
||||
assert.equal(f.stats().status, 'unavailable');
|
||||
assert.equal(f.stats().lastUpdate, priorTime);
|
||||
f.policy.hold = true;
|
||||
const controller = new AbortController();
|
||||
const work = f.update({ signal: controller.signal });
|
||||
await f.waitHeld(6);
|
||||
controller.abort();
|
||||
await assert.rejects(work, { name: 'AbortError' });
|
||||
assert.equal(f.stats().count, 6);
|
||||
assert.equal(f.stats().stale, true);
|
||||
assert.equal(f.stats().lastUpdate, priorTime);
|
||||
assert.equal(f.state._activeUpdateControllers.size, 0);
|
||||
});
|
||||
|
||||
test('dense provenance belongs to its admitted records and clears on removal', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.update();
|
||||
f.policy.stale.add('starlink');
|
||||
f.state._params.catalog = 'dense';
|
||||
assert.equal((await f.parts.catalog._loadDenseCatalog()).status, 'ready');
|
||||
assert.equal(f.stats().count, 7);
|
||||
assert.equal(f.feed(), 'stale');
|
||||
f.state._params.catalog = 'core';
|
||||
f.parts.catalog._removeDenseCatalog();
|
||||
assert.equal(f.stats().count, 6);
|
||||
assert.equal(f.feed(), 'nominal');
|
||||
f.policy.stale = new Set(['stations']);
|
||||
await f.update();
|
||||
f.state._params.catalog = 'dense';
|
||||
await f.parts.catalog._loadDenseCatalog();
|
||||
f.state._params.catalog = 'core';
|
||||
f.parts.catalog._removeDenseCatalog();
|
||||
assert.equal(
|
||||
f.feed(),
|
||||
'stale',
|
||||
'removing dense data must not clear core provenance',
|
||||
);
|
||||
});
|
||||
|
||||
test('cancelling dense replacement preserves admitted stale extras until rebuild', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.update();
|
||||
f.policy.stale.add('starlink');
|
||||
f.state._params.catalog = 'dense';
|
||||
await f.parts.catalog._loadDenseCatalog();
|
||||
f.policy.hold = true;
|
||||
const controller = new AbortController();
|
||||
const work = f.parts.catalog._loadDenseCatalog({ signal: controller.signal });
|
||||
await f.waitHeld(1);
|
||||
controller.abort();
|
||||
assert.equal((await work).status, 'cancelled');
|
||||
assert.equal(f.stats().count, 7);
|
||||
assert.equal(f.feed(), 'stale');
|
||||
f.policy.hold = false;
|
||||
f.policy.stale.clear();
|
||||
f.state._params.catalog = 'core';
|
||||
await f.update();
|
||||
assert.equal(f.stats().count, 6);
|
||||
assert.equal(f.feed(), 'nominal');
|
||||
});
|
||||
|
||||
test('destroy aborts an in-flight response and initialization starts with new ownership', async (t) => {
|
||||
const f = await fixture(t);
|
||||
f.policy.stale.add('stations');
|
||||
await f.update();
|
||||
f.policy.hold = true;
|
||||
const work = f.update();
|
||||
await f.waitHeld(6);
|
||||
const rejected = assert.rejects(work, { name: 'AbortError' });
|
||||
f.parts.lifecycle.methods.destroy(f.viewer);
|
||||
assert.equal(f.stats().count, 0);
|
||||
assert.equal(f.stats().stale, false);
|
||||
await f.parts.lifecycle.methods.init(f.viewer);
|
||||
await rejected;
|
||||
assert.equal(f.stats().stale, false);
|
||||
f.policy.hold = false;
|
||||
f.policy.stale.clear();
|
||||
await f.update();
|
||||
assert.equal(f.stats().count, 6);
|
||||
assert.equal(f.feed(), 'nominal');
|
||||
});
|
||||
@@ -40,6 +40,10 @@ export function createState({ services }) {
|
||||
|
||||
state._lastError = null;
|
||||
|
||||
// Cache provenance belongs to admitted core and dense records separately.
|
||||
state._coreStale = false;
|
||||
state._denseStale = false;
|
||||
|
||||
state._activeUpdateControllers = new Set();
|
||||
|
||||
state._denseLoadController = null;
|
||||
|
||||
@@ -1,209 +0,0 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { FOLLOW_EYE_HEIGHT_M } from './policy.js';
|
||||
|
||||
/** Plausible ellipsoidal land heights: Dead Sea to Everest, with geoid slack. */
|
||||
const SURFACE_MIN_M = -500;
|
||||
const SURFACE_MAX_M = 9000;
|
||||
|
||||
/** Drive the globe camera from the street-level pose: continuously (follow) or once (framing). */
|
||||
export function createCameraFollow({ state, parts }) {
|
||||
const { render } = state.services;
|
||||
|
||||
function requestRender() {
|
||||
render?.governorRequestRender?.('street-level-follow');
|
||||
}
|
||||
|
||||
/**
|
||||
* The application's camera authority (`attachNavigation`): `run(noun, move)`
|
||||
* releases tracking and other owners before `move`, or refuses (cockpit);
|
||||
* `begin(noun)` takes a deferred ticket (a generation, or false) that
|
||||
* `reassert(generation)` honours only if nothing newer took the camera;
|
||||
* `subscribeHandoff` reports a newer owner. Without it, moves run directly.
|
||||
*/
|
||||
let navigation = null;
|
||||
let unsubscribeHandoff = null;
|
||||
/** Set while our own claim stamps a handoff, so it does not end FOLLOW. */
|
||||
let claiming = false;
|
||||
|
||||
/** Take the camera for `move`; false when the application refuses. */
|
||||
function claim(move) {
|
||||
if (!navigation?.run) {
|
||||
move();
|
||||
return true;
|
||||
}
|
||||
claiming = true;
|
||||
try {
|
||||
return (
|
||||
navigation.run('photo', () => {
|
||||
move();
|
||||
return true;
|
||||
}) === true
|
||||
);
|
||||
} finally {
|
||||
claiming = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask for the camera as a photo starts opening. The ticket goes to
|
||||
* `lookAtPosition`; null when the application refuses (cockpit).
|
||||
*/
|
||||
function beginFraming() {
|
||||
if (!navigation?.begin || !navigation.reassert) return { generation: null };
|
||||
claiming = true;
|
||||
try {
|
||||
const generation = navigation.begin('photo');
|
||||
return generation === false ? null : { generation };
|
||||
} finally {
|
||||
claiming = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Another feature took the camera: stop following it. */
|
||||
function onHandoff() {
|
||||
if (claiming || !state.street.follow) return;
|
||||
state.street.follow = false;
|
||||
state.notify?.();
|
||||
}
|
||||
|
||||
function attachNavigation(next) {
|
||||
unsubscribeHandoff?.();
|
||||
navigation = next || null;
|
||||
unsubscribeHandoff = navigation?.subscribeHandoff?.(onHandoff) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ground under a photo. `sampleHeight` can return kilometres below the
|
||||
* ellipsoid before tiles load, so a sample counts only when plausible and
|
||||
* within the mesh window around bare earth (once that is known).
|
||||
*/
|
||||
function groundHeightAt(lon, lat, fallback) {
|
||||
const scene = state.viewer?.scene;
|
||||
const carto = Cesium.Cartographic.fromDegrees(lon, lat);
|
||||
const caster = parts?.groundCaster;
|
||||
const dem = caster?.groundAt(lon, lat) ?? null;
|
||||
if (dem === null) caster?.prepare([[lon, lat]]);
|
||||
// The application's mesh window around bare earth, once that is known.
|
||||
const withinPrior = state.services.ground?.meshFloorSampleWithinPrior;
|
||||
const plausible = (height) =>
|
||||
Number.isFinite(height) &&
|
||||
height >= SURFACE_MIN_M &&
|
||||
height <= SURFACE_MAX_M &&
|
||||
(dem === null || !withinPrior || withinPrior(height, dem));
|
||||
let height = null;
|
||||
try {
|
||||
if (scene?.sampleHeightSupported) height = scene.sampleHeight(carto);
|
||||
} catch {
|
||||
/* not sampleable yet */
|
||||
}
|
||||
if (!plausible(height)) height = dem;
|
||||
if (!plausible(height)) height = scene?.globe?.getHeight?.(carto);
|
||||
if (!plausible(height)) height = fallback;
|
||||
return plausible(height) ? height : 0;
|
||||
}
|
||||
|
||||
/** Put the Cesium camera where the street-level camera is. */
|
||||
function followCamera() {
|
||||
const { position, bearing, tilt, follow, altitude } = state.street;
|
||||
if (!follow || !position || !state.viewer) return;
|
||||
const ground = groundHeightAt(position.lon, position.lat, altitude);
|
||||
state.viewer.camera.setView({
|
||||
destination: Cesium.Cartesian3.fromDegrees(
|
||||
position.lon,
|
||||
position.lat,
|
||||
ground + FOLLOW_EYE_HEIGHT_M,
|
||||
),
|
||||
orientation: {
|
||||
heading: Cesium.Math.toRadians(bearing || 0),
|
||||
pitch: Cesium.Math.toRadians(Number.isFinite(tilt) ? tilt : 0),
|
||||
roll: 0,
|
||||
},
|
||||
});
|
||||
requestRender();
|
||||
}
|
||||
|
||||
/** Our framing flight while it is current; Cesium calls `cancel` when another flight starts. */
|
||||
let framing = null;
|
||||
|
||||
/**
|
||||
* Frame the current image from a short distance behind it, unless another
|
||||
* action took the camera since `ticket` (from `beginFraming`) was issued.
|
||||
*/
|
||||
function lookAtPosition(ticket) {
|
||||
const { position, bearing, altitude } = state.street;
|
||||
if (!ticket || !position || !state.viewer) return;
|
||||
if (
|
||||
ticket.generation !== null &&
|
||||
!navigation?.reassert?.(ticket.generation)
|
||||
)
|
||||
return;
|
||||
const ground = groundHeightAt(position.lon, position.lat, altitude);
|
||||
const fly = () => {
|
||||
// Owned before the call: starting it cancels the previous flight (ours
|
||||
// included) synchronously, and a zero-length one completes at once.
|
||||
const flight = {};
|
||||
framing = flight;
|
||||
const release = () => {
|
||||
if (framing === flight) framing = null;
|
||||
};
|
||||
state.viewer.camera.flyToBoundingSphere(
|
||||
new Cesium.BoundingSphere(
|
||||
Cesium.Cartesian3.fromDegrees(position.lon, position.lat, ground + 2),
|
||||
4,
|
||||
),
|
||||
{
|
||||
offset: new Cesium.HeadingPitchRange(
|
||||
Cesium.Math.toRadians(bearing || 0),
|
||||
Cesium.Math.toRadians(-32),
|
||||
140,
|
||||
),
|
||||
duration: 1.6,
|
||||
complete: release,
|
||||
cancel: release,
|
||||
},
|
||||
);
|
||||
};
|
||||
// A reasserted ticket has already released the other owners.
|
||||
if (ticket.generation === null) claim(fly);
|
||||
else fly();
|
||||
}
|
||||
|
||||
/** Stop our framing flight if it is still in the air; another feature's flight is left alone. */
|
||||
function cancelFraming() {
|
||||
if (!framing) return;
|
||||
// Give up ownership first: cancelFlight delivers `cancel` synchronously.
|
||||
framing = null;
|
||||
state.viewer?.camera?.cancelFlight?.();
|
||||
}
|
||||
|
||||
function setFollow(enabled) {
|
||||
const wanted = enabled === true && state.street.followAvailable;
|
||||
if (wanted && !state.street.follow) {
|
||||
// Following owns the camera until another feature takes it.
|
||||
const granted = claim(() => {
|
||||
state.street.follow = true;
|
||||
// The framing tween would keep overwriting the follow view until it lands.
|
||||
cancelFraming();
|
||||
followCamera();
|
||||
});
|
||||
if (!granted) state.street.follow = false;
|
||||
} else if (!wanted) {
|
||||
state.street.follow = false;
|
||||
}
|
||||
state.notify?.();
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
attachNavigation(null);
|
||||
}
|
||||
|
||||
return {
|
||||
followCamera,
|
||||
beginFraming,
|
||||
lookAtPosition,
|
||||
cancelFraming,
|
||||
setFollow,
|
||||
attachNavigation,
|
||||
destroy,
|
||||
};
|
||||
}
|
||||
@@ -1,265 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createCameraFollow } from './cameraFollow.js';
|
||||
import { FOLLOW_EYE_HEIGHT_M } from './policy.js';
|
||||
|
||||
/** Height (m) of a Cartesian above the WGS84 ellipsoid. */
|
||||
const heightOf = (cartesian) =>
|
||||
Cesium.Cartographic.fromCartesian(cartesian).height;
|
||||
|
||||
/**
|
||||
* A camera with Cesium's flight bookkeeping: a new flight or `cancelFlight`
|
||||
* cancels the current one, `land()` completes it, `cancelled` counts stops.
|
||||
*/
|
||||
function flightCamera(flights, views) {
|
||||
let current = null;
|
||||
function stop() {
|
||||
const flight = current;
|
||||
current = null;
|
||||
flight?.options.cancel?.();
|
||||
return Boolean(flight);
|
||||
}
|
||||
const camera = {
|
||||
cancelled: 0,
|
||||
flyToBoundingSphere(sphere, options = {}) {
|
||||
stop();
|
||||
current = { sphere, options };
|
||||
flights.push(current);
|
||||
},
|
||||
cancelFlight() {
|
||||
if (stop()) camera.cancelled++;
|
||||
},
|
||||
land() {
|
||||
const flight = current;
|
||||
current = null;
|
||||
flight?.options.complete?.();
|
||||
},
|
||||
setView: (view) => views.push(view),
|
||||
};
|
||||
return camera;
|
||||
}
|
||||
|
||||
/**
|
||||
* A viewer whose scene sample is `sampled` (e.g. −14,886 m before the tiles
|
||||
* under the photo load) and whose bare earth is `dem`.
|
||||
*/
|
||||
function setup({ sampled, dem = null, globe = null, altitude = 149 }) {
|
||||
const flights = [];
|
||||
const views = [];
|
||||
const state = {
|
||||
services: {},
|
||||
street: {
|
||||
position: { lon: -97.7364, lat: 30.2672 },
|
||||
bearing: 105,
|
||||
tilt: -10,
|
||||
altitude,
|
||||
follow: false,
|
||||
followAvailable: true,
|
||||
},
|
||||
viewer: {
|
||||
scene: {
|
||||
sampleHeightSupported: true,
|
||||
sampleHeight: () => sampled,
|
||||
globe: { getHeight: () => globe },
|
||||
},
|
||||
camera: flightCamera(flights, views),
|
||||
},
|
||||
};
|
||||
const parts = {
|
||||
groundCaster: dem === null ? null : { groundAt: () => dem, prepare() {} },
|
||||
};
|
||||
return {
|
||||
follow: createCameraFollow({ state, parts }),
|
||||
state,
|
||||
flights,
|
||||
views,
|
||||
};
|
||||
}
|
||||
|
||||
test('framing a photo ignores a sample kilometres underground and uses bare earth', () => {
|
||||
const { follow, flights } = setup({ sampled: -14_886, dem: 117 });
|
||||
follow.lookAtPosition(follow.beginFraming());
|
||||
assert.equal(flights.length, 1);
|
||||
const centre = heightOf(flights[0].sphere.center);
|
||||
assert.ok(
|
||||
Math.abs(centre - 119) < 0.01,
|
||||
`centre at ${centre} m, not underground`,
|
||||
);
|
||||
});
|
||||
|
||||
test('a plausible mesh sample near bare earth wins (a street, or a modest roof)', () => {
|
||||
const { follow, flights } = setup({ sampled: 121, dem: 117 });
|
||||
follow.lookAtPosition(follow.beginFraming());
|
||||
assert.ok(Math.abs(heightOf(flights[0].sphere.center) - 123) < 0.01);
|
||||
});
|
||||
|
||||
test('without bare earth an absurd sample falls back to the image altitude, never below the surface range', () => {
|
||||
const { follow, flights } = setup({
|
||||
sampled: -14_886,
|
||||
dem: null,
|
||||
globe: undefined,
|
||||
altitude: 149,
|
||||
});
|
||||
follow.lookAtPosition(follow.beginFraming());
|
||||
assert.ok(Math.abs(heightOf(flights[0].sphere.center) - 151) < 0.01);
|
||||
});
|
||||
|
||||
test('following stands the camera at eye height above the checked ground', () => {
|
||||
const { follow, state, views } = setup({ sampled: -14_886, dem: 117 });
|
||||
state.street.follow = true;
|
||||
follow.followCamera();
|
||||
assert.equal(views.length, 1);
|
||||
assert.ok(
|
||||
Math.abs(heightOf(views[0].destination) - (117 + FOLLOW_EYE_HEIGHT_M)) <
|
||||
0.01,
|
||||
);
|
||||
});
|
||||
|
||||
test('cancelFraming stops the framing flight while it is still ours', () => {
|
||||
const { follow, state } = setup({ sampled: 121, dem: 117 });
|
||||
const { camera } = state.viewer;
|
||||
follow.lookAtPosition(follow.beginFraming());
|
||||
follow.cancelFraming();
|
||||
assert.equal(camera.cancelled, 1, 'the flight toward the closed photo stops');
|
||||
follow.cancelFraming();
|
||||
assert.equal(camera.cancelled, 1, 'and only once');
|
||||
|
||||
// Re-framing (the next photo) supersedes the first flight, not the claim.
|
||||
follow.lookAtPosition(follow.beginFraming());
|
||||
follow.lookAtPosition(follow.beginFraming());
|
||||
follow.cancelFraming();
|
||||
assert.equal(camera.cancelled, 2);
|
||||
});
|
||||
|
||||
test('cancelFraming leaves a landed flight and a newer navigation flight alone', () => {
|
||||
const { follow, state, flights } = setup({ sampled: 121, dem: 117 });
|
||||
const { camera } = state.viewer;
|
||||
follow.lookAtPosition(follow.beginFraming());
|
||||
camera.land();
|
||||
follow.cancelFraming();
|
||||
assert.equal(camera.cancelled, 0, 'landed: nothing to stop');
|
||||
|
||||
follow.lookAtPosition(follow.beginFraming());
|
||||
// A search result flies the globe elsewhere; Cesium cancels ours first.
|
||||
camera.flyToBoundingSphere(null, {});
|
||||
assert.equal(flights.length, 3);
|
||||
follow.cancelFraming();
|
||||
assert.equal(camera.cancelled, 0, 'the newer flight keeps going');
|
||||
});
|
||||
|
||||
test('turning FOLLOW on stops the framing flight so it cannot fight the follow view', () => {
|
||||
const { follow, state, views } = setup({ sampled: 121, dem: 117 });
|
||||
const { camera } = state.viewer;
|
||||
follow.lookAtPosition(follow.beginFraming());
|
||||
follow.setFollow(true);
|
||||
assert.equal(camera.cancelled, 1, 'the framing tween stops');
|
||||
assert.equal(views.length, 1, 'and the camera stands at the photo');
|
||||
});
|
||||
|
||||
/**
|
||||
* The application's camera authority: `run` releases tracking (here, a tracked
|
||||
* entity) and stamps a handoff before the move, or refuses like the cockpit.
|
||||
*/
|
||||
function navigationFor(state, { refuse = false } = {}) {
|
||||
const listeners = new Set();
|
||||
let generation = 0;
|
||||
const stamp = () => {
|
||||
generation++;
|
||||
for (const listener of listeners) listener({ generation });
|
||||
return generation;
|
||||
};
|
||||
const nav = {
|
||||
runs: [],
|
||||
begins: [],
|
||||
run(noun, move) {
|
||||
nav.runs.push(noun);
|
||||
if (refuse) return false;
|
||||
const current = stamp();
|
||||
state.viewer.trackedEntity = undefined;
|
||||
return move(current);
|
||||
},
|
||||
/** Deferred: stamp now, release only on a successful reassert. */
|
||||
begin(noun) {
|
||||
nav.begins.push(noun);
|
||||
return refuse ? false : stamp();
|
||||
},
|
||||
reassert(ticket) {
|
||||
if (ticket !== generation) return false;
|
||||
state.viewer.trackedEntity = undefined;
|
||||
return true;
|
||||
},
|
||||
subscribeHandoff(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
/** Another feature takes the camera. */
|
||||
handOff: () => stamp(),
|
||||
listeners,
|
||||
};
|
||||
return nav;
|
||||
}
|
||||
|
||||
test('a photo claims the camera when it starts opening and frames once loaded, releasing tracking', () => {
|
||||
const { follow, state, flights } = setup({ sampled: 160 });
|
||||
state.viewer.trackedEntity = { id: 'aircraft' };
|
||||
const nav = navigationFor(state);
|
||||
follow.attachNavigation(nav);
|
||||
const ticket = follow.beginFraming();
|
||||
assert.deepEqual(nav.begins, ['photo']);
|
||||
assert.ok(state.viewer.trackedEntity, 'nothing is released while it loads');
|
||||
follow.lookAtPosition(ticket);
|
||||
assert.equal(state.viewer.trackedEntity, undefined);
|
||||
assert.equal(flights.length, 1);
|
||||
});
|
||||
|
||||
test('a photo that finishes loading after newer navigation does not frame', () => {
|
||||
const { follow, state, flights } = setup({ sampled: 160 });
|
||||
const nav = navigationFor(state);
|
||||
follow.attachNavigation(nav);
|
||||
const ticket = follow.beginFraming();
|
||||
// The user flies to Sacramento, and then tracks an aircraft, while it loads.
|
||||
nav.handOff();
|
||||
state.viewer.trackedEntity = { id: 'aircraft' };
|
||||
follow.lookAtPosition(ticket);
|
||||
assert.equal(flights.length, 0, 'no flight back to the photo');
|
||||
assert.deepEqual(state.viewer.trackedEntity, { id: 'aircraft' });
|
||||
});
|
||||
|
||||
test('a refused claim (cockpit) neither frames the photo nor turns FOLLOW on', () => {
|
||||
const { follow, state, flights, views } = setup({ sampled: 160 });
|
||||
follow.attachNavigation(navigationFor(state, { refuse: true }));
|
||||
follow.lookAtPosition(follow.beginFraming());
|
||||
follow.setFollow(true);
|
||||
assert.equal(flights.length, 0);
|
||||
assert.equal(views.length, 0);
|
||||
assert.equal(state.street.follow, false);
|
||||
});
|
||||
|
||||
test('FOLLOW claims the camera once and ends when another feature takes it', () => {
|
||||
const { follow, state, views } = setup({ sampled: 160 });
|
||||
let notified = 0;
|
||||
state.notify = () => notified++;
|
||||
const nav = navigationFor(state);
|
||||
follow.attachNavigation(nav);
|
||||
follow.setFollow(true);
|
||||
assert.equal(state.street.follow, true, 'our own handoff keeps FOLLOW on');
|
||||
assert.deepEqual(nav.runs, ['photo']);
|
||||
follow.followCamera();
|
||||
assert.deepEqual(nav.runs, ['photo'], 'pose updates do not claim again');
|
||||
assert.equal(views.length, 2);
|
||||
nav.handOff();
|
||||
assert.equal(state.street.follow, false);
|
||||
assert.ok(notified >= 2);
|
||||
follow.followCamera();
|
||||
assert.equal(views.length, 2, 'the camera is no longer driven');
|
||||
});
|
||||
|
||||
test('destroying the follow part stops listening for handoffs', () => {
|
||||
const { follow, state } = setup({ sampled: 160 });
|
||||
const nav = navigationFor(state);
|
||||
follow.attachNavigation(nav);
|
||||
assert.equal(nav.listeners.size, 1);
|
||||
follow.destroy();
|
||||
assert.equal(nav.listeners.size, 0);
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { plausibleSurfaceHeight, sampledSurfaceHeight } from './view.js';
|
||||
|
||||
/** Fly the globe camera once to frame a photo after it opens. */
|
||||
export function createCameraFraming({ state }) {
|
||||
/**
|
||||
* The application's camera authority: `reassert(begin())` succeeds only if
|
||||
* nothing newer took the camera. Without it, framing flies directly.
|
||||
*/
|
||||
let navigation = null;
|
||||
/** Our framing flight while it is current; Cesium calls `cancel` when another flight starts. */
|
||||
let flight = null;
|
||||
|
||||
function attachNavigation(next) {
|
||||
navigation = next || null;
|
||||
}
|
||||
|
||||
/** Ask for the camera as a photo starts opening: a ticket for `frame`, null when refused (cockpit). */
|
||||
function begin() {
|
||||
if (!navigation?.begin || !navigation.reassert) return { generation: null };
|
||||
const generation = navigation.begin('photo');
|
||||
return generation === false ? null : { generation };
|
||||
}
|
||||
|
||||
/**
|
||||
* Ground under a photo. `sampleHeight` can return kilometres below the
|
||||
* ellipsoid before tiles load, so only a plausible height counts.
|
||||
*/
|
||||
function groundHeightAt(lon, lat, fallback) {
|
||||
const scene = state.viewer?.scene;
|
||||
const carto = Cesium.Cartographic.fromDegrees(lon, lat);
|
||||
let height = sampledSurfaceHeight(scene, carto);
|
||||
if (height === null) height = scene?.globe?.getHeight?.(carto);
|
||||
if (!plausibleSurfaceHeight(height)) height = fallback;
|
||||
return plausibleSurfaceHeight(height) ? height : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Frame the open photo from a short distance behind it, unless another
|
||||
* action took the camera since `ticket` (from `begin`) was issued.
|
||||
*/
|
||||
function frame(ticket) {
|
||||
const { position, bearing, altitude } = state.street;
|
||||
if (!ticket || !position || !state.viewer) return;
|
||||
// Detached (shell teardown) since the ticket was issued: nothing to frame for.
|
||||
if (ticket.generation !== null && !navigation?.reassert(ticket.generation))
|
||||
return;
|
||||
const ground = groundHeightAt(position.lon, position.lat, altitude);
|
||||
// Owned before the call: starting it cancels the previous flight (ours
|
||||
// included) synchronously, and a zero-length one completes at once.
|
||||
const current = {};
|
||||
flight = current;
|
||||
const release = () => {
|
||||
if (flight === current) flight = null;
|
||||
};
|
||||
state.viewer.camera.flyToBoundingSphere(
|
||||
new Cesium.BoundingSphere(
|
||||
Cesium.Cartesian3.fromDegrees(position.lon, position.lat, ground + 2),
|
||||
4,
|
||||
),
|
||||
{
|
||||
offset: new Cesium.HeadingPitchRange(
|
||||
Cesium.Math.toRadians(bearing || 0),
|
||||
Cesium.Math.toRadians(-32),
|
||||
140,
|
||||
),
|
||||
duration: 1.6,
|
||||
complete: release,
|
||||
cancel: release,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** Stop our framing flight if it is still in the air; another feature's flight is left alone. */
|
||||
function cancel() {
|
||||
if (!flight) return;
|
||||
// Give up ownership first: cancelFlight delivers `cancel` synchronously.
|
||||
flight = null;
|
||||
state.viewer?.camera?.cancelFlight?.();
|
||||
}
|
||||
|
||||
return { attachNavigation, begin, frame, cancel };
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createCameraFraming } from './cameraFraming.js';
|
||||
import { fakeCesiumViewer } from '../../testSupport/streetLevelFakes.mjs';
|
||||
|
||||
/** Height (m) of a Cartesian above the WGS84 ellipsoid. */
|
||||
const heightOf = (cartesian) =>
|
||||
Cesium.Cartographic.fromCartesian(cartesian).height;
|
||||
|
||||
/**
|
||||
* A viewer whose scene sample is `sampled` (e.g. −14,886 m before the tiles
|
||||
* under the photo load) and whose globe terrain is `globe`.
|
||||
*/
|
||||
function setup({ sampled, globe = null, altitude = 149 }) {
|
||||
const viewer = fakeCesiumViewer();
|
||||
Object.assign(viewer.scene, {
|
||||
sampleHeightSupported: true,
|
||||
sampleHeight: () => sampled,
|
||||
globe: { getHeight: () => globe },
|
||||
});
|
||||
const state = {
|
||||
services: {},
|
||||
street: {
|
||||
position: { lon: -97.7364, lat: 30.2672 },
|
||||
bearing: 105,
|
||||
altitude,
|
||||
},
|
||||
viewer,
|
||||
};
|
||||
return {
|
||||
framing: createCameraFraming({ state }),
|
||||
state,
|
||||
viewer,
|
||||
flights: viewer.flights.list,
|
||||
};
|
||||
}
|
||||
|
||||
test('framing a photo stands the sphere on a plausible scene sample', () => {
|
||||
const { framing, flights } = setup({ sampled: 121 });
|
||||
framing.frame(framing.begin());
|
||||
assert.equal(flights.length, 1);
|
||||
assert.ok(Math.abs(heightOf(flights[0].sphere.center) - 123) < 0.01);
|
||||
});
|
||||
|
||||
test('a sample kilometres underground falls back to the globe, then the image altitude', () => {
|
||||
const globe = setup({ sampled: -14_886, globe: 117 });
|
||||
globe.framing.frame(globe.framing.begin());
|
||||
assert.ok(Math.abs(heightOf(globe.flights[0].sphere.center) - 119) < 0.01);
|
||||
const altitude = setup({
|
||||
sampled: -14_886,
|
||||
globe: undefined,
|
||||
altitude: 149,
|
||||
});
|
||||
altitude.framing.frame(altitude.framing.begin());
|
||||
assert.ok(Math.abs(heightOf(altitude.flights[0].sphere.center) - 151) < 0.01);
|
||||
});
|
||||
|
||||
test('cancel stops the framing flight while it is still ours', () => {
|
||||
const { framing, viewer } = setup({ sampled: 121 });
|
||||
framing.frame(framing.begin());
|
||||
framing.cancel();
|
||||
assert.equal(
|
||||
viewer.flights.cancelled,
|
||||
1,
|
||||
'the flight toward the photo stops',
|
||||
);
|
||||
framing.cancel();
|
||||
assert.equal(viewer.flights.cancelled, 1, 'and only once');
|
||||
|
||||
// Re-framing (the next photo) supersedes the first flight, not the claim.
|
||||
framing.frame(framing.begin());
|
||||
framing.frame(framing.begin());
|
||||
framing.cancel();
|
||||
assert.equal(viewer.flights.cancelled, 2);
|
||||
});
|
||||
|
||||
test('cancel leaves a landed flight and a newer navigation flight alone', () => {
|
||||
const { framing, viewer, flights } = setup({ sampled: 121 });
|
||||
framing.frame(framing.begin());
|
||||
viewer.flights.land();
|
||||
framing.cancel();
|
||||
assert.equal(viewer.flights.cancelled, 0, 'landed: nothing to stop');
|
||||
|
||||
framing.frame(framing.begin());
|
||||
// A search result flies the globe elsewhere; Cesium cancels ours first.
|
||||
viewer.camera.flyToBoundingSphere(null, {});
|
||||
assert.equal(flights.length, 3);
|
||||
framing.cancel();
|
||||
assert.equal(viewer.flights.cancelled, 0, 'the newer flight keeps going');
|
||||
});
|
||||
|
||||
/**
|
||||
* The application's camera authority: `begin` stamps a generation (or refuses
|
||||
* like the cockpit), `reassert` releases tracking (here, a tracked entity)
|
||||
* only while that generation is current.
|
||||
*/
|
||||
function navigationFor(state, { refuse = false } = {}) {
|
||||
let generation = 0;
|
||||
const stamp = () => ++generation;
|
||||
const nav = {
|
||||
begins: [],
|
||||
/** Deferred: stamp now, release only on a successful reassert. */
|
||||
begin(noun) {
|
||||
nav.begins.push(noun);
|
||||
return refuse ? false : stamp();
|
||||
},
|
||||
reassert(ticket) {
|
||||
if (ticket !== generation) return false;
|
||||
state.viewer.trackedEntity = undefined;
|
||||
return true;
|
||||
},
|
||||
/** Another feature takes the camera. */
|
||||
handOff: () => stamp(),
|
||||
};
|
||||
return nav;
|
||||
}
|
||||
|
||||
test('a photo claims the camera when it starts opening and frames once loaded, releasing tracking', () => {
|
||||
const { framing, state, flights } = setup({ sampled: 160 });
|
||||
state.viewer.trackedEntity = { id: 'aircraft' };
|
||||
const nav = navigationFor(state);
|
||||
framing.attachNavigation(nav);
|
||||
const ticket = framing.begin();
|
||||
assert.deepEqual(nav.begins, ['photo']);
|
||||
assert.ok(state.viewer.trackedEntity, 'nothing is released while it loads');
|
||||
framing.frame(ticket);
|
||||
assert.equal(state.viewer.trackedEntity, undefined);
|
||||
assert.equal(flights.length, 1);
|
||||
});
|
||||
|
||||
test('a photo that finishes loading after newer navigation does not frame', () => {
|
||||
const { framing, state, flights } = setup({ sampled: 160 });
|
||||
const nav = navigationFor(state);
|
||||
framing.attachNavigation(nav);
|
||||
const ticket = framing.begin();
|
||||
// The user flies to Sacramento, and then tracks an aircraft, while it loads.
|
||||
nav.handOff();
|
||||
state.viewer.trackedEntity = { id: 'aircraft' };
|
||||
framing.frame(ticket);
|
||||
assert.equal(flights.length, 0, 'no flight back to the photo');
|
||||
assert.deepEqual(state.viewer.trackedEntity, { id: 'aircraft' });
|
||||
});
|
||||
|
||||
test('a refused claim (cockpit) does not frame the photo', () => {
|
||||
const { framing, state, flights } = setup({ sampled: 160 });
|
||||
framing.attachNavigation(navigationFor(state, { refuse: true }));
|
||||
framing.frame(framing.begin());
|
||||
assert.equal(flights.length, 0);
|
||||
});
|
||||
|
||||
test('a photo that loads after the navigation was detached does not frame', () => {
|
||||
const { framing, state, flights } = setup({ sampled: 160 });
|
||||
framing.attachNavigation(navigationFor(state));
|
||||
const ticket = framing.begin();
|
||||
framing.attachNavigation(null);
|
||||
assert.doesNotThrow(() => framing.frame(ticket));
|
||||
assert.equal(flights.length, 0);
|
||||
});
|
||||
@@ -27,7 +27,7 @@ export function sameFilter(a, b) {
|
||||
|
||||
/**
|
||||
* Stored filter (relative days, stable in share links) to the absolute form
|
||||
* providers compare capture times against.
|
||||
* coverage, cones and lookups compare capture times against.
|
||||
* @returns {{pano: string, sinceMs: number|null}}
|
||||
*/
|
||||
export function resolveFilter(filter, now = Date.now()) {
|
||||
|
||||
@@ -1,48 +1,27 @@
|
||||
/** Canvas billboard glyphs (image cone, pano ring, position marker); no Cesium needed. */
|
||||
/** Billboard glyphs as SVG data URLs: the image cone, the 360° ring and the position markers. */
|
||||
|
||||
const _cache = new Map();
|
||||
const px = (n) => Number(n.toFixed(2));
|
||||
|
||||
/** A square canvas glyph drawn once per key, centred at (c, c). */
|
||||
function cachedGlyph(key, size, draw) {
|
||||
if (_cache.has(key)) return _cache.get(key);
|
||||
const element = document.createElement('canvas');
|
||||
element.width = size;
|
||||
element.height = size;
|
||||
draw(element.getContext('2d'), size / 2);
|
||||
_cache.set(key, element);
|
||||
return element;
|
||||
function svg(size, body) {
|
||||
const markup = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">${body}</svg>`;
|
||||
return `data:image/svg+xml;utf8,${encodeURIComponent(markup)}`;
|
||||
}
|
||||
|
||||
/** A filled wedge from the centre, pointing "up", `halfAngle` radians wide. */
|
||||
function wedge(ctx, c, radius, halfAngle, color, alpha) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(c, c);
|
||||
ctx.arc(c, c, radius, -Math.PI / 2 - halfAngle, -Math.PI / 2 + halfAngle);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = color;
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.fill();
|
||||
ctx.globalAlpha = 1;
|
||||
function wedge(size, radius, halfAngle, color, opacity) {
|
||||
const c = size / 2;
|
||||
const dx = px(radius * Math.sin(halfAngle));
|
||||
const dy = px(radius * Math.cos(halfAngle));
|
||||
const r = px(radius);
|
||||
return `<path d="M${c} ${c}L${px(c - dx)} ${px(c - dy)}A${r} ${r} 0 0 1 ${px(c + dx)} ${px(c - dy)}Z" fill="${color}" fill-opacity="${opacity}"/>`;
|
||||
}
|
||||
|
||||
function ring(ctx, c, radius, color, width, alpha) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(c, c, radius, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = width;
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.stroke();
|
||||
ctx.globalAlpha = 1;
|
||||
function ring(size, radius, color, width, opacity) {
|
||||
return `<circle cx="${size / 2}" cy="${size / 2}" r="${px(radius)}" fill="none" stroke="${color}" stroke-width="${px(width)}" stroke-opacity="${opacity}"/>`;
|
||||
}
|
||||
|
||||
function dot(ctx, c, radius, color, outline, width) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(c, c, radius, 0, Math.PI * 2);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fill();
|
||||
ctx.lineWidth = width;
|
||||
ctx.strokeStyle = outline;
|
||||
ctx.stroke();
|
||||
function dot(size, radius, color, outline, width) {
|
||||
return `<circle cx="${size / 2}" cy="${size / 2}" r="${px(radius)}" fill="${color}" stroke="${outline}" stroke-width="${px(width)}"/>`;
|
||||
}
|
||||
|
||||
/** Cone glyph pointing "up"; rotate the billboard by the compass angle. */
|
||||
@@ -51,34 +30,38 @@ export function imageConeGlyph({
|
||||
color = '#e8eaed',
|
||||
pano = false,
|
||||
} = {}) {
|
||||
return cachedGlyph(`cone:${size}:${color}:${pano}`, size, (ctx, c) => {
|
||||
if (pano) ring(ctx, c, size * 0.4, color, Math.max(1.5, size * 0.09), 0.5);
|
||||
else wedge(ctx, c, size * 0.46, 0.62, color, 0.42);
|
||||
dot(
|
||||
ctx,
|
||||
c,
|
||||
size * 0.16,
|
||||
color,
|
||||
'rgba(10,10,15,0.85)',
|
||||
Math.max(1, size * 0.05),
|
||||
);
|
||||
});
|
||||
const shape = pano
|
||||
? ring(size, size * 0.4, color, Math.max(1.5, size * 0.09), 0.5)
|
||||
: wedge(size, size * 0.46, 0.62, color, 0.42);
|
||||
return svg(
|
||||
size,
|
||||
shape +
|
||||
dot(
|
||||
size,
|
||||
size * 0.16,
|
||||
color,
|
||||
'rgba(10,10,15,0.85)',
|
||||
Math.max(1, size * 0.05),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** Street View-style marker: a field-of-view cone from a white-ringed dot. */
|
||||
export function viewConeMarkerGlyph({ size = 56, color = '#4285f4' } = {}) {
|
||||
return cachedGlyph(`view-cone:${size}:${color}`, size, (ctx, c) => {
|
||||
wedge(ctx, c, size * 0.48, 0.62, color, 0.38);
|
||||
wedge(ctx, c, size * 0.3, 0.62, color, 0.3);
|
||||
dot(ctx, c, size * 0.13, color, '#ffffff', Math.max(2, size * 0.05));
|
||||
});
|
||||
return svg(
|
||||
size,
|
||||
wedge(size, size * 0.48, 0.62, color, 0.38) +
|
||||
wedge(size, size * 0.3, 0.62, color, 0.3) +
|
||||
dot(size, size * 0.13, color, '#ffffff', Math.max(2, size * 0.05)),
|
||||
);
|
||||
}
|
||||
|
||||
/** Marker for the image the viewer currently shows. */
|
||||
export function positionMarkerGlyph({ size = 44, color = '#ffb300' } = {}) {
|
||||
return cachedGlyph(`pos:${size}:${color}`, size, (ctx, c) => {
|
||||
wedge(ctx, c, size * 0.48, 0.55, color, 0.55);
|
||||
dot(ctx, c, size * 0.2, color, '#0a0a0f', 2);
|
||||
ring(ctx, c, size * 0.3, color, 1.5, 0.8);
|
||||
});
|
||||
return svg(
|
||||
size,
|
||||
wedge(size, size * 0.48, 0.55, color, 0.55) +
|
||||
dot(size, size * 0.2, color, '#0a0a0f', 2) +
|
||||
ring(size, size * 0.3, color, 1.5, 0.8),
|
||||
);
|
||||
}
|
||||
@@ -1,256 +0,0 @@
|
||||
/**
|
||||
* Ground casting for street-level overlays on Google 3D. Draped overlays land
|
||||
* on the mesh top (trees, overpasses), so casting places them at bare-earth
|
||||
* (DEM) height plus a lift and lets the mesh hide what is behind buildings.
|
||||
* Heights come from a coarse grid shared with the ground-floor service; a
|
||||
* geoid fallback (terrain proxy down) counts as unknown and stays draped.
|
||||
*/
|
||||
|
||||
import { wrapLon } from './tileMath.js';
|
||||
|
||||
/** Grid spacing in degrees (~111 m north-south): the ground-floor grid. */
|
||||
export const GROUND_CAST_STEP_DEG = 0.001;
|
||||
/** Longest line segment left undivided, in degrees, so lines follow the grid. */
|
||||
export const GROUND_CAST_DENSIFY_DEG = 0.0005;
|
||||
/** Metres above the bare earth, so lines sit on the road instead of in it. */
|
||||
export const GROUND_CAST_LIFT_M = 2;
|
||||
/** Grid corners one prepare call may request; more stays draped. */
|
||||
export const GROUND_CAST_MAX_CORNERS = 1024;
|
||||
/** Cached grid corners before the oldest are dropped to make room. */
|
||||
const GROUND_CAST_CACHE_MAX = 50_000;
|
||||
|
||||
/**
|
||||
* Mesh refinement, where the Google 3D surface was sampled (the DEM grid
|
||||
* misses trenches, steep streets and roads under trees):
|
||||
* - mesh up to MESH_ROAD_ABOVE_M above bare earth is the road: follow it;
|
||||
* - higher mesh is over the road (canopy, deck): interpolate the road's
|
||||
* offset from the nearest road samples on both sides;
|
||||
* - otherwise bare earth plus GROUND_CAST_LIFT_M.
|
||||
*/
|
||||
export const MESH_ROAD_ABOVE_M = 3;
|
||||
/** A sample this far below bare earth is a bad probe, not a road. */
|
||||
export const MESH_ROAD_BELOW_M = 30;
|
||||
/** Metres above the sampled road surface. */
|
||||
export const MESH_LIFT_M = 1;
|
||||
/** Longest run of covered points bridged from the road on either side. */
|
||||
export const MESH_BRIDGE_MAX_POINTS = 8;
|
||||
/** Line spacing, in degrees (~22 m), while the mesh refines heights. */
|
||||
export const MESH_DENSIFY_DEG = 0.0002;
|
||||
|
||||
/**
|
||||
* Heights for a line's points by the rule above.
|
||||
* @param {Array<{dem: number, mesh?: number|null}>} points
|
||||
* @param {{lift?: number}} [options]
|
||||
* @returns {Array<number>}
|
||||
*/
|
||||
export function refineHeights(points, { lift = GROUND_CAST_LIFT_M } = {}) {
|
||||
const road = points.map(({ dem, mesh }) =>
|
||||
Number.isFinite(mesh) &&
|
||||
mesh <= dem + MESH_ROAD_ABOVE_M &&
|
||||
mesh >= dem - MESH_ROAD_BELOW_M
|
||||
? mesh - dem
|
||||
: null,
|
||||
);
|
||||
return points.map(({ dem, mesh }, i) => {
|
||||
if (road[i] !== null) return dem + road[i] + MESH_LIFT_M;
|
||||
if (Number.isFinite(mesh) && mesh > dem + MESH_ROAD_ABOVE_M) {
|
||||
let a = i - 1;
|
||||
while (a >= 0 && i - a <= MESH_BRIDGE_MAX_POINTS && road[a] === null) a--;
|
||||
let b = i + 1;
|
||||
while (
|
||||
b < points.length &&
|
||||
b - i <= MESH_BRIDGE_MAX_POINTS &&
|
||||
road[b] === null
|
||||
)
|
||||
b++;
|
||||
const left = a >= 0 && i - a <= MESH_BRIDGE_MAX_POINTS ? road[a] : null;
|
||||
const right =
|
||||
b < points.length && b - i <= MESH_BRIDGE_MAX_POINTS ? road[b] : null;
|
||||
if (left !== null && right !== null) {
|
||||
const t = (i - a) / (b - a);
|
||||
return dem + left + (right - left) * t + MESH_LIFT_M;
|
||||
}
|
||||
}
|
||||
return dem + lift;
|
||||
});
|
||||
}
|
||||
|
||||
/** Camera height (m above ground) below which Google 3D switches to terrain mode. */
|
||||
export const SURFACE_TERRAIN_ENTER_M = 1400;
|
||||
/** Camera height above which terrain mode switches back to draped. */
|
||||
export const SURFACE_TERRAIN_EXIT_M = 1800;
|
||||
|
||||
/**
|
||||
* Next surface mode: terrain only on Google 3D at street zoom, with
|
||||
* hysteresis between the enter and exit heights.
|
||||
* @param {'draped'|'terrain'} current
|
||||
* @param {{photoreal: boolean, heightM: number|null, available: boolean}} view
|
||||
* @returns {'draped'|'terrain'}
|
||||
*/
|
||||
export function nextSurfaceMode(current, { photoreal, heightM, available }) {
|
||||
if (!available || !photoreal || !Number.isFinite(heightM)) return 'draped';
|
||||
if (current === 'terrain')
|
||||
return heightM > SURFACE_TERRAIN_EXIT_M ? 'draped' : 'terrain';
|
||||
return heightM < SURFACE_TERRAIN_ENTER_M ? 'terrain' : 'draped';
|
||||
}
|
||||
|
||||
/**
|
||||
* Split long segments so a line follows the terrain. Segments across ±180°
|
||||
* go the short way round, with added points wrapped into [-180, 180].
|
||||
* @param {Array<[number, number]>} coords [lon, lat] pairs
|
||||
* @param {number} [maxStep] longest segment, in degrees
|
||||
* @returns {Array<[number, number]>}
|
||||
*/
|
||||
export function densifyLine(coords, maxStep = GROUND_CAST_DENSIFY_DEG) {
|
||||
if (!Array.isArray(coords) || coords.length < 2) return coords || [];
|
||||
const out = [coords[0]];
|
||||
for (let i = 1; i < coords.length; i++) {
|
||||
const [lon0, lat0] = coords[i - 1];
|
||||
const [lon1, lat1] = coords[i];
|
||||
const dLon = wrapLon(lon1 - lon0);
|
||||
const span = Math.max(Math.abs(dLon), Math.abs(lat1 - lat0));
|
||||
const parts = Math.min(64, Math.ceil(span / maxStep - 1e-9));
|
||||
for (let k = 1; k < parts; k++) {
|
||||
const t = k / parts;
|
||||
out.push([wrapLon(lon0 + dLon * t), lat0 + (lat1 - lat0) * t]);
|
||||
}
|
||||
out.push(coords[i]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* A bare-earth height cache over the terrain service.
|
||||
* @param {{terrain: {resolveEllipsoidalGround: Function}, step?: number, lift?: number, maxCorners?: number, cacheMax?: number}} options
|
||||
*/
|
||||
export function createGroundCaster({
|
||||
terrain,
|
||||
step = GROUND_CAST_STEP_DEG,
|
||||
lift = GROUND_CAST_LIFT_M,
|
||||
maxCorners = GROUND_CAST_MAX_CORNERS,
|
||||
cacheMax = GROUND_CAST_CACHE_MAX,
|
||||
}) {
|
||||
if (typeof terrain?.resolveEllipsoidalGround !== 'function')
|
||||
throw new TypeError('Ground casting requires a terrain service');
|
||||
/** "i,j" grid corner → ellipsoidal ground height in metres. */
|
||||
const heights = new Map();
|
||||
/** Prepares run one at a time, so neighbouring tiles share their corners. */
|
||||
let queue = Promise.resolve();
|
||||
|
||||
const cornerKey = (i, j) => `${i},${j}`;
|
||||
|
||||
function missingCorners(points) {
|
||||
const missing = new Map();
|
||||
for (const point of points) {
|
||||
const [lon, lat] = point;
|
||||
if (!Number.isFinite(lon) || !Number.isFinite(lat)) continue;
|
||||
const i0 = Math.floor(lon / step);
|
||||
const j0 = Math.floor(lat / step);
|
||||
for (let di = 0; di <= 1; di++)
|
||||
for (let dj = 0; dj <= 1; dj++) {
|
||||
const key = cornerKey(i0 + di, j0 + dj);
|
||||
if (!heights.has(key) && !missing.has(key))
|
||||
missing.set(key, { i: i0 + di, j: j0 + dj });
|
||||
}
|
||||
}
|
||||
return [...missing.entries()];
|
||||
}
|
||||
|
||||
async function resolve(points, signal) {
|
||||
if (signal?.aborted) return false;
|
||||
// Evict before counting, or we could drop corners this request counted
|
||||
// as cached. Oldest first, so the tiles still on screen keep theirs.
|
||||
for (const key of heights.keys()) {
|
||||
if (heights.size + maxCorners <= cacheMax) break;
|
||||
heights.delete(key);
|
||||
}
|
||||
const missing = missingCorners(points);
|
||||
if (!missing.length) return true;
|
||||
if (missing.length > maxCorners) return false;
|
||||
// A corner east of 180° is asked for at its wrapped longitude.
|
||||
const results = await terrain.resolveEllipsoidalGround(
|
||||
missing.map(([, { i, j }]) => ({
|
||||
lon: Number(wrapLon(i * step).toFixed(6)),
|
||||
lat: Number((j * step).toFixed(6)),
|
||||
})),
|
||||
{ signal },
|
||||
);
|
||||
let complete = true;
|
||||
missing.forEach(([key], n) => {
|
||||
const result = results?.[n];
|
||||
if (result?.source === 'reearth' && Number.isFinite(result.ellipsoid))
|
||||
heights.set(key, result.ellipsoid);
|
||||
else complete = false;
|
||||
});
|
||||
return complete && !signal?.aborted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the grid corners around the points. True when all can be cast;
|
||||
* false when some are unknown, too many, or aborted. Never rejects.
|
||||
* @param {Array<[number, number]>} points [lon, lat] pairs
|
||||
* @param {{signal?: AbortSignal}} [options]
|
||||
*/
|
||||
function prepare(points, { signal } = {}) {
|
||||
const run = queue.then(() => resolve(points, signal)).catch(() => false);
|
||||
queue = run;
|
||||
return run;
|
||||
}
|
||||
|
||||
/**
|
||||
* `prepare` for lines at both densities: the finer mesh points can fall in
|
||||
* cells the coarse ones skip, and a missing cell leaves the line draped.
|
||||
*/
|
||||
function prepareLines(lines, options) {
|
||||
return prepare(
|
||||
lines.flatMap((coords) => [
|
||||
...densifyLine(coords),
|
||||
...densifyLine(coords, MESH_DENSIFY_DEG),
|
||||
]),
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
/** Bare-earth ellipsoidal height at a point, from cached corners only; null when unknown. */
|
||||
function groundAt(lon, lat) {
|
||||
const x = lon / step;
|
||||
const y = lat / step;
|
||||
const i0 = Math.floor(x);
|
||||
const j0 = Math.floor(y);
|
||||
const h00 = heights.get(cornerKey(i0, j0));
|
||||
const h10 = heights.get(cornerKey(i0 + 1, j0));
|
||||
const h01 = heights.get(cornerKey(i0, j0 + 1));
|
||||
const h11 = heights.get(cornerKey(i0 + 1, j0 + 1));
|
||||
if (h00 === undefined || h10 === undefined) return null;
|
||||
if (h01 === undefined || h11 === undefined) return null;
|
||||
const fx = x - i0;
|
||||
const fy = y - j0;
|
||||
const south = h00 + (h10 - h00) * fx;
|
||||
const north = h01 + (h11 - h01) * fx;
|
||||
return south + (north - south) * fy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Densified line as flat [lon, lat, height, ...]; null until every corner is
|
||||
* cached. With `meshAt` it is densified finer and refined by `refineHeights`.
|
||||
* @param {Array<[number, number]>} coords
|
||||
* @param {{meshAt?: (lon: number, lat: number) => number|null|undefined}} [options]
|
||||
*/
|
||||
function castLine(coords, { meshAt = null } = {}) {
|
||||
const rows = [];
|
||||
const points = meshAt
|
||||
? densifyLine(coords, MESH_DENSIFY_DEG)
|
||||
: densifyLine(coords);
|
||||
for (const [lon, lat] of points) {
|
||||
const dem = groundAt(lon, lat);
|
||||
if (dem === null) return null;
|
||||
rows.push({ lon, lat, dem, mesh: meshAt ? meshAt(lon, lat) : null });
|
||||
}
|
||||
const heights = refineHeights(rows, { lift });
|
||||
const flat = [];
|
||||
rows.forEach(({ lon, lat }, i) => flat.push(lon, lat, heights[i]));
|
||||
return flat;
|
||||
}
|
||||
|
||||
return { prepare, prepareLines, groundAt, castLine };
|
||||
}
|
||||
@@ -1,370 +0,0 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
GROUND_CAST_LIFT_M,
|
||||
MESH_LIFT_M,
|
||||
refineHeights,
|
||||
SURFACE_TERRAIN_ENTER_M,
|
||||
SURFACE_TERRAIN_EXIT_M,
|
||||
createGroundCaster,
|
||||
densifyLine,
|
||||
nextSurfaceMode,
|
||||
} from './groundCast.js';
|
||||
|
||||
/** A terrain service whose ground rises 1 m per 0.001° east and 2 m per 0.001° north. */
|
||||
function fakeTerrain({ source = 'reearth', fail = false } = {}) {
|
||||
const calls = [];
|
||||
return {
|
||||
calls,
|
||||
async resolveEllipsoidalGround(coords) {
|
||||
calls.push(coords);
|
||||
if (fail) throw new Error('proxy down');
|
||||
return coords.map(({ lon, lat }) => ({
|
||||
ellipsoid: 100 + (lon - 10) * 1000 + (lat - 50) * 2000,
|
||||
source,
|
||||
}));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('surface mode: terrain only on Google 3D at street zoom, with hysteresis', () => {
|
||||
const view = { photoreal: true, available: true };
|
||||
assert.equal(nextSurfaceMode('draped', { ...view, heightM: 500 }), 'terrain');
|
||||
assert.equal(
|
||||
nextSurfaceMode('draped', {
|
||||
...view,
|
||||
heightM: (SURFACE_TERRAIN_ENTER_M + SURFACE_TERRAIN_EXIT_M) / 2,
|
||||
}),
|
||||
'draped',
|
||||
);
|
||||
assert.equal(
|
||||
nextSurfaceMode('terrain', {
|
||||
...view,
|
||||
heightM: (SURFACE_TERRAIN_ENTER_M + SURFACE_TERRAIN_EXIT_M) / 2,
|
||||
}),
|
||||
'terrain',
|
||||
);
|
||||
assert.equal(
|
||||
nextSurfaceMode('terrain', {
|
||||
...view,
|
||||
heightM: SURFACE_TERRAIN_EXIT_M + 1,
|
||||
}),
|
||||
'draped',
|
||||
);
|
||||
assert.equal(
|
||||
nextSurfaceMode('terrain', { ...view, photoreal: false, heightM: 200 }),
|
||||
'draped',
|
||||
);
|
||||
assert.equal(
|
||||
nextSurfaceMode('terrain', { ...view, available: false, heightM: 200 }),
|
||||
'draped',
|
||||
);
|
||||
assert.equal(
|
||||
nextSurfaceMode('terrain', { ...view, heightM: null }),
|
||||
'draped',
|
||||
);
|
||||
});
|
||||
|
||||
test('densifyLine splits long segments and keeps the vertices', () => {
|
||||
const line = [
|
||||
[10, 50],
|
||||
[10.002, 50],
|
||||
];
|
||||
const dense = densifyLine(line, 0.0005);
|
||||
assert.equal(dense.length, 5);
|
||||
assert.deepEqual(dense[0], [10, 50]);
|
||||
assert.deepEqual(dense.at(-1), [10.002, 50]);
|
||||
assert.ok(Math.abs(dense[2][0] - 10.001) < 1e-9);
|
||||
const short = [
|
||||
[10, 50],
|
||||
[10.0001, 50],
|
||||
];
|
||||
assert.deepEqual(densifyLine(short, 0.0005), short);
|
||||
assert.deepEqual(densifyLine([[10, 50]]), [[10, 50]]);
|
||||
});
|
||||
|
||||
test('densifyLine crosses the date line the short way and wraps what it adds', () => {
|
||||
// 0.0002° apart: short enough to stay one segment, not 65 points via 0°.
|
||||
const short = [
|
||||
[179.9999, 0],
|
||||
[-179.9999, 0],
|
||||
];
|
||||
assert.deepEqual(densifyLine(short), short);
|
||||
const line = [
|
||||
[179.999, 50.0005],
|
||||
[-179.999, 50.0005],
|
||||
];
|
||||
const dense = densifyLine(line, 0.0005);
|
||||
assert.equal(dense.length, 5);
|
||||
assert.deepEqual(dense[0], line[0]);
|
||||
assert.deepEqual(dense.at(-1), line[1]);
|
||||
for (const [lon] of dense)
|
||||
assert.ok(lon >= -180 && lon <= 180 && Math.abs(lon) >= 179.999, `${lon}`);
|
||||
assert.ok(Math.abs(dense[1][0] - 179.9995) < 1e-9);
|
||||
assert.ok(Math.abs(Math.abs(dense[2][0]) - 180) < 1e-9);
|
||||
assert.ok(Math.abs(dense[3][0] - -179.9995) < 1e-9);
|
||||
// Westward across the date line too.
|
||||
const back = densifyLine([...line].reverse(), 0.0005);
|
||||
assert.equal(back.length, 5);
|
||||
assert.ok(Math.abs(back[1][0] - -179.9995) < 1e-9);
|
||||
});
|
||||
|
||||
test('the caster interpolates cached grid corners and adds the lift', async () => {
|
||||
const terrain = fakeTerrain();
|
||||
const caster = createGroundCaster({ terrain, step: 0.001 });
|
||||
assert.equal(caster.groundAt(10.0005, 50.0005), null);
|
||||
assert.equal(await caster.prepare([[10.0005, 50.0005]]), true);
|
||||
assert.equal(terrain.calls.length, 1);
|
||||
assert.equal(terrain.calls[0].length, 4);
|
||||
// Centre of the cell: mean of 100, 101, 102, 103.
|
||||
assert.ok(Math.abs(caster.groundAt(10.0005, 50.0005) - 101.5) < 1e-6);
|
||||
// A cast line sits the lift above it.
|
||||
const point = [10.0005, 50.0005];
|
||||
const [, , height] = caster.castLine([point, point]);
|
||||
assert.ok(Math.abs(height - (101.5 + GROUND_CAST_LIFT_M)) < 1e-6);
|
||||
// Same cell again: nothing to fetch.
|
||||
assert.equal(await caster.prepare([[10.0002, 50.0008]]), true);
|
||||
assert.equal(terrain.calls.length, 1);
|
||||
});
|
||||
|
||||
test('castLine returns lon, lat, height triples or null when a corner is missing', async () => {
|
||||
const caster = createGroundCaster({ terrain: fakeTerrain(), step: 0.001 });
|
||||
const line = [
|
||||
[10.0001, 50.0001],
|
||||
[10.0009, 50.0001],
|
||||
];
|
||||
assert.equal(caster.castLine(line), null);
|
||||
await caster.prepareLines([line]);
|
||||
const flat = caster.castLine(line);
|
||||
assert.equal(flat.length % 3, 0);
|
||||
assert.ok(flat.length >= 6);
|
||||
assert.equal(flat[0], 10.0001);
|
||||
assert.equal(flat[1], 50.0001);
|
||||
assert.ok(flat[2] > 100 && flat[2] < 110);
|
||||
});
|
||||
|
||||
test('a geoid fallback or a failing proxy leaves the heights unknown', async () => {
|
||||
const fallback = createGroundCaster({
|
||||
terrain: fakeTerrain({ source: 'geoid-fallback' }),
|
||||
});
|
||||
assert.equal(await fallback.prepare([[10.0005, 50.0005]]), false);
|
||||
assert.equal(fallback.groundAt(10.0005, 50.0005), null);
|
||||
|
||||
const down = createGroundCaster({ terrain: fakeTerrain({ fail: true }) });
|
||||
assert.equal(await down.prepare([[10.0005, 50.0005]]), false);
|
||||
assert.equal(down.groundAt(10.0005, 50.0005), null);
|
||||
});
|
||||
|
||||
test('too many corners are not requested, and aborted prepares do nothing', async () => {
|
||||
const terrain = fakeTerrain();
|
||||
const caster = createGroundCaster({ terrain, step: 0.001, maxCorners: 8 });
|
||||
const wide = [];
|
||||
for (let i = 0; i < 10; i++) wide.push([10 + i * 0.001 + 0.0005, 50.0005]);
|
||||
assert.equal(await caster.prepare(wide), false);
|
||||
assert.equal(terrain.calls.length, 0);
|
||||
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
assert.equal(
|
||||
await caster.prepare([[10.0005, 50.0005]], { signal: controller.signal }),
|
||||
false,
|
||||
);
|
||||
assert.equal(terrain.calls.length, 0);
|
||||
});
|
||||
|
||||
test('prepares run one at a time so neighbours share corners', async () => {
|
||||
const terrain = fakeTerrain();
|
||||
const caster = createGroundCaster({ terrain, step: 0.001 });
|
||||
const [a, b] = await Promise.all([
|
||||
caster.prepare([[10.0005, 50.0005]]),
|
||||
caster.prepare([[10.0015, 50.0005]]),
|
||||
]);
|
||||
assert.equal(a, true);
|
||||
assert.equal(b, true);
|
||||
// The second cell shares its west corners with the first.
|
||||
assert.equal(terrain.calls[1].length, 2);
|
||||
});
|
||||
|
||||
test('the caster needs a terrain service', () => {
|
||||
assert.throws(() => createGroundCaster({ terrain: null }), TypeError);
|
||||
});
|
||||
|
||||
test('refineHeights follows the mesh where it is the road', () => {
|
||||
// A freeway trench 6 m below the bare-earth grid, and a steep street 2 m above it.
|
||||
const heights = refineHeights([
|
||||
{ dem: 100, mesh: 94 },
|
||||
{ dem: 100, mesh: 102 },
|
||||
{ dem: 100, mesh: null },
|
||||
]);
|
||||
assert.deepEqual(heights, [
|
||||
94 + MESH_LIFT_M,
|
||||
102 + MESH_LIFT_M,
|
||||
100 + GROUND_CAST_LIFT_M,
|
||||
]);
|
||||
});
|
||||
|
||||
test('refineHeights carries the road under a canopy from both sides', () => {
|
||||
// Road 1 m and 3 m above bare earth either side of a tree 12 m tall.
|
||||
const heights = refineHeights([
|
||||
{ dem: 50, mesh: 51 },
|
||||
{ dem: 50, mesh: 62 },
|
||||
{ dem: 50, mesh: 62 },
|
||||
{ dem: 50, mesh: 53 },
|
||||
]);
|
||||
assert.equal(heights[0], 51 + MESH_LIFT_M);
|
||||
assert.ok(Math.abs(heights[1] - (50 + 1 + 2 / 3 + MESH_LIFT_M)) < 1e-9);
|
||||
assert.ok(Math.abs(heights[2] - (50 + 1 + 4 / 3 + MESH_LIFT_M)) < 1e-9);
|
||||
assert.equal(heights[3], 53 + MESH_LIFT_M);
|
||||
});
|
||||
|
||||
test('refineHeights keeps bare earth under a deck with no road beside it, and rejects bad probes', () => {
|
||||
assert.deepEqual(
|
||||
refineHeights([
|
||||
{ dem: 20, mesh: 35 },
|
||||
{ dem: 20, mesh: 35 },
|
||||
]),
|
||||
[20 + GROUND_CAST_LIFT_M, 20 + GROUND_CAST_LIFT_M],
|
||||
);
|
||||
// A probe kilometres under the ground is not a road.
|
||||
assert.deepEqual(refineHeights([{ dem: 20, mesh: -14000 }]), [
|
||||
20 + GROUND_CAST_LIFT_M,
|
||||
]);
|
||||
});
|
||||
|
||||
test('castLine refines heights with sampled mesh and densifies finer', async () => {
|
||||
const caster = createGroundCaster({ terrain: fakeTerrain(), step: 0.001 });
|
||||
const line = [
|
||||
[10.0001, 50.0001],
|
||||
[10.0009, 50.0001],
|
||||
];
|
||||
await caster.prepareLines([line]);
|
||||
const plain = caster.castLine(line);
|
||||
const meshed = caster.castLine(line, { meshAt: () => 98 });
|
||||
assert.ok(meshed.length > plain.length, 'finer spacing with the mesh');
|
||||
for (let i = 2; i < meshed.length; i += 3)
|
||||
assert.equal(meshed[i], 98 + MESH_LIFT_M);
|
||||
// Unsampled points keep the bare-earth height.
|
||||
const unsampled = caster.castLine(line, { meshAt: () => undefined });
|
||||
assert.ok(unsampled[2] > 100 && unsampled[2] < 110);
|
||||
});
|
||||
|
||||
test('a full cache is cleared before a request counts its corners, so the request still casts', async () => {
|
||||
const caster = createGroundCaster({
|
||||
terrain: fakeTerrain(),
|
||||
step: 0.001,
|
||||
maxCorners: 8,
|
||||
cacheMax: 5,
|
||||
});
|
||||
// One cell's four corners are cached...
|
||||
assert.equal(await caster.prepare([[10.0005, 50.0005]]), true);
|
||||
// ...then a line over that cell and the next needs two more, and the first
|
||||
// cell must survive the eviction.
|
||||
const line = [
|
||||
[10.0002, 50.0005],
|
||||
[10.0018, 50.0005],
|
||||
];
|
||||
assert.equal(await caster.prepareLines([line]), true);
|
||||
assert.ok(caster.castLine(line), 'the whole line casts');
|
||||
});
|
||||
|
||||
test('a full cache drops its oldest corners, not every corner', async () => {
|
||||
const caster = createGroundCaster({
|
||||
terrain: fakeTerrain(),
|
||||
step: 0.001,
|
||||
maxCorners: 4,
|
||||
cacheMax: 12,
|
||||
});
|
||||
// Three cells apart from each other, four corners each: the cache is full.
|
||||
const cells = [10.0005, 10.0105, 10.0205, 10.0305].map((lon) => [
|
||||
lon,
|
||||
50.0005,
|
||||
]);
|
||||
for (const cell of cells.slice(0, 3))
|
||||
assert.equal(await caster.prepare([cell]), true);
|
||||
// A fourth makes room by dropping the first cell only.
|
||||
assert.equal(await caster.prepare([cells[3]]), true);
|
||||
assert.equal(caster.groundAt(...cells[0]), null, 'oldest dropped');
|
||||
for (const cell of cells.slice(1))
|
||||
assert.notEqual(caster.groundAt(...cell), null, 'newer cells kept');
|
||||
});
|
||||
|
||||
test('cancelling a prepare cancels its terrain request', async () => {
|
||||
const seen = [];
|
||||
const caster = createGroundCaster({
|
||||
terrain: {
|
||||
async resolveEllipsoidalGround(coords, options) {
|
||||
seen.push(options?.signal);
|
||||
return coords.map(() => ({ ellipsoid: 100, source: 'reearth' }));
|
||||
},
|
||||
},
|
||||
});
|
||||
const controller = new AbortController();
|
||||
await caster.prepare([[10.0005, 50.0005]], { signal: controller.signal });
|
||||
assert.equal(seen.length, 1);
|
||||
assert.equal(seen[0], controller.signal, 'the tile signal reaches the proxy');
|
||||
});
|
||||
|
||||
test('prepared lines always cast with the mesh, however their segments cross the grid', async () => {
|
||||
// Seeded walks with 50–300 m segments: the finer mesh points can land in
|
||||
// grid cells the coarse points skip.
|
||||
let seed = 7;
|
||||
const random = () => {
|
||||
seed = (seed * 16807) % 2147483647;
|
||||
return seed / 2147483647;
|
||||
};
|
||||
let failures = 0;
|
||||
for (let n = 0; n < 300; n++) {
|
||||
const caster = createGroundCaster({ terrain: fakeTerrain() });
|
||||
const line = [[10 + random() * 0.01, 50 + random() * 0.01]];
|
||||
for (let k = 0; k < 10; k++) {
|
||||
const [lon, lat] = line[line.length - 1];
|
||||
const length = (50 + random() * 250) / 111_000;
|
||||
const angle = random() * 2 * Math.PI;
|
||||
line.push([
|
||||
lon + length * Math.cos(angle),
|
||||
lat + length * Math.sin(angle),
|
||||
]);
|
||||
}
|
||||
assert.equal(await caster.prepareLines([line]), true);
|
||||
if (caster.castLine(line, { meshAt: () => undefined }) === null) failures++;
|
||||
}
|
||||
assert.equal(failures, 0);
|
||||
});
|
||||
|
||||
test('a line across the date line casts along the date line', async () => {
|
||||
// Ground that rises northward only, so both sides of ±180° agree.
|
||||
const calls = [];
|
||||
const terrain = {
|
||||
async resolveEllipsoidalGround(coords) {
|
||||
calls.push(...coords);
|
||||
return coords.map(({ lat }) => ({
|
||||
ellipsoid: 100 + (lat - 50) * 2000,
|
||||
source: 'reearth',
|
||||
}));
|
||||
},
|
||||
};
|
||||
const caster = createGroundCaster({ terrain, step: 0.001 });
|
||||
const line = [
|
||||
[179.9995, 50.0005],
|
||||
[180, 50.0005],
|
||||
[-179.9995, 50.0005],
|
||||
];
|
||||
assert.equal(await caster.prepareLines([line]), true);
|
||||
for (const meshAt of [null, () => undefined]) {
|
||||
const flat = caster.castLine(line, { meshAt });
|
||||
assert.ok(flat, 'every point casts');
|
||||
assert.ok(
|
||||
flat.length / 3 <= 8,
|
||||
`${flat.length / 3} points, not a line round the world`,
|
||||
);
|
||||
for (let i = 0; i < flat.length; i += 3) {
|
||||
assert.ok(Math.abs(flat[i]) >= 179.999 && Math.abs(flat[i]) <= 180);
|
||||
assert.ok(Math.abs(flat[i + 2] - (101 + GROUND_CAST_LIFT_M)) < 1e-6);
|
||||
}
|
||||
}
|
||||
// Only ground near the date line is asked for, and every longitude is a
|
||||
// real one (the far corner of a point on 180° is asked for at -179.999°).
|
||||
assert.ok(calls.length <= 16, `${calls.length} corners`);
|
||||
for (const { lon } of calls)
|
||||
assert.ok(Math.abs(lon) >= 179.999 && Math.abs(lon) <= 180, `${lon}`);
|
||||
});
|
||||
@@ -3,34 +3,24 @@ import { GROUND_CLICK_MAX_HEIGHT_M } from './policy.js';
|
||||
import { cameraHeightAboveGround } from './view.js';
|
||||
|
||||
/** Whether the camera is low enough for a click on the ground to mean a street. */
|
||||
export function groundClickHeightOk(viewer, groundAt) {
|
||||
export function groundClickHeightOk(viewer) {
|
||||
if (!viewer?.camera?.positionCartographic) return false;
|
||||
return (
|
||||
cameraHeightAboveGround(viewer, { groundAt }) <= GROUND_CLICK_MAX_HEIGHT_M
|
||||
);
|
||||
return cameraHeightAboveGround(viewer) <= GROUND_CLICK_MAX_HEIGHT_M;
|
||||
}
|
||||
|
||||
/**
|
||||
* A click on the map itself at street zoom opens the nearest image of the
|
||||
* active `groundClick` providers, without moving the camera. The height is
|
||||
* measured at the click, not read from the cached flag.
|
||||
* @param {{state: object, openNearest: Function, isAvailable: (entry: object) => boolean, groundAt?: Function}} options
|
||||
* @param {{state: object, openNearest: Function, isAvailable: (entry: object) => boolean}} options
|
||||
* @returns {(screenPosition: {x: number, y: number}, picked?: object) => Promise<boolean>|false}
|
||||
*/
|
||||
export function createGroundClick({
|
||||
state,
|
||||
openNearest,
|
||||
isAvailable,
|
||||
groundAt = null,
|
||||
}) {
|
||||
export function createGroundClick({ state, openNearest, isAvailable }) {
|
||||
return function openAtGround(screenPosition, picked) {
|
||||
const scenePick = state.services.scenePick;
|
||||
if (!state.enabled || !state.viewer || !scenePick?.pickGroundPosition)
|
||||
return false;
|
||||
if (
|
||||
!scenePick.isSurfacePick(picked) ||
|
||||
!groundClickHeightOk(state.viewer, groundAt)
|
||||
)
|
||||
if (!scenePick.isSurfacePick(picked) || !groundClickHeightOk(state.viewer))
|
||||
return false;
|
||||
const providerIds = [...state.providers.values()]
|
||||
.filter(
|
||||
|
||||
+108
-192
@@ -1,7 +1,7 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { createState } from './state.js';
|
||||
import { createMarker } from './marker.js';
|
||||
import { createCameraFollow } from './cameraFollow.js';
|
||||
import { createCameraFraming } from './cameraFraming.js';
|
||||
import { createCredits } from './credits.js';
|
||||
import { createPickRouter } from './pickRouter.js';
|
||||
import { createSelection } from './selection.js';
|
||||
@@ -14,17 +14,12 @@ import {
|
||||
keyAlternatives,
|
||||
summarizeCoverage,
|
||||
} from './uiState.js';
|
||||
import { cameraHeightAboveGround, viewCentre, whenIdle } from './view.js';
|
||||
import { groundUnderCamera, viewCentre } from './view.js';
|
||||
import { createGroundClick, groundClickHeightOk } from './groundClick.js';
|
||||
import { createGroundCaster, nextSurfaceMode } from './groundCast.js';
|
||||
import { createMeshSampler } from './meshSampler.js';
|
||||
import { msg, t, translateKnown } from '../../i18n/index.js';
|
||||
|
||||
/** Above this camera height the ground under the camera is not worth fetching. */
|
||||
const SURFACE_WARM_BELOW_M = 6000;
|
||||
import {
|
||||
AIM_AT_GROUND_MESSAGE,
|
||||
FOLLOW_MAP_STACK_ID,
|
||||
keyStatusLabel,
|
||||
NEAREST_RADIUS_M,
|
||||
POSITION_PICK_ID,
|
||||
STREET_LEVEL_LAYER_ID,
|
||||
@@ -32,35 +27,31 @@ import {
|
||||
|
||||
export { STREET_LEVEL_LAYER_ID } from './policy.js';
|
||||
|
||||
/** Longest wait for a provider's key status; past it, the provider decides. */
|
||||
const KEY_STATUS_TIMEOUT_MS = 10_000;
|
||||
/** Re-measures of a ground not loaded yet (Google 3D), a second apart. */
|
||||
const GROUND_RETRY_LIMIT = 5;
|
||||
const GROUND_RETRY_MS = 1000;
|
||||
|
||||
/**
|
||||
* The Street Level layer over imagery providers (contract in registry.js).
|
||||
* The core owns what they share; providers own coverage, sequences, viewer.
|
||||
* The core owns what they share: the filter and switches, the photo viewer
|
||||
* host, the position marker, the click handler and nearest-image lookups.
|
||||
* Providers own coverage, sequences and their viewer. One source is lit at
|
||||
* a time.
|
||||
* @param {{providers: Array<import('./registry.js').StreetLevelProvider>, services?: object}} options
|
||||
*/
|
||||
export function createStreetLevelLayer({
|
||||
providers: definitions,
|
||||
services = {},
|
||||
}) {
|
||||
const definitionsFrozen = validateProviders(definitions);
|
||||
const defs = validateProviders(definitions);
|
||||
const state = createState({ services });
|
||||
const parts = {};
|
||||
const context = { state, parts };
|
||||
parts.groundCaster = services.terrain?.resolveEllipsoidalGround
|
||||
? createGroundCaster({ terrain: services.terrain })
|
||||
: null;
|
||||
// Mesh samples follow the application's rules: a real bare-earth prior,
|
||||
// its mesh window, and a tileset that has finished streaming.
|
||||
parts.meshSampler = parts.groundCaster
|
||||
? createMeshSampler({
|
||||
getViewer: () => state.viewer,
|
||||
groundAt: parts.groundCaster.groundAt,
|
||||
withinPrior: services.ground?.meshFloorSampleWithinPrior ?? null,
|
||||
tilesReady: services.meshFloor?.visibleTilesetLoaded ?? null,
|
||||
})
|
||||
: null;
|
||||
parts.credits = createCredits();
|
||||
parts.marker = createMarker(context);
|
||||
parts.follow = createCameraFollow(context);
|
||||
parts.framing = createCameraFraming(context);
|
||||
parts.router = createPickRouter(() => state.providers.values(), {
|
||||
positionId: POSITION_PICK_ID,
|
||||
});
|
||||
@@ -78,7 +69,6 @@ export function createStreetLevelLayer({
|
||||
state,
|
||||
openNearest: (point, options) => layer.openNearest(point, options),
|
||||
isAvailable: (entry) => providerAvailable(entry),
|
||||
groundAt: parts.groundCaster?.groundAt ?? null,
|
||||
});
|
||||
|
||||
let notifyQueued = false;
|
||||
@@ -102,11 +92,10 @@ export function createStreetLevelLayer({
|
||||
function providerContext(entry) {
|
||||
return Object.freeze({
|
||||
services: state.services,
|
||||
/** Resolved against now, so "since N days" keeps up with the clock. */
|
||||
getFilter: () => resolveFilter(state.filter),
|
||||
/** The layer and this provider's switch are both on. */
|
||||
isActive: () => state.enabled && entry.on,
|
||||
groundCaster: parts.groundCaster,
|
||||
meshSampler: parts.meshSampler,
|
||||
getSurface: () => state.surface,
|
||||
notify,
|
||||
actions: {
|
||||
/** `{frame: false}` opens it without moving the camera. */
|
||||
@@ -138,13 +127,15 @@ export function createStreetLevelLayer({
|
||||
}
|
||||
|
||||
// One source at a time: the first provider that defaults on.
|
||||
const startsOn = definitionsFrozen.find((def) => def.defaultOn !== false);
|
||||
for (const def of definitionsFrozen) {
|
||||
const startsOn = defs.find((def) => def.defaultOn !== false);
|
||||
for (const def of defs) {
|
||||
const entry = {
|
||||
def,
|
||||
instance: null,
|
||||
on: def === startsOn,
|
||||
status: null,
|
||||
/** The status request in flight; overlapping checks share it. */
|
||||
statusCheck: null,
|
||||
/** The error this provider last reported, until it withdraws it. */
|
||||
reportedError: null,
|
||||
};
|
||||
@@ -155,15 +146,6 @@ export function createStreetLevelLayer({
|
||||
let mapStack = null;
|
||||
let unsubscribeMapStack = null;
|
||||
|
||||
/** Follow is offered only on the Google 3D stack; leaving it stops following. */
|
||||
function syncFollowAvailability() {
|
||||
const available = mapStack?.getActiveId?.() === FOLLOW_MAP_STACK_ID;
|
||||
if (available === state.street.followAvailable) return;
|
||||
state.street.followAvailable = available;
|
||||
if (!available && state.street.follow) parts.follow.setFollow(false);
|
||||
notify();
|
||||
}
|
||||
|
||||
/** Usable only on its map stack (Google's terms: Street View on Google 3D only). */
|
||||
function providerAvailable(entry) {
|
||||
const required = entry.def.requiresMapStack;
|
||||
@@ -184,109 +166,79 @@ export function createStreetLevelLayer({
|
||||
parts.viewerHost.unmount(entry.def.id);
|
||||
}
|
||||
}
|
||||
remeasureGround();
|
||||
notify();
|
||||
}
|
||||
|
||||
/** Whether a ground click can open an image: the camera is at street zoom. */
|
||||
let groundRetry = null;
|
||||
let groundRetries = 0;
|
||||
function cancelGroundRetry() {
|
||||
clearTimeout(groundRetry);
|
||||
groundRetry = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a ground click can open an image: the camera is at street zoom.
|
||||
* Under Google 3D the ground is unknown until its tiles load, so an unknown
|
||||
* ground is measured again a few times instead of waiting for the next move.
|
||||
*/
|
||||
function syncGroundClickReady() {
|
||||
const ready =
|
||||
cancelGroundRetry();
|
||||
const ready = state.enabled && groundClickHeightOk(state.viewer);
|
||||
if (
|
||||
state.enabled &&
|
||||
groundClickHeightOk(state.viewer, parts.groundCaster?.groundAt);
|
||||
!ready &&
|
||||
groundRetries < GROUND_RETRY_LIMIT &&
|
||||
groundUnderCamera(state.viewer) === null
|
||||
) {
|
||||
groundRetries++;
|
||||
groundRetry = setTimeout(syncGroundClickReady, GROUND_RETRY_MS);
|
||||
}
|
||||
if (ready === state.groundClickReady) return;
|
||||
state.groundClickReady = ready;
|
||||
notify();
|
||||
}
|
||||
|
||||
/**
|
||||
* Camera height above bare earth, for the surface mode. A cold grid cell is
|
||||
* fetched once and the mode rechecked when it lands.
|
||||
*/
|
||||
function cameraHeightForSurface() {
|
||||
const carto = state.viewer?.camera?.positionCartographic;
|
||||
if (!carto) return null;
|
||||
const caster = parts.groundCaster;
|
||||
const height = cameraHeightAboveGround(state.viewer, {
|
||||
groundAt: caster.groundAt,
|
||||
});
|
||||
const lon = (carto.longitude * 180) / Math.PI;
|
||||
const lat = (carto.latitude * 180) / Math.PI;
|
||||
if (caster.groundAt(lon, lat) === null && height < SURFACE_WARM_BELOW_M)
|
||||
caster.prepare([[lon, lat]]).then((ready) => {
|
||||
if (ready) scheduleSurfaceSync();
|
||||
});
|
||||
return height;
|
||||
}
|
||||
|
||||
/** Cast overlays to bare earth on Google 3D at street zoom; drape them elsewhere. */
|
||||
function syncSurface() {
|
||||
/** A camera move or a map change measures afresh. */
|
||||
function remeasureGround() {
|
||||
groundRetries = 0;
|
||||
syncGroundClickReady();
|
||||
const available = Boolean(parts.groundCaster) && state.enabled;
|
||||
const photoreal = mapStack?.getActiveId?.() === FOLLOW_MAP_STACK_ID;
|
||||
const mode = nextSurfaceMode(state.surface, {
|
||||
available,
|
||||
photoreal,
|
||||
heightM: available && photoreal ? cameraHeightForSurface() : null,
|
||||
});
|
||||
if (mode === state.surface) return;
|
||||
state.surface = mode;
|
||||
parts.meshSampler?.setEnabled(mode === 'terrain');
|
||||
parts.marker.setSurface(mode);
|
||||
for (const entry of state.providers.values())
|
||||
entry.instance.setSurface?.(mode);
|
||||
notify();
|
||||
}
|
||||
|
||||
let surfaceTimer = null;
|
||||
function scheduleSurfaceSync() {
|
||||
clearTimeout(surfaceTimer);
|
||||
surfaceTimer = setTimeout(syncSurface, 150);
|
||||
}
|
||||
|
||||
let removeSurfaceListeners = null;
|
||||
function watchSurface(viewer) {
|
||||
removeSurfaceListeners?.();
|
||||
const camera = viewer?.camera;
|
||||
if (!camera?.changed || !camera?.moveEnd) {
|
||||
removeSurfaceListeners = null;
|
||||
return;
|
||||
}
|
||||
const removeChanged = camera.changed.addEventListener(scheduleSurfaceSync);
|
||||
const removeEnd = camera.moveEnd.addEventListener(scheduleSurfaceSync);
|
||||
removeSurfaceListeners = () => {
|
||||
removeChanged();
|
||||
removeEnd();
|
||||
};
|
||||
}
|
||||
|
||||
function unwatchSurface() {
|
||||
removeSurfaceListeners?.();
|
||||
removeSurfaceListeners = null;
|
||||
clearTimeout(surfaceTimer);
|
||||
surfaceTimer = null;
|
||||
let removeCameraListener = null;
|
||||
function watchCamera(viewer) {
|
||||
removeCameraListener?.();
|
||||
removeCameraListener =
|
||||
viewer?.camera?.moveEnd?.addEventListener(remeasureGround) || null;
|
||||
}
|
||||
|
||||
const activeEntries = () =>
|
||||
[...state.providers.values()].filter((entry) => entry.on);
|
||||
|
||||
async function refreshStatus(entry) {
|
||||
try {
|
||||
entry.status = await entry.instance.status();
|
||||
} catch {
|
||||
entry.status = null;
|
||||
}
|
||||
notify();
|
||||
/** A provider's key status. A hung server must not hold LOADING, or the next check, forever. */
|
||||
function checkStatus(entry) {
|
||||
entry.statusCheck ||= (async () => {
|
||||
try {
|
||||
entry.status = await entry.instance.status({
|
||||
signal: AbortSignal.timeout(KEY_STATUS_TIMEOUT_MS),
|
||||
});
|
||||
} catch {
|
||||
entry.status = null;
|
||||
}
|
||||
notify();
|
||||
})().finally(() => {
|
||||
entry.statusCheck = null;
|
||||
});
|
||||
return entry.statusCheck;
|
||||
}
|
||||
|
||||
function activate(entry) {
|
||||
if (!state.viewer) return;
|
||||
entry.instance.activate(state.viewer);
|
||||
if (!entry.status) refreshStatus(entry);
|
||||
if (!providerAvailable(entry)) return;
|
||||
parts.credits.show(state.viewer, entry.def);
|
||||
// The map may have changed by then.
|
||||
whenIdle(() => {
|
||||
if (providerAvailable(entry)) parts.viewerHost.prewarm([entry]);
|
||||
}, 1500);
|
||||
// Without a known key, a switch-on asks again: the server may be back,
|
||||
// or have a key now.
|
||||
if (entry.status?.configured !== true) checkStatus(entry);
|
||||
if (providerAvailable(entry)) parts.credits.show(state.viewer, entry.def);
|
||||
}
|
||||
|
||||
function deactivate(entry) {
|
||||
@@ -442,7 +394,6 @@ export function createStreetLevelLayer({
|
||||
error: stats.error || null,
|
||||
color: entry.def.colors.coverage,
|
||||
coverageLabel: entry.def.coverageLabel || null,
|
||||
highlightsSequence: entry.def.highlightsSequence !== false,
|
||||
sinceBasis: entry.def.sinceBasis === 'upload' ? 'upload' : 'capture',
|
||||
groundClick: entry.def.groundClick === true,
|
||||
filters: [...filtersOf(entry.def)],
|
||||
@@ -450,23 +401,30 @@ export function createStreetLevelLayer({
|
||||
});
|
||||
}
|
||||
|
||||
/** The selected sequence; the provider showing the open image answers first. */
|
||||
function sequenceSnapshot() {
|
||||
// The provider showing the open image answers first.
|
||||
const owner = state.providers.get(state.street.providerId);
|
||||
const candidates = owner
|
||||
? [owner, ...[...state.providers.values()].filter((e) => e !== owner)]
|
||||
: [...state.providers.values()];
|
||||
for (const entry of candidates) {
|
||||
const stats = entry.instance.sequenceStats?.();
|
||||
if (stats?.selectedId || stats?.loading)
|
||||
if (stats?.selectedId || stats?.loading || stats?.error)
|
||||
return {
|
||||
providerId: entry.def.id,
|
||||
selectedId: stats.selectedId || null,
|
||||
images: stats.images || 0,
|
||||
loading: stats.loading === true,
|
||||
error: stats.error || null,
|
||||
};
|
||||
}
|
||||
return { providerId: null, selectedId: null, images: 0, loading: false };
|
||||
return {
|
||||
providerId: null,
|
||||
selectedId: null,
|
||||
images: 0,
|
||||
loading: false,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
function getUIState() {
|
||||
@@ -478,7 +436,6 @@ export function createStreetLevelLayer({
|
||||
providers: providerSnapshots(),
|
||||
street,
|
||||
sequence: sequenceSnapshot(),
|
||||
surface: state.surface,
|
||||
});
|
||||
// The lit source cannot run on this map: say so, not a blank panel.
|
||||
const stranded = [...state.providers.values()].find(
|
||||
@@ -503,15 +460,13 @@ export function createStreetLevelLayer({
|
||||
statsRefreshInterval: 1000,
|
||||
/** The key the switched-on providers share (or null), for the layer row. */
|
||||
get requiresKeyId() {
|
||||
const on = [...state.providers.values()].filter((entry) => entry.on);
|
||||
return requiresKeyIdFor(
|
||||
on.length ? on.map((entry) => entry.def) : definitionsFrozen,
|
||||
);
|
||||
const on = activeEntries();
|
||||
return requiresKeyIdFor(on.length ? on.map((entry) => entry.def) : defs);
|
||||
},
|
||||
/** Registered providers, in chip order. */
|
||||
providerIds: definitionsFrozen.map((def) => def.id),
|
||||
providerIds: defs.map((def) => def.id),
|
||||
getSourceAvailability() {
|
||||
const available = definitionsFrozen.length > 0;
|
||||
const available = defs.length > 0;
|
||||
return {
|
||||
available,
|
||||
source: STREET_LEVEL_LAYER_ID,
|
||||
@@ -522,15 +477,12 @@ export function createStreetLevelLayer({
|
||||
},
|
||||
|
||||
init(viewer) {
|
||||
if (state.initialized)
|
||||
throw new Error('Street Level layer is already initialized');
|
||||
state.viewer = viewer;
|
||||
state.initialized = true;
|
||||
parts.marker.ensure(viewer);
|
||||
parts.marker.setVisible(false);
|
||||
for (const entry of state.providers.values()) {
|
||||
entry.instance.init(viewer);
|
||||
refreshStatus(entry);
|
||||
checkStatus(entry);
|
||||
}
|
||||
console.log('[Data:StreetLevel] Initialized');
|
||||
},
|
||||
@@ -541,18 +493,20 @@ export function createStreetLevelLayer({
|
||||
parts.marker.setVisible(true);
|
||||
parts.selection.install(viewer);
|
||||
for (const entry of activeEntries()) activate(entry);
|
||||
watchSurface(viewer);
|
||||
syncSurface();
|
||||
watchCamera(viewer);
|
||||
remeasureGround();
|
||||
notify();
|
||||
},
|
||||
|
||||
disable() {
|
||||
state.enabled = false;
|
||||
abortNearest();
|
||||
unwatchSurface();
|
||||
removeCameraListener?.();
|
||||
removeCameraListener = null;
|
||||
cancelGroundRetry();
|
||||
parts.viewerHost.unmount();
|
||||
for (const entry of state.providers.values()) entry.instance.deactivate();
|
||||
syncSurface();
|
||||
syncGroundClickReady();
|
||||
parts.credits.hideAll(state.viewer);
|
||||
parts.selection.uninstall();
|
||||
parts.marker.setVisible(false);
|
||||
@@ -568,27 +522,22 @@ export function createStreetLevelLayer({
|
||||
for (const entry of state.providers.values())
|
||||
entry.instance.destroy(viewer);
|
||||
parts.marker.destroy(viewer);
|
||||
parts.meshSampler?.destroy();
|
||||
parts.follow.destroy();
|
||||
parts.framing.attachNavigation(null);
|
||||
unsubscribeMapStack?.();
|
||||
unsubscribeMapStack = null;
|
||||
mapStack = null;
|
||||
state.listeners.clear();
|
||||
state.viewer = null;
|
||||
state.initialized = false;
|
||||
state.destroyed = true;
|
||||
},
|
||||
|
||||
getStats() {
|
||||
// The lifecycle polls this every second: summarise, don't snapshot.
|
||||
// English data: the layer panel translates these through translateKnown.
|
||||
const snapshots = providerSnapshots();
|
||||
const coverage = summarizeCoverage(snapshots);
|
||||
// English data: the layer panel translates these through translateKnown.
|
||||
const keyLabel = keyStatusLabel(coverage);
|
||||
let loadingLabel = '';
|
||||
const keyLabel = coverage.keyRejected
|
||||
? msg('KEY REJECTED')
|
||||
: msg('KEY REQUIRED');
|
||||
if (coverage.keyRequired) loadingLabel = keyLabel;
|
||||
if (keyLabel) loadingLabel = keyLabel;
|
||||
else if (coverage.loading) loadingLabel = msg('loading coverage...');
|
||||
else if (coverage.hint && state.enabled) loadingLabel = coverage.hint;
|
||||
return {
|
||||
@@ -597,7 +546,8 @@ export function createStreetLevelLayer({
|
||||
loading: coverage.loading,
|
||||
keyRequired: coverage.keyRequired,
|
||||
keyAlternatives: keyAlternatives(snapshots),
|
||||
error: coverage.keyRequired
|
||||
// A rejected key's message names the fix; a missing one says so.
|
||||
error: keyLabel
|
||||
? coverage.keyRejected
|
||||
? coverage.error || keyLabel
|
||||
: keyLabel
|
||||
@@ -606,30 +556,18 @@ export function createStreetLevelLayer({
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* The application's camera authority: `run(noun, move)`, the deferred
|
||||
* `begin(noun)` / `reassert(generation)` pair and
|
||||
* `subscribeHandoff(listener)`. FOLLOW claims the camera with `run`; a
|
||||
* photo claims it when opening starts and frames only if it still owns
|
||||
* it once loaded. FOLLOW stops when another feature takes the camera.
|
||||
*/
|
||||
/** A photo frames itself only if it still owns the camera once loaded. */
|
||||
attachNavigation(navigation) {
|
||||
parts.follow.attachNavigation(navigation);
|
||||
parts.framing.attachNavigation(navigation);
|
||||
},
|
||||
|
||||
/** The application map stack; FOLLOW is available only on Google 3D. */
|
||||
/** The application map stack; Street View is usable only on Google 3D. */
|
||||
attachMapStackController(controller) {
|
||||
unsubscribeMapStack?.();
|
||||
mapStack = controller || null;
|
||||
unsubscribeMapStack =
|
||||
mapStack?.subscribe?.(() => {
|
||||
syncFollowAvailability();
|
||||
syncProviderAvailability();
|
||||
syncSurface();
|
||||
}) || null;
|
||||
syncFollowAvailability();
|
||||
mapStack?.subscribe?.(syncProviderAvailability) || null;
|
||||
syncProviderAvailability();
|
||||
syncSurface();
|
||||
},
|
||||
|
||||
/** Share-link and stored state: provider switches plus the filter. */
|
||||
@@ -640,6 +578,7 @@ export function createStreetLevelLayer({
|
||||
});
|
||||
},
|
||||
|
||||
/** Unknown providers and malformed values are ignored, so any link applies. */
|
||||
setParams(params = {}) {
|
||||
const decoded = decodeParams(params, {
|
||||
providerIds: state.providers.keys(),
|
||||
@@ -666,23 +605,14 @@ export function createStreetLevelLayer({
|
||||
/** The DOM element provider viewers render into. */
|
||||
attachViewerHost(element) {
|
||||
parts.viewerHost.attach(element);
|
||||
if (element && state.enabled)
|
||||
whenIdle(
|
||||
() =>
|
||||
parts.viewerHost.prewarm(
|
||||
activeEntries().filter((entry) => providerAvailable(entry)),
|
||||
),
|
||||
1500,
|
||||
);
|
||||
},
|
||||
setProviderEnabled,
|
||||
/** Imagery filter for coverage, cones and nearest-image lookups. */
|
||||
setCoverageFilter,
|
||||
openImage,
|
||||
/**
|
||||
* Open the nearest image of the active providers (or `providerIds`) near
|
||||
* a point, else the view centre, or with `aim: 'screen-centre'` the ground
|
||||
* under the screen centre (sky: nothing).
|
||||
* under the screen centre (sky: nothing). Providers without
|
||||
* `nearestImage` are skipped.
|
||||
*/
|
||||
async openNearest(
|
||||
point,
|
||||
@@ -712,6 +642,7 @@ export function createStreetLevelLayer({
|
||||
let lastError = null;
|
||||
const asked = (entry) =>
|
||||
(!providerIds || providerIds.includes(entry.def.id)) &&
|
||||
typeof entry.instance.nearestImage === 'function' &&
|
||||
providerAvailable(entry);
|
||||
const entries = activeEntries().filter(asked);
|
||||
for (const entry of entries) {
|
||||
@@ -751,35 +682,20 @@ export function createStreetLevelLayer({
|
||||
})
|
||||
: t(
|
||||
'No street-level imagery within {radius} m of the view centre',
|
||||
{
|
||||
radius: NEAREST_RADIUS_M,
|
||||
},
|
||||
{ radius: NEAREST_RADIUS_M },
|
||||
));
|
||||
state.street.loading = false;
|
||||
notify();
|
||||
return false;
|
||||
},
|
||||
/** Close the image and deselect it everywhere on the map. */
|
||||
/** Close the image and deselect its sequence on the map. */
|
||||
closeViewer() {
|
||||
abortNearest();
|
||||
parts.viewerHost.close();
|
||||
parts.clearSequences();
|
||||
},
|
||||
setViewerRenderMode: (mode) => parts.viewerHost.setRenderMode(mode),
|
||||
setFollow: (enabled) => parts.follow.setFollow(enabled),
|
||||
resizeViewer: () => parts.viewerHost.resize(),
|
||||
selectSequence(
|
||||
sequenceId,
|
||||
providerId = state.providers.keys().next().value,
|
||||
) {
|
||||
return state.providers
|
||||
.get(providerId)
|
||||
?.instance.selectSequence?.(sequenceId);
|
||||
},
|
||||
clearSequence: () => parts.clearSequences(),
|
||||
refreshCoverage() {
|
||||
for (const entry of activeEntries()) entry.instance.refreshCoverage();
|
||||
},
|
||||
};
|
||||
return layer;
|
||||
}
|
||||
@@ -5,9 +5,306 @@ import { createStreetLevelLayer } from './index.js';
|
||||
import { MAPILLARY_CREDIT_HTML } from './providers/mapillary/policy.js';
|
||||
import { createMapillaryProvider } from './providers/mapillary/index.js';
|
||||
import { createKartaViewProvider } from './providers/kartaview/index.js';
|
||||
import { fakeStreetLevelProvider as fakeProvider } from '../../testSupport/streetLevelFakes.mjs';
|
||||
import {
|
||||
fakeCesiumViewer,
|
||||
fakeMapillarySource,
|
||||
fakePhotoViewer,
|
||||
fakeStreetLevelProvider as fakeProvider,
|
||||
} from '../../testSupport/streetLevelFakes.mjs';
|
||||
import { setLocale } from '../../i18n/index.js';
|
||||
|
||||
/**
|
||||
* A layer over `source` and `photoViewer`, initialised on a stand-in viewer
|
||||
* and (unless `enable` is false) enabled.
|
||||
*/
|
||||
async function startLayer(
|
||||
t,
|
||||
{
|
||||
source = fakeMapillarySource(),
|
||||
photoViewer = fakePhotoViewer(),
|
||||
providers = [createMapillaryProvider({ source, photoViewer })],
|
||||
view = false,
|
||||
enable = true,
|
||||
} = {},
|
||||
) {
|
||||
const saved = globalThis.document;
|
||||
// The click handler listens for Esc on the document.
|
||||
globalThis.document = new EventTarget();
|
||||
// Skip Cesium's one-time terrain table download for draped lines.
|
||||
t.mock.method(
|
||||
Cesium.GroundPolylinePrimitive,
|
||||
'initializeTerrainHeights',
|
||||
async () => {},
|
||||
);
|
||||
const viewer = fakeCesiumViewer({ view });
|
||||
const layer = createStreetLevelLayer({ providers });
|
||||
layer.init(viewer);
|
||||
if (enable) layer.enable(viewer);
|
||||
t.after(() => {
|
||||
layer.destroy();
|
||||
globalThis.document = saved;
|
||||
});
|
||||
await settle();
|
||||
return { layer, viewer, source, photoViewer };
|
||||
}
|
||||
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
let reject;
|
||||
const promise = new Promise((done, fail) => {
|
||||
resolve = done;
|
||||
reject = fail;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
/** A photo viewer reporting a pose for every image it opens. */
|
||||
const posingViewer = () =>
|
||||
fakePhotoViewer({ pose: { position: { lon: -121.49, lat: 38.58 } } });
|
||||
|
||||
test('the CC BY-SA credit shows while Mapillary is on and goes with it', async (t) => {
|
||||
const { layer, viewer } = await startLayer(t);
|
||||
const shown = () => viewer.credits.map((credit) => credit.html);
|
||||
assert.deepEqual(shown(), [MAPILLARY_CREDIT_HTML], 'shown on enable');
|
||||
assert.match(shown()[0], /CC BY-SA 4\.0/);
|
||||
layer.setParams({ mapillary: false });
|
||||
assert.deepEqual(shown(), [], 'hidden with the switch');
|
||||
layer.setParams({ mapillary: true });
|
||||
assert.deepEqual(shown(), [MAPILLARY_CREDIT_HTML]);
|
||||
layer.disable();
|
||||
assert.deepEqual(shown(), [], 'the credit goes with the layer');
|
||||
});
|
||||
|
||||
test('coverage asks for no tiles before the key status is known, and none without a key', async (t) => {
|
||||
for (const configured of [false, true]) {
|
||||
const status = deferred();
|
||||
const source = fakeMapillarySource({ getStatus: () => status.promise });
|
||||
const { layer } = await startLayer(t, { source, view: true });
|
||||
assert.equal(source.calls.tiles.length, 0, 'nothing before the status');
|
||||
status.resolve({ configured });
|
||||
await settle();
|
||||
if (configured) assert.ok(source.calls.tiles.length > 0, 'then coverage');
|
||||
else {
|
||||
assert.equal(
|
||||
source.calls.tiles.length,
|
||||
0,
|
||||
'a key-less install never asks',
|
||||
);
|
||||
assert.deepEqual(
|
||||
[layer.getStats().loadingLabel, layer.getStats().error],
|
||||
['KEY REQUIRED', 'KEY REQUIRED'],
|
||||
);
|
||||
assert.equal(layer.getUIState().keyRequired, true);
|
||||
assert.equal(layer.getUIState().keyRejected, false);
|
||||
}
|
||||
layer.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('a key status that failed is asked again on the next switch-on', async (t) => {
|
||||
let up = false;
|
||||
let asked = 0;
|
||||
const source = fakeMapillarySource({
|
||||
hasToken: () => false,
|
||||
getStatus: async () => {
|
||||
asked++;
|
||||
if (!up) throw new TypeError('fetch failed');
|
||||
return { configured: true };
|
||||
},
|
||||
});
|
||||
const { layer, viewer } = await startLayer(t, { source, view: true });
|
||||
assert.equal(asked, 1, 'one check for init and the first switch-on');
|
||||
assert.equal(layer.getUIState().keyRequired, true);
|
||||
up = true; // the server is reachable again
|
||||
layer.disable();
|
||||
layer.enable(viewer);
|
||||
await settle();
|
||||
assert.equal(asked, 2);
|
||||
assert.equal(layer.getUIState().keyRequired, false);
|
||||
assert.ok(source.calls.tiles.length > 0, 'coverage loads');
|
||||
// A known key is not asked again.
|
||||
layer.disable();
|
||||
layer.enable(viewer);
|
||||
await settle();
|
||||
assert.equal(asked, 2);
|
||||
});
|
||||
|
||||
test('a key status that never answers gives up, so the next switch-on asks again', async (t) => {
|
||||
// The check's timeout, fired when the test says.
|
||||
const timeouts = [];
|
||||
t.mock.method(AbortSignal, 'timeout', (ms) => {
|
||||
const controller = new AbortController();
|
||||
timeouts.push({ ms, fire: () => controller.abort(new Error('timed out')) });
|
||||
return controller.signal;
|
||||
});
|
||||
let asked = 0;
|
||||
const source = fakeMapillarySource({
|
||||
hasToken: () => false,
|
||||
getStatus: ({ signal } = {}) =>
|
||||
new Promise((_, reject) => {
|
||||
asked++;
|
||||
signal?.addEventListener('abort', () => reject(signal.reason));
|
||||
}),
|
||||
});
|
||||
const { layer, viewer } = await startLayer(t, { source, view: true });
|
||||
assert.equal(asked, 1);
|
||||
assert.equal(timeouts[0].ms, 10_000, 'the check can be cut short');
|
||||
assert.equal(layer.getUIState().coverage.loading, true, 'LOADING meanwhile');
|
||||
timeouts[0].fire();
|
||||
await settle();
|
||||
assert.equal(layer.getUIState().coverage.loading, false);
|
||||
assert.equal(layer.getUIState().keyRequired, true, 'no token: KEY REQUIRED');
|
||||
layer.disable();
|
||||
layer.enable(viewer);
|
||||
assert.equal(asked, 2, 'asked again, not stuck on the first');
|
||||
});
|
||||
|
||||
test('a rejected key gates the layer as KEY REJECTED until it goes off', async (t) => {
|
||||
const rejected = Object.assign(new Error('Mapillary rejected the token'), {
|
||||
keyRejected: true,
|
||||
});
|
||||
const source = fakeMapillarySource({
|
||||
getTile: async () => {
|
||||
throw rejected;
|
||||
},
|
||||
});
|
||||
const { layer, viewer } = await startLayer(t, { source, view: true });
|
||||
await settle();
|
||||
const stats = layer.getStats();
|
||||
assert.equal(stats.keyRequired, true);
|
||||
assert.equal(stats.loadingLabel, 'KEY REJECTED');
|
||||
assert.match(stats.error, /rejected MAPILLARY_CLIENT_TOKEN/);
|
||||
const ui = layer.getUIState();
|
||||
assert.deepEqual([ui.keyRequired, ui.keyRejected], [true, true]);
|
||||
layer.disable();
|
||||
layer.enable(viewer);
|
||||
assert.equal(layer.getUIState().keyRejected, false, 'asked again once on');
|
||||
});
|
||||
|
||||
test('switching Mapillary off clears coverage and closes the image it shows', async (t) => {
|
||||
const { layer, photoViewer } = await startLayer(t);
|
||||
layer.attachViewerHost({});
|
||||
assert.equal(await layer.openImage('mapillary', 'img1'), true);
|
||||
assert.deepEqual(photoViewer.calls.open, ['img1']);
|
||||
assert.equal(layer.getUIState().street.open, true);
|
||||
layer.setParams({ mapillary: false });
|
||||
assert.equal(photoViewer.calls.unmount, 1, 'its viewer is released');
|
||||
assert.equal(layer.getUIState().street.open, false);
|
||||
assert.equal(layer.getUIState().providers[0].on, false);
|
||||
});
|
||||
|
||||
test('a sequence that fails to load reports it apart from the photo, and the error goes with it', async (t) => {
|
||||
const source = fakeMapillarySource({
|
||||
getSequenceImages: async () => {
|
||||
throw new Error('Sequence images unavailable');
|
||||
},
|
||||
});
|
||||
// The photo's pose names its sequence, which opening selects.
|
||||
const photoViewer = fakePhotoViewer({
|
||||
pose: { position: { lon: -121.49, lat: 38.58 }, sequenceId: 'seq-1' },
|
||||
});
|
||||
const { layer } = await startLayer(t, { source, photoViewer });
|
||||
layer.attachViewerHost({});
|
||||
assert.equal(await layer.openImage('mapillary', 'img1'), true);
|
||||
await settle();
|
||||
let ui = layer.getUIState();
|
||||
assert.equal(ui.sequence.error, 'Sequence images unavailable');
|
||||
assert.equal(ui.street.error, null, 'the photo itself is fine');
|
||||
layer.closeViewer();
|
||||
ui = layer.getUIState();
|
||||
assert.equal(ui.sequence.error, null, 'withdrawn with the sequence');
|
||||
});
|
||||
|
||||
test('closing the photo, switching the layer off or destroying it stops the framing flight', async (t) => {
|
||||
const { layer, viewer } = await startLayer(t, {
|
||||
photoViewer: posingViewer(),
|
||||
});
|
||||
layer.attachViewerHost({});
|
||||
assert.equal(await layer.openImage('mapillary', 'img1'), true);
|
||||
assert.equal(viewer.flights.started, 1, 'the photo is framed');
|
||||
layer.closeViewer();
|
||||
assert.equal(viewer.flights.cancelled, 1, 'closed');
|
||||
|
||||
await layer.openImage('mapillary', 'img2');
|
||||
layer.disable();
|
||||
assert.equal(viewer.flights.cancelled, 2, 'layer off');
|
||||
|
||||
layer.enable(viewer);
|
||||
await layer.openImage('mapillary', 'img3');
|
||||
layer.destroy();
|
||||
assert.equal(viewer.flights.cancelled, 3, 'destroyed');
|
||||
});
|
||||
|
||||
test('setParams takes "any date" (0 days) and the switch over the current values (share-link defaults)', async (t) => {
|
||||
const { layer } = await startLayer(t);
|
||||
assert.deepEqual(layer.getParams(), {
|
||||
mapillary: true,
|
||||
pano: 'all',
|
||||
sinceDays: 0,
|
||||
});
|
||||
layer.setParams({ pano: 'flat', sinceDays: 365 });
|
||||
assert.equal(layer.getParams().sinceDays, 365);
|
||||
layer.setParams({ sinceDays: 0 });
|
||||
assert.deepEqual(layer.getParams(), {
|
||||
mapillary: true,
|
||||
pano: 'flat',
|
||||
sinceDays: 0,
|
||||
});
|
||||
layer.setParams({ mapillary: false });
|
||||
assert.equal(layer.getParams().mapillary, false);
|
||||
});
|
||||
|
||||
test('setParams ignores unknown keys and malformed values', async (t) => {
|
||||
const { layer } = await startLayer(t);
|
||||
layer.setParams({ pano: 'pano', sinceDays: 730 });
|
||||
layer.setParams({ mapillary: '0', kartaview: true, pano: 'weird', x: 1 });
|
||||
layer.setParams({ sinceDays: -3 });
|
||||
layer.setParams(null);
|
||||
assert.deepEqual(layer.getParams(), {
|
||||
mapillary: true,
|
||||
pano: 'pano',
|
||||
sinceDays: 730,
|
||||
});
|
||||
});
|
||||
|
||||
test('switching Mapillary on while the layer is off draws nothing', async (t) => {
|
||||
const { layer, viewer, source, photoViewer } = await startLayer(t, {
|
||||
view: true,
|
||||
enable: false,
|
||||
});
|
||||
layer.attachViewerHost({});
|
||||
layer.setParams({ mapillary: false });
|
||||
layer.setParams({ mapillary: true });
|
||||
await settle();
|
||||
assert.equal(source.calls.tiles.length, 0, 'no coverage drawn');
|
||||
assert.deepEqual(viewer.credits, [], 'no credit shown');
|
||||
assert.equal(photoViewer.calls.mount, 0, 'no viewer stood up');
|
||||
assert.equal(layer.getUIState().providers[0].on, true, 'the switch is kept');
|
||||
// Enabling the layer is what draws it.
|
||||
layer.enable(viewer);
|
||||
await settle();
|
||||
assert.ok(source.calls.tiles.length > 0);
|
||||
assert.equal(viewer.credits.length, 1);
|
||||
});
|
||||
|
||||
test('an empty provider composition enables and tears down without acquiring imagery', async (t) => {
|
||||
const { layer, viewer } = await startLayer(t, { providers: [] });
|
||||
assert.deepEqual(layer.providerIds, []);
|
||||
assert.deepEqual(layer.getUIState().providers, []);
|
||||
assert.equal(layer.getUIState().keyRequired, false);
|
||||
assert.match(
|
||||
layer.getUIState().coverage.hint,
|
||||
/No street-level imagery providers/,
|
||||
);
|
||||
layer.disable();
|
||||
layer.enable(viewer);
|
||||
assert.deepEqual(layer.getUIState().providers, []);
|
||||
});
|
||||
|
||||
// ── Several providers: one source at a time, Google 3D, nearest lookups ──
|
||||
|
||||
/**
|
||||
* A stand-in Cesium viewer the layer can be enabled on: a canvas for the
|
||||
* click handler, camera events, a primitive list and a credit display.
|
||||
@@ -55,8 +352,11 @@ function fakeViewer() {
|
||||
},
|
||||
creditDisplay: {
|
||||
addStaticCredit: (credit) => credits.push(credit),
|
||||
removeStaticCredit: (credit) =>
|
||||
credits.splice(credits.indexOf(credit), 1),
|
||||
removeStaticCredit: (credit) => {
|
||||
// Cesium ignores a credit it does not hold.
|
||||
const at = credits.indexOf(credit);
|
||||
if (at !== -1) credits.splice(at, 1);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -81,19 +381,6 @@ async function enabledLayer(t, providers = [fakeProvider()]) {
|
||||
return { layer, viewer };
|
||||
}
|
||||
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
let reject;
|
||||
const promise = new Promise((done, fail) => {
|
||||
resolve = done;
|
||||
reject = fail;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
/** A provider whose viewer reports a pose for every image it opens. */
|
||||
function posingProvider(options) {
|
||||
const provider = fakeProvider(options);
|
||||
const create = provider.create;
|
||||
@@ -148,35 +435,6 @@ function fakeMapStack(initial) {
|
||||
listenerCount: () => listeners.size,
|
||||
};
|
||||
}
|
||||
|
||||
test('FOLLOW is available only while the Google 3D map stack is active', () => {
|
||||
const layer = createStreetLevelLayer({ providers: [fakeProvider()] });
|
||||
assert.equal(
|
||||
layer.getUIState().street.followAvailable,
|
||||
false,
|
||||
'unknown stack: off',
|
||||
);
|
||||
|
||||
const stack = fakeMapStack('esri-imagery');
|
||||
layer.attachMapStackController(stack);
|
||||
assert.equal(layer.getUIState().street.followAvailable, false);
|
||||
layer.setFollow(true);
|
||||
assert.equal(layer.getUIState().street.follow, false, 'refused on Esri');
|
||||
|
||||
stack.switchTo('photoreal');
|
||||
assert.equal(layer.getUIState().street.followAvailable, true);
|
||||
layer.setFollow(true);
|
||||
assert.equal(layer.getUIState().street.follow, true);
|
||||
|
||||
stack.switchTo('osm');
|
||||
assert.equal(layer.getUIState().street.followAvailable, false);
|
||||
assert.equal(
|
||||
layer.getUIState().street.follow,
|
||||
false,
|
||||
'leaving Google 3D stops following',
|
||||
);
|
||||
});
|
||||
|
||||
test('the layer lets go of the map stack when destroyed or re-attached', () => {
|
||||
const layer = createStreetLevelLayer({ providers: [fakeProvider()] });
|
||||
const first = fakeMapStack('photoreal');
|
||||
@@ -190,60 +448,6 @@ test('the layer lets go of the map stack when destroyed or re-attached', () => {
|
||||
assert.equal(second.listenerCount(), 0);
|
||||
});
|
||||
|
||||
test('the CC BY-SA credit shows while a provider is on and goes with it', async (t) => {
|
||||
const { layer, viewer } = await enabledLayer(t);
|
||||
const shown = () => viewer.credits.map((credit) => credit.html);
|
||||
assert.deepEqual(shown(), [MAPILLARY_CREDIT_HTML], 'shown on activate');
|
||||
assert.match(shown()[0], /CC BY-SA 4\.0/);
|
||||
layer.setProviderEnabled('mapillary', false);
|
||||
assert.deepEqual(shown(), [], 'hidden with the provider');
|
||||
layer.setProviderEnabled('mapillary', true);
|
||||
assert.deepEqual(shown(), [MAPILLARY_CREDIT_HTML]);
|
||||
layer.disable();
|
||||
assert.deepEqual(shown(), [], 'every credit goes with the layer');
|
||||
});
|
||||
|
||||
test('getStats tells a rejected key from a missing one', async (t) => {
|
||||
const provider = fakeProvider();
|
||||
const { layer } = await enabledLayer(t, [provider]);
|
||||
provider.stats.loading = true;
|
||||
assert.equal(layer.getStats().loadingLabel, 'loading coverage...');
|
||||
assert.equal(layer.getStats().keyRequired, false);
|
||||
|
||||
provider.stats.keyRequired = true;
|
||||
assert.deepEqual(
|
||||
[layer.getStats().loadingLabel, layer.getStats().error],
|
||||
['KEY REQUIRED', 'KEY REQUIRED'],
|
||||
);
|
||||
|
||||
// A rejected key gates the provider exactly like a missing one.
|
||||
provider.stats.keyRequired = false;
|
||||
provider.stats.keyRejected = true;
|
||||
provider.stats.error = 'Mapillary rejected MAPILLARY_CLIENT_TOKEN';
|
||||
const stats = layer.getStats();
|
||||
assert.equal(stats.keyRequired, true);
|
||||
assert.equal(stats.loadingLabel, 'KEY REJECTED');
|
||||
assert.equal(stats.error, 'Mapillary rejected MAPILLARY_CLIENT_TOKEN');
|
||||
const ui = layer.getUIState();
|
||||
assert.equal(ui.keyRequired, true);
|
||||
assert.equal(ui.keyRejected, true);
|
||||
assert.equal(ui.providers[0].keyRequired, true);
|
||||
});
|
||||
|
||||
test('switching a provider off deactivates it and closes the image it shows', async (t) => {
|
||||
const provider = fakeProvider();
|
||||
const { layer } = await enabledLayer(t, [provider]);
|
||||
layer.attachViewerHost({});
|
||||
assert.equal(await layer.openImage('mapillary', 'img1'), true);
|
||||
assert.deepEqual(provider.calls.open, ['img1']);
|
||||
assert.equal(layer.getUIState().street.open, true);
|
||||
layer.setProviderEnabled('mapillary', false);
|
||||
assert.equal(provider.calls.deactivate, 1);
|
||||
assert.equal(provider.calls.unmount, 1, 'its viewer is released');
|
||||
assert.equal(layer.getUIState().street.open, false);
|
||||
assert.equal(layer.getUIState().street.providerId, null);
|
||||
});
|
||||
|
||||
test('openNearest without the panel reports it instead of loading forever', async (t) => {
|
||||
const provider = fakeProvider({ nearestImage: async () => 'img1' });
|
||||
const { layer } = await enabledLayer(t, [provider]);
|
||||
@@ -299,40 +503,6 @@ test('a provider withdraws only the error it reported', async (t) => {
|
||||
actions.reportError(null);
|
||||
assert.match(layer.getUIState().street.error, /Open the Street Level panel/);
|
||||
});
|
||||
|
||||
test('closing the photo stops the globe flying to it', async (t) => {
|
||||
const { layer, viewer } = await enabledLayer(t, [posingProvider()]);
|
||||
layer.attachViewerHost({});
|
||||
assert.equal(await layer.openImage('mapillary', 'img1'), true);
|
||||
assert.equal(viewer.flights.started, 1, 'the photo is framed');
|
||||
layer.closeViewer();
|
||||
assert.equal(viewer.flights.cancelled, 1);
|
||||
});
|
||||
|
||||
test('switching the layer off or destroying it stops the framing flight', async (t) => {
|
||||
const { layer, viewer } = await enabledLayer(t, [posingProvider()]);
|
||||
layer.attachViewerHost({});
|
||||
await layer.openImage('mapillary', 'img1');
|
||||
layer.disable();
|
||||
assert.equal(viewer.flights.cancelled, 1, 'layer off');
|
||||
|
||||
layer.enable(viewer);
|
||||
await layer.openImage('mapillary', 'img2');
|
||||
layer.destroy();
|
||||
assert.equal(viewer.flights.cancelled, 2, 'destroyed');
|
||||
});
|
||||
|
||||
test('closing the photo leaves a newer navigation flight alone', async (t) => {
|
||||
const { layer, viewer } = await enabledLayer(t, [posingProvider()]);
|
||||
layer.attachViewerHost({});
|
||||
await layer.openImage('mapillary', 'img1');
|
||||
assert.equal(viewer.flights.started, 1, 'the photo is being framed');
|
||||
// A search result flies the globe elsewhere before the framing lands.
|
||||
viewer.camera.flyToBoundingSphere(null, {});
|
||||
layer.closeViewer();
|
||||
assert.equal(viewer.flights.cancelled, 0, 'the search flight keeps going');
|
||||
});
|
||||
|
||||
test('an older nearest lookup that answers late cannot replace a newer one', async (t) => {
|
||||
const { lookups, nearestImage } = slowLookups();
|
||||
const provider = fakeProvider({ nearestImage });
|
||||
@@ -395,54 +565,6 @@ test('closing the viewer or switching the layer off retires a nearest lookup', a
|
||||
assert.equal(lookups[1].signal?.aborted, true, 'layer off aborts it too');
|
||||
});
|
||||
|
||||
test('the Mapillary provider hands the lookup signal to its source', async () => {
|
||||
const requests = [];
|
||||
const source = {
|
||||
hasToken: () => true,
|
||||
getStatus: async () => ({ configured: true }),
|
||||
getTile: async () => new Uint8Array(0),
|
||||
getSequenceImages: async () => [],
|
||||
nearestImages: async (query, options) => {
|
||||
requests.push(options);
|
||||
return [{ id: 7, is_pano: false, captured_at: 10 }];
|
||||
},
|
||||
};
|
||||
const instance = createMapillaryProvider({ source }).create({
|
||||
services: {},
|
||||
getFilter: () => ({ pano: 'all', sinceMs: null }),
|
||||
isActive: () => true,
|
||||
notify() {},
|
||||
actions: { openImage() {}, reportError() {} },
|
||||
});
|
||||
const { signal } = new AbortController();
|
||||
assert.equal(
|
||||
await instance.nearestImage({ lat: 1, lon: 2 }, { signal }),
|
||||
'7',
|
||||
);
|
||||
assert.equal(requests[0]?.signal, signal);
|
||||
});
|
||||
|
||||
test('setParams takes "any date" (0 days) and a provider switch over the current values (share-link defaults)', async (t) => {
|
||||
const provider = fakeProvider();
|
||||
const { layer } = await enabledLayer(t, [provider]);
|
||||
layer.setParams({ pano: 'flat', sinceDays: 365 });
|
||||
assert.equal(layer.getParams().sinceDays, 365);
|
||||
layer.setParams({ sinceDays: 0 });
|
||||
assert.deepEqual(layer.getParams(), {
|
||||
mapillary: true,
|
||||
pano: 'flat',
|
||||
sinceDays: 0,
|
||||
});
|
||||
assert.equal(
|
||||
provider.filters.at(-1).sinceMs,
|
||||
null,
|
||||
'providers drop the cut-off',
|
||||
);
|
||||
layer.setParams({ mapillary: false });
|
||||
assert.equal(layer.getParams().mapillary, false);
|
||||
});
|
||||
|
||||
/** A layer initialised on a stand-in viewer but never enabled. */
|
||||
function initialisedLayer(t, providers) {
|
||||
const saved = globalThis.document;
|
||||
const drawing = new Proxy({}, { get: () => () => ({ addColorStop() {} }) });
|
||||
@@ -635,7 +757,7 @@ test('one imagery source at a time: lighting one darkens the other, either way',
|
||||
assert.deepEqual(lit(), []);
|
||||
});
|
||||
|
||||
test('Street View appears only on the Google 3D map: chip, legend, credit, source and images', async (t) => {
|
||||
test('Street View appears only on the Google 3D map: chip, credit, source and images', async (t) => {
|
||||
const { layer, viewer, stack } = await withStreetView(t, {
|
||||
mapStack: 'esri-imagery',
|
||||
});
|
||||
@@ -646,7 +768,6 @@ test('Street View appears only on the Google 3D map: chip, legend, credit, sourc
|
||||
const credited = () =>
|
||||
viewer.credits.some((credit) => /Street View imagery/.test(credit.html));
|
||||
assert.deepEqual(listed(), ['mapillary'], 'no Street View chip');
|
||||
assert.ok(!ui().legend.some((item) => item.key === 'google'));
|
||||
assert.equal(credited(), false, 'no Google credit on another map');
|
||||
assert.equal(layer.source, 'Mapillary');
|
||||
assert.equal(layer.getStats().source, 'Mapillary');
|
||||
@@ -668,6 +789,62 @@ test('Street View appears only on the Google 3D map: chip, legend, credit, sourc
|
||||
assert.deepEqual(listed(), ['mapillary']);
|
||||
});
|
||||
|
||||
test('under Google 3D the ground is measured again once its tiles load, without a move', async (t) => {
|
||||
const saved = globalThis.document;
|
||||
globalThis.document = new EventTarget();
|
||||
t.after(() => {
|
||||
globalThis.document = saved;
|
||||
});
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const viewer = fakeViewer();
|
||||
// 4 km up the ellipsoid, 2 km over a city whose tiles are still loading.
|
||||
let ground;
|
||||
viewer.camera.positionCartographic = { height: 4000 };
|
||||
viewer.scene.globe = { show: false };
|
||||
viewer.scene.sampleHeightSupported = true;
|
||||
viewer.scene.sampleHeight = () => ground;
|
||||
const layer = createStreetLevelLayer({ providers: [fakeProvider()] });
|
||||
layer.init(viewer);
|
||||
layer.enable(viewer);
|
||||
t.after(() => layer.destroy());
|
||||
assert.equal(layer.getUIState().groundClickReady, false);
|
||||
|
||||
ground = 2000;
|
||||
t.mock.timers.tick(1000);
|
||||
assert.equal(layer.getUIState().groundClickReady, true);
|
||||
});
|
||||
|
||||
test('an unknown ground is measured only a few times, and not after the layer is off', (t) => {
|
||||
const saved = globalThis.document;
|
||||
globalThis.document = new EventTarget();
|
||||
t.after(() => {
|
||||
globalThis.document = saved;
|
||||
});
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const viewer = fakeViewer();
|
||||
let samples = 0;
|
||||
viewer.camera.positionCartographic = { height: 4000 };
|
||||
viewer.scene.globe = { show: false };
|
||||
viewer.scene.sampleHeightSupported = true;
|
||||
viewer.scene.sampleHeight = () => {
|
||||
samples++;
|
||||
return undefined;
|
||||
};
|
||||
const layer = createStreetLevelLayer({ providers: [fakeProvider()] });
|
||||
layer.init(viewer);
|
||||
layer.enable(viewer);
|
||||
t.after(() => layer.destroy());
|
||||
t.mock.timers.tick(60_000);
|
||||
const measured = samples;
|
||||
assert.ok(measured > 1 && measured <= 12, `measured ${measured} times`);
|
||||
|
||||
viewer.camera.moveEnd.raiseEvent();
|
||||
layer.disable();
|
||||
const atDisable = samples;
|
||||
t.mock.timers.tick(60_000);
|
||||
assert.equal(samples, atDisable, 'no measuring once the layer is off');
|
||||
});
|
||||
|
||||
test('the UI state says when a click (or the button) can open Street View here', async (t) => {
|
||||
const { layer } = await withStreetView(t);
|
||||
assert.equal(typeof layer.getUIState().groundClickReady, 'boolean');
|
||||
@@ -825,20 +1002,6 @@ test('with no Mapillary token, the layer points at Street View, on Google 3D onl
|
||||
assert.equal(layer.getStats().keyRequired, false);
|
||||
});
|
||||
|
||||
test('an empty provider composition enables and tears down without acquiring imagery', async (t) => {
|
||||
const { layer, viewer } = await enabledLayer(t, []);
|
||||
assert.deepEqual(layer.providerIds, []);
|
||||
assert.deepEqual(layer.getUIState().providers, []);
|
||||
assert.equal(layer.getUIState().keyRequired, false);
|
||||
assert.match(
|
||||
layer.getUIState().coverage.hint,
|
||||
/No street-level imagery providers/,
|
||||
);
|
||||
layer.disable();
|
||||
layer.enable(viewer);
|
||||
assert.deepEqual(layer.getUIState().providers, []);
|
||||
});
|
||||
|
||||
test('a failed lookup’s error goes when another source is lit; a provider can open an image without framing', async (t) => {
|
||||
const failing = fakeProvider({
|
||||
nearestImage: async () => {
|
||||
@@ -890,7 +1053,7 @@ test('a provider’s openNearest (a line click) asks only that provider and leav
|
||||
assert.equal(viewer.flights.started, 0, 'the user picked the spot');
|
||||
});
|
||||
|
||||
test('KartaView through the core: its own swatch without Selected, its lines counted as streets', async (t) => {
|
||||
test('KartaView through the core: its lines counted as streets', async (t) => {
|
||||
const kartaView = createKartaViewProvider({
|
||||
source: {
|
||||
getTile: async () => ({ lines: [] }),
|
||||
@@ -906,15 +1069,7 @@ test('KartaView through the core: its own swatch without Selected, its lines cou
|
||||
layer.setProviderEnabled('kartaview', true);
|
||||
await settle();
|
||||
const ui = layer.getUIState();
|
||||
assert.deepEqual(
|
||||
ui.legend.map((entry) => entry.key),
|
||||
['kartaview'],
|
||||
);
|
||||
assert.equal(ui.coverage.label?.noun, 'streets');
|
||||
assert.equal(
|
||||
ui.providers.find((p) => p.id === 'kartaview').highlightsSequence,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('switching source by params (the chip path) stops a running lookup and clears its spinner and error', async (t) => {
|
||||
@@ -962,3 +1117,13 @@ test('switching source by params clears a failed lookup’s error; reapplying th
|
||||
second.setParams({ mapillary: false, other: true });
|
||||
assert.equal(second.getUIState().street.error, kept);
|
||||
});
|
||||
|
||||
test('the panel says why a lit Street View draws nothing off Google 3D', async (t) => {
|
||||
const { presentStreetLevelPanel } =
|
||||
await import('../../ui/streetLevelPresentation.js');
|
||||
const { layer, stack } = await withStreetView(t);
|
||||
layer.setProviderEnabled('google', true);
|
||||
stack.switchTo('esri-imagery');
|
||||
const view = presentStreetLevelPanel(layer.getUIState());
|
||||
assert.match(view.meta, /Google Street View needs the Google 3D map/);
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1,7 +1,6 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { positionMarkerGlyph, viewConeMarkerGlyph } from './glyphs.js';
|
||||
import { COLORS, POSITION_PICK_ID } from './policy.js';
|
||||
import { refineHeights } from './groundCast.js';
|
||||
import { createHorizonCull } from './view.js';
|
||||
|
||||
const SPRITE_ID = 'street-level:marker';
|
||||
@@ -22,10 +21,9 @@ function markerLook(style) {
|
||||
};
|
||||
}
|
||||
|
||||
/** On-globe marker for the open image; in terrain mode it stands on bare earth. */
|
||||
export function createMarker({ state, parts }) {
|
||||
/** On-globe marker for the open image, clamped to the ground. */
|
||||
export function createMarker({ state }) {
|
||||
const { render, sprites } = state.services;
|
||||
let last = null;
|
||||
|
||||
function requestRender() {
|
||||
render?.governorRequestRender?.('street-level-marker');
|
||||
@@ -47,38 +45,10 @@ export function createMarker({ state, parts }) {
|
||||
sprites?.registerSpriteCollection?.(SPRITE_ID, state.marker.collection);
|
||||
}
|
||||
|
||||
/**
|
||||
* Terrain-mode height (bare earth refined by mesh samples), or null to
|
||||
* clamp. Requests a missing terrain cell or mesh sample as a side effect.
|
||||
*/
|
||||
function castHeight(position) {
|
||||
const ground = parts?.groundCaster;
|
||||
if (state.surface !== 'terrain' || !ground) return null;
|
||||
const { lon, lat } = position;
|
||||
const dem = ground.groundAt(lon, lat);
|
||||
if (dem === null) {
|
||||
ground.prepare([[lon, lat]]).then((ready) => {
|
||||
if (ready && last?.position === position)
|
||||
set(position, last.bearing, last.style);
|
||||
});
|
||||
return null;
|
||||
}
|
||||
const mesh = parts.meshSampler?.meshAt(lon, lat);
|
||||
if (mesh === undefined) parts.meshSampler?.request([[lon, lat]]);
|
||||
return refineHeights([{ dem, mesh }])[0];
|
||||
}
|
||||
|
||||
// A new mesh sample may move the marker onto (or off) the road surface.
|
||||
parts?.meshSampler?.onSampled(() => {
|
||||
if (last && state.surface === 'terrain')
|
||||
set(last.position, last.bearing, last.style);
|
||||
});
|
||||
|
||||
/** Move (or create) the marker; null removes it. `style`: the provider's `marker`. */
|
||||
function set(position, bearing, style = null) {
|
||||
const collection = state.marker.collection;
|
||||
if (!collection) return;
|
||||
last = position ? { position, bearing, style } : null;
|
||||
if (!position) {
|
||||
collection.removeAll();
|
||||
state.marker.billboard = null;
|
||||
@@ -86,16 +56,7 @@ export function createMarker({ state, parts }) {
|
||||
requestRender();
|
||||
return;
|
||||
}
|
||||
const height = castHeight(position);
|
||||
const cartesian = Cesium.Cartesian3.fromDegrees(
|
||||
position.lon,
|
||||
position.lat,
|
||||
height ?? 0,
|
||||
);
|
||||
const heightReference =
|
||||
height === null
|
||||
? Cesium.HeightReference.CLAMP_TO_GROUND
|
||||
: Cesium.HeightReference.NONE;
|
||||
const cartesian = Cesium.Cartesian3.fromDegrees(position.lon, position.lat);
|
||||
const look = markerLook(style);
|
||||
if (state.marker.billboard && state.marker.look !== look.key) {
|
||||
collection.remove?.(state.marker.billboard);
|
||||
@@ -111,12 +72,11 @@ export function createMarker({ state, parts }) {
|
||||
width: look.size,
|
||||
height: look.size,
|
||||
alignedAxis: Cesium.Cartesian3.UNIT_Z,
|
||||
heightReference,
|
||||
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
verticalOrigin: Cesium.VerticalOrigin.CENTER,
|
||||
});
|
||||
} else {
|
||||
state.marker.billboard.heightReference = heightReference;
|
||||
state.marker.billboard.position = cartesian;
|
||||
}
|
||||
state.marker.billboard.rotation = -Cesium.Math.toRadians(bearing || 0);
|
||||
@@ -139,17 +99,11 @@ export function createMarker({ state, parts }) {
|
||||
state.marker.billboard = null;
|
||||
}
|
||||
|
||||
/** Re-place the marker after the surface mode changes. */
|
||||
function setSurface() {
|
||||
if (last) set(last.position, last.bearing, last.style);
|
||||
}
|
||||
|
||||
return {
|
||||
ensure,
|
||||
set,
|
||||
clear: () => set(null),
|
||||
setVisible,
|
||||
setSurface,
|
||||
destroy,
|
||||
};
|
||||
}
|
||||
@@ -2,17 +2,9 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createMarker } from './marker.js';
|
||||
import { GROUND_CAST_LIFT_M, MESH_LIFT_M } from './groundCast.js';
|
||||
|
||||
// The marker glyph draws on a canvas; Node has none, so drawing is a no-op.
|
||||
const noop = () => {};
|
||||
const context2d = new Proxy({}, { get: () => noop, set: () => true });
|
||||
globalThis.document ??= {
|
||||
createElement: () => ({ getContext: () => context2d }),
|
||||
};
|
||||
|
||||
/** A viewer whose camera is 20,000 km over `view`, and its pre-render listeners. */
|
||||
function setup({ parts = {}, surface = 'draped' } = {}) {
|
||||
function setup() {
|
||||
const view = { lon: -121.49, lat: 38.58, height: 20_000_000 };
|
||||
const preRender = new Set();
|
||||
const viewer = {
|
||||
@@ -34,7 +26,6 @@ function setup({ parts = {}, surface = 'draped' } = {}) {
|
||||
const state = {
|
||||
services: {},
|
||||
viewer,
|
||||
surface,
|
||||
marker: {
|
||||
// Stands in for the billboard collection; ensure() keeps it.
|
||||
collection: {
|
||||
@@ -46,7 +37,7 @@ function setup({ parts = {}, surface = 'draped' } = {}) {
|
||||
billboard: null,
|
||||
},
|
||||
};
|
||||
const marker = createMarker({ state, parts });
|
||||
const marker = createMarker({ state });
|
||||
const frame = () => {
|
||||
for (const listener of [...preRender]) listener();
|
||||
};
|
||||
@@ -79,114 +70,23 @@ test('destroying the marker stops the horizon cull', () => {
|
||||
assert.equal(preRender.size, 0);
|
||||
});
|
||||
|
||||
/**
|
||||
* Bare earth from a ground caster (null until its cell is fetched) and the
|
||||
* sampled Google 3D surface from a mesh sampler (undefined until sampled).
|
||||
*/
|
||||
function terrainParts({ dem = null, mesh } = {}) {
|
||||
const ground = { dem, prepares: [] };
|
||||
const sampler = { mesh, requests: [], listeners: [] };
|
||||
return {
|
||||
ground,
|
||||
sampler,
|
||||
parts: {
|
||||
groundCaster: {
|
||||
groundAt: () => ground.dem,
|
||||
prepare(points) {
|
||||
let resolve;
|
||||
const promise = new Promise((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
ground.prepares.push({ points, resolve });
|
||||
return promise;
|
||||
},
|
||||
},
|
||||
meshSampler: {
|
||||
meshAt: () => sampler.mesh,
|
||||
request: (points) => sampler.requests.push(points),
|
||||
onSampled: (listener) => sampler.listeners.push(listener),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const heightOf = (billboard) =>
|
||||
Cesium.Cartographic.fromCartesian(billboard.position).height;
|
||||
const SPOT = { lon: -121.49, lat: 38.58 };
|
||||
|
||||
test('in terrain mode the marker stands on the sampled road surface (mesh near bare earth)', () => {
|
||||
const { parts } = terrainParts({ dem: 10, mesh: 11.5 });
|
||||
const { state, marker } = setup({ parts, surface: 'terrain' });
|
||||
marker.set(SPOT, 45);
|
||||
const billboard = state.marker.billboard;
|
||||
assert.equal(billboard.heightReference, Cesium.HeightReference.NONE);
|
||||
assert.ok(
|
||||
Math.abs(heightOf(billboard) - (11.5 + MESH_LIFT_M)) < 1e-3,
|
||||
`height ${heightOf(billboard)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('in terrain mode without a mesh sample the marker stands on bare earth and asks for one', () => {
|
||||
const { parts, sampler } = terrainParts({ dem: 10 });
|
||||
const { state, marker } = setup({ parts, surface: 'terrain' });
|
||||
marker.set(SPOT, 45);
|
||||
const billboard = state.marker.billboard;
|
||||
assert.equal(billboard.heightReference, Cesium.HeightReference.NONE);
|
||||
assert.ok(
|
||||
Math.abs(heightOf(billboard) - (10 + GROUND_CAST_LIFT_M)) < 1e-3,
|
||||
`height ${heightOf(billboard)}`,
|
||||
);
|
||||
assert.deepEqual(sampler.requests, [[[SPOT.lon, SPOT.lat]]]);
|
||||
// The sample lands: the marker moves onto the road surface.
|
||||
sampler.mesh = 10.5;
|
||||
for (const listener of sampler.listeners) listener();
|
||||
assert.ok(Math.abs(heightOf(billboard) - (10.5 + MESH_LIFT_M)) < 1e-3);
|
||||
});
|
||||
|
||||
test('in terrain mode with no ground yet the marker clamps, then stands up once the ground arrives', async () => {
|
||||
const { parts, ground } = terrainParts({ mesh: 11 });
|
||||
const { state, marker } = setup({ parts, surface: 'terrain' });
|
||||
test('the marker is clamped to the ground and turned to the bearing', () => {
|
||||
const { state, marker } = setup();
|
||||
marker.set(SPOT, 45);
|
||||
const billboard = state.marker.billboard;
|
||||
assert.equal(
|
||||
billboard.heightReference,
|
||||
Cesium.HeightReference.CLAMP_TO_GROUND,
|
||||
);
|
||||
assert.deepEqual(ground.prepares[0].points, [[SPOT.lon, SPOT.lat]]);
|
||||
ground.dem = 10;
|
||||
ground.prepares[0].resolve(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(billboard.heightReference, Cesium.HeightReference.NONE);
|
||||
assert.ok(Math.abs(heightOf(billboard) - (11 + MESH_LIFT_M)) < 1e-3);
|
||||
assert.equal(billboard.rotation, -Cesium.Math.toRadians(45), 'bearing kept');
|
||||
});
|
||||
|
||||
test('ground that arrives after the marker moved on does not move it back', async () => {
|
||||
const { parts, ground } = terrainParts();
|
||||
const { state, marker } = setup({ parts, surface: 'terrain' });
|
||||
marker.set(SPOT, 0);
|
||||
const elsewhere = { lon: -121.5, lat: 38.6 };
|
||||
marker.set(elsewhere, 0);
|
||||
ground.dem = 10;
|
||||
ground.prepares[0].resolve(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
const placed = Cesium.Cartographic.fromCartesian(
|
||||
state.marker.billboard.position,
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(Cesium.Math.toDegrees(placed.longitude) - elsewhere.lon) < 1e-9,
|
||||
);
|
||||
});
|
||||
|
||||
test('draped mode clamps the marker to the globe whatever the ground says', () => {
|
||||
const { parts, ground } = terrainParts({ dem: 10, mesh: 11 });
|
||||
const { state, marker } = setup({ parts, surface: 'draped' });
|
||||
marker.set(SPOT, 0);
|
||||
assert.equal(
|
||||
state.marker.billboard.heightReference,
|
||||
Cesium.HeightReference.CLAMP_TO_GROUND,
|
||||
);
|
||||
assert.equal(ground.prepares.length, 0);
|
||||
assert.equal(billboard.rotation, -Cesium.Math.toRadians(45));
|
||||
// Moving it keeps the same billboard.
|
||||
marker.set({ lon: -121.5, lat: 38.6 }, 90);
|
||||
assert.equal(state.marker.billboard, billboard);
|
||||
const placed = Cesium.Cartographic.fromCartesian(billboard.position);
|
||||
assert.ok(Math.abs(Cesium.Math.toDegrees(placed.longitude) + 121.5) < 1e-9);
|
||||
assert.equal(billboard.rotation, -Cesium.Math.toRadians(90));
|
||||
});
|
||||
|
||||
test('a Street View photo gets a blue view cone; a Mapillary photo GEV\u2019s pin', () => {
|
||||
@@ -205,9 +105,6 @@ test('a Street View photo gets a blue view cone; a Mapillary photo GEV\u2019s pi
|
||||
// Dragging the panorama keeps the look and the billboard.
|
||||
marker.set({ lon: -121.49, lat: 38.58 }, 90, streetView);
|
||||
assert.equal(state.marker.billboard, cone);
|
||||
// Re-placed for a surface change: still the cone.
|
||||
marker.setSurface();
|
||||
assert.equal(state.marker.billboard, cone);
|
||||
// A Mapillary photo next (its provider has no marker): the pin.
|
||||
marker.set({ lon: -121.49, lat: 38.58 }, 90, undefined);
|
||||
assert.equal(state.marker.billboard.imageId, 'sl-position:pin');
|
||||
|
||||
@@ -1,371 +0,0 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { metresBetween, whenIdle } from './view.js';
|
||||
|
||||
/**
|
||||
* Google 3D surface heights for ground casting (groundCast.js refineHeights):
|
||||
* one cached `scene.sampleHeight` probe per ~11 m cell. Each probe renders a
|
||||
* pick pass (1–3 ms), so probes run in budgeted idle slices, nearest the
|
||||
* camera first and only within MESH_SAMPLE_RADIUS_M. Like the application's
|
||||
* mesh floor, a probe waits for the visible tileset to finish streaming and a
|
||||
* sample counts only within the shared window around a real bare-earth height;
|
||||
* anything else is a miss, retried later. Probes hit 3D tilesets only.
|
||||
*/
|
||||
|
||||
/** Cell size, in degrees (~11 m): samples are shared within a cell. */
|
||||
export const MESH_CELL_DEG = 0.0001;
|
||||
/** Cells farther than this from the point under the camera are not probed. */
|
||||
export const MESH_SAMPLE_RADIUS_M = 900;
|
||||
/** Main-thread time per idle slice, taking requests in and ranking included. */
|
||||
export const MESH_SAMPLE_BUDGET_MS = 6;
|
||||
/** The queue is re-ranked once the camera has moved this far, in metres. */
|
||||
export const MESH_RERANK_M = 100;
|
||||
/** A cell whose probe missed is tried again after this long. */
|
||||
export const MESH_MISS_RETRY_MS = 8000;
|
||||
/** While the visible tileset is streaming, the next probe waits this long. */
|
||||
export const MESH_STREAMING_WAIT_MS = 500;
|
||||
/**
|
||||
* A sampled cell is probed again once the camera is half as far from it as at
|
||||
* its last probe (finer tiles have loaded by then), down to this distance:
|
||||
* at most a handful of probes per cell.
|
||||
*/
|
||||
export const MESH_REFRESH_MIN_M = 40;
|
||||
/** A refreshed sample that moved less than this is not redrawn. */
|
||||
const MESH_REFRESH_CHANGE_M = 0.5;
|
||||
/** Cached cells (and remembered misses) before they are dropped and refilled. */
|
||||
const MESH_CACHE_MAX = 80_000;
|
||||
/** Listeners hear about new samples at most this often. */
|
||||
const MESH_NOTIFY_MS = 700;
|
||||
|
||||
const cellOf = (value) => Math.round(value / MESH_CELL_DEG);
|
||||
|
||||
/** Numeric key of the cell holding a point (exact within 2^53; no per-point string). */
|
||||
export const meshCellKey = (lon, lat) =>
|
||||
(cellOf(lon) + 1_800_001) * 2_000_000 + cellOf(lat) + 1_000_000;
|
||||
|
||||
/** Add a queued cell to a min-heap on `distance`. */
|
||||
function heapPush(heap, cell) {
|
||||
let i = heap.push(cell) - 1;
|
||||
while (i > 0) {
|
||||
const parent = (i - 1) >> 1;
|
||||
if (heap[parent].distance <= cell.distance) break;
|
||||
heap[i] = heap[parent];
|
||||
i = parent;
|
||||
}
|
||||
heap[i] = cell;
|
||||
}
|
||||
|
||||
/** Restore the heap below `i` after its cell was replaced. */
|
||||
function siftDown(heap, i) {
|
||||
const cell = heap[i];
|
||||
const n = heap.length;
|
||||
for (;;) {
|
||||
let child = 2 * i + 1;
|
||||
if (child >= n) break;
|
||||
if (child + 1 < n && heap[child + 1].distance < heap[child].distance)
|
||||
child++;
|
||||
if (heap[child].distance >= cell.distance) break;
|
||||
heap[i] = heap[child];
|
||||
i = child;
|
||||
}
|
||||
heap[i] = cell;
|
||||
}
|
||||
|
||||
function heapPop(heap) {
|
||||
const top = heap[0];
|
||||
const last = heap.pop();
|
||||
if (heap.length) {
|
||||
heap[0] = last;
|
||||
siftDown(heap, 0);
|
||||
}
|
||||
return top;
|
||||
}
|
||||
|
||||
/** Every top-level primitive that is not a 3D tileset: what a probe skips. */
|
||||
function overlays(scene) {
|
||||
const out = [];
|
||||
const primitives = scene.primitives;
|
||||
for (let i = 0; i < primitives.length; i++) {
|
||||
const primitive = primitives.get(i);
|
||||
if (!(primitive instanceof Cesium.Cesium3DTileset)) out.push(primitive);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* `groundAt(lon, lat)` is the bare-earth height or null, `withinPrior(height,
|
||||
* prior)` the application's mesh window, and `tilesReady(scene)` whether the
|
||||
* visible tileset has finished streaming.
|
||||
* @param {{getViewer: () => object|null, groundAt?: Function, withinPrior?: Function, tilesReady?: Function, budgetMs?: number, radiusM?: number, cacheMax?: number, now?: () => number}} options
|
||||
*/
|
||||
export function createMeshSampler({
|
||||
getViewer,
|
||||
groundAt = null,
|
||||
withinPrior = null,
|
||||
tilesReady = null,
|
||||
budgetMs = MESH_SAMPLE_BUDGET_MS,
|
||||
radiusM = MESH_SAMPLE_RADIUS_M,
|
||||
cacheMax = MESH_CACHE_MAX,
|
||||
now = () => performance.now(),
|
||||
}) {
|
||||
/** cell key → sampled mesh height (ellipsoidal metres). */
|
||||
const heights = new Map();
|
||||
/** cell key → camera distance (m) at the cell's last probe. */
|
||||
const probedFrom = new Map();
|
||||
/** cell key → retry time for probes that hit nothing; bounded like the height cache. */
|
||||
const misses = new Map();
|
||||
/** cell key → queued cell, so a cell is queued once. */
|
||||
const wanted = new Map();
|
||||
/** Queued {key, lon, lat, distance} cells, a min-heap on distance from `rankedFrom`. */
|
||||
let queue = [];
|
||||
/** Requests not yet taken in: {points, next, centre} (centre: the camera then). */
|
||||
let intake = [];
|
||||
/** Where the camera was when the queue was ranked. */
|
||||
let rankedFrom = null;
|
||||
/** Running estimate of one probe's cost, so a slice stops before overrunning. */
|
||||
let probeMs = 1;
|
||||
const listeners = new Set();
|
||||
let enabled = false;
|
||||
let running = false;
|
||||
let fresh = [];
|
||||
let notifyTimer = null;
|
||||
|
||||
/** The sampled mesh height for a point, or undefined when not sampled. */
|
||||
function meshAt(lon, lat) {
|
||||
return heights.get(meshCellKey(lon, lat));
|
||||
}
|
||||
|
||||
/** The point under the camera, with the camera's height, or null. */
|
||||
function cameraCentre() {
|
||||
const carto = getViewer()?.camera?.positionCartographic;
|
||||
if (!carto) return null;
|
||||
return {
|
||||
lon: Cesium.Math.toDegrees(carto.longitude),
|
||||
lat: Cesium.Math.toDegrees(carto.latitude),
|
||||
height: carto.height,
|
||||
};
|
||||
}
|
||||
|
||||
/** Camera distance (m) to a cell at `height`, from its ground distance. */
|
||||
function cameraDistance(ground, centre, height) {
|
||||
const above = (centre.height ?? 0) - (height ?? 0);
|
||||
return Number.isFinite(above)
|
||||
? Math.hypot(ground, Math.max(0, above))
|
||||
: ground;
|
||||
}
|
||||
|
||||
/** A sampled cell is probed again once the camera is half as far as before. */
|
||||
function dueForRefresh(key, distance) {
|
||||
const last = probedFrom.get(key);
|
||||
return last > MESH_REFRESH_MIN_M && distance < last / 2;
|
||||
}
|
||||
|
||||
/** A probe result the application would accept, or undefined. */
|
||||
function validated(height, lon, lat) {
|
||||
if (!Number.isFinite(height)) return undefined;
|
||||
if (!groundAt) return height;
|
||||
const prior = groundAt(lon, lat);
|
||||
if (!Number.isFinite(prior)) return undefined;
|
||||
return !withinPrior || withinPrior(height, prior) ? height : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask for the cells under [lon, lat] points; the list is read later, so do
|
||||
* not mutate it. Out-of-range cells are dropped; callers ask again on move.
|
||||
*/
|
||||
function request(points) {
|
||||
if (!enabled || !points?.length) return;
|
||||
const centre = cameraCentre();
|
||||
if (!centre) return;
|
||||
intake.push({ points, next: 0, centre });
|
||||
schedule();
|
||||
}
|
||||
|
||||
/** Queue requested cells, oldest request first, until the slice reaches `until`. */
|
||||
function takeIn(until) {
|
||||
const time = Date.now();
|
||||
while (intake.length) {
|
||||
const pending = intake[0];
|
||||
const { points, centre } = pending;
|
||||
while (pending.next < points.length) {
|
||||
if ((pending.next & 255) === 255 && now() > until) return;
|
||||
const [lon, lat] = points[pending.next++];
|
||||
if (!Number.isFinite(lon) || !Number.isFinite(lat)) continue;
|
||||
const key = meshCellKey(lon, lat);
|
||||
if (wanted.has(key)) continue;
|
||||
const cell = {
|
||||
key,
|
||||
lon: cellOf(lon) * MESH_CELL_DEG,
|
||||
lat: cellOf(lat) * MESH_CELL_DEG,
|
||||
distance: 0,
|
||||
};
|
||||
const ground = metresBetween(cell, centre);
|
||||
if (ground > radiusM) continue;
|
||||
if (
|
||||
heights.has(key) &&
|
||||
!dueForRefresh(key, cameraDistance(ground, centre, heights.get(key)))
|
||||
)
|
||||
continue;
|
||||
const retryAt = misses.get(key);
|
||||
if (retryAt !== undefined) {
|
||||
if (retryAt > time) continue;
|
||||
misses.delete(key);
|
||||
}
|
||||
cell.distance = metresBetween(cell, rankedFrom);
|
||||
wanted.set(key, cell);
|
||||
heapPush(queue, cell);
|
||||
}
|
||||
intake.shift();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rank the queue from where the camera is now, dropping cells it has left
|
||||
* out of range: one linear heapify, run only after the camera has moved.
|
||||
*/
|
||||
function rerank(centre) {
|
||||
rankedFrom = centre;
|
||||
const kept = [];
|
||||
for (const cell of queue) {
|
||||
cell.distance = metresBetween(cell, centre);
|
||||
if (cell.distance > radiusM) wanted.delete(cell.key);
|
||||
else kept.push(cell);
|
||||
}
|
||||
queue = kept;
|
||||
for (let i = (queue.length >> 1) - 1; i >= 0; i--) siftDown(queue, i);
|
||||
}
|
||||
|
||||
function clearQueue() {
|
||||
intake = [];
|
||||
wanted.clear();
|
||||
queue = [];
|
||||
rankedFrom = null;
|
||||
}
|
||||
|
||||
function schedule() {
|
||||
if (running || !enabled || !(queue.length || intake.length)) return;
|
||||
running = true;
|
||||
whenIdle(step, 300);
|
||||
}
|
||||
|
||||
function emit() {
|
||||
notifyTimer = null;
|
||||
const batch = fresh;
|
||||
fresh = [];
|
||||
for (const listener of [...listeners]) {
|
||||
try {
|
||||
listener(batch);
|
||||
} catch (error) {
|
||||
console.warn('[Data:StreetLevel] mesh listener error:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function step(deadline) {
|
||||
running = false;
|
||||
// The slice starts now: taking requests in and ranking count too.
|
||||
const started = now();
|
||||
const until =
|
||||
started +
|
||||
Math.min(budgetMs, Math.max(1, deadline?.timeRemaining?.() ?? budgetMs));
|
||||
const scene = getViewer()?.scene;
|
||||
const centre = cameraCentre();
|
||||
if (!enabled || !scene?.sampleHeightSupported || !centre) return;
|
||||
// Mid-stream tiles answer with coarse heights: wait for the stream.
|
||||
if (tilesReady && !tilesReady(scene)) {
|
||||
running = true;
|
||||
setTimeout(() => {
|
||||
running = false;
|
||||
schedule();
|
||||
}, MESH_STREAMING_WAIT_MS);
|
||||
return;
|
||||
}
|
||||
// Nearest first, ranked from where the camera was; out-of-range cells
|
||||
// are dropped (they stay bare earth).
|
||||
let worked = false;
|
||||
if (!rankedFrom || metresBetween(rankedFrom, centre) > MESH_RERANK_M) {
|
||||
rerank(centre);
|
||||
worked = true;
|
||||
}
|
||||
if (intake.length) {
|
||||
// Leave room for a probe, so a stream of requests cannot starve them.
|
||||
takeIn(until - probeMs);
|
||||
worked = true;
|
||||
}
|
||||
const exclude = overlays(scene);
|
||||
let probed = 0;
|
||||
while (queue.length) {
|
||||
// Stop before a probe would overrun the slice. A slice that did
|
||||
// nothing else always probes once, so the queue drains.
|
||||
if ((probed || worked) && now() + probeMs > until) break;
|
||||
const { key, lon, lat } = heapPop(queue);
|
||||
wanted.delete(key);
|
||||
// Ranked from up to MESH_RERANK_M away: check the range from here.
|
||||
const ground = metresBetween({ lon, lat }, centre);
|
||||
if (ground > radiusM) continue;
|
||||
probed++;
|
||||
const probeStarted = now();
|
||||
let height;
|
||||
try {
|
||||
height = scene.sampleHeight(
|
||||
Cesium.Cartographic.fromDegrees(lon, lat),
|
||||
exclude,
|
||||
);
|
||||
} catch {
|
||||
height = undefined;
|
||||
}
|
||||
probeMs = probeMs * 0.75 + (now() - probeStarted) * 0.25;
|
||||
height = validated(height, lon, lat);
|
||||
const previous = heights.get(key);
|
||||
if (height === undefined) {
|
||||
// A miss, or a failed refresh: the old sample (if any) and the
|
||||
// distance it was taken from stay, and the probe is retried after
|
||||
// the same cooldown.
|
||||
if (misses.size >= cacheMax) misses.clear();
|
||||
misses.set(key, Date.now() + MESH_MISS_RETRY_MS);
|
||||
continue;
|
||||
}
|
||||
if (previous === undefined && heights.size >= cacheMax) forget();
|
||||
misses.delete(key);
|
||||
heights.set(key, height);
|
||||
probedFrom.set(key, cameraDistance(ground, centre, height));
|
||||
if (
|
||||
previous === undefined ||
|
||||
Math.abs(height - previous) >= MESH_REFRESH_CHANGE_M
|
||||
)
|
||||
fresh.push([lon, lat]);
|
||||
}
|
||||
if (fresh.length && !notifyTimer)
|
||||
notifyTimer = setTimeout(emit, MESH_NOTIFY_MS);
|
||||
schedule();
|
||||
}
|
||||
|
||||
function forget() {
|
||||
heights.clear();
|
||||
probedFrom.clear();
|
||||
misses.clear();
|
||||
}
|
||||
|
||||
/** Probe only while overlays are cast (Google 3D at street zoom). */
|
||||
function setEnabled(on) {
|
||||
enabled = on === true;
|
||||
if (!enabled) clearQueue();
|
||||
else schedule();
|
||||
}
|
||||
|
||||
/** Hear about newly sampled cells, as [lon, lat] cell centres. */
|
||||
function onSampled(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
enabled = false;
|
||||
forget();
|
||||
clearQueue();
|
||||
listeners.clear();
|
||||
clearTimeout(notifyTimer);
|
||||
notifyTimer = null;
|
||||
}
|
||||
|
||||
return { meshAt, request, setEnabled, onSampled, destroy };
|
||||
}
|
||||
@@ -1,405 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
MESH_CELL_DEG,
|
||||
MESH_MISS_RETRY_MS,
|
||||
MESH_REFRESH_MIN_M,
|
||||
MESH_SAMPLE_BUDGET_MS,
|
||||
MESH_STREAMING_WAIT_MS,
|
||||
createMeshSampler,
|
||||
} from './meshSampler.js';
|
||||
import { meshFloorSampleWithinPrior } from '../../data/groundFloor.js';
|
||||
import { metresBetween } from './view.js';
|
||||
|
||||
const settle = (ms = 60) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const RAD = Math.PI / 180;
|
||||
|
||||
/** A scene whose mesh is 100 m everywhere except where `holes` say. */
|
||||
function fakeViewer({ holes = () => false } = {}) {
|
||||
const tileset = Object.create(Cesium.Cesium3DTileset.prototype);
|
||||
const overlay = { name: 'lines' };
|
||||
const probes = [];
|
||||
const scene = {
|
||||
sampleHeightSupported: true,
|
||||
primitives: {
|
||||
length: 2,
|
||||
get: (i) => [tileset, overlay][i],
|
||||
},
|
||||
sampleHeight(carto, exclude) {
|
||||
const lon = carto.longitude / RAD;
|
||||
const lat = carto.latitude / RAD;
|
||||
probes.push({ lon, lat, exclude });
|
||||
return holes(lon, lat) ? undefined : 100;
|
||||
},
|
||||
};
|
||||
return {
|
||||
probes,
|
||||
overlay,
|
||||
viewer: {
|
||||
scene,
|
||||
camera: {
|
||||
positionCartographic: { longitude: 10 * RAD, latitude: 50 * RAD },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('cells are probed once, nearest first, with overlays excluded', async () => {
|
||||
const { viewer, probes, overlay } = fakeViewer();
|
||||
const sampler = createMeshSampler({ getViewer: () => viewer });
|
||||
sampler.setEnabled(true);
|
||||
const heard = [];
|
||||
sampler.onSampled((batch) => heard.push(batch.length));
|
||||
sampler.request([
|
||||
[10.004, 50],
|
||||
[10.001, 50],
|
||||
[10.00101, 50.00001], // same ~11 m cell as the previous point
|
||||
]);
|
||||
await settle(1000);
|
||||
assert.equal(probes.length, 2);
|
||||
assert.ok(probes[0].lon < probes[1].lon, 'nearest the camera first');
|
||||
assert.deepEqual(probes[0].exclude, [overlay], 'only tilesets are hit');
|
||||
assert.equal(sampler.meshAt(10.001, 50), 100);
|
||||
assert.equal(sampler.meshAt(10.00101, 50.00001), 100);
|
||||
assert.deepEqual(heard, [2]);
|
||||
sampler.request([[10.001, 50]]);
|
||||
await settle();
|
||||
assert.equal(probes.length, 2, 'a sampled cell is not probed again');
|
||||
sampler.destroy();
|
||||
});
|
||||
|
||||
test('far cells are skipped, misses are not latched, and nothing runs while disabled', async () => {
|
||||
const { viewer, probes } = fakeViewer({ holes: (lon) => lon > 10.0049 });
|
||||
const sampler = createMeshSampler({ getViewer: () => viewer });
|
||||
sampler.request([[10.001, 50]]);
|
||||
await settle();
|
||||
assert.equal(probes.length, 0, 'disabled: nothing queued');
|
||||
sampler.setEnabled(true);
|
||||
sampler.request([
|
||||
[10.05, 50], // ~3.6 km away
|
||||
[10.005, 50], // a hole: tiles not streamed
|
||||
]);
|
||||
await settle();
|
||||
assert.equal(probes.length, 1);
|
||||
assert.equal(sampler.meshAt(10.05, 50), undefined);
|
||||
assert.equal(sampler.meshAt(10.005, 50), undefined);
|
||||
assert.ok(Math.abs(probes[0].lon - 10.005) < MESH_CELL_DEG);
|
||||
sampler.destroy();
|
||||
});
|
||||
|
||||
/** Probes run on a 16 ms timer headless; with mocked timers, run them now. */
|
||||
function probeNow(t) {
|
||||
t.mock.timers.tick(20);
|
||||
}
|
||||
|
||||
test('a missed cell is probed again after the retry window, not before', (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
|
||||
const { viewer, probes } = fakeViewer({ holes: () => true });
|
||||
const sampler = createMeshSampler({ getViewer: () => viewer });
|
||||
sampler.setEnabled(true);
|
||||
sampler.request([[10.001, 50]]);
|
||||
probeNow(t);
|
||||
assert.equal(probes.length, 1, 'probed, and missed');
|
||||
t.mock.timers.tick(MESH_MISS_RETRY_MS - 100);
|
||||
sampler.request([[10.001, 50]]);
|
||||
probeNow(t);
|
||||
assert.equal(probes.length, 1, 'within the window: not probed');
|
||||
t.mock.timers.tick(100 + 1);
|
||||
sampler.request([[10.001, 50]]);
|
||||
probeNow(t);
|
||||
assert.equal(probes.length, 2, 'after the window: probed again');
|
||||
sampler.destroy();
|
||||
});
|
||||
|
||||
test('remembered misses are bounded like the samples', (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
|
||||
const { viewer, probes } = fakeViewer({ holes: () => true });
|
||||
const sampler = createMeshSampler({ getViewer: () => viewer, cacheMax: 2 });
|
||||
sampler.setEnabled(true);
|
||||
for (const lon of [10.001, 10.002, 10.003]) {
|
||||
sampler.request([[lon, 50]]);
|
||||
probeNow(t);
|
||||
}
|
||||
assert.equal(probes.length, 3);
|
||||
// The third miss overflowed the list, which was dropped: the first cell
|
||||
// is asked about again rather than remembered forever.
|
||||
sampler.request([[10.001, 50]]);
|
||||
probeNow(t);
|
||||
assert.equal(probes.length, 4);
|
||||
sampler.destroy();
|
||||
});
|
||||
|
||||
test('destroy forgets the samples', (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
|
||||
const { viewer } = fakeViewer();
|
||||
const sampler = createMeshSampler({ getViewer: () => viewer });
|
||||
sampler.setEnabled(true);
|
||||
sampler.request([[10.001, 50]]);
|
||||
probeNow(t);
|
||||
assert.equal(sampler.meshAt(10.001, 50), 100);
|
||||
sampler.destroy();
|
||||
assert.equal(sampler.meshAt(10.001, 50), undefined);
|
||||
});
|
||||
|
||||
/**
|
||||
* A sampler over `fakeViewer` on a fake clock: each probe costs `probeMs`
|
||||
* and each read of the camera `cameraMs` (work done before any probe).
|
||||
*/
|
||||
function clocked(t, { probeMs = 2, cameraMs = 0, holes } = {}) {
|
||||
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
|
||||
const fake = fakeViewer({ holes });
|
||||
const clock = { now: 0 };
|
||||
const { scene, camera } = fake.viewer;
|
||||
const probe = scene.sampleHeight;
|
||||
scene.sampleHeight = (...args) => {
|
||||
clock.now += probeMs;
|
||||
return probe(...args);
|
||||
};
|
||||
const position = { ...camera.positionCartographic };
|
||||
Object.defineProperty(camera, 'positionCartographic', {
|
||||
get: () => {
|
||||
clock.now += cameraMs;
|
||||
return position;
|
||||
},
|
||||
});
|
||||
const sampler = createMeshSampler({
|
||||
getViewer: () => fake.viewer,
|
||||
now: () => clock.now,
|
||||
});
|
||||
sampler.setEnabled(true);
|
||||
/** Run one idle slice; its length on the fake clock. */
|
||||
function slice() {
|
||||
const started = clock.now;
|
||||
probeNow(t);
|
||||
return clock.now - started;
|
||||
}
|
||||
/** Put the camera over a point. */
|
||||
function moveTo(lon, lat) {
|
||||
position.longitude = lon * RAD;
|
||||
position.latitude = lat * RAD;
|
||||
}
|
||||
return { ...fake, sampler, slice, moveTo };
|
||||
}
|
||||
|
||||
/** Points on a grid `step` degrees apart, `n`×`n`, centred on a point. */
|
||||
function grid(lon, lat, n, step) {
|
||||
const points = [];
|
||||
for (let i = 0; i < n; i++)
|
||||
for (let j = 0; j < n; j++)
|
||||
points.push([
|
||||
lon + (i - (n - 1) / 2) * step,
|
||||
lat + (j - (n - 1) / 2) * step,
|
||||
]);
|
||||
return points;
|
||||
}
|
||||
|
||||
test('cells out of range when asked are not queued, even once the camera reaches them', (t) => {
|
||||
const { sampler, probes, slice, moveTo } = clocked(t);
|
||||
// 400 cells ~3.6 km east, past the sampler's range.
|
||||
sampler.request(grid(10.05, 50, 20, 0.0002));
|
||||
// The camera gets there before any idle slice runs.
|
||||
moveTo(10.05, 50);
|
||||
for (let i = 0; i < 5; i++) slice();
|
||||
assert.equal(probes.length, 0, 'nothing was queued for later');
|
||||
// Asked again from there (as a camera move does), they are probed.
|
||||
sampler.request([[10.05, 50]]);
|
||||
slice();
|
||||
assert.equal(probes.length, 1);
|
||||
sampler.destroy();
|
||||
});
|
||||
|
||||
test('a slice keeps to its budget, counting the work before its first probe', (t) => {
|
||||
const { sampler, probes, slice } = clocked(t, { probeMs: 2, cameraMs: 3 });
|
||||
sampler.request(grid(10.001, 50, 5, 0.0002));
|
||||
const lengths = [];
|
||||
while (probes.length < 25 && lengths.length < 40) lengths.push(slice());
|
||||
assert.equal(probes.length, 25, 'every cell is probed in the end');
|
||||
for (const length of lengths)
|
||||
assert.ok(length <= MESH_SAMPLE_BUDGET_MS, `a ${length} ms slice`);
|
||||
sampler.destroy();
|
||||
});
|
||||
|
||||
test('a large queue is ranked once, not sorted again every slice', (t) => {
|
||||
const { sampler, probes, slice, moveTo } = clocked(t);
|
||||
// 2,500 cells ~17 m apart around the camera, all within range.
|
||||
const cells = grid(10, 50, 50, 0.00015);
|
||||
sampler.request(cells);
|
||||
const sort = Array.prototype.sort;
|
||||
let sorted = 0;
|
||||
t.after(() => (Array.prototype.sort = sort));
|
||||
Array.prototype.sort = function (...args) {
|
||||
if (this.length >= 100) sorted += this.length;
|
||||
return sort.apply(this, args);
|
||||
};
|
||||
for (let i = 0; i < 10; i++) slice();
|
||||
const first = probes.map((probe) =>
|
||||
metresBetween(probe, { lon: 10, lat: 50 }),
|
||||
);
|
||||
assert.ok(first.length >= 10, 'probes ran');
|
||||
first.forEach((distance, i) =>
|
||||
assert.ok(
|
||||
!i || distance >= first[i - 1] - 0.01,
|
||||
'nearest the camera first, across slices',
|
||||
),
|
||||
);
|
||||
Array.prototype.sort = sort;
|
||||
assert.equal(sorted, 0, `${sorted} queued cells sorted in 10 slices`);
|
||||
// The camera moves ~200 m east: the queue is ranked from there.
|
||||
const done = probes.length;
|
||||
moveTo(10.003, 50);
|
||||
slice();
|
||||
slice();
|
||||
const after = probes
|
||||
.slice(done)
|
||||
.map((probe) => metresBetween(probe, { lon: 10.003, lat: 50 }));
|
||||
assert.ok(after.length > 0);
|
||||
assert.ok(after[0] < 20, `nearest the new view first (${after[0]} m)`);
|
||||
sampler.destroy();
|
||||
});
|
||||
|
||||
test('the sampling range is measured the short way round the date line', (t) => {
|
||||
const { sampler, probes, slice, moveTo } = clocked(t);
|
||||
moveTo(179.9999, 0);
|
||||
sampler.request([
|
||||
[-179.9999, 0], // ~22 m away, across the date line
|
||||
[179.99, 0], // ~1.1 km away, past the range
|
||||
]);
|
||||
for (let i = 0; i < 5; i++) slice();
|
||||
assert.equal(probes.length, 1, 'only the neighbour across ±180°');
|
||||
assert.ok(Math.abs(Math.abs(probes[0].lon) - 179.9999) < MESH_CELL_DEG);
|
||||
sampler.destroy();
|
||||
});
|
||||
|
||||
/** A scene whose mesh height can change (finer tiles) and a camera that can move. */
|
||||
function liveViewer({ surface = 80, height = 700, ready = () => true } = {}) {
|
||||
const tileset = Object.create(Cesium.Cesium3DTileset.prototype);
|
||||
const probes = [];
|
||||
const viewer = {
|
||||
surface,
|
||||
ready,
|
||||
scene: {
|
||||
sampleHeightSupported: true,
|
||||
primitives: { length: 1, get: () => tileset },
|
||||
sampleHeight(carto) {
|
||||
probes.push(carto);
|
||||
return viewer.surface;
|
||||
},
|
||||
},
|
||||
camera: {
|
||||
positionCartographic: { longitude: 10 * RAD, latitude: 50 * RAD, height },
|
||||
},
|
||||
};
|
||||
return { viewer, probes };
|
||||
}
|
||||
|
||||
function liveSampler(viewer, options = {}) {
|
||||
return createMeshSampler({
|
||||
getViewer: () => viewer,
|
||||
groundAt: () => 75,
|
||||
withinPrior: meshFloorSampleWithinPrior,
|
||||
tilesReady: () => viewer.ready(),
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
test('a sample is probed again once the camera is twice as close, and the change is redrawn', async () => {
|
||||
const { viewer, probes } = liveViewer({ surface: 80, height: 700 });
|
||||
const sampler = liveSampler(viewer);
|
||||
sampler.setEnabled(true);
|
||||
const heard = [];
|
||||
sampler.onSampled((batch) => heard.push(batch.length));
|
||||
sampler.request([[10.001, 50]]);
|
||||
await settle(900);
|
||||
assert.equal(sampler.meshAt(10.001, 50), 80);
|
||||
// Finer tiles load: the surface is really 100 m.
|
||||
viewer.surface = 100;
|
||||
sampler.request([[10.001, 50]]);
|
||||
await settle();
|
||||
assert.equal(probes.length, 1, 'no new probe from the same distance');
|
||||
assert.equal(sampler.meshAt(10.001, 50), 80);
|
||||
viewer.camera.positionCartographic.height = 250;
|
||||
sampler.request([[10.001, 50]]);
|
||||
await settle(900);
|
||||
assert.equal(probes.length, 2);
|
||||
assert.equal(sampler.meshAt(10.001, 50), 100);
|
||||
assert.deepEqual(heard, [1, 1], 'the new height is announced for a redraw');
|
||||
sampler.destroy();
|
||||
});
|
||||
|
||||
test('refreshing stops once a cell was probed from close by', async () => {
|
||||
const { viewer, probes } = liveViewer({ surface: 80, height: 110 });
|
||||
viewer.camera.positionCartographic.longitude = 10.001 * RAD;
|
||||
const sampler = liveSampler(viewer);
|
||||
sampler.setEnabled(true);
|
||||
sampler.request([[10.001, 50]]);
|
||||
await settle();
|
||||
viewer.camera.positionCartographic.height = 85;
|
||||
sampler.request([[10.001, 50]]);
|
||||
await settle();
|
||||
assert.equal(probes.length, 1, `probed from under ${MESH_REFRESH_MIN_M} m`);
|
||||
sampler.destroy();
|
||||
});
|
||||
|
||||
test('probes wait while the visible tileset is still streaming', async () => {
|
||||
let ready = false;
|
||||
const { viewer, probes } = liveViewer({ ready: () => ready });
|
||||
const sampler = liveSampler(viewer);
|
||||
sampler.setEnabled(true);
|
||||
sampler.request([[10.001, 50]]);
|
||||
await settle(MESH_STREAMING_WAIT_MS * 2);
|
||||
assert.equal(probes.length, 0);
|
||||
ready = true;
|
||||
await settle(MESH_STREAMING_WAIT_MS * 2);
|
||||
assert.equal(probes.length, 1);
|
||||
assert.equal(sampler.meshAt(10.001, 50), 80);
|
||||
sampler.destroy();
|
||||
});
|
||||
|
||||
test('a sample needs a real bare-earth prior and must sit in the shared mesh window', async () => {
|
||||
const { viewer } = liveViewer({ surface: 300 });
|
||||
const unknown = liveSampler(viewer, { groundAt: () => null });
|
||||
unknown.setEnabled(true);
|
||||
unknown.request([[10.001, 50]]);
|
||||
const outside = liveSampler(viewer);
|
||||
outside.setEnabled(true);
|
||||
outside.request([[10.001, 50]]);
|
||||
await settle();
|
||||
assert.equal(unknown.meshAt(10.001, 50), undefined, 'no prior: not kept');
|
||||
assert.equal(
|
||||
outside.meshAt(10.001, 50),
|
||||
undefined,
|
||||
'225 m above it: not kept',
|
||||
);
|
||||
unknown.destroy();
|
||||
outside.destroy();
|
||||
});
|
||||
|
||||
test('a failed refresh keeps the last good distance and is retried after the cooldown', (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
|
||||
const { viewer, probes } = liveViewer({ surface: 90, height: 700 });
|
||||
viewer.camera.positionCartographic.longitude = 10.001 * RAD;
|
||||
const sampler = liveSampler(viewer);
|
||||
sampler.setEnabled(true);
|
||||
sampler.request([[10.001, 50]]);
|
||||
probeNow(t);
|
||||
assert.equal(sampler.meshAt(10.001, 50), 90);
|
||||
// The camera comes down to 20 m and the refresh misses (tiles in flux).
|
||||
viewer.camera.positionCartographic.height = 110;
|
||||
viewer.surface = undefined;
|
||||
sampler.request([[10.001, 50]]);
|
||||
probeNow(t);
|
||||
assert.equal(probes.length, 2);
|
||||
assert.equal(sampler.meshAt(10.001, 50), 90, 'the old sample stays');
|
||||
viewer.surface = 100;
|
||||
t.mock.timers.tick(MESH_MISS_RETRY_MS - 100);
|
||||
sampler.request([[10.001, 50]]);
|
||||
probeNow(t);
|
||||
assert.equal(probes.length, 2, 'not before the cooldown');
|
||||
t.mock.timers.tick(100);
|
||||
sampler.request([[10.001, 50]]);
|
||||
probeNow(t);
|
||||
assert.equal(probes.length, 3, 'retried after it');
|
||||
assert.equal(sampler.meshAt(10.001, 50), 100);
|
||||
sampler.destroy();
|
||||
});
|
||||
@@ -6,6 +6,16 @@ import {
|
||||
import { imageSize } from './imageSize.js';
|
||||
import { msg, t } from '../../i18n/index.js';
|
||||
|
||||
/**
|
||||
* EXPAND puts the viewer's wrap full screen, or (iPhone) opens it as a
|
||||
* popover. Each is tried on its own: a browser that cannot parse one
|
||||
* (Safari before 16.4) throws on the selector.
|
||||
*/
|
||||
const EXPANDED_SELECTORS = [
|
||||
'.sl-viewer-wrap:fullscreen',
|
||||
'.sl-viewer-wrap:popover-open',
|
||||
];
|
||||
|
||||
/** A photo download (tens of kilobytes to a few megabytes). */
|
||||
export const IMAGE_TIMEOUT_MS = 15_000;
|
||||
|
||||
@@ -535,14 +545,14 @@ export function createPhotoViewer({
|
||||
/**
|
||||
* Docked, Esc anywhere in the viewer's box (the photo, its toolbar, its
|
||||
* caption) is the provider's before the panel's, which would collapse;
|
||||
* expanded, the dialog's own Esc shrinks it.
|
||||
* expanded, the panel's own Esc shrinks it.
|
||||
*/
|
||||
function onEscapeKey(event) {
|
||||
if (
|
||||
event.key !== 'Escape' ||
|
||||
event.shiftKey ||
|
||||
event.defaultPrevented ||
|
||||
root?.closest?.('.sl-viewer-wrap-expanded') ||
|
||||
isExpanded() ||
|
||||
!onEscape()
|
||||
)
|
||||
return;
|
||||
@@ -739,9 +749,15 @@ export function createPhotoViewer({
|
||||
}
|
||||
}
|
||||
|
||||
/** The viewer is the expanded dialog, not docked in the panel. */
|
||||
/** The viewer is expanded (full screen, or the iPhone popover), not docked in the panel. */
|
||||
function isExpanded() {
|
||||
return Boolean(root?.closest?.('.sl-viewer-wrap-expanded'));
|
||||
return EXPANDED_SELECTORS.some((selector) => {
|
||||
try {
|
||||
return Boolean(root?.closest?.(selector));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Another photo, or none: a sharper panorama still on its way is not needed. */
|
||||
|
||||
@@ -160,7 +160,7 @@ test('Esc: the docked viewer offers it to the provider first; expanded, it is th
|
||||
assert.equal(asked, 1);
|
||||
assert.equal(docked.prevented && docked.stopped, true);
|
||||
root.closest = (selector) =>
|
||||
selector.includes('sl-viewer-wrap-expanded') ? {} : null;
|
||||
selector.includes('sl-viewer-wrap:fullscreen') ? {} : null;
|
||||
const expanded = press();
|
||||
assert.equal(asked, 1, 'not asked while expanded');
|
||||
assert.equal(expanded.prevented, false);
|
||||
@@ -536,3 +536,38 @@ test('an <img> that turns out too large is refused and let go', async (t) => {
|
||||
await assert.rejects(aborted);
|
||||
assert.equal(images.at(-1).src, '', 'its download stops');
|
||||
});
|
||||
|
||||
test('a browser without :fullscreen / :popover-open (Safari < 16.4) still opens a flat photo and hands Esc on', async () => {
|
||||
let asked = 0;
|
||||
const viewer = createPhotoViewer({
|
||||
providerId: 'test',
|
||||
name: 'Test',
|
||||
sources: SOURCES,
|
||||
getPhoto: async (id) => PHOTOS[id],
|
||||
step: async () => null,
|
||||
onStep() {},
|
||||
onEscape: () => ++asked > 0,
|
||||
load: async (url) => ({ src: url }),
|
||||
});
|
||||
const host = fakeHost();
|
||||
await viewer.mount(host);
|
||||
const root = host.children[0];
|
||||
// Safari 16.0–16.3: neither pseudo-class parses, so closest() throws.
|
||||
root.closest = (selector) => {
|
||||
if (/:fullscreen|:popover-open/.test(selector))
|
||||
throw new SyntaxError(`'${selector}' is not a valid selector`);
|
||||
return null;
|
||||
};
|
||||
let failure = null;
|
||||
try {
|
||||
await viewer.open('f');
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
}
|
||||
assert.equal(failure, null, 'opening a flat photo does not throw');
|
||||
const event = { key: 'Escape' };
|
||||
event.preventDefault = () => {};
|
||||
event.stopPropagation = () => {};
|
||||
assert.doesNotThrow(() => root.fire('keydown', event), 'Esc does not throw');
|
||||
assert.equal(asked, 1, 'Esc reaches the provider');
|
||||
});
|
||||
@@ -5,10 +5,16 @@ const msg = (text) => text;
|
||||
/** Identity and shared tuning for the provider-neutral Street Level layer. */
|
||||
export const STREET_LEVEL_LAYER_ID = 'street-level';
|
||||
|
||||
/**
|
||||
* The pixel window a click picks in: coverage lines are 2.5 px wide and there
|
||||
* is no hover cue, so a click a few pixels off still finds the line.
|
||||
*/
|
||||
export const CLICK_PICK_WINDOW_PX = 9;
|
||||
|
||||
/** Pick id of the viewer position marker; the core owns it, not a provider. */
|
||||
export const POSITION_PICK_ID = 'sl:pos';
|
||||
|
||||
/** One colour per imagery source: chip, coverage lines, points, cones and legend. */
|
||||
/** One colour per imagery source: chip, coverage lines and cones. */
|
||||
export const PROVIDER_COLORS = Object.freeze({
|
||||
mapillary: '#05cb63',
|
||||
google: '#4285f4',
|
||||
@@ -31,17 +37,11 @@ export const MAX_SINCE_DAYS = 36_500;
|
||||
/** Filter every provider starts with: all imagery, any date. */
|
||||
export const FILTER_DEFAULT = Object.freeze({ pano: 'all', sinceDays: 0 });
|
||||
|
||||
/** Nearest-image search radius in metres (Mapillary's graph API caps it at 50 m). */
|
||||
/** Nearest-image search radius in metres. */
|
||||
export const NEAREST_RADIUS_M = 50;
|
||||
|
||||
/**
|
||||
* The only map stack camera follow is offered on: elsewhere a camera at eye
|
||||
* height looks at a smeared texture.
|
||||
*/
|
||||
export const FOLLOW_MAP_STACK_ID = 'photoreal';
|
||||
|
||||
/** Eye height above the sampled ground when the globe camera follows the viewer. */
|
||||
export const FOLLOW_EYE_HEIGHT_M = 2.4;
|
||||
/** The Google 3D map stack: Street View may be shown only on it. */
|
||||
export const GOOGLE_3D_MAP_STACK_ID = 'photoreal';
|
||||
|
||||
/** Above this, a ground click is a misclick, not a street. */
|
||||
export const GROUND_CLICK_MAX_HEIGHT_M = 3_000;
|
||||
@@ -50,3 +50,9 @@ export const GROUND_CLICK_MAX_HEIGHT_M = 3_000;
|
||||
export const AIM_AT_GROUND_MESSAGE = msg(
|
||||
'Aim the middle of the view at a street to look for imagery there',
|
||||
);
|
||||
|
||||
/** The key gate's label for the pill and the layer list, or null when the key is fine. */
|
||||
export function keyStatusLabel({ keyRequired, keyRejected }) {
|
||||
if (keyRejected) return msg('KEY REJECTED');
|
||||
return keyRequired ? msg('KEY REQUIRED') : null;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FOLLOW_MAP_STACK_ID } from '../../policy.js';
|
||||
import { GOOGLE_3D_MAP_STACK_ID } from '../../policy.js';
|
||||
import { createMapsLoader } from './mapsLoader.js';
|
||||
import { getLocale } from '../../../../i18n/index.js';
|
||||
import { createGoogleViewer } from './viewer.js';
|
||||
@@ -64,7 +64,7 @@ export function createGoogleProvider({
|
||||
credit: Object.freeze({ html: GOOGLE_CREDIT_HTML }),
|
||||
groundClick: true,
|
||||
// Google's terms forbid Street View beside non-Google maps.
|
||||
requiresMapStack: FOLLOW_MAP_STACK_ID,
|
||||
requiresMapStack: GOOGLE_3D_MAP_STACK_ID,
|
||||
// Every panorama is billed to the Maps key.
|
||||
defaultOn: false,
|
||||
// All 360°, and no coverage for a date filter to thin.
|
||||
@@ -114,8 +114,6 @@ export function createGoogleProvider({
|
||||
stopAuthWatch();
|
||||
},
|
||||
|
||||
refreshCoverage() {},
|
||||
|
||||
/** No filter applies to Street View (see `filters`). */
|
||||
setFilter() {},
|
||||
|
||||
@@ -123,8 +121,6 @@ export function createGoogleProvider({
|
||||
const keyRejected = loader.authFailed();
|
||||
return {
|
||||
count: 0,
|
||||
zoom: null,
|
||||
kind: null,
|
||||
loading: false,
|
||||
hint:
|
||||
!active || keyRequired || keyRejected ? '' : GROUND_CLICK_HINT,
|
||||
|
||||
@@ -40,7 +40,7 @@ export function tileSegments(payload) {
|
||||
* fourfold.
|
||||
* @param {Array<Array<[number, number]>>} segments
|
||||
* @param {{x: number, y: number, z: number}} address
|
||||
* @returns {{sequences: Array<{id: string, parts: Array<Array<[number, number]>>, capturedAt: number, isPano: boolean}>, overview: Array<never>}}
|
||||
* @returns {{sequences: Array<{id: string, parts: Array<Array<[number, number]>>, capturedAt: number, isPano: boolean}>}}
|
||||
*/
|
||||
export function chainSegments(segments, { x, y, z }) {
|
||||
const key = ([lon, lat]) => `${lon},${lat}`;
|
||||
@@ -84,7 +84,7 @@ export function chainSegments(segments, { x, y, z }) {
|
||||
isPano: false,
|
||||
});
|
||||
});
|
||||
return { sequences, overview: [] };
|
||||
return { sequences };
|
||||
}
|
||||
|
||||
/** Decode a KartaView coverage tile into the shared coverage's line records. */
|
||||
|
||||
@@ -13,7 +13,7 @@ const at = (lng, lat) => ({ lat: String(lat), lng: String(lng) });
|
||||
const TILE = { x: 4412, y: 6061, z: 14 };
|
||||
|
||||
test('segments that meet end to start chain into one line', () => {
|
||||
const { sequences, overview } = decodeCoverageTile(
|
||||
const { sequences } = decodeCoverageTile(
|
||||
{
|
||||
lines: [
|
||||
{ track: [at(1, 1), at(2, 1)], matched: '1' },
|
||||
@@ -23,7 +23,6 @@ test('segments that meet end to start chain into one line', () => {
|
||||
},
|
||||
TILE,
|
||||
);
|
||||
assert.deepEqual(overview, []);
|
||||
assert.equal(sequences.length, 2);
|
||||
assert.deepEqual(sequences[0].parts, [
|
||||
[
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createLineCoverage } from '../../lineCoverage.js';
|
||||
import { createLineCoverage, freshCoverage } from '../../lineCoverage.js';
|
||||
import { createLru, createTileCache } from '../../tileCache.js';
|
||||
|
||||
export { createTileCache } from '../../tileCache.js';
|
||||
@@ -49,10 +49,7 @@ const PAGE_CACHE_SIZE = 12;
|
||||
/** z14 near the street, z13 a little higher, nothing above. */
|
||||
export function kartaViewZoomFor(heightM) {
|
||||
if (!Number.isFinite(heightM) || heightM > COVERAGE_MAX_HEIGHT_M) return null;
|
||||
return {
|
||||
zoom: heightM > COVERAGE_STREET_ZOOM_BELOW_M ? 13 : 14,
|
||||
kind: 'sequence',
|
||||
};
|
||||
return heightM > COVERAGE_STREET_ZOOM_BELOW_M ? 13 : 14;
|
||||
}
|
||||
|
||||
/** The 1-based page of a sequence that holds `index`, if no photo is missing. */
|
||||
@@ -88,8 +85,6 @@ export function createKartaViewProvider({
|
||||
filters: Object.freeze(['pano', 'since']),
|
||||
// SINCE is the upload date for KartaView: the panel notes it.
|
||||
sinceBasis: 'upload',
|
||||
// Its lines are streets, not sequences: a selected drive shows as cones.
|
||||
highlightsSequence: false,
|
||||
coverageLabel: Object.freeze({
|
||||
noun: COVERAGE_NOUN,
|
||||
action: LINE_CLICK_HINT,
|
||||
@@ -100,30 +95,13 @@ export function createKartaViewProvider({
|
||||
context,
|
||||
services: context.services,
|
||||
viewer: null,
|
||||
keyRequired: false,
|
||||
keyRejected: false,
|
||||
statusKnown: true,
|
||||
coverage: {
|
||||
zoom: null,
|
||||
tiles: new Map(),
|
||||
stale: new Map(),
|
||||
staleTimer: null,
|
||||
pending: new Map(),
|
||||
lastError: null,
|
||||
holdUntil: 0,
|
||||
holdTimer: null,
|
||||
debounceTimer: null,
|
||||
removeCameraListener: null,
|
||||
terrainReady: null,
|
||||
hint: '',
|
||||
kind: null,
|
||||
},
|
||||
coverage: freshCoverage({ keyless: true }),
|
||||
sequence: {
|
||||
selectedId: null,
|
||||
images: [],
|
||||
cache: new Map(),
|
||||
collection: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
abort: null,
|
||||
},
|
||||
};
|
||||
@@ -349,6 +327,8 @@ export function createKartaViewProvider({
|
||||
// Filtered on the way in (fetchTile): a line has no date to test.
|
||||
passes: () => true,
|
||||
});
|
||||
// Its lines are streets, not drives: a selected drive shows as cones
|
||||
// only, so the coverage is never highlighted.
|
||||
|
||||
const cones = createSequenceCones({
|
||||
state,
|
||||
@@ -480,7 +460,7 @@ export function createKartaViewProvider({
|
||||
deactivate() {
|
||||
coverage.detach();
|
||||
coverage.clear();
|
||||
coverage.resetErrors();
|
||||
coverage.unblock();
|
||||
cones.clearSelection();
|
||||
cones.setVisible(false);
|
||||
drawnPage = null;
|
||||
@@ -492,17 +472,15 @@ export function createKartaViewProvider({
|
||||
destroy(cesiumViewer) {
|
||||
coverage.detach();
|
||||
coverage.clear();
|
||||
coverage.resetErrors();
|
||||
coverage.unblock();
|
||||
cones.destroy(cesiumViewer);
|
||||
tiles.clear();
|
||||
masks.clear();
|
||||
},
|
||||
|
||||
refreshCoverage: () => coverage.refresh(),
|
||||
|
||||
/**
|
||||
* The filter changed: the lines come from KartaView again under it
|
||||
* (the old ones show meanwhile), the cones are drawn again.
|
||||
* (cleared until they come), the cones are drawn again.
|
||||
*/
|
||||
setFilter() {
|
||||
const query = tileFilterQuery(context.getFilter());
|
||||
@@ -513,29 +491,17 @@ export function createKartaViewProvider({
|
||||
cones.rerender();
|
||||
},
|
||||
|
||||
setSurface() {
|
||||
coverage.setSurface();
|
||||
cones.rerender();
|
||||
},
|
||||
|
||||
/** Keyless; an empty view that has loaded says there is nothing here. */
|
||||
coverageStats() {
|
||||
const { hint } = state.coverage;
|
||||
const count = coverage.sequenceCount();
|
||||
const loading = state.coverage.pending.size > 0;
|
||||
const stats = coverage.stats();
|
||||
const empty =
|
||||
state.coverage.zoom !== null &&
|
||||
!loading &&
|
||||
count === 0 &&
|
||||
!state.coverage.lastError;
|
||||
!stats.loading &&
|
||||
stats.count === 0 &&
|
||||
!stats.error;
|
||||
return {
|
||||
count,
|
||||
zoom: state.coverage.zoom,
|
||||
kind: state.coverage.kind,
|
||||
loading,
|
||||
hint: hint || (empty ? NO_COVERAGE_HINT : ''),
|
||||
error: state.coverage.lastError,
|
||||
keyRequired: false,
|
||||
keyRejected: false,
|
||||
...stats,
|
||||
hint: stats.hint || (empty ? NO_COVERAGE_HINT : ''),
|
||||
};
|
||||
},
|
||||
|
||||
@@ -553,7 +519,7 @@ export function createKartaViewProvider({
|
||||
return false;
|
||||
},
|
||||
|
||||
// Never from the cache: it is keyed by sequence, the cones by page.
|
||||
// Always loaded afresh: the cones show the page of the photo open now.
|
||||
selectSequence: (sequenceId) =>
|
||||
cones.select(sequenceId, { force: true }),
|
||||
clearSequence: () => {
|
||||
@@ -567,6 +533,7 @@ export function createKartaViewProvider({
|
||||
counts.get(state.sequence.selectedId) ??
|
||||
state.sequence.images.length,
|
||||
loading: state.sequence.loading,
|
||||
error: state.sequence.error,
|
||||
}),
|
||||
|
||||
/**
|
||||
|
||||
@@ -37,16 +37,15 @@ export const COVERAGE_STREET_ZOOM_BELOW_M = 2_500;
|
||||
export const COVERAGE_MAX_HEIGHT_M = 6_000;
|
||||
export const COVERAGE_TUNING = Object.freeze({
|
||||
maxTiles: 9,
|
||||
overviewMaxTiles: 0,
|
||||
// Chained ways are short: a dense downtown z13 tile holds ~1,500, so each
|
||||
// tile may draw 1,600 (a frame stays at 16.7 ms with 4,400 drawn).
|
||||
maxSequences: 14_400,
|
||||
lineWidthPx: 2.5,
|
||||
overviewPointPx: 2.5,
|
||||
moveDebounceMs: 320,
|
||||
viewRangeMinM: 2_500,
|
||||
viewRangePerHeight: 10,
|
||||
viewNearM: 1_000,
|
||||
// Lines carry no date to sort by: keep KartaView's order.
|
||||
keepOrder: true,
|
||||
});
|
||||
export const TOO_HIGH_HINT = msg('zoom in to see KartaView coverage');
|
||||
export const NO_COVERAGE_HINT = msg('no KartaView photos in view');
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
kartaViewZoomFor,
|
||||
} from './index.js';
|
||||
import {
|
||||
COVERAGE_TUNING,
|
||||
FILTERED_OUT_MESSAGE,
|
||||
NO_COVERAGE_HINT,
|
||||
SEQUENCE_PAGE_SIZE,
|
||||
@@ -100,9 +101,6 @@ function fakeContext({ filter = { pano: 'all', sinceMs: null } } = {}) {
|
||||
services: {},
|
||||
getFilter: () => actions.filter,
|
||||
isActive: () => true,
|
||||
groundCaster: null,
|
||||
meshSampler: null,
|
||||
getSurface: () => 'draped',
|
||||
notify() {},
|
||||
actions: {
|
||||
// As the core does: the provider's own viewer opens it.
|
||||
@@ -153,11 +151,6 @@ test('KartaView registers keyless, off by default, with Mapillary’s filters an
|
||||
'upload',
|
||||
'SINCE is KartaView’s upload date',
|
||||
);
|
||||
assert.equal(
|
||||
definition.highlightsSequence,
|
||||
false,
|
||||
'its lines are streets: a selected drive shows as cones',
|
||||
);
|
||||
assert.match(
|
||||
definition.credit.html,
|
||||
/KartaView<\/a> contributors, CC BY-SA 4\.0/,
|
||||
@@ -176,14 +169,10 @@ test('KartaView registers keyless, off by default, with Mapillary’s filters an
|
||||
});
|
||||
|
||||
test('coverage is drawn from z14 near the street and z13 a little higher; never from orbit', () => {
|
||||
assert.deepEqual(kartaViewZoomFor(300), { zoom: 14, kind: 'sequence' });
|
||||
assert.deepEqual(kartaViewZoomFor(2_500), { zoom: 14, kind: 'sequence' });
|
||||
assert.deepEqual(kartaViewZoomFor(4_000), { zoom: 13, kind: 'sequence' });
|
||||
assert.deepEqual(
|
||||
kartaViewZoomFor(6_000),
|
||||
{ zoom: 13, kind: 'sequence' },
|
||||
'the top of the range still draws',
|
||||
);
|
||||
assert.equal(kartaViewZoomFor(300), 14);
|
||||
assert.equal(kartaViewZoomFor(2_500), 14);
|
||||
assert.equal(kartaViewZoomFor(4_000), 13);
|
||||
assert.equal(kartaViewZoomFor(6_000), 13, 'the top of the range still draws');
|
||||
assert.equal(kartaViewZoomFor(6_001), null);
|
||||
assert.equal(kartaViewZoomFor(NaN), null);
|
||||
});
|
||||
@@ -515,7 +504,7 @@ test('coverage tiles are kept for a while: panning back asks KartaView nothing',
|
||||
const tiles = createTileCache({
|
||||
load: async (tile) => {
|
||||
asked.push(`${tile.z}/${tile.x}/${tile.y}`);
|
||||
return { sequences: [], overview: [] };
|
||||
return { sequences: [] };
|
||||
},
|
||||
size: 2,
|
||||
ttlMs: 1000,
|
||||
@@ -789,6 +778,14 @@ function coverageSetup(
|
||||
};
|
||||
}
|
||||
|
||||
/** The camera came to rest: coverage refreshes once its debounce is over. */
|
||||
async function moved(viewer) {
|
||||
viewer.camera.moveEnd.raiseEvent();
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, COVERAGE_TUNING.moveDebounceMs + 20),
|
||||
);
|
||||
}
|
||||
|
||||
test('a filter thins the lines to those its PNG mask draws; without one they all show', async (t) => {
|
||||
const { lon, lat } = tileCentre(-83.0458, 42.3314);
|
||||
// Across the tile's middle row (mask row ~256), and a quarter up (~130).
|
||||
@@ -811,15 +808,16 @@ test('a filter thins the lines to those its PNG mask draws; without one they all
|
||||
assert.ok(await until(() => env.instance.coverageStats().count === 2));
|
||||
assert.equal(env.tiles.length, 1, 'the segments are kept');
|
||||
// SINCE a day: uploaded since yesterday (UTC) — none here. The lines
|
||||
// drawn now stay on the globe while KartaView draws the filtered tile.
|
||||
// drawn now go while KartaView draws the filtered tile.
|
||||
const since = Date.now() - 86_400_000;
|
||||
const release = env.holdMasks();
|
||||
env.actions.filter = { pano: 'all', sinceMs: since };
|
||||
env.instance.setFilter();
|
||||
assert.ok(await until(() => env.instance.coverageStats().loading));
|
||||
assert.ok(
|
||||
env.viewer.scene.groundPrimitives.items.size > 0,
|
||||
'the unfiltered lines show meanwhile',
|
||||
assert.equal(
|
||||
env.viewer.scene.groundPrimitives.items.size,
|
||||
0,
|
||||
'the unfiltered lines are not shown as filtered ones',
|
||||
);
|
||||
release();
|
||||
assert.ok(
|
||||
@@ -834,10 +832,7 @@ test('a filter thins the lines to those its PNG mask draws; without one they all
|
||||
`startDate=${new Date(since).toISOString().slice(0, 10)}`,
|
||||
);
|
||||
assert.equal(env.instance.coverageStats().hint, NO_COVERAGE_HINT);
|
||||
assert.ok(
|
||||
await until(() => env.viewer.scene.groundPrimitives.items.size === 0),
|
||||
'and go once it is in',
|
||||
);
|
||||
assert.equal(env.viewer.scene.groundPrimitives.items.size, 0);
|
||||
// The same filter again asks KartaView for nothing.
|
||||
env.instance.setFilter();
|
||||
for (let i = 0; i < 5; i++) await settle();
|
||||
@@ -851,7 +846,7 @@ test('coverage hints: zoom in from too high, “no photos” only once an empty
|
||||
assert.deepEqual(env.tiles, [], 'nothing asked from up there');
|
||||
const release = env.holdTiles();
|
||||
env.viewer.view.height = 900;
|
||||
env.instance.refreshCoverage();
|
||||
await moved(env.viewer);
|
||||
assert.ok(await until(() => env.instance.coverageStats().loading));
|
||||
assert.equal(env.instance.coverageStats().hint, '', 'not while it loads');
|
||||
release();
|
||||
@@ -871,7 +866,7 @@ test('coverage tiles are kept while KartaView is on and dropped once it is switc
|
||||
});
|
||||
env.instance.activate(env.viewer);
|
||||
assert.ok(await until(() => env.instance.coverageStats().count === 1));
|
||||
env.instance.refreshCoverage();
|
||||
await moved(env.viewer);
|
||||
assert.ok(await until(() => env.instance.coverageStats().count === 1));
|
||||
assert.equal(env.tiles.length, 1, 'kept while on');
|
||||
// Switched off: the decoded lines are not kept, so on again asks again.
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import {
|
||||
createLineCoverage,
|
||||
sequenceIdFromPick as lineSequenceIdFromPick,
|
||||
} from '../../lineCoverage.js';
|
||||
import { decodeCoverageTile } from './decode.js';
|
||||
import {
|
||||
coverageZoomForHeight,
|
||||
overviewZoomForHeight,
|
||||
} from '../../tileMath.js';
|
||||
import {
|
||||
COLORS,
|
||||
COVERAGE_LINE_WIDTH_PX,
|
||||
COVERAGE_MAX_SEQUENCES,
|
||||
COVERAGE_MAX_TILES,
|
||||
COVERAGE_MOVE_DEBOUNCE_MS,
|
||||
COVERAGE_OVERVIEW_MAX_TILES,
|
||||
COVERAGE_OVERVIEW_POINT_PX,
|
||||
KEY_REJECTED_MESSAGE,
|
||||
PICK_PREFIX,
|
||||
RATE_LIMITED_MESSAGE,
|
||||
SEQUENCE_VIEW_NEAR_M,
|
||||
SEQUENCE_VIEW_RANGE_MIN_M,
|
||||
SEQUENCE_VIEW_RANGE_PER_HEIGHT,
|
||||
} from './policy.js';
|
||||
|
||||
export { SWAP_MAX_WAIT_MS, meshBoxInRange } from '../../lineCoverage.js';
|
||||
|
||||
/** Overview points (z0–5) from orbit, sequence lines (z11–14) below 60 km. */
|
||||
export function mapillaryZoomFor(heightM) {
|
||||
const sequence = coverageZoomForHeight(heightM);
|
||||
if (sequence != null) return { zoom: sequence, kind: 'sequence' };
|
||||
const overview = overviewZoomForHeight(heightM);
|
||||
return overview != null ? { zoom: overview, kind: 'overview' } : null;
|
||||
}
|
||||
|
||||
/** Sequence id for any part's pick id (`mly:seq:<id>[~<part>]`), else null. */
|
||||
export function sequenceIdFromPick(pickId) {
|
||||
return lineSequenceIdFromPick(pickId, PICK_PREFIX.sequence);
|
||||
}
|
||||
|
||||
/** Mapillary's vector-tile coverage through the server proxy. */
|
||||
export function createCoverage({ state, source }) {
|
||||
return createLineCoverage({
|
||||
state,
|
||||
fetchTile: async (tile, { signal }) =>
|
||||
decodeCoverageTile(
|
||||
await source.getTile('coverage', tile.z, tile.x, tile.y, { signal }),
|
||||
tile,
|
||||
),
|
||||
zoomFor: mapillaryZoomFor,
|
||||
colors: COLORS,
|
||||
pickPrefix: PICK_PREFIX.sequence,
|
||||
renderKey: 'mapillary-coverage',
|
||||
tuning: {
|
||||
maxTiles: COVERAGE_MAX_TILES,
|
||||
overviewMaxTiles: COVERAGE_OVERVIEW_MAX_TILES,
|
||||
maxSequences: COVERAGE_MAX_SEQUENCES,
|
||||
lineWidthPx: COVERAGE_LINE_WIDTH_PX,
|
||||
overviewPointPx: COVERAGE_OVERVIEW_POINT_PX,
|
||||
moveDebounceMs: COVERAGE_MOVE_DEBOUNCE_MS,
|
||||
viewRangeMinM: SEQUENCE_VIEW_RANGE_MIN_M,
|
||||
viewRangePerHeight: SEQUENCE_VIEW_RANGE_PER_HEIGHT,
|
||||
viewNearM: SEQUENCE_VIEW_NEAR_M,
|
||||
},
|
||||
messages: {
|
||||
keyRejected: KEY_REJECTED_MESSAGE,
|
||||
rateLimited: RATE_LIMITED_MESSAGE,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,989 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
SWAP_MAX_WAIT_MS,
|
||||
createCoverage,
|
||||
meshBoxInRange,
|
||||
} from './coverage.js';
|
||||
import { encodeCoverageTile } from './coverageFixture.mjs';
|
||||
import { rayCamera } from '../../../../testSupport/streetLevelFakes.mjs';
|
||||
import {
|
||||
COLORS,
|
||||
COVERAGE_MAX_SEQUENCES,
|
||||
COVERAGE_MAX_TILES,
|
||||
} from './policy.js';
|
||||
import { createGroundCaster } from '../../groundCast.js';
|
||||
import { lonToTileX, latToTileY, tileBounds } from '../../tileMath.js';
|
||||
|
||||
const RAD = Math.PI / 180;
|
||||
// Node has no WebGL context to report line-width limits; a browser's
|
||||
// context sets these on startup, and appearances validate against them.
|
||||
Cesium.ContextLimits._minimumAliasedLineWidth = 1;
|
||||
Cesium.ContextLimits._maximumAliasedLineWidth = 10;
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
/** A scene collection that remembers what is on the globe. */
|
||||
function collection() {
|
||||
const items = new Set();
|
||||
return {
|
||||
items,
|
||||
add(primitive) {
|
||||
items.add(primitive);
|
||||
return primitive;
|
||||
},
|
||||
remove(primitive) {
|
||||
return items.delete(primitive);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The centre of the z14 tile holding a point, so a small view is one tile. */
|
||||
function tileCentre(lon, lat) {
|
||||
const x = lonToTileX(lon, 14);
|
||||
const y = latToTileY(lat, 14);
|
||||
const { west, east, south, north } = tileBounds(x, y, 14);
|
||||
return {
|
||||
tile: { x, y, z: 14 },
|
||||
lon: (west + east) / 2,
|
||||
lat: (south + north) / 2,
|
||||
};
|
||||
}
|
||||
|
||||
/** A viewer looking straight down on a 0.004° square around `view`. */
|
||||
function fakeViewer(view) {
|
||||
const postRender = new Set();
|
||||
const preRender = new Set();
|
||||
return {
|
||||
view,
|
||||
postRender,
|
||||
preRender,
|
||||
scene: {
|
||||
canvas: { clientWidth: 100, clientHeight: 100 },
|
||||
globe: { show: false, ellipsoid: Cesium.Ellipsoid.WGS84 },
|
||||
groundPrimitives: collection(),
|
||||
primitives: collection(),
|
||||
postRender: {
|
||||
addEventListener(listener) {
|
||||
postRender.add(listener);
|
||||
return () => postRender.delete(listener);
|
||||
},
|
||||
},
|
||||
preRender: {
|
||||
addEventListener(listener) {
|
||||
preRender.add(listener);
|
||||
return () => preRender.delete(listener);
|
||||
},
|
||||
},
|
||||
},
|
||||
camera: {
|
||||
get positionCartographic() {
|
||||
return {
|
||||
longitude: view.lon * RAD,
|
||||
latitude: view.lat * RAD,
|
||||
height: view.height,
|
||||
};
|
||||
},
|
||||
pickEllipsoid(point) {
|
||||
return Cesium.Cartesian3.fromDegrees(
|
||||
view.lon - 0.002 + (point.x / 100) * 0.004,
|
||||
view.lat + 0.002 - (point.y / 100) * 0.004,
|
||||
);
|
||||
},
|
||||
computeViewRectangle: () => undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A source whose tile requests the test resolves, one call at a time. */
|
||||
function deferredSource() {
|
||||
const calls = [];
|
||||
return {
|
||||
calls,
|
||||
getTile(layer, z, x, y, { signal } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
calls.push({ key: `${z}/${x}/${y}`, z, signal, resolve, reject });
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function setup({
|
||||
surface = 'draped',
|
||||
groundCaster = null,
|
||||
meshSampler = null,
|
||||
} = {}) {
|
||||
const centre = tileCentre(-121.4944, 38.5816);
|
||||
const viewer = fakeViewer({ lon: centre.lon, lat: centre.lat, height: 900 });
|
||||
const source = deferredSource();
|
||||
const state = {
|
||||
viewer,
|
||||
services: {},
|
||||
keyRequired: false,
|
||||
context: {
|
||||
filter: { pano: 'all', sinceMs: null },
|
||||
getFilter: () => state.context.filter,
|
||||
isActive: () => true,
|
||||
notify() {},
|
||||
getSurface: () => surface,
|
||||
groundCaster,
|
||||
meshSampler,
|
||||
},
|
||||
coverage: {
|
||||
zoom: null,
|
||||
kind: null,
|
||||
tiles: new Map(),
|
||||
stale: new Map(),
|
||||
staleTimer: null,
|
||||
pending: new Map(),
|
||||
lastError: null,
|
||||
debounceTimer: null,
|
||||
removeCameraListener: null,
|
||||
// Skip Cesium's one-time terrain table download.
|
||||
terrainReady: Promise.resolve(),
|
||||
hint: '',
|
||||
},
|
||||
sequence: { selectedId: null },
|
||||
};
|
||||
const coverage = createCoverage({ state, source });
|
||||
const bytes = encodeCoverageTile(centre.tile, {
|
||||
sequences: [
|
||||
{
|
||||
id: 'seq-1',
|
||||
parts: [
|
||||
[
|
||||
[centre.lon - 0.001, centre.lat],
|
||||
[centre.lon + 0.001, centre.lat],
|
||||
],
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
const tileKey = `14/${centre.tile.x}/${centre.tile.y}`;
|
||||
const onGlobe = () =>
|
||||
viewer.scene.groundPrimitives.items.size +
|
||||
viewer.scene.primitives.items.size;
|
||||
return { viewer, source, state, coverage, bytes, tileKey, onGlobe, centre };
|
||||
}
|
||||
|
||||
/** Run one frame's listeners, as Cesium's render loop would. */
|
||||
function frame(listeners) {
|
||||
for (const listener of [...listeners]) listener();
|
||||
}
|
||||
|
||||
test('a superseded tile request cannot strand lines on the globe', async () => {
|
||||
const { viewer, source, state, coverage, bytes, tileKey, onGlobe } = setup();
|
||||
const forTile = () => source.calls.filter((call) => call.key === tileKey);
|
||||
|
||||
coverage.refresh(); // street zoom: request #1 for the tile
|
||||
assert.equal(forTile().length, 1);
|
||||
viewer.view.height = 100_000; // zoom out: retire() drops request #1
|
||||
coverage.refresh();
|
||||
viewer.view.height = 900; // back in before #1 settles: request #2
|
||||
coverage.refresh();
|
||||
assert.equal(forTile().length, 2);
|
||||
await settle();
|
||||
|
||||
// The superseded request finishes late. It must not settle request #2's
|
||||
// key, or the next refresh would start a duplicate load.
|
||||
forTile()[0].resolve(bytes);
|
||||
await settle();
|
||||
assert.equal(
|
||||
state.coverage.pending.has(tileKey),
|
||||
true,
|
||||
'#2 still owns the key',
|
||||
);
|
||||
assert.equal(state.coverage.pending.size, 1, 'still loading');
|
||||
coverage.refresh();
|
||||
assert.equal(forTile().length, 2, 'no duplicate request');
|
||||
|
||||
forTile()[1].resolve(bytes);
|
||||
await settle();
|
||||
assert.equal(state.coverage.tiles.size, 1);
|
||||
assert.ok(onGlobe() > 0, 'the tile drew its lines');
|
||||
|
||||
// Everything drawn can be removed: nothing is orphaned.
|
||||
coverage.clear();
|
||||
assert.equal(onGlobe(), 0);
|
||||
});
|
||||
|
||||
test('in terrain mode a tile draws draped, then swaps to cast lines without a blank frame', async () => {
|
||||
let heightsReady = false;
|
||||
const groundCaster = {
|
||||
prepareLines: async () => {
|
||||
heightsReady = true;
|
||||
return true;
|
||||
},
|
||||
castLine: (coords) =>
|
||||
heightsReady ? coords.flatMap(([lon, lat]) => [lon, lat, 30]) : null,
|
||||
};
|
||||
const { viewer, source, coverage, bytes, onGlobe } = setup({
|
||||
surface: 'terrain',
|
||||
groundCaster,
|
||||
});
|
||||
coverage.refresh();
|
||||
source.calls[0].resolve(bytes);
|
||||
await settle();
|
||||
await settle();
|
||||
const { groundPrimitives, primitives } = viewer.scene;
|
||||
// Both are on the globe while the cast lines build: no blink.
|
||||
assert.equal(groundPrimitives.items.size, 1, 'draped lines kept');
|
||||
assert.equal(primitives.items.size, 1, 'cast lines added');
|
||||
assert.ok(
|
||||
[...primitives.items][0] instanceof Cesium.Primitive,
|
||||
'cast lines are plain polylines at terrain height',
|
||||
);
|
||||
// Leaving mid-swap removes both sets, not just the new one.
|
||||
coverage.clear();
|
||||
assert.equal(onGlobe(), 0);
|
||||
assert.equal(viewer.postRender.size, 0, 'the swap listener is gone');
|
||||
});
|
||||
|
||||
test('a tile whose lines cannot be cast is not rebuilt for nothing', async () => {
|
||||
let prepares = 0;
|
||||
const groundCaster = {
|
||||
prepareLines: async () => {
|
||||
prepares++;
|
||||
return false; // terrain proxy down, or tile too big
|
||||
},
|
||||
castLine: () => null,
|
||||
};
|
||||
const { viewer, source, coverage, bytes } = setup({
|
||||
surface: 'terrain',
|
||||
groundCaster,
|
||||
});
|
||||
const added = [];
|
||||
const add = viewer.scene.groundPrimitives.add.bind(
|
||||
viewer.scene.groundPrimitives,
|
||||
);
|
||||
viewer.scene.groundPrimitives.add = (primitive) => {
|
||||
added.push(primitive);
|
||||
return add(primitive);
|
||||
};
|
||||
coverage.refresh();
|
||||
source.calls[0].resolve(bytes);
|
||||
await settle();
|
||||
await settle();
|
||||
assert.equal(prepares, 1);
|
||||
assert.equal(added.length, 1, 'drawn once, draped; no second draped build');
|
||||
assert.equal(viewer.scene.primitives.items.size, 0);
|
||||
coverage.clear();
|
||||
});
|
||||
|
||||
test('panning off a tile that is still loading stops counting it as loading', async () => {
|
||||
const { viewer, source, state, coverage } = setup();
|
||||
coverage.refresh();
|
||||
assert.equal(state.coverage.pending.size, 1);
|
||||
// Same zoom, two tiles east: the first request is dropped, a new one starts.
|
||||
viewer.view.lon += 0.05;
|
||||
coverage.refresh();
|
||||
assert.equal(source.calls.length, 2);
|
||||
assert.equal(source.calls[0].signal.aborted, true);
|
||||
assert.equal(
|
||||
state.coverage.pending.size,
|
||||
1,
|
||||
'only the live request counts as loading',
|
||||
);
|
||||
source.calls[0].reject(new DOMException('aborted', 'AbortError'));
|
||||
source.calls[1].resolve(new Uint8Array());
|
||||
await settle();
|
||||
assert.equal(state.coverage.pending.size, 0, 'LOADING clears');
|
||||
coverage.clear();
|
||||
});
|
||||
|
||||
test('zoom 0 is a zoom: the whole-earth view still shows coverage', () => {
|
||||
const { viewer, source, state, coverage } = setup();
|
||||
viewer.view.height = 20_000_000;
|
||||
coverage.refresh();
|
||||
assert.equal(state.coverage.zoom, 0);
|
||||
assert.equal(state.coverage.hint, '');
|
||||
assert.deepEqual(
|
||||
source.calls.map((call) => call.key),
|
||||
['0/0/0'],
|
||||
);
|
||||
coverage.clear();
|
||||
});
|
||||
|
||||
test('a rejected key stops coverage requests until the layer goes off', async () => {
|
||||
const { viewer, source, state, coverage } = setup();
|
||||
coverage.refresh();
|
||||
const rejected = Object.assign(
|
||||
new Error('Mapillary rejected the access token'),
|
||||
{
|
||||
keyRejected: true,
|
||||
},
|
||||
);
|
||||
source.calls[0].reject(rejected);
|
||||
await settle();
|
||||
assert.equal(state.keyRejected, true);
|
||||
assert.match(state.coverage.lastError, /rejected MAPILLARY_CLIENT_TOKEN/);
|
||||
viewer.view.lon += 0.05;
|
||||
coverage.refresh();
|
||||
assert.equal(source.calls.length, 1, 'panning asks for nothing more');
|
||||
// Turning the provider off forgets the verdict; the next run asks again.
|
||||
coverage.clear();
|
||||
coverage.resetErrors();
|
||||
assert.equal(state.keyRejected, false);
|
||||
assert.equal(state.coverage.lastError, null);
|
||||
coverage.refresh();
|
||||
assert.equal(source.calls.length, 2);
|
||||
coverage.clear();
|
||||
});
|
||||
|
||||
test('a rate limit keeps the drawn tiles and asks again once the wait is over', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
|
||||
const { viewer, source, state, coverage } = setup();
|
||||
coverage.refresh();
|
||||
source.calls[0].reject(
|
||||
Object.assign(new Error('rate limited'), { retryAfterSec: 30 }),
|
||||
);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
assert.match(state.coverage.lastError, /rate-limited/);
|
||||
viewer.view.lon += 0.05;
|
||||
coverage.refresh();
|
||||
assert.equal(source.calls.length, 1, 'held: nothing is requested');
|
||||
t.mock.timers.tick(30_000);
|
||||
assert.equal(source.calls.length, 2, 'one refresh once the wait is over');
|
||||
assert.equal(state.coverage.lastError, null);
|
||||
coverage.clear();
|
||||
coverage.resetErrors();
|
||||
});
|
||||
|
||||
test('overview points behind the horizon are hidden, not drawn through the globe', async () => {
|
||||
const { viewer, source, state, coverage } = setup();
|
||||
const { view } = viewer;
|
||||
view.height = 20_000_000;
|
||||
Object.defineProperty(viewer.camera, 'positionWC', {
|
||||
get: () => Cesium.Cartesian3.fromDegrees(view.lon, view.lat, view.height),
|
||||
});
|
||||
coverage.refresh();
|
||||
// One dot under the camera (Sacramento), one near its antipode.
|
||||
source.calls[0].resolve(
|
||||
encodeCoverageTile(
|
||||
{ x: 0, y: 0, z: 0 },
|
||||
{
|
||||
overview: [
|
||||
{ id: 'near', lon: -121.5, lat: 38.6 },
|
||||
{ id: 'far', lon: 58.5, lat: -38.6 },
|
||||
],
|
||||
},
|
||||
),
|
||||
);
|
||||
await settle();
|
||||
const [entry] = state.coverage.tiles.values();
|
||||
const points = [0, 1].map((i) => entry.primitive.get(i));
|
||||
const west = (point) =>
|
||||
Cesium.Cartographic.fromCartesian(point.position).longitude < 0;
|
||||
const near = points.find(west);
|
||||
const far = points.find((point) => !west(point));
|
||||
frame(viewer.preRender);
|
||||
assert.equal(near.show, true, 'the near side stays visible');
|
||||
assert.equal(far.show, false, 'the far side does not show through');
|
||||
// Fly round to the other hemisphere: the two swap.
|
||||
view.lon = 58.5;
|
||||
view.lat = -38.6;
|
||||
frame(viewer.preRender);
|
||||
assert.equal(near.show, false);
|
||||
assert.equal(far.show, true);
|
||||
coverage.clear();
|
||||
assert.equal(viewer.preRender.size, 0, 'the cull listener is gone');
|
||||
});
|
||||
|
||||
test('a cast tile whose heights were evicted is cast again, not left draped', async () => {
|
||||
const terrain = {
|
||||
calls: 0,
|
||||
async resolveEllipsoidalGround(coords) {
|
||||
this.calls++;
|
||||
return coords.map(() => ({ ellipsoid: 20, source: 'reearth' }));
|
||||
},
|
||||
};
|
||||
// Room for one tile's corners at a time.
|
||||
const groundCaster = createGroundCaster({
|
||||
terrain,
|
||||
maxCorners: 16,
|
||||
cacheMax: 20,
|
||||
});
|
||||
let sampled = null;
|
||||
const meshSampler = {
|
||||
onSampled: (listener) => (sampled = listener),
|
||||
request() {},
|
||||
meshAt: () => undefined,
|
||||
};
|
||||
const { source, state, coverage, bytes, centre } = setup({
|
||||
surface: 'terrain',
|
||||
groundCaster,
|
||||
meshSampler,
|
||||
});
|
||||
const isCast = () => {
|
||||
const [entry] = state.coverage.tiles.values();
|
||||
return (
|
||||
entry.primitives.length > 0 &&
|
||||
entry.primitives.every(({ onGround }) => !onGround)
|
||||
);
|
||||
};
|
||||
coverage.refresh();
|
||||
source.calls[0].resolve(bytes);
|
||||
await settle();
|
||||
await settle();
|
||||
assert.ok(isCast(), 'cast once the heights are in');
|
||||
// Another region fills the cache and evicts this tile's corners...
|
||||
await groundCaster.prepare([[centre.lon + 0.05, centre.lat + 0.05]]);
|
||||
assert.equal(groundCaster.groundAt(centre.lon - 0.001, centre.lat), null);
|
||||
// ...then mesh samples redraw the tile, which can only drape now.
|
||||
sampled([[centre.lon, centre.lat]]);
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
coverage.refresh();
|
||||
await settle();
|
||||
await settle();
|
||||
assert.ok(isCast(), 'the tile is cast again');
|
||||
coverage.clear();
|
||||
});
|
||||
|
||||
test('a tile left draped by a failed terrain lookup is cast on the next refresh', async () => {
|
||||
let up = false;
|
||||
let prepares = 0;
|
||||
let heightsReady = false;
|
||||
const groundCaster = {
|
||||
prepareLines: async () => {
|
||||
prepares++;
|
||||
heightsReady = up;
|
||||
return up;
|
||||
},
|
||||
castLine: (coords) =>
|
||||
heightsReady ? coords.flatMap(([lon, lat]) => [lon, lat, 30]) : null,
|
||||
};
|
||||
const { viewer, source, coverage, bytes } = setup({
|
||||
surface: 'terrain',
|
||||
groundCaster,
|
||||
});
|
||||
coverage.refresh();
|
||||
source.calls[0].resolve(bytes);
|
||||
await settle();
|
||||
await settle();
|
||||
assert.equal(prepares, 1);
|
||||
assert.equal(viewer.scene.primitives.items.size, 0, 'draped');
|
||||
await settle();
|
||||
assert.equal(prepares, 1, 'no retry loop while the proxy is down');
|
||||
up = true; // the proxy is back
|
||||
coverage.refresh();
|
||||
await settle();
|
||||
assert.equal(prepares, 2, 'the next refresh tries again');
|
||||
assert.equal(viewer.scene.primitives.items.size, 1, 'cast lines added');
|
||||
coverage.clear();
|
||||
});
|
||||
|
||||
test('the per-tile cap applies after the imagery filter, so a dense tile keeps its 360° lines', async () => {
|
||||
const { state, source, coverage, centre } = setup();
|
||||
const line = (n) => [
|
||||
[
|
||||
[centre.lon - 0.001, centre.lat + n * 1e-6],
|
||||
[centre.lon + 0.001, centre.lat + n * 1e-6],
|
||||
],
|
||||
];
|
||||
// 700 newer flat sequences, then 300 older 360° ones.
|
||||
const sequences = [];
|
||||
for (let i = 0; i < 700; i++)
|
||||
sequences.push({
|
||||
id: `flat-${i}`,
|
||||
parts: line(i),
|
||||
capturedAt: 2_000_000_000_000 + i,
|
||||
});
|
||||
for (let i = 0; i < 300; i++)
|
||||
sequences.push({
|
||||
id: `pano-${i}`,
|
||||
parts: line(i),
|
||||
capturedAt: 1_000_000_000_000 + i,
|
||||
isPano: true,
|
||||
});
|
||||
state.context.filter = { pano: 'pano', sinceMs: null };
|
||||
coverage.refresh();
|
||||
source.calls[0].resolve(encodeCoverageTile(centre.tile, { sequences }));
|
||||
await settle();
|
||||
const [entry] = state.coverage.tiles.values();
|
||||
const cap = Math.floor(COVERAGE_MAX_SEQUENCES / COVERAGE_MAX_TILES);
|
||||
assert.equal(entry.count, Math.min(300, cap), 'every 360° line is drawn');
|
||||
assert.equal(coverage.sequenceCount(), Math.min(300, cap));
|
||||
// Picking and recolouring look sequences up among the drawn lines.
|
||||
assert.ok(entry.sequences.has('pano-0'), 'drawn lines can be looked up');
|
||||
assert.equal(entry.sequences.has('flat-699'), false);
|
||||
state.context.filter = { pano: 'all', sinceMs: null };
|
||||
coverage.rebuild();
|
||||
assert.equal(entry.count, cap, 'all imagery is capped as before');
|
||||
assert.ok(entry.sequences.has('flat-699'), 'the newest flat line is drawn');
|
||||
assert.equal(entry.sequences.has('pano-299'), false, 'over the cap');
|
||||
coverage.clear();
|
||||
});
|
||||
|
||||
/** The colour attribute value of a line, selected or not. */
|
||||
function colourValue(selected) {
|
||||
return Array.from(
|
||||
Cesium.ColorGeometryInstanceAttribute.toValue(
|
||||
selected
|
||||
? Cesium.Color.fromCssColorString(COLORS.selected)
|
||||
: Cesium.Color.fromCssColorString(COLORS.coverage).withAlpha(0.92),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** Take over a drawn primitive's readiness and seq-1's colour attribute. */
|
||||
function controlled(record, ready) {
|
||||
const control = { ready, attributes: { color: undefined } };
|
||||
Object.defineProperty(record.primitive, 'ready', {
|
||||
get: () => control.ready,
|
||||
});
|
||||
record.primitive.getGeometryInstanceAttributes = (id) =>
|
||||
id === 'mly:seq:seq-1' ? control.attributes : undefined;
|
||||
return control;
|
||||
}
|
||||
|
||||
test('a selection made or cleared while a tile builds is applied once it is ready', async () => {
|
||||
const { viewer, source, state, coverage, bytes } = setup();
|
||||
coverage.refresh();
|
||||
source.calls[0].resolve(bytes);
|
||||
await settle();
|
||||
const [entry] = state.coverage.tiles.values();
|
||||
// Selected while its line is still building: nothing to recolour yet...
|
||||
const first = controlled(entry.primitives[0], false);
|
||||
state.sequence.selectedId = 'seq-1';
|
||||
coverage.recolorSequence('seq-1', true);
|
||||
assert.equal(first.attributes.color, undefined);
|
||||
// ...so the highlight lands once it is ready.
|
||||
first.ready = true;
|
||||
frame(viewer.postRender);
|
||||
assert.deepEqual(Array.from(first.attributes.color), colourValue(true));
|
||||
|
||||
// Rebuilt with the highlight baked in, then cleared mid-build: not stuck.
|
||||
coverage.rebuild();
|
||||
const second = controlled(entry.primitives[0], false);
|
||||
coverage.recolorSequence('seq-1', false);
|
||||
state.sequence.selectedId = null;
|
||||
second.ready = true;
|
||||
frame(viewer.postRender);
|
||||
assert.deepEqual(Array.from(second.attributes.color), colourValue(false));
|
||||
coverage.clear();
|
||||
assert.equal(viewer.postRender.size, 0, 'the selection watch is gone');
|
||||
});
|
||||
|
||||
test('a sequence that crosses a tile edge is counted once', async () => {
|
||||
const { viewer, source, coverage, centre } = setup();
|
||||
// Look straight down on the edge between this tile and its east neighbour.
|
||||
const { east } = tileBounds(centre.tile.x, centre.tile.y, 14);
|
||||
viewer.view.lon = east;
|
||||
coverage.refresh();
|
||||
assert.equal(source.calls.length, 2, 'two tiles in view');
|
||||
const across = [
|
||||
[
|
||||
[east - 0.001, centre.lat],
|
||||
[east + 0.001, centre.lat],
|
||||
],
|
||||
];
|
||||
for (const call of source.calls) {
|
||||
const [, x, y] = call.key.split('/').map(Number);
|
||||
call.resolve(
|
||||
encodeCoverageTile(
|
||||
{ x, y, z: 14 },
|
||||
{
|
||||
sequences: [
|
||||
{ id: 'across', parts: across },
|
||||
{ id: `only-${x}`, parts: across },
|
||||
],
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
await settle();
|
||||
assert.equal(coverage.sequenceCount(), 3, 'across, plus one per tile');
|
||||
coverage.clear();
|
||||
});
|
||||
|
||||
/** A terrain-mode tile mid-swap, with a cast primitive the test readies. */
|
||||
async function midSwap() {
|
||||
// Drawn draped first; castLine only answers once the heights are in.
|
||||
let heightsReady = false;
|
||||
const groundCaster = {
|
||||
prepareLines: async () => (heightsReady = true),
|
||||
castLine: (coords) =>
|
||||
heightsReady ? coords.flatMap(([lon, lat]) => [lon, lat, 30]) : null,
|
||||
};
|
||||
const context = setup({ surface: 'terrain', groundCaster });
|
||||
context.coverage.refresh();
|
||||
context.source.calls[0].resolve(context.bytes);
|
||||
await settle();
|
||||
await settle();
|
||||
const { groundPrimitives, primitives } = context.viewer.scene;
|
||||
assert.equal(groundPrimitives.items.size, 1, 'draped lines kept');
|
||||
assert.equal(primitives.items.size, 1, 'cast lines building');
|
||||
const [cast] = primitives.items;
|
||||
const control = { ready: false };
|
||||
Object.defineProperty(cast, 'ready', { get: () => control.ready });
|
||||
return { ...context, control };
|
||||
}
|
||||
|
||||
test('the draped lines go once the cast lines are ready', async () => {
|
||||
const { viewer, coverage, control } = await midSwap();
|
||||
frame(viewer.postRender);
|
||||
assert.equal(viewer.scene.groundPrimitives.items.size, 1, 'still building');
|
||||
control.ready = true;
|
||||
frame(viewer.postRender);
|
||||
assert.equal(viewer.scene.groundPrimitives.items.size, 0, 'draped removed');
|
||||
assert.equal(viewer.scene.primitives.items.size, 1, 'cast lines stay');
|
||||
assert.equal(viewer.postRender.size, 0, 'nothing left watching');
|
||||
coverage.clear();
|
||||
});
|
||||
|
||||
test('the draped lines go after the wait even if the cast lines never get ready', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['Date'] });
|
||||
const { viewer, coverage } = await midSwap();
|
||||
t.mock.timers.tick(SWAP_MAX_WAIT_MS - 1);
|
||||
frame(viewer.postRender);
|
||||
assert.equal(viewer.scene.groundPrimitives.items.size, 1, 'still waiting');
|
||||
t.mock.timers.tick(2);
|
||||
frame(viewer.postRender);
|
||||
assert.equal(viewer.scene.groundPrimitives.items.size, 0, 'draped removed');
|
||||
assert.equal(viewer.scene.primitives.items.size, 1);
|
||||
coverage.clear();
|
||||
});
|
||||
|
||||
/** A camera over a 100×100 canvas with a 60°×40° view (see rayCamera). */
|
||||
function tiltedCamera({ lon, lat, height, heading, pitch }) {
|
||||
return rayCamera({
|
||||
lon,
|
||||
lat,
|
||||
altitude: height,
|
||||
heading,
|
||||
pitch,
|
||||
width: 100,
|
||||
height: 100,
|
||||
fovX: 60,
|
||||
fovY: 40,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Denver: 300 m above a street 1,600 m up, at a z14 tile centre, looking east
|
||||
* at ground `aheadM` metres out. Only the bare-earth caster knows the ground.
|
||||
*/
|
||||
function denverStreetView(aheadM) {
|
||||
const z = 14;
|
||||
const x = lonToTileX(-104.99, z);
|
||||
const y = latToTileY(39.74, z);
|
||||
const tile = tileBounds(x, y, z);
|
||||
const lon = (tile.west + tile.east) / 2;
|
||||
const lat = (tile.south + tile.north) / 2;
|
||||
const ground = 1600;
|
||||
const above = 300;
|
||||
const context = setup({ groundCaster: { groundAt: () => ground } });
|
||||
const tileWidthM = (tile.east - tile.west) * 111_320 * Math.cos(lat * RAD);
|
||||
context.viewer.camera = tiltedCamera({
|
||||
lon,
|
||||
lat,
|
||||
height: ground + above,
|
||||
heading: 90,
|
||||
pitch: -Math.atan(above / aheadM(tileWidthM)) / RAD,
|
||||
});
|
||||
context.coverage.refresh();
|
||||
const keys = context.source.calls.map((call) => call.key);
|
||||
/** Requested tiles as [dx, dy] from the tile under the camera. */
|
||||
const offsets = keys.map((k) => {
|
||||
const [, tx, ty] = k.split('/').map(Number);
|
||||
return [tx - x, ty - y];
|
||||
});
|
||||
return {
|
||||
...context,
|
||||
keys,
|
||||
offsets,
|
||||
key: (dx, dy) => `${z}/${x + dx}/${y + dy}`,
|
||||
};
|
||||
}
|
||||
|
||||
test('a tilted street view over ground 1,600 m up asks for the tiles it looks at first', () => {
|
||||
// The screen centre meets the ground at the centre of the next tile east.
|
||||
const { state, coverage, keys, offsets, key } = denverStreetView(
|
||||
(tileWidthM) => tileWidthM,
|
||||
);
|
||||
assert.equal(state.coverage.zoom, 14, 'height measured from the ground');
|
||||
assert.deepEqual(
|
||||
keys.slice(0, 2),
|
||||
[key(0, 0), key(1, 0)],
|
||||
'the tile under the camera, then the one the screen centre looks at',
|
||||
);
|
||||
assert.ok(keys.includes(key(-1, 0)), 'the ground behind the camera too');
|
||||
for (const [dx, dy] of offsets)
|
||||
assert.ok(dx >= -1 && dx <= 2 && Math.abs(dy) <= 1, `${dx},${dy} in reach`);
|
||||
coverage.clear();
|
||||
});
|
||||
|
||||
test('a street view toward the horizon ranks only the ground within range', () => {
|
||||
// The screen centre meets the ground 6 km out, past the 3 km range: the
|
||||
// tiles are ranked around the camera, not along a line to the horizon.
|
||||
const { coverage, keys, offsets, key } = denverStreetView(() => 6000);
|
||||
assert.equal(keys[0], key(0, 0), 'the tile under the camera first');
|
||||
// Behind and ahead are equally near the camera (ties keep row order); a
|
||||
// line of sight to the far ground would rank the tile ahead first.
|
||||
assert.ok(
|
||||
keys.indexOf(key(-1, 0)) < keys.indexOf(key(1, 0)),
|
||||
'the tile ahead is not pulled forward',
|
||||
);
|
||||
for (const [dx, dy] of offsets)
|
||||
assert.ok(Math.abs(dx) <= 1 && Math.abs(dy) <= 1, `${dx},${dy} around it`);
|
||||
coverage.clear();
|
||||
});
|
||||
|
||||
test('a selection cleared while the old zoom is still shown uncolours its lines', async () => {
|
||||
const { viewer, source, state, coverage, bytes } = setup();
|
||||
coverage.refresh();
|
||||
source.calls[0].resolve(bytes);
|
||||
await settle();
|
||||
const [entry] = state.coverage.tiles.values();
|
||||
const line = controlled(entry.primitives[0], true);
|
||||
state.sequence.selectedId = 'seq-1';
|
||||
coverage.recolorSequence('seq-1', true);
|
||||
assert.deepEqual(Array.from(line.attributes.color), colourValue(true));
|
||||
// Zoom out: the street tile stays on screen while the new zoom loads...
|
||||
viewer.view.height = 100_000;
|
||||
coverage.refresh();
|
||||
assert.equal(state.coverage.stale.size, 1, 'the old zoom is kept');
|
||||
// ...and the selection is cleared meanwhile.
|
||||
state.sequence.selectedId = null;
|
||||
coverage.recolorSequence('seq-1', false);
|
||||
assert.deepEqual(
|
||||
Array.from(line.attributes.color),
|
||||
colourValue(false),
|
||||
'the retained line is not left cyan',
|
||||
);
|
||||
coverage.clear();
|
||||
});
|
||||
|
||||
test('a selection made while the old zoom is still building is applied once it is ready', async () => {
|
||||
const { viewer, source, state, coverage, bytes } = setup();
|
||||
coverage.refresh();
|
||||
source.calls[0].resolve(bytes);
|
||||
await settle();
|
||||
const [entry] = state.coverage.tiles.values();
|
||||
const line = controlled(entry.primitives[0], false);
|
||||
viewer.view.height = 100_000;
|
||||
coverage.refresh();
|
||||
assert.equal(state.coverage.stale.size, 1);
|
||||
frame(viewer.postRender);
|
||||
state.sequence.selectedId = 'seq-1';
|
||||
coverage.recolorSequence('seq-1', true);
|
||||
line.ready = true;
|
||||
frame(viewer.postRender);
|
||||
assert.deepEqual(
|
||||
line.attributes.color && Array.from(line.attributes.color),
|
||||
colourValue(true),
|
||||
'the highlight lands on the retained line',
|
||||
);
|
||||
coverage.clear();
|
||||
assert.equal(viewer.postRender.size, 0, 'the selection watch is gone');
|
||||
});
|
||||
|
||||
test('only lines within the mesh sampler range of the camera are asked for', async () => {
|
||||
let heightsReady = false;
|
||||
const groundCaster = {
|
||||
prepareLines: async () => (heightsReady = true),
|
||||
castLine: (coords) =>
|
||||
heightsReady ? coords.flatMap(([lon, lat]) => [lon, lat, 30]) : null,
|
||||
};
|
||||
const requested = [];
|
||||
const meshSampler = {
|
||||
onSampled() {},
|
||||
request: (points) => requested.push(...points),
|
||||
meshAt: () => undefined,
|
||||
};
|
||||
const { source, coverage, centre } = setup({
|
||||
surface: 'terrain',
|
||||
groundCaster,
|
||||
meshSampler,
|
||||
});
|
||||
const { east, north } = tileBounds(centre.tile.x, centre.tile.y, 14);
|
||||
coverage.refresh();
|
||||
source.calls[0].resolve(
|
||||
encodeCoverageTile(centre.tile, {
|
||||
sequences: [
|
||||
{
|
||||
id: 'here',
|
||||
parts: [
|
||||
[
|
||||
[centre.lon - 0.001, centre.lat],
|
||||
[centre.lon + 0.001, centre.lat],
|
||||
],
|
||||
],
|
||||
},
|
||||
// ~1.1 km from the camera, in the tile's north-east corner.
|
||||
{
|
||||
id: 'corner',
|
||||
parts: [
|
||||
[
|
||||
[east - 0.002, north - 0.002],
|
||||
[east - 0.0005, north - 0.0005],
|
||||
],
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
await settle();
|
||||
await settle();
|
||||
assert.ok(requested.length > 0, 'the near line is asked for');
|
||||
const farthest = Math.max(
|
||||
...requested.map(([lon, lat]) =>
|
||||
Math.hypot(
|
||||
(lon - centre.lon) * 111_320 * Math.cos(centre.lat * RAD),
|
||||
(lat - centre.lat) * 110_540,
|
||||
),
|
||||
),
|
||||
);
|
||||
assert.ok(farthest < 200, `nothing from the corner (${farthest} m)`);
|
||||
coverage.clear();
|
||||
});
|
||||
|
||||
test('the old zoom goes as soon as the new zoom has loaded, not after the stale wait', async () => {
|
||||
const { viewer, source, state, coverage, bytes } = setup();
|
||||
coverage.refresh();
|
||||
source.calls[0].resolve(bytes);
|
||||
await settle();
|
||||
const [old] = viewer.scene.groundPrimitives.items;
|
||||
assert.ok(old, 'the z14 lines are drawn');
|
||||
viewer.view.height = 3000; // zoom out to z13
|
||||
coverage.refresh();
|
||||
assert.equal(state.coverage.zoom, 13);
|
||||
const fresh = source.calls.slice(1);
|
||||
assert.ok(fresh.length > 0, 'the new zoom is loading');
|
||||
assert.equal(state.coverage.stale.size, 1, 'the old zoom stays meanwhile');
|
||||
assert.ok(viewer.scene.groundPrimitives.items.has(old));
|
||||
for (const call of fresh) {
|
||||
assert.ok(
|
||||
viewer.scene.groundPrimitives.items.has(old),
|
||||
'kept while a new tile is still loading',
|
||||
);
|
||||
call.resolve(new Uint8Array());
|
||||
await settle();
|
||||
}
|
||||
// The last new tile is in: the old lines go now, long before the wait.
|
||||
assert.equal(state.coverage.pending.size, 0);
|
||||
assert.equal(state.coverage.stale.size, 0);
|
||||
assert.equal(viewer.scene.groundPrimitives.items.has(old), false);
|
||||
assert.equal(state.coverage.staleTimer, null, 'the stale wait is cancelled');
|
||||
coverage.clear();
|
||||
});
|
||||
|
||||
test('a rate limit holds every refresh inside its wait, not only the first', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
|
||||
const { viewer, source, coverage } = setup();
|
||||
coverage.refresh();
|
||||
source.calls[0].reject(
|
||||
Object.assign(new Error('rate limited'), { retryAfterSec: 30 }),
|
||||
);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
t.mock.timers.tick(1000);
|
||||
viewer.view.lon += 0.05;
|
||||
coverage.refresh();
|
||||
assert.equal(source.calls.length, 1, 'held 1 s into a 30 s wait');
|
||||
t.mock.timers.tick(28_000);
|
||||
viewer.view.lon += 0.05;
|
||||
coverage.refresh();
|
||||
assert.equal(source.calls.length, 1, 'held 29 s into a 30 s wait');
|
||||
t.mock.timers.tick(1000);
|
||||
assert.ok(source.calls.length > 1, 'asked again once the wait is over');
|
||||
coverage.clear();
|
||||
coverage.resetErrors();
|
||||
});
|
||||
|
||||
test('a line across the date line from the camera is in mesh range', () => {
|
||||
// A z14 tile's width of lines just past -180, the camera 111 m short of it.
|
||||
const camera = { lon: 179.999, lat: 0 };
|
||||
const east = { west: -180, east: -179.978, south: -0.001, north: 0.001 };
|
||||
assert.equal(meshBoxInRange(east, camera), true);
|
||||
// The mirror case, from the other side.
|
||||
const west = { west: 179.978, east: 180, south: -0.001, north: 0.001 };
|
||||
assert.equal(meshBoxInRange(west, { lon: -179.999, lat: 0 }), true);
|
||||
// ~2.2 km away across the line: out of range either way.
|
||||
const far = { west: -179.99, east: -179.97, south: -0.001, north: 0.001 };
|
||||
assert.equal(meshBoxInRange(far, camera), false);
|
||||
});
|
||||
|
||||
test('a tile that fails before the terrain table loads is reported at once, never unhandled', async () => {
|
||||
const { source, state, coverage, tileKey } = setup();
|
||||
let terrainLoaded;
|
||||
state.coverage.terrainReady = new Promise((resolve) => {
|
||||
terrainLoaded = resolve;
|
||||
});
|
||||
const unhandled = [];
|
||||
const onUnhandled = (reason) => unhandled.push(reason);
|
||||
process.on('unhandledRejection', onUnhandled);
|
||||
try {
|
||||
coverage.refresh();
|
||||
source.calls
|
||||
.find((call) => call.key === tileKey)
|
||||
.reject(new Error('Mapillary tiles HTTP 500'));
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
assert.equal(state.coverage.lastError, 'Mapillary tiles HTTP 500');
|
||||
assert.deepEqual(unhandled, []);
|
||||
} finally {
|
||||
terrainLoaded();
|
||||
await settle();
|
||||
process.off('unhandledRejection', onUnhandled);
|
||||
}
|
||||
});
|
||||
|
||||
test('lines a partial terrain lookup left draped are cast once the terrain recovers', async () => {
|
||||
// Two lines: the west one's heights arrive, the east one's fail at first.
|
||||
let eastUp = false;
|
||||
let prepares = 0;
|
||||
const groundCaster = {
|
||||
prepareLines: async () => {
|
||||
prepares++;
|
||||
return eastUp;
|
||||
},
|
||||
castLine: (coords) =>
|
||||
coords[0][0] < centreLon || eastUp
|
||||
? coords.flatMap(([lon, lat]) => [lon, lat, 30])
|
||||
: null,
|
||||
};
|
||||
const { state, source, coverage, centre } = setup({
|
||||
surface: 'terrain',
|
||||
groundCaster,
|
||||
});
|
||||
const centreLon = centre.lon;
|
||||
const line = (from, to) => [
|
||||
[
|
||||
[from, centre.lat],
|
||||
[to, centre.lat],
|
||||
],
|
||||
];
|
||||
coverage.refresh();
|
||||
source.calls[0].resolve(
|
||||
encodeCoverageTile(centre.tile, {
|
||||
sequences: [
|
||||
{ id: 'west', parts: line(centre.lon - 0.002, centre.lon - 0.001) },
|
||||
{ id: 'east', parts: line(centre.lon + 0.001, centre.lon + 0.002) },
|
||||
],
|
||||
}),
|
||||
);
|
||||
await settle();
|
||||
await settle();
|
||||
const [entry] = state.coverage.tiles.values();
|
||||
assert.equal(prepares, 1);
|
||||
assert.deepEqual([entry.castLines, entry.drapedLines], [1, 1], 'partial');
|
||||
await settle();
|
||||
assert.equal(prepares, 1, 'no retry loop while the lookup fails');
|
||||
|
||||
// A refresh while still failing asks again but rebuilds nothing.
|
||||
const drawn = entry.primitives;
|
||||
coverage.refresh();
|
||||
await settle();
|
||||
assert.equal(prepares, 2);
|
||||
assert.equal(entry.primitives, drawn, 'no rebuild without new heights');
|
||||
|
||||
eastUp = true; // the terrain proxy recovers
|
||||
coverage.refresh();
|
||||
await settle();
|
||||
assert.equal(prepares, 3, 'the next refresh tries the draped line again');
|
||||
assert.deepEqual([entry.castLines, entry.drapedLines], [2, 0], 'all cast');
|
||||
coverage.clear();
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import { PbfWriter } from 'pbf';
|
||||
export const zigzag = (value) => (value << 1) ^ (value >> 31);
|
||||
|
||||
/** Tile-local [x, y] (0..extent) of a lon/lat inside tile (x, y, z). */
|
||||
export function lonLatToTileLocal([lon, lat], { x, y, z }, extent = 4096) {
|
||||
function lonLatToTileLocal([lon, lat], { x, y, z }, extent = 4096) {
|
||||
const n = 2 ** z;
|
||||
const rad = (lat * Math.PI) / 180;
|
||||
const tx = ((lon + 180) / 360) * n;
|
||||
@@ -86,11 +86,11 @@ export function lineGeometry(parts) {
|
||||
* Encode a `mly1_public`-shaped tile from lon/lat features, for tests and
|
||||
* the hermetic QA gate.
|
||||
* @param {{x: number, y: number, z: number}} tile
|
||||
* @param {{sequences?: Array<{id: string, capturedAt?: number, isPano?: boolean, parts: Array<Array<[number, number]>>}>, overview?: Array<{id: string, lon: number, lat: number, capturedAt?: number, isPano?: boolean}>, extent?: number}} [content]
|
||||
* @param {{sequences?: Array<{id: string, capturedAt?: number, isPano?: boolean, parts: Array<Array<[number, number]>>}>, extent?: number}} [content]
|
||||
*/
|
||||
export function encodeCoverageTile(
|
||||
tile,
|
||||
{ sequences = [], overview = [], extent = 4096 } = {},
|
||||
{ sequences = [], extent = 4096 } = {},
|
||||
) {
|
||||
const pbf = new PbfWriter();
|
||||
if (sequences.length) {
|
||||
@@ -111,17 +111,5 @@ export function encodeCoverageTile(
|
||||
);
|
||||
pbf.writeMessage(3, (_, out) => layer.write(out), null);
|
||||
}
|
||||
if (overview.length) {
|
||||
const layer = layerWriter('overview', extent);
|
||||
for (const point of overview) {
|
||||
const [px, py] = lonLatToTileLocal([point.lon, point.lat], tile, extent);
|
||||
layer.add(1, [9, zigzag(px), zigzag(py)], {
|
||||
id: point.id,
|
||||
captured_at: point.capturedAt ?? Date.now(),
|
||||
is_pano: point.isPano === true,
|
||||
});
|
||||
}
|
||||
pbf.writeMessage(3, (_, out) => layer.write(out), null);
|
||||
}
|
||||
return new Uint8Array(pbf.finish());
|
||||
}
|
||||
@@ -3,13 +3,13 @@ import { VectorTile } from '@mapbox/vector-tile';
|
||||
import { tileLocalToLonLat } from '../../tileMath.js';
|
||||
|
||||
/**
|
||||
* Decode a `mly1_public` coverage tile into sequences and (z0–5) overview
|
||||
* points. The z14 `image` layer, which can hold >150k points, is skipped.
|
||||
* Decode a `mly1_public` coverage tile (z11–14) into sequences. The z14
|
||||
* `image` layer, which can hold >150k points, is skipped.
|
||||
* @param {Uint8Array} bytes
|
||||
* @param {{x:number,y:number,z:number}} address
|
||||
*/
|
||||
export function decodeCoverageTile(bytes, address) {
|
||||
const result = { sequences: [], overview: [] };
|
||||
const result = { sequences: [] };
|
||||
if (!bytes || !bytes.length) return result;
|
||||
const tile = new VectorTile(new PbfReader(bytes));
|
||||
const { x, y, z } = address;
|
||||
@@ -30,41 +30,11 @@ export function decodeCoverageTile(bytes, address) {
|
||||
if (!parts.length) continue;
|
||||
result.sequences.push({
|
||||
id: String(props.id ?? feature.id ?? `${x}/${y}/${i}`),
|
||||
imageId: props.image_id != null ? String(props.image_id) : null,
|
||||
capturedAt: Number(props.captured_at) || 0,
|
||||
isPano: props.is_pano === true,
|
||||
onFoot: props.foot === true,
|
||||
quality: Number.isFinite(props.quality_score)
|
||||
? props.quality_score
|
||||
: null,
|
||||
parts,
|
||||
});
|
||||
}
|
||||
}
|
||||
const overviewLayer = tile.layers.overview;
|
||||
if (overviewLayer) {
|
||||
for (let i = 0; i < overviewLayer.length; i++) {
|
||||
const feature = overviewLayer.feature(i);
|
||||
const props = feature.properties || {};
|
||||
const point = feature.loadGeometry()?.[0]?.[0];
|
||||
if (!point) continue;
|
||||
const [lon, lat] = tileLocalToLonLat(
|
||||
point.x,
|
||||
point.y,
|
||||
overviewLayer.extent,
|
||||
x,
|
||||
y,
|
||||
z,
|
||||
);
|
||||
result.overview.push({
|
||||
id: String(props.id ?? feature.id ?? i),
|
||||
lon,
|
||||
lat,
|
||||
capturedAt: Number(props.captured_at) || 0,
|
||||
isPano: props.is_pano === true,
|
||||
sequenceId: props.sequence_id ? String(props.sequence_id) : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -17,7 +17,10 @@ function linePart(points) {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A minimal mly1_public-shaped tile: one sequence line and one overview point. */
|
||||
/**
|
||||
* A minimal mly1_public-shaped tile: one sequence line, plus one point in the
|
||||
* z14 `image` layer the decoder skips.
|
||||
*/
|
||||
function sampleTile({ geometry = null } = {}) {
|
||||
const writer = new PbfWriter();
|
||||
writer.writeMessage(
|
||||
@@ -70,7 +73,7 @@ function sampleTile({ geometry = null } = {}) {
|
||||
3,
|
||||
(_, layer) => {
|
||||
layer.writeVarintField(15, 2);
|
||||
layer.writeStringField(1, 'overview');
|
||||
layer.writeStringField(1, 'image');
|
||||
layer.writeVarintField(5, 4096);
|
||||
layer.writeStringField(3, 'is_pano');
|
||||
layer.writeMessage(
|
||||
@@ -94,7 +97,7 @@ function sampleTile({ geometry = null } = {}) {
|
||||
return new Uint8Array(writer.finish());
|
||||
}
|
||||
|
||||
test('sequences and overview points decode into lon/lat records', () => {
|
||||
test('sequences decode into lon/lat records; image points are skipped', () => {
|
||||
const decoded = decodeCoverageTile(sampleTile(), { x: 0, y: 0, z: 1 });
|
||||
assert.equal(decoded.sequences.length, 1);
|
||||
const [sequence] = decoded.sequences;
|
||||
@@ -109,19 +112,13 @@ test('sequences and overview points decode into lon/lat records', () => {
|
||||
'west half of tile 0/0 at z1',
|
||||
);
|
||||
assert.ok(lat0 > 0 && lat1 < lat0, 'northern hemisphere, moving south');
|
||||
assert.equal(decoded.overview.length, 1);
|
||||
assert.equal(decoded.overview[0].id, '3');
|
||||
assert.equal(decoded.overview[0].isPano, false);
|
||||
assert.ok(Math.abs(decoded.overview[0].lon - -90) < 1e-6, 'tile centre');
|
||||
assert.deepEqual(Object.keys(decoded), ['sequences']);
|
||||
});
|
||||
|
||||
test('an empty tile decodes to empty lists', () => {
|
||||
assert.deepEqual(
|
||||
decodeCoverageTile(new Uint8Array(0), { x: 0, y: 0, z: 0 }),
|
||||
{
|
||||
sequences: [],
|
||||
overview: [],
|
||||
},
|
||||
{ sequences: [] },
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,70 +1,67 @@
|
||||
import { createCoverage, sequenceIdFromPick } from './coverage.js';
|
||||
import { createSequences } from './sequences.js';
|
||||
import {
|
||||
createLineCoverage,
|
||||
freshCoverage,
|
||||
sequenceIdFromPick,
|
||||
} from '../../lineCoverage.js';
|
||||
import { createSequenceCones, thinImages } from '../../sequenceCones.js';
|
||||
import { coverageZoomForHeight } from '../../tileMath.js';
|
||||
import { decodeCoverageTile } from './decode.js';
|
||||
import { createMapillaryViewer } from './viewer.js';
|
||||
import { passesImageryFilter } from '../../filter.js';
|
||||
import { metresBetween } from '../../view.js';
|
||||
import {
|
||||
COLORS,
|
||||
COVERAGE_TUNING,
|
||||
KEY_REJECTED_MESSAGE,
|
||||
MAPILLARY_CREDIT_HTML,
|
||||
MAPILLARY_KEY_ID,
|
||||
MAPILLARY_LABEL,
|
||||
MAPILLARY_NAME,
|
||||
MAPILLARY_PROVIDER_ID,
|
||||
MAPILLARY_SOURCE_METHODS,
|
||||
NEAREST_LIMIT,
|
||||
NEAREST_RADIUS_M,
|
||||
PICK_PREFIX,
|
||||
RATE_LIMITED_MESSAGE,
|
||||
} from './policy.js';
|
||||
|
||||
/** Per-provider mutable state, created once per `create()`. */
|
||||
/** Normalize a graph image record into the shape the cones use. */
|
||||
function normalizeSequenceImage(record) {
|
||||
const coordinates = record?.geometry?.coordinates;
|
||||
if (!Array.isArray(coordinates) || coordinates.length < 2) return null;
|
||||
return {
|
||||
id: String(record.id),
|
||||
lon: Number(coordinates[0]),
|
||||
lat: Number(coordinates[1]),
|
||||
compassAngle: Number(record.compass_angle) || 0,
|
||||
capturedAt: Number(record.captured_at) || 0,
|
||||
isPano: record.is_pano === true,
|
||||
};
|
||||
}
|
||||
|
||||
/** Per-provider mutable state; coverage and cones share it. */
|
||||
function createProviderState(context) {
|
||||
return {
|
||||
context,
|
||||
services: context.services,
|
||||
viewer: null,
|
||||
keyRequired: false,
|
||||
/** Coverage waits for the first status: no tile requests without a key. */
|
||||
statusKnown: false,
|
||||
/** Mapillary refused the token: request nothing until re-enabled. */
|
||||
keyRejected: false,
|
||||
status: null,
|
||||
coverage: {
|
||||
zoom: null,
|
||||
/** Current zoom's tiles by `z/x/y` key. */
|
||||
tiles: new Map(),
|
||||
/** Previous zoom's tiles, kept on screen until replacements land. */
|
||||
stale: new Map(),
|
||||
staleTimer: null,
|
||||
/** Tile key → its request's controller; any entry means LOADING. */
|
||||
pending: new Map(),
|
||||
lastError: null,
|
||||
/** While Mapillary rate-limits: no requests before this time (ms). */
|
||||
holdUntil: 0,
|
||||
holdTimer: null,
|
||||
debounceTimer: null,
|
||||
removeCameraListener: null,
|
||||
terrainReady: null,
|
||||
hint: '',
|
||||
kind: null,
|
||||
},
|
||||
coverage: freshCoverage(),
|
||||
sequence: {
|
||||
selectedId: null,
|
||||
images: [],
|
||||
/** Recent sequences' thinned images, by sequence id. */
|
||||
cache: new Map(),
|
||||
collection: null,
|
||||
loading: false,
|
||||
/** Why the selected sequence's images could not load, until it goes. */
|
||||
error: null,
|
||||
abort: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mapillary Street Level provider: tile coverage, Graph API image cones and
|
||||
* the MapillaryJS viewer. `source` is ./source.js or a stand-in.
|
||||
* The Mapillary provider: coverage lines from vector tiles, image cones from
|
||||
* the Graph API and the MapillaryJS viewer.
|
||||
* @param {{source: object, photoViewer?: object}} options `source` is
|
||||
* source.js or a stand-in; `photoViewer` stands in for the viewer adapter.
|
||||
* @returns {import('../../registry.js').StreetLevelProvider}
|
||||
*/
|
||||
export function createMapillaryProvider({ source }) {
|
||||
export function createMapillaryProvider({ source, photoViewer = null }) {
|
||||
if (
|
||||
!MAPILLARY_SOURCE_METHODS.every(
|
||||
(method) => typeof source?.[method] === 'function',
|
||||
@@ -84,86 +81,97 @@ export function createMapillaryProvider({ source }) {
|
||||
create(context) {
|
||||
const state = createProviderState(context);
|
||||
const parts = {};
|
||||
parts.coverage = createCoverage({ state, source, parts });
|
||||
parts.sequences = createSequences({ state, source, parts });
|
||||
const viewer = createMapillaryViewer({
|
||||
source,
|
||||
render: context.services?.render,
|
||||
parts.coverage = createLineCoverage({
|
||||
state,
|
||||
fetchTile: async (tile, { signal }) =>
|
||||
decodeCoverageTile(
|
||||
await source.getTile('coverage', tile.z, tile.x, tile.y, {
|
||||
signal,
|
||||
}),
|
||||
tile,
|
||||
),
|
||||
zoomFor: coverageZoomForHeight,
|
||||
colors: COLORS,
|
||||
pickPrefix: PICK_PREFIX.sequence,
|
||||
renderKey: 'mapillary-coverage',
|
||||
tuning: COVERAGE_TUNING,
|
||||
messages: {
|
||||
keyRejected: KEY_REJECTED_MESSAGE,
|
||||
rateLimited: RATE_LIMITED_MESSAGE,
|
||||
},
|
||||
});
|
||||
parts.sequences = createSequenceCones({
|
||||
state,
|
||||
async loadImages(sequenceId, { signal }) {
|
||||
const records = await source.getSequenceImages(sequenceId, {
|
||||
signal,
|
||||
});
|
||||
return thinImages(
|
||||
records
|
||||
.map(normalizeSequenceImage)
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => a.capturedAt - b.capturedAt),
|
||||
);
|
||||
},
|
||||
colors: COLORS,
|
||||
pickPrefix: PICK_PREFIX.image,
|
||||
spriteId: 'street-level:mapillary-cones',
|
||||
renderKey: 'mapillary-sequence',
|
||||
onHighlight: (sequenceId) => parts.coverage.highlight(sequenceId),
|
||||
});
|
||||
|
||||
return {
|
||||
async status() {
|
||||
/** Coverage waits for the answer: no tile requests without a key. */
|
||||
async status({ signal } = {}) {
|
||||
let configured;
|
||||
try {
|
||||
state.status = await source.getStatus();
|
||||
state.keyRequired = state.status?.configured !== true;
|
||||
configured =
|
||||
(await source.getStatus({ signal }))?.configured === true;
|
||||
} catch {
|
||||
state.status = null;
|
||||
state.keyRequired = !source.hasToken();
|
||||
configured = source.hasToken?.() === true;
|
||||
}
|
||||
const first = !state.statusKnown;
|
||||
state.statusKnown = true;
|
||||
// Coverage held back until now: draw it if the layer is on.
|
||||
if (first) parts.coverage.refresh();
|
||||
context.notify();
|
||||
return { configured: !state.keyRequired };
|
||||
parts.coverage.setKeyStatus(configured);
|
||||
return { configured };
|
||||
},
|
||||
|
||||
init(cesiumViewer) {
|
||||
state.viewer = cesiumViewer;
|
||||
parts.sequences.ensureCollections(cesiumViewer);
|
||||
init(viewer) {
|
||||
state.viewer = viewer;
|
||||
parts.sequences.ensureCollections(viewer);
|
||||
parts.sequences.setVisible(false);
|
||||
},
|
||||
|
||||
activate(cesiumViewer) {
|
||||
state.viewer = cesiumViewer;
|
||||
activate(viewer) {
|
||||
state.viewer = viewer;
|
||||
parts.sequences.setVisible(true);
|
||||
parts.coverage.attach(cesiumViewer);
|
||||
parts.coverage.attach(viewer);
|
||||
},
|
||||
|
||||
/** Clear lines and cones, forget refusals. */
|
||||
deactivate() {
|
||||
parts.coverage.detach();
|
||||
parts.coverage.clear();
|
||||
parts.coverage.resetErrors();
|
||||
parts.coverage.unblock();
|
||||
parts.sequences.clearSelection();
|
||||
parts.sequences.setVisible(false);
|
||||
},
|
||||
|
||||
destroy(cesiumViewer) {
|
||||
destroy(viewer) {
|
||||
parts.coverage.detach();
|
||||
parts.coverage.clear();
|
||||
parts.coverage.resetErrors();
|
||||
parts.sequences.destroy(cesiumViewer);
|
||||
parts.sequences.destroy(viewer);
|
||||
},
|
||||
|
||||
refreshCoverage: () => parts.coverage.refresh(),
|
||||
|
||||
/** The filter changed: redraw. Each draw reads it from the context. */
|
||||
setFilter() {
|
||||
parts.coverage.rebuild();
|
||||
parts.sequences.rerender();
|
||||
},
|
||||
|
||||
/** Core surface mode changed: redraw lines and cones draped or cast. */
|
||||
setSurface() {
|
||||
parts.coverage.setSurface();
|
||||
parts.sequences.rerender();
|
||||
},
|
||||
|
||||
coverageStats() {
|
||||
return {
|
||||
count: parts.coverage.sequenceCount(),
|
||||
zoom: state.coverage.zoom,
|
||||
kind: state.coverage.kind,
|
||||
loading: state.coverage.pending.size > 0,
|
||||
hint: state.coverage.hint,
|
||||
error: state.coverage.lastError,
|
||||
keyRequired: state.keyRequired,
|
||||
keyRejected: state.keyRejected,
|
||||
};
|
||||
},
|
||||
coverageStats: () => parts.coverage.stats(),
|
||||
|
||||
/** A line selects its sequence; a cone opens its image. */
|
||||
handlePick(pickId) {
|
||||
const sequenceId = sequenceIdFromPick(pickId);
|
||||
const sequenceId = sequenceIdFromPick(pickId, PICK_PREFIX.sequence);
|
||||
if (sequenceId) {
|
||||
parts.sequences.select(sequenceId);
|
||||
return true;
|
||||
@@ -181,39 +189,12 @@ export function createMapillaryProvider({ source }) {
|
||||
selectedId: state.sequence.selectedId,
|
||||
images: state.sequence.images.length,
|
||||
loading: state.sequence.loading,
|
||||
error: state.sequence.error,
|
||||
}),
|
||||
|
||||
/**
|
||||
* Nearest image that passes the imagery filter, or null. The core
|
||||
* aborts `signal` once a newer request overtakes the lookup.
|
||||
*/
|
||||
async nearestImage({ lat, lon }, { signal } = {}) {
|
||||
const images = await source.nearestImages(
|
||||
{ lat, lon, radius: NEAREST_RADIUS_M, limit: NEAREST_LIMIT },
|
||||
{ signal },
|
||||
);
|
||||
// The API returns the images in the radius in no particular order.
|
||||
const metres = (record) => {
|
||||
const [lon2, lat2] = (record.computed_geometry || record.geometry)
|
||||
?.coordinates || [NaN, NaN];
|
||||
const d = metresBetween({ lon, lat }, { lon: lon2, lat: lat2 });
|
||||
return Number.isFinite(d) ? d : Infinity;
|
||||
};
|
||||
const byDistance = [...images].sort((a, b) => metres(a) - metres(b));
|
||||
const filter = context.getFilter();
|
||||
const hit = byDistance.find((record) =>
|
||||
passesImageryFilter(
|
||||
{
|
||||
isPano: record.is_pano === true,
|
||||
capturedAt: Number(record.captured_at) || 0,
|
||||
},
|
||||
filter,
|
||||
),
|
||||
);
|
||||
return hit ? String(hit.id) : null;
|
||||
},
|
||||
|
||||
viewer,
|
||||
viewer:
|
||||
photoViewer ||
|
||||
createMapillaryViewer({ source, render: context.services?.render }),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { mapillaryImageUrl } from './policy.js';
|
||||
import { sequenceIdFromPick } from '../../lineCoverage.js';
|
||||
import { thinImages } from '../../sequenceCones.js';
|
||||
|
||||
test('image deep links match the mapillary.com share format', () => {
|
||||
assert.equal(
|
||||
mapillaryImageUrl(1814275685699406),
|
||||
'https://www.mapillary.com/app/?pKey=1814275685699406&focus=photo',
|
||||
);
|
||||
assert.equal(mapillaryImageUrl(' '), 'https://www.mapillary.com/app/');
|
||||
});
|
||||
|
||||
test('a sequence line pick names its sequence; other picks do not', () => {
|
||||
assert.equal(sequenceIdFromPick('mly:seq:abc', 'mly:seq:'), 'abc');
|
||||
assert.equal(sequenceIdFromPick('mly:img:abc', 'mly:seq:'), null);
|
||||
assert.equal(sequenceIdFromPick(null, 'mly:seq:'), null);
|
||||
});
|
||||
|
||||
test('cone thinning across the date line drops images metres apart', () => {
|
||||
const image = (id, lon) => ({ id, lon, lat: 0 });
|
||||
// A sequence driving east over ±180° with images ~1 m apart, then ~11 m on.
|
||||
const kept = thinImages(
|
||||
[
|
||||
image('a', 179.99999),
|
||||
image('b', -179.99999), // ~2 m from a, across the date line
|
||||
image('c', -179.9999), // ~11 m from a
|
||||
],
|
||||
3,
|
||||
);
|
||||
assert.deepEqual(
|
||||
kept.map(({ id }) => id),
|
||||
['a', 'c'],
|
||||
);
|
||||
});
|
||||
@@ -4,8 +4,6 @@ import { COLORS as SHARED_COLORS, PROVIDER_COLORS } from '../../policy.js';
|
||||
// i18n import: the messages stay English data, translated where shown.
|
||||
const msg = (text) => text;
|
||||
|
||||
export { NEAREST_RADIUS_M } from '../../policy.js';
|
||||
|
||||
/** Identity and tuning for the Mapillary street-level provider. */
|
||||
export const MAPILLARY_PROVIDER_ID = 'mapillary';
|
||||
export const MAPILLARY_NAME = 'Mapillary';
|
||||
@@ -13,12 +11,11 @@ export const MAPILLARY_LABEL = 'MAPILLARY';
|
||||
export const MAPILLARY_KEY_ID = 'mapillary';
|
||||
export const MAPILLARY_GRAPH_HOST = 'https://graph.mapillary.com';
|
||||
|
||||
/** Methods a source (./source.js or a stand-in) must have for the provider. */
|
||||
/** What the provider needs from its source (source.js or a stand-in). */
|
||||
export const MAPILLARY_SOURCE_METHODS = Object.freeze([
|
||||
'getStatus',
|
||||
'getTile',
|
||||
'getSequenceImages',
|
||||
'nearestImages',
|
||||
]);
|
||||
|
||||
/** Stable id prefixes for picked primitives; every one starts with `mly:`. */
|
||||
@@ -28,17 +25,12 @@ export const PICK_PREFIX = Object.freeze({
|
||||
image: 'mly:img:',
|
||||
});
|
||||
|
||||
/** Mapillary green for lines, points and cones; GEV cyan for selection. */
|
||||
/** Mapillary green for lines and cones; GEV cyan for the selection. */
|
||||
export const COLORS = Object.freeze({
|
||||
coverage: PROVIDER_COLORS.mapillary,
|
||||
selected: SHARED_COLORS.selected,
|
||||
});
|
||||
|
||||
/** Camera-driven coverage refresh. */
|
||||
export const COVERAGE_MOVE_DEBOUNCE_MS = 320;
|
||||
export const COVERAGE_MAX_TILES = 9;
|
||||
/** Overview (z0–5) coverage points seen from orbit. */
|
||||
export const COVERAGE_OVERVIEW_MAX_TILES = 16;
|
||||
/** Shown when Mapillary refuses the server's token (401/403). */
|
||||
export const KEY_REJECTED_MESSAGE = msg(
|
||||
'Mapillary rejected MAPILLARY_CLIENT_TOKEN — replace it in Provider Settings',
|
||||
@@ -47,24 +39,25 @@ export const KEY_REJECTED_MESSAGE = msg(
|
||||
export const RATE_LIMITED_MESSAGE = msg(
|
||||
'Coverage requests are being rate-limited — they resume on their own',
|
||||
);
|
||||
/**
|
||||
* Street-zoom coverage reaches this far from the camera: camera height times
|
||||
* the factor, never below the floor, even when a tilted view sees further.
|
||||
*/
|
||||
export const SEQUENCE_VIEW_RANGE_MIN_M = 2_500;
|
||||
export const SEQUENCE_VIEW_RANGE_PER_HEIGHT = 10;
|
||||
/** Ground always covered around the camera at street zooms, in metres. */
|
||||
export const SEQUENCE_VIEW_NEAR_M = 1_000;
|
||||
export const COVERAGE_OVERVIEW_POINT_PX = 2.5;
|
||||
export const COVERAGE_MAX_SEQUENCES = 6000;
|
||||
export const COVERAGE_LINE_WIDTH_PX = 2.5;
|
||||
|
||||
/** Coverage tuning (see LineCoverageTuning in lineCoverage.js). */
|
||||
export const COVERAGE_TUNING = Object.freeze({
|
||||
maxTiles: 9,
|
||||
maxSequences: 6000,
|
||||
lineWidthPx: 2.5,
|
||||
moveDebounceMs: 320,
|
||||
/**
|
||||
* Street-zoom coverage reaches this far from the camera: camera height
|
||||
* times the factor, never below the floor, even when a tilted view sees
|
||||
* further.
|
||||
*/
|
||||
viewRangeMinM: 2_500,
|
||||
viewRangePerHeight: 10,
|
||||
});
|
||||
|
||||
/** Per-sequence image cones after a sequence is selected. */
|
||||
export const SEQUENCE_IMAGES_LIMIT = 2000;
|
||||
|
||||
/** Images asked for in a nearest-image search (radius: NEAREST_RADIUS_M). */
|
||||
export const NEAREST_LIMIT = 8;
|
||||
|
||||
/** On-globe credit: Mapillary imagery is CC BY-SA 4.0 and needs attribution. */
|
||||
export const MAPILLARY_CREDIT_HTML =
|
||||
'Street Level: imagery © <a href="https://www.mapillary.com" target="_blank" rel="noopener">Mapillary</a> contributors, CC BY-SA 4.0';
|
||||
@@ -76,8 +69,6 @@ export function mapillaryImageUrl(imageId) {
|
||||
return `https://www.mapillary.com/app/?pKey=${encodeURIComponent(id)}&focus=photo`;
|
||||
}
|
||||
|
||||
/** Fields requested from the graph API. */
|
||||
export const IMAGE_FIELDS =
|
||||
'id,captured_at,compass_angle,computed_compass_angle,geometry,computed_geometry,computed_altitude,is_pano,sequence,thumb_256_url,creator,quality_score';
|
||||
/** Fields requested for a sequence's images. */
|
||||
export const SEQUENCE_IMAGE_FIELDS =
|
||||
'id,captured_at,compass_angle,geometry,is_pano,computed_altitude';
|
||||
'id,captured_at,compass_angle,geometry,is_pano';
|
||||
@@ -1,44 +1,30 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createMapillaryProvider } from './index.js';
|
||||
import { mapillaryImageUrl } from './policy.js';
|
||||
import { sequenceIdFromPick } from './coverage.js';
|
||||
import { thinImages } from './sequences.js';
|
||||
import { sequenceIdFromPick } from '../../lineCoverage.js';
|
||||
import { thinImages } from '../../sequenceCones.js';
|
||||
import { validateProviders } from '../../registry.js';
|
||||
import { PROVIDER_COLORS } from '../../policy.js';
|
||||
import { resolveFilter } from '../../filter.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { rayCamera } from '../../../../testSupport/streetLevelFakes.mjs';
|
||||
|
||||
function fakeSource({ nearest = [] } = {}) {
|
||||
return {
|
||||
hasToken: () => true,
|
||||
getStatus: async () => ({ configured: true }),
|
||||
getTile: async () => new Uint8Array(0),
|
||||
getSequenceImages: async () => [],
|
||||
nearestImages: async () => nearest,
|
||||
};
|
||||
}
|
||||
import {
|
||||
fakeMapillarySource,
|
||||
rayCamera,
|
||||
} from '../../../../testSupport/streetLevelFakes.mjs';
|
||||
|
||||
function fakeContext(filter = { pano: 'all', sinceMs: null }) {
|
||||
const opened = [];
|
||||
return {
|
||||
opened,
|
||||
services: {},
|
||||
getFilter: typeof filter === 'function' ? filter : () => filter,
|
||||
getFilter: () => filter,
|
||||
isActive: () => true,
|
||||
notify: () => {},
|
||||
actions: {
|
||||
openImage: (id) => {
|
||||
opened.push(id);
|
||||
},
|
||||
reportError: () => {},
|
||||
},
|
||||
actions: { openImage: (id) => opened.push(id) },
|
||||
};
|
||||
}
|
||||
|
||||
test('the definition satisfies the Street Level provider contract', () => {
|
||||
const def = createMapillaryProvider({ source: fakeSource() });
|
||||
const def = createMapillaryProvider({ source: fakeMapillarySource() });
|
||||
assert.doesNotThrow(() => validateProviders([def]));
|
||||
assert.equal(def.id, 'mapillary');
|
||||
assert.equal(def.label, 'MAPILLARY');
|
||||
@@ -46,9 +32,7 @@ test('the definition satisfies the Street Level provider contract', () => {
|
||||
assert.equal(def.pickPrefix, 'mly:');
|
||||
assert.match(def.credit.html, /CC BY-SA 4\.0/);
|
||||
// One colour per source: Mapillary is green everywhere it draws.
|
||||
assert.deepEqual(Object.keys(def.colors).sort(), ['coverage', 'selected']);
|
||||
assert.equal(def.colors.coverage, PROVIDER_COLORS.mapillary);
|
||||
assert.equal(PROVIDER_COLORS.mapillary, '#05cb63');
|
||||
assert.equal(def.colors.coverage, '#05cb63');
|
||||
assert.throws(() => createMapillaryProvider({ source: {} }), /source/);
|
||||
});
|
||||
|
||||
@@ -62,9 +46,9 @@ test('image deep links match the mapillary.com share format', () => {
|
||||
|
||||
test('picks route sequences to selection and images to the core opener', () => {
|
||||
const context = fakeContext();
|
||||
const instance = createMapillaryProvider({ source: fakeSource() }).create(
|
||||
context,
|
||||
);
|
||||
const instance = createMapillaryProvider({
|
||||
source: fakeMapillarySource(),
|
||||
}).create(context);
|
||||
assert.equal(instance.handlePick('mly:img:77'), true);
|
||||
assert.deepEqual(context.opened, ['77']);
|
||||
assert.equal(instance.handlePick('cctv:1'), false);
|
||||
@@ -72,99 +56,14 @@ test('picks route sequences to selection and images to the core opener', () => {
|
||||
selectedId: null,
|
||||
images: 0,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('nearestImage honours the imagery filter and reports key status', async () => {
|
||||
const nearest = [
|
||||
{ id: 1, is_pano: false, captured_at: 10 },
|
||||
{ id: 2, is_pano: true, captured_at: 20 },
|
||||
];
|
||||
const source = fakeSource({ nearest });
|
||||
const flat = createMapillaryProvider({ source }).create(fakeContext());
|
||||
assert.equal(await flat.nearestImage({ lat: 1, lon: 2 }), '1');
|
||||
const pano = createMapillaryProvider({ source }).create(
|
||||
fakeContext({ pano: 'pano', sinceMs: null }),
|
||||
);
|
||||
assert.equal(await pano.nearestImage({ lat: 1, lon: 2 }), '2');
|
||||
const none = createMapillaryProvider({ source }).create(
|
||||
fakeContext({ pano: 'all', sinceMs: 100 }),
|
||||
);
|
||||
assert.equal(await none.nearestImage({ lat: 1, lon: 2 }), null);
|
||||
assert.deepEqual(await flat.status(), { configured: true });
|
||||
assert.equal(flat.coverageStats().keyRequired, false);
|
||||
});
|
||||
|
||||
test('any part of a multi-part sequence picks the whole sequence', () => {
|
||||
assert.equal(sequenceIdFromPick('mly:seq:abc'), 'abc');
|
||||
assert.equal(sequenceIdFromPick('mly:seq:abc~2'), 'abc');
|
||||
assert.equal(sequenceIdFromPick('mly:img:abc'), null);
|
||||
assert.equal(sequenceIdFromPick(null), null);
|
||||
});
|
||||
|
||||
test('nearestImage picks the closest image, not the first the API returned', async () => {
|
||||
const at = (id, lon, lat) => ({
|
||||
id,
|
||||
is_pano: false,
|
||||
captured_at: 10,
|
||||
geometry: { type: 'Point', coordinates: [lon, lat] },
|
||||
});
|
||||
const source = fakeSource({
|
||||
nearest: [
|
||||
at('far', 2.0004, 1), // ~45 m east
|
||||
at('near', 2.00005, 1), // ~5 m east
|
||||
at('mid', 2, 1.0002), // ~22 m north
|
||||
],
|
||||
});
|
||||
const instance = createMapillaryProvider({ source }).create(fakeContext());
|
||||
assert.equal(await instance.nearestImage({ lat: 1, lon: 2 }), 'near');
|
||||
});
|
||||
|
||||
test('a "since N days" window follows the clock in a long-open tab', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['Date'], now: Date.UTC(2026, 9, 1) });
|
||||
const day = 86_400_000;
|
||||
const capturedAt = Date.now() - day / 2;
|
||||
const source = fakeSource({
|
||||
nearest: [{ id: 'recent', is_pano: false, captured_at: capturedAt }],
|
||||
});
|
||||
// The core's context resolves the stored filter whenever it is asked.
|
||||
const instance = createMapillaryProvider({ source }).create(
|
||||
fakeContext(() => resolveFilter({ pano: 'all', sinceDays: 1 })),
|
||||
);
|
||||
assert.equal(await instance.nearestImage({ lat: 1, lon: 2 }), 'recent');
|
||||
t.mock.timers.tick(2 * day);
|
||||
assert.equal(
|
||||
await instance.nearestImage({ lat: 1, lon: 2 }),
|
||||
null,
|
||||
'two days on, the image is older than the window',
|
||||
);
|
||||
});
|
||||
|
||||
test('nearestImage measures the short way round the date line', async () => {
|
||||
const at = (id, lon, lat) => ({
|
||||
id,
|
||||
is_pano: false,
|
||||
captured_at: 10,
|
||||
geometry: { type: 'Point', coordinates: [lon, lat] },
|
||||
});
|
||||
const source = fakeSource({
|
||||
nearest: [
|
||||
at('same-side', 179.9995, 0), // ~45 m west, on the camera's side
|
||||
at('across', -179.9999, 0), // ~11 m east, across ±180°
|
||||
],
|
||||
});
|
||||
const instance = createMapillaryProvider({ source }).create(fakeContext());
|
||||
assert.equal(
|
||||
await instance.nearestImage({ lat: 0, lon: 179.9999 }),
|
||||
'across',
|
||||
);
|
||||
// And from the other side.
|
||||
const west = createMapillaryProvider({
|
||||
source: fakeSource({
|
||||
nearest: [at('same-side', -179.9995, 0), at('across', 179.9999, 0)],
|
||||
}),
|
||||
}).create(fakeContext());
|
||||
assert.equal(await west.nearestImage({ lat: 0, lon: -179.9999 }), 'across');
|
||||
test('a line pick names its sequence; anything else is not a sequence', () => {
|
||||
assert.equal(sequenceIdFromPick('mly:seq:abc', 'mly:seq:'), 'abc');
|
||||
assert.equal(sequenceIdFromPick('mly:img:abc', 'mly:seq:'), null);
|
||||
assert.equal(sequenceIdFromPick(null, 'mly:seq:'), null);
|
||||
});
|
||||
|
||||
test('cone thinning across the date line drops images metres apart', () => {
|
||||
@@ -220,19 +119,24 @@ function streetViewer() {
|
||||
};
|
||||
}
|
||||
|
||||
test('coverage asks for no tiles before the key status is known', async () => {
|
||||
test('coverage asks for no tiles before the key status is known, and none without a key', async (t) => {
|
||||
// Skip Cesium's one-time terrain table download for draped lines.
|
||||
t.mock.method(
|
||||
Cesium.GroundPolylinePrimitive,
|
||||
'initializeTerrainHeights',
|
||||
async () => {},
|
||||
);
|
||||
for (const configured of [false, true]) {
|
||||
let answer;
|
||||
const tiles = [];
|
||||
const source = {
|
||||
...fakeSource(),
|
||||
const source = fakeMapillarySource({
|
||||
hasToken: () => configured,
|
||||
getStatus: () => new Promise((resolve) => (answer = resolve)),
|
||||
getTile: (...args) => {
|
||||
tiles.push(args);
|
||||
return new Promise(() => {});
|
||||
},
|
||||
};
|
||||
});
|
||||
const instance = createMapillaryProvider({ source }).create(fakeContext());
|
||||
const viewer = streetViewer();
|
||||
instance.init(viewer);
|
||||
@@ -240,7 +144,7 @@ test('coverage asks for no tiles before the key status is known', async () => {
|
||||
instance.activate(viewer);
|
||||
assert.equal(tiles.length, 0, 'nothing before the status answers');
|
||||
answer({ configured });
|
||||
await status;
|
||||
assert.deepEqual(await status, { configured });
|
||||
if (configured)
|
||||
assert.ok(tiles.length > 0, 'coverage loads as soon as the key is known');
|
||||
else {
|
||||
@@ -250,3 +154,19 @@ test('coverage asks for no tiles before the key status is known', async () => {
|
||||
instance.destroy(viewer);
|
||||
}
|
||||
});
|
||||
|
||||
test('a status the server never answers falls back to the bundled token once the core gives up', async () => {
|
||||
const source = fakeMapillarySource({
|
||||
hasToken: () => false,
|
||||
getStatus: ({ signal } = {}) =>
|
||||
new Promise((_, reject) =>
|
||||
signal?.addEventListener('abort', () => reject(signal.reason)),
|
||||
),
|
||||
});
|
||||
const instance = createMapillaryProvider({ source }).create(fakeContext());
|
||||
const controller = new AbortController();
|
||||
const status = instance.status({ signal: controller.signal });
|
||||
controller.abort(new Error('timed out'));
|
||||
assert.deepEqual(await status, { configured: false });
|
||||
assert.equal(instance.coverageStats().keyRequired, true);
|
||||
});
|
||||
@@ -1,43 +0,0 @@
|
||||
import { createSequenceCones, thinImages } from '../../sequenceCones.js';
|
||||
import { COLORS, PICK_PREFIX } from './policy.js';
|
||||
|
||||
export { thinImages } from '../../sequenceCones.js';
|
||||
|
||||
/** Normalize a graph image record into the shape the cones use. */
|
||||
export function normalizeSequenceImage(record) {
|
||||
const coordinates = record?.geometry?.coordinates;
|
||||
if (!Array.isArray(coordinates) || coordinates.length < 2) return null;
|
||||
return {
|
||||
id: String(record.id),
|
||||
lon: Number(coordinates[0]),
|
||||
lat: Number(coordinates[1]),
|
||||
compassAngle: Number(record.compass_angle) || 0,
|
||||
capturedAt: Number(record.captured_at) || 0,
|
||||
isPano: record.is_pano === true,
|
||||
altitude: Number.isFinite(record.computed_altitude)
|
||||
? record.computed_altitude
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Image cones for the selected Mapillary sequence, its line highlighted. */
|
||||
export function createSequences({ state, source, parts }) {
|
||||
return createSequenceCones({
|
||||
state,
|
||||
async loadImages(sequenceId, { signal }) {
|
||||
const records = await source.getSequenceImages(sequenceId, { signal });
|
||||
return thinImages(
|
||||
records
|
||||
.map(normalizeSequenceImage)
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => a.capturedAt - b.capturedAt),
|
||||
);
|
||||
},
|
||||
colors: COLORS,
|
||||
pickPrefix: PICK_PREFIX.image,
|
||||
spriteId: 'street-level:mapillary-cones',
|
||||
renderKey: 'mapillary-sequence',
|
||||
onHighlight: (sequenceId, selected) =>
|
||||
parts.coverage.recolorSequence(sequenceId, selected),
|
||||
});
|
||||
}
|
||||
@@ -1,325 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import * as Cesium from 'cesium';
|
||||
import { MESH_CELL_DEG } from '../../meshSampler.js';
|
||||
import { createSequences } from './sequences.js';
|
||||
|
||||
// The cone glyphs draw on a canvas; Node has none, so any drawing is a no-op.
|
||||
const noop = () => {};
|
||||
const context2d = new Proxy({}, { get: () => noop, set: () => true });
|
||||
globalThis.document ??= {
|
||||
createElement: () => ({ getContext: () => context2d }),
|
||||
};
|
||||
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
/** A billboard collection that remembers the cones on the globe. */
|
||||
function cones() {
|
||||
const items = [];
|
||||
return {
|
||||
items,
|
||||
show: true,
|
||||
add(options) {
|
||||
items.push(options);
|
||||
return options;
|
||||
},
|
||||
removeAll() {
|
||||
items.length = 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A source whose sequence lookups the test answers, one call at a time. */
|
||||
function deferredSource() {
|
||||
const calls = [];
|
||||
return {
|
||||
calls,
|
||||
getSequenceImages(sequenceId, { signal } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
calls.push({ sequenceId, signal, resolve, reject });
|
||||
});
|
||||
},
|
||||
/** The latest lookup for a sequence. */
|
||||
last(sequenceId) {
|
||||
return calls.filter((call) => call.sequenceId === sequenceId).at(-1);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Graph image records for a sequence, a few metres apart. */
|
||||
function records(sequenceId, count = 2) {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
id: `${sequenceId}-${i}`,
|
||||
geometry: { coordinates: [-121.49 + i * 0.001, 38.58] },
|
||||
compass_angle: 90,
|
||||
captured_at: 1_700_000_000_000 + i,
|
||||
}));
|
||||
}
|
||||
|
||||
function setup() {
|
||||
const source = deferredSource();
|
||||
const colours = [];
|
||||
const errors = [];
|
||||
const state = {
|
||||
services: {},
|
||||
viewer: {},
|
||||
context: {
|
||||
notify() {},
|
||||
getFilter: () => ({ pano: 'all', sinceMs: null }),
|
||||
getSurface: () => 'draped',
|
||||
groundCaster: null,
|
||||
meshSampler: null,
|
||||
actions: { reportError: (message) => errors.push(message) },
|
||||
},
|
||||
sequence: {
|
||||
selectedId: null,
|
||||
images: [],
|
||||
cache: new Map(),
|
||||
collection: cones(),
|
||||
loading: false,
|
||||
abort: null,
|
||||
},
|
||||
};
|
||||
const parts = {
|
||||
coverage: {
|
||||
recolorSequence: (id, selected) => colours.push([id, selected]),
|
||||
},
|
||||
};
|
||||
const sequences = createSequences({ state, source, parts });
|
||||
const drawn = () =>
|
||||
state.sequence.collection.items.map((cone) => cone.id.split(':').at(-1));
|
||||
return { state, source, sequences, colours, errors, drawn };
|
||||
}
|
||||
|
||||
test('a failed sequence load leaves no stale cones and can be retried', async () => {
|
||||
const { state, source, sequences, colours, errors, drawn } = setup();
|
||||
sequences.select('A');
|
||||
source.last('A').resolve(records('A'));
|
||||
await settle();
|
||||
assert.deepEqual(drawn(), ['A-0', 'A-1']);
|
||||
|
||||
sequences.select('B');
|
||||
assert.deepEqual(drawn(), [], "A's cones go while B loads");
|
||||
source.last('B').reject(new Error('Sequence images unavailable'));
|
||||
await settle();
|
||||
assert.deepEqual(drawn(), [], 'nothing of A is left to click');
|
||||
assert.equal(state.sequence.loading, false);
|
||||
assert.equal(state.sequence.selectedId, null, 'B is not left highlighted');
|
||||
assert.deepEqual(colours.at(-1), ['B', false]);
|
||||
assert.deepEqual(errors, [null, 'Sequence images unavailable']);
|
||||
|
||||
// Clicking B again asks again.
|
||||
sequences.select('B');
|
||||
assert.equal(source.calls.filter((c) => c.sequenceId === 'B').length, 2);
|
||||
source.last('B').resolve(records('B'));
|
||||
await settle();
|
||||
assert.deepEqual(drawn(), ['B-0', 'B-1']);
|
||||
assert.equal(state.sequence.selectedId, 'B');
|
||||
assert.equal(errors.at(-1), null, 'the error is cleared once B loads');
|
||||
});
|
||||
|
||||
test('a late answer for a superseded sequence neither draws nor is cached', async () => {
|
||||
const { state, source, sequences, drawn } = setup();
|
||||
sequences.select('A');
|
||||
sequences.select('B');
|
||||
assert.equal(source.last('A').signal.aborted, true, 'A was cancelled');
|
||||
source.last('B').resolve(records('B'));
|
||||
await settle();
|
||||
source.last('A').resolve(records('A'));
|
||||
await settle();
|
||||
assert.deepEqual(drawn(), ['B-0', 'B-1']);
|
||||
assert.equal(state.sequence.selectedId, 'B');
|
||||
assert.equal(state.sequence.cache.has('A'), false);
|
||||
assert.equal(state.sequence.loading, false);
|
||||
});
|
||||
|
||||
test('a second click on a loading sequence does not start another lookup', async () => {
|
||||
const { source, sequences } = setup();
|
||||
sequences.select('A');
|
||||
sequences.select('A');
|
||||
assert.equal(source.calls.length, 1);
|
||||
assert.equal(source.calls[0].signal.aborted, false);
|
||||
});
|
||||
|
||||
test('a cached sequence is re-selected without a lookup', async () => {
|
||||
const { state, source, sequences, drawn } = setup();
|
||||
sequences.select('A');
|
||||
source.last('A').resolve(records('A'));
|
||||
await settle();
|
||||
sequences.select('B');
|
||||
source.last('B').resolve(records('B'));
|
||||
await settle();
|
||||
sequences.select('A');
|
||||
assert.equal(source.calls.length, 2, 'A came from the cache');
|
||||
assert.deepEqual(drawn(), ['A-0', 'A-1'], 'drawn at once');
|
||||
assert.equal(state.sequence.loading, false);
|
||||
assert.equal(state.sequence.selectedId, 'A');
|
||||
});
|
||||
|
||||
test('clearSelection cancels the lookup and uncolours the sequence', async () => {
|
||||
const { state, source, sequences, colours, errors, drawn } = setup();
|
||||
sequences.select('A');
|
||||
sequences.clearSelection();
|
||||
assert.equal(source.last('A').signal.aborted, true);
|
||||
assert.deepEqual(colours, [
|
||||
['A', true],
|
||||
['A', false],
|
||||
]);
|
||||
assert.equal(state.sequence.selectedId, null);
|
||||
assert.equal(state.sequence.loading, false);
|
||||
assert.equal(errors.at(-1), null, 'an error about it goes too');
|
||||
source.last('A').resolve(records('A'));
|
||||
await settle();
|
||||
assert.deepEqual(drawn(), [], 'the late answer draws nothing');
|
||||
});
|
||||
|
||||
/**
|
||||
* Terrain mode with bare earth at 10 m, a mesh sampler the test drives, and
|
||||
* cones that log every change made to them.
|
||||
*/
|
||||
function terrainSetup() {
|
||||
const context = setup();
|
||||
const { state } = context;
|
||||
const mesh = new Map();
|
||||
const cell = (lon, lat) =>
|
||||
`${Math.round(lon / MESH_CELL_DEG)},${Math.round(lat / MESH_CELL_DEG)}`;
|
||||
let announce = null;
|
||||
state.context.getSurface = () => 'terrain';
|
||||
state.context.groundCaster = {
|
||||
groundAt: () => 10,
|
||||
prepare: async () => true,
|
||||
};
|
||||
state.context.meshSampler = {
|
||||
request() {},
|
||||
meshAt: (lon, lat) => mesh.get(cell(lon, lat)),
|
||||
onSampled: (listener) => {
|
||||
announce = listener;
|
||||
return () => (announce = null);
|
||||
},
|
||||
};
|
||||
const changes = [];
|
||||
const items = [];
|
||||
state.sequence.collection = {
|
||||
items,
|
||||
show: true,
|
||||
add(options) {
|
||||
changes.push(['add', options.id]);
|
||||
const cone = new Proxy(options, {
|
||||
set(target, key, value) {
|
||||
changes.push([key, target.id]);
|
||||
target[key] = value;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
items.push(cone);
|
||||
return cone;
|
||||
},
|
||||
removeAll() {
|
||||
changes.push(['removeAll']);
|
||||
items.length = 0;
|
||||
},
|
||||
};
|
||||
// Created again, so it listens to this sampler.
|
||||
const sequences = createSequences({
|
||||
state,
|
||||
source: context.source,
|
||||
parts: { coverage: { recolorSequence() {} } },
|
||||
});
|
||||
/** Sample the mesh under a point, then announce it as the sampler does. */
|
||||
function sample(lon, lat, height) {
|
||||
const centre = [
|
||||
Math.round(lon / MESH_CELL_DEG) * MESH_CELL_DEG,
|
||||
Math.round(lat / MESH_CELL_DEG) * MESH_CELL_DEG,
|
||||
];
|
||||
mesh.set(cell(lon, lat), height);
|
||||
announce([centre]);
|
||||
}
|
||||
const heightOf = (cone) =>
|
||||
Cesium.Cartographic.fromCartesian(cone.position).height;
|
||||
return { ...context, sequences, changes, items, sample, heightOf };
|
||||
}
|
||||
|
||||
test('a mesh sample away from the selected sequence changes no cone', async () => {
|
||||
const { source, sequences, changes, items, sample } = terrainSetup();
|
||||
sequences.select('A');
|
||||
source.last('A').resolve(records('A', 3));
|
||||
await settle();
|
||||
assert.equal(items.length, 3);
|
||||
changes.length = 0;
|
||||
sample(-121.3, 38.7, 12); // a street elsewhere in the tile
|
||||
assert.deepEqual(changes, [], 'nothing rebuilt or moved');
|
||||
});
|
||||
|
||||
test('a mesh sample under a cone moves that cone in place, and no other', async () => {
|
||||
const { source, sequences, changes, items, sample, heightOf } =
|
||||
terrainSetup();
|
||||
sequences.select('A');
|
||||
source.last('A').resolve(records('A', 3));
|
||||
await settle();
|
||||
const before = items.map(heightOf);
|
||||
changes.length = 0;
|
||||
// The road under the second image is 2.5 m above the bare earth.
|
||||
sample(-121.489, 38.58, 12.5);
|
||||
assert.ok(
|
||||
changes.every(([, id]) => id === 'mly:img:A-1'),
|
||||
`only A-1 changed: ${JSON.stringify(changes)}`,
|
||||
);
|
||||
assert.ok(
|
||||
changes.some(([key]) => key === 'position'),
|
||||
'A-1 moved',
|
||||
);
|
||||
assert.equal(
|
||||
changes.some(([key]) => key === 'removeAll' || key === 'add'),
|
||||
false,
|
||||
'the collection is not rebuilt',
|
||||
);
|
||||
const after = items.map(heightOf);
|
||||
assert.ok(
|
||||
Math.abs(after[1] - 13.5) < 0.01,
|
||||
`A-1 stands on the road (${after[1]})`,
|
||||
);
|
||||
assert.deepEqual([after[0], after[2]], [before[0], before[2]]);
|
||||
});
|
||||
|
||||
test('cones behind the globe are hidden, and show again from the other side', async () => {
|
||||
const { state, source, sequences } = setup();
|
||||
const view = { lon: -121.49, lat: 38.58, height: 20_000_000 };
|
||||
const preRender = new Set();
|
||||
state.viewer = {
|
||||
camera: {
|
||||
get positionWC() {
|
||||
return Cesium.Cartesian3.fromDegrees(view.lon, view.lat, view.height);
|
||||
},
|
||||
},
|
||||
scene: {
|
||||
preRender: {
|
||||
addEventListener(listener) {
|
||||
preRender.add(listener);
|
||||
return () => preRender.delete(listener);
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
sequences.select('A');
|
||||
// One image under the camera, one near its antipode.
|
||||
source.last('A').resolve([
|
||||
...records('A', 1),
|
||||
{
|
||||
id: 'A-far',
|
||||
geometry: { coordinates: [58.51, -38.58] },
|
||||
captured_at: 1_800_000_000_000,
|
||||
},
|
||||
]);
|
||||
await settle();
|
||||
const [near, far] = state.sequence.collection.items;
|
||||
assert.equal(near.show, true, 'the near side stays visible');
|
||||
assert.equal(far.show, false, 'the far side does not show through');
|
||||
// Fly round to the other hemisphere: the two swap.
|
||||
view.lon = 58.51;
|
||||
view.lat = -38.58;
|
||||
for (const listener of [...preRender]) listener();
|
||||
assert.equal(near.show, false);
|
||||
assert.equal(far.show, true);
|
||||
sequences.clearSelection();
|
||||
assert.equal(preRender.size, 0, 'the cull listener is gone');
|
||||
});
|
||||
@@ -1,8 +1,5 @@
|
||||
import {
|
||||
IMAGE_FIELDS,
|
||||
MAPILLARY_GRAPH_HOST,
|
||||
NEAREST_LIMIT,
|
||||
NEAREST_RADIUS_M,
|
||||
SEQUENCE_IMAGE_FIELDS,
|
||||
SEQUENCE_IMAGES_LIMIT,
|
||||
} from './policy.js';
|
||||
@@ -126,23 +123,5 @@ export function createMapillarySource({
|
||||
);
|
||||
return Array.isArray(payload?.data) ? payload.data : [];
|
||||
},
|
||||
|
||||
async nearestImages(
|
||||
{ lat, lon, radius = NEAREST_RADIUS_M, limit = NEAREST_LIMIT },
|
||||
{ signal } = {},
|
||||
) {
|
||||
const payload = await graph(
|
||||
'images',
|
||||
{
|
||||
lat: String(lat),
|
||||
lng: String(lon),
|
||||
radius: String(Math.min(50, Math.max(1, radius))),
|
||||
limit: String(Math.min(100, Math.max(1, limit))),
|
||||
fields: IMAGE_FIELDS,
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
return Array.isArray(payload?.data) ? payload.data : [];
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -62,21 +62,6 @@ test('graph lookups call graph.mapillary.com with the client token and requested
|
||||
assert.equal(url.searchParams.get('access_token'), 'MLY|1|abc');
|
||||
assert.equal(url.searchParams.get('sequence_ids'), 'seq-1');
|
||||
assert.match(url.searchParams.get('fields'), /geometry/);
|
||||
|
||||
const nearest = await source.nearestImages({
|
||||
lat: 38.5,
|
||||
lon: -121.5,
|
||||
radius: 500,
|
||||
limit: 500,
|
||||
});
|
||||
assert.equal(nearest.length, 1);
|
||||
const nearestUrl = new URL(calls[1].url);
|
||||
assert.equal(
|
||||
nearestUrl.searchParams.get('radius'),
|
||||
'50',
|
||||
'radius is clamped to the API maximum',
|
||||
);
|
||||
assert.equal(nearestUrl.searchParams.get('limit'), '100');
|
||||
});
|
||||
|
||||
test('graph calls without a token fail fast as keyRequired without a request', async () => {
|
||||
@@ -104,12 +89,6 @@ test('graph API errors carry the upstream message and status', async () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('the source exposes only imagery lookups', () => {
|
||||
const source = createMapillarySource({ token: 'MLY|1|abc' });
|
||||
for (const gone of ['queryFeatures', 'plan', 'geocode', 'spriteUrl'])
|
||||
assert.equal(source[gone], undefined, gone);
|
||||
});
|
||||
|
||||
test('a rejected key and a rate limit are told apart from other tile errors', async () => {
|
||||
const rejectedSource = createMapillarySource({
|
||||
token: 'MLY|1|abc',
|
||||
|
||||
@@ -1,24 +1,36 @@
|
||||
import { MAPILLARY_PROVIDER_ID, mapillaryImageUrl } from './policy.js';
|
||||
import { mapillaryImageUrl } from './policy.js';
|
||||
|
||||
/**
|
||||
* Viewer adapter: lazy-loads MapillaryJS into the core's host and emits a
|
||||
* provider-neutral pose on every image, view or position change.
|
||||
* @returns {import('../../registry.js').ViewerAdapter}
|
||||
* @typedef {object} StreetPose
|
||||
* @property {string} imageId
|
||||
* @property {{lon: number, lat: number}} position
|
||||
* @property {number|null} bearing
|
||||
* @property {number|null} altitude
|
||||
* @property {boolean} isPano
|
||||
* @property {number|null} capturedAt Epoch milliseconds.
|
||||
* @property {string|null} creator
|
||||
* @property {string|null} sequenceId
|
||||
* @property {string} externalUrl Deep link to the image on mapillary.com.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The panel's photo viewer: loads MapillaryJS on the first `mount` and emits
|
||||
* a pose on every image, view or position change.
|
||||
*/
|
||||
export function createMapillaryViewer({ source, render } = {}) {
|
||||
let viewer = null;
|
||||
let Library = null;
|
||||
let container = null;
|
||||
/** The viewer being built for a host: a second mount joins it, not a twin. */
|
||||
let building = null;
|
||||
let pendingOpen = null;
|
||||
let prewarming = null;
|
||||
/** In-flight viewer construction, so pre-warm and open never build two. */
|
||||
let creating = null;
|
||||
/** Bumped by `unmount`, so a construction it overtook builds nothing. */
|
||||
let generation = 0;
|
||||
let renderMode = 'letterbox';
|
||||
/** Metadata of the image on screen; pov/position events reuse it. */
|
||||
let current = null;
|
||||
const listeners = new Set();
|
||||
/** The pose listener (the viewer host); one at a time. */
|
||||
let poseListener = null;
|
||||
|
||||
function requestRender() {
|
||||
render?.governorRequestRender?.('mapillary-viewer');
|
||||
@@ -43,16 +55,14 @@ export function createMapillaryViewer({ source, render } = {}) {
|
||||
}
|
||||
|
||||
function emit(pose) {
|
||||
for (const listener of [...listeners]) {
|
||||
try {
|
||||
listener(pose);
|
||||
} catch {
|
||||
/* listener errors are the core's to log */
|
||||
}
|
||||
try {
|
||||
poseListener?.(pose);
|
||||
} catch {
|
||||
/* listener errors are the core's to log */
|
||||
}
|
||||
}
|
||||
|
||||
/** Read MapillaryJS image metadata into the neutral pose shape. */
|
||||
/** Read MapillaryJS image metadata into the pose shape. */
|
||||
function describe(image) {
|
||||
return {
|
||||
imageId: String(image.id),
|
||||
@@ -81,11 +91,9 @@ export function createMapillaryViewer({ source, render } = {}) {
|
||||
if (image) current = describe(image);
|
||||
if (!current || !viewer) return;
|
||||
emit({
|
||||
providerId: MAPILLARY_PROVIDER_ID,
|
||||
...current,
|
||||
position: { lon: lngLat.lng, lat: lngLat.lat },
|
||||
bearing: Number.isFinite(pov?.bearing) ? pov.bearing : null,
|
||||
tilt: Number.isFinite(pov?.tilt) ? pov.tilt : 0,
|
||||
externalUrl: mapillaryImageUrl(current.imageId),
|
||||
});
|
||||
requestRender();
|
||||
@@ -95,16 +103,16 @@ export function createMapillaryViewer({ source, render } = {}) {
|
||||
}
|
||||
|
||||
function ensureViewer(host) {
|
||||
if (viewer && container === host) return viewer;
|
||||
if (creating?.container === host) return creating.promise;
|
||||
const promise = createViewer(host).finally(() => {
|
||||
if (creating?.promise === promise) creating = null;
|
||||
if (viewer && container === host) return Promise.resolve(viewer);
|
||||
if (building?.host === host) return building.promise;
|
||||
const promise = buildViewer(host).finally(() => {
|
||||
if (building?.promise === promise) building = null;
|
||||
});
|
||||
creating = { container: host, promise };
|
||||
building = { host, promise };
|
||||
return promise;
|
||||
}
|
||||
|
||||
async function createViewer(host) {
|
||||
async function buildViewer(host) {
|
||||
const built = generation;
|
||||
destroyViewer();
|
||||
const { Viewer } = await ensureLibrary();
|
||||
@@ -178,9 +186,9 @@ export function createMapillaryViewer({ source, render } = {}) {
|
||||
|
||||
unmount() {
|
||||
pendingOpen = null;
|
||||
// The next mount builds afresh rather than joining a stale build.
|
||||
// A build still waiting for the library gives up when it arrives.
|
||||
generation++;
|
||||
creating = null;
|
||||
building = null;
|
||||
destroyViewer();
|
||||
},
|
||||
|
||||
@@ -192,26 +200,6 @@ export function createMapillaryViewer({ source, render } = {}) {
|
||||
}
|
||||
},
|
||||
|
||||
/** Build the viewer ahead of the first image. Safe to call repeatedly. */
|
||||
async prewarm(host) {
|
||||
if (!host) return;
|
||||
// An in-flight prewarm may give up (layer toggled mid-download): wait
|
||||
// for it, then build if it did not.
|
||||
if (prewarming) await prewarming;
|
||||
if (viewer) return;
|
||||
const run = (async () => {
|
||||
try {
|
||||
await ensureLibrary();
|
||||
if (!viewer) await ensureViewer(host);
|
||||
} catch {
|
||||
/* the real open reports errors */
|
||||
}
|
||||
})();
|
||||
prewarming = run;
|
||||
await run;
|
||||
if (prewarming === run) prewarming = null;
|
||||
},
|
||||
|
||||
/** Show the whole image ('letterbox') or crop it to the frame ('fill'). */
|
||||
setRenderMode(mode) {
|
||||
renderMode = mode === 'fill' ? 'fill' : 'letterbox';
|
||||
@@ -224,8 +212,10 @@ export function createMapillaryViewer({ source, render } = {}) {
|
||||
},
|
||||
|
||||
onPose(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
poseListener = listener;
|
||||
return () => {
|
||||
if (poseListener === listener) poseListener = null;
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import { VectorTile } from '@mapbox/vector-tile';
|
||||
import { tileLocalToLonLat } from '../../tileMath.js';
|
||||
import {
|
||||
DECODE_SLICE_MS,
|
||||
OVERVIEW_BINS_PER_AXIS,
|
||||
SIMPLIFY_TOLERANCE_UNITS,
|
||||
panoramaxTimeMs,
|
||||
} from './policy.js';
|
||||
@@ -63,16 +62,14 @@ function dayMs(value) {
|
||||
* - `sequences` (z7–15): one line per sequence, its parts kept apart so no
|
||||
* segment is drawn across a gap, with its day and camera type; longest
|
||||
* first, so a cap on lines drawn drops the shortest.
|
||||
* - `grid` (z0–6): picture-density cells, as overview points binned to at
|
||||
* most OVERVIEW_BINS_PER_AXIS² a tile (a z0 tile holds ~70,000 cells). A
|
||||
* cell has 360° and flat counts but no date.
|
||||
* - `grid` (z0–6, density cells) is not read: nothing is drawn from orbit.
|
||||
* - `pictures` (z15) is left for `decodePictures`.
|
||||
*
|
||||
* In one go; `decodeCoverageTileSliced` gives the page a turn between
|
||||
* slices.
|
||||
* @param {Uint8Array} bytes
|
||||
* @param {{x: number, y: number, z: number}} address
|
||||
* @returns {{sequences: Array<{id: string, parts: Array<Array<[number, number]>>, capturedAt: number, isPano: boolean}>, overview: Array<{id: string, lon: number, lat: number, capturedAt: null, isPano: boolean, hasPano: boolean, hasFlat: boolean, weight: number}>}}
|
||||
* @returns {{sequences: Array<{id: string, parts: Array<Array<[number, number]>>, capturedAt: number, isPano: boolean}>}}
|
||||
*/
|
||||
export function decodeCoverageTile(bytes, address) {
|
||||
const steps = coverageSteps(bytes, address);
|
||||
@@ -123,7 +120,7 @@ export async function decodeCoverageTileSliced(
|
||||
|
||||
/** The decode, a step a feature (see `decodeCoverageTile`). */
|
||||
function* coverageSteps(bytes, address) {
|
||||
const result = { sequences: [], overview: [] };
|
||||
const result = { sequences: [] };
|
||||
if (!bytes || !bytes.length) return result;
|
||||
const tile = new VectorTile(new PbfReader(bytes));
|
||||
const { x, y, z } = address;
|
||||
@@ -174,56 +171,6 @@ function* coverageSteps(bytes, address) {
|
||||
result.sequences = measured.map(({ sequence }) => sequence);
|
||||
}
|
||||
|
||||
const grid = tile.layers.grid;
|
||||
if (grid) {
|
||||
const bins = new Map();
|
||||
const size = grid.extent / OVERVIEW_BINS_PER_AXIS;
|
||||
for (let i = 0; i < grid.length; i++) {
|
||||
const feature = grid.feature(i);
|
||||
const props = feature.properties || {};
|
||||
const point = feature.loadGeometry()?.[0]?.[0];
|
||||
if (i % 64 === 63) yield;
|
||||
if (!point) continue;
|
||||
const pano = Number(props.nb_360_pictures) || 0;
|
||||
const flat = Number(props.nb_flat_pictures) || 0;
|
||||
const weight = Number(props.nb_pictures) || pano + flat;
|
||||
if (weight <= 0) continue;
|
||||
const key = `${Math.floor(point.x / size)}:${Math.floor(point.y / size)}`;
|
||||
const bin = bins.get(key);
|
||||
if (!bin) {
|
||||
bins.set(key, { point, weight, pano, flat, top: weight });
|
||||
continue;
|
||||
}
|
||||
// The bin sits on its busiest cell and counts them all.
|
||||
bin.weight += weight;
|
||||
bin.pano += pano;
|
||||
bin.flat += flat;
|
||||
if (weight > bin.top) {
|
||||
bin.top = weight;
|
||||
bin.point = point;
|
||||
}
|
||||
}
|
||||
for (const [key, bin] of bins) {
|
||||
const [lon, lat] = tileLocalToLonLat(
|
||||
bin.point.x,
|
||||
bin.point.y,
|
||||
grid.extent,
|
||||
x,
|
||||
y,
|
||||
z,
|
||||
);
|
||||
result.overview.push({
|
||||
id: `${z}/${x}/${y}/${key}`,
|
||||
lon,
|
||||
lat,
|
||||
capturedAt: null,
|
||||
isPano: bin.pano > bin.flat,
|
||||
hasPano: bin.pano > 0,
|
||||
hasFlat: bin.flat > 0,
|
||||
weight: bin.weight,
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
decodePictures,
|
||||
simplifyLine,
|
||||
} from './decode.js';
|
||||
import { OVERVIEW_BINS_PER_AXIS } from './policy.js';
|
||||
import { encodePanoramaxTile } from '../../../../testSupport/panoramaxFixtures.mjs';
|
||||
import { tileCentre } from '../../../../testSupport/streetLevelFakes.mjs';
|
||||
|
||||
@@ -42,8 +41,7 @@ test('sequence lines keep their parts apart, with their day and camera type', ()
|
||||
},
|
||||
],
|
||||
});
|
||||
const { sequences, overview } = decodeCoverageTile(bytes, tile);
|
||||
assert.equal(overview.length, 0);
|
||||
const { sequences } = decodeCoverageTile(bytes, tile);
|
||||
assert.equal(sequences.length, 2);
|
||||
const pano = sequences.find((s) => s.id === 'pano-seq');
|
||||
const flat = sequences.find((s) => s.id === 'flat-seq');
|
||||
@@ -72,33 +70,13 @@ test('sequence lines keep their parts apart, with their day and camera type', ()
|
||||
assert.equal(untyped.isPano, null, 'no camera type: neither');
|
||||
});
|
||||
|
||||
test('density cells bin to a bounded number of points that keep the counts and kinds', () => {
|
||||
test('the density grid is not read: nothing is drawn from orbit', () => {
|
||||
const tile = { x: 0, y: 0, z: 0 };
|
||||
// Two cells in one bin (3.75° wide at z0) far enough apart to tell
|
||||
// which it sits on, one far away, one empty.
|
||||
const grid = [
|
||||
{ lon: 0.3, lat: 48.85, pano: 0, flat: 5 },
|
||||
{ lon: 3.4, lat: 48.85, pano: 30, flat: 0 },
|
||||
{ lon: -73.9, lat: 40.7, pano: 1, flat: 0 },
|
||||
{ lon: 10, lat: 10, pano: 0, flat: 0 },
|
||||
];
|
||||
const { overview } = decodeCoverageTile(
|
||||
encodePanoramaxTile(tile, { grid }),
|
||||
tile,
|
||||
const grid = [{ lon: 2.35, lat: 48.85, pano: 3, flat: 5 }];
|
||||
assert.deepEqual(
|
||||
decodeCoverageTile(encodePanoramaxTile(tile, { grid }), tile),
|
||||
{ sequences: [] },
|
||||
);
|
||||
assert.equal(overview.length, 2, 'Paris cells share a bin; empty cells go');
|
||||
const paris = overview.find((p) => p.lon > 0);
|
||||
assert.equal(paris.weight, 35);
|
||||
assert.equal(paris.hasPano, true);
|
||||
assert.equal(paris.hasFlat, true);
|
||||
assert.equal(paris.isPano, true, 'mostly 360°');
|
||||
assert.equal(paris.capturedAt, null, 'cells are undated');
|
||||
assert.ok(
|
||||
near(paris.lon, 3.4, 0.2),
|
||||
`sits on the busiest cell: ${paris.lon}`,
|
||||
);
|
||||
const nyc = overview.find((p) => p.lon < 0);
|
||||
assert.deepEqual([nyc.hasPano, nyc.hasFlat, nyc.weight], [true, false, 1]);
|
||||
});
|
||||
|
||||
test('lines come longest first, so a cap on lines drawn drops the shortest', () => {
|
||||
@@ -124,25 +102,6 @@ test('lines come longest first, so a cap on lines drawn drops the shortest', ()
|
||||
);
|
||||
});
|
||||
|
||||
test('a dense grid tile never draws more than the bins allow', () => {
|
||||
const tile = { x: 0, y: 0, z: 0 };
|
||||
const grid = [];
|
||||
for (let i = 0; i < 300; i++)
|
||||
for (let j = 0; j < 60; j++)
|
||||
grid.push({ lon: -170 + i * 1.1, lat: -60 + j * 2 });
|
||||
const { overview } = decodeCoverageTile(
|
||||
encodePanoramaxTile(tile, { grid }),
|
||||
tile,
|
||||
);
|
||||
assert.ok(overview.length <= OVERVIEW_BINS_PER_AXIS ** 2);
|
||||
assert.ok(overview.length > 1000);
|
||||
assert.equal(
|
||||
overview.reduce((sum, p) => sum + p.weight, 0),
|
||||
grid.length,
|
||||
'every picture is still counted',
|
||||
);
|
||||
});
|
||||
|
||||
/** A tile with many lines and a dense grid, for the sliced decode. */
|
||||
function busyTile() {
|
||||
const { tile, lon, lat } = tileCentre(2.35, 48.85);
|
||||
@@ -212,7 +171,6 @@ test('an empty tile decodes to nothing', () => {
|
||||
for (const bytes of [new Uint8Array(0), null, undefined])
|
||||
assert.deepEqual(decodeCoverageTile(bytes, { x: 0, y: 0, z: 0 }), {
|
||||
sequences: [],
|
||||
overview: [],
|
||||
});
|
||||
assert.deepEqual(
|
||||
decodePictures(new Uint8Array(0), { x: 0, y: 0, z: 15 }, 's'),
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { createLineCoverage, sequenceIdFromPick } from '../../lineCoverage.js';
|
||||
import {
|
||||
createLineCoverage,
|
||||
freshCoverage,
|
||||
sequenceIdFromPick,
|
||||
} from '../../lineCoverage.js';
|
||||
import { createSequenceCones, thinImages } from '../../sequenceCones.js';
|
||||
import { passesImageryFilter } from '../../filter.js';
|
||||
import {
|
||||
lonToTileX,
|
||||
latToTileY,
|
||||
overviewZoomForHeight,
|
||||
tileBounds,
|
||||
} from '../../tileMath.js';
|
||||
import { lonToTileX, latToTileY, tileBounds } from '../../tileMath.js';
|
||||
import { metresBetween } from '../../view.js';
|
||||
import { createLru, createTileCache } from '../../tileCache.js';
|
||||
import { decodeCoverageTileSliced, decodePictures } from './decode.js';
|
||||
@@ -29,7 +28,6 @@ import {
|
||||
NEAREST_RADIUS_M,
|
||||
FILTERED_HINT,
|
||||
NO_COVERAGE_HINT,
|
||||
OVERVIEW_HINT,
|
||||
PANORAMAX_CREDIT_HTML,
|
||||
PANORAMAX_LABEL,
|
||||
PANORAMAX_NAME,
|
||||
@@ -39,6 +37,7 @@ import {
|
||||
PICTURE_TILE_ZOOM,
|
||||
RATE_LIMITED_MESSAGE,
|
||||
UNREADABLE_TILE_MESSAGE,
|
||||
ZOOM_IN_HINT,
|
||||
normalizeItem,
|
||||
} from './policy.js';
|
||||
|
||||
@@ -59,15 +58,6 @@ async function readable(decode) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A density dot's size: busier places draw bigger (2.5 px for one picture,
|
||||
* 8 px from a few hundred thousand), so a city does not read like farmland.
|
||||
*/
|
||||
export function overviewPointSize(point) {
|
||||
const weight = Math.max(1, Number(point?.weight) || 1);
|
||||
return Math.min(8, 2.5 + Math.log10(weight));
|
||||
}
|
||||
|
||||
/** Pictures kept in memory by id (lookups and ‹ › fill it). */
|
||||
const PHOTO_CACHE_SIZE = 2000;
|
||||
/** Sequences whose cone centre is remembered. */
|
||||
@@ -78,44 +68,31 @@ const COVERAGE_TILE_CACHE_SIZE = 16;
|
||||
const PICTURE_LIST_CACHE_SIZE = 32;
|
||||
|
||||
/**
|
||||
* Sequence lines at z10–14 below 60 km; above, the density grid at z5 and
|
||||
* coarser.
|
||||
* Sequence lines at z10–14 below 60 km; above, nothing is drawn (the hint
|
||||
* says to zoom in).
|
||||
*/
|
||||
export function panoramaxZoomFor(heightM) {
|
||||
if (!Number.isFinite(heightM)) return null;
|
||||
if (heightM <= COVERAGE_LINES_BELOW_M) {
|
||||
const zoom =
|
||||
heightM <= COVERAGE_Z14_BELOW_M
|
||||
? 14
|
||||
: heightM <= COVERAGE_Z13_BELOW_M
|
||||
? 13
|
||||
: heightM <= COVERAGE_Z12_BELOW_M
|
||||
? 12
|
||||
: heightM <= COVERAGE_Z11_BELOW_M
|
||||
? 11
|
||||
: 10;
|
||||
return { zoom, kind: 'sequence' };
|
||||
}
|
||||
return {
|
||||
zoom: Math.min(5, overviewZoomForHeight(heightM) ?? 5),
|
||||
kind: 'overview',
|
||||
};
|
||||
if (!Number.isFinite(heightM) || heightM > COVERAGE_LINES_BELOW_M)
|
||||
return null;
|
||||
return heightM <= COVERAGE_Z14_BELOW_M
|
||||
? 14
|
||||
: heightM <= COVERAGE_Z13_BELOW_M
|
||||
? 13
|
||||
: heightM <= COVERAGE_Z12_BELOW_M
|
||||
? 12
|
||||
: heightM <= COVERAGE_Z11_BELOW_M
|
||||
? 11
|
||||
: 10;
|
||||
}
|
||||
|
||||
/**
|
||||
* The imagery filter for lines and grid cells. A cell (undated, with 360°
|
||||
* and flat counts) stays while it holds pictures of the kind asked for. A
|
||||
* line is dated by its sequence's first day, so a sequence still growing
|
||||
* may hide under "since" while its newer pictures can be opened; a line
|
||||
* with no camera type or no day is kept (a click finds out).
|
||||
* The imagery filter for lines. A line is dated by its sequence's first
|
||||
* day, so a sequence still growing may hide under "since" while its newer
|
||||
* pictures can be opened; a line with no camera type or no day is kept (a
|
||||
* click finds out).
|
||||
*/
|
||||
export function passesCoverage(record, filter) {
|
||||
if (!filter) return true;
|
||||
if ('hasPano' in record) {
|
||||
if (filter.pano === 'pano') return record.hasPano;
|
||||
if (filter.pano === 'flat') return record.hasFlat;
|
||||
return true;
|
||||
}
|
||||
return passesImageryFilter(record, {
|
||||
pano: typeof record.isPano === 'boolean' ? filter.pano : 'all',
|
||||
sinceMs: record.capturedAt ? filter.sinceMs : null,
|
||||
@@ -159,8 +136,8 @@ const tileKey = ({ x, y, z }) => `${z}/${x}/${y}`;
|
||||
|
||||
/**
|
||||
* Panoramax Street Level provider: the federated, keyless, open
|
||||
* street-level commons. Coverage from its vector tiles (density points
|
||||
* from orbit, sequence lines near the ground); a click on a line opens that
|
||||
* street-level commons. Coverage from its vector tiles (sequence lines
|
||||
* near the ground, nothing from orbit); a click on a line opens that
|
||||
* sequence's picture nearest the click, with cones along the sequence
|
||||
* around it and ‹ › to step through it. Flat pictures show as photos, 360°
|
||||
* ones on the shared WebGL panorama.
|
||||
@@ -190,30 +167,13 @@ export function createPanoramaxProvider({
|
||||
context,
|
||||
services: context.services,
|
||||
viewer: null,
|
||||
keyRequired: false,
|
||||
keyRejected: false,
|
||||
statusKnown: true,
|
||||
coverage: {
|
||||
zoom: null,
|
||||
tiles: new Map(),
|
||||
stale: new Map(),
|
||||
staleTimer: null,
|
||||
pending: new Map(),
|
||||
lastError: null,
|
||||
holdUntil: 0,
|
||||
holdTimer: null,
|
||||
debounceTimer: null,
|
||||
removeCameraListener: null,
|
||||
terrainReady: null,
|
||||
hint: '',
|
||||
kind: null,
|
||||
},
|
||||
coverage: freshCoverage({ keyless: true }),
|
||||
sequence: {
|
||||
selectedId: null,
|
||||
images: [],
|
||||
cache: new Map(),
|
||||
collection: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
abort: null,
|
||||
},
|
||||
};
|
||||
@@ -354,9 +314,8 @@ export function createPanoramaxProvider({
|
||||
pickPrefix: PICK_PREFIX.sequence,
|
||||
renderKey: 'panoramax-coverage',
|
||||
tuning: COVERAGE_TUNING,
|
||||
messages: { rateLimited: RATE_LIMITED_MESSAGE },
|
||||
messages: { rateLimited: RATE_LIMITED_MESSAGE, tooHigh: ZOOM_IN_HINT },
|
||||
passes: passesCoverage,
|
||||
overviewPointSize,
|
||||
});
|
||||
|
||||
const cones = createSequenceCones({
|
||||
@@ -406,8 +365,7 @@ export function createPanoramaxProvider({
|
||||
pickPrefix: PICK_PREFIX.image,
|
||||
spriteId: 'street-level:panoramax-cones',
|
||||
renderKey: 'panoramax-sequence',
|
||||
onHighlight: (sequenceId, selected) =>
|
||||
coverage.recolorSequence(sequenceId, selected),
|
||||
onHighlight: (sequenceId) => coverage.highlight(sequenceId),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -500,7 +458,7 @@ export function createPanoramaxProvider({
|
||||
deactivate() {
|
||||
coverage.detach();
|
||||
coverage.clear();
|
||||
coverage.resetErrors();
|
||||
coverage.unblock();
|
||||
cones.clearSelection();
|
||||
cones.setVisible(false);
|
||||
drawn = null;
|
||||
@@ -513,55 +471,30 @@ export function createPanoramaxProvider({
|
||||
destroy(cesiumViewer) {
|
||||
coverage.detach();
|
||||
coverage.clear();
|
||||
coverage.resetErrors();
|
||||
coverage.unblock();
|
||||
cones.destroy(cesiumViewer);
|
||||
},
|
||||
|
||||
refreshCoverage: () => coverage.refresh(),
|
||||
|
||||
/** The filter changed: redraw. Each draw reads it from the context. */
|
||||
setFilter() {
|
||||
coverage.rebuild();
|
||||
cones.rerender();
|
||||
},
|
||||
|
||||
setSurface() {
|
||||
coverage.setSurface();
|
||||
cones.rerender();
|
||||
},
|
||||
|
||||
coverageStats() {
|
||||
const { kind } = state.coverage;
|
||||
const lines = kind === 'sequence';
|
||||
const shown = coverage.sequenceCount();
|
||||
// Density dots are not sequences: they are not counted as such.
|
||||
const count = lines ? shown : 0;
|
||||
const loading = state.coverage.pending.size > 0;
|
||||
const settled = !loading && !state.coverage.lastError;
|
||||
const stats = coverage.stats();
|
||||
let fallback = '';
|
||||
// Tiles that held something the filter hid all of.
|
||||
const filteredOut = [...state.coverage.tiles.values()].some(
|
||||
(tile) => tile.total > 0,
|
||||
);
|
||||
if (kind === 'overview')
|
||||
fallback =
|
||||
shown > 0 || !settled
|
||||
? OVERVIEW_HINT
|
||||
: filteredOut
|
||||
? FILTERED_HINT
|
||||
: NO_COVERAGE_HINT;
|
||||
else if (lines && settled && count === 0)
|
||||
// Lines drawn for the camera (a zoom), all settled, none shown.
|
||||
const settled =
|
||||
state.coverage.zoom != null && !stats.loading && !stats.error;
|
||||
if (settled && stats.count === 0) {
|
||||
// Tiles that held lines the filter hid all of.
|
||||
const filteredOut = [...state.coverage.tiles.values()].some(
|
||||
(tile) => tile.total > 0,
|
||||
);
|
||||
fallback = filteredOut ? FILTERED_HINT : NO_COVERAGE_HINT;
|
||||
return {
|
||||
count,
|
||||
zoom: state.coverage.zoom,
|
||||
kind: state.coverage.kind,
|
||||
loading,
|
||||
hint: state.coverage.hint || fallback,
|
||||
error: state.coverage.lastError,
|
||||
keyRequired: false,
|
||||
keyRejected: false,
|
||||
};
|
||||
}
|
||||
return { ...stats, hint: stats.hint || fallback };
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -599,6 +532,7 @@ export function createPanoramaxProvider({
|
||||
selectedId: state.sequence.selectedId,
|
||||
images: state.sequence.images.length,
|
||||
loading: state.sequence.loading,
|
||||
error: state.sequence.error,
|
||||
}),
|
||||
|
||||
/**
|
||||
|
||||
@@ -32,9 +32,7 @@ export const COLORS = Object.freeze({
|
||||
/**
|
||||
* Sequence lines come from z7–15 tiles; z10–14 are drawn below 60 km, as
|
||||
* Mapillary's are (a dense tile over Paris: z10 ~2.1 MB and 8,600
|
||||
* sequences, z11 ~1.4 MB, z12 ~800 kB). Higher up, the z0–5 `grid` layer's
|
||||
* density cells (0.1°, ~10 km) draw as points: lower down they would be a
|
||||
* handful of dots over a whole city, which reads as no coverage.
|
||||
* sequences, z11 ~1.4 MB, z12 ~800 kB). Higher up nothing is drawn.
|
||||
*/
|
||||
export const COVERAGE_Z14_BELOW_M = 1_800;
|
||||
export const COVERAGE_Z13_BELOW_M = 5_000;
|
||||
@@ -43,23 +41,21 @@ export const COVERAGE_Z11_BELOW_M = 30_000;
|
||||
export const COVERAGE_LINES_BELOW_M = 60_000;
|
||||
export const COVERAGE_TUNING = Object.freeze({
|
||||
maxTiles: 9,
|
||||
overviewMaxTiles: 6,
|
||||
// Each tile draws at most 1,600 lines, its longest first (as decoded,
|
||||
// `keepOrder`): a dense z11
|
||||
// tile over Paris holds 3,800, and a lower cap left the city centre
|
||||
// looking emptier than its suburbs. (KartaView draws as many.)
|
||||
maxSequences: 14_400,
|
||||
keepOrder: true,
|
||||
// Points drawn at most, over every tile (each becomes ~8 ground-line
|
||||
// vertices, ~1.4 kB of geometry): the simplified Paris z10 tile alone
|
||||
// holds 57,000, unsimplified 750,000, which hung the tab.
|
||||
// Points drawn at most, over every tile, a ninth of it a tile (each
|
||||
// becomes ~8 ground-line vertices, ~1.4 kB of geometry): the simplified
|
||||
// Paris z10 tile alone holds 57,000, unsimplified 750,000, which hung
|
||||
// the tab.
|
||||
maxPoints: 90_000,
|
||||
lineWidthPx: 2.5,
|
||||
overviewPointPx: 4,
|
||||
moveDebounceMs: 320,
|
||||
viewRangeMinM: 2_500,
|
||||
viewRangePerHeight: 10,
|
||||
viewNearM: 1_000,
|
||||
});
|
||||
/**
|
||||
* Lines are simplified (Douglas–Peucker) to this many tile units of a
|
||||
@@ -74,12 +70,6 @@ export const SIMPLIFY_TOLERANCE_UNITS = 4;
|
||||
*/
|
||||
export const DECODE_SLICE_MS = 8;
|
||||
|
||||
/**
|
||||
* Grid cells per overview tile, at most: a z0 tile holds ~70,000 cells
|
||||
* (1.9 MB), drawn as this many binned points.
|
||||
*/
|
||||
export const OVERVIEW_BINS_PER_AXIS = 96;
|
||||
|
||||
export const RATE_LIMITED_MESSAGE = msg(
|
||||
'Panoramax is busy — coverage resumes on its own',
|
||||
);
|
||||
@@ -90,8 +80,8 @@ export const UNREADABLE_TILE_MESSAGE = msg(
|
||||
'Panoramax sent a map tile that could not be read',
|
||||
);
|
||||
export const GONE_MESSAGE = msg('This Panoramax photo is no longer available');
|
||||
/** From high up the dots are where pictures are, not sequences to click. */
|
||||
export const OVERVIEW_HINT = msg('zoom in to see Panoramax coverage');
|
||||
/** Above the line ceiling nothing is drawn. */
|
||||
export const ZOOM_IN_HINT = msg('zoom in to see Panoramax coverage');
|
||||
|
||||
/** Cones come from z15 tiles' `pictures` layer (~110 bytes a picture). */
|
||||
export const PICTURE_TILE_ZOOM = 15;
|
||||
|
||||
@@ -2,7 +2,6 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
createPanoramaxProvider,
|
||||
overviewPointSize,
|
||||
panoramaxZoomFor,
|
||||
passesCoverage,
|
||||
pictureTilesAround,
|
||||
@@ -10,8 +9,8 @@ import {
|
||||
import {
|
||||
FILTERED_HINT,
|
||||
NO_COVERAGE_HINT,
|
||||
OVERVIEW_HINT,
|
||||
PICK_PREFIX,
|
||||
ZOOM_IN_HINT,
|
||||
} from './policy.js';
|
||||
import { validateProviders } from '../../registry.js';
|
||||
import { tileBounds } from '../../tileMath.js';
|
||||
@@ -95,9 +94,6 @@ function fakeContext({ filter = { pano: 'all', sinceMs: null } } = {}) {
|
||||
services: {},
|
||||
getFilter: () => filter,
|
||||
isActive: () => true,
|
||||
groundCaster: null,
|
||||
meshSampler: null,
|
||||
getSurface: () => 'draped',
|
||||
notify() {},
|
||||
actions: {
|
||||
openImage: async (id, options) => {
|
||||
@@ -152,19 +148,17 @@ test('Panoramax registers keyless and off by default, with both filters and its
|
||||
assert.doesNotThrow(() => validateProviders([definition]));
|
||||
});
|
||||
|
||||
test('lines at z10–14 below 60 km, as Mapillary’s; density points from z5 down to z0 above', () => {
|
||||
assert.deepEqual(panoramaxZoomFor(500), { zoom: 14, kind: 'sequence' });
|
||||
assert.deepEqual(panoramaxZoomFor(1_800), { zoom: 14, kind: 'sequence' });
|
||||
assert.deepEqual(panoramaxZoomFor(4_000), { zoom: 13, kind: 'sequence' });
|
||||
assert.deepEqual(panoramaxZoomFor(12_000), { zoom: 12, kind: 'sequence' });
|
||||
assert.deepEqual(panoramaxZoomFor(25_000), { zoom: 11, kind: 'sequence' });
|
||||
assert.deepEqual(panoramaxZoomFor(30_000), { zoom: 11, kind: 'sequence' });
|
||||
assert.deepEqual(panoramaxZoomFor(30_001), { zoom: 10, kind: 'sequence' });
|
||||
assert.deepEqual(panoramaxZoomFor(60_000), { zoom: 10, kind: 'sequence' });
|
||||
assert.deepEqual(panoramaxZoomFor(60_001), { zoom: 5, kind: 'overview' });
|
||||
assert.deepEqual(panoramaxZoomFor(100_000), { zoom: 5, kind: 'overview' });
|
||||
assert.deepEqual(panoramaxZoomFor(2_000_000), { zoom: 3, kind: 'overview' });
|
||||
assert.deepEqual(panoramaxZoomFor(20_000_000), { zoom: 0, kind: 'overview' });
|
||||
test('lines at z10–14 below 60 km, as Mapillary’s; nothing above', () => {
|
||||
assert.equal(panoramaxZoomFor(500), 14);
|
||||
assert.equal(panoramaxZoomFor(1_800), 14);
|
||||
assert.equal(panoramaxZoomFor(4_000), 13);
|
||||
assert.equal(panoramaxZoomFor(12_000), 12);
|
||||
assert.equal(panoramaxZoomFor(25_000), 11);
|
||||
assert.equal(panoramaxZoomFor(30_000), 11);
|
||||
assert.equal(panoramaxZoomFor(30_001), 10);
|
||||
assert.equal(panoramaxZoomFor(60_000), 10);
|
||||
assert.equal(panoramaxZoomFor(60_001), null);
|
||||
assert.equal(panoramaxZoomFor(20_000_000), null);
|
||||
assert.equal(panoramaxZoomFor(NaN), null);
|
||||
});
|
||||
|
||||
@@ -182,7 +176,7 @@ test('a line with no camera type or no day passes the filters: a click finds out
|
||||
);
|
||||
});
|
||||
|
||||
test('the imagery filter: lines by type and day, density cells by what they hold', () => {
|
||||
test('the imagery filter: lines by type and day', () => {
|
||||
const old = Date.UTC(2020, 0, 1);
|
||||
const since = { pano: 'all', sinceMs: Date.UTC(2024, 0, 1) };
|
||||
assert.equal(
|
||||
@@ -199,11 +193,7 @@ test('the imagery filter: lines by type and day, density cells by what they hold
|
||||
),
|
||||
true,
|
||||
);
|
||||
const cell = { hasPano: false, hasFlat: true, capturedAt: null };
|
||||
assert.equal(passesCoverage(cell, { pano: 'pano', sinceMs: null }), false);
|
||||
assert.equal(passesCoverage(cell, { pano: 'flat', sinceMs: null }), true);
|
||||
assert.equal(passesCoverage(cell, since), true, 'cells are undated: kept');
|
||||
assert.equal(passesCoverage(cell, null), true);
|
||||
assert.equal(passesCoverage({ isPano: false, capturedAt: old }, null), true);
|
||||
});
|
||||
|
||||
test('cones read the z15 tile around a picture, and its neighbours near an edge', () => {
|
||||
@@ -249,7 +239,7 @@ test('a click on a line opens that sequence’s picture nearest the click, not a
|
||||
];
|
||||
const { instance, actions, source } = setup({ items });
|
||||
assert.equal(
|
||||
instance.handlePick(`${PICK_PREFIX.sequence}clicked~1`, {
|
||||
instance.handlePick(`${PICK_PREFIX.sequence}clicked`, {
|
||||
groundPoint: () => east(1),
|
||||
}),
|
||||
true,
|
||||
@@ -569,8 +559,13 @@ test('a broken neighbour z15 tile only thins the cones; an unreadable home tile
|
||||
await withCones(t, env2);
|
||||
await env2.instance.viewer.open('c-0');
|
||||
await env2.instance.selectSequence('c');
|
||||
assert.ok(await until(() => env2.actions.errors.some(Boolean)));
|
||||
assert.match(env2.actions.errors.filter(Boolean).at(-1), /could not be read/);
|
||||
assert.ok(await until(() => env2.instance.sequenceStats().error));
|
||||
assert.match(env2.instance.sequenceStats().error, /could not be read/);
|
||||
assert.deepEqual(
|
||||
env2.actions.errors.filter(Boolean),
|
||||
[],
|
||||
'a sequence error is the sequence’s, not the viewer’s',
|
||||
);
|
||||
});
|
||||
|
||||
test('cones re-centred into tiles already read do not read them again', async (t) => {
|
||||
@@ -629,16 +624,13 @@ test('the cones re-centre when › walks into another z15 tile', async (t) => {
|
||||
});
|
||||
|
||||
/** The provider drawing coverage on a stand-in viewer above one z14 tile. */
|
||||
function coverageSetup(
|
||||
t,
|
||||
{ sequences = [], grid = [], filter, height = 900 } = {},
|
||||
) {
|
||||
function coverageSetup(t, { sequences = [], filter, height = 900 } = {}) {
|
||||
const centre = tileCentre(ORIGIN.lon, ORIGIN.lat);
|
||||
let hold = null;
|
||||
const env = setup(
|
||||
{
|
||||
tiles: (tile) => {
|
||||
const bytes = encodePanoramaxTile(tile, { sequences, grid });
|
||||
const bytes = encodePanoramaxTile(tile, { sequences });
|
||||
return hold ? hold.then(() => bytes) : bytes;
|
||||
},
|
||||
},
|
||||
@@ -682,8 +674,8 @@ test('sequence lines draw near the ground, and the imagery filter thins them', a
|
||||
});
|
||||
env.instance.activate(env.viewer);
|
||||
assert.ok(await until(() => env.instance.coverageStats().count === 2));
|
||||
assert.equal(env.instance.coverageStats().kind, 'sequence');
|
||||
assert.equal(env.instance.coverageStats().keyRequired, false);
|
||||
assert.equal(env.instance.coverageStats().error, null);
|
||||
env.instance.deactivate();
|
||||
|
||||
const filtered = coverageSetup(t, {
|
||||
@@ -697,6 +689,18 @@ test('sequence lines draw near the ground, and the imagery filter thins them', a
|
||||
assert.ok(await until(() => filtered.instance.coverageStats().count === 1));
|
||||
});
|
||||
|
||||
test('the selected sequence’s line is drawn again over the coverage, and goes with it', async (t) => {
|
||||
const env = coverageSetup(t, { sequences: [line('a'), line('b')] });
|
||||
env.instance.activate(env.viewer);
|
||||
assert.ok(await until(() => env.instance.coverageStats().count === 2));
|
||||
const ground = env.viewer.scene.groundPrimitives.items;
|
||||
const drawn = ground.size;
|
||||
await env.instance.selectSequence('a');
|
||||
assert.equal(ground.size, drawn + 1, 'one highlight over the lines');
|
||||
env.instance.clearSequence();
|
||||
assert.equal(ground.size, drawn);
|
||||
});
|
||||
|
||||
test('coverage hints: nothing while loading, “no pictures” once an empty view has loaded', async (t) => {
|
||||
const env = coverageSetup(t);
|
||||
const release = env.holdTiles();
|
||||
@@ -708,19 +712,16 @@ test('coverage hints: nothing while loading, “no pictures” once an empty vie
|
||||
assert.equal(env.instance.coverageStats().hint, NO_COVERAGE_HINT);
|
||||
});
|
||||
|
||||
test('from high up the density grid draws points, not counted as sequences, and the panel says to zoom in', async (t) => {
|
||||
const env = coverageSetup(t, {
|
||||
height: 200_000,
|
||||
grid: [{ lon: ORIGIN.lon, lat: ORIGIN.lat, pano: 2, flat: 3 }],
|
||||
});
|
||||
test('from high up nothing is drawn or fetched, and the panel says to zoom in', async (t) => {
|
||||
const env = coverageSetup(t, { height: 200_000, sequences: [line('a')] });
|
||||
env.instance.activate(env.viewer);
|
||||
assert.ok(
|
||||
await until(() => env.instance.coverageStats().kind === 'overview'),
|
||||
await until(() => env.instance.coverageStats().hint === ZOOM_IN_HINT),
|
||||
);
|
||||
assert.ok(await until(() => !env.instance.coverageStats().loading));
|
||||
assert.equal(env.instance.coverageStats().zoom, 5);
|
||||
assert.equal(env.instance.coverageStats().count, 0, 'dots are not sequences');
|
||||
assert.equal(env.instance.coverageStats().hint, OVERVIEW_HINT);
|
||||
assert.equal(env.instance.coverageStats().count, 0);
|
||||
assert.equal(env.instance.coverageStats().loading, false);
|
||||
assert.deepEqual(env.source.calls.tiles, []);
|
||||
assert.equal(env.viewer.scene.groundPrimitives.items.size, 0);
|
||||
});
|
||||
|
||||
test('switching off clears the cones and forgets a pending line click', async (t) => {
|
||||
@@ -747,41 +748,12 @@ test('lines in view that the filter hides all of say so, not that there is nothi
|
||||
sequences: [line('flat-only')],
|
||||
});
|
||||
env.instance.activate(env.viewer);
|
||||
assert.ok(
|
||||
await until(
|
||||
() =>
|
||||
!env.instance.coverageStats().loading &&
|
||||
env.instance.coverageStats().zoom !== null,
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
await until(() => env.instance.coverageStats().hint === FILTERED_HINT),
|
||||
);
|
||||
assert.equal(env.instance.coverageStats().count, 0);
|
||||
});
|
||||
|
||||
test('from high up over empty sea the panel says there is nothing, not to zoom in', async (t) => {
|
||||
const env = coverageSetup(t, { height: 200_000, grid: [] });
|
||||
env.instance.activate(env.viewer);
|
||||
assert.ok(
|
||||
await until(() => env.instance.coverageStats().kind === 'overview'),
|
||||
);
|
||||
assert.ok(
|
||||
await until(() => env.instance.coverageStats().hint === NO_COVERAGE_HINT),
|
||||
);
|
||||
});
|
||||
|
||||
test('density dots grow with the pictures they stand for', () => {
|
||||
assert.equal(overviewPointSize({ weight: 1 }), 2.5);
|
||||
assert.equal(overviewPointSize({ weight: 100 }), 4.5);
|
||||
assert.equal(overviewPointSize({ weight: 1e9 }), 8);
|
||||
assert.equal(overviewPointSize({}), 2.5);
|
||||
assert.ok(
|
||||
overviewPointSize({ weight: 50_000 }) >
|
||||
overviewPointSize({ weight: 50 }) + 2,
|
||||
);
|
||||
});
|
||||
|
||||
test('Esc in the docked viewer clears the selected sequence before the panel can collapse', async (t) => {
|
||||
const items = sequence('s', 3);
|
||||
const env = await mounted({ items, tiles: pictureTiles(items) });
|
||||
@@ -890,42 +862,7 @@ test('cones re-centre on a picture that opens while they are still loading', asy
|
||||
);
|
||||
});
|
||||
|
||||
test('a filter that hides every density dot says so', async (t) => {
|
||||
const env = coverageSetup(t, {
|
||||
height: 200_000,
|
||||
filter: { pano: 'pano', sinceMs: null },
|
||||
grid: [{ lon: ORIGIN.lon, lat: ORIGIN.lat, pano: 0, flat: 5 }],
|
||||
});
|
||||
env.instance.activate(env.viewer);
|
||||
assert.ok(
|
||||
await until(() => env.instance.coverageStats().hint === FILTERED_HINT),
|
||||
);
|
||||
});
|
||||
|
||||
test('dots are drawn at their own sizes, and the busiest is the biggest', async (t) => {
|
||||
const env = coverageSetup(t, {
|
||||
height: 200_000,
|
||||
grid: [
|
||||
{ lon: ORIGIN.lon, lat: ORIGIN.lat, pano: 0, flat: 1 },
|
||||
{ lon: ORIGIN.lon + 3, lat: ORIGIN.lat, pano: 30_000, flat: 20_000 },
|
||||
],
|
||||
});
|
||||
env.instance.activate(env.viewer);
|
||||
const sizes = () => {
|
||||
const out = [];
|
||||
for (const primitive of env.viewer.scene.primitives.items)
|
||||
for (let i = 0; i < (primitive.length ?? 0); i++)
|
||||
if (primitive.get(i).pixelSize) out.push(primitive.get(i).pixelSize);
|
||||
return out.sort((a, b) => a - b);
|
||||
};
|
||||
assert.ok(await until(() => sizes().length === 2));
|
||||
assert.deepEqual(sizes(), [
|
||||
overviewPointSize({ weight: 1 }),
|
||||
overviewPointSize({ weight: 50_000 }),
|
||||
]);
|
||||
});
|
||||
|
||||
test('lines keep the decoded (longest-first) order under a budget of points', async () => {
|
||||
test('lines keep the decoded (longest-first) order under a cap on points', async () => {
|
||||
const { COVERAGE_TUNING } = await import('./policy.js');
|
||||
assert.equal(COVERAGE_TUNING.keepOrder, true);
|
||||
assert.ok(COVERAGE_TUNING.maxPoints > 0);
|
||||
@@ -943,15 +880,16 @@ test('a coverage tile decoded a moment ago is not fetched again; switched off, t
|
||||
assert.ok(await until(() => env.instance.coverageStats().count === 1));
|
||||
const z14 = asked.length;
|
||||
// Up a zoom band and back down: the z14 tile comes from memory.
|
||||
// (Activating again refreshes at once, without the camera debounce.)
|
||||
env.viewer.view.height = 3_000;
|
||||
env.instance.refreshCoverage();
|
||||
assert.ok(await until(() => env.instance.coverageStats().zoom === 13));
|
||||
assert.ok(await until(() => !env.instance.coverageStats().loading));
|
||||
env.instance.activate(env.viewer);
|
||||
assert.ok(await until(() => asked.some((key) => key.startsWith('13/'))));
|
||||
assert.ok(await until(() => env.instance.coverageStats().count === 1));
|
||||
const withZ13 = asked.length;
|
||||
env.viewer.view.height = 900;
|
||||
env.instance.refreshCoverage();
|
||||
assert.ok(await until(() => env.instance.coverageStats().zoom === 14));
|
||||
env.instance.activate(env.viewer);
|
||||
assert.ok(await until(() => env.instance.coverageStats().count === 1));
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
assert.equal(asked.length, withZ13, 'nothing fetched again');
|
||||
assert.ok(z14 > 0);
|
||||
// Off and on: the memory went back, so the tile is fetched again.
|
||||
@@ -1240,7 +1178,7 @@ test('a busy or timed-out tile keeps its own message; only a body that is not a
|
||||
await withCones(t, env);
|
||||
await env.instance.viewer.open('s-0');
|
||||
await env.instance.selectSequence('s');
|
||||
assert.ok(await until(() => env.actions.errors.some(Boolean)));
|
||||
assert.equal(env.actions.errors.filter(Boolean).at(-1), failure.message);
|
||||
assert.ok(await until(() => env.instance.sequenceStats().error));
|
||||
assert.equal(env.instance.sequenceStats().error, failure.message);
|
||||
}
|
||||
});
|
||||
@@ -160,7 +160,7 @@ test('a 360° picture shows its SD equirectangular image, then HD shrunk to 4,09
|
||||
assert.equal(loads.length, 1, 'docked: still SD only');
|
||||
const root = canvas.parentNode;
|
||||
root.closest = (selector) =>
|
||||
selector.includes('sl-viewer-wrap-expanded') ? {} : null;
|
||||
selector.includes('sl-viewer-wrap:fullscreen') ? {} : null;
|
||||
// EXPAND resizes more than once while the panel settles.
|
||||
viewer.resize();
|
||||
viewer.resize();
|
||||
@@ -280,7 +280,7 @@ test('a picture whose SD fails falls back to the thumbnail; the original only fo
|
||||
// Expanded, the original is worth its megabytes.
|
||||
tried.length = 0;
|
||||
parts(host).root.closest = (selector) =>
|
||||
selector.includes('sl-viewer-wrap-expanded') ? {} : null;
|
||||
selector.includes('sl-viewer-wrap:fullscreen') ? {} : null;
|
||||
await viewer.open('flat');
|
||||
assert.deepEqual(tried, [
|
||||
PHOTOS.flat.urls.sd,
|
||||
|
||||
@@ -11,7 +11,7 @@ export function filtersOf(provider) {
|
||||
*
|
||||
* @typedef {object} StreetLevelProvider
|
||||
* @property {string} id Stable id, e.g. 'mapillary'; also the share-link option key.
|
||||
* @property {string} name Human name for captions and legends, e.g. 'Mapillary'.
|
||||
* @property {string} name Human name for captions, e.g. 'Mapillary'.
|
||||
* @property {string} label Chip text and link label, e.g. 'MAPILLARY'.
|
||||
* @property {string|null} requiresKeyId Key-setup id the provider needs, or null when keyless.
|
||||
* @property {string} pickPrefix Every primitive id the provider creates starts with it.
|
||||
@@ -19,54 +19,44 @@ export function filtersOf(provider) {
|
||||
* @property {{html: string}} credit On-globe attribution while the provider is active.
|
||||
* @property {boolean} [groundClick] No coverage: a ground click at street zoom opens its nearest image.
|
||||
* @property {string} [requiresMapStack] Usable only on this map stack.
|
||||
* @property {boolean} [defaultOn] Default true.
|
||||
* @property {boolean} [defaultOn] Default true; the first that defaults on is lit.
|
||||
* @property {{kind: 'view-cone', color: string}} [marker] Default: GEV's pin.
|
||||
* @property {Array<'pano'|'since'>} [filters] Filters it honours (default both).
|
||||
* @property {{noun: string, action: string}} [coverageLabel] How the panel counts its lines and says what a click does (default: sequences, "click a line for its photos").
|
||||
* @property {boolean} [highlightsSequence] Its lines show the selected sequence (default true; the legend's "Selected" needs one).
|
||||
* @property {'capture'|'upload'} [sinceBasis] What SINCE compares (default 'capture'); 'upload' puts a note under the slider while the provider is lit.
|
||||
* @property {(context: ProviderContext) => ProviderInstance} create
|
||||
*
|
||||
* @typedef {object} ProviderContext Handed to `create()` once by the core.
|
||||
* @property {object} services Scene services: picking, input, render, sprites, ground, terrain, scenePick (ground clicks need it).
|
||||
* @property {object} services Scene services: picking, input, render, sprites, scenePick (ground clicks need it).
|
||||
* @property {() => {pano: string, sinceMs: number|null}} getFilter Filter resolved against now; read it on every use so "since N days" keeps up.
|
||||
* @property {() => boolean} isActive Layer enabled and this provider switched on.
|
||||
* @property {() => 'draped'|'terrain'} getSurface 'terrain' on Google 3D at street zoom: draw on bare earth.
|
||||
* @property {GroundCaster|null} groundCaster Bare-earth heights for terrain mode (groundCast.js); null without a terrain service.
|
||||
* @property {{meshAt: (lon: number, lat: number) => number|undefined, request: (points: Array<[number, number]>) => void, onSampled: (listener: (cells: Array<[number, number]>) => void) => () => void}|null} meshSampler Sampled Google 3D surface heights (meshSampler.js); pass `meshAt` to `castLine`.
|
||||
* @property {() => void} notify Ask the core to publish a new UI snapshot.
|
||||
* @property {{openImage: (imageId: string, options?: {frame?: boolean}) => Promise<boolean>, openNearest: (point: {lon: number, lat: number}) => Promise<boolean>, reportError: (message: string|null) => void}} actions
|
||||
* `openNearest` opens this provider's image nearest a point the user picked
|
||||
* (needs `nearestImage`); `reportError` shows a viewer or key error in the
|
||||
* panel, or withdraws this provider's own (null).
|
||||
*
|
||||
* @typedef {object} ProviderInstance
|
||||
* @property {() => Promise<{configured: boolean}>} status
|
||||
* @property {(options?: {signal?: AbortSignal}) => Promise<{configured: boolean}>} status Whether a usable key is set; the core gives up on a hung answer through `signal`.
|
||||
* @property {(viewer: object) => void} init Create collections, hidden.
|
||||
* @property {(viewer: object) => void} activate Start drawing coverage for the camera.
|
||||
* @property {() => void} deactivate Detach, clear coverage and selection, hide.
|
||||
* @property {(viewer: object) => void} destroy
|
||||
* @property {() => void} refreshCoverage
|
||||
* @property {(filter: {pano: string, sinceMs: number|null}) => void} setFilter The filter changed: redraw (reading `getFilter()`).
|
||||
* @property {(mode: 'draped'|'terrain') => void} [setSurface] Redraw coverage and cones for the new surface mode.
|
||||
* @property {() => {count: number, zoom: number|null, kind: string|null, loading: boolean, hint: string, error: string|null, keyRequired: boolean}} coverageStats
|
||||
* @property {() => {count: number, loading: boolean, hint: string, error: string|null, keyRequired: boolean, keyRejected?: boolean}} coverageStats
|
||||
* @property {(pickId: string, click?: {groundPoint: () => {lon: number, lat: number}|null}) => boolean} handlePick The id carries the provider's own prefix; `groundPoint` is where the click met the ground.
|
||||
* @property {(sequenceId: string) => Promise<void>} [selectSequence]
|
||||
* @property {() => void} [clearSequence]
|
||||
* @property {() => {selectedId: string|null, images: number, loading: boolean}} [sequenceStats]
|
||||
* @property {(point: {lat: number, lon: number}, options?: {signal?: AbortSignal}) => Promise<string|null>} nearestImage An aborted lookup gives up (it may reject with an AbortError).
|
||||
* @property {() => {selectedId: string|null, images: number, loading: boolean, error?: string|null}} [sequenceStats]
|
||||
* @property {(point: {lat: number, lon: number}, options?: {signal?: AbortSignal}) => Promise<string|null>} [nearestImage] For ground clicks and `openNearest`; an aborted lookup gives up (it may reject with an AbortError).
|
||||
* @property {ViewerAdapter} viewer
|
||||
*
|
||||
* @typedef {object} GroundCaster
|
||||
* @property {(points: Array<[number, number]>, options?: {signal?: AbortSignal}) => Promise<boolean>} prepare Fetch the heights around [lon, lat] points; true when all are known.
|
||||
* @property {(lines: Array<Array<[number, number]>>, options?: {signal?: AbortSignal}) => Promise<boolean>} prepareLines
|
||||
* @property {(lon: number, lat: number) => number|null} groundAt Cached bare-earth ellipsoidal height.
|
||||
* @property {(coords: Array<[number, number]>) => Array<number>|null} castLine Densified [lon, lat, height, ...] or null.
|
||||
*
|
||||
* @typedef {object} ViewerAdapter
|
||||
* @property {(host: HTMLElement) => Promise<void>} mount Idempotent; may lazy-load a library.
|
||||
* @property {(imageId: string) => Promise<void>} open Resolves once the image is on screen and a pose was emitted.
|
||||
* @property {() => void} close Drop the image, keep the instance warm.
|
||||
* @property {() => void} unmount Destroy the instance (host handed to another provider).
|
||||
* @property {() => void} resize
|
||||
* @property {(host: HTMLElement) => Promise<void>} [prewarm]
|
||||
* @property {(mode: 'letterbox'|'fill') => void} [setRenderMode]
|
||||
* @property {() => boolean} [supportsRenderModes] False while the image has no frame to fit (a panorama): FIT / FILL hide.
|
||||
* @property {(listener: (pose: StreetPose) => void) => () => void} onPose
|
||||
|
||||
@@ -1,33 +1,31 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { STREET_LEVEL_LAYER_ID } from './policy.js';
|
||||
import { CLICK_PICK_WINDOW_PX, STREET_LEVEL_LAYER_ID } from './policy.js';
|
||||
|
||||
/** Hover picks at most ~8 times a second (like the CCTV layer), never per frame. */
|
||||
const HOVER_PICK_INTERVAL_MS = 120;
|
||||
|
||||
/** One click/hover handler for every provider's coverage and image cones. */
|
||||
/** One click handler for every provider's lines and cones, and the marker. */
|
||||
export function createSelection({ state, parts }) {
|
||||
const { picking, input } = state.services;
|
||||
|
||||
function ownsPick(pickedId) {
|
||||
return parts.router.ownsPick(pickedId);
|
||||
}
|
||||
|
||||
/** The id carries a provider's prefix: that provider handles the pick. */
|
||||
function onClick(click) {
|
||||
const viewer = state.viewer;
|
||||
if (!viewer || !state.enabled) return;
|
||||
if (input?.isPointerFree && !input.isPointerFree()) return;
|
||||
const picked = viewer.scene.pick(click.position);
|
||||
const picked = viewer.scene.pick(
|
||||
click.position,
|
||||
CLICK_PICK_WINDOW_PX,
|
||||
CLICK_PICK_WINDOW_PX,
|
||||
);
|
||||
const id = picking?.resolvePickId
|
||||
? picking.resolvePickId(picked)
|
||||
: picked?.id;
|
||||
const route = parts.router.resolve(id);
|
||||
// Not ours: the ground handler decides whether it is the map itself.
|
||||
if (!route?.instance) {
|
||||
if (!route) {
|
||||
parts.openAtGround?.(click.position, picked);
|
||||
return;
|
||||
}
|
||||
const entry = state.providers.get(route.providerId);
|
||||
if (!entry?.on) return;
|
||||
// The position marker is the core's own: nothing to select.
|
||||
if (!route.instance) return;
|
||||
route.instance.handlePick(route.id, {
|
||||
groundPoint: () => groundPointAt(viewer, click.position),
|
||||
});
|
||||
@@ -68,106 +66,6 @@ export function createSelection({ state, parts }) {
|
||||
parts.clearSequences();
|
||||
}
|
||||
|
||||
let hoverQueued = false;
|
||||
let hoverCursor = false;
|
||||
let hoverPosition = null;
|
||||
let hoverLastPickAt = -Infinity;
|
||||
let hoverTimer = null;
|
||||
let pointerButtons = 0;
|
||||
let removeHoverWatchers = null;
|
||||
|
||||
function setHoverCursor(canvas, hit) {
|
||||
if (hit && !hoverCursor) {
|
||||
canvas.style.cursor = 'pointer';
|
||||
hoverCursor = true;
|
||||
} else if (!hit && hoverCursor) {
|
||||
canvas.style.cursor = '';
|
||||
hoverCursor = false;
|
||||
}
|
||||
}
|
||||
|
||||
function pickHover() {
|
||||
hoverQueued = false;
|
||||
const viewer = state.viewer;
|
||||
// Switched off (or torn down) since the frame was queued.
|
||||
if (!viewer || !state.enabled || !state.clickHandler) return;
|
||||
// A drag in progress: the pointer is the camera's.
|
||||
if (pointerButtons !== 0) return;
|
||||
const canvas = viewer.scene?.canvas;
|
||||
if (!canvas || viewer.isDestroyed?.()) return;
|
||||
hoverLastPickAt = Date.now();
|
||||
let hit = false;
|
||||
try {
|
||||
const picked = viewer.scene.pick(hoverPosition);
|
||||
const id = picking?.resolvePickId
|
||||
? picking.resolvePickId(picked)
|
||||
: picked?.id;
|
||||
hit = ownsPick(id);
|
||||
} catch {
|
||||
hit = false;
|
||||
}
|
||||
setHoverCursor(canvas, hit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pointer cursor over anything this layer owns. Throttled; a move inside
|
||||
* the interval is picked when it ends, so the cursor never sticks.
|
||||
*/
|
||||
function onMove(movement) {
|
||||
const viewer = state.viewer;
|
||||
if (!viewer || !state.enabled) return;
|
||||
hoverPosition = movement.endPosition;
|
||||
queueHoverPick();
|
||||
}
|
||||
|
||||
/** Pick `hoverPosition` on a coming frame, within the throttle. */
|
||||
function queueHoverPick() {
|
||||
if (hoverQueued) return;
|
||||
hoverQueued = true;
|
||||
const wait = hoverLastPickAt + HOVER_PICK_INTERVAL_MS - Date.now();
|
||||
if (wait <= 0) {
|
||||
requestAnimationFrame(pickHover);
|
||||
return;
|
||||
}
|
||||
hoverTimer = setTimeout(() => {
|
||||
hoverTimer = null;
|
||||
requestAnimationFrame(pickHover);
|
||||
}, wait);
|
||||
}
|
||||
|
||||
/**
|
||||
* Track held buttons (a drag picks nothing) and re-pick when the camera
|
||||
* rests. Hover keeps running while it moves: a tracking camera never stops.
|
||||
*/
|
||||
function watchHover(viewer) {
|
||||
const canvas = viewer.scene.canvas;
|
||||
const camera = viewer.camera;
|
||||
const onButtons = (event) => {
|
||||
pointerButtons = event.buttons ?? 0;
|
||||
};
|
||||
for (const type of ['pointerdown', 'pointermove', 'pointerup'])
|
||||
canvas.addEventListener?.(type, onButtons);
|
||||
const removeEnd = camera?.moveEnd?.addEventListener(() => {
|
||||
if (hoverPosition) queueHoverPick();
|
||||
});
|
||||
removeHoverWatchers = () => {
|
||||
for (const type of ['pointerdown', 'pointermove', 'pointerup'])
|
||||
canvas.removeEventListener?.(type, onButtons);
|
||||
removeEnd?.();
|
||||
};
|
||||
}
|
||||
|
||||
function unwatchHover() {
|
||||
removeHoverWatchers?.();
|
||||
removeHoverWatchers = null;
|
||||
clearTimeout(hoverTimer);
|
||||
hoverTimer = null;
|
||||
hoverQueued = false;
|
||||
hoverPosition = null;
|
||||
hoverLastPickAt = -Infinity;
|
||||
pointerButtons = 0;
|
||||
}
|
||||
|
||||
function install(viewer) {
|
||||
if (state.clickHandler) return;
|
||||
state.clickHandler = new Cesium.ScreenSpaceEventHandler(
|
||||
@@ -177,29 +75,18 @@ export function createSelection({ state, parts }) {
|
||||
onClick,
|
||||
Cesium.ScreenSpaceEventType.LEFT_CLICK,
|
||||
);
|
||||
state.clickHandler.setInputAction(
|
||||
onMove,
|
||||
Cesium.ScreenSpaceEventType.MOUSE_MOVE,
|
||||
);
|
||||
watchHover(viewer);
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
picking?.registerPickOwner?.(STREET_LEVEL_LAYER_ID, ownsPick);
|
||||
picking?.registerPickOwner?.(STREET_LEVEL_LAYER_ID, parts.router.ownsPick);
|
||||
}
|
||||
|
||||
function uninstall() {
|
||||
// Nothing to undo for a layer that was never enabled.
|
||||
if (!state.clickHandler) return;
|
||||
unwatchHover();
|
||||
if (hoverCursor && state.viewer?.scene?.canvas) {
|
||||
state.viewer.scene.canvas.style.cursor = '';
|
||||
hoverCursor = false;
|
||||
}
|
||||
if (state.clickHandler && !state.clickHandler.isDestroyed())
|
||||
state.clickHandler.destroy();
|
||||
if (!state.clickHandler.isDestroyed()) state.clickHandler.destroy();
|
||||
state.clickHandler = null;
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
picking?.unregisterPickOwner?.(STREET_LEVEL_LAYER_ID);
|
||||
}
|
||||
|
||||
return { install, uninstall, ownsPick };
|
||||
return { install, uninstall, ownsPick: parts.router.ownsPick };
|
||||
}
|
||||
@@ -1,26 +1,12 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test, { mock } from 'node:test';
|
||||
import test from 'node:test';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createSelection } from './selection.js';
|
||||
import { createPickRouter } from './pickRouter.js';
|
||||
|
||||
/**
|
||||
* The selection installed on a stand-in viewer. Animation frames queue until
|
||||
* `frame()` runs them; timers and Date.now() are mocked so a burst of moves
|
||||
* has a known timeline.
|
||||
*/
|
||||
function harness({
|
||||
selected = false,
|
||||
router = null,
|
||||
providers = new Map(),
|
||||
services = {},
|
||||
} = {}) {
|
||||
const saved = {
|
||||
document: globalThis.document,
|
||||
requestAnimationFrame: globalThis.requestAnimationFrame,
|
||||
};
|
||||
mock.timers.enable({ apis: ['setTimeout', 'Date'] });
|
||||
const frames = [];
|
||||
globalThis.requestAnimationFrame = (task) => frames.push(task);
|
||||
/** The selection installed on a stand-in viewer, with what its clicks reach. */
|
||||
function harness({ selected = false, services = {}, providers = [] } = {}) {
|
||||
const saved = globalThis.document;
|
||||
globalThis.document = new EventTarget();
|
||||
// The document's keydown listeners, so a test can hand them any target.
|
||||
const keyListeners = new Set();
|
||||
@@ -38,62 +24,64 @@ function harness({
|
||||
if (type === 'keydown') keyListeners.delete(listener);
|
||||
removeListener(type, listener, options);
|
||||
};
|
||||
const sequences = { selected, cleared: 0 };
|
||||
const sequences = { selected, cleared: 0, selects: [] };
|
||||
const opened = [];
|
||||
const groundClicks = [];
|
||||
const picks = [];
|
||||
/** What the pointer is over: a line this layer owns, or nothing. */
|
||||
/** What the pointer is over: a pick id, or nothing. */
|
||||
const scene = { under: null };
|
||||
const canvas = Object.assign(new EventTarget(), {
|
||||
style: {},
|
||||
// Keep Cesium's handler on the canvas; there is no real document here.
|
||||
disableRootEvents: true,
|
||||
onwheel: null,
|
||||
});
|
||||
const camera = { moveStart: new Cesium.Event(), moveEnd: new Cesium.Event() };
|
||||
const viewer = {
|
||||
scene: {
|
||||
canvas,
|
||||
pick(position) {
|
||||
picks.push(position);
|
||||
return scene.under && { id: scene.under };
|
||||
},
|
||||
},
|
||||
camera,
|
||||
isDestroyed: () => false,
|
||||
scene: { canvas, pick: () => scene.under && { id: scene.under } },
|
||||
};
|
||||
const state = {
|
||||
viewer,
|
||||
enabled: true,
|
||||
clickHandler: null,
|
||||
providers,
|
||||
services: { picking: null, input: null, ...services },
|
||||
providers: new Map([
|
||||
[
|
||||
'mapillary',
|
||||
{
|
||||
def: { id: 'mapillary', pickPrefix: 'mly:' },
|
||||
instance: {
|
||||
handlePick(id) {
|
||||
if (id.startsWith('mly:seq:'))
|
||||
sequences.selects.push(id.slice(8));
|
||||
else if (id.startsWith('mly:img:')) opened.push(id.slice(8));
|
||||
return true;
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
...providers,
|
||||
]),
|
||||
};
|
||||
const selection = createSelection({
|
||||
state,
|
||||
parts: {
|
||||
router: router || {
|
||||
ownsPick: (id) => id === 'mly:seq:1',
|
||||
resolve: () => null,
|
||||
},
|
||||
hasSelectedSequence: () => sequences.selected,
|
||||
openAtGround: (position, picked) =>
|
||||
groundClicks.push({ position, picked }),
|
||||
clearSequences() {
|
||||
sequences.cleared++;
|
||||
sequences.selected = false;
|
||||
},
|
||||
const parts = {
|
||||
router: createPickRouter(() => state.providers.values(), {
|
||||
positionId: 'sl:pos',
|
||||
}),
|
||||
hasSelectedSequence: () => sequences.selected,
|
||||
openAtGround: (position, picked) => groundClicks.push({ position, picked }),
|
||||
clearSequences() {
|
||||
sequences.cleared++;
|
||||
sequences.selected = false;
|
||||
},
|
||||
});
|
||||
};
|
||||
const selection = createSelection({ state, parts });
|
||||
selection.install(viewer);
|
||||
const onMove = state.clickHandler.getInputAction(
|
||||
Cesium.ScreenSpaceEventType.MOUSE_MOVE,
|
||||
);
|
||||
const onClick = state.clickHandler.getInputAction(
|
||||
Cesium.ScreenSpaceEventType.LEFT_CLICK,
|
||||
);
|
||||
const action = (type) =>
|
||||
state.clickHandler.getInputAction(Cesium.ScreenSpaceEventType[type]);
|
||||
return {
|
||||
state,
|
||||
sequences,
|
||||
opened,
|
||||
groundClicks,
|
||||
scene,
|
||||
click: (x = 10) => action('LEFT_CLICK')({ position: { x, y: 10 } }),
|
||||
/** A keydown reaching the document from `target`; the event as handled. */
|
||||
key(key, target, { defaultPrevented = false } = {}) {
|
||||
const event = {
|
||||
@@ -107,114 +95,32 @@ function harness({
|
||||
for (const listener of [...keyListeners]) listener(event);
|
||||
return event;
|
||||
},
|
||||
scene,
|
||||
canvas,
|
||||
camera,
|
||||
picks,
|
||||
move: (x) => onMove({ endPosition: { x, y: 10 } }),
|
||||
click: (x) => onClick({ position: { x, y: 10 } }),
|
||||
groundClicks,
|
||||
frame() {
|
||||
for (const task of frames.splice(0)) task();
|
||||
},
|
||||
/** Let `ms` pass, running the frames that come due on the way. */
|
||||
wait(ms) {
|
||||
this.frame();
|
||||
mock.timers.tick(ms);
|
||||
this.frame();
|
||||
},
|
||||
buttons(type, buttons) {
|
||||
const event = new Event(type);
|
||||
Object.defineProperty(event, 'buttons', { value: buttons });
|
||||
canvas.dispatchEvent(event);
|
||||
},
|
||||
restore() {
|
||||
selection.uninstall();
|
||||
mock.timers.reset();
|
||||
Object.assign(globalThis, saved);
|
||||
globalThis.document = saved;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('hover picks at most every 120 ms through a burst of moves, ending on the last one', () => {
|
||||
test('a click on a line selects its sequence, on a cone opens its image, elsewhere does nothing', () => {
|
||||
const h = harness();
|
||||
try {
|
||||
// One second of a pointer moving at 60 Hz.
|
||||
for (let i = 0; i < 60; i++) {
|
||||
h.move(i);
|
||||
h.wait(16);
|
||||
h.scene.under = 'mly:seq:abc';
|
||||
h.click();
|
||||
assert.deepEqual(h.sequences.selects, ['abc']);
|
||||
h.scene.under = 'mly:img:42';
|
||||
h.click();
|
||||
assert.deepEqual(h.opened, ['42']);
|
||||
for (const under of [null, 'cctv:7', 'sl:pos']) {
|
||||
h.scene.under = under;
|
||||
h.click();
|
||||
}
|
||||
assert.ok(h.picks.length <= 9, `${h.picks.length} picks in ~1 s`);
|
||||
assert.ok(h.picks.length >= 7, `${h.picks.length} picks in ~1 s`);
|
||||
h.wait(200);
|
||||
assert.deepEqual(h.picks.at(-1), { x: 59, y: 10 }, 'the resting position');
|
||||
const settled = h.picks.length;
|
||||
h.wait(1000);
|
||||
assert.equal(h.picks.length, settled, 'a still pointer costs nothing');
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('hover does not pick while a mouse button is held', () => {
|
||||
const h = harness();
|
||||
try {
|
||||
h.buttons('pointerdown', 1);
|
||||
h.move(1);
|
||||
h.wait(200);
|
||||
assert.equal(h.picks.length, 0, 'a drag in progress');
|
||||
h.buttons('pointerup', 0);
|
||||
h.move(2);
|
||||
h.wait(200);
|
||||
assert.equal(h.picks.length, 1);
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('a camera that never stops (orbit, tracking) keeps hover throttled, not off', () => {
|
||||
const h = harness();
|
||||
try {
|
||||
// An orbiting or tracking camera raises moveStart and never moveEnd.
|
||||
h.camera.moveStart.raiseEvent();
|
||||
for (let i = 0; i < 60; i++) {
|
||||
h.move(i);
|
||||
h.wait(16);
|
||||
}
|
||||
assert.ok(h.picks.length >= 7, `${h.picks.length} picks in ~1 s`);
|
||||
assert.ok(h.picks.length <= 9, `${h.picks.length} picks in ~1 s`);
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('when the camera stops, the pointer is picked again so the cursor cannot stick', () => {
|
||||
const h = harness();
|
||||
try {
|
||||
h.scene.under = 'mly:seq:1';
|
||||
h.move(5);
|
||||
h.wait(200);
|
||||
assert.equal(h.canvas.style.cursor, 'pointer', 'over a line');
|
||||
// The camera moves the line out from under a still pointer.
|
||||
h.camera.moveStart.raiseEvent();
|
||||
h.scene.under = null;
|
||||
h.camera.moveEnd.raiseEvent();
|
||||
h.wait(200);
|
||||
assert.equal(h.picks.length, 2, 'one pick when the camera rests');
|
||||
assert.deepEqual(h.picks.at(-1), { x: 5, y: 10 });
|
||||
assert.equal(h.canvas.style.cursor, '', 'the pointer cursor is gone');
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('a hover frame queued before the layer went off does not pick', () => {
|
||||
const h = harness();
|
||||
try {
|
||||
h.move(1);
|
||||
h.state.enabled = false;
|
||||
h.wait(200);
|
||||
assert.equal(h.picks.length, 0);
|
||||
assert.deepEqual([h.sequences.selects, h.opened], [['abc'], ['42']]);
|
||||
// The marker is ours; nothing and another layer's pick go to the ground.
|
||||
assert.deepEqual(
|
||||
h.groundClicks.map(({ picked }) => picked?.id),
|
||||
[undefined, 'cctv:7'],
|
||||
);
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
@@ -274,6 +180,19 @@ test('Esc in a text field or rich-text editor keeps the selection (M61)', () =>
|
||||
}
|
||||
});
|
||||
|
||||
test('a click on the position marker neither selects nor opens at the ground', () => {
|
||||
const h = harness();
|
||||
try {
|
||||
h.scene.under = 'sl:pos';
|
||||
h.click();
|
||||
assert.deepEqual(h.groundClicks, []);
|
||||
assert.deepEqual(h.opened, []);
|
||||
assert.deepEqual(h.sequences.selects, []);
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('a click on nothing of ours goes to the ground handler with the pick, which decides', () => {
|
||||
const h = harness();
|
||||
try {
|
||||
@@ -294,16 +213,19 @@ test('a click on nothing of ours goes to the ground handler with the pick, which
|
||||
|
||||
test('a click on a provider line hands it the ground under the click, measured only if asked', () => {
|
||||
const handled = [];
|
||||
const instance = {
|
||||
handlePick: (id, click) => handled.push({ id, click }),
|
||||
};
|
||||
const groundPicks = [];
|
||||
const h = harness({
|
||||
router: {
|
||||
ownsPick: () => true,
|
||||
resolve: (id) => ({ providerId: 'kartaview', instance, id }),
|
||||
},
|
||||
providers: new Map([['kartaview', { on: true }]]),
|
||||
providers: [
|
||||
[
|
||||
'kartaview',
|
||||
{
|
||||
def: { id: 'kartaview', pickPrefix: 'kv:' },
|
||||
instance: {
|
||||
handlePick: (id, click) => handled.push({ id, click }),
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
services: {
|
||||
scenePick: {
|
||||
pickGroundPosition: (viewer, position) => {
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { imageConeGlyph } from './glyphs.js';
|
||||
import { passesImageryFilter } from './filter.js';
|
||||
import { refineHeights } from './groundCast.js';
|
||||
import { createHorizonCull, metresBetween } from './view.js';
|
||||
import { meshCellKey } from './meshSampler.js';
|
||||
|
||||
/** How many recently viewed sequences keep their image list in memory. */
|
||||
const SEQUENCE_CACHE_SIZE = 40;
|
||||
import { msg } from '../../i18n/index.js';
|
||||
|
||||
/** Cones closer than this to the previous kept one are dropped. */
|
||||
export const IMAGE_CONE_MIN_SPACING_M = 3;
|
||||
@@ -36,8 +32,10 @@ export function thinImages(images, spacingM = IMAGE_CONE_MIN_SPACING_M) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Image cones for one selected sequence, any provider's. A cone's pick id is
|
||||
* `pickPrefix` + the image id; 360° images draw as rings.
|
||||
* Image cones for one selected sequence, any provider's, clamped to the
|
||||
* ground. A cone's pick id is `pickPrefix` + the image id; 360° images draw
|
||||
* as rings. Why a sequence could not load stays in `state.sequence.error`
|
||||
* until it goes.
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {object} options.state Provider state: `context`, `services`, `viewer`, `sequence`.
|
||||
@@ -47,8 +45,8 @@ export function thinImages(images, spacingM = IMAGE_CONE_MIN_SPACING_M) {
|
||||
* @param {string} options.pickPrefix Pick-id prefix of the cones.
|
||||
* @param {string} options.spriteId Sprite-order id of the cone collection.
|
||||
* @param {string} options.renderKey Render-governor reason.
|
||||
* @param {(sequenceId: string, selected: boolean) => void} [options.onHighlight]
|
||||
* Highlight (or stop highlighting) the sequence's coverage line.
|
||||
* @param {(sequenceId: string|null) => void} [options.onHighlight]
|
||||
* Highlight the sequence's coverage line (null: none).
|
||||
* @param {(image: ConeImage, filter: object) => boolean} [options.passes]
|
||||
* Whether the imagery filter keeps a cone.
|
||||
*/
|
||||
@@ -72,14 +70,13 @@ export function createSequenceCones({
|
||||
state.context.notify();
|
||||
}
|
||||
|
||||
const nothingDrawn = () => ({ images: [], cones: [], cells: new Set() });
|
||||
/** Drawn cones, plus the mesh cells under them, so a sample moves only those. */
|
||||
let drawn = nothingDrawn();
|
||||
/** Drawn cone billboards. */
|
||||
let cones = [];
|
||||
|
||||
// The cones skip the depth test: hide the ones behind the globe.
|
||||
const horizon = createHorizonCull({
|
||||
getViewer: () => state.viewer,
|
||||
items: () => drawn.cones.map((cone) => cone.billboard),
|
||||
items: () => cones,
|
||||
onChange: requestRender,
|
||||
});
|
||||
|
||||
@@ -95,58 +92,15 @@ export function createSequenceCones({
|
||||
function clearCones() {
|
||||
state.sequence.collection?.removeAll();
|
||||
state.sequence.images = [];
|
||||
drawn = nothingDrawn();
|
||||
cones = [];
|
||||
horizon.stop();
|
||||
}
|
||||
|
||||
/** Where a cone stands: at `height`, or clamped to the ground when null. */
|
||||
function placement(image, height) {
|
||||
return {
|
||||
position: Cesium.Cartesian3.fromDegrees(
|
||||
image.lon,
|
||||
image.lat,
|
||||
height ?? 0,
|
||||
),
|
||||
heightReference:
|
||||
height === null
|
||||
? Cesium.HeightReference.CLAMP_TO_GROUND
|
||||
: Cesium.HeightReference.NONE,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Terrain-mode cone heights on the street, not on roofs: bare earth refined
|
||||
* by mesh samples. Null when draped or while terrain heights are fetched.
|
||||
*/
|
||||
function coneHeights(images, { request = true } = {}) {
|
||||
const ground = state.context.groundCaster;
|
||||
if (state.context.getSurface?.() !== 'terrain' || !ground) return null;
|
||||
const dems = images.map((image) => ground.groundAt(image.lon, image.lat));
|
||||
if (dems.includes(null)) {
|
||||
ground
|
||||
.prepare(images.map((image) => [image.lon, image.lat]))
|
||||
.then((ready) => {
|
||||
if (ready && state.sequence.images === images) renderCones(images);
|
||||
});
|
||||
return null;
|
||||
}
|
||||
const sampler = state.context.meshSampler;
|
||||
if (request)
|
||||
sampler?.request(images.map((image) => [image.lon, image.lat]));
|
||||
return refineHeights(
|
||||
images.map((image, i) => ({
|
||||
dem: dems[i],
|
||||
mesh: sampler?.meshAt(image.lon, image.lat),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
function renderCones(images) {
|
||||
const collection = state.sequence.collection;
|
||||
if (!collection) return;
|
||||
collection.removeAll();
|
||||
drawn = { ...nothingDrawn(), images };
|
||||
const heights = coneHeights(images);
|
||||
cones = [];
|
||||
// One colour per source; 360° images keep the ring shape.
|
||||
const cone = imageConeGlyph({ size: 32, color: colors.coverage });
|
||||
const ring = imageConeGlyph({
|
||||
@@ -156,12 +110,12 @@ export function createSequenceCones({
|
||||
});
|
||||
// Resolved now, so a "since N days" window keeps up with the clock.
|
||||
const filter = state.context.getFilter();
|
||||
images.forEach((image, index) => {
|
||||
if (!passes(image, filter)) return;
|
||||
const height = heights?.[index] ?? null;
|
||||
for (const image of images) {
|
||||
if (!passes(image, filter)) continue;
|
||||
const billboard = collection.add({
|
||||
id: `${pickPrefix}${image.id}`,
|
||||
...placement(image, height),
|
||||
position: Cesium.Cartesian3.fromDegrees(image.lon, image.lat),
|
||||
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
|
||||
image: image.isPano ? ring : cone,
|
||||
imageId: image.isPano ? `${spriteId}:pano` : `${spriteId}:flat`,
|
||||
width: IMAGE_CONE_SIZE_PX,
|
||||
@@ -172,52 +126,12 @@ export function createSequenceCones({
|
||||
verticalOrigin: Cesium.VerticalOrigin.CENTER,
|
||||
scaleByDistance: new Cesium.NearFarScalar(200, 1.1, 6000, 0.35),
|
||||
});
|
||||
drawn.cones.push({ index, billboard, height });
|
||||
drawn.cells.add(meshCellKey(image.lon, image.lat));
|
||||
});
|
||||
cones.push(billboard);
|
||||
}
|
||||
horizon.update();
|
||||
requestRender();
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-place cones whose height new mesh samples changed. A sample can move
|
||||
* a neighbour too, so every height is recomputed but only moved cones touched.
|
||||
*/
|
||||
function onMeshSampled(batch) {
|
||||
if (state.context.getSurface?.() !== 'terrain' || !drawn.cones.length)
|
||||
return;
|
||||
if (!batch.some(([lon, lat]) => drawn.cells.has(meshCellKey(lon, lat))))
|
||||
return;
|
||||
const { images, cones } = drawn;
|
||||
// Already asked for when drawn; a missing terrain cell redraws them all.
|
||||
const heights = coneHeights(images, { request: false });
|
||||
if (!heights) return;
|
||||
const moved = [];
|
||||
for (const cone of cones) {
|
||||
const height = heights[cone.index] ?? null;
|
||||
if (height === cone.height) continue;
|
||||
cone.height = height;
|
||||
const { position, heightReference } = placement(
|
||||
images[cone.index],
|
||||
height,
|
||||
);
|
||||
cone.billboard.heightReference = heightReference;
|
||||
cone.billboard.position = position;
|
||||
moved.push(cone.billboard);
|
||||
}
|
||||
if (!moved.length) return;
|
||||
horizon.update(moved);
|
||||
requestRender();
|
||||
}
|
||||
|
||||
function remember(sequenceId, images) {
|
||||
const { cache } = state.sequence;
|
||||
cache.delete(sequenceId);
|
||||
cache.set(sequenceId, images);
|
||||
while (cache.size > SEQUENCE_CACHE_SIZE)
|
||||
cache.delete(cache.keys().next().value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a sequence: highlight its line and load its image cones. `force`
|
||||
* reloads a selected one (a provider that loads part of a sequence moved on).
|
||||
@@ -231,21 +145,9 @@ export function createSequenceCones({
|
||||
(state.sequence.images.length || state.sequence.abort)
|
||||
)
|
||||
return;
|
||||
if (state.sequence.selectedId && state.sequence.selectedId !== sequenceId)
|
||||
onHighlight(state.sequence.selectedId, false);
|
||||
state.sequence.abort?.abort();
|
||||
state.sequence.selectedId = sequenceId;
|
||||
onHighlight(sequenceId, true);
|
||||
const cached = force ? null : state.sequence.cache.get(sequenceId);
|
||||
if (cached) {
|
||||
state.sequence.abort = null;
|
||||
state.sequence.loading = false;
|
||||
state.sequence.images = cached;
|
||||
renderCones(cached);
|
||||
state.context.actions.reportError(null);
|
||||
notify();
|
||||
return;
|
||||
}
|
||||
onHighlight(sequenceId);
|
||||
// The previous sequence's cones must not stay clickable under the new
|
||||
// highlight while this one loads, nor after its load fails.
|
||||
clearCones();
|
||||
@@ -253,24 +155,24 @@ export function createSequenceCones({
|
||||
const controller = new AbortController();
|
||||
state.sequence.abort = controller;
|
||||
state.sequence.loading = true;
|
||||
// An earlier sequence's error does not belong to this one.
|
||||
state.sequence.error = null;
|
||||
notify();
|
||||
try {
|
||||
const images = await loadImages(sequenceId, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (controller.signal.aborted) return;
|
||||
remember(sequenceId, images);
|
||||
state.sequence.images = images;
|
||||
renderCones(images);
|
||||
state.context.actions.reportError(null);
|
||||
state.sequence.error = null;
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted) {
|
||||
// Nothing to show: drop the highlight, so a click asks again.
|
||||
onHighlight(sequenceId, false);
|
||||
onHighlight(null);
|
||||
state.sequence.selectedId = null;
|
||||
state.context.actions.reportError(
|
||||
error?.message || 'Sequence images unavailable',
|
||||
);
|
||||
state.sequence.error =
|
||||
error?.message || msg('Sequence images unavailable');
|
||||
}
|
||||
} finally {
|
||||
if (state.sequence.abort === controller) {
|
||||
@@ -284,13 +186,12 @@ export function createSequenceCones({
|
||||
function clearSelection() {
|
||||
state.sequence.abort?.abort();
|
||||
state.sequence.abort = null;
|
||||
if (state.sequence.selectedId)
|
||||
onHighlight(state.sequence.selectedId, false);
|
||||
if (state.sequence.selectedId) onHighlight(null);
|
||||
state.sequence.selectedId = null;
|
||||
state.sequence.loading = false;
|
||||
clearCones();
|
||||
// An error about the sequence goes with it.
|
||||
state.context.actions.reportError(null);
|
||||
state.sequence.error = null;
|
||||
requestRender();
|
||||
notify();
|
||||
}
|
||||
@@ -300,9 +201,6 @@ export function createSequenceCones({
|
||||
if (state.sequence.images.length) renderCones(state.sequence.images);
|
||||
}
|
||||
|
||||
// New mesh samples may move the cones onto (or off) the road surface.
|
||||
const stopMeshListener = state.context.meshSampler?.onSampled(onMeshSampled);
|
||||
|
||||
function setVisible(visible) {
|
||||
if (state.sequence.collection) state.sequence.collection.show = visible;
|
||||
requestRender();
|
||||
@@ -311,8 +209,7 @@ export function createSequenceCones({
|
||||
function destroy(viewer) {
|
||||
state.sequence.abort?.abort();
|
||||
horizon.stop();
|
||||
drawn = nothingDrawn();
|
||||
stopMeshListener?.();
|
||||
cones = [];
|
||||
const collection = state.sequence.collection;
|
||||
if (collection) {
|
||||
sprites?.unregisterSpriteCollection?.(spriteId, collection);
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import * as Cesium from 'cesium';
|
||||
import { resolveFilter } from './filter.js';
|
||||
import { createSequenceCones, thinImages } from './sequenceCones.js';
|
||||
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
/** A billboard collection that remembers the cones on the globe. */
|
||||
function cones() {
|
||||
const items = [];
|
||||
return {
|
||||
items,
|
||||
show: true,
|
||||
add(options) {
|
||||
items.push(options);
|
||||
return options;
|
||||
},
|
||||
removeAll() {
|
||||
items.length = 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A source whose sequence lookups the test answers, one call at a time. */
|
||||
function deferredSource() {
|
||||
const calls = [];
|
||||
return {
|
||||
calls,
|
||||
getSequenceImages(sequenceId, { signal } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
calls.push({ sequenceId, signal, resolve, reject });
|
||||
});
|
||||
},
|
||||
/** The latest lookup for a sequence. */
|
||||
last(sequenceId) {
|
||||
return calls.filter((call) => call.sequenceId === sequenceId).at(-1);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Graph image records for a sequence, a few metres apart. */
|
||||
function records(sequenceId, count = 2) {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
id: `${sequenceId}-${i}`,
|
||||
geometry: { coordinates: [-121.49 + i * 0.001, 38.58] },
|
||||
compass_angle: 90,
|
||||
captured_at: 1_700_000_000_000 + i,
|
||||
}));
|
||||
}
|
||||
|
||||
function setup() {
|
||||
const source = deferredSource();
|
||||
/** Every highlight asked of the coverage: a sequence id, or null for none. */
|
||||
const highlights = [];
|
||||
const state = {
|
||||
services: {},
|
||||
viewer: {},
|
||||
filter: { pano: 'all', sinceDays: 0 },
|
||||
context: {
|
||||
getFilter: () => resolveFilter(state.filter),
|
||||
notify() {},
|
||||
},
|
||||
sequence: {
|
||||
selectedId: null,
|
||||
images: [],
|
||||
collection: cones(),
|
||||
loading: false,
|
||||
error: null,
|
||||
abort: null,
|
||||
},
|
||||
};
|
||||
const sequences = createSequenceCones({
|
||||
state,
|
||||
async loadImages(sequenceId, { signal }) {
|
||||
const list = await source.getSequenceImages(sequenceId, { signal });
|
||||
return thinImages(
|
||||
list.map((record) => ({
|
||||
id: String(record.id),
|
||||
lon: record.geometry.coordinates[0],
|
||||
lat: record.geometry.coordinates[1],
|
||||
compassAngle: Number(record.compass_angle) || 0,
|
||||
capturedAt: Number(record.captured_at) || 0,
|
||||
isPano: record.is_pano === true,
|
||||
})),
|
||||
);
|
||||
},
|
||||
colors: { coverage: '#05cb63' },
|
||||
pickPrefix: 'mly:img:',
|
||||
spriteId: 'street-level:test-cones',
|
||||
renderKey: 'test-sequence',
|
||||
onHighlight: (id) => highlights.push(id),
|
||||
});
|
||||
const drawn = () =>
|
||||
state.sequence.collection.items.map((cone) => cone.id.split(':').at(-1));
|
||||
return { state, source, sequences, highlights, drawn };
|
||||
}
|
||||
|
||||
test('a failed sequence load leaves no stale cones and can be retried', async () => {
|
||||
const { state, source, sequences, highlights, drawn } = setup();
|
||||
sequences.select('A');
|
||||
source.last('A').resolve(records('A'));
|
||||
await settle();
|
||||
assert.deepEqual(drawn(), ['A-0', 'A-1']);
|
||||
|
||||
sequences.select('B');
|
||||
assert.deepEqual(drawn(), [], "A's cones go while B loads");
|
||||
source.last('B').reject(new Error('Sequence images unavailable'));
|
||||
await settle();
|
||||
assert.deepEqual(drawn(), [], 'nothing of A is left to click');
|
||||
assert.equal(state.sequence.loading, false);
|
||||
assert.equal(state.sequence.selectedId, null, 'B is not left highlighted');
|
||||
assert.equal(highlights.at(-1), null);
|
||||
assert.equal(state.sequence.error, 'Sequence images unavailable');
|
||||
|
||||
// Clicking B again asks again, without the old error.
|
||||
sequences.select('B');
|
||||
assert.equal(source.calls.filter((c) => c.sequenceId === 'B').length, 2);
|
||||
assert.equal(state.sequence.error, null, 'not shown while B loads again');
|
||||
source.last('B').resolve(records('B'));
|
||||
await settle();
|
||||
assert.deepEqual(drawn(), ['B-0', 'B-1']);
|
||||
assert.equal(state.sequence.selectedId, 'B');
|
||||
assert.equal(state.sequence.error, null, 'cleared once B loads');
|
||||
});
|
||||
|
||||
test('a late answer for a superseded sequence draws nothing', async () => {
|
||||
const { state, source, sequences, drawn } = setup();
|
||||
sequences.select('A');
|
||||
sequences.select('B');
|
||||
assert.equal(source.last('A').signal.aborted, true, 'A was cancelled');
|
||||
source.last('B').resolve(records('B'));
|
||||
await settle();
|
||||
source.last('A').resolve(records('A'));
|
||||
await settle();
|
||||
assert.deepEqual(drawn(), ['B-0', 'B-1']);
|
||||
assert.equal(state.sequence.selectedId, 'B');
|
||||
assert.equal(state.sequence.loading, false);
|
||||
});
|
||||
|
||||
test('a second click on a loading sequence does not start another lookup', async () => {
|
||||
const { source, sequences } = setup();
|
||||
sequences.select('A');
|
||||
sequences.select('A');
|
||||
assert.equal(source.calls.length, 1);
|
||||
assert.equal(source.calls[0].signal.aborted, false);
|
||||
});
|
||||
|
||||
test('clearSelection cancels the lookup and drops the highlight', async () => {
|
||||
const { state, source, sequences, highlights, drawn } = setup();
|
||||
sequences.select('A');
|
||||
sequences.clearSelection();
|
||||
assert.equal(source.last('A').signal.aborted, true);
|
||||
assert.deepEqual(highlights, ['A', null]);
|
||||
assert.equal(state.sequence.selectedId, null);
|
||||
assert.equal(state.sequence.loading, false);
|
||||
assert.equal(state.sequence.error, null, 'an error about it goes too');
|
||||
source.last('A').resolve(records('A'));
|
||||
await settle();
|
||||
assert.deepEqual(drawn(), [], 'the late answer draws nothing');
|
||||
});
|
||||
|
||||
test('cones are clamped to the ground and turned to their compass angle', async () => {
|
||||
const { state, source, sequences } = setup();
|
||||
sequences.select('A');
|
||||
source.last('A').resolve(records('A', 2));
|
||||
await settle();
|
||||
const items = state.sequence.collection.items;
|
||||
assert.equal(items.length, 2);
|
||||
for (const cone of items) {
|
||||
assert.equal(cone.heightReference, Cesium.HeightReference.CLAMP_TO_GROUND);
|
||||
assert.equal(cone.rotation, -Cesium.Math.toRadians(90));
|
||||
}
|
||||
});
|
||||
|
||||
test('cones behind the globe are hidden, and show again from the other side', async () => {
|
||||
const { state, source, sequences } = setup();
|
||||
const view = { lon: -121.49, lat: 38.58, height: 20_000_000 };
|
||||
const preRender = new Set();
|
||||
state.viewer = {
|
||||
camera: {
|
||||
get positionWC() {
|
||||
return Cesium.Cartesian3.fromDegrees(view.lon, view.lat, view.height);
|
||||
},
|
||||
},
|
||||
scene: {
|
||||
preRender: {
|
||||
addEventListener(listener) {
|
||||
preRender.add(listener);
|
||||
return () => preRender.delete(listener);
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
sequences.select('A');
|
||||
// One image under the camera, one near its antipode.
|
||||
source.last('A').resolve([
|
||||
...records('A', 1),
|
||||
{
|
||||
id: 'A-far',
|
||||
geometry: { coordinates: [58.51, -38.58] },
|
||||
captured_at: 1_800_000_000_000,
|
||||
},
|
||||
]);
|
||||
await settle();
|
||||
const [near, far] = state.sequence.collection.items;
|
||||
assert.equal(near.show, true, 'the near side stays visible');
|
||||
assert.equal(far.show, false, 'the far side does not show through');
|
||||
// Fly round to the other hemisphere: the two swap.
|
||||
view.lon = 58.51;
|
||||
view.lat = -38.58;
|
||||
for (const listener of [...preRender]) listener();
|
||||
assert.equal(near.show, false);
|
||||
assert.equal(far.show, true);
|
||||
sequences.clearSelection();
|
||||
assert.equal(preRender.size, 0, 'the cull listener is gone');
|
||||
});
|
||||
@@ -2,12 +2,11 @@ import { FILTER_DEFAULT } from './policy.js';
|
||||
|
||||
/**
|
||||
* The per-image fields of `state.street` with no image open; closing resets
|
||||
* exactly these (host, render mode and follow availability outlive an image).
|
||||
* exactly these (host and render mode outlive an image).
|
||||
*/
|
||||
export function freshStreet() {
|
||||
return {
|
||||
open: false,
|
||||
follow: false,
|
||||
providerId: null,
|
||||
providerName: null,
|
||||
providerLabel: null,
|
||||
@@ -39,19 +38,15 @@ export function createState({ services }) {
|
||||
services,
|
||||
viewer: null,
|
||||
enabled: false,
|
||||
initialized: false,
|
||||
destroyed: false,
|
||||
listeners: new Set(),
|
||||
notify: null,
|
||||
/** Shared imagery filter, in the stored (relative-days) form. */
|
||||
filter: { ...FILTER_DEFAULT },
|
||||
/** @type {Map<string, {def: object, instance: object, on: boolean}>} */
|
||||
/** @type {Map<string, {def: object, instance: object, on: boolean, status: object|null}>} */
|
||||
providers: new Map(),
|
||||
|
||||
street: {
|
||||
host: null,
|
||||
/** Whether the active map stack allows following (Google 3D only). */
|
||||
followAvailable: false,
|
||||
/** 'letterbox' shows the whole image; 'fill' crops it to the frame. */
|
||||
renderMode: 'letterbox',
|
||||
...freshStreet(),
|
||||
@@ -60,9 +55,6 @@ export function createState({ services }) {
|
||||
/** Whether the camera is low enough for a ground click to open an image. */
|
||||
groundClickReady: false,
|
||||
|
||||
/** 'terrain' on Google 3D at street zoom (overlays on the bare earth), else 'draped'. */
|
||||
surface: 'draped',
|
||||
|
||||
marker: { collection: null, billboard: null, look: null },
|
||||
clickHandler: null,
|
||||
};
|
||||
|
||||
@@ -2,30 +2,17 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { tilesForBbox } from './tileMath.js';
|
||||
|
||||
test('reachable polar dateline coverage ranks 24 distinct tiles before overview truncation', () => {
|
||||
// coverage uses the actual view bbox above zoom 1; polar views can wrap.
|
||||
const bbox = [-10, 60, -20, 85];
|
||||
const result = tilesForBbox(bbox, 3);
|
||||
const keys = result.tiles.map((tile) => `${tile.x}/${tile.y}/${tile.z}`);
|
||||
const keysOf = (tiles) => tiles.map((tile) => `${tile.x}/${tile.y}/${tile.z}`);
|
||||
|
||||
test('a polar view across the date line ranks 24 distinct tiles', () => {
|
||||
// The halves of [-10, 60, -20, 85] share a column at z3.
|
||||
const keys = keysOf(tilesForBbox([-10, 60, -20, 85], 3));
|
||||
assert.equal(keys.length, 24);
|
||||
assert.equal(new Set(keys).size, 24);
|
||||
assert.equal(result.total, 24);
|
||||
assert.equal(result.truncated, false);
|
||||
const overview = tilesForBbox(bbox, 3, { limit: 16 });
|
||||
assert.equal(overview.total, 24);
|
||||
assert.equal(overview.tiles.length, 16);
|
||||
assert.equal(
|
||||
new Set(overview.tiles.map((tile) => `${tile.x}/${tile.y}/${tile.z}`)).size,
|
||||
16,
|
||||
);
|
||||
assert.equal(overview.truncated, true);
|
||||
});
|
||||
|
||||
test('a limit that fits every distinct tile is not spent on repeats', () => {
|
||||
// Before de-duplication this box yielded 27 entries for 24 tiles, so a
|
||||
// 24-slot overview held repeats and still reported itself truncated.
|
||||
const result = tilesForBbox([-10, 60, -20, 85], 3, { limit: 24 });
|
||||
const keys = result.tiles.map((tile) => `${tile.x}/${tile.y}/${tile.z}`);
|
||||
assert.equal(new Set(keys).size, 24);
|
||||
assert.equal(result.truncated, false);
|
||||
test('a limit is not spent on repeats', () => {
|
||||
const keys = keysOf(tilesForBbox([-10, 60, -20, 85], 3, { limit: 16 }));
|
||||
assert.equal(keys.length, 16);
|
||||
assert.equal(new Set(keys).size, 16);
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Web-Mercator tile math, without Cesium, shared by the browser layer and the server executor. */
|
||||
/** Web-Mercator tile math for Street Level coverage, without Cesium. */
|
||||
|
||||
const MAX_LAT = 85.05112878;
|
||||
|
||||
@@ -58,15 +58,10 @@ export function tileBounds(x, y, z) {
|
||||
};
|
||||
}
|
||||
|
||||
/** A [west, south, east, north] array or object as finite, ordered bounds; null if unusable. */
|
||||
/** A [west, south, east, north] array as finite, ordered bounds; null if unusable. */
|
||||
export function normalizeBbox(input) {
|
||||
const values = Array.isArray(input)
|
||||
? input
|
||||
: input && typeof input === 'object'
|
||||
? [input.west, input.south, input.east, input.north]
|
||||
: null;
|
||||
if (!values || values.length !== 4) return null;
|
||||
const [w, s, e, n] = values.map(Number);
|
||||
if (!Array.isArray(input) || input.length !== 4) return null;
|
||||
const [w, s, e, n] = input.map(Number);
|
||||
if (![w, s, e, n].every(Number.isFinite)) return null;
|
||||
const west = Math.max(-180, Math.min(w, e));
|
||||
const east = Math.min(180, Math.max(w, e));
|
||||
@@ -77,97 +72,55 @@ export function normalizeBbox(input) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Squared distance (in tiles) from tile (x, y)'s centre to the segment
|
||||
* `from` → `to` at zoom z, measured the short way round the date line.
|
||||
* Tiles at zoom z covering a [west, south, east, north] box (west > east
|
||||
* crosses the date line), nearest to `from` first (default: the box centre),
|
||||
* capped at `limit`. Distances are measured the short way round ±180°.
|
||||
* @param {{limit?: number, from?: {lon: number, lat: number}|null}} [options]
|
||||
* @returns {Array<{x:number,y:number,z:number}>}
|
||||
*/
|
||||
function segmentDistance2(tile, from, to, z) {
|
||||
const n = 2 ** z;
|
||||
const px = tile.x + 0.5;
|
||||
const py = tile.y + 0.5;
|
||||
const ax = tileXAt(from.lon, z);
|
||||
const ay = tileYAt(from.lat, z);
|
||||
let bx = to ? tileXAt(to.lon, z) : ax;
|
||||
const by = to ? tileYAt(to.lat, z) : ay;
|
||||
if (bx - ax > n / 2) bx -= n;
|
||||
else if (ax - bx > n / 2) bx += n;
|
||||
let best = Infinity;
|
||||
for (const shift of [-n, 0, n]) {
|
||||
const x = px + shift;
|
||||
const vx = bx - ax;
|
||||
const vy = by - ay;
|
||||
const len2 = vx * vx + vy * vy;
|
||||
const t = len2
|
||||
? Math.max(0, Math.min(1, ((x - ax) * vx + (py - ay) * vy) / len2))
|
||||
: 0;
|
||||
const dx = x - (ax + t * vx);
|
||||
const dy = py - (ay + t * vy);
|
||||
best = Math.min(best, dx * dx + dy * dy);
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tiles at zoom z covering a bbox (west > east crosses the date line), from
|
||||
* the centre outwards, capped at `limit`. With `focus`, tiles rank by distance
|
||||
* from the segment `from` (ground under the camera) to `to` (screen centre):
|
||||
* a tilted view's box centre can sit kilometres ahead of both.
|
||||
* @param {{limit?: number, focus?: {from: {lon: number, lat: number}, to?: {lon: number, lat: number}|null}|null}} [options]
|
||||
* @returns {{tiles: Array<{x:number,y:number,z:number}>, truncated: boolean, total: number}}
|
||||
*/
|
||||
export function tilesForBbox(bbox, z, { limit = Infinity, focus = null } = {}) {
|
||||
const n = 2 ** z;
|
||||
const tiles = [];
|
||||
let centre;
|
||||
if (Array.isArray(bbox) && Number(bbox[0]) > Number(bbox[2])) {
|
||||
// A box across the date line comes as west > east: split it at ±180°.
|
||||
const [west, south, east, north] = bbox.map(Number);
|
||||
for (const half of [
|
||||
[west, south, 180, north],
|
||||
[-180, south, east, north],
|
||||
]) {
|
||||
const box = normalizeBbox(half);
|
||||
if (box) tiles.push(...tileGrid(box, z).tiles);
|
||||
}
|
||||
centre = {
|
||||
x: ((lonToTileX(west, z) + lonToTileX(east, z) + n) / 2) % n,
|
||||
y: (latToTileY(north, z) + latToTileY(south, z)) / 2,
|
||||
};
|
||||
} else {
|
||||
const box = normalizeBbox(bbox);
|
||||
if (!box) return { tiles: [], truncated: false, total: 0 };
|
||||
const grid = tileGrid(box, z);
|
||||
tiles.push(...grid.tiles);
|
||||
centre = { x: (grid.x0 + grid.x1) / 2, y: (grid.y0 + grid.y1) / 2 };
|
||||
}
|
||||
const distance2 = focus?.from
|
||||
? (tile) => segmentDistance2(tile, focus.from, focus.to, z)
|
||||
: (tile) => {
|
||||
// Measured the short way round the date line.
|
||||
const dx = Math.abs(tile.x - centre.x);
|
||||
return Math.min(dx, n - dx) ** 2 + (tile.y - centre.y) ** 2;
|
||||
};
|
||||
// The date-line halves share a column when the gap between east and west is
|
||||
// narrower than a tile; count that column once so a capped overview does not
|
||||
// spend its slots on repeats. Each tile's distance is computed once; ties keep
|
||||
// their row order.
|
||||
const unique = [
|
||||
export function tilesForBbox(bbox, z, { limit = Infinity, from = null } = {}) {
|
||||
const [west, south, east, north] = Array.isArray(bbox)
|
||||
? bbox.map(Number)
|
||||
: [];
|
||||
const across = west > east;
|
||||
// A box across the date line is split at ±180°.
|
||||
const halves = across
|
||||
? [
|
||||
[west, south, 180, north],
|
||||
[-180, south, east, north],
|
||||
]
|
||||
: [bbox];
|
||||
// The halves share a column when the gap between east and west is
|
||||
// narrower than a tile: count it once, so a capped set holds no repeats.
|
||||
const grid = [
|
||||
...new Map(
|
||||
tiles.map((tile) => [`${tile.x}/${tile.y}/${tile.z}`, tile]),
|
||||
halves
|
||||
.map(normalizeBbox)
|
||||
.filter(Boolean)
|
||||
.flatMap((box) => tileGrid(box, z))
|
||||
.map((tile) => [`${tile.x}/${tile.y}`, tile]),
|
||||
).values(),
|
||||
];
|
||||
const ranked = unique
|
||||
.map((tile) => ({ tile, d: distance2(tile) }))
|
||||
const n = 2 ** z;
|
||||
const centre = from || {
|
||||
lon: wrapLon(west + (east - west + (across ? 360 : 0)) / 2),
|
||||
lat: (south + north) / 2,
|
||||
};
|
||||
const cx = tileXAt(wrapLon(centre.lon), z);
|
||||
const cy = tileYAt(centre.lat, z);
|
||||
// Each tile's distance is computed once; ties keep their row order.
|
||||
const ranked = grid
|
||||
.map((tile) => {
|
||||
const dx = Math.abs(tile.x + 0.5 - cx);
|
||||
const d = Math.min(dx, n - dx) ** 2 + (tile.y + 0.5 - cy) ** 2;
|
||||
return { tile, d };
|
||||
})
|
||||
.sort((a, b) => a.d - b.d)
|
||||
.map(({ tile }) => tile);
|
||||
const truncated = ranked.length > limit;
|
||||
return {
|
||||
tiles: truncated ? ranked.slice(0, limit) : ranked,
|
||||
truncated,
|
||||
total: ranked.length,
|
||||
};
|
||||
return ranked.slice(0, limit);
|
||||
}
|
||||
|
||||
/** The tiles at zoom z in a normalized box, row by row, and their range. */
|
||||
/** The tiles at zoom z in a normalized box, row by row. */
|
||||
function tileGrid(box, z) {
|
||||
const x0 = lonToTileX(box.west, z);
|
||||
const x1 = lonToTileX(box.east, z);
|
||||
@@ -176,7 +129,7 @@ function tileGrid(box, z) {
|
||||
const tiles = [];
|
||||
for (let y = y0; y <= y1; y++)
|
||||
for (let x = x0; x <= x1; x++) tiles.push({ x, y, z });
|
||||
return { tiles, x0, x1, y0, y1 };
|
||||
return tiles;
|
||||
}
|
||||
|
||||
/** A vector-tile-local coordinate (0..extent) in tile (x, y, z) as [lon, lat]. */
|
||||
@@ -198,17 +151,3 @@ export function coverageZoomForHeight(heightM) {
|
||||
if (heightM > 1_800) return 13;
|
||||
return 14;
|
||||
}
|
||||
|
||||
/**
|
||||
* Overview-point zoom (z0–5) above 60 km, where sequence tiles are too heavy;
|
||||
* null below, where `coverageZoomForHeight` takes over.
|
||||
*/
|
||||
export function overviewZoomForHeight(heightM) {
|
||||
if (!Number.isFinite(heightM) || heightM <= 60_000) return null;
|
||||
if (heightM > 15_000_000) return 0;
|
||||
if (heightM > 7_000_000) return 1;
|
||||
if (heightM > 3_000_000) return 2;
|
||||
if (heightM > 1_200_000) return 3;
|
||||
if (heightM > 400_000) return 4;
|
||||
return 5;
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
coverageZoomForHeight,
|
||||
overviewZoomForHeight,
|
||||
latToTileY,
|
||||
lonToTileX,
|
||||
normalizeBbox,
|
||||
@@ -38,31 +37,21 @@ test('normalizeBbox orders and clamps coordinates', () => {
|
||||
north: 38.69,
|
||||
});
|
||||
assert.equal(normalizeBbox([0, 0, 0, 1]), null);
|
||||
assert.equal(normalizeBbox('nope'), null);
|
||||
assert.equal(normalizeBbox([1, 2, 3]), null);
|
||||
assert.equal(normalizeBbox([1, 2, Number.NaN, 3]), null);
|
||||
});
|
||||
|
||||
test('city-scale boxes span many z14 tiles', () => {
|
||||
const sacramento = [-121.56, 38.44, -121.36, 38.69];
|
||||
assert.equal(tilesForBbox(sacramento, 14).total, 160);
|
||||
const detroit = [-83.29, 42.25, -82.91, 42.45];
|
||||
assert.equal(tilesForBbox(detroit, 14).total, 234);
|
||||
});
|
||||
|
||||
test('tilesForBbox orders from the centre outwards and honours the cap', () => {
|
||||
const result = tilesForBbox([-121.56, 38.44, -121.36, 38.69], 14, {
|
||||
const tiles = tilesForBbox([-121.56, 38.44, -121.36, 38.69], 14, {
|
||||
limit: 5,
|
||||
});
|
||||
assert.equal(result.total, 160);
|
||||
assert.equal(result.truncated, true);
|
||||
assert.equal(result.tiles.length, 5);
|
||||
assert.equal(tiles.length, 5);
|
||||
const centreX = lonToTileX(-121.46, 14);
|
||||
const centreY = latToTileY(38.565, 14);
|
||||
assert.ok(Math.abs(result.tiles[0].x - centreX) <= 1);
|
||||
assert.ok(Math.abs(result.tiles[0].y - centreY) <= 1);
|
||||
assert.ok(Math.abs(tiles[0].x - centreX) <= 1);
|
||||
assert.ok(Math.abs(tiles[0].y - centreY) <= 1);
|
||||
const full = tilesForBbox([-121.56, 38.44, -121.36, 38.69], 14);
|
||||
assert.equal(full.truncated, false);
|
||||
assert.equal(full.tiles.length, 160);
|
||||
assert.equal(full.length, 160);
|
||||
});
|
||||
|
||||
test('coverageZoomForHeight steps from coarse to z14 as the camera descends', () => {
|
||||
@@ -74,28 +63,18 @@ test('coverageZoomForHeight steps from coarse to z14 as the camera descends', ()
|
||||
assert.equal(coverageZoomForHeight(Number.NaN), null);
|
||||
});
|
||||
|
||||
test('overviewZoomForHeight covers the globe above the sequence ceiling', () => {
|
||||
assert.equal(overviewZoomForHeight(30_000), null);
|
||||
assert.equal(overviewZoomForHeight(100_000), 5);
|
||||
assert.equal(overviewZoomForHeight(900_000), 4);
|
||||
assert.equal(overviewZoomForHeight(2_000_000), 3);
|
||||
assert.equal(overviewZoomForHeight(5_000_000), 2);
|
||||
assert.equal(overviewZoomForHeight(10_000_000), 1);
|
||||
assert.equal(overviewZoomForHeight(25_000_000), 0);
|
||||
});
|
||||
|
||||
test('longitude 180 is the last column, so a world view keeps the eastern hemisphere', () => {
|
||||
assert.equal(lonToTileX(180, 1), 1);
|
||||
assert.equal(lonToTileX(179.99, 1), 1);
|
||||
assert.equal(lonToTileX(-180, 1), 0);
|
||||
const columns = new Set(
|
||||
tilesForBbox([-180, -85, 180, 85], 1).tiles.map((tile) => tile.x),
|
||||
tilesForBbox([-180, -85, 180, 85], 1).map((tile) => tile.x),
|
||||
);
|
||||
assert.deepEqual([...columns].sort(), [0, 1]);
|
||||
});
|
||||
|
||||
test('a box across the date line takes tiles on both sides of it, not the far side of the globe', () => {
|
||||
const { tiles } = tilesForBbox([179, -1, -179, 1], 8);
|
||||
const tiles = tilesForBbox([179, -1, -179, 1], 8);
|
||||
const columns = new Set(tiles.map((tile) => tile.x));
|
||||
assert.ok(columns.has(255), 'west of the line');
|
||||
assert.ok(columns.has(0), 'east of the line');
|
||||
@@ -104,70 +83,49 @@ test('a box across the date line takes tiles on both sides of it, not the far si
|
||||
'nothing from the middle of the map',
|
||||
);
|
||||
// The limit keeps the tiles nearest the line.
|
||||
const limited = tilesForBbox([170, -1, -170, 1], 8, { limit: 4 }).tiles;
|
||||
const limited = tilesForBbox([170, -1, -170, 1], 8, { limit: 4 });
|
||||
assert.ok(limited.every((tile) => tile.x <= 8 || tile.x >= 247));
|
||||
});
|
||||
|
||||
test('a box across the date line ranks the tiles at the line first, without and with a focus', () => {
|
||||
test('a box across the date line ranks from the line, or from the camera across it', () => {
|
||||
const z = 14;
|
||||
const n = 2 ** z;
|
||||
const bbox = [179.9, 0, -179.9, 0.1];
|
||||
const key = (t) => `${t.x}/${t.y}`;
|
||||
const keys = (list) => new Set(list.map(key));
|
||||
const [north, south] = [latToTileY(0.1, z), latToTileY(0, z)];
|
||||
const plain = tilesForBbox(bbox, z);
|
||||
assert.equal(plain.total, 10 * (south - north + 1), '5 columns either side');
|
||||
assert.equal(plain.length, 10 * (south - north + 1), '5 columns either side');
|
||||
// The box centre is the date line itself, not the middle of the map: the
|
||||
// middle two rows of the columns touching it come first, then the ring
|
||||
// around them, all within two columns of the line.
|
||||
const [mid1, mid2] = [(north + south - 1) / 2, (north + south + 1) / 2];
|
||||
assert.deepEqual(
|
||||
keys(plain.tiles.slice(0, 4)),
|
||||
new Set([`${n - 1}/${mid1}`, `${n - 1}/${mid2}`, `0/${mid1}`, `0/${mid2}`]),
|
||||
);
|
||||
for (const tile of plain.tiles.slice(4, 12))
|
||||
// columns touching it come first, then the ones beside them.
|
||||
for (const tile of plain.slice(0, 4))
|
||||
assert.ok([n - 1, 0].includes(tile.x), `${key(tile)} at the line`);
|
||||
for (const tile of plain.slice(4, 12))
|
||||
assert.ok(
|
||||
[n - 2, n - 1, 0, 1].includes(tile.x),
|
||||
`${key(tile)} near the line`,
|
||||
);
|
||||
// A line of sight across the date line just north of the equator: the two
|
||||
// tiles it crosses come first, then the two in the row below them.
|
||||
// From a camera metres from the line: its tile, then the one across it.
|
||||
const row = latToTileY(0.05, z);
|
||||
const from = { lon: 179.99, lat: 0.05 };
|
||||
const to = { lon: -179.99, lat: 0.05 };
|
||||
for (const focus of [
|
||||
{ from, to },
|
||||
{ from: to, to: from },
|
||||
]) {
|
||||
const ranked = tilesForBbox(bbox, z, { focus }).tiles;
|
||||
assert.deepEqual(
|
||||
keys(ranked.slice(0, 2)),
|
||||
new Set([`${n - 1}/${row}`, `0/${row}`]),
|
||||
);
|
||||
assert.deepEqual(
|
||||
keys(ranked.slice(2, 4)),
|
||||
new Set([`${n - 1}/${row + 1}`, `0/${row + 1}`]),
|
||||
);
|
||||
}
|
||||
const { south: rowSouth, north: rowNorth } = tileBounds(0, row, z);
|
||||
const lat = (rowSouth + rowNorth) / 2;
|
||||
const west = tilesForBbox(bbox, z, { from: { lon: 179.999, lat } });
|
||||
assert.deepEqual(west.slice(0, 2).map(key), [`${n - 1}/${row}`, `0/${row}`]);
|
||||
const east = tilesForBbox(bbox, z, { from: { lon: -179.999, lat } });
|
||||
assert.deepEqual(east.slice(0, 2).map(key), [`0/${row}`, `${n - 1}/${row}`]);
|
||||
});
|
||||
|
||||
test('a focus ranks tiles along the line of sight, not from the box centre', () => {
|
||||
test('ranking from the camera keeps the ground under it, not the box centre', () => {
|
||||
// A box that runs far north of the camera, as a tilted view's does: the
|
||||
// box centre is kilometres from both the camera and the screen centre.
|
||||
// box centre is kilometres from the camera.
|
||||
const from = { lon: -121.4944, lat: 38.5816 };
|
||||
const to = { lon: -121.4944, lat: 38.596 };
|
||||
const bbox = [-121.6, 38.57, -121.38, 38.9];
|
||||
const z = 14;
|
||||
const key = (t) => `${t.x}/${t.y}`;
|
||||
const under = `${lonToTileX(from.lon, z)}/${latToTileY(from.lat, z)}`;
|
||||
const centre = `${lonToTileX(to.lon, z)}/${latToTileY(to.lat, z)}`;
|
||||
const plain = tilesForBbox(bbox, z, { limit: 9 }).tiles.map(key);
|
||||
assert.equal(plain.includes(under), false, 'the old ranking drops them');
|
||||
const ranked = tilesForBbox(bbox, z, { limit: 9, focus: { from, to } });
|
||||
const keys = ranked.tiles.map(key);
|
||||
assert.ok(keys.includes(under), 'the tile under the camera');
|
||||
assert.ok(keys.includes(centre), 'the tile at the centre of the screen');
|
||||
assert.equal(ranked.total > 9, true);
|
||||
const plain = tilesForBbox(bbox, z, { limit: 9 }).map(key);
|
||||
assert.equal(plain.includes(under), false, 'the box centre drops it');
|
||||
const ranked = tilesForBbox(bbox, z, { limit: 9, from });
|
||||
assert.equal(key(ranked[0]), under, 'the tile under the camera first');
|
||||
});
|
||||
|
||||
test('wrapLon wraps longitudes and their differences into [-180, 180]', () => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { COLORS } from './policy.js';
|
||||
|
||||
import { msg, t } from '../../i18n/index.js';
|
||||
|
||||
/**
|
||||
* @typedef {object} ProviderSnapshot
|
||||
* @property {string} id
|
||||
@@ -19,7 +18,6 @@ import { msg, t } from '../../i18n/index.js';
|
||||
* @property {boolean} [groundClick]
|
||||
* @property {Array<'pano'|'since'>} [filters]
|
||||
* @property {{noun: string, action: string}|null} [coverageLabel]
|
||||
* @property {boolean} [highlightsSequence] Its lines turn cyan when their sequence is selected.
|
||||
* @property {'capture'|'upload'} [sinceBasis] What its SINCE compares.
|
||||
*/
|
||||
|
||||
@@ -89,9 +87,8 @@ export function sinceNote(providers) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The panel snapshot from core state and provider snapshots. The legend has
|
||||
* one swatch per active source that draws coverage lines, then Selected.
|
||||
* @param {{enabled: boolean, filter: object, providers: Array<ProviderSnapshot>, street: object, sequence: object}} input
|
||||
* The panel snapshot from core state and provider snapshots.
|
||||
* @param {{enabled: boolean, groundClickReady?: boolean, filter: object, providers: Array<ProviderSnapshot>, street: object, sequence: object}} input
|
||||
*/
|
||||
export function composeUIState({
|
||||
enabled,
|
||||
@@ -100,30 +97,9 @@ export function composeUIState({
|
||||
providers,
|
||||
street,
|
||||
sequence,
|
||||
surface = 'draped',
|
||||
}) {
|
||||
const active = providers.filter((p) => p.on);
|
||||
const { keyRequired, keyRejected, ...coverage } =
|
||||
summarizeCoverage(providers);
|
||||
const legend = active
|
||||
.filter((provider) => provider.groundClick !== true)
|
||||
.map((provider) => ({
|
||||
key: provider.id,
|
||||
label: provider.name,
|
||||
color: provider.color,
|
||||
}));
|
||||
// "Selected" only beside a source whose lines turn cyan when selected.
|
||||
if (
|
||||
active.some(
|
||||
(provider) =>
|
||||
provider.groundClick !== true && provider.highlightsSequence !== false,
|
||||
)
|
||||
)
|
||||
legend.push({
|
||||
key: 'selected',
|
||||
label: t('Selected'),
|
||||
color: COLORS.selected,
|
||||
});
|
||||
return {
|
||||
enabled,
|
||||
groundClickReady: groundClickReady === true,
|
||||
@@ -135,9 +111,7 @@ export function composeUIState({
|
||||
sinceNote: sinceNote(providers),
|
||||
providers: providers.map((p) => ({ ...p })),
|
||||
coverage,
|
||||
legend,
|
||||
sequence: { ...sequence },
|
||||
street: { ...street },
|
||||
surface,
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { composeUIState, summarizeCoverage } from './uiState.js';
|
||||
import { COLORS } from './policy.js';
|
||||
import { providerSnapshot } from '../../testSupport/streetLevelFakes.mjs';
|
||||
|
||||
const provider = (overrides = {}) =>
|
||||
@@ -47,66 +46,6 @@ test('the layer is key-gated only when every switched-on provider lacks its key'
|
||||
assert.equal(offOnly.keyRequired, false, 'nothing switched on to gate');
|
||||
});
|
||||
|
||||
test('the legend is one swatch per active source, in its colour, then Selected', () => {
|
||||
const single = composeUIState({ ...base, providers: [provider()] });
|
||||
assert.deepEqual(single.legend, [
|
||||
{ key: 'mapillary', label: 'Mapillary', color: '#05cb63' },
|
||||
{ key: 'selected', label: 'Selected', color: COLORS.selected },
|
||||
]);
|
||||
const two = composeUIState({
|
||||
...base,
|
||||
providers: [
|
||||
provider(),
|
||||
provider({ id: 'panoramax', name: 'Panoramax', color: '#a66bff' }),
|
||||
],
|
||||
});
|
||||
assert.deepEqual(
|
||||
two.legend.map((entry) => [entry.label, entry.color]),
|
||||
[
|
||||
['Mapillary', '#05cb63'],
|
||||
['Panoramax', '#a66bff'],
|
||||
['Selected', COLORS.selected],
|
||||
],
|
||||
);
|
||||
const oneOff = composeUIState({
|
||||
...base,
|
||||
providers: [
|
||||
provider(),
|
||||
provider({ id: 'panoramax', name: 'Panoramax', on: false }),
|
||||
],
|
||||
});
|
||||
assert.deepEqual(
|
||||
oneOff.legend.map((entry) => entry.key),
|
||||
['mapillary', 'selected'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
composeUIState({ ...base, providers: [provider({ on: false })] }).legend,
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('Street View draws no lines: no legend swatch, and no Selected on its own', () => {
|
||||
const streetView = provider({
|
||||
id: 'google',
|
||||
name: 'Google Street View',
|
||||
color: '#4285f4',
|
||||
count: 0,
|
||||
groundClick: true,
|
||||
filters: [],
|
||||
});
|
||||
assert.deepEqual(
|
||||
composeUIState({ ...base, providers: [streetView] }).legend,
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(
|
||||
composeUIState({
|
||||
...base,
|
||||
providers: [provider(), streetView],
|
||||
}).legend.map((entry) => entry.key),
|
||||
['mapillary', 'selected'],
|
||||
);
|
||||
});
|
||||
|
||||
test('the filter controls show only for a lit provider that honours them', () => {
|
||||
const streetView = provider({ id: 'google', groundClick: true, filters: [] });
|
||||
const controls = (providers) =>
|
||||
@@ -194,18 +133,6 @@ test('a rejected key gates like a missing one and is named as rejected', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('the surface mode defaults to draped and passes through', () => {
|
||||
assert.equal(
|
||||
composeUIState({ ...base, providers: [provider()] }).surface,
|
||||
'draped',
|
||||
);
|
||||
assert.equal(
|
||||
composeUIState({ ...base, providers: [provider()], surface: 'terrain' })
|
||||
.surface,
|
||||
'terrain',
|
||||
);
|
||||
});
|
||||
|
||||
test('every switched-on provider’s hint is kept, once each', async () => {
|
||||
const { summarizeCoverage } = await import('./uiState.js');
|
||||
const { providerSnapshot } =
|
||||
@@ -249,22 +176,17 @@ test('a key-gated layer offers the switched-off sources that would draw with a k
|
||||
);
|
||||
});
|
||||
|
||||
test('a source whose lines never show a selection lists no Selected swatch, and names its own lines', () => {
|
||||
test('a source names its own lines', () => {
|
||||
const kartaView = provider({
|
||||
id: 'kartaview',
|
||||
name: 'KartaView',
|
||||
color: '#635bff',
|
||||
highlightsSequence: false,
|
||||
coverageLabel: {
|
||||
noun: 'streets',
|
||||
action: 'click a line for its nearest photo',
|
||||
},
|
||||
});
|
||||
const ui = composeUIState({ ...base, providers: [kartaView] });
|
||||
assert.deepEqual(
|
||||
ui.legend.map((entry) => entry.key),
|
||||
['kartaview'],
|
||||
);
|
||||
assert.deepEqual(ui.coverage.label, {
|
||||
noun: 'streets',
|
||||
action: 'click a line for its nearest photo',
|
||||
|
||||
+96
-109
@@ -10,40 +10,42 @@ export function metresBetween(a, b) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `task(deadline)` when idle, within `timeout` ms; without idle callbacks
|
||||
* (Node) it runs a frame later with a null deadline.
|
||||
*/
|
||||
export function whenIdle(task, timeout) {
|
||||
if (typeof globalThis.requestIdleCallback === 'function')
|
||||
globalThis.requestIdleCallback(task, { timeout });
|
||||
else setTimeout(() => task(null), 16);
|
||||
/** Whether a height (m) is plausible ellipsoidal land: Dead Sea to Everest, with geoid slack. */
|
||||
export function plausibleSurfaceHeight(height) {
|
||||
return Number.isFinite(height) && height >= -500 && height <= 9000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ellipsoidal height (m) of the surface under the camera: globe terrain, or
|
||||
* `groundAt` where Google 3D hides the globe; null without a sample.
|
||||
* @param {object} viewer
|
||||
* @param {{groundAt?: (lon: number, lat: number) => number|null}} [options]
|
||||
* Height (m) of the rendered surface, 3D tiles included, at `carto`; null
|
||||
* when unsampleable or implausible (`sampleHeight` reads kilometres
|
||||
* underground before tiles load).
|
||||
*/
|
||||
export function groundUnderCamera(viewer, { groundAt } = {}) {
|
||||
const carto = viewer?.camera?.positionCartographic;
|
||||
if (!carto) return null;
|
||||
const globe = viewer.scene?.globe;
|
||||
let ground = globe?.show === false ? null : globe?.getHeight?.(carto);
|
||||
if (!Number.isFinite(ground))
|
||||
ground = groundAt?.(
|
||||
Cesium.Math.toDegrees(carto.longitude),
|
||||
Cesium.Math.toDegrees(carto.latitude),
|
||||
);
|
||||
return Number.isFinite(ground) ? ground : null;
|
||||
export function sampledSurfaceHeight(scene, carto) {
|
||||
let height = null;
|
||||
try {
|
||||
if (scene?.sampleHeightSupported) height = scene.sampleHeight(carto);
|
||||
} catch {
|
||||
/* not sampleable yet */
|
||||
}
|
||||
return plausibleSurfaceHeight(height) ? height : null;
|
||||
}
|
||||
|
||||
/** Camera height (m) above the ground under it (options as `groundUnderCamera`). */
|
||||
export function cameraHeightAboveGround(viewer, options = {}) {
|
||||
/**
|
||||
* Ellipsoidal height (m) of the ground under the camera, or null. Under
|
||||
* Google 3D (globe hidden) the rendered surface counts, minus any roof above
|
||||
* the camera.
|
||||
*/
|
||||
export function groundUnderCamera(viewer) {
|
||||
const carto = viewer?.camera?.positionCartographic;
|
||||
if (!carto) return null;
|
||||
return carto.height - (groundUnderCamera(viewer, options) ?? 0);
|
||||
const scene = viewer?.scene;
|
||||
if (!carto || !scene) return null;
|
||||
const globe = scene.globe;
|
||||
if (globe && globe.show !== false) {
|
||||
const ground = globe.getHeight?.(carto);
|
||||
if (Number.isFinite(ground)) return ground;
|
||||
}
|
||||
const sampled = sampledSurfaceHeight(scene, carto);
|
||||
return sampled !== null && sampled < carto.height ? sampled : null;
|
||||
}
|
||||
|
||||
/** The ellipsoid `height` metres above `ellipsoid` (the ground at that height). */
|
||||
@@ -74,16 +76,24 @@ function groundHit(camera, point, ellipsoid, maxRange) {
|
||||
return Cesium.Cartographic.fromCartesian(cartesian, ellipsoid) || null;
|
||||
}
|
||||
|
||||
/** Camera height (m) above the ground under it. */
|
||||
export function cameraHeightAboveGround(viewer) {
|
||||
const carto = viewer?.camera?.positionCartographic;
|
||||
if (!carto) return null;
|
||||
return carto.height - (groundUnderCamera(viewer) ?? 0);
|
||||
}
|
||||
|
||||
/** Screen rays per side, less one, that `visibleBbox` casts. */
|
||||
const BBOX_GRID = 5;
|
||||
|
||||
/**
|
||||
* Visible [west, south, east, north] degrees, or null. Only screen rays that
|
||||
* hit count, so a horizon cannot inflate the box to the world.
|
||||
* `groundHeight` raises the ellipsoid to the ground (Google 3D hides the
|
||||
* globe), `maxRange` (m) drops near-horizon hits, and `nearRange` (m) always
|
||||
* includes the ground around the camera.
|
||||
* Visible [west, south, east, north] degrees from the rays that hit the
|
||||
* ground, or null when fewer than four do. `groundHeight` raises the ellipsoid
|
||||
* to the ground (Denver is 1,600 m up); `maxRange` (m) drops far hits.
|
||||
*/
|
||||
export function visibleBbox(
|
||||
viewer,
|
||||
{ grid = 5, groundHeight = null, maxRange = null, nearRange = null } = {},
|
||||
{ groundHeight = null, maxRange = null } = {},
|
||||
) {
|
||||
const scene = viewer?.scene;
|
||||
const camera = viewer?.camera;
|
||||
@@ -97,36 +107,15 @@ export function visibleBbox(
|
||||
groundHeight,
|
||||
);
|
||||
const point = new Cesium.Cartesian2();
|
||||
for (let i = 0; i <= grid; i++) {
|
||||
for (let j = 0; j <= grid; j++) {
|
||||
point.x = (width * i) / grid;
|
||||
point.y = (height * j) / grid;
|
||||
for (let i = 0; i <= BBOX_GRID; i++) {
|
||||
for (let j = 0; j <= BBOX_GRID; j++) {
|
||||
point.x = (width * i) / BBOX_GRID;
|
||||
point.y = (height * j) / BBOX_GRID;
|
||||
const carto = groundHit(camera, point, ellipsoid, maxRange);
|
||||
if (carto) hits.push(carto);
|
||||
}
|
||||
}
|
||||
}
|
||||
// The ground around the camera always counts: a street-level view's screen
|
||||
// rows skip from the horizon to the first few metres.
|
||||
const nadir = camera.positionCartographic;
|
||||
if (Number.isFinite(nearRange) && nearRange > 0 && nadir) {
|
||||
const dLat = nearRange / 111_320;
|
||||
const dLon = dLat / Math.max(0.05, Math.cos(nadir.latitude));
|
||||
const lat = Cesium.Math.toDegrees(nadir.latitude);
|
||||
const lon = Cesium.Math.toDegrees(nadir.longitude);
|
||||
for (const [dx, dy] of [
|
||||
[-1, -1],
|
||||
[-1, 1],
|
||||
[1, -1],
|
||||
[1, 1],
|
||||
])
|
||||
hits.push(
|
||||
Cesium.Cartographic.fromDegrees(
|
||||
lon + dx * dLon,
|
||||
Math.max(-85, Math.min(85, lat + dy * dLat)),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (hits.length >= 4) {
|
||||
let west = Infinity;
|
||||
let south = Infinity;
|
||||
@@ -159,78 +148,57 @@ export function visibleBbox(
|
||||
}
|
||||
if (west !== east && north - south > 0) return [west, south, east, north];
|
||||
}
|
||||
const rectangle = camera.computeViewRectangle?.(scene.globe?.ellipsoid);
|
||||
if (!rectangle) return null;
|
||||
return [
|
||||
Cesium.Math.toDegrees(rectangle.west),
|
||||
Cesium.Math.toDegrees(rectangle.south),
|
||||
Cesium.Math.toDegrees(rectangle.east),
|
||||
Cesium.Math.toDegrees(rectangle.north),
|
||||
];
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Centre of the visible ground, or the camera's own footprint. */
|
||||
export function viewCentre(viewer) {
|
||||
if (!viewer) return null;
|
||||
const bbox = visibleBbox(viewer);
|
||||
if (bbox) {
|
||||
// Across the date line (west > east) the middle is on the far side of 0°.
|
||||
const span = bbox[2] - bbox[0] + (bbox[0] > bbox[2] ? 360 : 0);
|
||||
let lon = bbox[0] + span / 2;
|
||||
if (lon > 180) lon -= 360;
|
||||
return { lat: (bbox[1] + bbox[3]) / 2, lon };
|
||||
}
|
||||
const carto = viewer.camera?.positionCartographic;
|
||||
/** The point on the ground under the camera, as {lon, lat} degrees. */
|
||||
export function cameraNadir(viewer) {
|
||||
const carto = viewer?.camera?.positionCartographic;
|
||||
if (!carto) return null;
|
||||
return {
|
||||
lat: Cesium.Math.toDegrees(carto.latitude),
|
||||
lon: Cesium.Math.toDegrees(carto.longitude),
|
||||
lat: Cesium.Math.toDegrees(carto.latitude),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ground the camera looks at, for ranking tiles: `nadir` under the camera and
|
||||
* `ahead` at the screen centre (null on a miss or beyond `maxRange`).
|
||||
* Where coverage tiles are ranked from: halfway between the nadir and the
|
||||
* screen centre's ground, or the nadir when that is out of `maxRange`.
|
||||
*/
|
||||
export function viewFocus(
|
||||
viewer,
|
||||
{ groundHeight = null, maxRange = null } = {},
|
||||
) {
|
||||
const camera = viewer?.camera;
|
||||
const carto = camera?.positionCartographic;
|
||||
if (!carto) return null;
|
||||
const nadir = {
|
||||
lon: Cesium.Math.toDegrees(carto.longitude),
|
||||
lat: Cesium.Math.toDegrees(carto.latitude),
|
||||
const nadir = cameraNadir(viewer);
|
||||
const scene = viewer?.scene;
|
||||
const width = scene?.canvas?.clientWidth || scene?.canvas?.width || 0;
|
||||
const height = scene?.canvas?.clientHeight || scene?.canvas?.height || 0;
|
||||
if (!nadir || !width || !height) return nadir;
|
||||
const hit = groundHit(
|
||||
viewer.camera,
|
||||
new Cesium.Cartesian2(width / 2, height / 2),
|
||||
raisedEllipsoid(
|
||||
scene.globe?.ellipsoid || Cesium.Ellipsoid.WGS84,
|
||||
groundHeight,
|
||||
),
|
||||
maxRange,
|
||||
);
|
||||
if (!hit) return nadir;
|
||||
const ahead = {
|
||||
lon: Cesium.Math.toDegrees(hit.longitude),
|
||||
lat: Cesium.Math.toDegrees(hit.latitude),
|
||||
};
|
||||
return {
|
||||
lon: wrapLon(nadir.lon + wrapLon(ahead.lon - nadir.lon) / 2),
|
||||
lat: (nadir.lat + ahead.lat) / 2,
|
||||
};
|
||||
const canvas = viewer.scene?.canvas;
|
||||
const width = canvas?.clientWidth || canvas?.width || 0;
|
||||
const height = canvas?.clientHeight || canvas?.height || 0;
|
||||
let ahead = null;
|
||||
if (width > 0 && height > 0) {
|
||||
const hit = groundHit(
|
||||
camera,
|
||||
new Cesium.Cartesian2(width / 2, height / 2),
|
||||
raisedEllipsoid(
|
||||
viewer.scene?.globe?.ellipsoid || Cesium.Ellipsoid.WGS84,
|
||||
groundHeight,
|
||||
),
|
||||
maxRange,
|
||||
);
|
||||
if (hit)
|
||||
ahead = {
|
||||
lon: Cesium.Math.toDegrees(hit.longitude),
|
||||
lat: Cesium.Math.toDegrees(hit.latitude),
|
||||
};
|
||||
}
|
||||
return { nadir, ahead };
|
||||
}
|
||||
|
||||
/**
|
||||
* Horizon occluder depth below WGS84 (m): ground can lie below the ellipsoid
|
||||
* (NYC −22 m), and an occluder at 0 m would hide the cones around it.
|
||||
*/
|
||||
export const HORIZON_CULL_DEPTH_M = 1000;
|
||||
const HORIZON_CULL_DEPTH_M = 1000;
|
||||
const HORIZON_CULL_ELLIPSOID = new Cesium.Ellipsoid(
|
||||
Cesium.Ellipsoid.WGS84.radii.x - HORIZON_CULL_DEPTH_M,
|
||||
Cesium.Ellipsoid.WGS84.radii.y - HORIZON_CULL_DEPTH_M,
|
||||
@@ -295,3 +263,22 @@ export function createHorizonCull({ getViewer, items, onChange }) {
|
||||
|
||||
return { update, stop };
|
||||
}
|
||||
|
||||
/** The middle of the visible ground, else the point under the camera; null without a camera. */
|
||||
export function viewCentre(viewer) {
|
||||
if (!viewer) return null;
|
||||
const bbox = visibleBbox(viewer);
|
||||
if (bbox) {
|
||||
// Across the date line (west > east) the middle is on the far side of 0°.
|
||||
const span = bbox[2] - bbox[0] + (bbox[0] > bbox[2] ? 360 : 0);
|
||||
let lon = bbox[0] + span / 2;
|
||||
if (lon > 180) lon -= 360;
|
||||
return { lat: (bbox[1] + bbox[3]) / 2, lon };
|
||||
}
|
||||
const carto = viewer.camera?.positionCartographic;
|
||||
if (!carto) return null;
|
||||
return {
|
||||
lat: Cesium.Math.toDegrees(carto.latitude),
|
||||
lon: Cesium.Math.toDegrees(carto.longitude),
|
||||
};
|
||||
}
|
||||
@@ -2,13 +2,14 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
cameraHeightAboveGround,
|
||||
cameraNadir,
|
||||
createHorizonCull,
|
||||
groundUnderCamera,
|
||||
metresBetween,
|
||||
viewCentre,
|
||||
viewFocus,
|
||||
visibleBbox,
|
||||
} from './view.js';
|
||||
import { latToTileY, lonToTileX, tilesForBbox } from './tileMath.js';
|
||||
import { rayCamera } from '../../testSupport/streetLevelFakes.mjs';
|
||||
|
||||
const RAD = Math.PI / 180;
|
||||
@@ -28,28 +29,42 @@ function viewer({ globeShown, globeHeight }) {
|
||||
|
||||
test('a shown globe answers the ground under the camera', () => {
|
||||
assert.equal(
|
||||
cameraHeightAboveGround(viewer({ globeShown: true, globeHeight: 1600 })),
|
||||
300,
|
||||
groundUnderCamera(viewer({ globeShown: true, globeHeight: 1600 })),
|
||||
1600,
|
||||
);
|
||||
});
|
||||
|
||||
test('a hidden globe (Google 3D) falls back to the bare-earth height', () => {
|
||||
const calls = [];
|
||||
const groundAt = (lon, lat) => {
|
||||
calls.push([+lon.toFixed(2), +lat.toFixed(2)]);
|
||||
return 1600;
|
||||
};
|
||||
test('a hidden globe (Google 3D) or a missing sample gives no ground', () => {
|
||||
// A hidden globe's getHeight is ignored even when it returns a number.
|
||||
const hidden = viewer({ globeShown: false, globeHeight: 0 });
|
||||
assert.equal(cameraHeightAboveGround(hidden, { groundAt }), 300);
|
||||
assert.deepEqual(calls, [[-104.99, 39.74]]);
|
||||
assert.equal(
|
||||
groundUnderCamera(viewer({ globeShown: false, globeHeight: 0 })),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
groundUnderCamera(viewer({ globeShown: true, globeHeight: undefined })),
|
||||
null,
|
||||
);
|
||||
assert.equal(groundUnderCamera({}), null);
|
||||
});
|
||||
|
||||
test('with no ground sample the ellipsoidal height is used', () => {
|
||||
const hidden = viewer({ globeShown: false, globeHeight: 0 });
|
||||
assert.equal(cameraHeightAboveGround(hidden), 1900);
|
||||
assert.equal(cameraHeightAboveGround(hidden, { groundAt: () => null }), 1900);
|
||||
assert.equal(cameraHeightAboveGround({}), null);
|
||||
/** The Denver viewer with Google 3D: no globe, a sampleable rendered surface. */
|
||||
function google3d(sample) {
|
||||
const view = viewer({ globeShown: false, globeHeight: 0 });
|
||||
view.scene.sampleHeightSupported = true;
|
||||
view.scene.sampleHeight = () => sample;
|
||||
return view;
|
||||
}
|
||||
|
||||
test('on Google 3D the rendered surface answers the ground under the camera', () => {
|
||||
assert.equal(groundUnderCamera(google3d(1612)), 1612);
|
||||
});
|
||||
|
||||
test('an implausible or above-camera surface sample gives no ground', () => {
|
||||
// Before tiles stream in, sampleHeight can read kilometres underground.
|
||||
assert.equal(groundUnderCamera(google3d(-14_886)), null);
|
||||
// A roof above the camera (1,900 m) would put the camera underground.
|
||||
assert.equal(groundUnderCamera(google3d(1950)), null);
|
||||
assert.equal(groundUnderCamera(google3d(undefined)), null);
|
||||
});
|
||||
|
||||
/** A camera whose screen rays land on a grid of lon/lat points. */
|
||||
@@ -73,16 +88,11 @@ test('a view across the date line is a narrow box with west > east', () => {
|
||||
const bbox = visibleBbox(viewer);
|
||||
assert.ok(bbox[0] > 177 && bbox[0] < 179, `west ${bbox[0]}`);
|
||||
assert.ok(bbox[2] < -177 && bbox[2] > -179, `east ${bbox[2]}`);
|
||||
const centre = viewCentre(viewer);
|
||||
assert.ok(Math.abs(Math.abs(centre.lon) - 180) < 0.5, `centre ${centre.lon}`);
|
||||
});
|
||||
|
||||
test('an ordinary view keeps west < east', () => {
|
||||
const bbox = visibleBbox(gridViewer([10, 11, 12], [50, 51]));
|
||||
assert.ok(bbox[0] < bbox[2]);
|
||||
assert.ok(
|
||||
Math.abs(viewCentre(gridViewer([10, 11, 12], [50, 51])).lon - 11) < 1e-6,
|
||||
);
|
||||
});
|
||||
|
||||
/** A pinhole viewer `agl` m above ground `ground` m up, looking north. */
|
||||
@@ -119,20 +129,66 @@ test('a tilted street view over high ground boxes the streets it looks at, not t
|
||||
// the box is 20 km wide and starts past the street at the screen centre.
|
||||
assert.ok(unranged[2] - unranged[0] > 0.2);
|
||||
assert.ok(unranged[1] > ahead, 'the old box missed the street in view');
|
||||
const options = { groundHeight: 1610, maxRange: 6000, nearRange: 1000 };
|
||||
const options = { groundHeight: 1610, maxRange: 6000 };
|
||||
const [west, south, east, north] = visibleBbox(view, options);
|
||||
assert.ok(north - south < 0.08 && east - west < 0.08, 'a street-sized box');
|
||||
assert.ok(south < 39.74 && north > ahead, 'camera and screen centre inside');
|
||||
const focus = viewFocus(view, options);
|
||||
assert.ok(Math.abs(focus.nadir.lat - 39.74) < 1e-9);
|
||||
assert.ok(south < ahead && north > ahead, 'the screen centre is inside');
|
||||
assert.ok(
|
||||
Math.abs(focus.ahead.lat - ahead) < 0.002,
|
||||
'centre ray on the street',
|
||||
south > 39.74,
|
||||
'it starts ahead of the camera, where the view does',
|
||||
);
|
||||
assert.ok(west < -104.99 && east > -104.99);
|
||||
const nadir = cameraNadir(view);
|
||||
assert.ok(
|
||||
Math.abs(nadir.lat - 39.74) < 1e-9 && Math.abs(nadir.lon + 104.99) < 1e-9,
|
||||
);
|
||||
assert.ok(Math.abs(focus.ahead.lon - -104.99) < 1e-6);
|
||||
});
|
||||
|
||||
test('looking at the horizon from eye height still boxes the ground around the camera', () => {
|
||||
test('a tilted view from 3 km loads the tile at the centre of the screen', () => {
|
||||
// 3 km up, 70° from straight down: the screen centre meets the ground
|
||||
// 8.2 km ahead, past the 3 × 3 z13 tiles around the camera's ground point.
|
||||
const lon = -121.49;
|
||||
const lat = 38.58;
|
||||
const view = pinhole({ lon, lat, ground: 10, agl: 3000, pitch: -20 });
|
||||
const ranged = { groundHeight: 10, maxRange: 30_000 };
|
||||
const bbox = visibleBbox(view, ranged);
|
||||
const ahead = lat + 3000 / Math.tan((20 * Math.PI) / 180) / 110_540;
|
||||
const centreTile = `${lonToTileX(lon, 13)}/${latToTileY(ahead, 13)}`;
|
||||
const chosen = (from) =>
|
||||
tilesForBbox(bbox, 13, { limit: 9, from }).map(
|
||||
(tile) => `${tile.x}/${tile.y}`,
|
||||
);
|
||||
assert.ok(
|
||||
!chosen(cameraNadir(view)).includes(centreTile),
|
||||
'ranked from the nadir alone, the screen centre is left out',
|
||||
);
|
||||
const focus = viewFocus(view, ranged);
|
||||
assert.ok(focus.lat > lat && focus.lat < ahead, 'between camera and centre');
|
||||
assert.ok(chosen(focus).includes(centreTile), 'the screen centre is loaded');
|
||||
// The bottom edge of the screen (50° down) meets the ground 2.5 km ahead.
|
||||
const nearest = lat + 3000 / Math.tan((50 * Math.PI) / 180) / 110_540;
|
||||
assert.ok(
|
||||
chosen(focus).includes(`${lonToTileX(lon, 13)}/${latToTileY(nearest, 13)}`),
|
||||
'and so is the nearest ground in view',
|
||||
);
|
||||
});
|
||||
|
||||
test('looking straight down or at the sky, the focus is the ground under the camera', () => {
|
||||
const lon = -121.49;
|
||||
const lat = 38.58;
|
||||
const down = viewFocus(
|
||||
pinhole({ lon, lat, ground: 10, agl: 500, pitch: -90 }),
|
||||
{ groundHeight: 10, maxRange: 5000 },
|
||||
);
|
||||
assert.ok(Math.abs(down.lat - lat) < 1e-4 && Math.abs(down.lon - lon) < 1e-4);
|
||||
const sky = viewFocus(pinhole({ lon, lat, ground: 10, agl: 2, pitch: 60 }), {
|
||||
groundHeight: 10,
|
||||
maxRange: 2500,
|
||||
});
|
||||
assert.ok(Math.abs(sky.lat - lat) < 1e-9 && Math.abs(sky.lon - lon) < 1e-9);
|
||||
});
|
||||
|
||||
test('looking at the horizon from eye height boxes the near ground, not the horizon', () => {
|
||||
const view = pinhole({
|
||||
lon: -121.4944,
|
||||
lat: 38.5816,
|
||||
@@ -143,10 +199,19 @@ test('looking at the horizon from eye height still boxes the ground around the c
|
||||
const [west, south, east, north] = visibleBbox(view, {
|
||||
groundHeight: 10,
|
||||
maxRange: 2500,
|
||||
nearRange: 1000,
|
||||
});
|
||||
assert.ok(north - 38.5816 > 0.008 && 38.5816 - south > 0.008);
|
||||
assert.ok(east - -121.4944 > 0.008 && -121.4944 - west > 0.008);
|
||||
assert.ok(south > 38.5816 && south - 38.5816 < 0.001, 'from just ahead');
|
||||
assert.ok(north - 38.5816 < 2500 / 111_000, 'no further than the range');
|
||||
assert.ok(west < -121.4944 && east > -121.4944);
|
||||
// Looking at the sky, nothing is in range: no box, never the horizon's.
|
||||
const sky = pinhole({
|
||||
lon: -121.4944,
|
||||
lat: 38.5816,
|
||||
ground: 10,
|
||||
agl: 2,
|
||||
pitch: 60,
|
||||
});
|
||||
assert.equal(visibleBbox(sky, { groundHeight: 10, maxRange: 2500 }), null);
|
||||
});
|
||||
|
||||
test('metresBetween measures the short way round the date line', () => {
|
||||
@@ -285,7 +350,7 @@ function cullFrom(camera, points) {
|
||||
}
|
||||
|
||||
test('cones and the marker stay visible on ground below the WGS84 ellipsoid', () => {
|
||||
// NYC in FOLLOW: eye 2.4 m above ground at -22 m, a cone 10 m away.
|
||||
// NYC at street level: eye 2.4 m above ground at -22 m, a cone 10 m away.
|
||||
assert.deepEqual(
|
||||
cullFrom([-74.006, 40.7128, -19.6], [[-74.006, 40.71289, -22]]),
|
||||
[true],
|
||||
|
||||
@@ -2,35 +2,27 @@ import { msg } from '../../i18n/index.js';
|
||||
import { freshStreet } from './state.js';
|
||||
|
||||
/**
|
||||
* Owns the panel's viewer element and the provider adapter mounted in it.
|
||||
* Adapter poses update `state.street`, the marker and the follow camera.
|
||||
* The panel's photo viewer element and the provider adapter mounted in it.
|
||||
* Adapter poses update `state.street` and the marker.
|
||||
*/
|
||||
export function createViewerHost({ state, parts }) {
|
||||
/** @type {{id: string, adapter: object, unsubscribe: () => void}|null} */
|
||||
let active = null;
|
||||
/** @type {{id: string, promise: Promise<object>}|null} */
|
||||
/** The mount in flight, shared by concurrent opens. @type {{id: string, promise: Promise<object>}|null} */
|
||||
let mounting = null;
|
||||
let openSeq = 0;
|
||||
/** Prewarmed, inactive adapters; each holds a live viewer (WebGL context) until `unmount`. */
|
||||
const warmed = new Map();
|
||||
|
||||
function notify() {
|
||||
state.notify?.();
|
||||
}
|
||||
|
||||
function providerEntry(providerId) {
|
||||
return state.providers.get(providerId) || null;
|
||||
}
|
||||
|
||||
function applyPose(pose) {
|
||||
if (!active || !state.street.open || pose.providerId !== active.id) return;
|
||||
if (!active || !state.street.open) return;
|
||||
const previousSequence = state.street.sequenceId;
|
||||
Object.assign(state.street, {
|
||||
providerId: pose.providerId,
|
||||
imageId: pose.imageId,
|
||||
position: pose.position ? { ...pose.position } : null,
|
||||
bearing: Number.isFinite(pose.bearing) ? pose.bearing : null,
|
||||
tilt: Number.isFinite(pose.tilt) ? pose.tilt : 0,
|
||||
altitude: Number.isFinite(pose.altitude) ? pose.altitude : null,
|
||||
isPano: pose.isPano === true,
|
||||
capturedAt: pose.capturedAt ?? null,
|
||||
@@ -49,9 +41,8 @@ export function createViewerHost({ state, parts }) {
|
||||
parts.marker.set(
|
||||
state.street.position,
|
||||
state.street.bearing,
|
||||
providerEntry(pose.providerId)?.def.marker,
|
||||
state.providers.get(active.id)?.def.marker,
|
||||
);
|
||||
parts.follow.followCamera();
|
||||
// Select the sequence only once the image is on screen, so its lookup
|
||||
// never competes with the image download.
|
||||
if (!state.street.loading && state.street.sequenceId !== previousSequence)
|
||||
@@ -61,67 +52,58 @@ export function createViewerHost({ state, parts }) {
|
||||
|
||||
function selectCurrentSequence() {
|
||||
const { sequenceId, providerId } = state.street;
|
||||
const entry = providerEntry(providerId);
|
||||
const entry = state.providers.get(providerId);
|
||||
if (!sequenceId || !entry?.instance.selectSequence || !state.enabled)
|
||||
return;
|
||||
const current = entry.instance.sequenceStats?.()?.selectedId;
|
||||
if (current !== sequenceId) entry.instance.selectSequence(sequenceId);
|
||||
if (entry.instance.sequenceStats?.()?.selectedId !== sequenceId)
|
||||
entry.instance.selectSequence(sequenceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount a provider's adapter. It becomes `active` only on success, so a
|
||||
* failed mount is retried on the next open; concurrent opens share one.
|
||||
* Mount a provider's adapter, releasing another provider's first. It
|
||||
* counts as mounted only on success, so a failed mount is retried on the
|
||||
* next open; concurrent opens share one.
|
||||
*/
|
||||
function mount(entry) {
|
||||
if (active?.id === entry.def.id) return Promise.resolve(active.adapter);
|
||||
if (mounting?.id === entry.def.id) return mounting.promise;
|
||||
const id = entry.def.id;
|
||||
if (active?.id === id) return Promise.resolve(active.adapter);
|
||||
if (mounting?.id === id) return mounting.promise;
|
||||
if (active) {
|
||||
active.adapter.close();
|
||||
active.unsubscribe();
|
||||
active.adapter.unmount();
|
||||
active = null;
|
||||
}
|
||||
// A prewarmed viewer would sit under this one, holding its WebGL context.
|
||||
for (const [id, warm] of warmed) {
|
||||
if (id === entry.def.id) continue;
|
||||
warmed.delete(id);
|
||||
warm.unmount();
|
||||
}
|
||||
const adapter = entry.instance.viewer;
|
||||
const promise = (async () => {
|
||||
await adapter.mount(state.street.host);
|
||||
if (mounting?.promise !== promise) {
|
||||
// Unmounted (layer off, provider switched) while loading. A newer
|
||||
// mount of this provider shares the adapter and its viewer: tearing
|
||||
// it down here would leave that mount active with no viewer.
|
||||
const reused =
|
||||
active?.id === entry.def.id || mounting?.id === entry.def.id;
|
||||
if (!reused) adapter.unmount();
|
||||
// mount of this provider shares the adapter: tearing it down here
|
||||
// would leave that mount without a viewer.
|
||||
if (active?.id !== id && mounting?.id !== id) adapter.unmount();
|
||||
throw new Error('Street-level viewer was closed');
|
||||
}
|
||||
// Listen only once current: an outdated mount releasing the same
|
||||
// `applyPose` would otherwise unhook the mount that replaced it.
|
||||
const unsubscribe = adapter.onPose(applyPose);
|
||||
active = { id: entry.def.id, adapter, unsubscribe };
|
||||
warmed.delete(entry.def.id);
|
||||
active = { id, adapter, unsubscribe: adapter.onPose(applyPose) };
|
||||
adapter.setRenderMode?.(state.street.renderMode);
|
||||
return adapter;
|
||||
})();
|
||||
mounting = { id: entry.def.id, promise };
|
||||
promise.then(
|
||||
() => {
|
||||
if (mounting?.promise === promise) mounting = null;
|
||||
},
|
||||
() => {
|
||||
if (mounting?.promise === promise) mounting = null;
|
||||
},
|
||||
);
|
||||
mounting = { id, adapter, promise };
|
||||
const settle = () => {
|
||||
if (mounting?.promise === promise) mounting = null;
|
||||
};
|
||||
promise.then(settle, settle);
|
||||
return promise;
|
||||
}
|
||||
|
||||
/** Open an image: true once its first pose is in, false if it failed or was overtaken. */
|
||||
/**
|
||||
* Open an image: true once its first pose is in, false if it failed or was
|
||||
* overtaken. `frame: false` leaves the camera where it is.
|
||||
*/
|
||||
async function open(providerId, imageId, { frame = true } = {}) {
|
||||
const entry = providerEntry(providerId);
|
||||
const entry = state.providers.get(providerId);
|
||||
if (!entry || !imageId) return false;
|
||||
if (!state.street.host) {
|
||||
// A nearest-image lookup may have set it; nothing is loading now.
|
||||
@@ -134,8 +116,7 @@ export function createViewerHost({ state, parts }) {
|
||||
const seq = ++openSeq;
|
||||
const current = () => seq === openSeq;
|
||||
// Claimed now, honoured after loading only if nothing newer took the camera.
|
||||
const ticket =
|
||||
frame && !state.street.follow ? parts.follow.beginFraming() : null;
|
||||
const ticket = frame ? parts.framing.begin() : null;
|
||||
Object.assign(state.street, {
|
||||
loading: true,
|
||||
error: null,
|
||||
@@ -152,7 +133,7 @@ export function createViewerHost({ state, parts }) {
|
||||
state.street.renderModes = typeof adapter.setRenderMode === 'function';
|
||||
await adapter.open(String(imageId));
|
||||
if (!current()) return false;
|
||||
if (ticket && !state.street.follow) parts.follow.lookAtPosition(ticket);
|
||||
if (ticket) parts.framing.frame(ticket);
|
||||
} catch (error) {
|
||||
if (current())
|
||||
state.street.error = error?.message || msg('Image could not be opened');
|
||||
@@ -165,10 +146,10 @@ export function createViewerHost({ state, parts }) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Close the image and stop any framing flight; the adapter stays warm. */
|
||||
/** Close the image and stop any framing flight; the adapter stays mounted. */
|
||||
function close() {
|
||||
openSeq++;
|
||||
parts.follow.cancelFraming();
|
||||
parts.framing.cancel();
|
||||
active?.adapter.close();
|
||||
Object.assign(state.street, freshStreet());
|
||||
parts.marker.clear();
|
||||
@@ -181,74 +162,30 @@ export function createViewerHost({ state, parts }) {
|
||||
}
|
||||
|
||||
function resize() {
|
||||
try {
|
||||
active?.adapter.resize();
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
active?.adapter.resize();
|
||||
}
|
||||
|
||||
function setRenderMode(mode) {
|
||||
state.street.renderMode = mode === 'fill' ? 'fill' : 'letterbox';
|
||||
try {
|
||||
active?.adapter.setRenderMode?.(state.street.renderMode);
|
||||
} catch {
|
||||
/* adapter not ready */
|
||||
}
|
||||
active?.adapter.setRenderMode?.(state.street.renderMode);
|
||||
notify();
|
||||
}
|
||||
|
||||
/** Stand active providers' viewers up ahead of the first image. */
|
||||
async function prewarm(entries) {
|
||||
const host = state.street.host;
|
||||
if (!host || !state.enabled) return;
|
||||
for (const entry of entries) {
|
||||
const adapter = entry.instance.viewer;
|
||||
if (!adapter.prewarm || !entry.on) continue;
|
||||
// Another provider's viewer is showing in the host: do not build under it.
|
||||
if (active && active.id !== entry.def.id) continue;
|
||||
try {
|
||||
await adapter.prewarm(host);
|
||||
} catch {
|
||||
/* the real open reports errors */
|
||||
}
|
||||
if (active?.id === entry.def.id || mounting?.id === entry.def.id)
|
||||
continue;
|
||||
// Switched off, or another viewer took the host meanwhile.
|
||||
if (!state.enabled || !entry.on || active || mounting) adapter.unmount();
|
||||
else warmed.set(entry.def.id, adapter);
|
||||
}
|
||||
}
|
||||
|
||||
/** Tear down every viewer, or only `providerId`'s (active or prewarmed). */
|
||||
/**
|
||||
* Close the image and destroy the viewer (and its WebGL context): every
|
||||
* provider's, or only `providerId`'s. A mount still in flight is told too,
|
||||
* so it builds nothing when its library lands.
|
||||
*/
|
||||
function unmount(providerId) {
|
||||
if (
|
||||
!providerId ||
|
||||
active?.id === providerId ||
|
||||
mounting?.id === providerId
|
||||
) {
|
||||
close();
|
||||
mounting = null;
|
||||
if (active) {
|
||||
active.unsubscribe();
|
||||
active.adapter.unmount();
|
||||
active = null;
|
||||
}
|
||||
}
|
||||
for (const [id, adapter] of warmed) {
|
||||
if (providerId && id !== providerId) continue;
|
||||
warmed.delete(id);
|
||||
adapter.unmount();
|
||||
}
|
||||
if (providerId && active?.id !== providerId && mounting?.id !== providerId)
|
||||
return;
|
||||
close();
|
||||
const pending = mounting;
|
||||
mounting = null;
|
||||
active?.unsubscribe();
|
||||
(active?.adapter ?? pending?.adapter)?.unmount();
|
||||
active = null;
|
||||
}
|
||||
|
||||
return {
|
||||
attach,
|
||||
open,
|
||||
close,
|
||||
resize,
|
||||
setRenderMode,
|
||||
prewarm,
|
||||
unmount,
|
||||
};
|
||||
return { attach, open, close, resize, setRenderMode, unmount };
|
||||
}
|
||||
@@ -32,53 +32,55 @@ const { createMapillaryViewer } =
|
||||
|
||||
/**
|
||||
* A viewer host over `adapter`. `marker` records where the position marker
|
||||
* was put (null once cleared), `followed` counts camera-follow updates and
|
||||
* `selected` every sequence the provider was asked to select.
|
||||
* was put (null once cleared), `framing` counts framing calls and
|
||||
* `selected` every sequence the layer was asked to select.
|
||||
*/
|
||||
function harness(adapter) {
|
||||
const selected = [];
|
||||
const state = {
|
||||
enabled: true,
|
||||
notify() {},
|
||||
providers: new Map(),
|
||||
street: { host: {}, renderMode: 'letterbox', follow: false },
|
||||
street: { host: {}, renderMode: 'letterbox' },
|
||||
sequence: { selectedId: null },
|
||||
providers: new Map([
|
||||
[
|
||||
'mapillary',
|
||||
{
|
||||
def: { id: 'mapillary', name: 'Mapillary', label: 'MAPILLARY' },
|
||||
instance: {
|
||||
viewer: adapter,
|
||||
selectSequence(id) {
|
||||
selected.push(id);
|
||||
state.sequence.selectedId = id;
|
||||
},
|
||||
sequenceStats: () => ({ selectedId: state.sequence.selectedId }),
|
||||
},
|
||||
},
|
||||
],
|
||||
]),
|
||||
};
|
||||
const def = { id: 'mapillary', name: 'Mapillary', label: 'MAPILLARY' };
|
||||
const selected = [];
|
||||
let selectedId = null;
|
||||
const instance = {
|
||||
viewer: adapter,
|
||||
selectSequence(id) {
|
||||
selected.push(id);
|
||||
selectedId = id;
|
||||
},
|
||||
sequenceStats: () => ({ selectedId }),
|
||||
};
|
||||
state.providers.set('mapillary', { def, instance });
|
||||
const framing = { begun: 0, started: 0, cancelled: 0 };
|
||||
const marker = [];
|
||||
const followed = { count: 0 };
|
||||
const parts = {
|
||||
marker: {
|
||||
set: (position, bearing) =>
|
||||
marker.push(position && { position, bearing }),
|
||||
clear: () => marker.push(null),
|
||||
},
|
||||
follow: {
|
||||
followCamera: () => followed.count++,
|
||||
beginFraming: () => ({ generation: ++framing.begun }),
|
||||
lookAtPosition: (ticket) => ticket && framing.started++,
|
||||
cancelFraming: () => framing.cancelled++,
|
||||
framing: {
|
||||
begin: () => ({ generation: ++framing.begun }),
|
||||
frame: (ticket) => ticket && framing.started++,
|
||||
cancel: () => framing.cancelled++,
|
||||
},
|
||||
};
|
||||
return {
|
||||
state,
|
||||
framing,
|
||||
marker,
|
||||
followed,
|
||||
selected,
|
||||
/** Esc on the map: the provider drops its selected sequence. */
|
||||
/** Esc on the map: the selected sequence is dropped. */
|
||||
clearSequence() {
|
||||
selectedId = null;
|
||||
state.sequence.selectedId = null;
|
||||
},
|
||||
host: createViewerHost({ state, parts }),
|
||||
};
|
||||
@@ -105,7 +107,6 @@ function fakeAdapter({ failMounts = 0, gate = null, openGate = null } = {}) {
|
||||
/** A pose event from the library, as MapillaryJS fires them. */
|
||||
emitPose(id, overrides = {}) {
|
||||
emit?.({
|
||||
providerId: 'mapillary',
|
||||
imageId: id,
|
||||
sequenceId: `seq-${id}`,
|
||||
position: { lon: 1, lat: 2 },
|
||||
@@ -168,7 +169,7 @@ test('unmounting while a mount is in flight does not leave it active', async ()
|
||||
host.unmount();
|
||||
await opening;
|
||||
assert.equal(adapter.calls.listeners, 0);
|
||||
assert.equal(adapter.calls.unmount, 1);
|
||||
assert.equal(adapter.calls.unmount, 2, 'layer off, then the late mount');
|
||||
assert.equal(state.street.open, false);
|
||||
// Not left active: the next open mounts again.
|
||||
await host.open('mapillary', 'b');
|
||||
@@ -176,46 +177,6 @@ test('unmounting while a mount is in flight does not leave it active', async ()
|
||||
assert.equal(state.street.imageId, 'b');
|
||||
});
|
||||
|
||||
test('a prewarmed viewer is released when the layer goes off, or at once if it went off mid-load', async () => {
|
||||
const adapter = fakeAdapter();
|
||||
let release = null;
|
||||
adapter.prewarm = () =>
|
||||
new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const { state, host } = harness(adapter);
|
||||
const entry = state.providers.get('mapillary');
|
||||
entry.on = true;
|
||||
|
||||
// Warmed, then the layer is switched off: unmount releases the warm viewer.
|
||||
const warming = host.prewarm([entry]);
|
||||
release();
|
||||
await warming;
|
||||
assert.equal(adapter.calls.unmount, 0);
|
||||
host.unmount();
|
||||
assert.equal(adapter.calls.unmount, 1, 'the WebGL viewer is destroyed');
|
||||
|
||||
// Switched off while the library was still loading: released on arrival.
|
||||
const late = host.prewarm([entry]);
|
||||
state.enabled = false;
|
||||
release();
|
||||
await late;
|
||||
assert.equal(adapter.calls.unmount, 2);
|
||||
});
|
||||
|
||||
test('switching one provider off releases only its viewer', async () => {
|
||||
const adapter = fakeAdapter();
|
||||
adapter.prewarm = async () => {};
|
||||
const { state, host } = harness(adapter);
|
||||
const entry = state.providers.get('mapillary');
|
||||
entry.on = true;
|
||||
await host.prewarm([entry]);
|
||||
host.unmount('panoramax');
|
||||
assert.equal(adapter.calls.unmount, 0, 'another provider leaves it alone');
|
||||
host.unmount('mapillary');
|
||||
assert.equal(adapter.calls.unmount, 1);
|
||||
});
|
||||
|
||||
test('a pose that arrives after the photo was closed does not bring it back', async () => {
|
||||
const adapter = fakeAdapter();
|
||||
const { state, host } = harness(adapter);
|
||||
@@ -238,44 +199,23 @@ function deferred() {
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
test('switching the layer off and on during a cold first open keeps the shared viewer', async () => {
|
||||
const gate = deferred();
|
||||
const adapter = fakeAdapter({ gate });
|
||||
const { state, host } = harness(adapter);
|
||||
const first = host.open('mapillary', 'a'); // the library is still loading
|
||||
host.unmount(); // layer off …
|
||||
const second = host.open('mapillary', 'b'); // … on again, and a new click
|
||||
gate.resolve();
|
||||
await Promise.all([first, second]);
|
||||
assert.equal(adapter.calls.unmount, 0, 'the outdated mount left it alone');
|
||||
assert.equal(adapter.calls.listeners, 1);
|
||||
assert.equal(state.street.error, null);
|
||||
assert.equal(state.street.imageId, 'b');
|
||||
await host.open('mapillary', 'c');
|
||||
assert.equal(state.street.error, null, 'later opens are not stuck');
|
||||
assert.equal(state.street.imageId, 'c');
|
||||
});
|
||||
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
test('every pose the viewer reports moves the marker and the followed camera', async () => {
|
||||
test('every pose the viewer reports moves the marker; only the open frames the camera', async () => {
|
||||
const adapter = fakeAdapter();
|
||||
const { state, host, marker, followed } = harness(adapter);
|
||||
const { host, marker, framing } = harness(adapter);
|
||||
await host.open('mapillary', 'a');
|
||||
assert.deepEqual(marker, [{ position: { lon: 1, lat: 2 }, bearing: 90 }]);
|
||||
assert.equal(followed.count, 1);
|
||||
// The user walks on, with FOLLOW on: the globe camera goes along.
|
||||
state.street.follow = true;
|
||||
// The user walks on: the marker goes along, the globe camera stays.
|
||||
adapter.emitPose('b', { position: { lon: 3, lat: 4 }, bearing: 180 });
|
||||
assert.deepEqual(marker.at(-1), {
|
||||
position: { lon: 3, lat: 4 },
|
||||
bearing: 180,
|
||||
});
|
||||
assert.equal(followed.count, 2);
|
||||
// Looking around in place is a pose too.
|
||||
adapter.emitPose('b', { position: { lon: 3, lat: 4 }, bearing: 270 });
|
||||
assert.equal(marker.at(-1).bearing, 270);
|
||||
assert.equal(followed.count, 3);
|
||||
assert.deepEqual([framing.begun, framing.started], [1, 1]);
|
||||
});
|
||||
|
||||
test('a pose on another sequence selects it, but not while the image still loads', async () => {
|
||||
@@ -445,27 +385,17 @@ test('a Mapillary viewer unmounted mid-download is never built; the next mount b
|
||||
assert.equal(viewers.live, 0, 'nothing left holding a WebGL context');
|
||||
});
|
||||
|
||||
test('a prewarm that gave up when the layer went off and on mid-download is followed by one that builds', async () => {
|
||||
test('two mounts on one host during the first download build one Mapillary viewer', async () => {
|
||||
const { gate, viewers } = fakeLibrary();
|
||||
const adapter = createMapillaryViewer({ source: { token: 't' } });
|
||||
const element = {};
|
||||
const first = adapter.prewarm(element);
|
||||
adapter.unmount(); // layer off while MapillaryJS downloads
|
||||
const second = adapter.prewarm(element); // layer on again
|
||||
const first = adapter.mount(element);
|
||||
const second = adapter.mount(element);
|
||||
gate.resolve();
|
||||
await Promise.all([first, second]);
|
||||
assert.equal(viewers.created, 1, 'the second prewarm built the viewer');
|
||||
assert.equal(viewers.live, 1);
|
||||
adapter.unmount();
|
||||
});
|
||||
|
||||
test('the Mapillary viewer leaves resizing to the panel, so a hidden one never asks for z=NaN', async () => {
|
||||
const { gate, viewers } = fakeLibrary();
|
||||
gate.resolve();
|
||||
const adapter = createMapillaryViewer({ source: { token: 't' } });
|
||||
await adapter.mount({});
|
||||
assert.equal(viewers.instances[0].options.trackResize, false);
|
||||
assert.equal(viewers.created, 1);
|
||||
adapter.unmount();
|
||||
assert.equal(viewers.live, 0, 'nothing left holding a WebGL context');
|
||||
});
|
||||
|
||||
test('closing the photo stops sequence playback in the hidden viewer', async () => {
|
||||
@@ -481,20 +411,6 @@ test('closing the photo stops sequence playback in the hidden viewer', async ()
|
||||
assert.equal(viewers.live, 1, 'the viewer itself stays warm');
|
||||
});
|
||||
|
||||
test('closing the photo or switching the layer off stops its framing flight', async () => {
|
||||
const adapter = fakeAdapter();
|
||||
const { framing, host } = harness(adapter);
|
||||
await host.open('mapillary', 'a');
|
||||
assert.equal(framing.started, 1, 'the open framed the photo');
|
||||
host.close();
|
||||
assert.equal(framing.cancelled, 1, 'closed: the globe stops flying to it');
|
||||
await host.open('mapillary', 'b');
|
||||
host.unmount('panoramax');
|
||||
assert.equal(framing.cancelled, 1, 'another provider leaves it alone');
|
||||
host.unmount();
|
||||
assert.equal(framing.cancelled, 2, 'layer off: likewise');
|
||||
});
|
||||
|
||||
/** The real Mapillary adapter, mounted on a ready library, and its poses. */
|
||||
async function mountedMapillary() {
|
||||
const library = fakeLibrary();
|
||||
@@ -562,7 +478,7 @@ test('back-to-back Mapillary opens download only the newer image', async () => {
|
||||
test('a photo asks for the camera when it starts opening and frames with that ticket once loaded', async () => {
|
||||
const openGate = deferred();
|
||||
const adapter = fakeAdapter({ openGate });
|
||||
const { state, host, framing } = harness(adapter);
|
||||
const { host, framing } = harness(adapter);
|
||||
const opening = host.open('mapillary', 'a');
|
||||
assert.equal(framing.begun, 1, 'claimed before the download');
|
||||
await settle();
|
||||
@@ -570,11 +486,6 @@ test('a photo asks for the camera when it starts opening and frames with that ti
|
||||
openGate.resolve();
|
||||
assert.equal(await opening, true);
|
||||
assert.equal(framing.started, 1);
|
||||
|
||||
// FOLLOW owns the camera already: an open neither claims nor frames.
|
||||
state.street.follow = true;
|
||||
assert.equal(await host.open('mapillary', 'b'), true);
|
||||
assert.deepEqual([framing.begun, framing.started], [1, 1]);
|
||||
});
|
||||
|
||||
/** The harness with a second provider, `google`, whose adapter has no render modes. */
|
||||
@@ -594,20 +505,6 @@ function twoProviders() {
|
||||
return { ...h, mapillary, google };
|
||||
}
|
||||
|
||||
test("opening one provider's image releases another's prewarmed viewer in the shared host", async () => {
|
||||
const { state, host, mapillary, google } = twoProviders();
|
||||
await host.prewarm([state.providers.get('mapillary')]);
|
||||
assert.equal(mapillary.calls.unmount, 0, 'warm');
|
||||
await host.open('google', 'g-1');
|
||||
assert.equal(mapillary.calls.unmount, 1, 'not left under the panorama');
|
||||
assert.equal(google.calls.unmount, 0);
|
||||
// While Google shows, Mapillary is not built again under it.
|
||||
let built = 0;
|
||||
mapillary.prewarm = async () => built++;
|
||||
await host.prewarm([...state.providers.values()]);
|
||||
assert.equal(built, 0);
|
||||
});
|
||||
|
||||
test('the render modes follow the viewer that is open', async () => {
|
||||
const { state, host } = twoProviders();
|
||||
await host.open('mapillary', 'm-1');
|
||||
@@ -634,23 +531,6 @@ test('a pose can date its image by month and name its place', async () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('a prewarm that lands after another provider took the host is released, not built under it', async () => {
|
||||
const { state, host, mapillary } = twoProviders();
|
||||
let release;
|
||||
mapillary.prewarm = () =>
|
||||
new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const warming = host.prewarm([state.providers.get('mapillary')]);
|
||||
await host.open('google', 'g-1'); // the user opens Street View meanwhile
|
||||
release();
|
||||
await warming;
|
||||
assert.equal(mapillary.calls.unmount, 1, 'released at once');
|
||||
// Not kept warm: the next Mapillary open mounts afresh.
|
||||
await host.open('mapillary', 'm-1');
|
||||
assert.equal(state.street.providerId, 'mapillary');
|
||||
});
|
||||
|
||||
test('FIT/FILL stays hidden for a second Street View panorama after a close', async () => {
|
||||
const { state, host } = twoProviders();
|
||||
await host.open('google', 'g-1');
|
||||
|
||||
@@ -45,11 +45,7 @@ test('desktop Display participates in Tactical exclusivity without changing mobi
|
||||
/panel\.id !== 'pp-toggles' && !panel\.classList\.contains\('collapsed'\)/,
|
||||
);
|
||||
assert.match(ui, /if \(exclusive && panel\.classList\.contains\('collapsed'\)\)\s*panel\.setAttribute\('aria-hidden', 'true'\)/);
|
||||
// A panel floating out of the rail (portable mode) is exempt from exclusivity.
|
||||
assert.match(
|
||||
css,
|
||||
/#right-context-rail\.layout-exclusive\s*> \[data-panel-id\]\.collapsed:not\(\.panel-floating\) \{/,
|
||||
);
|
||||
assert.match(css, /#right-context-rail\.layout-exclusive > \[data-panel-id\]\.collapsed \{/);
|
||||
});
|
||||
|
||||
test('explicit Contacts, Space Missions, and Cockpit actions expand Global Context after success', () => {
|
||||
|
||||
@@ -187,9 +187,5 @@ export function createMeshFloorSampler({
|
||||
() => eventTarget?.removeEventListener('gev:map-stack-changed', onStack),
|
||||
{ once: true },
|
||||
);
|
||||
return {
|
||||
sampleMeshFloorCells,
|
||||
cachedGroundFloor,
|
||||
visibleTilesetLoaded: _visibleTilesetLoaded,
|
||||
};
|
||||
return { sampleMeshFloorCells, cachedGroundFloor };
|
||||
}
|
||||
+12
-165
@@ -11,12 +11,11 @@ const read = (file) => fs.readFileSync(path.join(ROOT, file), 'utf8');
|
||||
|
||||
const html = expandApplicationHtml(read('index.html'));
|
||||
const css = readStylesheet(path.join(ROOT, 'style.css'));
|
||||
const panelCss = read('src/ui/styles/street-level.css');
|
||||
const panelChrome = read('src/ui/panelChrome.js');
|
||||
const layoutController = read('src/ui/panelLayoutController.js');
|
||||
const panelCss = read('src/ui/styles/street-level.css');
|
||||
const controls = read('src/ui/streetLevelControls.js');
|
||||
|
||||
test('Street Level is an ordinary collapsible GEV panel that starts collapsed', () => {
|
||||
test('Street Level is a collapsible right-rail panel that starts collapsed', () => {
|
||||
assert.match(
|
||||
html,
|
||||
/<div id="street-level-panel" class="panel-collapsible collapsed" data-panel-id="street-level-panel">/,
|
||||
@@ -51,179 +50,27 @@ test('the panel is registered with panel chrome, cockpit entry and the right rai
|
||||
panelChrome,
|
||||
/const isRightRail = \[[\s\S]*'street-level-panel'/,
|
||||
);
|
||||
// The layout controller moves every rail panel in one loop; ours is listed.
|
||||
assert.match(
|
||||
layoutController,
|
||||
/for \(const panel of \[[\s\S]*?this\._streetLevelPanel,[\s\S]*?\]\) \{[\s\S]*?stack\.insertBefore\(panel, globalContextPanel\)/,
|
||||
);
|
||||
for (const rule of [
|
||||
'#right-context-rail > #street-level-panel:not(.panel-floating)',
|
||||
'#right-context-rail #street-level-panel.collapsed:not(.panel-floating)',
|
||||
])
|
||||
assert.ok(css.includes(rule), `layers.css names ${rule}`);
|
||||
// Rail placement never applies to the panel once it floats as a window.
|
||||
assert.match(
|
||||
css,
|
||||
/#right-context-rail\.layout-focus\s*>\s*#street-level-panel:not\(\.collapsed\):not\(\.panel-floating\)/,
|
||||
);
|
||||
assert.ok(css.includes('#right-context-rail > #street-level-panel'));
|
||||
});
|
||||
|
||||
test('panel styles stay inside GEV conventions: no !important, no fixed panel', () => {
|
||||
assert.equal((panelCss.match(/!important/g) || []).length, 0);
|
||||
const fixed = panelCss
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.split('}')
|
||||
.filter((block) => /position:\s*fixed/.test(block))
|
||||
.map((block) => block.slice(0, block.indexOf('{')).trim());
|
||||
assert.deepEqual(fixed, ['.sl-viewer-wrap-expanded']);
|
||||
});
|
||||
|
||||
test('the keyless state gates the controls rather than leaving dead buttons', () => {
|
||||
// The key requirement is documented (README, .env.example), not repeated in the panel.
|
||||
assert.doesNotMatch(
|
||||
html,
|
||||
/sl-keyless|sl-query|sl-results|data-sl-suggestion/,
|
||||
);
|
||||
assert.match(html, /<fieldset id="sl-controls"/);
|
||||
assert.match(html, /<div id="sl-provider-chips" class="sl-chips"><\/div>/);
|
||||
// The gate holds the filters only, so the chips' key tooltip stays reachable.
|
||||
const gate = html.slice(
|
||||
html.indexOf('<fieldset id="sl-controls"'),
|
||||
html.indexOf('</fieldset>', html.indexOf('<fieldset id="sl-controls"')),
|
||||
);
|
||||
assert.doesNotMatch(gate, /sl-provider-chips/);
|
||||
test('the key gate holds every filter, with the error line outside it', () => {
|
||||
const start = html.indexOf('<fieldset id="sl-controls"');
|
||||
assert.ok(start > 0);
|
||||
const gate = html.slice(start, html.indexOf('</fieldset>', start));
|
||||
assert.match(gate, /data-sl-pano="all"/);
|
||||
assert.match(gate, /id="sl-since"/);
|
||||
assert.match(
|
||||
html,
|
||||
/<input id="sl-since" class="sl-range" type="range" min="0" max="8" step="1"/,
|
||||
);
|
||||
assert.match(html, /<output id="sl-since-label"/);
|
||||
assert.doesNotMatch(html, /<select id="sl-since"|value="year:/);
|
||||
assert.match(html, /<ul id="sl-legend"/);
|
||||
assert.ok(html.indexOf('id="sl-error"') < start);
|
||||
// The chips stay outside the gate, so their key tooltip is reachable.
|
||||
assert.match(html, /<div id="sl-provider-chips" class="sl-chips"><\/div>/);
|
||||
assert.doesNotMatch(gate, /sl-provider-chips/);
|
||||
assert.match(html, /id="sl-error"[^>]*role="alert"/);
|
||||
// The header pill is the layer's on/off switch, not just a readout.
|
||||
assert.match(
|
||||
html,
|
||||
/<button id="sl-status" class="sl-status" type="button" aria-pressed="false"/,
|
||||
);
|
||||
assert.match(
|
||||
controls,
|
||||
/this\.listen\(el\.status, 'click', \(\) => this\._toggleEnabled\(\)\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test('the expanded viewer is a modal dialog that restores focus', () => {
|
||||
assert.match(controls, /setAttribute\('role', 'dialog'\)/);
|
||||
assert.match(controls, /setAttribute\('aria-modal', 'true'\)/);
|
||||
assert.match(controls, /_expandReturnFocus/);
|
||||
// A real modal: the rest of the application is inert while it is shown.
|
||||
assert.match(controls, /node\.inert = true/);
|
||||
assert.match(controls, /node\.inert = false/);
|
||||
});
|
||||
|
||||
test('labels say what the buttons do', () => {
|
||||
for (const label of [
|
||||
'aria-label="Camera follows view"',
|
||||
'PROVIDERS',
|
||||
'SINCE',
|
||||
])
|
||||
assert.ok(html.includes(label), label);
|
||||
// The provider chips are the on/off switch; no separate buttons.
|
||||
assert.doesNotMatch(
|
||||
html,
|
||||
/OPEN NEAREST PHOTO|STREET LEVEL OFF|sl-enable-btn|sl-look-btn|LOOK HERE|STREET COCKPIT|>FRAME<|ZOOM TO RESULTS|ASK IN PLAIN ENGLISH/,
|
||||
);
|
||||
});
|
||||
|
||||
test('on phones the viewer is sized from the rail band, not its aspect ratio', () => {
|
||||
assert.match(
|
||||
panelCss,
|
||||
/@media \(max-width: 720px\) \{\s*\.sl-viewer \{[^}]*height: clamp\(/,
|
||||
);
|
||||
// Floating and expanded viewers must not inherit the phone height.
|
||||
for (const selector of [
|
||||
".panel-floating\\[style\\*='height'\\] \\.sl-viewer",
|
||||
'\\.sl-viewer-wrap-expanded \\.sl-viewer',
|
||||
])
|
||||
assert.match(panelCss, new RegExp(`${selector} \\{[^}]*height: auto`));
|
||||
});
|
||||
|
||||
test('the viewer comes first and the panel is a portable, resizable window', () => {
|
||||
const controlsBlock = html.slice(html.indexOf('<div class="sl-main">'));
|
||||
assert.ok(
|
||||
controlsBlock.indexOf('id="sl-viewer-wrap"') <
|
||||
controlsBlock.indexOf('class="sl-settings"'),
|
||||
'imagery sits above the settings, visible without scrolling',
|
||||
);
|
||||
assert.match(
|
||||
controlsBlock.slice(0, controlsBlock.indexOf('id="sl-viewer"')),
|
||||
/id="sl-follow-btn"/,
|
||||
'follow lives in the viewer toolbar',
|
||||
);
|
||||
const position = read('src/ui/panelPositionControls.js');
|
||||
assert.match(
|
||||
position,
|
||||
/id: 'street-level-panel',[\s\S]*?portable: true,[\s\S]*?min: \{ width: 320, height: 280 \}/,
|
||||
);
|
||||
assert.match(
|
||||
panelCss,
|
||||
/\.panel-floating\[style\*='height'\] \.sl-settings \{[^}]*overflow-y: auto/,
|
||||
);
|
||||
// Collapsing a floating panel, or SHRINK / Esc on the expanded viewer,
|
||||
// docks it rather than leaving a window over the globe.
|
||||
assert.match(
|
||||
position,
|
||||
/id: 'street-level-panel',[\s\S]*?dockOnCollapse: true/,
|
||||
);
|
||||
assert.match(
|
||||
read('src/ui/panelChrome.js'),
|
||||
/classList\.toggle\('collapsed', nextCollapsed\);[\s\S]{0,200}?if \(explicit && !restore\)\s*this\._panelPosition\?\.onPanelCollapsed\?\.\(panelId, nextCollapsed\)/,
|
||||
);
|
||||
assert.match(
|
||||
controls,
|
||||
/setViewerExpanded\(!this\.isViewerExpanded\(\), \{ dock: true \}\)/,
|
||||
);
|
||||
assert.match(controls, /this\.setViewerExpanded\(false, \{ dock: true \}\)/);
|
||||
assert.match(controls, /if \(!on && dock\) this\.actions\.dockPanel\?\.\(\)/);
|
||||
assert.match(
|
||||
read('src/ui/applicationShell.js'),
|
||||
/panelId === 'street-level-panel'\)\s*this\._streetLevelControls\?\.onPanelResized\(\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test('Clean View, recording and the cockpit hide the expanded viewer on <body> (P2-5)', () => {
|
||||
// EXPAND moves the viewer out of every panel, past the panel-hiding rules.
|
||||
assert.match(
|
||||
read('src/ui/styles/controls.css'),
|
||||
/body\.ui-clean-view \.sl-viewer-wrap-expanded \{\s*opacity: 0 !important;\s*visibility: hidden !important;/,
|
||||
);
|
||||
assert.match(
|
||||
read('src/ui/styles/recording.css'),
|
||||
/body\.recording-mode \.sl-viewer-wrap-expanded \{\s*opacity: 0 !important;\s*visibility: hidden !important;/,
|
||||
);
|
||||
assert.match(
|
||||
read('src/ui/styles/cockpit.css'),
|
||||
/body\.cockpit-mode\s*:is\([^)]*\.sl-viewer-wrap-expanded\s*\)\s*\{\s*display: none !important;/,
|
||||
);
|
||||
// Hidden, it must not keep holding Esc and Tab.
|
||||
assert.match(controls, /_isDialogShown\(\)/);
|
||||
});
|
||||
|
||||
test('the expanded viewer keeps the panel tokens (P3)', () => {
|
||||
assert.match(
|
||||
panelCss,
|
||||
/#street-level-panel,\s*\.sl-viewer-wrap \{\s*--sl-green:[^}]*--sl-text:[^}]*--sl-text-small:/,
|
||||
);
|
||||
});
|
||||
|
||||
test('radiogroups are one tab stop and their focus ring is not clipped (P3)', () => {
|
||||
const seg = /\.sl-seg \{([^}]*)\}/.exec(panelCss)[1];
|
||||
assert.doesNotMatch(seg, /overflow/);
|
||||
assert.match(
|
||||
panelCss,
|
||||
/\.sl-seg-btn:focus-visible \{[^}]*outline: 1px solid/,
|
||||
);
|
||||
test('each radiogroup is one tab stop, on its checked button', () => {
|
||||
for (const [group, checked] of [
|
||||
['data-sl-render', 'letterbox'],
|
||||
['data-sl-pano', 'all'],
|
||||
|
||||
@@ -4,10 +4,19 @@ import { PbfWriter } from 'pbf';
|
||||
import {
|
||||
layerWriter,
|
||||
lineGeometry,
|
||||
lonLatToTileLocal,
|
||||
zigzag,
|
||||
} from '../layers/streetLevel/providers/mapillary/coverageFixture.mjs';
|
||||
|
||||
/** Tile-local [x, y] (0..extent) of a lon/lat inside tile (x, y, z). */
|
||||
function lonLatToTileLocal([lon, lat], { x, y, z }, extent = 4096) {
|
||||
const n = 2 ** z;
|
||||
const rad = (lat * Math.PI) / 180;
|
||||
const tx = ((lon + 180) / 360) * n;
|
||||
const ty =
|
||||
((1 - Math.log(Math.tan(rad) + 1 / Math.cos(rad)) / Math.PI) / 2) * n;
|
||||
return [Math.round((tx - x) * extent), Math.round((ty - y) * extent)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a Panoramax `/api/map/{z}/{x}/{y}.mvt` tile from lon/lat features.
|
||||
* @param {{x: number, y: number, z: number}} tile
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Shared Street Level test stand-ins: provider snapshot, minimal provider, ray-casting camera. */
|
||||
/** Shared Street Level test stand-ins: provider snapshot and provider, Mapillary source, photo viewer, Cesium viewer, ray-casting camera. */
|
||||
import * as Cesium from 'cesium';
|
||||
import { MAPILLARY_CREDIT_HTML } from '../layers/streetLevel/providers/mapillary/policy.js';
|
||||
import {
|
||||
@@ -42,8 +42,6 @@ export function fakeStreetLevelProvider({
|
||||
const filters = [];
|
||||
const stats = {
|
||||
count: 0,
|
||||
zoom: null,
|
||||
kind: null,
|
||||
loading: false,
|
||||
hint: '',
|
||||
error: null,
|
||||
@@ -70,7 +68,6 @@ export function fakeStreetLevelProvider({
|
||||
activate: () => calls.activate++,
|
||||
deactivate: () => calls.deactivate++,
|
||||
destroy() {},
|
||||
refreshCoverage() {},
|
||||
setFilter: (filter) => filters.push(filter),
|
||||
coverageStats: () => ({ ...stats }),
|
||||
handlePick: () => false,
|
||||
@@ -88,6 +85,135 @@ export function fakeStreetLevelProvider({
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A Mapillary source for the provider: a configured key, empty tiles, and the
|
||||
* given lookups; `calls` records tile requests.
|
||||
*/
|
||||
export function fakeMapillarySource(overrides = {}) {
|
||||
const calls = { tiles: [] };
|
||||
return {
|
||||
calls,
|
||||
hasToken: () => true,
|
||||
getStatus: async () => ({ configured: true }),
|
||||
getTile: async (...args) => {
|
||||
calls.tiles.push(args);
|
||||
return new Uint8Array(0);
|
||||
},
|
||||
getSequenceImages: async () => [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A photo viewer in place of MapillaryJS: `calls` records use, and `open`
|
||||
* reports a pose for the image (when `pose` is given) as the real one does.
|
||||
*/
|
||||
export function fakePhotoViewer({ pose = null } = {}) {
|
||||
const calls = { mount: 0, open: [], unmount: 0 };
|
||||
let emit = null;
|
||||
return {
|
||||
calls,
|
||||
mount: async () => calls.mount++,
|
||||
async open(imageId) {
|
||||
calls.open.push(imageId);
|
||||
if (pose) emit?.({ imageId, ...pose });
|
||||
},
|
||||
close() {},
|
||||
unmount: () => calls.unmount++,
|
||||
resize() {},
|
||||
onPose(listener) {
|
||||
emit = listener;
|
||||
return () => {
|
||||
emit = null;
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A stand-in Cesium viewer a layer can be enabled on: a canvas for the click
|
||||
* handler, camera events with Cesium's flight bookkeeping (`flights` counts
|
||||
* them, lists them and can `land` the current one), primitive lists and a
|
||||
* credit display. With `view`, the camera looks straight down on Sacramento
|
||||
* from 900 m, so coverage asks for tiles.
|
||||
*/
|
||||
export function fakeCesiumViewer({ view = false } = {}) {
|
||||
const credits = [];
|
||||
let flight = null;
|
||||
const flights = {
|
||||
started: 0,
|
||||
cancelled: 0,
|
||||
/** Every flight asked for, oldest first: `{ sphere, options }`. */
|
||||
list: [],
|
||||
land() {
|
||||
const current = flight;
|
||||
flight = null;
|
||||
current?.options.complete?.();
|
||||
},
|
||||
};
|
||||
const stopFlight = () => {
|
||||
const current = flight;
|
||||
flight = null;
|
||||
current?.options.cancel?.();
|
||||
return Boolean(current);
|
||||
};
|
||||
const canvas = Object.assign(new EventTarget(), {
|
||||
style: {},
|
||||
// Keep Cesium's handler on the canvas; there is no real document here.
|
||||
disableRootEvents: true,
|
||||
onwheel: null,
|
||||
...(view ? { clientWidth: 100, clientHeight: 100 } : {}),
|
||||
});
|
||||
const camera = Object.assign(
|
||||
view
|
||||
? rayCamera({
|
||||
lon: -121.4944,
|
||||
lat: 38.5816,
|
||||
altitude: 900,
|
||||
pitch: -90,
|
||||
width: 100,
|
||||
height: 100,
|
||||
})
|
||||
: {},
|
||||
{
|
||||
changed: new Cesium.Event(),
|
||||
moveStart: new Cesium.Event(),
|
||||
moveEnd: new Cesium.Event(),
|
||||
flyToBoundingSphere(sphere, options = {}) {
|
||||
stopFlight();
|
||||
flight = { sphere, options };
|
||||
flights.list.push(flight);
|
||||
flights.started++;
|
||||
},
|
||||
cancelFlight() {
|
||||
if (stopFlight()) flights.cancelled++;
|
||||
},
|
||||
},
|
||||
);
|
||||
return {
|
||||
credits,
|
||||
flights,
|
||||
scene: {
|
||||
canvas,
|
||||
primitives: { add: (p) => p, remove() {} },
|
||||
groundPrimitives: { add: (p) => p, remove() {} },
|
||||
// Enough of a scene for the position marker to clamp to the ground.
|
||||
frameState: { mode: Cesium.SceneMode.SCENE3D },
|
||||
updateHeight: () => () => {},
|
||||
getHeight: () => undefined,
|
||||
},
|
||||
camera,
|
||||
creditDisplay: {
|
||||
addStaticCredit: (credit) => credits.push(credit),
|
||||
removeStaticCredit: (credit) => {
|
||||
// Cesium ignores a credit it does not hold.
|
||||
const at = credits.indexOf(credit);
|
||||
if (at !== -1) credits.splice(at, 1);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A pinhole camera `altitude` m above WGS84, at `heading` (deg from north) and
|
||||
* `pitch` (deg, negative down); `pickEllipsoid` hits whatever ellipsoid it gets.
|
||||
@@ -143,7 +269,6 @@ export function rayCamera({
|
||||
const hit = Cesium.IntersectionTests.rayEllipsoid(ray, ellipsoid);
|
||||
return hit ? Cesium.Ray.getPoint(ray, hit.start) : undefined;
|
||||
},
|
||||
computeViewRectangle: () => undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+659
-1273
File diff suppressed because it is too large.
Load diff
@@ -1,57 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
driftedEdges,
|
||||
growthDelta,
|
||||
handlePoint,
|
||||
MIN_SIZE,
|
||||
pinnedEdges,
|
||||
STORAGE_KEY,
|
||||
} from '../../scripts/qa-panel-resize.mjs';
|
||||
import { RESIZE_DIRECTIONS } from '../ui/panelResize.js';
|
||||
|
||||
test('the harness targets the key and minimums the controls use', () => {
|
||||
assert.equal(STORAGE_KEY, 'godsEyeView.v8.panelPos.cctv-panel');
|
||||
assert.deepEqual(MIN_SIZE, { width: 300, height: 160 });
|
||||
});
|
||||
|
||||
test('pinned edges are exactly the sides a direction does not name', () => {
|
||||
assert.deepEqual(pinnedEdges('n'), ['bottom', 'right', 'left']);
|
||||
assert.deepEqual(pinnedEdges('se'), ['top', 'left']);
|
||||
assert.deepEqual(pinnedEdges('nw'), ['bottom', 'right']);
|
||||
for (const dir of RESIZE_DIRECTIONS)
|
||||
assert.equal(pinnedEdges(dir).length, 4 - dir.length);
|
||||
});
|
||||
|
||||
test('growth deltas push each named edge outward', () => {
|
||||
assert.deepEqual(growthDelta('n', 40), { dx: 0, dy: -40 });
|
||||
assert.deepEqual(growthDelta('e', 40), { dx: 40, dy: 0 });
|
||||
assert.deepEqual(growthDelta('sw', 40), { dx: -40, dy: 40 });
|
||||
assert.deepEqual(growthDelta('se', 40), { dx: 40, dy: 40 });
|
||||
});
|
||||
|
||||
test('handle points land inside the strip or corner for every direction', () => {
|
||||
const rect = { left: 100, top: 200, width: 400, height: 300 };
|
||||
rect.right = rect.left + rect.width;
|
||||
rect.bottom = rect.top + rect.height;
|
||||
assert.deepEqual(handlePoint(rect, 'n'), { x: 300, y: 201 });
|
||||
assert.deepEqual(handlePoint(rect, 'w'), { x: 101, y: 350 });
|
||||
assert.deepEqual(handlePoint(rect, 'se'), { x: 496, y: 496 });
|
||||
assert.deepEqual(handlePoint(rect, 'nw'), { x: 104, y: 204 });
|
||||
for (const dir of RESIZE_DIRECTIONS) {
|
||||
const { x, y } = handlePoint(rect, dir);
|
||||
assert.ok(x >= rect.left && x <= rect.right, `${dir} x inside`);
|
||||
assert.ok(y >= rect.top && y <= rect.bottom, `${dir} y inside`);
|
||||
}
|
||||
});
|
||||
|
||||
test('drift detection reports only pinned edges that moved', () => {
|
||||
const before = { left: 100, top: 100, width: 400, height: 300 };
|
||||
const grewEast = { left: 100, top: 100, width: 440, height: 300 };
|
||||
assert.deepEqual(driftedEdges(before, grewEast, 'e'), []);
|
||||
const slidWest = { left: 60, top: 100, width: 440, height: 300 };
|
||||
assert.deepEqual(driftedEdges(before, slidWest, 'e'), ['left']);
|
||||
assert.deepEqual(driftedEdges(before, slidWest, 'w'), []);
|
||||
const wobble = { left: 101, top: 100, width: 439, height: 300 };
|
||||
assert.deepEqual(driftedEdges(before, wobble, 'e'), []);
|
||||
});
|
||||
@@ -1,21 +1,9 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import { inflateSync } from 'node:zlib';
|
||||
import { createApplicationStreetLevel } from '../app/layers/streetLevel.js';
|
||||
import {
|
||||
fixtureTile,
|
||||
EXPECTED_PROVIDERS,
|
||||
isCollapsed,
|
||||
PARK,
|
||||
STRICT_ALLOWED_SKIPS,
|
||||
strictViolations,
|
||||
} from '../../scripts/qa-street-level.mjs';
|
||||
import {
|
||||
answerMapillaryRequest,
|
||||
describeCall,
|
||||
metresApart,
|
||||
PHOTO_LINE,
|
||||
PHOTO_SEQUENCE_ID,
|
||||
photoImages,
|
||||
THUMB_HOST,
|
||||
@@ -40,350 +28,6 @@ import {
|
||||
resizedImageUrl,
|
||||
} from '../layers/streetLevel/providers/kartaview/policy.js';
|
||||
|
||||
// The press helpers wait for paint; Node has no frames, so a tick stands in.
|
||||
globalThis.requestAnimationFrame ??= (callback) =>
|
||||
setTimeout(() => callback(Date.now()), 0);
|
||||
|
||||
test('the harness expects exactly the providers the app registers, in chip order', () => {
|
||||
// Any method a provider asks its source for is a no-op: only ids matter.
|
||||
const source = new Proxy({}, { get: () => () => {} });
|
||||
const layer = createApplicationStreetLevel({
|
||||
surface: null,
|
||||
sources: { mapillary: source },
|
||||
});
|
||||
assert.deepEqual([...EXPECTED_PROVIDERS], [...layer.providerIds]);
|
||||
});
|
||||
|
||||
test('a panel reads as collapsed only with the collapsed class', () => {
|
||||
assert.equal(isCollapsed(['panel-collapsible', 'collapsed']), true);
|
||||
assert.equal(isCollapsed(['panel-collapsible']), false);
|
||||
});
|
||||
|
||||
test('the harness only runs its browser flow when executed directly', () => {
|
||||
const source = fs.readFileSync(
|
||||
new URL('../../scripts/qa-street-level.mjs', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/import\.meta\.url === pathToFileURL\(process\.argv\[1\]\)\.href/,
|
||||
);
|
||||
assert.match(source, /PUPPETEER_EXECUTABLE_PATH/);
|
||||
assert.match(source, /--url/);
|
||||
});
|
||||
|
||||
test('fixture tiles give the hermetic gate something real to filter', async () => {
|
||||
const { decodeCoverageTile } =
|
||||
await import('../layers/streetLevel/providers/mapillary/decode.js');
|
||||
const now = Date.UTC(2026, 9, 1);
|
||||
const street = decodeCoverageTile(fixtureTile(14, 2662, 6286, now), {
|
||||
x: 2662,
|
||||
y: 6286,
|
||||
z: 14,
|
||||
});
|
||||
const grid = street.sequences.filter((s) => s.id !== PHOTO_SEQUENCE_ID);
|
||||
assert.equal(grid.length, 8);
|
||||
const pano = grid.filter((s) => s.isPano).length;
|
||||
assert.ok(pano > 0 && pano < 8, '360° and flat both present');
|
||||
const year = 365 * 86_400_000;
|
||||
const old = grid.filter((s) => now - s.capturedAt > year).length;
|
||||
assert.ok(old > 0 && old < 8, 'recent and older both present');
|
||||
const orbit = decodeCoverageTile(fixtureTile(3, 1, 3, now), {
|
||||
x: 1,
|
||||
y: 3,
|
||||
z: 3,
|
||||
});
|
||||
assert.ok(orbit.overview.length > 0, 'overview points from orbit');
|
||||
assert.equal(fixtureTile(8, 1, 1, now).length, 0, 'nothing in between');
|
||||
});
|
||||
|
||||
/* ── The real status route probe ─────────────────────────────────────── */
|
||||
|
||||
test('the gate requires the server’s real Mapillary status route', async () => {
|
||||
const { assertRealStatusRoute } =
|
||||
await import('../../scripts/qa-street-level.mjs');
|
||||
const asked = [];
|
||||
const answer = (body, init) => async (url) => {
|
||||
asked.push(url);
|
||||
return typeof body === 'string'
|
||||
? new Response(body, init)
|
||||
: Response.json(body, init);
|
||||
};
|
||||
assert.deepEqual(
|
||||
await assertRealStatusRoute(
|
||||
'http://localhost:4173',
|
||||
answer({ configured: false }),
|
||||
),
|
||||
{ configured: false },
|
||||
);
|
||||
assert.deepEqual(asked, ['http://localhost:4173/api/mapillary/status']);
|
||||
for (const [why, fetchImpl] of [
|
||||
[
|
||||
'an unregistered route (the API 404)',
|
||||
answer({ error: 'Unknown API route' }, { status: 404 }),
|
||||
],
|
||||
[
|
||||
'the SPA fallback',
|
||||
answer('<!doctype html><title>GEV</title>', {
|
||||
headers: { 'content-type': 'text/html' },
|
||||
}),
|
||||
],
|
||||
['a malformed status', answer({ configured: 'yes' })],
|
||||
[
|
||||
'an unreachable server',
|
||||
async () => {
|
||||
throw new TypeError('fetch failed');
|
||||
},
|
||||
],
|
||||
])
|
||||
await assert.rejects(
|
||||
assertRealStatusRoute('http://localhost:4173', fetchImpl),
|
||||
/api\/mapillary\/status/,
|
||||
why,
|
||||
);
|
||||
});
|
||||
|
||||
/* ── Header press-and-verify shared with the panel gate ───────────────── */
|
||||
|
||||
/**
|
||||
* A page stand-in that runs page functions in Node against a fake document.
|
||||
* The hit check always sees the header; `presses` says where each pointerdown
|
||||
* lands ('header', 'other', 'moved' or 'none').
|
||||
*/
|
||||
function fakePanelPage({ presses = [], floating = false, stuck = false } = {}) {
|
||||
const state = { floating, downs: 0, last: null, dragging: false, shift: 0 };
|
||||
const listeners = [];
|
||||
const node = (tagName, id, classes, inHeader) => ({
|
||||
tagName,
|
||||
id,
|
||||
classList: classes,
|
||||
closest: (selector) =>
|
||||
inHeader && selector.includes('.panel-header') ? {} : null,
|
||||
});
|
||||
const header = node('DIV', '', ['panel-title'], true);
|
||||
const other = node('SECTION', 'weather-panel', ['panel'], false);
|
||||
const rect = (left, top, width, height) => () => ({
|
||||
left,
|
||||
top,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
const document = {
|
||||
querySelector: () => ({ getBoundingClientRect: rect(120, 210, 100, 20) }),
|
||||
getElementById: () => ({
|
||||
getBoundingClientRect: () => rect(100, 200 + state.shift, 300, 400)(),
|
||||
classList: {
|
||||
contains: (name) => name === 'panel-floating' && state.floating,
|
||||
},
|
||||
}),
|
||||
elementFromPoint: () => header,
|
||||
};
|
||||
const window = {
|
||||
addEventListener: (type, listener) => listeners.push(listener),
|
||||
};
|
||||
const inPage = (fn, ...args) => {
|
||||
const saved = { document: globalThis.document, window: globalThis.window };
|
||||
Object.assign(globalThis, { document, window });
|
||||
try {
|
||||
return fn(...args);
|
||||
} finally {
|
||||
Object.assign(globalThis, saved);
|
||||
}
|
||||
};
|
||||
const evaluate = async (fn, ...args) => inPage(fn, ...args);
|
||||
const press = () => {
|
||||
state.last = presses[state.downs++] ?? 'header';
|
||||
// 'none': the pointerdown never reaches the page.
|
||||
if (state.last === 'none') return;
|
||||
const target = state.last === 'header' ? header : other;
|
||||
// 'moved': the panel's layout shifted just before the press landed.
|
||||
if (state.last === 'moved') state.shift = -116;
|
||||
for (const listener of listeners.splice(0)) inPage(listener, { target });
|
||||
state.shift = 0;
|
||||
};
|
||||
return {
|
||||
state,
|
||||
evaluate,
|
||||
async waitForFunction(fn, options, ...args) {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (await evaluate(fn, ...args)) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
}
|
||||
throw Object.assign(new Error('timed out'), { name: 'TimeoutError' });
|
||||
},
|
||||
mouse: {
|
||||
async move() {},
|
||||
async down() {
|
||||
press();
|
||||
state.dragging = true;
|
||||
},
|
||||
async up() {
|
||||
if (state.dragging && state.last === 'header' && !stuck)
|
||||
state.floating = true;
|
||||
state.dragging = false;
|
||||
},
|
||||
async click(x, y, { clickCount }) {
|
||||
if (clickCount === 1) press();
|
||||
else if (state.last === 'header' && !stuck) state.floating = false;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('a header press is retried only when it provably missed the header', async () => {
|
||||
const { pressMissed } = await import('../../scripts/qa-panelDrag.mjs');
|
||||
assert.equal(pressMissed({ done: true, pressed: null }), false);
|
||||
assert.equal(
|
||||
pressMissed({ done: false, pressed: { inHeader: true } }),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
pressMissed({ done: false, pressed: { inHeader: false } }),
|
||||
true,
|
||||
);
|
||||
// No recorded press is a failure, not a miss to retry.
|
||||
assert.equal(pressMissed({ done: false, pressed: null }), false);
|
||||
});
|
||||
|
||||
test('a lift whose press the rail moved off the header is retried, then lifts', async () => {
|
||||
const { liftPanelByHeader, describePress } =
|
||||
await import('../../scripts/qa-panelDrag.mjs');
|
||||
const page = fakePanelPage({ presses: ['other', 'header'] });
|
||||
const notes = [];
|
||||
const attempt = await liftPanelByHeader(page, 'street-level-panel', {
|
||||
dx: -400,
|
||||
dy: 80,
|
||||
log: (line) => notes.push(line),
|
||||
pauseMs: 0,
|
||||
});
|
||||
assert.equal(attempt.done, true);
|
||||
assert.equal(page.state.downs, 2);
|
||||
assert.equal(notes.length, 2, 'a note and a GitHub warning');
|
||||
assert.match(notes[0], /missed the street-level-panel header/);
|
||||
assert.match(notes[0], /section#weather-panel\.panel/, 'names what it hit');
|
||||
assert.match(notes[0], /pre-press hit check passed/);
|
||||
assert.match(notes[0], /stillness (held|timed out)/);
|
||||
assert.match(
|
||||
notes[0],
|
||||
/panel box at hit check 100,200 300x400 -> at press 100,200 300x400/,
|
||||
);
|
||||
assert.match(notes[1], /^::warning title=Panel press retried::retry 1\/2/);
|
||||
assert.match(describePress(attempt), /in header: true, after 1 retry$/);
|
||||
});
|
||||
|
||||
test('a press in the header that does not lift fails at once, naming the target', async () => {
|
||||
const { liftPanelByHeader, describePress } =
|
||||
await import('../../scripts/qa-panelDrag.mjs');
|
||||
const page = fakePanelPage({ stuck: true });
|
||||
const attempt = await liftPanelByHeader(page, 'street-level-panel', {
|
||||
dx: -400,
|
||||
dy: 80,
|
||||
log: () => assert.fail('no retry'),
|
||||
pauseMs: 0,
|
||||
});
|
||||
assert.equal(attempt.done, false);
|
||||
assert.equal(page.state.downs, 1);
|
||||
assert.match(describePress(attempt), /on div\.panel-title, in header: true/);
|
||||
});
|
||||
|
||||
test('presses that keep missing stop after the retries and report the miss', async () => {
|
||||
const { liftPanelByHeader, PRESS_RETRIES } =
|
||||
await import('../../scripts/qa-panelDrag.mjs');
|
||||
const page = fakePanelPage({ presses: ['other', 'other', 'other', 'other'] });
|
||||
const attempt = await liftPanelByHeader(page, 'street-level-panel', {
|
||||
dx: -400,
|
||||
dy: 80,
|
||||
log: () => {},
|
||||
pauseMs: 0,
|
||||
});
|
||||
assert.equal(attempt.done, false);
|
||||
assert.equal(page.state.downs, PRESS_RETRIES + 1);
|
||||
assert.equal(attempt.pressed.inHeader, false);
|
||||
});
|
||||
|
||||
test('a header double-click docks, waiting on the docked state', async () => {
|
||||
const { dockPanelByDoubleClick } =
|
||||
await import('../../scripts/qa-panelDrag.mjs');
|
||||
const page = fakePanelPage({ floating: true, presses: ['other', 'header'] });
|
||||
const attempt = await dockPanelByDoubleClick(page, 'street-level-panel', {
|
||||
log: () => {},
|
||||
pauseMs: 0,
|
||||
});
|
||||
assert.equal(attempt.done, true);
|
||||
assert.equal(page.state.floating, false);
|
||||
assert.equal(page.state.downs, 2, 'the missed first press was retried');
|
||||
});
|
||||
|
||||
test('both panel gates press the header through the shared helper', () => {
|
||||
for (const script of ['qa-street-level.mjs', 'qa-panel-resize.mjs']) {
|
||||
const source = fs.readFileSync(
|
||||
new URL(`../../scripts/${script}`, import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(source, /from '\.\/qa-panelDrag\.mjs'/, script);
|
||||
assert.match(source, /liftPanelByHeader\(page, PANEL_ID/, script);
|
||||
assert.match(source, /dockPanelByDoubleClick\(page, PANEL_ID/, script);
|
||||
}
|
||||
});
|
||||
|
||||
/* ── Hermetic photo flow: the photo line and the Graph fixtures ────────── */
|
||||
|
||||
test('the photo sequence runs through the parked view, clipped to each tile it crosses', async () => {
|
||||
const { decodeCoverageTile } =
|
||||
await import('../layers/streetLevel/providers/mapillary/decode.js');
|
||||
const { lonToTileX, latToTileY } =
|
||||
await import('../layers/streetLevel/tileMath.js');
|
||||
const x = lonToTileX(PHOTO_LINE.lon, 14);
|
||||
const rows = new Set(
|
||||
[PHOTO_LINE.south, PHOTO_LINE.north].map((lat) => latToTileY(lat, 14)),
|
||||
);
|
||||
assert.ok(rows.size > 1, 'the line crosses a tile edge');
|
||||
let south = Infinity;
|
||||
let north = -Infinity;
|
||||
for (const y of rows) {
|
||||
const line = decodeCoverageTile(fixtureTile(14, x, y), {
|
||||
x,
|
||||
y,
|
||||
z: 14,
|
||||
}).sequences.find((s) => s.id === PHOTO_SEQUENCE_ID);
|
||||
assert.ok(line, `tile 14/${x}/${y} carries the photo line`);
|
||||
for (const [lon, lat] of line.parts[0]) {
|
||||
assert.ok(Math.abs(lon - PHOTO_LINE.lon) < 1e-4);
|
||||
south = Math.min(south, lat);
|
||||
north = Math.max(north, lat);
|
||||
}
|
||||
}
|
||||
assert.ok(Math.abs(south - PHOTO_LINE.south) < 1e-4);
|
||||
assert.ok(Math.abs(north - PHOTO_LINE.north) < 1e-4);
|
||||
// Far away there is no photo line, only the grid.
|
||||
const elsewhere = decodeCoverageTile(fixtureTile(14, 100, 100), {
|
||||
x: 100,
|
||||
y: 100,
|
||||
z: 14,
|
||||
});
|
||||
assert.equal(
|
||||
elsewhere.sequences.some((s) => s.id === PHOTO_SEQUENCE_ID),
|
||||
false,
|
||||
);
|
||||
// The parked camera looks down onto the line.
|
||||
assert.equal(PARK.lon, PHOTO_LINE.lon);
|
||||
assert.ok(PARK.lat > PHOTO_LINE.south && PARK.lat < PHOTO_LINE.north);
|
||||
});
|
||||
|
||||
test('the fixture photos: one sequence, 360° and flat, close enough for any nearest lookup on the line', () => {
|
||||
const images = photoImages(Date.UTC(2026, 9, 1));
|
||||
assert.ok(images.length >= 3);
|
||||
assert.ok(images.some((image) => image.isPano));
|
||||
assert.ok(images.some((image) => !image.isPano));
|
||||
for (let i = 1; i < images.length; i++) {
|
||||
const gap = metresApart(images[i - 1], images[i]);
|
||||
// Above the cones' 3 m thinning, and at most 50 m apart: any point on
|
||||
// the line has a photo within the Graph API's 50 m radius.
|
||||
assert.ok(gap > 3 && gap < 50, `gap ${gap}`);
|
||||
}
|
||||
assert.equal(new Set(images.map((image) => image.id)).size, images.length);
|
||||
});
|
||||
|
||||
const graph = (path, params) =>
|
||||
`https://graph.mapillary.com/${path}?${new URLSearchParams({ access_token: 'MLY|0|qa-fixture', ...params })}`;
|
||||
const answer = (url, method = 'GET') => {
|
||||
@@ -394,49 +38,6 @@ const answer = (url, method = 'GET') => {
|
||||
};
|
||||
};
|
||||
|
||||
test('the Graph fixtures answer the provider’s nearest and sequence lookups', () => {
|
||||
const images = photoImages();
|
||||
const middle = images[Math.floor(images.length / 2)];
|
||||
const near = answer(
|
||||
graph('images', {
|
||||
lat: String(middle.lat + 0.0001),
|
||||
lng: String(middle.lon),
|
||||
radius: '50',
|
||||
limit: '8',
|
||||
fields: 'id,geometry,is_pano,captured_at,sequence',
|
||||
}),
|
||||
);
|
||||
assert.equal(near.status, 200);
|
||||
assert.ok(near.json.data.length > 0);
|
||||
assert.ok(near.json.data.some((record) => record.id === middle.id));
|
||||
for (const record of near.json.data) {
|
||||
assert.deepEqual(Object.keys(record).sort(), [
|
||||
'captured_at',
|
||||
'geometry',
|
||||
'id',
|
||||
'is_pano',
|
||||
'sequence',
|
||||
]);
|
||||
assert.equal(record.sequence, PHOTO_SEQUENCE_ID);
|
||||
}
|
||||
const far = answer(
|
||||
graph('images', { lat: '0', lng: '0', radius: '50', fields: 'id' }),
|
||||
);
|
||||
assert.deepEqual(far.json.data, [], 'nothing outside the radius');
|
||||
const sequence = answer(
|
||||
graph('images', {
|
||||
sequence_ids: PHOTO_SEQUENCE_ID,
|
||||
fields: 'id,geometry,compass_angle,captured_at,is_pano',
|
||||
limit: '2000',
|
||||
}),
|
||||
);
|
||||
assert.equal(sequence.json.data.length, images.length);
|
||||
assert.deepEqual(
|
||||
answer(graph('images', { sequence_ids: 'other', fields: 'id' })).json.data,
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('the Graph fixtures answer every call MapillaryJS makes to open a photo', () => {
|
||||
const [first, second] = photoImages();
|
||||
const spatial = answer(
|
||||
@@ -531,134 +132,6 @@ test('evidence never carries a Mapillary token', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('--strict accepts only the documented skips', () => {
|
||||
assert.deepEqual(
|
||||
[...STRICT_ALLOWED_SKIPS],
|
||||
['no Google 3D', 'no Google key', 'fixtures only'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
strictViolations([
|
||||
{ reason: 'no Google 3D', label: 'terrain' },
|
||||
{ reason: 'no Google key', label: 'street view' },
|
||||
{ reason: 'fixtures only', label: 'keyless page' },
|
||||
{ reason: 'no Mapillary key', label: 'the keyed steps' },
|
||||
{ reason: 'Google key refused', label: 'street view' },
|
||||
]),
|
||||
[
|
||||
{ reason: 'no Mapillary key', label: 'the keyed steps' },
|
||||
{ reason: 'Google key refused', label: 'street view' },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('a press that records no pointerdown fails at once, without a retry', async () => {
|
||||
const { liftPanelByHeader, describePress } =
|
||||
await import('../../scripts/qa-panelDrag.mjs');
|
||||
const page = fakePanelPage({ presses: ['none', 'header'] });
|
||||
const attempt = await liftPanelByHeader(page, 'street-level-panel', {
|
||||
dx: -400,
|
||||
dy: 80,
|
||||
log: () => assert.fail('no retry'),
|
||||
pauseMs: 0,
|
||||
});
|
||||
assert.equal(attempt.done, false);
|
||||
assert.equal(attempt.pressed, null);
|
||||
assert.equal(page.state.downs, 1);
|
||||
assert.match(describePress(attempt), /no pointerdown reached the page/);
|
||||
});
|
||||
|
||||
test('QA_FAIL_ON_RETRY turns a retry into a failure that says why', async () => {
|
||||
const { liftPanelByHeader, failOnRetryDefault } =
|
||||
await import('../../scripts/qa-panelDrag.mjs');
|
||||
assert.equal(failOnRetryDefault({ QA_FAIL_ON_RETRY: '1' }, []), true);
|
||||
assert.equal(failOnRetryDefault({}, ['--fail-on-retry']), true);
|
||||
assert.equal(failOnRetryDefault({}, []), false);
|
||||
const page = fakePanelPage({ presses: ['other', 'header'] });
|
||||
await assert.rejects(
|
||||
liftPanelByHeader(page, 'street-level-panel', {
|
||||
dx: -400,
|
||||
dy: 80,
|
||||
log: () => {},
|
||||
pauseMs: 0,
|
||||
failOnRetry: true,
|
||||
}),
|
||||
/QA_FAIL_ON_RETRY=1 .*missed the street-level-panel header.*pre-press hit check passed/,
|
||||
);
|
||||
assert.equal(page.state.downs, 1);
|
||||
});
|
||||
|
||||
test('QA_FAIL_ON_RETRY retries, with a warning, a miss the panel moving explains', async () => {
|
||||
const { liftPanelByHeader, panelMoved } =
|
||||
await import('../../scripts/qa-panelDrag.mjs');
|
||||
assert.equal(panelMoved({ left: 0, top: 382 }, { left: 0, top: 266 }), true);
|
||||
assert.equal(panelMoved({ left: 0, top: 382 }, { left: 1, top: 383 }), false);
|
||||
const page = fakePanelPage({ presses: ['moved', 'header'] });
|
||||
const lines = [];
|
||||
const attempt = await liftPanelByHeader(page, 'street-level-panel', {
|
||||
dx: -400,
|
||||
dy: 80,
|
||||
log: (line) => lines.push(line),
|
||||
pauseMs: 0,
|
||||
failOnRetry: true,
|
||||
});
|
||||
assert.equal(attempt.done, true, 'the second press lifted it');
|
||||
assert.equal(page.state.downs, 2);
|
||||
assert.ok(
|
||||
lines.some((line) =>
|
||||
/::warning title=Panel press retried::.*-> at press 100,84/.test(line),
|
||||
),
|
||||
lines.join('\n'),
|
||||
);
|
||||
});
|
||||
|
||||
test('both gates save failure evidence and fail on render-loop errors', () => {
|
||||
for (const script of ['qa-street-level.mjs', 'qa-panel-resize.mjs']) {
|
||||
const source = fs.readFileSync(
|
||||
new URL(`../../scripts/${script}`, import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(source, /saveFailureArtifacts\(/, script);
|
||||
assert.match(source, /hookRenderErrors\(/, script);
|
||||
assert.match(source, /readRenderErrors\(/, script);
|
||||
assert.match(source, /--fail-on-retry/, script);
|
||||
}
|
||||
});
|
||||
|
||||
test('CI runs the gates hermetically against a production build, strict, with evidence', () => {
|
||||
const ci = fs.readFileSync(
|
||||
new URL('../../.github/workflows/ci.yml', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const job = ci.slice(ci.indexOf('street-level-browser:'));
|
||||
const gate = job.slice(0, job.indexOf('\n windows-onboarding:'));
|
||||
// The dummy token is baked into the bundle at build time and the preview
|
||||
// server's status route reads it at run time: both steps carry it.
|
||||
assert.match(
|
||||
gate,
|
||||
/MAPILLARY_CLIENT_TOKEN: 'MLY\|0\|qa-fixture'\s+run: npm run build/,
|
||||
);
|
||||
assert.match(
|
||||
gate,
|
||||
/MAPILLARY_CLIENT_TOKEN: 'MLY\|0\|qa-fixture'\s+run: \|\s+npx vite preview --port 4173 --strictPort/,
|
||||
);
|
||||
assert.doesNotMatch(gate, /npx vite --port/);
|
||||
assert.equal(
|
||||
(gate.match(/MAPILLARY_CLIENT_TOKEN: 'MLY\|0\|qa-fixture'/g) || []).length,
|
||||
2,
|
||||
);
|
||||
assert.match(gate, /qa:street-level:fixtures -- --strict/);
|
||||
assert.match(
|
||||
gate,
|
||||
/if: failure\(\)[\s\S]*actions\/upload-artifact@[0-9a-f]{40}/,
|
||||
);
|
||||
assert.match(gate, /path: qa-artifacts\//);
|
||||
assert.match(
|
||||
gate,
|
||||
/QA_FAIL_ON_RETRY: '1'/,
|
||||
'a missed header press fails CI now that the rail settles',
|
||||
);
|
||||
});
|
||||
|
||||
test('KartaView fixtures draw the drive, answer nearest first, and refuse the storage host the API names', () => {
|
||||
const body = (answer) => JSON.parse(answer.body);
|
||||
const lat = (KV_LINE.south + KV_LINE.north) / 2;
|
||||
|
||||
@@ -53,8 +53,7 @@ import { applyLocaleChoice, mountLocalePicker } from './localePicker.js';
|
||||
* Responsibilities:
|
||||
* - Visual controls and presets backed by the VisualEffects controller.
|
||||
* - Bloom and sharpen post-processing toggle/intensity control.
|
||||
* - Draggable/collapsible panel system with localStorage persistence,
|
||||
* z-order stacking, and viewport-clamped positioning.
|
||||
* - Collapsible rail panels with their collapsed state kept in localStorage.
|
||||
* - CCTV panel: camera selection, coverage toggle, projection, calibration
|
||||
* sliders, auto-hop, and summary typewriter effect.
|
||||
* - Location bar with city/POI preset pills, QWERTY key navigation,
|
||||
@@ -115,7 +114,6 @@ export class StyleManager extends ShellFacade {
|
||||
_setRadioDisclosure: (...args) => this._setRadioDisclosure(...args),
|
||||
_syncCctvPanelViewport: (...args) =>
|
||||
this._syncCctvPanelViewport(...args),
|
||||
_onPanelResized: (...args) => this._onPanelResized(...args),
|
||||
_syncContextRadioLauncherState: (...args) =>
|
||||
this._syncContextRadioLauncherState(...args),
|
||||
_showToast: (...args) => this._showToast(...args),
|
||||
@@ -275,6 +273,7 @@ export class StyleManager extends ShellFacade {
|
||||
_contextControls: this._contextControls,
|
||||
_cctvControls: this._cctvControls,
|
||||
_radioControls: this._radioControls,
|
||||
_streetLevelControls: this._streetLevelControls,
|
||||
}),
|
||||
operations: {
|
||||
_updateTrafficSyncChip: (...args) =>
|
||||
@@ -1089,13 +1088,11 @@ export class StyleManager extends ShellFacade {
|
||||
this._streetLevelControls?.destroy();
|
||||
this._streetLevelControls = null;
|
||||
if (!this._streetLevelPanel || !streetLevelLayer) return;
|
||||
// Photo framing and FOLLOW release tracking like any explicit flight.
|
||||
// Photo framing yields to newer navigation and releases tracking like
|
||||
// any deferred flight.
|
||||
streetLevelLayer.attachNavigation?.({
|
||||
run: (noun, move) => this._runExplicitNavigation(noun, move),
|
||||
begin: (noun) => this._beginDeferredNavigation(noun),
|
||||
reassert: (generation) => this._reassertNavigationHandoff(generation),
|
||||
subscribeHandoff: (listener) =>
|
||||
this._navigation.subscribeCameraHandoff(listener),
|
||||
});
|
||||
this._streetLevelControls = new StreetLevelControls({
|
||||
root: this._streetLevelPanel,
|
||||
@@ -1110,7 +1107,6 @@ export class StyleManager extends ShellFacade {
|
||||
this._dataManager?.setLayerParams('street-level', params, options),
|
||||
setPanelCollapsed: (collapsed, options) =>
|
||||
this.setPanelCollapsed('street-level-panel', collapsed, options),
|
||||
dockPanel: () => this._panelChrome.dockPanel('street-level-panel'),
|
||||
showToast: (message) => this._showToast(message),
|
||||
// Lets the panel open for a user, voice or tool switch-on, not a restore.
|
||||
subscribeEnableRequests: (listener) =>
|
||||
@@ -1207,19 +1203,6 @@ export class StyleManager extends ShellFacade {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes a panel draggable via its handle element. Implements:
|
||||
* - Z-order promotion: each pointerdown increments the global z-counter
|
||||
* so the clicked panel floats above siblings.
|
||||
* - Viewport clamping: drag moves are clamped to a 6px inset from all edges.
|
||||
* - Right-rail pinning: pp-toggles panel is re-anchored right after drag.
|
||||
* - CCTV viewport sync: cctv-panel recalculates scroll height after drag.
|
||||
* @param {string} panelId - DOM id of the panel.
|
||||
* @param {HTMLElement} panelEl - The panel DOM element.
|
||||
* @param {HTMLElement} handleEl - The drag handle element within the panel.
|
||||
* @returns {void}
|
||||
*/
|
||||
|
||||
/**
|
||||
* Programmatically collapses or expands a panel, persists the state,
|
||||
* and triggers layout recalculation for dependent panels.
|
||||
@@ -1666,13 +1649,6 @@ export class StyleManager extends ShellFacade {
|
||||
this._initCockpitDisplayPortal();
|
||||
}
|
||||
|
||||
/** A portable panel was resized or docked: refit any viewport inside it. */
|
||||
_onPanelResized(panelId) {
|
||||
if (panelId === 'cctv-panel') this._syncCctvPanelViewport();
|
||||
if (panelId === 'street-level-panel')
|
||||
this._streetLevelControls?.onPanelResized();
|
||||
}
|
||||
|
||||
/**
|
||||
* Recalculates the CCTV panel max-height based on its current top position
|
||||
* and the window height, enabling internal scroll without viewport overflow.
|
||||
|
||||
@@ -85,16 +85,3 @@ test('non-Cyber explicit panels and restored preferences retain their existing p
|
||||
} finally { globalThis.document = prior; }
|
||||
}
|
||||
});
|
||||
|
||||
test('opening a floating Cyber window leaves the docked rail panels open', () => {
|
||||
const f = fixture(), prior = globalThis.document;
|
||||
globalThis.document = f.doc;
|
||||
try {
|
||||
f.owner.setPanelCollapsed('pp-toggles', false, { explicit: true });
|
||||
f.cctv.classList.add('panel-floating');
|
||||
f.owner.setPanelCollapsed('cctv-panel', false, { explicit: true });
|
||||
assert.equal(f.cctv.classList.contains('collapsed'), false);
|
||||
assert.equal(f.display.classList.contains('collapsed'), false, 'DISPLAY stays open');
|
||||
assert.ok(!f.saved.some(([id, collapsed]) => id === 'pp-toggles' && collapsed));
|
||||
} finally { globalThis.document = prior; }
|
||||
});
|
||||
@@ -55,6 +55,9 @@ export class LayerBindings {
|
||||
get _radioControls() {
|
||||
return this.readControls()._radioControls;
|
||||
}
|
||||
get _streetLevelControls() {
|
||||
return this.readControls()._streetLevelControls;
|
||||
}
|
||||
observeCamera() {
|
||||
this._cctvRequestFocusHandler = (event) =>
|
||||
routeCctvFocusRequest(
|
||||
@@ -235,6 +238,8 @@ export class LayerBindings {
|
||||
this._syncContextModeButtons();
|
||||
this._cctvControls.connect();
|
||||
this._radioControls.connect();
|
||||
// Its switch-on requests come from the manager, which arrives after it.
|
||||
this._streetLevelControls?.connect();
|
||||
this._connectDirectionsCamera();
|
||||
this._connectWeatherCamera();
|
||||
if (!this._awarenessSelectedHandler) {
|
||||
|
||||
+1
-18
@@ -61,10 +61,7 @@ export class PanelChrome {
|
||||
this._cockpitContextCollapsedForDataPanel = false;
|
||||
this._panelPosition = new PanelPositionControls({
|
||||
syncPanelCollapseButton: (panel) => this._syncPanelCollapseButton(panel),
|
||||
layoutRightPanels: () => this._layoutRightPanels(),
|
||||
syncCctvPanelViewport: () => this._syncCctvPanelViewport(),
|
||||
showToast: (message) => this._showToast(message),
|
||||
onPanelResized: (panelId) => this._onPanelResized(panelId),
|
||||
});
|
||||
this._panelLayout = new PanelLayoutController({
|
||||
readHud: () => ({
|
||||
@@ -149,7 +146,6 @@ export class PanelChrome {
|
||||
this._initCommandDockPins();
|
||||
this._initCommandDockTrayMetrics();
|
||||
this._maybeNotifyLayoutReset();
|
||||
this._panelPosition._initPanelDrag();
|
||||
}
|
||||
|
||||
_collapsePanelOnEscape(event, panelId) {
|
||||
@@ -416,11 +412,6 @@ export class PanelChrome {
|
||||
this.shareLinkManager?.onPanelStateChange?.();
|
||||
}
|
||||
|
||||
/** @returns {boolean} Whether the panel was floating and is now docked. */
|
||||
dockPanel(panelId) {
|
||||
return this._panelPosition?.dockPanel?.(panelId) === true;
|
||||
}
|
||||
|
||||
setPanelCollapsed(
|
||||
panelId,
|
||||
collapsed,
|
||||
@@ -443,20 +434,17 @@ export class PanelChrome {
|
||||
panelEl.classList.contains('cyber-accordion-collapsed');
|
||||
// A user opening a Cyber rail panel owns the whole accordion, including
|
||||
// peers that were only presentation-collapsed during a saved-state restore.
|
||||
// A floating window is outside the accordion both ways.
|
||||
if (
|
||||
explicit &&
|
||||
!restore &&
|
||||
!nextCollapsed &&
|
||||
document.documentElement?.dataset.uiTheme === 'cyber' &&
|
||||
panelEl.parentElement === this._rightPanelStack &&
|
||||
!panelEl.classList.contains('panel-floating')
|
||||
panelEl.parentElement === this._rightPanelStack
|
||||
) {
|
||||
for (const peer of this._rightPanelStack.children) {
|
||||
if (
|
||||
peer !== panelEl &&
|
||||
peer.matches('[data-panel-id]') &&
|
||||
!peer.classList.contains('panel-floating') &&
|
||||
!peer.hidden
|
||||
) {
|
||||
this.setPanelCollapsed(peer.id, true, {
|
||||
@@ -593,10 +581,6 @@ export class PanelChrome {
|
||||
}
|
||||
}
|
||||
panelEl.classList.toggle('collapsed', nextCollapsed);
|
||||
// Only a user's own collapse docks a floating window; restores, cockpit
|
||||
// entry and accordion peers keep its place.
|
||||
if (explicit && !restore)
|
||||
this._panelPosition?.onPanelCollapsed?.(panelId, nextCollapsed);
|
||||
if (
|
||||
nextCollapsed &&
|
||||
this.cockpitView?.active &&
|
||||
@@ -666,7 +650,6 @@ export class PanelChrome {
|
||||
if (this._disposed) return;
|
||||
this._disposed = true;
|
||||
this._lifetime.destroy();
|
||||
this._panelPosition.destroy();
|
||||
this._panelLayout.destroy();
|
||||
for (const control of this._panelDisclosureControls || [])
|
||||
control.destroy();
|
||||
|
||||
@@ -1,580 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { PanelPositionControls } from './panelPositionControls.js';
|
||||
|
||||
/** Just enough DOM for PanelPositionControls' portable-panel paths. */
|
||||
function fixture() {
|
||||
const saved = Object.fromEntries(
|
||||
['document', 'window', 'localStorage', 'performance', 'ResizeObserver'].map(
|
||||
(key) => [key, globalThis[key]],
|
||||
),
|
||||
);
|
||||
const store = new Map();
|
||||
const element = () => {
|
||||
const classes = new Set();
|
||||
const style = {
|
||||
removeProperty(name) {
|
||||
// Like CSSStyleDeclaration: 'z-index' also clears style.zIndex.
|
||||
delete this[name];
|
||||
delete this[name.replace(/-([a-z])/g, (_, c) => c.toUpperCase())];
|
||||
},
|
||||
getPropertyValue: () => '',
|
||||
};
|
||||
return Object.assign(new EventTarget(), {
|
||||
id: '',
|
||||
style,
|
||||
dataset: {},
|
||||
classList: {
|
||||
add: (...names) => names.forEach((name) => classes.add(name)),
|
||||
remove: (...names) => names.forEach((name) => classes.delete(name)),
|
||||
contains: (name) => classes.has(name),
|
||||
toggle: (name, on) => (on ? classes.add(name) : classes.delete(name)),
|
||||
},
|
||||
querySelector: () => null,
|
||||
querySelectorAll: () => [],
|
||||
closest: () => null,
|
||||
setAttribute() {},
|
||||
removeAttribute() {},
|
||||
appendChild() {},
|
||||
remove() {},
|
||||
getBoundingClientRect: () => ({
|
||||
left: 100,
|
||||
top: 80,
|
||||
width: 360,
|
||||
height: 420,
|
||||
right: 460,
|
||||
bottom: 500,
|
||||
}),
|
||||
});
|
||||
};
|
||||
globalThis.document = Object.assign(new EventTarget(), {
|
||||
getElementById: () => null,
|
||||
querySelectorAll: () => [],
|
||||
createElement: () => element(),
|
||||
});
|
||||
globalThis.window = Object.assign(new EventTarget(), {
|
||||
innerWidth: 1440,
|
||||
innerHeight: 900,
|
||||
});
|
||||
globalThis.localStorage = {
|
||||
getItem: (key) => (store.has(key) ? store.get(key) : null),
|
||||
setItem: (key, value) => store.set(key, String(value)),
|
||||
removeItem: (key) => store.delete(key),
|
||||
};
|
||||
const calls = { layout: 0, resized: [] };
|
||||
const owner = new PanelPositionControls({
|
||||
syncPanelCollapseButton() {},
|
||||
layoutRightPanels: () => calls.layout++,
|
||||
syncCctvPanelViewport() {},
|
||||
showToast() {},
|
||||
onPanelResized: (id) => calls.resized.push(id),
|
||||
});
|
||||
return {
|
||||
owner,
|
||||
element,
|
||||
store,
|
||||
calls,
|
||||
restore() {
|
||||
owner.destroy();
|
||||
Object.assign(globalThis, saved);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function floatingPanel(f, id, { dockOnCollapse = false } = {}) {
|
||||
const panel = f.element();
|
||||
const handle = f.element();
|
||||
panel.id = id;
|
||||
panel.classList.add('panel-floating', 'panel-draggable');
|
||||
Object.assign(panel.style, {
|
||||
left: '100px',
|
||||
top: '80px',
|
||||
width: '360px',
|
||||
height: '420px',
|
||||
});
|
||||
f.store.set(`godsEyeView.v8.panelPos.${id}`, '{"floating":true}');
|
||||
f.owner._portablePanels.set(id, {
|
||||
panel,
|
||||
min: { width: 320, height: 280 },
|
||||
dockOnCollapse,
|
||||
});
|
||||
f.owner._makePanelDraggable(id, panel, handle);
|
||||
return { panel, handle };
|
||||
}
|
||||
|
||||
/** A left-button header press; `timeStamp` is a read-only getter on Event. */
|
||||
function press(handle, { timeStamp, clientX = 200, clientY = 90 }) {
|
||||
const event = new Event('pointerdown', { cancelable: true });
|
||||
for (const [key, value] of Object.entries({
|
||||
button: 0,
|
||||
clientX,
|
||||
clientY,
|
||||
timeStamp,
|
||||
}))
|
||||
Object.defineProperty(event, key, { value });
|
||||
handle.dispatchEvent(event);
|
||||
}
|
||||
|
||||
test('dockPanel returns a floating window to its rail and forgets its place', () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const { panel } = floatingPanel(f, 'street-level-panel');
|
||||
assert.equal(f.owner.dockPanel('street-level-panel'), true);
|
||||
assert.equal(panel.classList.contains('panel-floating'), false);
|
||||
assert.equal(panel.style.width, undefined);
|
||||
assert.equal(panel.style.height, undefined);
|
||||
assert.equal(
|
||||
f.store.has('godsEyeView.v8.panelPos.street-level-panel'),
|
||||
false,
|
||||
);
|
||||
assert.deepEqual(f.calls.resized, ['street-level-panel']);
|
||||
assert.equal(
|
||||
f.owner.dockPanel('street-level-panel'),
|
||||
false,
|
||||
'already docked',
|
||||
);
|
||||
assert.equal(f.owner.dockPanel('unknown'), false);
|
||||
} finally {
|
||||
f.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('only a dockOnCollapse panel docks when collapsed, and only on collapse', () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const street = floatingPanel(f, 'street-level-panel', {
|
||||
dockOnCollapse: true,
|
||||
});
|
||||
const cctv = floatingPanel(f, 'cctv-panel');
|
||||
f.owner.onPanelCollapsed('street-level-panel', false);
|
||||
assert.equal(
|
||||
street.panel.classList.contains('panel-floating'),
|
||||
true,
|
||||
'expanding keeps it',
|
||||
);
|
||||
f.owner.onPanelCollapsed('cctv-panel', true);
|
||||
assert.equal(
|
||||
cctv.panel.classList.contains('panel-floating'),
|
||||
true,
|
||||
'CCTV did not opt in',
|
||||
);
|
||||
f.owner.onPanelCollapsed('street-level-panel', true);
|
||||
assert.equal(street.panel.classList.contains('panel-floating'), false);
|
||||
} finally {
|
||||
f.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('two quick header presses snap a floating panel back; slow or distant ones do not', () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const { panel, handle } = floatingPanel(f, 'cctv-panel');
|
||||
press(handle, { timeStamp: 1000 });
|
||||
window.dispatchEvent(new Event('pointerup'));
|
||||
press(handle, { timeStamp: 1600 });
|
||||
window.dispatchEvent(new Event('pointerup'));
|
||||
assert.equal(
|
||||
panel.classList.contains('panel-floating'),
|
||||
true,
|
||||
'600 ms apart is two clicks',
|
||||
);
|
||||
press(handle, { timeStamp: 1700, clientX: 260 });
|
||||
window.dispatchEvent(new Event('pointerup'));
|
||||
assert.equal(
|
||||
panel.classList.contains('panel-floating'),
|
||||
true,
|
||||
'60 px apart is two clicks',
|
||||
);
|
||||
press(handle, { timeStamp: 1900, clientX: 262 });
|
||||
assert.equal(
|
||||
panel.classList.contains('panel-floating'),
|
||||
false,
|
||||
'a double press docks',
|
||||
);
|
||||
} finally {
|
||||
f.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('a restored floating window stacks above the docked DISPLAY panel (z 110)', () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const panel = f.element();
|
||||
panel.id = 'street-level-panel';
|
||||
f.owner._portablePanels.set('street-level-panel', {
|
||||
panel,
|
||||
min: { width: 320, height: 280 },
|
||||
dockOnCollapse: true,
|
||||
});
|
||||
f.store.set(
|
||||
'godsEyeView.v8.panelPos.street-level-panel',
|
||||
JSON.stringify({
|
||||
left: 900,
|
||||
top: 200,
|
||||
width: 420,
|
||||
height: 600,
|
||||
floating: true,
|
||||
}),
|
||||
);
|
||||
f.owner._restorePanelPosition('street-level-panel', panel);
|
||||
assert.equal(panel.classList.contains('panel-floating'), true);
|
||||
assert.ok(Number(panel.style.zIndex) > 110, `z ${panel.style.zIndex}`);
|
||||
f.owner.dockPanel('street-level-panel');
|
||||
assert.equal(panel.style.zIndex, undefined, 'docking drops the promotion');
|
||||
} finally {
|
||||
f.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('renumbering the z ladder never drops a window below the docked panels', () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const windows = [f.element(), f.element(), f.element()];
|
||||
windows.forEach((node, index) => {
|
||||
node.classList.add('panel-draggable');
|
||||
node.style.zIndex = String(130 + index);
|
||||
});
|
||||
globalThis.document.querySelectorAll = (selector) =>
|
||||
selector === '.panel-draggable' ? windows : [];
|
||||
f.owner._panelZCounter = 139;
|
||||
const top = f.element();
|
||||
top.classList.add('panel-draggable');
|
||||
f.owner._promotePanelZ(top);
|
||||
for (const node of [...windows, top])
|
||||
assert.ok(Number(node.style.zIndex) > 110, `z ${node.style.zIndex}`);
|
||||
assert.ok(Number(top.style.zIndex) > Number(windows[2].style.zIndex));
|
||||
} finally {
|
||||
f.restore();
|
||||
}
|
||||
});
|
||||
|
||||
/** A portable panel still in its rail, with its header and resize handles. */
|
||||
function dockedPanel(f, id) {
|
||||
const panel = f.element();
|
||||
const handle = f.element();
|
||||
panel.id = id;
|
||||
const portable = { panel, min: { width: 320, height: 280 } };
|
||||
f.owner._portablePanels.set(id, portable);
|
||||
f.owner._makePanelDraggable(id, panel, handle);
|
||||
f.owner._makePanelResizable(id, panel, portable.min);
|
||||
const edges = Object.fromEntries(
|
||||
f.owner._resizeHandles.map((node) => [node.dataset.dir, node]),
|
||||
);
|
||||
return { panel, handle, edges };
|
||||
}
|
||||
|
||||
/** A pointer event; `pointerType` defaults to what a mouse reports. */
|
||||
function pointer(target, type, { x = 200, y = 90, pointerType = 'mouse' }) {
|
||||
const event = new Event(type, { cancelable: true });
|
||||
for (const [key, value] of Object.entries({
|
||||
button: 0,
|
||||
clientX: x,
|
||||
clientY: y,
|
||||
pointerType,
|
||||
}))
|
||||
Object.defineProperty(event, key, { value });
|
||||
target.dispatchEvent(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
const POSITION_KEY = 'godsEyeView.v8.panelPos.street-level-panel';
|
||||
|
||||
test('a click on a docked panel’s resize edge leaves it docked and stores nothing', () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const { panel, edges } = dockedPanel(f, 'street-level-panel');
|
||||
pointer(edges.w, 'pointerdown', { x: 101, y: 200 });
|
||||
pointer(window, 'pointermove', { x: 103, y: 201 }); // a jittery click
|
||||
pointer(window, 'pointerup', { x: 103, y: 201 });
|
||||
assert.equal(panel.classList.contains('panel-floating'), false);
|
||||
assert.equal(panel.style.width, undefined, 'no size was pinned');
|
||||
assert.equal(f.store.has(POSITION_KEY), false);
|
||||
assert.deepEqual(f.calls.resized, []);
|
||||
|
||||
// A real resize still lifts it out and remembers the window.
|
||||
pointer(edges.w, 'pointerdown', { x: 101, y: 200 });
|
||||
pointer(window, 'pointermove', { x: 61, y: 200 });
|
||||
pointer(window, 'pointerup', { x: 61, y: 200 });
|
||||
assert.equal(panel.classList.contains('panel-floating'), true);
|
||||
assert.equal(JSON.parse(f.store.get(POSITION_KEY)).floating, true);
|
||||
} finally {
|
||||
f.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('a touch on the header or an edge does not lift the panel, and the rail can still scroll', () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const { panel, handle, edges } = dockedPanel(f, 'street-level-panel');
|
||||
const press = pointer(handle, 'pointerdown', { pointerType: 'touch' });
|
||||
assert.equal(press.defaultPrevented, false, 'the swipe can scroll');
|
||||
pointer(window, 'pointermove', { y: 190, pointerType: 'touch' });
|
||||
pointer(window, 'pointerup', { y: 190, pointerType: 'touch' });
|
||||
pointer(edges.s, 'pointerdown', { y: 500, pointerType: 'touch' });
|
||||
pointer(window, 'pointermove', { y: 600, pointerType: 'touch' });
|
||||
pointer(window, 'pointerup', { y: 600, pointerType: 'touch' });
|
||||
assert.equal(panel.classList.contains('panel-floating'), false);
|
||||
assert.equal(f.store.has(POSITION_KEY), false);
|
||||
} finally {
|
||||
f.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('a pointercancel mid-drag saves nothing and puts the panel back', () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const { panel, handle, edges } = dockedPanel(f, 'street-level-panel');
|
||||
pointer(handle, 'pointerdown', {});
|
||||
pointer(window, 'pointermove', { x: 260, y: 140 });
|
||||
assert.equal(panel.classList.contains('panel-floating'), true, 'lifted');
|
||||
pointer(window, 'pointercancel', { x: 260, y: 140 });
|
||||
assert.equal(panel.classList.contains('panel-floating'), false, 'docked');
|
||||
assert.equal(f.store.has(POSITION_KEY), false);
|
||||
|
||||
pointer(edges.e, 'pointerdown', { x: 459 });
|
||||
pointer(window, 'pointermove', { x: 520 });
|
||||
pointer(window, 'pointercancel', { x: 520 });
|
||||
assert.equal(panel.classList.contains('panel-floating'), false);
|
||||
assert.equal(f.store.has(POSITION_KEY), false);
|
||||
} finally {
|
||||
f.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('a pointercancel while dragging a floating window returns it to where it was', () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const { panel, handle } = floatingPanel(f, 'cctv-panel');
|
||||
const stored = f.store.get('godsEyeView.v8.panelPos.cctv-panel');
|
||||
pointer(handle, 'pointerdown', {});
|
||||
pointer(window, 'pointermove', { x: 400, y: 300 });
|
||||
assert.notEqual(panel.style.left, '100px', 'it moved');
|
||||
pointer(window, 'pointercancel', { x: 400, y: 300 });
|
||||
assert.equal(panel.style.left, '100px');
|
||||
assert.equal(panel.style.top, '80px');
|
||||
assert.equal(panel.classList.contains('panel-floating'), true);
|
||||
assert.equal(f.store.get('godsEyeView.v8.panelPos.cctv-panel'), stored);
|
||||
} finally {
|
||||
f.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('a header press that travels under the drag threshold is a click, not a lift', () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const { panel, handle } = dockedPanel(f, 'street-level-panel');
|
||||
pointer(handle, 'pointerdown', { x: 200, y: 90 });
|
||||
pointer(window, 'pointermove', { x: 202, y: 90 });
|
||||
pointer(window, 'pointerup', { x: 202, y: 90 });
|
||||
assert.equal(panel.classList.contains('panel-floating'), false);
|
||||
assert.equal(panel.classList.contains('panel-dragging'), false);
|
||||
assert.equal(f.store.has(POSITION_KEY), false);
|
||||
assert.equal(f.calls.layout, 0, 'the rail was not re-laid out');
|
||||
assert.deepEqual(f.calls.resized, []);
|
||||
} finally {
|
||||
f.restore();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Both portable panels wired through _initPanelDrag. Headers are the top
|
||||
* 36 px; `obstacles` stand in for the command dock and voice pill.
|
||||
* ResizeObserver callbacks run on `observe()`.
|
||||
*/
|
||||
function portableShell(f, { stored = {}, box = {}, obstacles = {} } = {}) {
|
||||
const observers = [];
|
||||
globalThis.ResizeObserver = class {
|
||||
constructor(callback) {
|
||||
this.callback = callback;
|
||||
this.targets = [];
|
||||
observers.push(this);
|
||||
}
|
||||
observe(target) {
|
||||
this.targets.push(target);
|
||||
}
|
||||
disconnect() {}
|
||||
};
|
||||
const nodes = new Map();
|
||||
const panels = {};
|
||||
for (const id of ['cctv-panel', 'street-level-panel']) {
|
||||
const panel = f.element();
|
||||
const handle = f.element();
|
||||
const natural = { width: 360, height: 420, ...box[id] };
|
||||
panel.id = id;
|
||||
panel.querySelector = (selector) =>
|
||||
selector === '.panel-header' ? handle : null;
|
||||
panel.getBoundingClientRect = () => {
|
||||
const left = parseFloat(panel.style.left) || 0;
|
||||
const top = parseFloat(panel.style.top) || 0;
|
||||
const width = parseFloat(panel.style.width) || natural.width;
|
||||
const height = parseFloat(panel.style.height) || natural.height;
|
||||
return {
|
||||
left,
|
||||
top,
|
||||
width,
|
||||
height,
|
||||
right: left + width,
|
||||
bottom: top + height,
|
||||
};
|
||||
};
|
||||
handle.getBoundingClientRect = () => {
|
||||
const rect = panel.getBoundingClientRect();
|
||||
return { ...rect, height: 36, bottom: rect.top + 36 };
|
||||
};
|
||||
if (stored[id])
|
||||
f.store.set(
|
||||
`godsEyeView.v8.panelPos.${id}`,
|
||||
JSON.stringify({ floating: true, ...stored[id] }),
|
||||
);
|
||||
nodes.set(id, panel);
|
||||
panels[id] = { panel, handle, natural };
|
||||
}
|
||||
const obstacle = (id, rect) =>
|
||||
nodes.set(id, {
|
||||
getBoundingClientRect: () => ({
|
||||
...rect,
|
||||
width: rect.right - rect.left,
|
||||
height: rect.bottom - rect.top,
|
||||
}),
|
||||
});
|
||||
for (const [id, rect] of Object.entries(obstacles)) obstacle(id, rect);
|
||||
globalThis.document.getElementById = (id) => nodes.get(id) ?? null;
|
||||
f.owner._initPanelDrag();
|
||||
return {
|
||||
...panels,
|
||||
obstacle,
|
||||
observed: (node) => observers.some(({ targets }) => targets.includes(node)),
|
||||
contentResized: () => observers.forEach(({ callback }) => callback([])),
|
||||
};
|
||||
}
|
||||
|
||||
test('a floating window whose content grows is pulled back on-screen', () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const shell = portableShell(f, {
|
||||
stored: { 'cctv-panel': { left: 900, top: 380 } },
|
||||
});
|
||||
const { panel, natural } = shell['cctv-panel'];
|
||||
assert.equal(panel.style.top, '380px');
|
||||
assert.equal(shell.observed(panel), true, 'its size is watched');
|
||||
assert.equal(shell.observed(shell['street-level-panel'].panel), true);
|
||||
// Expanding the collapsed strip, or an image opening in Street Level.
|
||||
natural.height = 800;
|
||||
shell.contentResized();
|
||||
assert.equal(panel.style.top, `${900 - 800 - 6}px`);
|
||||
assert.equal(panel.style.left, '900px');
|
||||
} finally {
|
||||
f.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('a live window resize shrinks a floating window larger than the viewport', () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const shell = portableShell(f, {
|
||||
stored: {
|
||||
'cctv-panel': { left: 100, top: 80, width: 1300, height: 700 },
|
||||
},
|
||||
});
|
||||
const { panel } = shell['cctv-panel'];
|
||||
assert.equal(panel.style.width, '1300px');
|
||||
Object.assign(window, { innerWidth: 1000, innerHeight: 500 });
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
assert.equal(panel.style.width, '988px');
|
||||
assert.equal(panel.style.height, '488px');
|
||||
assert.equal(panel.style.left, '6px');
|
||||
assert.equal(panel.style.top, '6px');
|
||||
// A window that already fits keeps the size the user gave it.
|
||||
Object.assign(window, { innerWidth: 1440, innerHeight: 900 });
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
assert.equal(panel.style.width, '988px');
|
||||
} finally {
|
||||
f.restore();
|
||||
}
|
||||
});
|
||||
|
||||
const DOCK = { left: 300, top: 820, right: 1140, bottom: 882 };
|
||||
|
||||
test('a floating header never lands under the command dock or voice pill', () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const shell = portableShell(f, {
|
||||
stored: { 'cctv-panel': { left: 100, top: 80 } },
|
||||
// A collapsed CCTV window is just its header strip.
|
||||
box: { 'cctv-panel': { height: 36 } },
|
||||
obstacles: { 'command-dock': DOCK },
|
||||
});
|
||||
const { panel, handle, natural } = shell['cctv-panel'];
|
||||
const above = DOCK.top - 36 - 6;
|
||||
|
||||
// Drag: the strip dropped at the bottom centre stops above the dock.
|
||||
pointer(handle, 'pointerdown', { x: 200, y: 90 });
|
||||
pointer(window, 'pointermove', { x: 720, y: 880 });
|
||||
assert.equal(panel.style.top, `${above}px`);
|
||||
pointer(window, 'pointerup', { x: 720, y: 880 });
|
||||
const key = 'godsEyeView.v8.panelPos.cctv-panel';
|
||||
assert.equal(JSON.parse(f.store.get(key)).top, above);
|
||||
|
||||
// Restore: a place saved under the dock (older build) comes back clear.
|
||||
f.store.set(key, JSON.stringify({ left: 620, top: 858, floating: true }));
|
||||
f.owner._restorePanelPosition('cctv-panel', panel);
|
||||
assert.equal(panel.style.top, `${above}px`);
|
||||
|
||||
// Re-clamp: the voice pill measured when the window resizes.
|
||||
Object.assign(panel.style, { left: '1000px', top: '700px' });
|
||||
shell.obstacle('gev-voice-control', {
|
||||
left: 1190,
|
||||
top: 690,
|
||||
right: 1404,
|
||||
bottom: 740,
|
||||
});
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
assert.equal(panel.style.top, `${690 - 36 - 6}px`);
|
||||
|
||||
// Only the header has to stay clear: a body over the dock is fine.
|
||||
natural.height = 200;
|
||||
Object.assign(panel.style, { left: '620px', top: '650px' });
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
assert.equal(panel.style.top, '650px');
|
||||
} finally {
|
||||
f.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('a collapsed floating window keeps its chosen height through a drag and a reload', () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const { panel, handle } = floatingPanel(f, 'cctv-panel');
|
||||
panel.style.height = '500px';
|
||||
panel.classList.add('collapsed');
|
||||
// Collapsed, the window measures only its header strip.
|
||||
panel.getBoundingClientRect = () => {
|
||||
const left = parseFloat(panel.style.left);
|
||||
const top = parseFloat(panel.style.top);
|
||||
return {
|
||||
left,
|
||||
top,
|
||||
width: 360,
|
||||
height: 50,
|
||||
right: left + 360,
|
||||
bottom: top + 50,
|
||||
};
|
||||
};
|
||||
pointer(handle, 'pointerdown', { x: 200, y: 90 });
|
||||
pointer(window, 'pointermove', { x: 260, y: 140 });
|
||||
pointer(window, 'pointerup', { x: 260, y: 140 });
|
||||
const record = JSON.parse(
|
||||
f.store.get('godsEyeView.v8.panelPos.cctv-panel'),
|
||||
);
|
||||
assert.equal(record.height, 500, 'the chosen height, not the strip');
|
||||
assert.equal(record.width, 360);
|
||||
|
||||
// Reload: a fresh panel restores the size it expands to.
|
||||
const reloaded = f.element();
|
||||
reloaded.id = 'cctv-panel';
|
||||
f.owner._restorePanelPosition('cctv-panel', reloaded);
|
||||
assert.equal(reloaded.style.height, '500px');
|
||||
} finally {
|
||||
f.restore();
|
||||
}
|
||||
});
|
||||
@@ -189,13 +189,6 @@ export class PanelLayoutController {
|
||||
const stack = this._rightPanelStack;
|
||||
if (!stack || !this._ppToggles) return;
|
||||
|
||||
this._ppToggles.style.removeProperty('top');
|
||||
this._ppToggles.style.removeProperty('right');
|
||||
this._ppToggles.style.removeProperty('bottom');
|
||||
this._ppToggles.style.removeProperty('left');
|
||||
this._ppToggles.style.removeProperty('z-index');
|
||||
this._ppToggles.classList.remove('panel-draggable', 'panel-dragging');
|
||||
this._ppToggles.querySelector('.pp-header-row')?.removeAttribute('title');
|
||||
stack.prepend(this._ppToggles);
|
||||
const globalContextPanel = document.getElementById('global-context-panel');
|
||||
for (const panel of [
|
||||
@@ -205,12 +198,6 @@ export class PanelLayoutController {
|
||||
this._streetLevelPanel,
|
||||
]) {
|
||||
if (!panel) continue;
|
||||
// A floating window keeps its restored place and size.
|
||||
if (!panel.classList.contains('panel-floating')) {
|
||||
for (const property of ['top', 'right', 'bottom', 'left', 'z-index'])
|
||||
panel.style.removeProperty(property);
|
||||
panel.classList.remove('panel-draggable', 'panel-dragging');
|
||||
}
|
||||
stack.insertBefore(panel, globalContextPanel);
|
||||
this._syncPanelCollapseButton(panel);
|
||||
}
|
||||
|
||||
+18
-762
@@ -1,217 +1,26 @@
|
||||
/** Own panel position preferences, viewport clamping and drag listeners. */
|
||||
import { t, msg } from '../i18n/index.js';
|
||||
import { RESIZE_DIRECTIONS, resizeBox } from './panelResize.js';
|
||||
/** Own panel collapsed-state preferences and the one-time layout-reset notice. */
|
||||
import { t } from '../i18n/index.js';
|
||||
/** Versioned localStorage namespace prefix to invalidate stale panel layouts. */
|
||||
const PANEL_LAYOUT_STORAGE_VERSION = 'v6';
|
||||
/**
|
||||
* Position keys are versioned separately from collapsed-state keys so layout
|
||||
* default changes (e.g. right-rail origin) can reset positions without also
|
||||
* resetting every panel's open/closed preference.
|
||||
* Position keys are versioned separately from collapsed-state keys. The rails
|
||||
* now lay panels out themselves and store no position; the version still names
|
||||
* the layout-reset notice and the documented key.
|
||||
*/
|
||||
const PANEL_POSITION_STORAGE_VERSION = 'v8';
|
||||
/** Z ladder: panels promote within [100, 139]; voice pill 150, toast 200, clean-view-exit 300. */
|
||||
const PANEL_Z_BASE = 100;
|
||||
const PANEL_Z_MAX = 139;
|
||||
/**
|
||||
* Lowest z for a promoted panel: above every docked rail panel (#pp-toggles is
|
||||
* 110). Keep in step with `#right-context-rail > .panel-floating` in controls.css.
|
||||
*/
|
||||
const PANEL_Z_FLOATING_FLOOR = PANEL_Z_BASE + 11;
|
||||
/** Pointer travel before a press becomes a drag or resize. */
|
||||
const DRAG_THRESHOLD_PX = 4;
|
||||
/**
|
||||
* Double press that snaps a floating panel back. Detected from pointerdown
|
||||
* because the drag's preventDefault() suppresses a native dblclick.
|
||||
*/
|
||||
const DOUBLE_PRESS_MS = 400;
|
||||
const DOUBLE_PRESS_SLOP_PX = 6;
|
||||
const VIEWPORT_MARGIN_PX = 6;
|
||||
/**
|
||||
* Fixed chrome that paints over the rail's stacking context; a floating
|
||||
* window keeps its header clear of it so it can always be grabbed again.
|
||||
*/
|
||||
const PANEL_OBSTACLE_IDS = ['command-dock', 'gev-voice-control'];
|
||||
const PANEL_FLOAT_HINT_STORAGE_KEY = `godsEyeView.${PANEL_POSITION_STORAGE_VERSION}.panelFloatHintShown`;
|
||||
const PANEL_FLOAT_HINT = msg('Double-click the header to snap the panel back');
|
||||
/** Header children whose own interaction wins over a drag or a snap-back. */
|
||||
const INTERACTIVE_SELECTOR =
|
||||
'input, select, option, textarea, button, a, [role="button"]';
|
||||
const FLOATING_STYLE_PROPERTIES = [
|
||||
'left',
|
||||
'top',
|
||||
'right',
|
||||
'bottom',
|
||||
'width',
|
||||
'height',
|
||||
'z-index',
|
||||
/** Floating panels' stored positions and hint flag: nothing reads them now. */
|
||||
const RETIRED_PANEL_KEY_PREFIXES = [
|
||||
`godsEyeView.${PANEL_POSITION_STORAGE_VERSION}.panelPos.`,
|
||||
`godsEyeView.${PANEL_POSITION_STORAGE_VERSION}.panelFloatHintShown`,
|
||||
];
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function fitWindowExtent(value, minimum, viewportExtent) {
|
||||
return clamp(
|
||||
Math.round(value),
|
||||
minimum,
|
||||
Math.max(minimum, viewportExtent - 2 * VIEWPORT_MARGIN_PX),
|
||||
);
|
||||
}
|
||||
|
||||
function placeAt(panelEl, left, top) {
|
||||
panelEl.style.left = `${left}px`;
|
||||
panelEl.style.top = `${top}px`;
|
||||
panelEl.style.right = 'auto';
|
||||
panelEl.style.bottom = 'auto';
|
||||
}
|
||||
|
||||
/** On a phone the rail scrolls, so a finger on a header is a swipe, not a drag. */
|
||||
function isPrecisePointer(event) {
|
||||
const pointerType = event.pointerType || 'mouse';
|
||||
return pointerType === 'mouse' || pointerType === 'pen';
|
||||
}
|
||||
|
||||
function geometryOf(panelEl) {
|
||||
const { left, top, width, height } = panelEl.style;
|
||||
return { left, top, width, height };
|
||||
}
|
||||
|
||||
export class PanelPositionControls {
|
||||
/** `onPanelResized(panelId)` runs after a portable panel moves, resizes or docks. */
|
||||
constructor({
|
||||
syncPanelCollapseButton,
|
||||
layoutRightPanels,
|
||||
syncCctvPanelViewport,
|
||||
showToast,
|
||||
onPanelResized = null,
|
||||
}) {
|
||||
constructor({ syncPanelCollapseButton, showToast }) {
|
||||
this._syncPanelCollapseButton = syncPanelCollapseButton;
|
||||
this._layoutRightPanels = layoutRightPanels;
|
||||
this._syncCctvPanelViewport = syncCctvPanelViewport;
|
||||
this._showToast = showToast;
|
||||
this._onPanelResized = onPanelResized;
|
||||
this._ppToggles = document.getElementById('pp-toggles');
|
||||
this._panelZCounter = PANEL_Z_BASE + 10;
|
||||
this._draggableResizeObserver = null;
|
||||
this._cancelDrag = null;
|
||||
this._portablePanels = new Map();
|
||||
this._resizeHandles = [];
|
||||
this._dragInitialized = false;
|
||||
this.removers = [];
|
||||
this.destroyed = false;
|
||||
}
|
||||
listen(target, type, callback, options) {
|
||||
if (this.destroyed || !target) return;
|
||||
const listener = (event) => {
|
||||
if (!this.destroyed) callback(event);
|
||||
};
|
||||
target.addEventListener(type, listener, options);
|
||||
this.removers.push(() =>
|
||||
target.removeEventListener(type, listener, options),
|
||||
);
|
||||
}
|
||||
_reclampDraggablePanels() {
|
||||
if (this.destroyed) return;
|
||||
const el = this._ppToggles;
|
||||
if (el && el.style.top && el.style.top !== 'auto') {
|
||||
const top = parseInt(el.style.top, 10);
|
||||
if (Number.isFinite(top)) {
|
||||
el.style.top = `${this._clampToViewport(0, top, el).top}px`;
|
||||
this._pinPanelToRight(el);
|
||||
}
|
||||
}
|
||||
for (const entry of this._portablePanels.values()) {
|
||||
const { classList } = entry.panel;
|
||||
if (!classList.contains('panel-floating')) continue;
|
||||
// A live gesture owns the box; it re-clamps when it ends.
|
||||
if (classList.contains('panel-dragging')) continue;
|
||||
if (classList.contains('panel-resizing')) continue;
|
||||
this._reclampPortablePanel(entry);
|
||||
}
|
||||
}
|
||||
|
||||
/** Bring a floating window on-screen; shrink it only if it exceeds the viewport. */
|
||||
_reclampPortablePanel(entry) {
|
||||
const { panel, min } = entry;
|
||||
const left = parseInt(panel.style.left, 10);
|
||||
const top = parseInt(panel.style.top, 10);
|
||||
if (!Number.isFinite(left) || !Number.isFinite(top)) return;
|
||||
for (const [property, viewportExtent] of [
|
||||
['width', window.innerWidth],
|
||||
['height', window.innerHeight],
|
||||
]) {
|
||||
const extent = parseFloat(panel.style[property]);
|
||||
if (!Number.isFinite(extent)) continue;
|
||||
const fitted = fitWindowExtent(extent, min[property], viewportExtent);
|
||||
if (fitted < extent) panel.style[property] = `${fitted}px`;
|
||||
}
|
||||
const next = this._clampPortableWindow(entry, left, top);
|
||||
panel.style.left = `${next.left}px`;
|
||||
panel.style.top = `${next.top}px`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamp a floating window inside the viewport margin with its header above
|
||||
* the command dock and voice pill, measured each time since the dock resizes.
|
||||
*/
|
||||
_clampPortableWindow(
|
||||
entry,
|
||||
left,
|
||||
top,
|
||||
geometry = this._windowGeometry(entry),
|
||||
) {
|
||||
const { width, height, header } = geometry;
|
||||
const maxLeft = Math.max(
|
||||
VIEWPORT_MARGIN_PX,
|
||||
window.innerWidth - width - VIEWPORT_MARGIN_PX,
|
||||
);
|
||||
const maxTop = Math.max(
|
||||
VIEWPORT_MARGIN_PX,
|
||||
window.innerHeight - height - VIEWPORT_MARGIN_PX,
|
||||
);
|
||||
const x = clamp(left, VIEWPORT_MARGIN_PX, maxLeft);
|
||||
let y = clamp(top, VIEWPORT_MARGIN_PX, maxTop);
|
||||
const obstacles = this._panelObstacles();
|
||||
for (let pass = 0; pass < obstacles.length; pass++) {
|
||||
const hit = obstacles.find(
|
||||
(rect) =>
|
||||
rect.left < x + width &&
|
||||
rect.right > x &&
|
||||
rect.top < y + header &&
|
||||
rect.bottom > y,
|
||||
);
|
||||
if (!hit) break;
|
||||
const above = hit.top - header - VIEWPORT_MARGIN_PX;
|
||||
y =
|
||||
above >= VIEWPORT_MARGIN_PX
|
||||
? above
|
||||
: Math.min(maxTop, hit.bottom + VIEWPORT_MARGIN_PX);
|
||||
}
|
||||
return { left: x, top: y };
|
||||
}
|
||||
|
||||
_windowGeometry({ panel, handle }, rect = panel.getBoundingClientRect()) {
|
||||
const handleRect = handle?.getBoundingClientRect();
|
||||
const header = handleRect ? handleRect.bottom - rect.top : 0;
|
||||
return {
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
// Without a measurable header, keep the whole window clear.
|
||||
header: header > 0 ? Math.min(header, rect.height) : rect.height,
|
||||
};
|
||||
}
|
||||
|
||||
_panelObstacles() {
|
||||
const rects = [];
|
||||
for (const id of PANEL_OBSTACLE_IDS) {
|
||||
const rect = document.getElementById(id)?.getBoundingClientRect();
|
||||
// A hidden dock, or a voice UI switched off, measures empty.
|
||||
if (rect && rect.width > 0 && rect.height > 0) rects.push(rect);
|
||||
}
|
||||
return rects;
|
||||
}
|
||||
|
||||
_maybeNotifyLayoutReset() {
|
||||
this._dropRetiredPanelKeys();
|
||||
try {
|
||||
const marker = `godsEyeView.${PANEL_POSITION_STORAGE_VERSION}.layoutResetNotified`;
|
||||
if (localStorage.getItem(marker)) return;
|
||||
@@ -229,77 +38,14 @@ export class PanelPositionControls {
|
||||
}
|
||||
}
|
||||
|
||||
_initPanelDrag() {
|
||||
if (this.destroyed || this._dragInitialized) return;
|
||||
this._dragInitialized = true;
|
||||
const cctvPanel = document.getElementById('cctv-panel');
|
||||
const streetLevelPanel = document.getElementById('street-level-panel');
|
||||
// A `portable` panel lifts out of its rail on a header drag, resizes from
|
||||
// every edge and remembers its window; the others only reposition.
|
||||
const dragSpecs = [
|
||||
{
|
||||
id: 'pp-toggles',
|
||||
panel: this._ppToggles,
|
||||
handle: this._ppToggles?.querySelector('.panel-drag-handle.compact'),
|
||||
},
|
||||
{
|
||||
id: 'cctv-panel',
|
||||
panel: cctvPanel,
|
||||
handle: cctvPanel?.querySelector('.panel-header'),
|
||||
portable: true,
|
||||
min: { width: 300, height: 160 },
|
||||
},
|
||||
{
|
||||
id: 'street-level-panel',
|
||||
panel: streetLevelPanel,
|
||||
handle: streetLevelPanel?.querySelector('.panel-header'),
|
||||
portable: true,
|
||||
min: { width: 320, height: 280 },
|
||||
// Shrinking the window to its strip docks it, so it never sits
|
||||
// over the globe as a stray header.
|
||||
dockOnCollapse: true,
|
||||
},
|
||||
].filter(Boolean);
|
||||
|
||||
for (const spec of dragSpecs) {
|
||||
if (!spec.panel || !spec.handle) continue;
|
||||
if (spec.portable) {
|
||||
this._portablePanels.set(spec.id, {
|
||||
panel: spec.panel,
|
||||
handle: spec.handle,
|
||||
min: { width: 300, height: 160, ...spec.min },
|
||||
dockOnCollapse: spec.dockOnCollapse === true,
|
||||
});
|
||||
spec.panel.classList.add('panel-portable');
|
||||
}
|
||||
this._restorePanelPosition(spec.id, spec.panel);
|
||||
this._makePanelDraggable(spec.id, spec.panel, spec.handle);
|
||||
if (spec.portable) {
|
||||
this._makePanelResizable(spec.id, spec.panel, spec.min);
|
||||
}
|
||||
_dropRetiredPanelKeys() {
|
||||
try {
|
||||
for (const key of Object.keys(localStorage))
|
||||
if (RETIRED_PANEL_KEY_PREFIXES.some((prefix) => key.startsWith(prefix)))
|
||||
localStorage.removeItem(key);
|
||||
} catch {
|
||||
// storage unavailable
|
||||
}
|
||||
// Keep a positioned panel on-screen when its HEIGHT changes after restore — it expands to its
|
||||
// full row set a frame or two later, so the restore-time clamp used a stale (shorter) height and
|
||||
// the panel could still hang off the bottom (audit U2). Re-clamp on every size change. Floating
|
||||
// windows grow the same way when their content does.
|
||||
const observed = [
|
||||
this._ppToggles,
|
||||
...[...this._portablePanels.values()].map(({ panel }) => panel),
|
||||
].filter(Boolean);
|
||||
if (observed.length && typeof ResizeObserver !== 'undefined') {
|
||||
this._draggableResizeObserver = new ResizeObserver(() =>
|
||||
this._reclampDraggablePanels(),
|
||||
);
|
||||
for (const panel of observed)
|
||||
this._draggableResizeObserver.observe(panel);
|
||||
}
|
||||
if (this._portablePanels.size) {
|
||||
this.listen(window, 'resize', () => this._reclampDraggablePanels());
|
||||
}
|
||||
}
|
||||
|
||||
_panelStorageKey(panelId) {
|
||||
return `godsEyeView.${PANEL_POSITION_STORAGE_VERSION}.panelPos.${panelId}`;
|
||||
}
|
||||
|
||||
_panelCollapseStorageKey(panelId) {
|
||||
@@ -351,494 +97,4 @@ export class PanelPositionControls {
|
||||
// storage unavailable
|
||||
}
|
||||
}
|
||||
|
||||
_pinPanelToRight(panelEl) {
|
||||
if (!panelEl) return;
|
||||
const rect = panelEl.getBoundingClientRect();
|
||||
const rightOffset = Math.max(6, Math.round(window.innerWidth - rect.right));
|
||||
panelEl.style.right = `${rightOffset}px`;
|
||||
panelEl.style.left = 'auto';
|
||||
}
|
||||
|
||||
_restorePanelPosition(panelId, panelEl) {
|
||||
try {
|
||||
const raw = localStorage.getItem(this._panelStorageKey(panelId));
|
||||
if (!raw) return;
|
||||
const pos = JSON.parse(raw);
|
||||
if (!pos || typeof pos.left !== 'number' || typeof pos.top !== 'number')
|
||||
return;
|
||||
// Only portable panels store, and accept, floating records.
|
||||
const portable = this._portablePanels.get(panelId);
|
||||
if (Boolean(pos.floating) !== Boolean(portable)) return;
|
||||
if (portable) {
|
||||
panelEl.classList.add('panel-floating', 'panel-draggable');
|
||||
// A restored window must stack above the docked rail panels too.
|
||||
this._promotePanelZ(panelEl);
|
||||
if (Number.isFinite(pos.width)) {
|
||||
panelEl.style.width = `${fitWindowExtent(
|
||||
pos.width,
|
||||
portable.min.width,
|
||||
window.innerWidth,
|
||||
)}px`;
|
||||
}
|
||||
if (Number.isFinite(pos.height)) {
|
||||
panelEl.style.height = `${fitWindowExtent(
|
||||
pos.height,
|
||||
portable.min.height,
|
||||
window.innerHeight,
|
||||
)}px`;
|
||||
}
|
||||
}
|
||||
// Clamp to the viewport: a position saved at one window size would otherwise land off-screen at
|
||||
// another (audit U2 — observed a panel at x:-192). The drag handler clamps; restore must too,
|
||||
// and a window saved under the command dock must not come back there.
|
||||
const { left, top } = portable
|
||||
? this._clampPortableWindow(
|
||||
portable,
|
||||
Math.round(pos.left),
|
||||
Math.round(pos.top),
|
||||
)
|
||||
: this._clampToViewport(
|
||||
Math.round(pos.left),
|
||||
Math.round(pos.top),
|
||||
panelEl,
|
||||
);
|
||||
placeAt(panelEl, left, top);
|
||||
if (panelId === 'pp-toggles') {
|
||||
this._pinPanelToRight(panelEl);
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed saved panel position
|
||||
}
|
||||
}
|
||||
|
||||
_clampToViewport(left, top, panelEl) {
|
||||
const rect = panelEl.getBoundingClientRect();
|
||||
const maxLeft = Math.max(6, window.innerWidth - rect.width - 6);
|
||||
const maxTop = Math.max(6, window.innerHeight - rect.height - 6);
|
||||
return {
|
||||
left: Math.max(6, Math.min(maxLeft, left)),
|
||||
top: Math.max(6, Math.min(maxTop, top)),
|
||||
};
|
||||
}
|
||||
|
||||
_savePanelPosition(panelId, panelEl) {
|
||||
const rect = panelEl.getBoundingClientRect();
|
||||
const record = {
|
||||
left: Math.round(rect.left),
|
||||
top: Math.round(rect.top),
|
||||
};
|
||||
if (this._portablePanels.has(panelId)) {
|
||||
// Only an explicitly set size is remembered, and the inline value
|
||||
// rather than the measured box: a collapsed window measures only its
|
||||
// header but keeps the size the user chose for when it expands.
|
||||
const chosen = (inline, measured) => {
|
||||
const px = parseFloat(inline);
|
||||
return Math.round(Number.isFinite(px) ? px : measured);
|
||||
};
|
||||
if (panelEl.style.width)
|
||||
record.width = chosen(panelEl.style.width, rect.width);
|
||||
if (panelEl.style.height)
|
||||
record.height = chosen(panelEl.style.height, rect.height);
|
||||
record.floating = true;
|
||||
}
|
||||
try {
|
||||
localStorage.setItem(
|
||||
this._panelStorageKey(panelId),
|
||||
JSON.stringify(record),
|
||||
);
|
||||
} catch {
|
||||
// storage unavailable
|
||||
}
|
||||
}
|
||||
|
||||
_promotePanelZ(panelEl) {
|
||||
this._panelZCounter += 1;
|
||||
if (this._panelZCounter > PANEL_Z_MAX) {
|
||||
const promoted = [...document.querySelectorAll('.panel-draggable')]
|
||||
.filter((el) => el.style.zIndex)
|
||||
.sort((a, b) => Number(a.style.zIndex) - Number(b.style.zIndex));
|
||||
let z = PANEL_Z_FLOATING_FLOOR;
|
||||
for (const el of promoted) {
|
||||
el.style.zIndex = String(z);
|
||||
z += 1;
|
||||
}
|
||||
this._panelZCounter = z;
|
||||
}
|
||||
panelEl.style.zIndex = String(this._panelZCounter);
|
||||
}
|
||||
|
||||
_maybeShowFloatHint() {
|
||||
try {
|
||||
if (localStorage.getItem(PANEL_FLOAT_HINT_STORAGE_KEY)) return;
|
||||
localStorage.setItem(PANEL_FLOAT_HINT_STORAGE_KEY, '1');
|
||||
} catch {
|
||||
// storage unavailable: still show it once this session
|
||||
if (this._floatHintShown) return;
|
||||
this._floatHintShown = true;
|
||||
}
|
||||
this._showToast(t(PANEL_FLOAT_HINT));
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a rail panel into a fixed window in place. Width is frozen so a
|
||||
* `width: 100%` panel keeps its size; height stays natural until a resize.
|
||||
*/
|
||||
_liftPanelOut(panelId, panelEl, rect = panelEl.getBoundingClientRect()) {
|
||||
if (panelEl.classList.contains('panel-floating')) return false;
|
||||
placeAt(panelEl, Math.round(rect.left), Math.round(rect.top));
|
||||
if (!panelEl.classList.contains('collapsed')) {
|
||||
panelEl.style.width = `${Math.round(rect.width)}px`;
|
||||
}
|
||||
panelEl.style.removeProperty('--right-panel-allocated-height');
|
||||
panelEl.removeAttribute('aria-hidden');
|
||||
panelEl.classList.add('panel-floating', 'panel-draggable');
|
||||
this._promotePanelZ(panelEl);
|
||||
this._layoutRightPanels();
|
||||
this._maybeShowFloatHint();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Snap a floating panel back into its rail and forget its window. */
|
||||
_resetPanelPosition(panelId) {
|
||||
const portable = this._portablePanels.get(panelId);
|
||||
if (!portable) return;
|
||||
const panelEl = portable.panel;
|
||||
this._cancelDrag?.();
|
||||
panelEl.classList.remove(
|
||||
'panel-floating',
|
||||
'panel-draggable',
|
||||
'panel-dragging',
|
||||
'panel-resizing',
|
||||
);
|
||||
for (const property of FLOATING_STYLE_PROPERTIES) {
|
||||
panelEl.style.removeProperty(property);
|
||||
}
|
||||
try {
|
||||
localStorage.removeItem(this._panelStorageKey(panelId));
|
||||
} catch {
|
||||
// storage unavailable
|
||||
}
|
||||
this._layoutRightPanels();
|
||||
this._onPanelResized?.(panelId);
|
||||
}
|
||||
|
||||
_makePanelDraggable(panelId, panelEl, handleEl) {
|
||||
// Z-order promotion: bring clicked panel to front of the stacking context
|
||||
this.listen(panelEl, 'pointerdown', () => {
|
||||
this._promotePanelZ(panelEl);
|
||||
});
|
||||
if (this._portablePanels.has(panelId)) {
|
||||
this._makePortablePanelDraggable(panelId, panelEl, handleEl);
|
||||
return;
|
||||
}
|
||||
|
||||
this.listen(handleEl, 'pointerdown', (event) => {
|
||||
if (event.button !== 0) return;
|
||||
if (event.target.closest('.panel-collapse-btn')) return;
|
||||
if (
|
||||
event.target.closest(
|
||||
'input, select, option, button:not(.panel-collapse-btn)',
|
||||
)
|
||||
)
|
||||
return;
|
||||
|
||||
this._cancelDrag?.();
|
||||
event.preventDefault();
|
||||
const rect = panelEl.getBoundingClientRect();
|
||||
const offsetX = event.clientX - rect.left;
|
||||
const offsetY = event.clientY - rect.top;
|
||||
|
||||
placeAt(panelEl, rect.left, rect.top);
|
||||
panelEl.classList.add('panel-dragging');
|
||||
this._promotePanelZ(panelEl);
|
||||
|
||||
this._trackPointerGesture(event, {
|
||||
onMove: (moveEvent) => {
|
||||
const nextLeftRaw = moveEvent.clientX - offsetX;
|
||||
const nextTopRaw = moveEvent.clientY - offsetY;
|
||||
const maxLeft = Math.max(6, window.innerWidth - rect.width - 6);
|
||||
const maxTop = Math.max(6, window.innerHeight - rect.height - 6);
|
||||
const nextLeft = Math.max(6, Math.min(maxLeft, nextLeftRaw));
|
||||
const nextTop = Math.max(6, Math.min(maxTop, nextTopRaw));
|
||||
panelEl.style.left = `${nextLeft}px`;
|
||||
panelEl.style.top = `${nextTop}px`;
|
||||
if (panelId === 'pp-toggles') {
|
||||
this._layoutRightPanels();
|
||||
}
|
||||
if (panelId === 'cctv-panel') {
|
||||
this._syncCctvPanelViewport();
|
||||
}
|
||||
},
|
||||
onStop: () => panelEl.classList.remove('panel-dragging'),
|
||||
// A cancelled press keeps the place too: there is no rail to revert to.
|
||||
onEnd: () => {
|
||||
if (panelId === 'pp-toggles') {
|
||||
this._pinPanelToRight(panelEl);
|
||||
}
|
||||
this._savePanelPosition(panelId, panelEl);
|
||||
if (panelId === 'cctv-panel') {
|
||||
this._syncCctvPanelViewport();
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Track one pointer gesture. With a `threshold`, `onStart` and `onMove` wait
|
||||
* until the pointer has travelled that far; `onEnd`/`onCancel` get whether
|
||||
* it started. Stopping through `_cancelDrag` calls neither.
|
||||
*/
|
||||
_trackPointerGesture(
|
||||
event,
|
||||
{ threshold = 0, onStart, onMove, onStop, onEnd, onCancel = onEnd },
|
||||
) {
|
||||
const startX = event.clientX;
|
||||
const startY = event.clientY;
|
||||
let started = threshold <= 0;
|
||||
const move = (moveEvent) => {
|
||||
const dx = moveEvent.clientX - startX;
|
||||
const dy = moveEvent.clientY - startY;
|
||||
if (!started) {
|
||||
if (Math.hypot(dx, dy) < threshold) return;
|
||||
started = true;
|
||||
onStart?.();
|
||||
}
|
||||
onMove(moveEvent, dx, dy);
|
||||
};
|
||||
const stop = () => {
|
||||
onStop?.();
|
||||
window.removeEventListener('pointermove', move);
|
||||
window.removeEventListener('pointerup', up);
|
||||
window.removeEventListener('pointercancel', cancel);
|
||||
if (this._cancelDrag === stop) this._cancelDrag = null;
|
||||
};
|
||||
const up = () => {
|
||||
stop();
|
||||
onEnd?.(started);
|
||||
};
|
||||
const cancel = () => {
|
||||
stop();
|
||||
onCancel?.(started);
|
||||
};
|
||||
this._cancelDrag = stop;
|
||||
window.addEventListener('pointermove', move);
|
||||
window.addEventListener('pointerup', up);
|
||||
window.addEventListener('pointercancel', cancel);
|
||||
}
|
||||
|
||||
/** @returns {boolean} Whether the panel was floating and is now docked. */
|
||||
dockPanel(panelId) {
|
||||
const portable = this._portablePanels.get(panelId);
|
||||
if (!portable?.panel.classList.contains('panel-floating')) return false;
|
||||
this._resetPanelPosition(panelId);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** A `dockOnCollapse` panel returns to its rail when collapsed while floating. */
|
||||
onPanelCollapsed(panelId, collapsed) {
|
||||
if (!collapsed || !this._portablePanels.get(panelId)?.dockOnCollapse)
|
||||
return;
|
||||
this.dockPanel(panelId);
|
||||
}
|
||||
|
||||
/**
|
||||
* The collapse button still toggles on a plain click; the click that ends a
|
||||
* real drag is swallowed so it cannot toggle by accident.
|
||||
*/
|
||||
_makePortablePanelDraggable(panelId, panelEl, handleEl) {
|
||||
let swallowNextClick = false;
|
||||
this.listen(
|
||||
handleEl,
|
||||
'click',
|
||||
(event) => {
|
||||
if (!swallowNextClick) return;
|
||||
swallowNextClick = false;
|
||||
event.stopImmediatePropagation();
|
||||
event.preventDefault();
|
||||
},
|
||||
{ capture: true },
|
||||
);
|
||||
this.listen(handleEl, 'dblclick', (event) => {
|
||||
if (event.target.closest?.(INTERACTIVE_SELECTOR)) return;
|
||||
if (!panelEl.classList.contains('panel-floating')) return;
|
||||
event.preventDefault();
|
||||
this._resetPanelPosition(panelId);
|
||||
});
|
||||
let lastPress = null;
|
||||
this.listen(handleEl, 'pointerdown', (event) => {
|
||||
if (event.button !== 0 || !isPrecisePointer(event)) return;
|
||||
const interactive = event.target.closest?.(INTERACTIVE_SELECTOR);
|
||||
if (interactive && !interactive.matches('.panel-collapse-btn')) return;
|
||||
|
||||
const now = event.timeStamp || performance.now();
|
||||
const doublePress =
|
||||
!interactive &&
|
||||
lastPress &&
|
||||
now - lastPress.time <= DOUBLE_PRESS_MS &&
|
||||
Math.hypot(event.clientX - lastPress.x, event.clientY - lastPress.y) <=
|
||||
DOUBLE_PRESS_SLOP_PX;
|
||||
lastPress = doublePress
|
||||
? null
|
||||
: { time: now, x: event.clientX, y: event.clientY };
|
||||
if (doublePress && panelEl.classList.contains('panel-floating')) {
|
||||
event.preventDefault();
|
||||
this._resetPanelPosition(panelId);
|
||||
return;
|
||||
}
|
||||
|
||||
this._cancelDrag?.();
|
||||
// A header press must not start a text selection across the page.
|
||||
event.preventDefault();
|
||||
const entry = this._portablePanels.get(panelId);
|
||||
let lifted = false;
|
||||
let before = null;
|
||||
let offsetX = 0;
|
||||
let offsetY = 0;
|
||||
let geometry = null;
|
||||
|
||||
this._trackPointerGesture(event, {
|
||||
threshold: DRAG_THRESHOLD_PX,
|
||||
onStart: () => {
|
||||
// A drag between two presses is not a double-click.
|
||||
lastPress = null;
|
||||
const rect = panelEl.getBoundingClientRect();
|
||||
offsetX = event.clientX - rect.left;
|
||||
offsetY = event.clientY - rect.top;
|
||||
geometry = this._windowGeometry(entry, rect);
|
||||
before = geometryOf(panelEl);
|
||||
lifted = this._liftPanelOut(panelId, panelEl, rect);
|
||||
panelEl.classList.add('panel-dragging');
|
||||
this._promotePanelZ(panelEl);
|
||||
},
|
||||
onMove: (moveEvent) => {
|
||||
moveEvent.preventDefault();
|
||||
const next = this._clampPortableWindow(
|
||||
entry,
|
||||
moveEvent.clientX - offsetX,
|
||||
moveEvent.clientY - offsetY,
|
||||
geometry,
|
||||
);
|
||||
panelEl.style.left = `${next.left}px`;
|
||||
panelEl.style.top = `${next.top}px`;
|
||||
},
|
||||
onStop: () => panelEl.classList.remove('panel-dragging'),
|
||||
onEnd: (dragged) => {
|
||||
if (!dragged) return;
|
||||
swallowNextClick = true;
|
||||
setTimeout(() => {
|
||||
swallowNextClick = false;
|
||||
}, 0);
|
||||
this._savePanelPosition(panelId, panelEl);
|
||||
this._onPanelResized?.(panelId);
|
||||
},
|
||||
onCancel: (dragged) => {
|
||||
if (dragged)
|
||||
this._revertPanelGesture(panelId, panelEl, lifted, before);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** On pointercancel, restore the panel as the gesture found it; save nothing. */
|
||||
_revertPanelGesture(panelId, panelEl, lifted, before) {
|
||||
if (lifted) {
|
||||
this._resetPanelPosition(panelId);
|
||||
return;
|
||||
}
|
||||
Object.assign(panelEl.style, before);
|
||||
this._onPanelResized?.(panelId);
|
||||
}
|
||||
|
||||
/** Resizing a docked panel lifts it out first, so handles work in both states. */
|
||||
_makePanelResizable(panelId, panelEl, min = {}) {
|
||||
const limits = { width: 300, height: 160, ...min };
|
||||
for (const dir of RESIZE_DIRECTIONS) {
|
||||
const handle = document.createElement('div');
|
||||
handle.className =
|
||||
dir === 'se' ? 'panel-resize-grip' : 'panel-resize-edge';
|
||||
handle.dataset.dir = dir;
|
||||
handle.setAttribute('aria-hidden', 'true');
|
||||
if (dir === 'se') {
|
||||
handle.title = t(
|
||||
'Drag to resize · double-click the header to snap back',
|
||||
);
|
||||
}
|
||||
panelEl.appendChild(handle);
|
||||
this._resizeHandles.push(handle);
|
||||
this.listen(handle, 'pointerdown', (event) =>
|
||||
this._startPanelResize(event, panelId, panelEl, dir, limits),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
_startPanelResize(event, panelId, panelEl, dir, limits) {
|
||||
if (event.button !== 0 || !isPrecisePointer(event)) return;
|
||||
event.preventDefault();
|
||||
this._cancelDrag?.();
|
||||
// Set once the pointer has travelled: a plain click on an edge must not
|
||||
// lift the panel out.
|
||||
let startBox = null;
|
||||
let lifted = false;
|
||||
let before = null;
|
||||
const apply = ({ left, top, width, height }) => {
|
||||
panelEl.style.left = `${left}px`;
|
||||
panelEl.style.top = `${top}px`;
|
||||
panelEl.style.width = `${width}px`;
|
||||
panelEl.style.height = `${height}px`;
|
||||
};
|
||||
|
||||
this._trackPointerGesture(event, {
|
||||
threshold: DRAG_THRESHOLD_PX,
|
||||
onStart: () => {
|
||||
const rect = panelEl.getBoundingClientRect();
|
||||
before = geometryOf(panelEl);
|
||||
lifted = this._liftPanelOut(panelId, panelEl, rect);
|
||||
startBox = {
|
||||
left: Math.round(rect.left),
|
||||
top: Math.round(rect.top),
|
||||
width: Math.round(rect.width),
|
||||
height: Math.round(rect.height),
|
||||
};
|
||||
panelEl.classList.add('panel-resizing');
|
||||
this._promotePanelZ(panelEl);
|
||||
apply(startBox);
|
||||
},
|
||||
onMove: (moveEvent, dx, dy) => {
|
||||
moveEvent.preventDefault();
|
||||
apply(
|
||||
resizeBox(startBox, dir, dx, dy, {
|
||||
minWidth: limits.width,
|
||||
minHeight: limits.height,
|
||||
viewportWidth: window.innerWidth,
|
||||
viewportHeight: window.innerHeight,
|
||||
}),
|
||||
);
|
||||
},
|
||||
onStop: () => panelEl.classList.remove('panel-resizing'),
|
||||
onEnd: (resized) => {
|
||||
if (!resized) return;
|
||||
// Re-clamping skipped the live gesture; a north edge may have moved
|
||||
// the header under the command dock.
|
||||
const entry = this._portablePanels.get(panelId);
|
||||
if (entry) this._reclampPortablePanel(entry);
|
||||
this._savePanelPosition(panelId, panelEl);
|
||||
this._onPanelResized?.(panelId);
|
||||
},
|
||||
onCancel: (resized) => {
|
||||
if (resized) this._revertPanelGesture(panelId, panelEl, lifted, before);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.destroyed) return;
|
||||
this.destroyed = true;
|
||||
this._cancelDrag?.();
|
||||
for (const remove of this.removers.splice(0)) remove();
|
||||
for (const handle of this._resizeHandles.splice(0)) handle.remove();
|
||||
this._draggableResizeObserver?.disconnect();
|
||||
this._draggableResizeObserver = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { PanelPositionControls } from './panelPositionControls.js';
|
||||
|
||||
/** A localStorage stand-in whose keys enumerate like the real one. */
|
||||
function fakeStorage(entries) {
|
||||
const storage = {};
|
||||
Object.defineProperties(storage, {
|
||||
getItem: { value: (key) => (key in storage ? storage[key] : null) },
|
||||
setItem: { value: (key, value) => (storage[key] = String(value)) },
|
||||
removeItem: { value: (key) => delete storage[key] },
|
||||
});
|
||||
Object.assign(storage, entries);
|
||||
return storage;
|
||||
}
|
||||
|
||||
test('the floating panels’ stored positions and hint flag are dropped; collapse state stays', (t) => {
|
||||
const saved = globalThis.localStorage;
|
||||
globalThis.localStorage = fakeStorage({
|
||||
'godsEyeView.v8.panelPos.cctv-panel': '{"x":1}',
|
||||
'godsEyeView.v8.panelFloatHintShown': '1',
|
||||
'godsEyeView.v6.panelCollapsed.cctv-panel': '1',
|
||||
'godsEyeView.v8.layoutResetNotified': '1',
|
||||
});
|
||||
t.after(() => {
|
||||
globalThis.localStorage = saved;
|
||||
});
|
||||
const controls = new PanelPositionControls({ showToast() {} });
|
||||
controls._maybeNotifyLayoutReset();
|
||||
assert.deepEqual(Object.keys(globalThis.localStorage).sort(), [
|
||||
'godsEyeView.v6.panelCollapsed.cctv-panel',
|
||||
'godsEyeView.v8.layoutResetNotified',
|
||||
]);
|
||||
});
|
||||
@@ -113,10 +113,7 @@ function element(
|
||||
getBoundingClientRect() {
|
||||
return this.rect;
|
||||
},
|
||||
matches: (selector) =>
|
||||
selector === '[data-panel-id]' ||
|
||||
(selector === '[data-panel-id]:not(.panel-floating)' &&
|
||||
!classes.has('panel-floating')),
|
||||
matches: (selector) => selector === '[data-panel-id]',
|
||||
contains(target) {
|
||||
return (
|
||||
target === this || this.children.some((child) => child.contains(target))
|
||||
@@ -325,28 +322,6 @@ test('right layout retains Display allocation during measurement and caps restor
|
||||
);
|
||||
});
|
||||
|
||||
test('right rail neither measures nor allocates a panel floating out of it', () => {
|
||||
const f = fixture('right');
|
||||
f.expand(f.first, 900);
|
||||
f.expand(f.second, 300);
|
||||
f.first.classList.add('panel-floating');
|
||||
f.first.style.setProperty('--right-panel-allocated-height', '500px');
|
||||
f.first.writes.length = 0;
|
||||
f.run();
|
||||
assert.equal(f.stack.dataset.expandedCount, '1');
|
||||
assert.deepEqual(
|
||||
f.first.writes,
|
||||
[],
|
||||
'a floating panel keeps whatever inline style it left the rail with',
|
||||
);
|
||||
assert.equal(f.first.getAttribute('aria-hidden'), undefined);
|
||||
assert.equal(f.first.classList.contains('layout-auto-collapsed'), false);
|
||||
assert.ok(
|
||||
f.second.style.getPropertyValue('--right-panel-allocated-height'),
|
||||
'the docked panel still receives its allocation',
|
||||
);
|
||||
});
|
||||
|
||||
test('right rail aligns to the current left rail and excludes hidden obstacles', () => {
|
||||
const f = fixture('right');
|
||||
f.options.leftStack.rect.top = 200;
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
// Pure resize geometry for floating panels; PanelPositionControls wires the DOM.
|
||||
|
||||
const EDGE_MARGIN_PX = 6;
|
||||
export const RESIZE_DIRECTIONS = ['n', 's', 'e', 'w', 'ne', 'nw', 'sw', 'se'];
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Edges named in `dir` follow the pointer (dx, dy since gesture start) and the
|
||||
* opposite edge stays put. Minimum size and the viewport margin win.
|
||||
*/
|
||||
export function resizeBox(
|
||||
box,
|
||||
dir,
|
||||
dx,
|
||||
dy,
|
||||
{
|
||||
minWidth,
|
||||
minHeight,
|
||||
viewportWidth = Infinity,
|
||||
viewportHeight = Infinity,
|
||||
margin = EDGE_MARGIN_PX,
|
||||
} = {},
|
||||
) {
|
||||
let { left, top, width, height } = box;
|
||||
const right = box.left + box.width;
|
||||
const bottom = box.top + box.height;
|
||||
if (dir.includes('e'))
|
||||
width = clamp(box.width + dx, minWidth, viewportWidth - margin - box.left);
|
||||
if (dir.includes('s'))
|
||||
height = clamp(
|
||||
box.height + dy,
|
||||
minHeight,
|
||||
viewportHeight - margin - box.top,
|
||||
);
|
||||
if (dir.includes('w')) {
|
||||
width = clamp(box.width - dx, minWidth, right - margin);
|
||||
left = right - width;
|
||||
}
|
||||
if (dir.includes('n')) {
|
||||
height = clamp(box.height - dy, minHeight, bottom - margin);
|
||||
top = bottom - height;
|
||||
}
|
||||
return { left, top, width, height };
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { resizeBox } from './panelResize.js';
|
||||
|
||||
const box = { left: 100, top: 100, width: 400, height: 300 };
|
||||
const limits = {
|
||||
minWidth: 200,
|
||||
minHeight: 150,
|
||||
viewportWidth: 1000,
|
||||
viewportHeight: 800,
|
||||
};
|
||||
|
||||
test('east and south edges grow away from the anchored corner', () => {
|
||||
assert.deepEqual(resizeBox(box, 'se', 50, 20, limits), {
|
||||
left: 100,
|
||||
top: 100,
|
||||
width: 450,
|
||||
height: 320,
|
||||
});
|
||||
assert.deepEqual(resizeBox(box, 'e', -30, 999, limits), {
|
||||
left: 100,
|
||||
top: 100,
|
||||
width: 370,
|
||||
height: 300,
|
||||
});
|
||||
});
|
||||
|
||||
test('west and north edges keep the opposite edge pinned', () => {
|
||||
assert.deepEqual(resizeBox(box, 'w', -40, 0, limits), {
|
||||
left: 60,
|
||||
top: 100,
|
||||
width: 440,
|
||||
height: 300,
|
||||
});
|
||||
assert.deepEqual(resizeBox(box, 'nw', 40, 30, limits), {
|
||||
left: 140,
|
||||
top: 130,
|
||||
width: 360,
|
||||
height: 270,
|
||||
});
|
||||
assert.deepEqual(
|
||||
resizeBox(box, 'n', -50, 0, limits).top,
|
||||
100,
|
||||
'horizontal motion does not move a north edge',
|
||||
);
|
||||
});
|
||||
|
||||
test('minimum size stops the moving edge, not the anchored one', () => {
|
||||
const small = resizeBox(box, 'w', 350, 0, limits);
|
||||
assert.deepEqual(small, { left: 300, top: 100, width: 200, height: 300 });
|
||||
assert.equal(resizeBox(box, 'ne', 0, 400, limits).height, 150);
|
||||
assert.equal(resizeBox(box, 'ne', 0, 400, limits).top, 250);
|
||||
});
|
||||
|
||||
test('the viewport margin caps growth in every direction', () => {
|
||||
assert.equal(resizeBox(box, 'e', 2000, 0, limits).width, 1000 - 6 - 100);
|
||||
assert.equal(resizeBox(box, 's', 0, 2000, limits).height, 800 - 6 - 100);
|
||||
const west = resizeBox(box, 'w', -2000, 0, limits);
|
||||
assert.equal(west.left, 6);
|
||||
assert.equal(west.left + west.width, 500);
|
||||
const north = resizeBox(box, 'n', 0, -2000, limits);
|
||||
assert.equal(north.top, 6);
|
||||
assert.equal(north.top + north.height, 400);
|
||||
});
|
||||
@@ -69,15 +69,15 @@ export function layoutRightPanelRail({
|
||||
// A collapse may schedule one follow-up, which only measures and allocates.
|
||||
const isCollapseRetry = pendingCollapseRetries.delete(stack);
|
||||
|
||||
// A floating (lifted-out) panel is not laid out by the rail. Neither is one
|
||||
// that other CSS hides, such as an event view. Exclusive presentation hides
|
||||
// collapsed panels itself; those stay in the rail so they can be restored.
|
||||
// Other CSS may hide a panel, such as an event view: it is not laid out.
|
||||
// Exclusive presentation hides collapsed panels itself; those stay in the
|
||||
// rail so they can be restored.
|
||||
const hiddenByRail = (panel) =>
|
||||
stack.classList.contains('layout-exclusive') &&
|
||||
panel.classList.contains('collapsed');
|
||||
const panels = [...stack.children].filter(
|
||||
(panel) =>
|
||||
panel.matches('[data-panel-id]:not(.panel-floating)') &&
|
||||
panel.matches('[data-panel-id]') &&
|
||||
!panel.hidden &&
|
||||
(hiddenByRail(panel) || getComputedStyle(panel).display !== 'none'),
|
||||
);
|
||||
|
||||
@@ -576,16 +576,6 @@ export class ShellFacade {
|
||||
return this._layerBindings._connectDirectionsCamera(...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* On window resize, keep the draggable panel on-screen — a panel positioned near an edge can fall
|
||||
* outside a now-smaller viewport (audit U2). pp-toggles is right-pinned, so re-pin (horizontal) and
|
||||
* clamp its top. No-op until the panel has been positioned (explicit inline top).
|
||||
* @returns {void}
|
||||
*/
|
||||
_reclampDraggablePanels() {
|
||||
return this._panelPosition._reclampDraggablePanels();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates one CesiumJS PostProcessStage per visual style and registers
|
||||
* it with the scene. Each stage starts with intensity 0 (invisible)
|
||||
@@ -911,15 +901,6 @@ export class ShellFacade {
|
||||
return this._panelChrome._maybeNotifyLayoutReset(...arguments);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up drag-to-reposition for legacy floating controls. The right rail
|
||||
* and left accordion remain fixed so their HUD alignment is deterministic.
|
||||
* @returns {void}
|
||||
*/
|
||||
_initPanelDrag() {
|
||||
return this._panelPosition._initPanelDrag();
|
||||
}
|
||||
|
||||
_persistAwarenessSelection(...args) {
|
||||
return this._layerBindings._persistAwarenessSelection(...args);
|
||||
}
|
||||
@@ -980,15 +961,6 @@ export class ShellFacade {
|
||||
return this._radioControls?._renderRadioState?.(...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the versioned localStorage key for a panel's saved position.
|
||||
* @param {string} panelId - DOM id of the panel.
|
||||
* @returns {string} localStorage key.
|
||||
*/
|
||||
_panelStorageKey(panelId) {
|
||||
return this._panelPosition._panelStorageKey(panelId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the versioned localStorage key for a panel's collapsed state.
|
||||
* @param {string} panelId - DOM id of the panel.
|
||||
@@ -1083,64 +1055,6 @@ export class ShellFacade {
|
||||
return this._panelChrome._syncPanelCollapseButton(...arguments);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a panel from left-positioned to right-anchored so it expands
|
||||
* leftward on resize. Used for the right-rail parameter panel.
|
||||
* @param {HTMLElement} panelEl - The panel to re-anchor.
|
||||
* @returns {void}
|
||||
*/
|
||||
_pinPanelToRight(panelEl) {
|
||||
return this._panelPosition._pinPanelToRight(panelEl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores a panel's top/left position from localStorage.
|
||||
* Right-rail panels are additionally pinned to the right edge.
|
||||
* @param {string} panelId - DOM id of the panel.
|
||||
* @param {HTMLElement} panelEl - The panel DOM element.
|
||||
* @returns {void}
|
||||
*/
|
||||
_restorePanelPosition(panelId, panelEl) {
|
||||
return this._panelPosition._restorePanelPosition(panelId, panelEl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamp a desired left/top so the panel stays fully on-screen (6px inset), matching the drag
|
||||
* clamp (ui.js ~1822). Width/height are position-independent, so reading the rect first is safe.
|
||||
* @param {number} left - desired left (px)
|
||||
* @param {number} top - desired top (px)
|
||||
* @param {HTMLElement} panelEl - the panel element
|
||||
* @returns {{left:number, top:number}}
|
||||
*/
|
||||
_clampToViewport(left, top, panelEl) {
|
||||
return this._panelPosition._clampToViewport(left, top, panelEl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists a panel's current bounding-rect position to localStorage.
|
||||
* @param {string} panelId - DOM id of the panel.
|
||||
* @param {HTMLElement} panelEl - The panel DOM element.
|
||||
* @returns {void}
|
||||
*/
|
||||
_savePanelPosition(panelId, panelEl) {
|
||||
return this._panelPosition._savePanelPosition(panelId, panelEl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Promotes a panel to the top of the panel z band [PANEL_Z_BASE, PANEL_Z_MAX].
|
||||
* Renormalizes all promoted panels when the band is exhausted so panels can
|
||||
* never climb above the voice pill (150), toasts (200), or clean-view exit (300).
|
||||
* @param {HTMLElement} panelEl - Panel to bring to front.
|
||||
* @returns {void}
|
||||
*/
|
||||
_promotePanelZ(panelEl) {
|
||||
return this._panelPosition._promotePanelZ(panelEl);
|
||||
}
|
||||
|
||||
_makePanelDraggable(panelId, panelEl, handleEl) {
|
||||
return this._panelPosition._makePanelDraggable(panelId, panelEl, handleEl);
|
||||
}
|
||||
|
||||
_buildSharePanelState() {
|
||||
return this._panelChrome._buildSharePanelState(...arguments);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import assert from 'node:assert/strict';
|
||||
import { UiLifetime } from './uiLifetime.js';
|
||||
import { PanelChrome } from './panelChrome.js';
|
||||
import { PanelLayoutController } from './panelLayoutController.js';
|
||||
import { PanelPositionControls } from './panelPositionControls.js';
|
||||
import { ShellFeedback } from './shellFeedback.js';
|
||||
import { RecordingControls } from './recordingControls.js';
|
||||
|
||||
@@ -174,46 +173,6 @@ test('panel layout coalesces each rail and cannot rearm after destruction', () =
|
||||
}
|
||||
});
|
||||
|
||||
test('panel disposal cancels a live drag without saving or accepting later pointer movement', () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const owner = new PanelPositionControls({
|
||||
syncPanelCollapseButton() {},
|
||||
layoutRightPanels() {},
|
||||
syncCctvPanelViewport() {},
|
||||
showToast() {},
|
||||
});
|
||||
const panel = f.element(),
|
||||
handle = f.element();
|
||||
owner._makePanelDraggable('sample', panel, handle);
|
||||
handle.dispatchEvent(
|
||||
Object.assign(new Event('pointerdown', { cancelable: true }), {
|
||||
button: 0,
|
||||
clientX: 30,
|
||||
clientY: 40,
|
||||
}),
|
||||
);
|
||||
assert.equal(panel.classList.contains('panel-dragging'), true);
|
||||
owner.destroy();
|
||||
const before = { ...panel.style };
|
||||
window.dispatchEvent(
|
||||
Object.assign(new Event('pointermove'), { clientX: 500, clientY: 300 }),
|
||||
);
|
||||
window.dispatchEvent(new Event('pointerup'));
|
||||
assert.deepEqual(panel.style, before);
|
||||
assert.equal(panel.classList.contains('panel-dragging'), false);
|
||||
owner._makePanelDraggable('sample', panel, handle);
|
||||
owner._initPanelDrag();
|
||||
owner._ppToggles.style.top = '10000px';
|
||||
owner._reclampDraggablePanels();
|
||||
assert.equal(owner._ppToggles.style.top, '10000px');
|
||||
assert.equal(owner.removers.length, 0);
|
||||
assert.deepEqual(f.writes, []);
|
||||
} finally {
|
||||
f.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('feedback disposal clears toast and polling work and rejects retained notices', () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
@@ -269,7 +228,7 @@ test('recording restores the original HUD after repeated entry and revokes retai
|
||||
}
|
||||
});
|
||||
|
||||
test('weather rail orders between CCTV and Context, resets positions, observes and syncs chrome', (t) => {
|
||||
test('weather rail orders between CCTV and Context, observes and syncs chrome', (t) => {
|
||||
const f = fixture();
|
||||
const observed = new Set();
|
||||
const previousResizeObserver = globalThis.ResizeObserver;
|
||||
@@ -304,17 +263,8 @@ test('weather rail orders between CCTV and Context, resets positions, observes a
|
||||
]) {
|
||||
const panel = f.nodes.get(id) || f.element();
|
||||
panel.id = id;
|
||||
const positions = new Map(
|
||||
['top', 'right', 'bottom', 'left', 'z-index'].map((key) => [
|
||||
key,
|
||||
'20px',
|
||||
]),
|
||||
);
|
||||
panel.style = {
|
||||
removeProperty: (key) => positions.delete(key),
|
||||
positions,
|
||||
};
|
||||
panel.classList.add('panel-draggable', 'panel-dragging', 'collapsed');
|
||||
panel.style = {};
|
||||
panel.classList.add('collapsed');
|
||||
const button = f.element();
|
||||
button.closest = () => panel;
|
||||
const attributes = new Map();
|
||||
@@ -348,9 +298,6 @@ test('weather rail orders between CCTV and Context, resets positions, observes a
|
||||
);
|
||||
assert.ok(synced.includes('weather-panel'));
|
||||
assert.ok(observed.has(weather));
|
||||
assert.equal(weather.style.positions.size, 0);
|
||||
assert.equal(weather.classList.contains('panel-draggable'), false);
|
||||
assert.equal(weather.classList.contains('panel-dragging'), false);
|
||||
assert.equal(weather.button.textContent, '◀');
|
||||
assert.equal(weather.button.getAttribute('aria-expanded'), 'false');
|
||||
assert.equal(weather.button.title, 'Expand WEATHER');
|
||||
|
||||
@@ -80,6 +80,7 @@ function bindings(t) {
|
||||
hud: { attachDataManager() {} },
|
||||
_contextControls: { connect() {} },
|
||||
_cctvControls: { connect() {} }, _radioControls: { connect() {} },
|
||||
_streetLevelControls: { connects: 0, connect() { this.connects += 1; } },
|
||||
};
|
||||
const owner = new LayerBindings({
|
||||
viewer: {}, services: {}, readControls: () => controls,
|
||||
@@ -97,7 +98,7 @@ function bindings(t) {
|
||||
subscribe() { events.push([id, 'subscribe']); return () => events.push([id, 'unsubscribe']); },
|
||||
};
|
||||
}
|
||||
return { owner, manager, events };
|
||||
return { owner, manager, events, controls };
|
||||
}
|
||||
|
||||
test('manager replacement releases Directions even when the new manager has no route layer', (t) => {
|
||||
@@ -116,6 +117,13 @@ test('manager replacement releases Directions even when the new manager has no r
|
||||
assert.equal(events.length, count);
|
||||
});
|
||||
|
||||
test('attaching the manager reconnects Street Level, so its switch-on requests reach the panel', (t) => {
|
||||
const { owner, manager, controls } = bindings(t);
|
||||
owner.attachDataManager(manager('first'));
|
||||
assert.equal(controls._streetLevelControls.connects, 1);
|
||||
owner.stop(); owner.disconnect();
|
||||
});
|
||||
|
||||
test('camera-entry listeners are removed before any asynchronous layer cleanup', (t) => {
|
||||
const { owner } = bindings(t);
|
||||
let changes = 0;
|
||||
|
||||
@@ -28,7 +28,6 @@ const KEYS = [
|
||||
'Exit cockpit to fly to a shared view',
|
||||
'Go to expanded Radio section',
|
||||
'Expand Radio section in Context',
|
||||
'Double-click the header to snap the panel back',
|
||||
'New scene name',
|
||||
'Delete scene "{scene}" and all shots?',
|
||||
'Detailed outline unavailable',
|
||||
|
||||
Loaded 100 of 151 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user