mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts: # docs/CURRENT-STATE.md # server/providers/cctv/sources.js
This commit is contained in:
commit
2bec3d521b
154 files changed
+17718
-1059
No files matched your search
@@ -24,6 +24,7 @@ import { createApplicationLocalAdsb } from './layers/localAdsb.js';
|
||||
import { createApplicationAwareness } from './layers/militaryAwareness.js';
|
||||
import { createApplicationFirms } from './layers/firms.js';
|
||||
import { createApplicationEarthquakes } from './layers/earthquakes.js';
|
||||
import { createApplicationAirQuality } from './layers/airQuality.js';
|
||||
import { createApplicationFirePerimeters } from './layers/perimeters.js';
|
||||
import { createApplicationGnssIntegrity } from './layers/gnss.js';
|
||||
import { createApplicationCables } from './layers/submarineCables.js';
|
||||
@@ -118,6 +119,7 @@ export function createApplicationCatalog({
|
||||
...(resolveAsset ? { resolveAsset } : {}),
|
||||
}),
|
||||
createApplicationEarthquakes({ source: sources.earthquakes }),
|
||||
createApplicationAirQuality({ source: sources['air-quality'] }),
|
||||
createApplicationFirePerimeters({
|
||||
source: sources['fire-perimeters'],
|
||||
}),
|
||||
|
||||
@@ -40,7 +40,7 @@ test('catalogs construct distinct layers and classification from their supplied
|
||||
signal: b.signal,
|
||||
surface: fixtureSurface(b.signal),
|
||||
});
|
||||
assert.equal(first.layers.length, 31);
|
||||
assert.equal(first.layers.length, 32);
|
||||
assert.ok(first.get('local-adsb'), 'Local ADS-B is registered');
|
||||
assert.deepEqual(
|
||||
first.metadata.find(({ id }) => id === 'local-adsb'),
|
||||
@@ -154,7 +154,7 @@ test('Mapillary can be omitted or replaced without changing catalog membership',
|
||||
surface: fixtureSurface(lifetime.signal),
|
||||
};
|
||||
const catalog = createApplicationCatalog(options);
|
||||
assert.equal(catalog.layers.length, 31);
|
||||
assert.equal(catalog.layers.length, 32);
|
||||
assert.deepEqual(catalog.get('street-level').providerIds, []);
|
||||
assert.equal(
|
||||
catalogControlServices(catalog).streetLevelLayer,
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createAirQualityLayer } from '../../layers/airQuality/index.js';
|
||||
import { overlayHost } from './overlayHost.js';
|
||||
/** Wire air-quality readings to the application overlay host. */
|
||||
export function createApplicationAirQuality(options) {
|
||||
return createAirQualityLayer({ overlayHost, ...options });
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { createStreetLevelLayer } from '../../layers/streetLevel/index.js';
|
||||
import { createMapillaryProvider } from '../../layers/streetLevel/providers/mapillary/index.js';
|
||||
import { createGoogleProvider } from '../../layers/streetLevel/providers/google/index.js';
|
||||
import { createKartaViewProvider } from '../../layers/streetLevel/providers/kartaview/index.js';
|
||||
import { createPanoramaxProvider } from '../../layers/streetLevel/providers/panoramax/index.js';
|
||||
import * as sprites from '../../data/spriteOrder.js';
|
||||
import * as picking from '../../data/pickRegistry.js';
|
||||
import * as input from '../../data/inputOwnership.js';
|
||||
@@ -12,8 +13,8 @@ import * as render from '../../renderGovernor.js';
|
||||
const googleApiKey = () => globalThis.window?.__GOOGLE_MAPS_API_KEY__ || null;
|
||||
|
||||
/**
|
||||
* Mapillary, Google Street View and KartaView, once the Mapillary source is
|
||||
* supplied; else none.
|
||||
* Mapillary, Google Street View, KartaView and Panoramax, once the Mapillary
|
||||
* source is supplied; else none.
|
||||
*/
|
||||
function defaultProviders(sources) {
|
||||
if (sources.mapillary == null) return [];
|
||||
@@ -21,6 +22,7 @@ function defaultProviders(sources) {
|
||||
createMapillaryProvider({ source: sources.mapillary }),
|
||||
createGoogleProvider({ getApiKey: googleApiKey }),
|
||||
createKartaViewProvider(),
|
||||
createPanoramaxProvider(),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ test('explicit providers replace the default source and an empty list removes it
|
||||
);
|
||||
});
|
||||
|
||||
test('the defaults are Mapillary (given its source), Street View and KartaView, which start off and travel as provider switches', async () => {
|
||||
test('the defaults are Mapillary (given its source), Street View, KartaView and Panoramax, which start off and travel as provider switches', async () => {
|
||||
const { createDefaultLayerState } = await import('../../data/layerState.js');
|
||||
const mapillary = {
|
||||
getStatus() {},
|
||||
@@ -37,7 +37,12 @@ test('the defaults are Mapillary (given its source), Street View and KartaView,
|
||||
nearestImages() {},
|
||||
};
|
||||
const layer = createApplicationStreetLevel({ sources: { mapillary } });
|
||||
assert.deepEqual(layer.providerIds, ['mapillary', 'google', 'kartaview']);
|
||||
assert.deepEqual(layer.providerIds, [
|
||||
'mapillary',
|
||||
'google',
|
||||
'kartaview',
|
||||
'panoramax',
|
||||
]);
|
||||
// A fresh page and a restored default agree: Street View is billed, so off.
|
||||
layer.setParams(createDefaultLayerState().options['street-level']);
|
||||
assert.equal(layer.getParams().google, false);
|
||||
@@ -45,12 +50,25 @@ test('the defaults are Mapillary (given its source), Street View and KartaView,
|
||||
// A link that lit Street View carries it in the shared switch field.
|
||||
layer.setParams({ providerSwitches: 'google-1' });
|
||||
assert.equal(layer.getParams().google, true);
|
||||
assert.equal(layer.getParams().providerSwitches, 'google-1*kartaview-0');
|
||||
assert.equal(
|
||||
layer.getParams().providerSwitches,
|
||||
'google-1*kartaview-0*panoramax-0',
|
||||
);
|
||||
// KartaView is keyless and free, but one source shows at a time.
|
||||
layer.setParams({ providerSwitches: 'kartaview-1' });
|
||||
assert.equal(layer.getParams().kartaview, true);
|
||||
assert.equal(layer.getParams().google, false);
|
||||
assert.equal(layer.getParams().mapillary, false);
|
||||
// As is Panoramax: lighting it puts KartaView out.
|
||||
layer.setParams({ providerSwitches: 'panoramax-1' });
|
||||
assert.equal(layer.getParams().panoramax, true);
|
||||
assert.equal(layer.getParams().kartaview, false);
|
||||
assert.equal(layer.getParams().mapillary, false);
|
||||
// The form the app writes (the README and CHANGELOG quote it).
|
||||
assert.equal(
|
||||
layer.getParams().providerSwitches,
|
||||
'google-0*kartaview-0*panoramax-1',
|
||||
);
|
||||
// Without the application's Street Level source there are none.
|
||||
assert.deepEqual(createApplicationStreetLevel({}).providerIds, []);
|
||||
});
|
||||
@@ -46,6 +46,7 @@ export const CATALOG_SOURCE_CONTRACTS = Object.freeze({
|
||||
cyclones: contract(['weather-cyclones'], ['getSnapshot']),
|
||||
earthquakes: contract(['earthquakes'], ['getSnapshot']),
|
||||
'fire-perimeters': contract(['fire-perimeters'], ['getSnapshot']),
|
||||
'air-quality': contract(['air-quality'], ['getSnapshot']),
|
||||
'gnss-integrity': contract(['gnss-integrity'], ['getSnapshot']),
|
||||
cables: contract(['telegeography-submarine-cables'], ['fetch']),
|
||||
transit: contract(['transit'], ['requestSnapshot', 'getHistory']),
|
||||
|
||||
@@ -37,7 +37,7 @@ test('every acquisition source can be omitted without losing catalog membership'
|
||||
|
||||
test('all acquisition sources may be absent, and enable fails before initializing or fetching', async (t) => {
|
||||
const actual = catalog({}, t);
|
||||
assert.equal(actual.layers.length, 31);
|
||||
assert.equal(actual.layers.length, 32);
|
||||
const manager = new LayerLifecycle(
|
||||
{},
|
||||
{ getSourceAvailability: actual.getSourceAvailability },
|
||||
|
||||
@@ -0,0 +1,683 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
|
||||
import {
|
||||
CATALONIA_CAMERAS_URL,
|
||||
CATALONIA_MAX_CATALOG_BYTES,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import { decodeNumericEntities } from '../../server/providers/cctv/normalize.js';
|
||||
import {
|
||||
parseCataloniaXml,
|
||||
normalizeCataloniaImageUrl,
|
||||
cataloniaCameraName,
|
||||
loadCataloniaSourcesFromOpenData,
|
||||
} from '../../server/providers/cctv/sources.js';
|
||||
|
||||
/** Set (or, for `undefined`, delete) environment variables for one test. */
|
||||
function withEnv(t, env) {
|
||||
for (const [name, value] of Object.entries(env)) {
|
||||
const previous = process.env[name];
|
||||
t.after(() => {
|
||||
if (previous === undefined) delete process.env[name];
|
||||
else process.env[name] = previous;
|
||||
});
|
||||
if (value === undefined) delete process.env[name];
|
||||
else process.env[name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
/** Silence the loaders' progress and failure logging. */
|
||||
function quiet(t) {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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('<?xml'));
|
||||
},
|
||||
cancel() {
|
||||
state.cancelled = true;
|
||||
},
|
||||
});
|
||||
return { response: new Response(body, init), state };
|
||||
};
|
||||
|
||||
/** One `<gml:featureMember>` block matching the real feed's shape. */
|
||||
function cataloniaFeature({
|
||||
fid,
|
||||
lon,
|
||||
lat,
|
||||
carretera = '',
|
||||
municipi = '',
|
||||
pk = '',
|
||||
link = '',
|
||||
font = '',
|
||||
}) {
|
||||
const pkTag = pk ? `<cite:pk>${pk}</cite:pk>` : '';
|
||||
return (
|
||||
`<gml:featureMember><cite:cameres fid="${fid}">` +
|
||||
`<cite:geom><gml:Point srsName="http://www.opengis.net/gml/srs/epsg.xml#4326">` +
|
||||
`<gml:coordinates xmlns:gml="http://www.opengis.net/gml" decimal="." cs="," ts=" ">${lon},${lat}</gml:coordinates>` +
|
||||
`</gml:Point></cite:geom>` +
|
||||
`<cite:carretera>${carretera}</cite:carretera>` +
|
||||
`<cite:municipi>${municipi}</cite:municipi>${pkTag}` +
|
||||
`<cite:link>${link}</cite:link>` +
|
||||
`<cite:font>${font}</cite:font>` +
|
||||
`</cite:cameres></gml:featureMember>`
|
||||
);
|
||||
}
|
||||
|
||||
function cataloniaFeed(features) {
|
||||
return (
|
||||
'<?xml version="1.0" encoding="UTF-8"?><wfs:FeatureCollection xmlns:wfs="http://www.opengis.net/wfs">' +
|
||||
features.join('') +
|
||||
'</wfs:FeatureCollection>'
|
||||
);
|
||||
}
|
||||
|
||||
test('parseCataloniaXml extracts every field from a feature block', () => {
|
||||
const xml = cataloniaFeed([
|
||||
cataloniaFeature({
|
||||
fid: 'cameres.fid-52bf6eca_1a0a06bea1c_386f',
|
||||
lon: '2.1849528',
|
||||
lat: '41.45989301',
|
||||
carretera: 'C-58',
|
||||
municipi: 'Nus Trinitat',
|
||||
pk: '0.50',
|
||||
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif',
|
||||
font: 'SCT',
|
||||
}),
|
||||
]);
|
||||
const rows = parseCataloniaXml(xml);
|
||||
assert.equal(rows.length, 1);
|
||||
assert.deepEqual(rows[0], {
|
||||
fid: 'cameres.fid-52bf6eca_1a0a06bea1c_386f',
|
||||
lon: 2.1849528,
|
||||
lat: 41.45989301,
|
||||
carretera: 'C-58',
|
||||
municipi: 'Nus Trinitat',
|
||||
pk: '0.50',
|
||||
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif',
|
||||
font: 'SCT',
|
||||
});
|
||||
});
|
||||
|
||||
test('normalizeCataloniaImageUrl rewrites SCT RenderService links to the direct http TransitCamera URL', () => {
|
||||
// RenderService always 302s to a hardcoded http:// Location (which
|
||||
// fetchWithinHost refuses as a scheme downgrade), and mct.gencat.cat's TLS
|
||||
// setup fails the handshake with Node's fetch regardless — TransitCamera
|
||||
// over plain http is the only transport that actually works here, so we
|
||||
// build that URL from RenderService's sctidcam id instead of registering
|
||||
// the redirector.
|
||||
assert.equal(
|
||||
normalizeCataloniaImageUrl(
|
||||
'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif',
|
||||
),
|
||||
'http://mct.gencat.cat/mct2bo/TransitCamera?nom=nc87.gif&visualitzacio=imatge',
|
||||
);
|
||||
assert.equal(
|
||||
normalizeCataloniaImageUrl(
|
||||
'https://mct.gencat.cat/mct2bo/RenderService?sctidcam=sc64.gif',
|
||||
),
|
||||
'http://mct.gencat.cat/mct2bo/TransitCamera?nom=sc64.gif&visualitzacio=imatge',
|
||||
'an already-https RenderService link is rewritten the same way',
|
||||
);
|
||||
assert.equal(
|
||||
normalizeCataloniaImageUrl('https://mct.gencat.cat/mct2bo/RenderService'),
|
||||
'',
|
||||
'a RenderService link with no sctidcam id is rejected',
|
||||
);
|
||||
});
|
||||
|
||||
test('normalizeCataloniaImageUrl accepts the other registered hosts, upgraded to https', () => {
|
||||
assert.equal(
|
||||
normalizeCataloniaImageUrl('http://www.bcn.cat/transit/imatges/a.gif'),
|
||||
'https://www.bcn.cat/transit/imatges/a.gif',
|
||||
);
|
||||
assert.equal(
|
||||
normalizeCataloniaImageUrl(
|
||||
'https://emap.terrassa.cat/it_terrassa/cam02.jpeg',
|
||||
),
|
||||
'https://emap.terrassa.cat/it_terrassa/cam02.jpeg',
|
||||
);
|
||||
assert.equal(
|
||||
normalizeCataloniaImageUrl('https://evil.example.com/mct.gencat.cat.gif'),
|
||||
'',
|
||||
'a look-alike host is rejected',
|
||||
);
|
||||
assert.equal(
|
||||
normalizeCataloniaImageUrl('https://unlisted-publisher.example/frame.gif'),
|
||||
'',
|
||||
'any host outside the allowlist is rejected',
|
||||
);
|
||||
assert.equal(normalizeCataloniaImageUrl(''), '');
|
||||
assert.equal(normalizeCataloniaImageUrl('not a url'), '');
|
||||
});
|
||||
|
||||
test('normalizeCataloniaImageUrl rejects credential-bearing URLs on every allowed host', () => {
|
||||
// The hostname matches the allowlist, so only an explicit userinfo check
|
||||
// stops catalog data injecting credentials into the frame request.
|
||||
for (const link of [
|
||||
'https://user:[email protected]/transit/imatges/a.gif',
|
||||
'http://[email protected]/transit/imatges/a.gif',
|
||||
'https://:[email protected]/it_terrassa/cam02.jpeg',
|
||||
'http://user:[email protected]/mct2bo/RenderService?sctidcam=nc87.gif',
|
||||
]) {
|
||||
assert.equal(normalizeCataloniaImageUrl(link), '', link);
|
||||
}
|
||||
});
|
||||
|
||||
test("normalizeCataloniaImageUrl rejects explicit ports and paths outside each host's frame directory", () => {
|
||||
// An allowlisted hostname alone would let catalog data aim the frame
|
||||
// request at another service or path on that host.
|
||||
for (const link of [
|
||||
'http://www.bcn.cat:8080/transit/imatges/a.gif',
|
||||
'https://emap.terrassa.cat:8443/it_terrassa/cam02.jpeg',
|
||||
'http://mct.gencat.cat:8080/mct2bo/RenderService?sctidcam=nc87.gif',
|
||||
'http://www.bcn.cat/admin/a.gif',
|
||||
'http://www.bcn.cat/transit/imatges/',
|
||||
'http://www.bcn.cat/transit/imatges/sub/a.gif',
|
||||
'http://www.bcn.cat/transit/imatges/../../admin/a.gif',
|
||||
'https://emap.terrassa.cat/other/cam02.jpeg',
|
||||
'http://mct.gencat.cat/mct2bo/OtherService?sctidcam=nc87.gif',
|
||||
]) {
|
||||
assert.equal(normalizeCataloniaImageUrl(link), '', link);
|
||||
}
|
||||
});
|
||||
|
||||
test('normalizeCataloniaImageUrl accepts only a bare image filename as the SCT sctidcam', () => {
|
||||
for (const camId of [
|
||||
'../../etc/passwd',
|
||||
'..%2F..%2Fetc%2Fpasswd',
|
||||
'nc87.png',
|
||||
'nc87',
|
||||
'nc87.gif/../x.gif',
|
||||
'nc87.gif&nom=other.gif',
|
||||
'nc-87.gif',
|
||||
]) {
|
||||
const link = `http://mct.gencat.cat/mct2bo/RenderService?sctidcam=${encodeURIComponent(camId)}`;
|
||||
assert.equal(normalizeCataloniaImageUrl(link), '', camId);
|
||||
}
|
||||
assert.equal(
|
||||
normalizeCataloniaImageUrl(
|
||||
'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=CB10.JPEG',
|
||||
),
|
||||
'http://mct.gencat.cat/mct2bo/TransitCamera?nom=CB10.JPEG&visualitzacio=imatge',
|
||||
);
|
||||
});
|
||||
|
||||
test('decodeNumericEntities decodes decimal and hex references and leaves invalid ones as written', () => {
|
||||
assert.equal(decodeNumericEntities('Lluís'), 'Lluís');
|
||||
assert.equal(decodeNumericEntities('Lluís 🚦'), 'Lluís 🚦');
|
||||
assert.equal(decodeNumericEntities('Lluís'), 'Lluís');
|
||||
for (const raw of [
|
||||
'a�b',
|
||||
'a�b',
|
||||
'a�b',
|
||||
'a�b',
|
||||
'a�b',
|
||||
'a�b',
|
||||
'a�b',
|
||||
]) {
|
||||
assert.equal(decodeNumericEntities(raw), raw, raw);
|
||||
}
|
||||
});
|
||||
|
||||
test('cataloniaCameraName joins road and municipality, skipping a redundant municipality', () => {
|
||||
assert.equal(
|
||||
cataloniaCameraName('C-58', 'Nus Trinitat'),
|
||||
'C-58 (Nus Trinitat)',
|
||||
);
|
||||
assert.equal(
|
||||
cataloniaCameraName('Plaça Urquinaona', 'Barcelona'),
|
||||
'Plaça Urquinaona (Barcelona)',
|
||||
);
|
||||
assert.equal(
|
||||
cataloniaCameraName('Barcelona Ronda Litoral', 'Barcelona'),
|
||||
'Barcelona Ronda Litoral',
|
||||
);
|
||||
assert.equal(cataloniaCameraName('', ''), 'Càmera de trànsit');
|
||||
assert.equal(cataloniaCameraName('C-58', ''), 'C-58');
|
||||
});
|
||||
|
||||
test('Catalonia loader keeps SCT, Barcelona and Terrassa cameras with per-publisher credit', async (t) => {
|
||||
quiet(t);
|
||||
withEnv(t, { CCTV_CATALONIA_MAX_SOURCES: undefined });
|
||||
const requested = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url, init) => {
|
||||
requested.push([String(url), init.redirect]);
|
||||
const xml = cataloniaFeed([
|
||||
cataloniaFeature({
|
||||
fid: 'cameres.fid-1',
|
||||
lon: '2.1849528',
|
||||
lat: '41.45989301',
|
||||
carretera: 'C-58',
|
||||
municipi: 'Nus Trinitat',
|
||||
pk: '0.50',
|
||||
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif',
|
||||
font: 'SCT',
|
||||
}),
|
||||
cataloniaFeature({
|
||||
fid: 'cameres.fid-2',
|
||||
lon: '2.17228212',
|
||||
lat: '41.38890733',
|
||||
carretera: 'Plaça Urquinaona',
|
||||
municipi: 'Barcelona',
|
||||
link: 'http://www.bcn.cat/transit/imatges/PlUrquinaona.gif?a=1',
|
||||
font: 'IMI',
|
||||
}),
|
||||
cataloniaFeature({
|
||||
fid: 'cameres.fid-3',
|
||||
lon: '2.0111',
|
||||
lat: '41.5636',
|
||||
carretera: 'Cam02',
|
||||
municipi: 'Terrassa',
|
||||
link: 'https://emap.terrassa.cat/it_terrassa/cam02.jpeg?a=1',
|
||||
font: 'Terrassa',
|
||||
}),
|
||||
// Rejected: link host not in the allowlist.
|
||||
cataloniaFeature({
|
||||
fid: 'cameres.fid-4',
|
||||
lon: '2.0',
|
||||
lat: '41.5',
|
||||
link: 'https://example.com/fake.gif',
|
||||
font: 'SCT',
|
||||
}),
|
||||
// Rejected: outside the Catalonia bounding box.
|
||||
cataloniaFeature({
|
||||
fid: 'cameres.fid-5',
|
||||
lon: '-3.7',
|
||||
lat: '40.4',
|
||||
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=x.gif',
|
||||
font: 'SCT',
|
||||
}),
|
||||
]);
|
||||
return new Response(xml, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/xml' },
|
||||
});
|
||||
});
|
||||
|
||||
const cameras = await loadCataloniaSourcesFromOpenData();
|
||||
|
||||
assert.deepEqual(requested, [[CATALONIA_CAMERAS_URL, 'manual']]);
|
||||
assert.ok(
|
||||
CATALONIA_CAMERAS_URL.startsWith('https://'),
|
||||
'the catalog is fetched over https',
|
||||
);
|
||||
assert.deepEqual(
|
||||
cameras.map((camera) => camera.id).sort(),
|
||||
[
|
||||
'cat-emap-terrassa-cat-it-terrassa-cam02-jpeg',
|
||||
'cat-sct-nc87',
|
||||
'cat-www-bcn-cat-transit-imatges-plurquinaona-gif',
|
||||
],
|
||||
'the off-allowlist link (fid-4) and the out-of-bbox row (fid-5) are excluded',
|
||||
);
|
||||
|
||||
const sct = cameras.find((c) => c.id === 'cat-sct-nc87');
|
||||
assert.equal(sct.name, 'C-58 (Nus Trinitat)');
|
||||
assert.equal(sct.provider, 'Servei Català de Trànsit');
|
||||
assert.equal(sct.credit, '', 'SCT is the publisher, no separate credit');
|
||||
assert.equal(
|
||||
sct.url,
|
||||
'http://mct.gencat.cat/mct2bo/TransitCamera?nom=nc87.gif&visualitzacio=imatge',
|
||||
'registers the direct, non-redirecting http frame URL, not the RenderService redirector',
|
||||
);
|
||||
assert.equal(sct.groundElevationM, 80);
|
||||
assert.equal(sct.headingConfidence, 'low', 'the feed carries no heading');
|
||||
|
||||
const bcn = cameras.find(
|
||||
(c) => c.id === 'cat-www-bcn-cat-transit-imatges-plurquinaona-gif',
|
||||
);
|
||||
assert.equal(bcn.credit, 'Ajuntament de Barcelona');
|
||||
assert.equal(bcn.city, 'Barcelona');
|
||||
assert.equal(bcn.groundElevationM, 20);
|
||||
|
||||
const terrassa = cameras.find(
|
||||
(c) => c.id === 'cat-emap-terrassa-cat-it-terrassa-cam02-jpeg',
|
||||
);
|
||||
assert.equal(terrassa.credit, 'Ajuntament de Terrassa');
|
||||
assert.equal(terrassa.groundElevationM, 270);
|
||||
});
|
||||
|
||||
test('Catalonia loader fails soft on HTTP errors, empty payloads and network errors', async (t) => {
|
||||
quiet(t);
|
||||
let respond;
|
||||
t.mock.method(globalThis, 'fetch', (...args) => respond(...args));
|
||||
for (respond of [
|
||||
async () => new Response('unavailable', { status: 503 }),
|
||||
async () => new Response('<wfs:FeatureCollection></wfs:FeatureCollection>'),
|
||||
async () => {
|
||||
throw new TypeError('fetch failed');
|
||||
},
|
||||
]) {
|
||||
assert.deepEqual(await loadCataloniaSourcesFromOpenData(), []);
|
||||
}
|
||||
});
|
||||
|
||||
test('the Catalonia catalog fetch refuses redirects and oversized bodies', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
// A redirect is never followed: the list host cannot be steered or
|
||||
// downgraded. Its body is a live stream, so the test also proves the
|
||||
// refusal releases the transport.
|
||||
const seen = [];
|
||||
const redirected = streamingResponse({
|
||||
status: 302,
|
||||
headers: { location: 'http://www.gencat.cat/transit/opendata/cameres.xml' },
|
||||
});
|
||||
t.mock.method(globalThis, 'fetch', async (url, init) => {
|
||||
seen.push([String(url), init.redirect]);
|
||||
return redirected.response;
|
||||
});
|
||||
assert.deepEqual(await loadCataloniaSourcesFromOpenData(), []);
|
||||
assert.deepEqual(seen, [[CATALONIA_CAMERAS_URL, 'manual']]);
|
||||
assert.equal(
|
||||
redirected.state.cancelled,
|
||||
true,
|
||||
'the redirect body is cancelled',
|
||||
);
|
||||
|
||||
// An HTTP error body is released too.
|
||||
t.mock.restoreAll();
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const failed = streamingResponse({ status: 503 });
|
||||
t.mock.method(globalThis, 'fetch', async () => failed.response);
|
||||
assert.deepEqual(await loadCataloniaSourcesFromOpenData(), []);
|
||||
assert.equal(failed.state.cancelled, true, 'the error body is cancelled');
|
||||
|
||||
const validFeed = cataloniaFeed([
|
||||
cataloniaFeature({
|
||||
fid: 'cameres.fid-1',
|
||||
lon: '2.1849528',
|
||||
lat: '41.45989301',
|
||||
carretera: 'C-58',
|
||||
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif',
|
||||
font: 'SCT',
|
||||
}),
|
||||
]);
|
||||
|
||||
// A body that declares a size over the cap is refused rather than buffered,
|
||||
// even though its actual content would parse.
|
||||
t.mock.restoreAll();
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
return new Response(validFeed, {
|
||||
headers: {
|
||||
'Content-Type': 'application/xml',
|
||||
'content-length': String(CATALONIA_MAX_CATALOG_BYTES + 1),
|
||||
},
|
||||
});
|
||||
});
|
||||
assert.deepEqual(await loadCataloniaSourcesFromOpenData(), []);
|
||||
|
||||
// So is a body with no declared size that only turns out too long while
|
||||
// streaming — a valid feed padded past the cap.
|
||||
t.mock.restoreAll();
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
const oversized =
|
||||
validFeed + ' '.repeat(CATALONIA_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 loadCataloniaSourcesFromOpenData(), []);
|
||||
});
|
||||
|
||||
test('CCTV catalog merges Catalonia cameras and CCTV_CATALONIA_ENABLED=0 skips the request', async (t) => {
|
||||
quiet(t);
|
||||
const sourceRoot = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'gev-cctv-catalog-catalonia-'),
|
||||
);
|
||||
t.after(() => fs.rmSync(sourceRoot, { recursive: true, force: true }));
|
||||
withEnv(t, {
|
||||
CCTV_SOURCES_FILE: undefined,
|
||||
CCTV_SOURCES_JSON: undefined,
|
||||
CCTV_FORCE_AUSTIN: undefined,
|
||||
CCTV_PREFER_AUSTIN: undefined,
|
||||
CCTV_MAX_SOURCES: undefined,
|
||||
CCTV_CALTRANS_DISTRICTS: undefined,
|
||||
CCTV_TFL_ENABLED: undefined,
|
||||
CCTV_CATALONIA_ENABLED: undefined,
|
||||
CCTV_CATALONIA_MAX_SOURCES: undefined,
|
||||
});
|
||||
const requested = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url, init) => {
|
||||
requested.push([String(url), init.redirect]);
|
||||
if (String(url) !== CATALONIA_CAMERAS_URL) {
|
||||
return new Response('unavailable', { status: 503 });
|
||||
}
|
||||
const xml = cataloniaFeed([
|
||||
cataloniaFeature({
|
||||
fid: 'cameres.fid-1',
|
||||
lon: '2.1849528',
|
||||
lat: '41.45989301',
|
||||
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif',
|
||||
font: 'SCT',
|
||||
}),
|
||||
]);
|
||||
return new Response(xml, { status: 200 });
|
||||
});
|
||||
|
||||
const sources = await createCctvCatalog({ sourceRoot })();
|
||||
assert.deepEqual(
|
||||
sources.map((source) => source.id),
|
||||
['cat-sct-nc87'],
|
||||
);
|
||||
|
||||
process.env.CCTV_CATALONIA_ENABLED = '0';
|
||||
requested.length = 0;
|
||||
assert.deepEqual(await createCctvCatalog({ sourceRoot })(), []);
|
||||
assert.ok(requested.length > 0, 'the other live packs still load');
|
||||
assert.equal(
|
||||
requested.some(([url]) => url === CATALONIA_CAMERAS_URL),
|
||||
false,
|
||||
'a disabled pack makes no request',
|
||||
);
|
||||
});
|
||||
|
||||
/** The two SCT, one Barcelona and one Terrassa rows of a real feed snapshot,
|
||||
* under one upstream run's `fid` prefix. */
|
||||
function snapshotFeed(fidPrefix) {
|
||||
return cataloniaFeed([
|
||||
cataloniaFeature({
|
||||
fid: `cameres.fid-${fidPrefix}_386f`,
|
||||
lon: '2.1849528',
|
||||
lat: '41.45989301',
|
||||
carretera: 'C-58',
|
||||
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif',
|
||||
font: 'SCT',
|
||||
}),
|
||||
cataloniaFeature({
|
||||
fid: `cameres.fid-${fidPrefix}_b48`,
|
||||
lon: '2.18',
|
||||
lat: '41.46',
|
||||
carretera: 'C-31',
|
||||
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc85.gif',
|
||||
font: 'SCT',
|
||||
}),
|
||||
cataloniaFeature({
|
||||
fid: `cameres.fid-${fidPrefix}_1c2`,
|
||||
lon: '2.17228212',
|
||||
lat: '41.38890733',
|
||||
carretera: 'Plaça Urquinaona',
|
||||
municipi: 'Barcelona',
|
||||
link: 'http://www.bcn.cat/transit/imatges/PlUrquinaona.gif?a=1',
|
||||
font: 'IMI',
|
||||
}),
|
||||
cataloniaFeature({
|
||||
fid: `cameres.fid-${fidPrefix}_1c3`,
|
||||
lon: '2.0111',
|
||||
lat: '41.5636',
|
||||
municipi: 'Terrassa',
|
||||
link: 'https://emap.terrassa.cat/it_terrassa/cam01.jpeg?a=1 ',
|
||||
font: 'Terrassa',
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
async function loadFromFeed(t, xml) {
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
return new Response(xml, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/xml' },
|
||||
});
|
||||
});
|
||||
try {
|
||||
return await loadCataloniaSourcesFromOpenData();
|
||||
} finally {
|
||||
t.mock.restoreAll();
|
||||
}
|
||||
}
|
||||
|
||||
test('Catalonia camera ids survive an upstream restart that changes every fid', async (t) => {
|
||||
quiet(t);
|
||||
withEnv(t, { CCTV_CATALONIA_MAX_SOURCES: undefined });
|
||||
// The fid embeds the upstream server-start stamp: the PR fixture's
|
||||
// 1a0a06bea1c run and a later 1a140311953 run name the same cameras.
|
||||
const before = await loadFromFeed(t, snapshotFeed('52bf6eca_1a0a06bea1c'));
|
||||
const after = await loadFromFeed(t, snapshotFeed('52bf6eca_1a140311953'));
|
||||
const ids = (cameras) => cameras.map((camera) => camera.id).sort();
|
||||
assert.deepEqual(ids(before), [
|
||||
'cat-emap-terrassa-cat-it-terrassa-cam01-jpeg',
|
||||
'cat-sct-nc85',
|
||||
'cat-sct-nc87',
|
||||
'cat-www-bcn-cat-transit-imatges-plurquinaona-gif',
|
||||
]);
|
||||
assert.deepEqual(ids(after), ids(before));
|
||||
});
|
||||
|
||||
test('Catalonia loader collapses rows the feed repeats for one camera', async (t) => {
|
||||
quiet(t);
|
||||
withEnv(t, { CCTV_CATALONIA_MAX_SOURCES: undefined });
|
||||
// The live feed lists nc85, cb5, cb10, cb25 and cd17 twice each, under
|
||||
// different fids.
|
||||
const row = (fid, camId) =>
|
||||
cataloniaFeature({
|
||||
fid,
|
||||
lon: '2.18',
|
||||
lat: '41.46',
|
||||
carretera: 'C-31',
|
||||
link: `http://mct.gencat.cat/mct2bo/RenderService?sctidcam=${camId}`,
|
||||
font: 'SCT',
|
||||
});
|
||||
const cameras = await loadFromFeed(
|
||||
t,
|
||||
cataloniaFeed([
|
||||
row('cameres.fid-x_1', 'nc85.gif'),
|
||||
row('cameres.fid-x_2', 'cb5.gif'),
|
||||
row('cameres.fid-x_3', 'nc85.gif'),
|
||||
row('cameres.fid-x_4', 'cb5.gif'),
|
||||
row('cameres.fid-x_5', 'cb10.gif'),
|
||||
]),
|
||||
);
|
||||
assert.deepEqual(cameras.map((camera) => camera.id).sort(), [
|
||||
'cat-sct-cb10',
|
||||
'cat-sct-cb5',
|
||||
'cat-sct-nc85',
|
||||
]);
|
||||
});
|
||||
|
||||
test('Catalonia loader drops ported and bad-sctidcam links but keeps the rest', async (t) => {
|
||||
quiet(t);
|
||||
withEnv(t, { CCTV_CATALONIA_MAX_SOURCES: undefined });
|
||||
const cameras = await loadFromFeed(
|
||||
t,
|
||||
cataloniaFeed([
|
||||
cataloniaFeature({
|
||||
fid: 'cameres.fid-x_1',
|
||||
lon: '2.17',
|
||||
lat: '41.39',
|
||||
link: 'http://www.bcn.cat:8080/transit/imatges/PlUrquinaona.gif?a=1',
|
||||
font: 'IMI',
|
||||
}),
|
||||
cataloniaFeature({
|
||||
fid: 'cameres.fid-x_2',
|
||||
lon: '2.0111',
|
||||
lat: '41.5636',
|
||||
link: 'https://emap.terrassa.cat:8443/it_terrassa/cam02.jpeg?a=1',
|
||||
font: 'Terrassa',
|
||||
}),
|
||||
cataloniaFeature({
|
||||
fid: 'cameres.fid-x_3',
|
||||
lon: '2.18',
|
||||
lat: '41.46',
|
||||
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=../../etc/passwd',
|
||||
font: 'SCT',
|
||||
}),
|
||||
cataloniaFeature({
|
||||
fid: 'cameres.fid-x_4',
|
||||
lon: '2.18',
|
||||
lat: '41.46',
|
||||
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif',
|
||||
font: 'SCT',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
assert.deepEqual(
|
||||
cameras.map((camera) => [camera.id, camera.url]),
|
||||
[
|
||||
[
|
||||
'cat-sct-nc87',
|
||||
'http://mct.gencat.cat/mct2bo/TransitCamera?nom=nc87.gif&visualitzacio=imatge',
|
||||
],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('a malformed numeric entity in one row neither throws nor empties the Catalonia pack', async (t) => {
|
||||
quiet(t);
|
||||
withEnv(t, { CCTV_CATALONIA_MAX_SOURCES: undefined });
|
||||
const cameras = await loadFromFeed(
|
||||
t,
|
||||
cataloniaFeed([
|
||||
cataloniaFeature({
|
||||
fid: 'cameres.fid-x_1',
|
||||
lon: '2.18',
|
||||
lat: '41.46',
|
||||
carretera: 'C-31 �',
|
||||
municipi: 'Bad�',
|
||||
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc85.gif',
|
||||
font: 'SCT',
|
||||
}),
|
||||
cataloniaFeature({
|
||||
fid: 'cameres.fid-x_2',
|
||||
lon: '2.18',
|
||||
lat: '41.46',
|
||||
carretera: 'Plaça Catalunya',
|
||||
municipi: 'Llinars del Vallès',
|
||||
link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif',
|
||||
font: 'SCT',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
const byId = new Map(cameras.map((camera) => [camera.id, camera]));
|
||||
assert.equal(cameras.length, 2);
|
||||
assert.equal(
|
||||
byId.get('cat-sct-nc85').name,
|
||||
'C-31 � (Bad�)',
|
||||
'an invalid reference is kept as written',
|
||||
);
|
||||
assert.equal(
|
||||
byId.get('cat-sct-nc87').name,
|
||||
'Plaça Catalunya (Llinars del Vallès)',
|
||||
);
|
||||
});
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
DEFAULT_TALLINN_MAX_SOURCES,
|
||||
DEFAULT_TARKTEE_MAX_SOURCES,
|
||||
DEFAULT_NSW_MAX_SOURCES,
|
||||
DEFAULT_CATALONIA_MAX_SOURCES,
|
||||
DRIVEBC_WEBCAMS_URL,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import {
|
||||
@@ -221,7 +222,7 @@ test('CCTV catalog merges DriveBC cameras and CCTV_DRIVEBC_ENABLED=0 skips the r
|
||||
);
|
||||
});
|
||||
|
||||
test('default per-pack camera caps fit inside the default catalog cap', () => {
|
||||
test('default caps of the original packs fit inside the default catalog cap', () => {
|
||||
// Every default pack at its own cap (Warendorf ships 1 curated camera).
|
||||
const packs =
|
||||
DEFAULT_AUSTIN_MAX_SOURCES +
|
||||
@@ -234,6 +235,7 @@ test('default per-pack camera caps fit inside the default catalog cap', () => {
|
||||
DEFAULT_TALLINN_MAX_SOURCES +
|
||||
DEFAULT_TARKTEE_MAX_SOURCES +
|
||||
DEFAULT_NSW_MAX_SOURCES +
|
||||
DEFAULT_CATALONIA_MAX_SOURCES +
|
||||
1;
|
||||
assert.ok(
|
||||
packs <= DEFAULT_CCTV_MAX_SOURCES,
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
DEFAULT_KING_COUNTY_MAX_SOURCES,
|
||||
KING_COUNTY_CAMERAS_URL,
|
||||
KING_COUNTY_MAX_CATALOG_BYTES,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import { loadKingCountySourcesFromOpenData } from '../../server/providers/cctv/sources.js';
|
||||
import { normalizeSourceItem } from '../../server/providers/cctv/normalize.js';
|
||||
import { cctvProxy } from '../../server/providers/cctv.js';
|
||||
import { createCatalog } from '../layers/cctv/catalog.js';
|
||||
|
||||
/** Set (or, for `undefined`, delete) environment variables for one test. */
|
||||
function withEnv(t, env) {
|
||||
for (const [name, value] of Object.entries(env)) {
|
||||
const previous = process.env[name];
|
||||
t.after(() => {
|
||||
if (previous === undefined) delete process.env[name];
|
||||
else process.env[name] = previous;
|
||||
});
|
||||
if (value === undefined) delete process.env[name];
|
||||
else process.env[name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
/** Silence the loaders' progress and failure logging. */
|
||||
function quiet(t) {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
}
|
||||
|
||||
/** A FeatureServer GeoJSON feature carrying the fields the loader reads. */
|
||||
function kingCountyFeature(id, lat, lon, overrides = {}) {
|
||||
const { properties = {}, geometry } = overrides;
|
||||
return {
|
||||
type: 'Feature',
|
||||
geometry:
|
||||
geometry === undefined
|
||||
? { type: 'Point', coordinates: [lon, lat] }
|
||||
: geometry,
|
||||
properties: {
|
||||
AssetID: id,
|
||||
Location: `Road ${id} at Test Ave`,
|
||||
ImageURL: `http://info.kingcounty.gov/transportation/kcdot/Roads/TrafficCameras/ImageHandler/Handler.ashx?id=cam${id}.jpg`,
|
||||
Live: 1,
|
||||
Model: '3950',
|
||||
Manufacturer: 'Cohu',
|
||||
Owner: 'King County',
|
||||
CurrentStatus: 'Active',
|
||||
...properties,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('King County loader keeps live county cameras and upgrades frame URLs to HTTPS', async (t) => {
|
||||
quiet(t);
|
||||
withEnv(t, { CCTV_KINGCOUNTY_MAX_SOURCES: undefined });
|
||||
const requested = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
requested.push(String(url));
|
||||
return Response.json({
|
||||
type: 'FeatureCollection',
|
||||
features: [
|
||||
kingCountyFeature(56, 47.70059, -122.02443, {
|
||||
properties: {
|
||||
Location: 'NE Novelty Hill Road at Trilogy Parkway NE',
|
||||
// The live layer carries stray whitespace in model strings.
|
||||
Model: 'Q6135-LE\n',
|
||||
Manufacturer: 'Axis',
|
||||
},
|
||||
}),
|
||||
// City-owned camera the county hosts: kept, owner becomes the credit.
|
||||
kingCountyFeature(90, 47.674, -122.119, {
|
||||
properties: { Owner: 'City of Newcastle', Model: null },
|
||||
}),
|
||||
// WSDOT-owned row: its frames are WSDOT-hosted, so it is skipped.
|
||||
kingCountyFeature(70, 47.6, -122.3, { properties: { Owner: 'WSDOT' } }),
|
||||
// Off-host frame, not live, retired, bad id, bad geometry, out of county.
|
||||
kingCountyFeature(71, 47.6, -122.3, {
|
||||
properties: { ImageURL: 'https://example.com/cam71.jpg' },
|
||||
}),
|
||||
kingCountyFeature(72, 47.6, -122.3, { properties: { Live: 0 } }),
|
||||
kingCountyFeature(73, 47.6, -122.3, {
|
||||
properties: { CurrentStatus: 'Retired' },
|
||||
}),
|
||||
kingCountyFeature('74', 47.6, -122.3),
|
||||
kingCountyFeature(75, 47.6, -122.3, { geometry: null }),
|
||||
kingCountyFeature(76, 34.05, -118.24),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
const cameras = await loadKingCountySourcesFromOpenData();
|
||||
|
||||
assert.deepEqual(requested, [KING_COUNTY_CAMERAS_URL]);
|
||||
assert.deepEqual(cameras.map((camera) => camera.id).sort(), [
|
||||
'kingcounty-56',
|
||||
'kingcounty-90',
|
||||
]);
|
||||
|
||||
const novelty = cameras.find((camera) => camera.id === 'kingcounty-56');
|
||||
assert.equal(novelty.name, 'NE Novelty Hill Road at Trilogy Parkway NE');
|
||||
assert.equal(novelty.provider, 'King County Road Services');
|
||||
assert.equal(
|
||||
novelty.url,
|
||||
'https://info.kingcounty.gov/transportation/kcdot/Roads/TrafficCameras/ImageHandler/Handler.ashx?id=cam56.jpg',
|
||||
);
|
||||
assert.equal(novelty.snapshotUrl, novelty.url);
|
||||
assert.equal(novelty.feedType, 'image');
|
||||
// Published hardware model rides along, whitespace-collapsed.
|
||||
assert.equal(novelty.model, 'Q6135-LE');
|
||||
assert.equal(novelty.manufacturer, 'Axis');
|
||||
assert.equal(novelty.credit, '');
|
||||
// No facing in the layer: id-hash fallback at low confidence.
|
||||
assert.equal(novelty.headingConfidence, 'low');
|
||||
assert.equal(novelty.fovDeg, 44);
|
||||
assert.ok(novelty.headingDeg >= 0 && novelty.headingDeg < 360);
|
||||
|
||||
const newcastle = cameras.find((camera) => camera.id === 'kingcounty-90');
|
||||
assert.equal(newcastle.credit, 'City of Newcastle');
|
||||
assert.equal(newcastle.model, undefined);
|
||||
// A manufacturer without a model identifies nothing, so it is dropped too.
|
||||
assert.equal(newcastle.manufacturer, undefined);
|
||||
});
|
||||
|
||||
test('normalizeSourceItem passes a hardware model through and omits it otherwise', () => {
|
||||
const withModel = normalizeSourceItem({
|
||||
id: 'kingcounty-56',
|
||||
lat: 47.7,
|
||||
lon: -122.0,
|
||||
model: ' Q6135-LE ',
|
||||
manufacturer: ' Axis ',
|
||||
});
|
||||
assert.equal(withModel.model, 'Q6135-LE');
|
||||
assert.equal(withModel.manufacturer, 'Axis');
|
||||
|
||||
const withoutModel = normalizeSourceItem({
|
||||
id: 'austin-1',
|
||||
lat: 30.3,
|
||||
lon: -97.7,
|
||||
});
|
||||
assert.equal(withoutModel.model, undefined);
|
||||
assert.equal(withoutModel.manufacturer, undefined);
|
||||
assert.equal(normalizeSourceItem({ id: 'x', model: ' ' }).model, undefined);
|
||||
});
|
||||
|
||||
/** Drive the real `/api/cctv/sources` middleware and return its parsed body. */
|
||||
async function requestSources(plugin) {
|
||||
let handler;
|
||||
plugin.configureServer({
|
||||
middlewares: {
|
||||
use(_route, fn) {
|
||||
handler = fn;
|
||||
},
|
||||
},
|
||||
});
|
||||
const res = {
|
||||
writeHead(status, headers) {
|
||||
Object.assign(this, { status, headers });
|
||||
},
|
||||
end(body) {
|
||||
this.body = body;
|
||||
},
|
||||
};
|
||||
await handler({ url: '/sources', method: 'GET' }, res);
|
||||
assert.equal(res.status, 200);
|
||||
return JSON.parse(res.body);
|
||||
}
|
||||
|
||||
/** The browser catalog with the pose math stubbed out: only field
|
||||
* passthrough is under test here. */
|
||||
function browserCatalog() {
|
||||
const model = {
|
||||
safeNumber: (value, fallback = NaN) =>
|
||||
Number.isFinite(Number(value)) ? Number(value) : fallback,
|
||||
normalizeHeading: (deg) => ((deg % 360) + 360) % 360,
|
||||
clamp: (value, lo, hi) => Math.min(hi, Math.max(lo, value)),
|
||||
headingFromId: () => 0,
|
||||
normalizeFeedType: (type) => type,
|
||||
ensureCameraPose: () => {},
|
||||
};
|
||||
return createCatalog({
|
||||
state: {},
|
||||
services: { locations: { CITY_POIS: {} } },
|
||||
parts: { model },
|
||||
source: {},
|
||||
});
|
||||
}
|
||||
|
||||
test('King County model and manufacturer survive the /api/cctv/sources boundary into the browser catalog', async (t) => {
|
||||
quiet(t);
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'gev-kingcounty-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
// No file/env catalog, so the live packs load; every pack but King County
|
||||
// sees an upstream failure and contributes nothing.
|
||||
withEnv(t, {
|
||||
CCTV_SOURCES_FILE: undefined,
|
||||
CCTV_SOURCES_JSON: undefined,
|
||||
CCTV_FORCE_AUSTIN: undefined,
|
||||
CCTV_PREFER_AUSTIN: undefined,
|
||||
CCTV_KINGCOUNTY_ENABLED: undefined,
|
||||
CCTV_KINGCOUNTY_MAX_SOURCES: undefined,
|
||||
CCTV_MAX_SOURCES: undefined,
|
||||
});
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
if (String(url) !== KING_COUNTY_CAMERAS_URL)
|
||||
return new Response('', { status: 503 });
|
||||
return Response.json({
|
||||
type: 'FeatureCollection',
|
||||
features: [
|
||||
kingCountyFeature(56, 47.70059, -122.02443, {
|
||||
properties: { Model: 'Q6135-LE\n', Manufacturer: 'Axis' },
|
||||
}),
|
||||
kingCountyFeature(90, 47.674, -122.119, {
|
||||
properties: { Model: null, Manufacturer: 'Cohu' },
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
const body = await requestSources(cctvProxy({ sourceRoot: root }));
|
||||
const served = new Map(body.sources.map((source) => [source.id, source]));
|
||||
assert.deepEqual([...served.keys()].sort(), [
|
||||
'kingcounty-56',
|
||||
'kingcounty-90',
|
||||
]);
|
||||
assert.equal(served.get('kingcounty-56').model, 'Q6135-LE');
|
||||
assert.equal(served.get('kingcounty-56').manufacturer, 'Axis');
|
||||
// Absent hardware is an explicit empty string on the wire, like
|
||||
// headingConfidence, so consumers never have to probe for the key.
|
||||
assert.equal(served.get('kingcounty-90').model, '');
|
||||
assert.equal(served.get('kingcounty-90').manufacturer, '');
|
||||
|
||||
const cameras = browserCatalog().buildCatalogFromSources(body.sources);
|
||||
const byId = new Map(cameras.map((camera) => [camera.id, camera]));
|
||||
assert.equal(byId.get('kingcounty-56').model, 'Q6135-LE');
|
||||
assert.equal(byId.get('kingcounty-56').manufacturer, 'Axis');
|
||||
assert.equal(byId.get('kingcounty-90').model, '');
|
||||
assert.equal(byId.get('kingcounty-90').manufacturer, '');
|
||||
});
|
||||
|
||||
test('King County loader honors the max-sources cap', async (t) => {
|
||||
quiet(t);
|
||||
withEnv(t, { CCTV_KINGCOUNTY_MAX_SOURCES: '8' });
|
||||
const features = [];
|
||||
for (let i = 0; i < 24; i += 1) {
|
||||
features.push(
|
||||
kingCountyFeature(100 + i, 47.2 + i * 0.02, -122.4 + i * 0.02),
|
||||
);
|
||||
}
|
||||
t.mock.method(globalThis, 'fetch', async () =>
|
||||
Response.json({ type: 'FeatureCollection', features }),
|
||||
);
|
||||
|
||||
const cameras = await loadKingCountySourcesFromOpenData();
|
||||
assert.equal(cameras.length, 8);
|
||||
});
|
||||
|
||||
test('King County loader defaults its cap and survives upstream failure', async (t) => {
|
||||
quiet(t);
|
||||
withEnv(t, { CCTV_KINGCOUNTY_MAX_SOURCES: undefined });
|
||||
assert.equal(DEFAULT_KING_COUNTY_MAX_SOURCES, 125);
|
||||
|
||||
t.mock.method(
|
||||
globalThis,
|
||||
'fetch',
|
||||
async () => new Response('', { status: 503 }),
|
||||
);
|
||||
assert.deepEqual(await loadKingCountySourcesFromOpenData(), []);
|
||||
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
throw new Error('network down');
|
||||
});
|
||||
assert.deepEqual(await loadKingCountySourcesFromOpenData(), []);
|
||||
});
|
||||
|
||||
test('King County loader does not follow a redirected catalog', async (t) => {
|
||||
quiet(t);
|
||||
let init;
|
||||
t.mock.method(globalThis, 'fetch', async (_url, options) => {
|
||||
init = options;
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
type: 'FeatureCollection',
|
||||
features: [kingCountyFeature(56, 47.70059, -122.02443)],
|
||||
}),
|
||||
{ status: 302, headers: { Location: 'https://example.com/cams' } },
|
||||
);
|
||||
});
|
||||
|
||||
assert.deepEqual(await loadKingCountySourcesFromOpenData(), []);
|
||||
assert.equal(init.redirect, 'manual');
|
||||
});
|
||||
|
||||
test('King County loader rejects an oversized catalog', async (t) => {
|
||||
quiet(t);
|
||||
const features = [kingCountyFeature(56, 47.70059, -122.02443)];
|
||||
const body = JSON.stringify({
|
||||
type: 'FeatureCollection',
|
||||
features,
|
||||
padding: 'x'.repeat(KING_COUNTY_MAX_CATALOG_BYTES),
|
||||
});
|
||||
t.mock.method(globalThis, 'fetch', async () => new Response(body));
|
||||
|
||||
assert.deepEqual(await loadKingCountySourcesFromOpenData(), []);
|
||||
});
|
||||
|
||||
test('King County loader drops duplicate ids before the per-pack cap', async (t) => {
|
||||
quiet(t);
|
||||
withEnv(t, { CCTV_KINGCOUNTY_MAX_SOURCES: '8' });
|
||||
// Repeats of the camera nearest the anchor would fill an 8-camera cap
|
||||
// before the second, farther camera if duplicates reached prioritization.
|
||||
const features = [];
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
features.push(kingCountyFeature(56, 47.4829, -122.2171));
|
||||
}
|
||||
features.push(kingCountyFeature(57, 47.70059, -122.02443));
|
||||
t.mock.method(globalThis, 'fetch', async () =>
|
||||
Response.json({ type: 'FeatureCollection', features }),
|
||||
);
|
||||
|
||||
const cameras = await loadKingCountySourcesFromOpenData();
|
||||
assert.deepEqual(cameras.map((camera) => camera.id).sort(), [
|
||||
'kingcounty-56',
|
||||
'kingcounty-57',
|
||||
]);
|
||||
});
|
||||
|
||||
test('King County loader omits vendor-generic model strings', async (t) => {
|
||||
quiet(t);
|
||||
withEnv(t, { CCTV_KINGCOUNTY_MAX_SOURCES: undefined });
|
||||
t.mock.method(globalThis, 'fetch', async () =>
|
||||
Response.json({
|
||||
type: 'FeatureCollection',
|
||||
features: [
|
||||
kingCountyFeature(61, 47.5, -122.2, {
|
||||
properties: { Model: 'PTZ', Manufacturer: 'Bosch' },
|
||||
}),
|
||||
kingCountyFeature(62, 47.51, -122.2, {
|
||||
properties: { Model: 'PTZ IP', Manufacturer: 'Cohu' },
|
||||
}),
|
||||
kingCountyFeature(63, 47.52, -122.2, {
|
||||
properties: { Model: 'BOSCH/PTZ', Manufacturer: 'Bosch' },
|
||||
}),
|
||||
kingCountyFeature(64, 47.53, -122.2, {
|
||||
properties: { Model: 'AUTODOME IP 5000', Manufacturer: 'Bosch' },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const byId = new Map(
|
||||
(await loadKingCountySourcesFromOpenData()).map((camera) => [
|
||||
camera.id,
|
||||
camera,
|
||||
]),
|
||||
);
|
||||
for (const id of ['kingcounty-61', 'kingcounty-62', 'kingcounty-63']) {
|
||||
assert.equal(byId.get(id).model, undefined, id);
|
||||
assert.equal(byId.get(id).manufacturer, undefined, id);
|
||||
}
|
||||
assert.equal(byId.get('kingcounty-64').model, 'AUTODOME IP 5000');
|
||||
assert.equal(byId.get('kingcounty-64').manufacturer, 'Bosch');
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
lithuaniaCameraToSource,
|
||||
loadLithuaniaSources,
|
||||
LITHUANIA_CAMERAS_URL,
|
||||
LITHUANIA_LOCATIONS_URL,
|
||||
LITHUANIA_MAX_CATALOG_BYTES,
|
||||
} from '../../server/providers/cctv/lithuania.js';
|
||||
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
|
||||
|
||||
const row = {
|
||||
id: 72,
|
||||
name: 'Vilnius A1 10,04',
|
||||
x: 576154,
|
||||
y: 6056867,
|
||||
image:
|
||||
'https://eismoinfo.lt/eismoinfo-backend/image-provider/camera/last?id=72',
|
||||
};
|
||||
const feature = {
|
||||
id: '72',
|
||||
points: [{ point: [54.642550940833566, 25.17981587877114] }],
|
||||
};
|
||||
const layers = [{ layer: 'VKR', features: [feature] }];
|
||||
|
||||
test('Lithuania joins by ID and uses WGS84 latitude/longitude, never LKS-94 metres', () => {
|
||||
const camera = lithuaniaCameraToSource(row, feature);
|
||||
assert.equal(camera.id, 'lithuania-72');
|
||||
assert.equal(camera.lat, 54.642550940833566);
|
||||
assert.equal(camera.lon, 25.17981587877114);
|
||||
assert.equal(camera.name, 'Vilnius A1 10,04');
|
||||
assert.equal(camera.feedType, 'image');
|
||||
assert.equal(camera.headingConfidence, 'low');
|
||||
assert.equal(camera.url, row.image);
|
||||
assert.match(camera.credit, /Via Lietuva/);
|
||||
});
|
||||
|
||||
test('invalid joins, coordinates and image destinations cannot enter the proxy registry', () => {
|
||||
for (const invalid of [
|
||||
null,
|
||||
{ ...feature, id: '8' },
|
||||
{ ...feature, points: [] },
|
||||
{ ...feature, points: [{ point: [25.18, 54.64] }] },
|
||||
{ ...feature, points: [{ point: [576154, 6056867] }] },
|
||||
{ ...feature, points: [{ point: [null, 25] }] },
|
||||
]) {
|
||||
assert.equal(lithuaniaCameraToSource(row, invalid), null);
|
||||
}
|
||||
for (const image of [
|
||||
'http://eismoinfo.lt/eismoinfo-backend/image-provider/camera/last?id=72',
|
||||
row.image.replace('eismoinfo.lt', 'eismoinfo.lt.evil.test'),
|
||||
row.image.replace('https://', 'https://user:pass@'),
|
||||
row.image.replace('id=72', 'id=8'),
|
||||
'http://127.0.0.1/private',
|
||||
'',
|
||||
null,
|
||||
]) {
|
||||
assert.equal(lithuaniaCameraToSource({ ...row, image }, feature), null);
|
||||
}
|
||||
assert.equal(
|
||||
lithuaniaCameraToSource(
|
||||
{ ...row, image: row.image + '&extra=ignored' },
|
||||
feature,
|
||||
).url,
|
||||
row.image,
|
||||
);
|
||||
assert.equal(lithuaniaCameraToSource(null, feature), null);
|
||||
});
|
||||
|
||||
function mockCatalogs(t) {
|
||||
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 (url === LITHUANIA_CAMERAS_URL) {
|
||||
assert.equal(options.redirect, 'manual');
|
||||
return Response.json([row, row, { ...row, id: 999 }]);
|
||||
}
|
||||
if (url === LITHUANIA_LOCATIONS_URL) return Response.json(layers);
|
||||
return Response.json([]);
|
||||
});
|
||||
return requested;
|
||||
}
|
||||
|
||||
test('loader deduplicates and omits cameras without matching published coordinates', async (t) => {
|
||||
const requested = mockCatalogs(t);
|
||||
assert.deepEqual(
|
||||
(await loadLithuaniaSources()).map((c) => c.id),
|
||||
['lithuania-72'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
requested.sort(),
|
||||
[LITHUANIA_CAMERAS_URL, LITHUANIA_LOCATIONS_URL].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
/** Run one catalog refresh with CCTV_LITHUANIA_ENABLED set to `flag`
|
||||
* (undefined leaves it unset), restoring the environment afterwards. */
|
||||
async function runCatalogWithLithuaniaFlag(t, flag) {
|
||||
const requested = mockCatalogs(t);
|
||||
const saved = process.env.CCTV_LITHUANIA_ENABLED;
|
||||
try {
|
||||
if (flag === undefined) delete process.env.CCTV_LITHUANIA_ENABLED;
|
||||
else process.env.CCTV_LITHUANIA_ENABLED = flag;
|
||||
const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
|
||||
return { requested, sources };
|
||||
} finally {
|
||||
if (saved === undefined) delete process.env.CCTV_LITHUANIA_ENABLED;
|
||||
else process.env.CCTV_LITHUANIA_ENABLED = saved;
|
||||
}
|
||||
}
|
||||
|
||||
test('CCTV_LITHUANIA_ENABLED=1 wires the Lithuania pack into the catalog', async (t) => {
|
||||
const { sources } = await runCatalogWithLithuaniaFlag(t, '1');
|
||||
assert.equal(
|
||||
sources.find((c) => c.id === 'lithuania-72')?.cityId,
|
||||
'lithuania',
|
||||
);
|
||||
});
|
||||
|
||||
// The camera endpoints are the eismoinfo.lt website backend, not a service the
|
||||
// open-data statement lists, so an install that has not opted in must never
|
||||
// contact them.
|
||||
for (const [label, flag] of [
|
||||
['an unset CCTV_LITHUANIA_ENABLED', undefined],
|
||||
['an empty CCTV_LITHUANIA_ENABLED', ''],
|
||||
['CCTV_LITHUANIA_ENABLED=0', '0'],
|
||||
['CCTV_LITHUANIA_ENABLED=true', 'true'],
|
||||
]) {
|
||||
test(`${label} makes no Lithuania request`, async (t) => {
|
||||
const { requested, sources } = await runCatalogWithLithuaniaFlag(t, flag);
|
||||
assert.ok(requested.length > 0, 'the other packs still refreshed');
|
||||
assert.equal(
|
||||
requested.some((href) => href.includes('eismoinfo.lt')),
|
||||
false,
|
||||
'the pack never reaches its upstream',
|
||||
);
|
||||
assert.ok(!sources.some((c) => c.cityId === 'lithuania'));
|
||||
});
|
||||
}
|
||||
|
||||
test('a failed catalog cancels the sibling request instead of waiting for the timeout', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
let siblingSignal;
|
||||
t.mock.method(globalThis, 'fetch', async (url, options) => {
|
||||
if (url === LITHUANIA_CAMERAS_URL) {
|
||||
return new Response('', { status: 503 });
|
||||
}
|
||||
siblingSignal = options.signal;
|
||||
// Never settles on its own; only an abort ends it.
|
||||
return new Promise((_, reject) => {
|
||||
options.signal.addEventListener('abort', () =>
|
||||
reject(options.signal.reason),
|
||||
);
|
||||
});
|
||||
});
|
||||
assert.deepEqual(await loadLithuaniaSources(), []);
|
||||
assert.equal(siblingSignal?.aborted, true);
|
||||
assert.notEqual(siblingSignal.reason?.name, 'TimeoutError');
|
||||
});
|
||||
|
||||
test('a redirected catalog is reported as not followed', async (t) => {
|
||||
const warnings = [];
|
||||
t.mock.method(console, 'warn', (...args) => warnings.push(args.join(' ')));
|
||||
t.mock.method(
|
||||
globalThis,
|
||||
'fetch',
|
||||
async () =>
|
||||
new Response('', {
|
||||
status: 302,
|
||||
headers: { Location: 'http://127.0.0.1/' },
|
||||
}),
|
||||
);
|
||||
assert.deepEqual(await loadLithuaniaSources(), []);
|
||||
assert.ok(warnings.some((line) => /redirects are not followed/.test(line)));
|
||||
});
|
||||
|
||||
test('failed, redirected, malformed and oversized catalogs degrade to an empty pack', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
for (const makeResponse of [
|
||||
() => new Response('', { status: 503 }),
|
||||
() =>
|
||||
new Response('', {
|
||||
status: 302,
|
||||
headers: { Location: 'http://127.0.0.1/' },
|
||||
}),
|
||||
() => new Response('invalid json'),
|
||||
() => Response.json({ unexpected: true }),
|
||||
() =>
|
||||
new Response('[]', {
|
||||
headers: { 'Content-Length': String(LITHUANIA_MAX_CATALOG_BYTES + 1) },
|
||||
}),
|
||||
]) {
|
||||
t.mock.method(globalThis, 'fetch', async () => makeResponse());
|
||||
assert.deepEqual(await loadLithuaniaSources(), []);
|
||||
}
|
||||
});
|
||||
@@ -38,6 +38,10 @@ const PROVIDER_STATIC_REFRESH_MS = Object.freeze({
|
||||
// collectionInterval, 600 s for every station sampled; polling faster only
|
||||
// re-fetches the same JPEG.
|
||||
fintraffic: 10 * 60 * 1000,
|
||||
// Measured empirically (2026-09-15): polling the same SCT/Terrassa frame
|
||||
// every 20s showed no change inside 64s but a new frame by ~90-150s later —
|
||||
// matches the other highway packs' 3-minute cadence.
|
||||
'servei català de trànsit': 3 * 60 * 1000,
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -641,6 +641,10 @@ test('staticFrameRefreshMs follows known pack cadences and bounds explicit value
|
||||
assert.equal(staticFrameRefreshMs({ provider: 'TxDOT' }), 180_000);
|
||||
// Digitraffic publishes one weathercam frame per 600 s collection interval.
|
||||
assert.equal(staticFrameRefreshMs({ provider: 'Fintraffic' }), 600_000);
|
||||
assert.equal(
|
||||
staticFrameRefreshMs({ provider: 'Servei Català de Trànsit' }),
|
||||
180_000,
|
||||
);
|
||||
assert.equal(staticFrameRefreshMs({ frameRefreshMs: 5_000 }), 60_000);
|
||||
assert.equal(staticFrameRefreshMs({ frameRefreshMs: 3_000_000 }), 1_200_000);
|
||||
assert.equal(staticFrameRefreshMs({ provider: 'Unknown Provider' }), 300_000);
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
// /api/cctv/media is the live-feed relay. A still camera used to be relayed
|
||||
// through it as well, by an ordinary redirect-following fetch with no content
|
||||
// type check and no body cap, which side-stepped everything /api/cctv/frame
|
||||
// enforces for the same URL. These pin that a still is refused there and only
|
||||
// reachable through /frame, while live feeds keep their relay.
|
||||
//
|
||||
// Run with: npm test (node --test)
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { Readable } from 'node:stream';
|
||||
import { cctvProxy } from '../../server/providers/cctv.js';
|
||||
|
||||
const STILL = {
|
||||
id: 'still-fixture',
|
||||
name: 'Still fixture',
|
||||
lat: 30.27,
|
||||
lon: -97.74,
|
||||
feedType: 'image',
|
||||
url: 'https://cams.upstream.invalid/still.jpg',
|
||||
};
|
||||
// 'jpg' normalizes to 'image'; the alias must not reopen the relay.
|
||||
const STILL_ALIAS = {
|
||||
...STILL,
|
||||
id: 'still-alias',
|
||||
feedType: 'jpg',
|
||||
url: 'https://cams.upstream.invalid/alias.jpg',
|
||||
};
|
||||
const VIDEO = {
|
||||
id: 'video-fixture',
|
||||
name: 'Video fixture',
|
||||
lat: 30.27,
|
||||
lon: -97.74,
|
||||
feedType: 'video',
|
||||
url: 'https://cams.upstream.invalid/live.mp4',
|
||||
};
|
||||
const MJPEG = {
|
||||
id: 'mjpeg-fixture',
|
||||
name: 'MJPEG fixture',
|
||||
lat: 30.27,
|
||||
lon: -97.74,
|
||||
feedType: 'mjpeg',
|
||||
url: 'https://cams.upstream.invalid/live.mjpg',
|
||||
};
|
||||
|
||||
/** A minimal upstream Response with a streaming body. */
|
||||
function upstreamResponse({ status = 200, headers = {}, body = 'payload' }) {
|
||||
const store = new Map(
|
||||
Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]),
|
||||
);
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
headers: { get: (name) => store.get(String(name).toLowerCase()) ?? null },
|
||||
body: Readable.toWeb(Readable.from([Buffer.from(String(body))])),
|
||||
};
|
||||
}
|
||||
|
||||
/** Collects what a handler wrote, the way a Node ServerResponse would. */
|
||||
function recordingResponse() {
|
||||
const chunks = [];
|
||||
let finished;
|
||||
const done = new Promise((resolve) => {
|
||||
finished = resolve;
|
||||
});
|
||||
const res = {
|
||||
statusCode: 0,
|
||||
headers: {},
|
||||
writableEnded: false,
|
||||
body: '',
|
||||
writeHead(status, headers) {
|
||||
res.statusCode = status;
|
||||
res.headers = headers || {};
|
||||
},
|
||||
write(chunk) {
|
||||
chunks.push(Buffer.from(chunk));
|
||||
return true;
|
||||
},
|
||||
end(chunk) {
|
||||
if (chunk) chunks.push(Buffer.from(chunk));
|
||||
res.writableEnded = true;
|
||||
res.body = Buffer.concat(chunks).toString('utf8');
|
||||
finished();
|
||||
},
|
||||
on() {},
|
||||
once() {},
|
||||
emit() {},
|
||||
destroy() {},
|
||||
};
|
||||
return { res, done };
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount the real provider over the fixture cameras and answer every upstream
|
||||
* request from the supplied function.
|
||||
*/
|
||||
function mount(t, respond) {
|
||||
const before = {
|
||||
json: process.env.CCTV_SOURCES_JSON,
|
||||
file: process.env.CCTV_SOURCES_FILE,
|
||||
austin: process.env.CCTV_FORCE_AUSTIN,
|
||||
serverKey: process.env.GOOGLE_MAPS_SERVER_API_KEY,
|
||||
browserKey: process.env.GOOGLE_MAPS_API_KEY,
|
||||
};
|
||||
process.env.CCTV_SOURCES_JSON = JSON.stringify([
|
||||
STILL,
|
||||
STILL_ALIAS,
|
||||
VIDEO,
|
||||
MJPEG,
|
||||
]);
|
||||
process.env.CCTV_SOURCES_FILE = 'absent-source-file.json';
|
||||
process.env.CCTV_FORCE_AUSTIN = '0';
|
||||
// No Street View key: a /frame miss lands on the synthetic billboard rather
|
||||
// than a network call this file does not model.
|
||||
delete process.env.GOOGLE_MAPS_SERVER_API_KEY;
|
||||
delete process.env.GOOGLE_MAPS_API_KEY;
|
||||
const nativeFetch = globalThis.fetch;
|
||||
const requests = [];
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
requests.push({ url: String(url), redirect: init.redirect });
|
||||
return respond({ url: String(url), init });
|
||||
};
|
||||
t.after(() => {
|
||||
globalThis.fetch = nativeFetch;
|
||||
for (const [name, value] of [
|
||||
['CCTV_SOURCES_JSON', before.json],
|
||||
['CCTV_SOURCES_FILE', before.file],
|
||||
['CCTV_FORCE_AUSTIN', before.austin],
|
||||
['GOOGLE_MAPS_SERVER_API_KEY', before.serverKey],
|
||||
['GOOGLE_MAPS_API_KEY', before.browserKey],
|
||||
]) {
|
||||
if (value === undefined) delete process.env[name];
|
||||
else process.env[name] = value;
|
||||
}
|
||||
});
|
||||
|
||||
let handler = null;
|
||||
cctvProxy().configureServer({
|
||||
middlewares: {
|
||||
use: (_route, fn) => {
|
||||
handler = fn;
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.ok(handler, 'the CCTV provider must mount a middleware');
|
||||
|
||||
const call = async (path) => {
|
||||
const { res, done } = recordingResponse();
|
||||
await handler({ url: path, headers: {}, method: 'GET', on() {} }, res);
|
||||
await done;
|
||||
return res;
|
||||
};
|
||||
const health = async (id) => {
|
||||
const res = await call('/health');
|
||||
return JSON.parse(res.body).cameras.find((entry) => entry.id === id);
|
||||
};
|
||||
return { call, health, requests };
|
||||
}
|
||||
|
||||
test('a still camera is refused by /media without any upstream request', async (t) => {
|
||||
// Everything the old relay would have done with this camera: redirect to a
|
||||
// private address, then answer with HTML.
|
||||
const app = mount(t, ({ url }) =>
|
||||
url.includes('169.254.169.254')
|
||||
? upstreamResponse({
|
||||
headers: { 'content-type': 'text/html' },
|
||||
body: '<html>metadata</html>',
|
||||
})
|
||||
: upstreamResponse({
|
||||
status: 302,
|
||||
headers: { location: 'http://169.254.169.254/latest/meta-data/' },
|
||||
body: '',
|
||||
}),
|
||||
);
|
||||
for (const camera of [STILL, STILL_ALIAS]) {
|
||||
const res = await app.call(`/media/${camera.id}`);
|
||||
assert.equal(res.statusCode, 404, camera.id);
|
||||
assert.equal(res.headers['Content-Type'], 'application/json');
|
||||
assert.deepEqual(JSON.parse(res.body), {
|
||||
error: 'No live media for this camera; use the frame endpoint',
|
||||
});
|
||||
assert.doesNotMatch(res.body, /metadata/);
|
||||
// Being asked on the wrong route is not a camera fault.
|
||||
assert.equal(await app.health(camera.id), undefined);
|
||||
}
|
||||
assert.deepEqual(app.requests, [], 'a still camera reached its upstream');
|
||||
});
|
||||
|
||||
test('an unregistered camera id is refused by /media the same way', async (t) => {
|
||||
const app = mount(t, () => {
|
||||
throw new Error('an unknown camera must not fetch');
|
||||
});
|
||||
const res = await app.call('/media/not-a-camera');
|
||||
assert.equal(res.statusCode, 404);
|
||||
assert.equal(app.requests.length, 0);
|
||||
});
|
||||
|
||||
test('a video camera is still relayed by /media', async (t) => {
|
||||
const app = mount(t, () =>
|
||||
upstreamResponse({
|
||||
headers: { 'content-type': 'video/mp4', 'content-length': '5' },
|
||||
body: 'video',
|
||||
}),
|
||||
);
|
||||
const res = await app.call(`/media/${VIDEO.id}`);
|
||||
assert.equal(res.statusCode, 200);
|
||||
assert.equal(res.headers['Content-Type'], 'video/mp4');
|
||||
assert.equal(res.headers['X-CCTV-Source'], 'live-media');
|
||||
assert.equal(res.body, 'video');
|
||||
assert.deepEqual(
|
||||
app.requests.map(({ url }) => url),
|
||||
[VIDEO.url],
|
||||
);
|
||||
assert.equal((await app.health(VIDEO.id)).status, 'ok');
|
||||
});
|
||||
|
||||
test('an MJPEG camera is still relayed by /media', async (t) => {
|
||||
const app = mount(t, () =>
|
||||
upstreamResponse({
|
||||
headers: { 'content-type': 'multipart/x-mixed-replace; boundary=frame' },
|
||||
body: '--frame',
|
||||
}),
|
||||
);
|
||||
const res = await app.call(`/media/${MJPEG.id}`);
|
||||
assert.equal(res.statusCode, 200);
|
||||
assert.match(res.headers['Content-Type'], /^multipart\/x-mixed-replace/);
|
||||
assert.equal(res.body, '--frame');
|
||||
assert.deepEqual(
|
||||
app.requests.map(({ url }) => url),
|
||||
[MJPEG.url],
|
||||
);
|
||||
});
|
||||
|
||||
test('a still camera is still served through /frame with its bounded fetch', async (t) => {
|
||||
const app = mount(t, () =>
|
||||
upstreamResponse({
|
||||
headers: { 'content-type': 'image/jpeg' },
|
||||
body: 'jpeg-bytes',
|
||||
}),
|
||||
);
|
||||
const res = await app.call(`/frame/${STILL.id}`);
|
||||
assert.equal(res.statusCode, 200);
|
||||
assert.equal(res.headers['Content-Type'], 'image/jpeg');
|
||||
assert.equal(res.headers['X-CCTV-Source'], 'upstream-image');
|
||||
assert.equal(res.body, 'jpeg-bytes');
|
||||
assert.deepEqual(app.requests, [{ url: STILL.url, redirect: 'manual' }]);
|
||||
});
|
||||
|
||||
test('/frame does not follow a still camera off its origin', async (t) => {
|
||||
// The protection /media used to bypass, shown on the route that keeps it.
|
||||
const app = mount(t, ({ url }) =>
|
||||
url === STILL.url
|
||||
? upstreamResponse({
|
||||
status: 302,
|
||||
headers: { location: 'http://169.254.169.254/latest/meta-data/' },
|
||||
body: '',
|
||||
})
|
||||
: upstreamResponse({
|
||||
headers: { 'content-type': 'image/jpeg' },
|
||||
body: 'private',
|
||||
}),
|
||||
);
|
||||
const res = await app.call(`/frame/${STILL.id}`);
|
||||
assert.equal(res.statusCode, 200);
|
||||
assert.equal(res.headers['X-CCTV-Source'], 'synthetic');
|
||||
assert.doesNotMatch(res.body, /private/);
|
||||
assert.deepEqual(
|
||||
app.requests.map(({ url }) => url),
|
||||
[STILL.url],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,423 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
parseRescuCameraList,
|
||||
torontoCameraToSource,
|
||||
loadTorontoSourcesFromOpenData,
|
||||
} from '../../server/providers/cctv/sources.js';
|
||||
import {
|
||||
TORONTO_RESCU_LIST_URL,
|
||||
TORONTO_RESCU_IMAGE_ORIGIN,
|
||||
TORONTO_MAX_CATALOG_BYTES,
|
||||
DEFAULT_CCTV_MAX_SOURCES,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import { CAMERA_CODE_MAX_CHARS } 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('j'));
|
||||
},
|
||||
cancel() {
|
||||
state.cancelled = true;
|
||||
},
|
||||
});
|
||||
return { response: new Response(body, init), state };
|
||||
};
|
||||
|
||||
/** One RESCU row, shaped like the live `tmcearthcameras.json` payload. */
|
||||
const row = (overrides = {}) => ({
|
||||
Number: '8048',
|
||||
Name: 'BATHURST ST & BLOOR ST W',
|
||||
Latitude: '43.665249',
|
||||
Longitude: '-79.41114',
|
||||
D1: 'n',
|
||||
D2: 'e',
|
||||
D3: 's',
|
||||
D4: 'w',
|
||||
Group: 'Arterial',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
/** The city wraps the list in a JSONP callback; this is that envelope. */
|
||||
const jsonp = (rows) =>
|
||||
`jsonTMCEarthCamerasCallback(${JSON.stringify({ Data: rows })});`;
|
||||
|
||||
test('a RESCU row maps to a source on the constructed frame URL', () => {
|
||||
const source = torontoCameraToSource(row());
|
||||
assert.equal(source.id, 'toronto-8048');
|
||||
assert.equal(source.name, 'BATHURST ST & BLOOR ST W');
|
||||
assert.equal(source.city, 'Toronto');
|
||||
assert.equal(source.cityId, 'toronto');
|
||||
assert.equal(source.provider, 'City of Toronto');
|
||||
assert.equal(source.lat, 43.665249);
|
||||
assert.equal(source.lon, -79.41114);
|
||||
assert.equal(source.feedType, 'image');
|
||||
assert.equal(source.sourceKind, 'toronto-rescu-open-data');
|
||||
assert.equal(
|
||||
source.license,
|
||||
'Contains information licensed under the Open Government Licence – Toronto',
|
||||
);
|
||||
assert.equal(
|
||||
source.url,
|
||||
`${TORONTO_RESCU_IMAGE_ORIGIN}loc8048.jpg`,
|
||||
'the documented loc####.jpg convention',
|
||||
);
|
||||
assert.equal(source.url, source.snapshotUrl);
|
||||
assert.ok(source.url.startsWith(TORONTO_RESCU_IMAGE_ORIGIN));
|
||||
});
|
||||
|
||||
test('the frame URL is built from the camera number, never read from the payload', () => {
|
||||
// The list carries no URL field at all, so an added one must stay inert: the
|
||||
// frame proxy is pinned to the city origin by construction.
|
||||
const planted = torontoCameraToSource(
|
||||
row({
|
||||
url: 'https://evil.example/x.jpg',
|
||||
snapshotUrl: 'https://evil.example/x.jpg',
|
||||
Url: 'https://evil.example/x.jpg',
|
||||
}),
|
||||
);
|
||||
assert.equal(planted.url, `${TORONTO_RESCU_IMAGE_ORIGIN}loc8048.jpg`);
|
||||
assert.equal(planted.snapshotUrl, planted.url);
|
||||
|
||||
// A number that is not plain digits cannot reach the URL builder: path
|
||||
// traversal and absolute URLs are refused outright rather than escaped.
|
||||
for (const Number_ of [
|
||||
'../../etc/passwd',
|
||||
'8048/../../x',
|
||||
'https://evil.example/1',
|
||||
'80 48',
|
||||
'8048.jpg',
|
||||
'-8048',
|
||||
'',
|
||||
' ',
|
||||
null,
|
||||
]) {
|
||||
assert.equal(
|
||||
torontoCameraToSource(row({ Number: Number_ })),
|
||||
null,
|
||||
`refused: ${String(Number_)}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('the comparison directions never become a camera heading', () => {
|
||||
// D1-D4 are the city's *comparison image* directions (static 2016 reference
|
||||
// stills served from ComparisonImages/), not a facing for the live frame:
|
||||
// these cameras pan. The letters read like a compass and map trivially onto
|
||||
// one (n -> 0, w -> 270), so the temptation to wire them up is real — and a
|
||||
// heading derived from them would be confidently wrong for every camera in
|
||||
// the city. Nobody may "fix" this.
|
||||
const base = torontoCameraToSource(row());
|
||||
for (const dirs of [
|
||||
{ D1: 'n', D2: 'e', D3: 's', D4: 'w' },
|
||||
{ D1: '', D2: 'e', D3: '', D4: 'w' },
|
||||
{ D1: '', D2: '', D3: '', D4: '' },
|
||||
{ D1: 's', D2: '', D3: '', D4: '' },
|
||||
]) {
|
||||
const source = torontoCameraToSource(row(dirs));
|
||||
assert.equal(source.headingConfidence, 'low');
|
||||
assert.ok(Number.isFinite(source.headingDeg));
|
||||
// Same id, same fallback heading, whatever the directions say.
|
||||
assert.equal(source.headingDeg, base.headingDeg);
|
||||
}
|
||||
});
|
||||
|
||||
test('the two cameras at one intersection are kept apart', () => {
|
||||
// 8047 and 8048 are both "BATHURST ST & BLOOR ST W", five metres apart. They
|
||||
// are distinct units, so both survive, and the id-hash fallback fans their
|
||||
// gizmos apart instead of stacking them on one bearing.
|
||||
const a = torontoCameraToSource(
|
||||
row({ Number: '8047', Latitude: '43.665223', Longitude: '-79.411083' }),
|
||||
);
|
||||
const b = torontoCameraToSource(row());
|
||||
assert.equal(a.id, 'toronto-8047');
|
||||
assert.equal(b.id, 'toronto-8048');
|
||||
assert.notEqual(a.url, b.url);
|
||||
assert.notEqual(a.headingDeg, b.headingDeg);
|
||||
});
|
||||
|
||||
test('rows outside Toronto and unusable geometry are dropped', () => {
|
||||
// Calgary: a plausible lat/lon, but not a Toronto camera.
|
||||
assert.equal(
|
||||
torontoCameraToSource(row({ Latitude: '51.0453', Longitude: '-114.1413' })),
|
||||
null,
|
||||
);
|
||||
// Null island, and the empty strings that would become it.
|
||||
assert.equal(
|
||||
torontoCameraToSource(row({ Latitude: '0', Longitude: '0' })),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
torontoCameraToSource(row({ Latitude: '', Longitude: '' })),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
torontoCameraToSource(row({ Latitude: 'north', Longitude: 'west' })),
|
||||
null,
|
||||
);
|
||||
assert.equal(torontoCameraToSource({}), null);
|
||||
assert.equal(torontoCameraToSource(null), null);
|
||||
});
|
||||
|
||||
test('a nameless row still gets a label', () => {
|
||||
const source = torontoCameraToSource(row({ Name: ' ' }));
|
||||
assert.equal(source.name, 'Toronto Camera 8048');
|
||||
});
|
||||
|
||||
test('the JSONP envelope is unwrapped, and only that envelope', () => {
|
||||
assert.deepEqual(parseRescuCameraList(jsonp([row()])), [row()]);
|
||||
// Whitespace and a missing trailing semicolon are both tolerated.
|
||||
assert.equal(
|
||||
parseRescuCameraList(`\n ${jsonp([row()]).replace(/;$/, '')} \n`).length,
|
||||
1,
|
||||
);
|
||||
// Should the city ever drop the wrapper, the bare document still parses.
|
||||
assert.equal(
|
||||
parseRescuCameraList(JSON.stringify({ Data: [row()] })).length,
|
||||
1,
|
||||
);
|
||||
|
||||
// Anything else yields no cameras rather than a thrown loader.
|
||||
for (const text of [
|
||||
'otherCallback({"Data":[]})',
|
||||
'jsonTMCEarthCamerasCallback(',
|
||||
'jsonTMCEarthCamerasCallback({"Data":',
|
||||
'{"Data":"not-an-array"}',
|
||||
'{"nope":[]}',
|
||||
'<html>503</html>',
|
||||
'',
|
||||
null,
|
||||
]) {
|
||||
assert.deepEqual(parseRescuCameraList(text), [], `rejected: ${text}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('the loader reads the keyless list and collapses duplicate numbers', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
const requested = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
requested.push(String(url));
|
||||
return new Response(
|
||||
jsonp([
|
||||
row(),
|
||||
// Same camera number twice: one camera, not two.
|
||||
row({ Name: 'BATHURST ST & BLOOR ST W (duplicate)' }),
|
||||
row({
|
||||
Number: '8001',
|
||||
Name: 'YORK ST & BREMNER BLVD / RAPTORS WAY',
|
||||
Latitude: '43.643120',
|
||||
Longitude: '-79.381386',
|
||||
}),
|
||||
row({ Number: '../evil' }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
const cameras = await loadTorontoSourcesFromOpenData();
|
||||
assert.deepEqual(requested, [TORONTO_RESCU_LIST_URL]);
|
||||
assert.deepEqual(
|
||||
cameras.map((camera) => camera.id),
|
||||
// Nearest downtown first: York & Bremner is at the core, Bathurst & Bloor
|
||||
// is three kilometres out.
|
||||
['toronto-8001', 'toronto-8048'],
|
||||
);
|
||||
assert.equal(cameras[1].name, 'BATHURST ST & BLOOR ST W');
|
||||
});
|
||||
|
||||
test('an upstream failure yields an empty pack and releases the response', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
// A 503 can still arrive with a streaming body; returning without cancelling
|
||||
// it would hold the connection until the socket times out.
|
||||
const failed = streamingResponse({ status: 503 });
|
||||
t.mock.method(globalThis, 'fetch', async () => failed.response);
|
||||
assert.deepEqual(await loadTorontoSourcesFromOpenData(), []);
|
||||
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 loadTorontoSourcesFromOpenData(), []);
|
||||
});
|
||||
|
||||
test('the list fetch refuses redirects and oversized bodies', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
// A redirect is never followed: the list host cannot be steered. Its body is
|
||||
// a live stream, so the test also proves the refusal releases the transport.
|
||||
const seen = [];
|
||||
const redirected = streamingResponse({
|
||||
status: 302,
|
||||
headers: { location: 'https://evil.example/cameras.json' },
|
||||
});
|
||||
t.mock.method(globalThis, 'fetch', async (url, init) => {
|
||||
seen.push([String(url), init.redirect]);
|
||||
return redirected.response;
|
||||
});
|
||||
assert.deepEqual(await loadTorontoSourcesFromOpenData(), []);
|
||||
assert.deepEqual(seen, [[TORONTO_RESCU_LIST_URL, 'manual']]);
|
||||
assert.equal(
|
||||
redirected.state.cancelled,
|
||||
true,
|
||||
'the redirect body is cancelled',
|
||||
);
|
||||
|
||||
// A body over the cap is refused rather than buffered.
|
||||
t.mock.restoreAll();
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
return new Response(jsonp([row()]), {
|
||||
headers: {
|
||||
'content-length': String(TORONTO_MAX_CATALOG_BYTES + 1),
|
||||
},
|
||||
});
|
||||
});
|
||||
assert.deepEqual(await loadTorontoSourcesFromOpenData(), []);
|
||||
|
||||
// So is a body that only declares its size once it is already too long.
|
||||
t.mock.restoreAll();
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
const oversized = 'x'.repeat(TORONTO_MAX_CATALOG_BYTES + 1024);
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(oversized));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
assert.deepEqual(await loadTorontoSourcesFromOpenData(), []);
|
||||
});
|
||||
|
||||
test('the unselected label is the intersection, trimmed to the code width', () => {
|
||||
assert.equal(torontoCameraToSource(row()).code, 'BATHURST ST & BLOOR ST W');
|
||||
const long = torontoCameraToSource(
|
||||
row({
|
||||
Name: 'BAYVIEW AVE & POTTERY RD / PRIVATE ACCESS @ 620m SOUTH OF - 550 BAYVIEW AVE (EVERGREEN)',
|
||||
}),
|
||||
);
|
||||
assert.ok(long.code.length <= CAMERA_CODE_MAX_CHARS);
|
||||
assert.ok(long.code.endsWith('…'));
|
||||
assert.ok(long.code.startsWith('BAYVIEW AVE & POTTERY'));
|
||||
});
|
||||
|
||||
test('a reduced catalog cap thins every pack instead of dropping Toronto', () => {
|
||||
// Toronto merges last in LIVE_PACKS, so a positional slice would delete it
|
||||
// outright. The round-robin allocation must give it its share.
|
||||
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('toronto', 336)],
|
||||
30,
|
||||
);
|
||||
assert.equal(sources.length, 30);
|
||||
assert.deepEqual(
|
||||
packs.map((p) => [p.name, p.kept]),
|
||||
[
|
||||
['austin', 10],
|
||||
['calgary', 10],
|
||||
['toronto', 10],
|
||||
],
|
||||
);
|
||||
// Toronto contributes its own highest-priority cameras, in its own order.
|
||||
assert.deepEqual(
|
||||
sources.filter((s) => s.id.startsWith('toronto-')).map((s) => s.id),
|
||||
Array.from({ length: 10 }, (_, i) => `toronto-${i}`),
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Serve the RESCU list to the Toronto 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 === TORONTO_RESCU_LIST_URL) {
|
||||
return new Response(
|
||||
jsonp([
|
||||
row({
|
||||
Number: '8001',
|
||||
Name: 'YORK ST & BREMNER BLVD / RAPTORS WAY',
|
||||
Latitude: '43.643120',
|
||||
Longitude: '-79.381386',
|
||||
}),
|
||||
row(),
|
||||
]),
|
||||
);
|
||||
}
|
||||
return Response.json([]);
|
||||
});
|
||||
const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
|
||||
return { requested, sources };
|
||||
};
|
||||
|
||||
test('the pack is registered, so its cameras reach the served catalog', async (t) => {
|
||||
const saved = { ...process.env };
|
||||
try {
|
||||
delete process.env.CCTV_SOURCES_FILE;
|
||||
delete process.env.CCTV_SOURCES_JSON;
|
||||
delete process.env.CCTV_TORONTO_ENABLED;
|
||||
const { requested, sources } = await runCatalogWithMockedUpstreams(t);
|
||||
assert.ok(
|
||||
requested.includes(TORONTO_RESCU_LIST_URL),
|
||||
'the registered lane reaches its upstream',
|
||||
);
|
||||
assert.deepEqual(
|
||||
sources.filter((s) => s.cityId === 'toronto').map((s) => s.id),
|
||||
['toronto-8001', 'toronto-8048'],
|
||||
'Toronto 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_TORONTO_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_TORONTO_ENABLED = '0';
|
||||
const { requested, sources } = await runCatalogWithMockedUpstreams(t);
|
||||
assert.equal(
|
||||
requested.includes(TORONTO_RESCU_LIST_URL),
|
||||
false,
|
||||
'the disabled lane never reaches its upstream',
|
||||
);
|
||||
assert.deepEqual(
|
||||
sources.filter((s) => s.cityId === 'toronto'),
|
||||
[],
|
||||
);
|
||||
} 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);
|
||||
});
|
||||
@@ -0,0 +1,302 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
|
||||
import {
|
||||
DEFAULT_WSDOT_MAX_SOURCES,
|
||||
WSDOT_CAMERAS_URL,
|
||||
WSDOT_MAX_CATALOG_BYTES,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import {
|
||||
loadWsdotSourcesFromOpenData,
|
||||
webMercatorToWgs84,
|
||||
} from '../../server/providers/cctv/sources.js';
|
||||
|
||||
/** Set (or, for `undefined`, delete) environment variables for one test. */
|
||||
function withEnv(t, env) {
|
||||
for (const [name, value] of Object.entries(env)) {
|
||||
const previous = process.env[name];
|
||||
t.after(() => {
|
||||
if (previous === undefined) delete process.env[name];
|
||||
else process.env[name] = previous;
|
||||
});
|
||||
if (value === undefined) delete process.env[name];
|
||||
else process.env[name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
/** Silence the loaders' progress and failure logging. */
|
||||
function quiet(t) {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
}
|
||||
|
||||
/** Web Mercator metres for a WGS84 coordinate (inverse of the loader's math). */
|
||||
function mercator(lat, lon) {
|
||||
const R = 6378137;
|
||||
return {
|
||||
x: (lon * Math.PI * R) / 180,
|
||||
y: R * Math.log(Math.tan(Math.PI / 4 + (lat * Math.PI) / 360)),
|
||||
};
|
||||
}
|
||||
|
||||
/** A `Cameras.json` point feature carrying the fields the loader reads. */
|
||||
function wsdotFeature(id, lat, lon, overrides = {}) {
|
||||
const { attributes = {}, geometry } = overrides;
|
||||
return {
|
||||
attributes: {
|
||||
CameraID: id,
|
||||
CameraTitle: `Camera ${id}`,
|
||||
CompassDirection: 'N',
|
||||
ImageURL: `https://images.wsdot.wa.gov/nw/${id}.jpg`,
|
||||
...attributes,
|
||||
},
|
||||
geometry: geometry === undefined ? mercator(lat, lon) : geometry,
|
||||
};
|
||||
}
|
||||
|
||||
/** Serialize a payload for the mocked fetch as raw Windows-1252 bytes. */
|
||||
function cp1252Response(payload) {
|
||||
const text = JSON.stringify(payload);
|
||||
const bytes = new Uint8Array(text.length);
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
const code = text.charCodeAt(i);
|
||||
// U+2013 EN DASH is 0x96 in Windows-1252 — the byte the live feed ships.
|
||||
bytes[i] = code === 0x2013 ? 0x96 : code;
|
||||
}
|
||||
return new Response(bytes, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
test('webMercatorToWgs84 recovers WGS84 degrees from layer geometry', () => {
|
||||
// Real feature from the live layer: I-5 at the Interstate Bridge.
|
||||
const { lat, lon } = webMercatorToWgs84(-13656007.102, 5719738.545);
|
||||
assert.ok(Math.abs(lat - 45.62) < 0.05, `lat ${lat}`);
|
||||
assert.ok(Math.abs(lon - -122.675) < 0.05, `lon ${lon}`);
|
||||
});
|
||||
|
||||
test('WSDOT loader keeps official-host cameras and maps compass directions', async (t) => {
|
||||
quiet(t);
|
||||
withEnv(t, { CCTV_WSDOT_MAX_SOURCES: undefined });
|
||||
const requested = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
requested.push(String(url));
|
||||
return cp1252Response({
|
||||
features: [
|
||||
wsdotFeature(1001, 47.6097, -122.3331, {
|
||||
attributes: {
|
||||
// The 0x2013 below is serialized as the feed's 0x96 byte.
|
||||
CameraTitle: 'I-5 – Seattle, looking south',
|
||||
CompassDirection: 'S',
|
||||
},
|
||||
}),
|
||||
wsdotFeature(9059, 47.66, -117.43, {
|
||||
attributes: { CompassDirection: 'B' },
|
||||
}),
|
||||
// Partner-hosted frame (Oregon DOT): skipped, never proxied.
|
||||
wsdotFeature(1002, 45.62, -122.675, {
|
||||
attributes: {
|
||||
ImageURL: 'https://www.tripcheck.com/RoadCams/cams/bridge.jpg',
|
||||
},
|
||||
}),
|
||||
// Bad rows: non-integer id, missing geometry, out-of-state point.
|
||||
wsdotFeature('1003', 47.6, -122.3),
|
||||
wsdotFeature(1004, 47.6, -122.3, { geometry: null }),
|
||||
wsdotFeature(1005, 34.05, -118.24),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
const cameras = await loadWsdotSourcesFromOpenData();
|
||||
|
||||
assert.deepEqual(requested, [WSDOT_CAMERAS_URL]);
|
||||
// Exactly the two valid official-host cameras survive; the partner-hosted
|
||||
// frame and the malformed rows are dropped.
|
||||
assert.deepEqual(cameras.map((camera) => camera.id).sort(), [
|
||||
'wsdot-1001',
|
||||
'wsdot-9059',
|
||||
]);
|
||||
|
||||
const seattle = cameras.find((camera) => camera.id === 'wsdot-1001');
|
||||
assert.equal(seattle.name, 'I-5 – Seattle, looking south');
|
||||
assert.equal(seattle.provider, 'WSDOT');
|
||||
assert.equal(seattle.url, 'https://images.wsdot.wa.gov/nw/1001.jpg');
|
||||
assert.equal(seattle.snapshotUrl, seattle.url);
|
||||
assert.equal(seattle.feedType, 'image');
|
||||
assert.equal(seattle.headingDeg, 180);
|
||||
assert.equal(seattle.headingConfidence, 'high');
|
||||
assert.equal(seattle.fovDeg, 56);
|
||||
assert.ok(Math.abs(seattle.lat - 47.6097) < 0.001);
|
||||
assert.ok(Math.abs(seattle.lon - -122.3331) < 0.001);
|
||||
|
||||
// `B` (both directions) carries no single facing: id-hash fallback at low
|
||||
// confidence, with the low-confidence pose personality.
|
||||
const spokane = cameras.find((camera) => camera.id === 'wsdot-9059');
|
||||
assert.equal(spokane.headingConfidence, 'low');
|
||||
assert.equal(spokane.fovDeg, 44);
|
||||
assert.ok(spokane.headingDeg >= 0 && spokane.headingDeg < 360);
|
||||
});
|
||||
|
||||
test('WSDOT loader maps every CompassDirection code', async (t) => {
|
||||
quiet(t);
|
||||
withEnv(t, { CCTV_WSDOT_MAX_SOURCES: undefined });
|
||||
const codes = {
|
||||
N: 0,
|
||||
NE: 45,
|
||||
E: 90,
|
||||
SE: 135,
|
||||
S: 180,
|
||||
SW: 225,
|
||||
W: 270,
|
||||
NW: 315,
|
||||
// Lower-case and padded codes are the same code.
|
||||
' nw ': 315,
|
||||
};
|
||||
// The live feed's no-facing codes, plus an unknown one.
|
||||
const noFacing = ['B', 'O', null, 'BW'];
|
||||
const entries = [...Object.keys(codes), ...noFacing];
|
||||
t.mock.method(globalThis, 'fetch', async () =>
|
||||
cp1252Response({
|
||||
features: entries.map((code, i) =>
|
||||
wsdotFeature(3000 + i, 47.0 + i * 0.05, -122.9 + i * 0.05, {
|
||||
attributes: { CompassDirection: code },
|
||||
}),
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
const cameras = await loadWsdotSourcesFromOpenData();
|
||||
const byId = new Map(cameras.map((camera) => [camera.id, camera]));
|
||||
entries.forEach((code, i) => {
|
||||
const camera = byId.get(`wsdot-${3000 + i}`);
|
||||
if (code in codes) {
|
||||
assert.equal(camera.headingDeg, codes[code], `heading for ${code}`);
|
||||
assert.equal(camera.headingConfidence, 'high', `confidence for ${code}`);
|
||||
assert.equal(camera.fovDeg, 56, `pose personality for ${code}`);
|
||||
} else {
|
||||
assert.equal(camera.headingConfidence, 'low', `confidence for ${code}`);
|
||||
assert.equal(camera.fovDeg, 44, `pose personality for ${code}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('WSDOT loader honors the max-sources cap', async (t) => {
|
||||
quiet(t);
|
||||
withEnv(t, { CCTV_WSDOT_MAX_SOURCES: '8' });
|
||||
const features = [];
|
||||
for (let i = 0; i < 24; i += 1) {
|
||||
features.push(wsdotFeature(2000 + i, 47.0 + i * 0.05, -122.9 + i * 0.05));
|
||||
}
|
||||
t.mock.method(globalThis, 'fetch', async () => cp1252Response({ features }));
|
||||
|
||||
const cameras = await loadWsdotSourcesFromOpenData();
|
||||
assert.equal(cameras.length, 8);
|
||||
});
|
||||
|
||||
test('WSDOT loader defaults its cap and survives upstream failure', async (t) => {
|
||||
quiet(t);
|
||||
withEnv(t, { CCTV_WSDOT_MAX_SOURCES: undefined });
|
||||
assert.equal(DEFAULT_WSDOT_MAX_SOURCES, 250);
|
||||
|
||||
t.mock.method(
|
||||
globalThis,
|
||||
'fetch',
|
||||
async () => new Response('', { status: 503 }),
|
||||
);
|
||||
assert.deepEqual(await loadWsdotSourcesFromOpenData(), []);
|
||||
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
throw new Error('network down');
|
||||
});
|
||||
assert.deepEqual(await loadWsdotSourcesFromOpenData(), []);
|
||||
});
|
||||
|
||||
test('the catalog fetch refuses a redirect and cancels its body', async (t) => {
|
||||
quiet(t);
|
||||
// A redirect is never followed, and its still-open body is cancelled.
|
||||
const seen = [];
|
||||
const state = { cancelled: false };
|
||||
t.mock.method(globalThis, 'fetch', async (url, init) => {
|
||||
seen.push([String(url), init.redirect]);
|
||||
const body = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode('{'));
|
||||
},
|
||||
cancel() {
|
||||
state.cancelled = true;
|
||||
},
|
||||
});
|
||||
return new Response(body, {
|
||||
status: 302,
|
||||
headers: { location: 'https://evil.example/Cameras.json' },
|
||||
});
|
||||
});
|
||||
assert.deepEqual(await loadWsdotSourcesFromOpenData(), []);
|
||||
assert.deepEqual(seen, [[WSDOT_CAMERAS_URL, 'manual']]);
|
||||
assert.equal(state.cancelled, true, 'the redirect body is cancelled');
|
||||
});
|
||||
|
||||
test('the catalog fetch refuses a body over the byte cap', async (t) => {
|
||||
quiet(t);
|
||||
// A valid catalog padded past the cap is refused rather than buffered.
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
const text = JSON.stringify({
|
||||
features: [wsdotFeature(1001, 47.6097, -122.3331)],
|
||||
});
|
||||
const padded = text + ' '.repeat(WSDOT_MAX_CATALOG_BYTES);
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(padded));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
{ headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
});
|
||||
assert.deepEqual(await loadWsdotSourcesFromOpenData(), []);
|
||||
});
|
||||
|
||||
test('CCTV_WSDOT_ENABLED=0 skips the WSDOT request', async (t) => {
|
||||
quiet(t);
|
||||
const sourceRoot = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'gev-cctv-catalog-'),
|
||||
);
|
||||
t.after(() => fs.rmSync(sourceRoot, { recursive: true, force: true }));
|
||||
withEnv(t, {
|
||||
CCTV_SOURCES_FILE: undefined,
|
||||
CCTV_SOURCES_JSON: undefined,
|
||||
CCTV_FORCE_AUSTIN: undefined,
|
||||
CCTV_PREFER_AUSTIN: undefined,
|
||||
CCTV_MAX_SOURCES: undefined,
|
||||
CCTV_WSDOT_ENABLED: undefined,
|
||||
CCTV_WSDOT_MAX_SOURCES: undefined,
|
||||
});
|
||||
const requested = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
requested.push(String(url));
|
||||
// Every other live pack is down; only WSDOT answers.
|
||||
return String(url) === WSDOT_CAMERAS_URL
|
||||
? cp1252Response({ features: [wsdotFeature(1001, 47.6097, -122.3331)] })
|
||||
: new Response('unavailable', { status: 503 });
|
||||
});
|
||||
|
||||
const sources = await createCctvCatalog({ sourceRoot })();
|
||||
assert.deepEqual(
|
||||
sources.map((source) => source.id),
|
||||
['wsdot-1001'],
|
||||
);
|
||||
|
||||
process.env.CCTV_WSDOT_ENABLED = '0';
|
||||
requested.length = 0;
|
||||
assert.deepEqual(await createCctvCatalog({ sourceRoot })(), []);
|
||||
assert.ok(requested.length > 0, 'the other live packs still load');
|
||||
assert.equal(
|
||||
requested.includes(WSDOT_CAMERAS_URL),
|
||||
false,
|
||||
'a disabled pack makes no request',
|
||||
);
|
||||
});
|
||||
@@ -162,6 +162,44 @@ test('malformed, duplicate, oversized, future and expired status is rejected rat
|
||||
assert.deepEqual(parseCycloneStatus({ activeStorms: [] }, NOW), []);
|
||||
});
|
||||
|
||||
test('advisories published before their nominal time are accepted, far-future ones are not', () => {
|
||||
// NHC wrote the 03:00Z advisory at 02:34Z.
|
||||
const early = Date.UTC(2026, 8, 16, 2, 34);
|
||||
assert.equal(parseCycloneStatus(status(), early)[0].issuedAt, TIME);
|
||||
assert.throws(
|
||||
() => parseCycloneStatus(status(), Date.UTC(2026, 8, 16, 1, 25)),
|
||||
/invalid_cyclone_data/,
|
||||
);
|
||||
});
|
||||
|
||||
test('advisory and position times are bounded at 90 minutes ahead and 12 hours old', () => {
|
||||
const MINUTE = 60_000;
|
||||
const at = (offsetMs) => new Date(NOW + offsetMs).toISOString();
|
||||
const fields = {
|
||||
issuedAt: (value) =>
|
||||
status({
|
||||
forecastAdvisory: {
|
||||
advNum: '010',
|
||||
issuance: value,
|
||||
url: 'https://www.nhc.noaa.gov/text/MIATCMEP5.shtml',
|
||||
},
|
||||
}),
|
||||
positionAt: (value) => status({ lastUpdate: value }),
|
||||
};
|
||||
for (const [field, payload] of Object.entries(fields)) {
|
||||
for (const offset of [90 * MINUTE, -12 * 60 * MINUTE]) {
|
||||
const [storm] = parseCycloneStatus(payload(at(offset)), NOW);
|
||||
assert.equal(storm[field], at(offset), `${field} ${offset}`);
|
||||
}
|
||||
for (const offset of [90 * MINUTE + 1, -12 * 60 * MINUTE - 1])
|
||||
assert.throws(
|
||||
() => parseCycloneStatus(payload(at(offset)), NOW),
|
||||
/invalid_cyclone_data/,
|
||||
`${field} ${offset}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('coherent geometry is retained with holes/dateline while older advisory geometry is omitted', () => {
|
||||
const storms = parseCycloneStatus(status(), NOW);
|
||||
const parts = collections();
|
||||
|
||||
+52
-1
@@ -169,6 +169,13 @@ export const DATA_CREDITS = [
|
||||
},
|
||||
OSM_CREDIT,
|
||||
OPENMAPTILES_CREDIT,
|
||||
{
|
||||
key: 'eccc-aqhi',
|
||||
html:
|
||||
'Air quality (AQHI): Environment and Climate Change Canada — MSC GeoMet ' +
|
||||
'(<a href="https://eccc-msc.github.io/open-data/" target="_blank" rel="noopener">eccc-msc.github.io/open-data</a>) ' +
|
||||
'· <a href="https://eccc-msc.github.io/open-data/licence/readme_en/" target="_blank" rel="noopener">ECCC Data Servers End-use Licence</a>',
|
||||
},
|
||||
{
|
||||
key: 'overture-military-names',
|
||||
html: 'Military area names: <a href="https://overturemaps.org" target="_blank" rel="noopener">Overture Maps Foundation</a> (ODbL)',
|
||||
@@ -241,12 +248,38 @@ export const DATA_CREDITS = [
|
||||
'<a href="https://www.digitraffic.fi/en/" target="_blank" rel="noopener">digitraffic.fi</a>, ' +
|
||||
'license CC BY 4.0',
|
||||
},
|
||||
{
|
||||
key: 'lithuania-cctv',
|
||||
html:
|
||||
// Opt-in: the camera endpoints are the eismoinfo.lt website backend, not
|
||||
// among the services Via Lietuva's open-data statement lists (its
|
||||
// conditions: attribution, reuse on the same terms).
|
||||
'Road-camera stills (Lithuania, when enabled): ' +
|
||||
'<a href="https://eismoinfo.lt/" target="_blank" rel="noopener">Via Lietuva / Eismoinfo</a>; periodically updated images',
|
||||
},
|
||||
{
|
||||
key: 'calgary-cctv',
|
||||
html:
|
||||
'Traffic cameras (Calgary): contains information licensed under the ' +
|
||||
'<a href="https://data.calgary.ca/stories/s/Open-Calgary-Terms-of-Use/u45n-7awa" target="_blank" rel="noopener">Open Government Licence – City of Calgary</a>',
|
||||
},
|
||||
{
|
||||
key: 'catalonia-cctv',
|
||||
html:
|
||||
'CCTV cameras & frames (Catalonia): ' +
|
||||
'<a href="https://transit.gencat.cat/" target="_blank" rel="noopener">Servei Català de Trànsit</a> ' +
|
||||
'(Llicència oberta d’ús d’informació - Catalunya); some cameras are supplied by partners ' +
|
||||
'(Ajuntament de Barcelona, Ajuntament de Terrassa), each named in the CCTV panel',
|
||||
},
|
||||
{
|
||||
key: 'kingcounty-cctv',
|
||||
html:
|
||||
'CCTV cameras & frames (King County, WA): King County Road Services — ' +
|
||||
'<a href="https://kingcounty.gov/en/dept/local-services/roads" target="_blank" rel="noopener">kingcounty.gov</a>, ' +
|
||||
'per the ' +
|
||||
'<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: 'vegvesen-cctv',
|
||||
html:
|
||||
@@ -263,9 +296,17 @@ export const DATA_CREDITS = [
|
||||
'<a href="https://webcams.nyctmc.org/" target="_blank" rel="noopener">NYC DOT Traffic Management Center</a>. ' +
|
||||
NYC_DOT_DISCLAIMER,
|
||||
},
|
||||
{
|
||||
key: 'toronto-cctv',
|
||||
html:
|
||||
'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: 'gbfs',
|
||||
html: 'Bikeshare availability: GBFS operator feeds (e.g. Austin BCycle)',
|
||||
html:
|
||||
'Bikeshare availability: GBFS operator feeds ' +
|
||||
'(e.g. Austin BCycle; Bike Share Toronto, operated by the Toronto Parking Authority; Bike Itaú, operated by Tembici)',
|
||||
},
|
||||
{
|
||||
key: 'urbansharing-gbfs',
|
||||
@@ -364,6 +405,16 @@ export const DATA_CREDITS = [
|
||||
'<a href="https://www2.gov.bc.ca/gov/content/data/open-data/open-government-licence-bc" target="_blank" rel="noopener">Open Government Licence – British Columbia</a>. ' +
|
||||
'Some cameras are supplied by partners (TransLink, the City of Vancouver, the City of Surrey, Parks Canada and others); each names its provider in the CCTV panel.',
|
||||
},
|
||||
{
|
||||
key: 'wsdot-cctv',
|
||||
html:
|
||||
'CCTV cameras & frames (Washington State): ' +
|
||||
'<a href="https://wsdot.wa.gov/" target="_blank" rel="noopener">Washington State Department of Transportation</a> ' +
|
||||
'(<a href="https://wsdot.com/travel/real-time/cameras" target="_blank" rel="noopener">Travel Center</a>), ' +
|
||||
'used per the ' +
|
||||
'<a href="https://wsdot.wa.gov/about/policies/travel-information-disclaimer" target="_blank" rel="noopener">WSDOT travel information disclaimer</a> ' +
|
||||
'(low-volume public feed; catalog cached, frames proxied on demand)',
|
||||
},
|
||||
{
|
||||
key: 'tallinn-cctv',
|
||||
html:
|
||||
|
||||
@@ -4,7 +4,7 @@ const GBFS_ALLOWED_HOSTS = new Set([
|
||||
'gbfs.bluebikes.com',
|
||||
'gbfs.bcycle.com',
|
||||
'gbfs.biketownpdx.com',
|
||||
'gbfs.cogobikeshare.com',
|
||||
'reddybikeshare.socialbicycles.com',
|
||||
'austin.publicbikesystem.net',
|
||||
'hon.publicbikesystem.net',
|
||||
'chat.publicbikesystem.net',
|
||||
|
||||
@@ -582,6 +582,11 @@ export const LAYER_STATE_TOKEN_RESERVATIONS =
|
||||
* owns stable URL ordering.
|
||||
*/
|
||||
export const LAYER_STATE_REGISTRY = Object.freeze([
|
||||
Object.freeze({
|
||||
id: 'air-quality',
|
||||
token: '4',
|
||||
disposition: 'enabled-only',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'ais-live-vessels',
|
||||
token: 'a',
|
||||
|
||||
@@ -202,8 +202,8 @@ function encode(state) {
|
||||
|
||||
test('production registry is exact, canonical, and rejects incomplete contracts', async () => {
|
||||
assert.equal(validateLayerStateRegistry(), true);
|
||||
assert.equal(REGISTERED_LAYER_IDS.length, 30);
|
||||
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 30);
|
||||
assert.equal(REGISTERED_LAYER_IDS.length, 31);
|
||||
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 31);
|
||||
assert.ok(REGISTERED_LAYER_IDS.includes('transit'));
|
||||
assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort());
|
||||
assert.deepEqual(LEGACY_LAYER_STATE_TOKENS, {
|
||||
@@ -242,19 +242,19 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
|
||||
for (const [id, token] of Object.entries(LEGACY_LAYER_STATE_TOKENS)) {
|
||||
assert.equal(LAYER_STATE_TOKEN_RESERVATIONS[id], token);
|
||||
}
|
||||
assert.equal(nextLayerStateToken(), '4');
|
||||
assert.equal(nextLayerStateToken(), '5');
|
||||
assert.equal(
|
||||
nextLayerStateToken({
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
alpha: '4',
|
||||
bravo: '5',
|
||||
alpha: '5',
|
||||
bravo: '6',
|
||||
}),
|
||||
'6',
|
||||
'7',
|
||||
);
|
||||
const digitsExhausted = {
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
...Object.fromEntries(
|
||||
[...'456789'].map((digit) => [`prior-${digit}`, digit]),
|
||||
[...'56789'].map((digit) => [`prior-${digit}`, digit]),
|
||||
),
|
||||
};
|
||||
assert.equal(nextLayerStateToken(digitsExhausted), '00');
|
||||
@@ -307,8 +307,8 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
|
||||
assert.equal(
|
||||
validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
future: '4',
|
||||
next: '5',
|
||||
future: '5',
|
||||
next: '6',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
@@ -318,7 +318,7 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
future: '00',
|
||||
}),
|
||||
/next free token 4/,
|
||||
/next free token 5/,
|
||||
);
|
||||
const beforeLastDigit = { ...digitsExhausted };
|
||||
delete beforeLastDigit['prior-9'];
|
||||
@@ -341,10 +341,10 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
|
||||
assert.throws(
|
||||
() =>
|
||||
validateLayerStateAllocations(
|
||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '4' },
|
||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '4', competing: '4' },
|
||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '5' },
|
||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '5', competing: '5' },
|
||||
),
|
||||
/next free token 5/,
|
||||
/next free token 6/,
|
||||
);
|
||||
assert.throws(
|
||||
() => validateLayerStateAllocations({ future: '00' }, { future: '01' }),
|
||||
|
||||
@@ -128,17 +128,23 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
|
||||
{ ...parseLayerStateTokenReservations(reservationRows) },
|
||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
|
||||
);
|
||||
assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 30);
|
||||
assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 31);
|
||||
assert.deepEqual(
|
||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
|
||||
{
|
||||
...LEGACY_LAYER_STATE_TOKENS,
|
||||
'street-level': '0',
|
||||
'gnss-integrity': '3',
|
||||
'air-quality': '4',
|
||||
},
|
||||
);
|
||||
assert.throws(
|
||||
() => parseLayerStateTokenReservations(reservationRows.slice(1)),
|
||||
() =>
|
||||
parseLayerStateTokenReservations(
|
||||
// Drop a legacy row by id; the first row sorts as air-quality, which
|
||||
// is not a legacy reservation.
|
||||
reservationRows.filter(([id]) => id !== 'ais-live-vessels'),
|
||||
),
|
||||
/Missing or changed legacy/,
|
||||
);
|
||||
assert.throws(
|
||||
@@ -155,9 +161,9 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
|
||||
/Duplicate layer-state token reservation/,
|
||||
);
|
||||
assert.equal(
|
||||
parseLayerStateTokenReservations([...reservationRows, ['future', '4']])
|
||||
parseLayerStateTokenReservations([...reservationRows, ['future', '5']])
|
||||
.future,
|
||||
'4',
|
||||
'5',
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
@@ -175,14 +181,14 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
|
||||
|
||||
test('published retired digits and competing PRs advance the merge-time allocation', () => {
|
||||
withPublishedBase(
|
||||
{ rows: [...reservationRows, ['retired-layer', '4']] },
|
||||
{ rows: [...reservationRows, ['retired-layer', '5']] },
|
||||
(cwd) => {
|
||||
const published = readPublishedLayerStateReservations('HEAD', cwd);
|
||||
assert.equal(nextLayerStateToken(published), '5');
|
||||
assert.equal(nextLayerStateToken(published), '6');
|
||||
assert.equal(
|
||||
validateLayerStateAllocations(published, {
|
||||
...published,
|
||||
newcomer: '5',
|
||||
newcomer: '6',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
@@ -190,15 +196,15 @@ test('published retired digits and competing PRs advance the merge-time allocati
|
||||
() =>
|
||||
validateLayerStateAllocations(published, {
|
||||
...published,
|
||||
competing: '4',
|
||||
competing: '5',
|
||||
}),
|
||||
/next free token 5/,
|
||||
/next free token 6/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
validateLayerStateAllocations(published, {
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
newcomer: '4',
|
||||
newcomer: '5',
|
||||
}),
|
||||
/changed or removed: retired-layer/,
|
||||
);
|
||||
@@ -206,8 +212,8 @@ test('published retired digits and competing PRs advance the merge-time allocati
|
||||
);
|
||||
});
|
||||
|
||||
test('PR B manually replaces provisional 4 with 5 after PR A publishes 4', () => {
|
||||
withPublishedBase({ rows: [...reservationRows, ['pr-a', '4']] }, (cwd) => {
|
||||
test('PR B manually replaces provisional 5 with 6 after PR A publishes 5', () => {
|
||||
withPublishedBase({ rows: [...reservationRows, ['pr-a', '5']] }, (cwd) => {
|
||||
mkdirSync(path.join(cwd, 'scripts'));
|
||||
const fixtureChecker = path.join(
|
||||
cwd,
|
||||
@@ -228,8 +234,8 @@ test('PR B manually replaces provisional 4 with 5 after PR A publishes 4', () =>
|
||||
|
||||
writeCodecFixture(
|
||||
cwd,
|
||||
[...reservationRows, ['pr-b', '4']],
|
||||
[{ id: 'pr-b', token: '4' }],
|
||||
[...reservationRows, ['pr-b', '5']],
|
||||
[{ id: 'pr-b', token: '5' }],
|
||||
);
|
||||
const beforeRebase = runCheck();
|
||||
assert.equal(beforeRebase.status, 1);
|
||||
@@ -237,10 +243,10 @@ test('PR B manually replaces provisional 4 with 5 after PR A publishes 4', () =>
|
||||
|
||||
writeCodecFixture(
|
||||
cwd,
|
||||
[...reservationRows, ['pr-a', '4'], ['pr-b', '4']],
|
||||
[...reservationRows, ['pr-a', '5'], ['pr-b', '5']],
|
||||
[
|
||||
{ id: 'pr-a', token: '4' },
|
||||
{ id: 'pr-b', token: '4' },
|
||||
{ id: 'pr-a', token: '5' },
|
||||
{ id: 'pr-b', token: '5' },
|
||||
],
|
||||
);
|
||||
const staleRebase = runCheck();
|
||||
@@ -248,16 +254,16 @@ test('PR B manually replaces provisional 4 with 5 after PR A publishes 4', () =>
|
||||
assert.match(staleRebase.stderr, /Duplicate layer-state token reservation/);
|
||||
|
||||
const published = readPublishedLayerStateReservations('HEAD', cwd);
|
||||
assert.equal(nextLayerStateToken(published), '5');
|
||||
const validBaselineRows = [...reservationRows, ['pr-a', '4']];
|
||||
writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '4' }]);
|
||||
assert.equal(nextLayerStateToken(published), '6');
|
||||
const validBaselineRows = [...reservationRows, ['pr-a', '5']];
|
||||
writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '5' }]);
|
||||
const helper = spawnSync(
|
||||
process.execPath,
|
||||
[realpathSync(fixtureNext), 'pr-b'],
|
||||
{ cwd, encoding: 'utf8' },
|
||||
);
|
||||
assert.equal(helper.status, 0, helper.stderr);
|
||||
assert.match(helper.stdout, /pr-b: 5/);
|
||||
assert.match(helper.stdout, /pr-b: 6/);
|
||||
assert.deepEqual(
|
||||
JSON.parse(readFileSync(path.join(cwd, LEDGER_PATH), 'utf8')),
|
||||
validBaselineRows,
|
||||
@@ -265,20 +271,20 @@ test('PR B manually replaces provisional 4 with 5 after PR A publishes 4', () =>
|
||||
);
|
||||
writeCodecFixture(
|
||||
cwd,
|
||||
[...reservationRows, ['pr-a', '4'], ['pr-b', '5']],
|
||||
[...reservationRows, ['pr-a', '5'], ['pr-b', '6']],
|
||||
[
|
||||
{ id: 'pr-a', token: '4' },
|
||||
{ id: 'pr-b', token: '5' },
|
||||
{ id: 'pr-a', token: '5' },
|
||||
{ id: 'pr-b', token: '6' },
|
||||
],
|
||||
);
|
||||
const corrected = runCheck();
|
||||
assert.equal(corrected.status, 0, corrected.stderr);
|
||||
assert.match(corrected.stdout, /31 published, 1 new/);
|
||||
assert.match(corrected.stdout, /32 published, 1 new/);
|
||||
});
|
||||
});
|
||||
|
||||
test('allocation batches cross the last digit and base-36 pair boundaries in order', () => {
|
||||
const digitRows = [...'456789'].map((digit) => [`retired-${digit}`, digit]);
|
||||
const digitRows = [...'56789'].map((digit) => [`retired-${digit}`, digit]);
|
||||
const allDigits = parseLayerStateTokenReservations([
|
||||
...reservationRows,
|
||||
...digitRows,
|
||||
@@ -330,7 +336,7 @@ test('allocation batches cross the last digit and base-36 pair boundaries in ord
|
||||
test('an isolated valid two-character fixture round-trips an l field beyond the old 64-character cap', async () => {
|
||||
const cwd = mkdtempSync(path.join(tmpdir(), 'gev-layer-token-width-'));
|
||||
try {
|
||||
const priorDigits = [...'456789'].map((digit) => [
|
||||
const priorDigits = [...'56789'].map((digit) => [
|
||||
`qa-prior-digit-${digit}`,
|
||||
digit,
|
||||
]);
|
||||
@@ -367,7 +373,7 @@ test('an isolated valid two-character fixture round-trips an l field beyond the
|
||||
const params = new URLSearchParams([['v', '2']]);
|
||||
codec.encodeLayerStateParams(params, state);
|
||||
assert.ok(params.get('l').length > 64, 'fixture must cross the old cap');
|
||||
assert.equal(params.get('l').length, 71);
|
||||
assert.equal(params.get('l').length, 73);
|
||||
const restored = codec.decodeLayerStateParams(params);
|
||||
assert.deepEqual(restored?.enabledLayerIds, expectedLayerIds);
|
||||
assert.deepEqual(restored?.options, state.options);
|
||||
@@ -385,7 +391,7 @@ test('checker reads a complete future base ledger and rejects retired-token reus
|
||||
},
|
||||
(cwd) => {
|
||||
const published = readPublishedLayerStateReservations('HEAD', cwd);
|
||||
assert.equal(Object.keys(published).length, 31);
|
||||
assert.equal(Object.keys(published).length, 32);
|
||||
assert.equal(published['retired-layer'], '00');
|
||||
assert.throws(
|
||||
() =>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
[
|
||||
["air-quality", "4"],
|
||||
["ais-live-vessels", "a"],
|
||||
["alpr-cameras", "p"],
|
||||
["bhote-koshi-2026", "h"],
|
||||
|
||||
+498
-7
@@ -115,6 +115,265 @@ function metroTransitRouteMode(routeId) {
|
||||
return 'bus';
|
||||
}
|
||||
|
||||
/**
|
||||
* TTC BusTime is the SURFACE feed: buses and streetcars, never the subway.
|
||||
* Streetcar routes are the 500-series, plus the four Blue Night routes that
|
||||
* run on streetcar trackage (301 Queen, 304 King, 306 Carlton, 310 Spadina);
|
||||
* the rest of the 300-series is night buses.
|
||||
* @param {string|null} routeId
|
||||
* @returns {string}
|
||||
*/
|
||||
function ttcRouteMode(routeId) {
|
||||
if (!routeId) return 'unknown';
|
||||
if (/^5\d\d$/.test(routeId)) return 'tram';
|
||||
if (/^(301|304|306|310)$/.test(routeId)) return 'tram';
|
||||
return 'bus';
|
||||
}
|
||||
|
||||
/**
|
||||
* The fleet number settles TTC's mode before the route does: buses replace
|
||||
* streetcars on 5xx routes during track work, and streetcars running out of
|
||||
* service report no route at all. Flexity streetcars are numbered 4400–4699;
|
||||
* every other TTC number is a bus.
|
||||
* @param {string|null} vehicleId GTFS-RT vehicle id (the fleet number).
|
||||
* @returns {string}
|
||||
*/
|
||||
function ttcVehicleMode(vehicleId) {
|
||||
if (!/^\d{4}$/.test(String(vehicleId ?? ''))) return 'unknown';
|
||||
const fleet = Number(vehicleId);
|
||||
return fleet >= 4400 && fleet < 4700 ? 'tram' : 'bus';
|
||||
}
|
||||
|
||||
/**
|
||||
* Guelph Transit's realtime feed publishes the internal database key of a
|
||||
* route (`2991`), not the number on the front of the bus (`1`). Without this
|
||||
* table the detection label reads "Route 2991", which is not what anyone at
|
||||
* the stop calls it. Route ids and short names are from the City of Guelph's
|
||||
* own published GTFS `routes.txt` (see DATA_SOURCES.md); an id missing here
|
||||
* falls through unchanged, so a route added later degrades to its raw id
|
||||
* rather than disappearing.
|
||||
*/
|
||||
const GUELPH_ROUTE_NAMES = Object.freeze({
|
||||
2991: '1',
|
||||
2992: '10',
|
||||
2993: '11',
|
||||
2994: '12',
|
||||
2995: '13',
|
||||
2996: '14',
|
||||
2997: '15',
|
||||
2998: '16',
|
||||
2999: '17',
|
||||
3000: '18',
|
||||
3001: '19',
|
||||
3002: '2',
|
||||
3003: '20',
|
||||
3004: '3',
|
||||
3005: '4',
|
||||
3006: '5',
|
||||
3007: '50 U',
|
||||
3008: '52 U',
|
||||
3009: '56 U',
|
||||
3010: '58 U',
|
||||
3011: '6',
|
||||
3012: '7',
|
||||
3013: '8',
|
||||
3014: '9',
|
||||
3015: '98',
|
||||
3016: '99',
|
||||
3017: '99Lite',
|
||||
3018: 'BREW',
|
||||
3019: 'EdiCol',
|
||||
3023: 'IroHarv',
|
||||
3030: 'Sun LN',
|
||||
3032: 'WHanSco',
|
||||
3039: 'ZehCha',
|
||||
});
|
||||
|
||||
/**
|
||||
* Guelph route id to the number a rider would say.
|
||||
* @param {string|null} routeId
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function guelphRouteLabel(routeId) {
|
||||
return GUELPH_ROUTE_NAMES[routeId] || routeId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hamilton publishes an internal key for every route: not one of HSR's 80 route
|
||||
* ids equals the number on the bus, so an untranslated feed reads as
|
||||
* "Route 5783" city-wide. Two generations of id are live at once — `5688` and
|
||||
* `5780` are both the 2 BARTON — and both are listed, because both arrive.
|
||||
*
|
||||
* Numbers are unpadded to match the rest of the registry (`routes.txt` says
|
||||
* `02`; a rider says 2). The seasonal and event shuttles publish a name rather
|
||||
* than a number — `TC230`, `PEACH`, `ROCKTON` — and those are already what a
|
||||
* rider would say, so they survive as published. Ids and names are from the
|
||||
* City's own published GTFS `routes.txt`, under the same terms as the feed.
|
||||
*/
|
||||
const HAMILTON_ROUTE_NAMES = Object.freeze({
|
||||
5687: '1',
|
||||
5688: '2',
|
||||
5689: '3',
|
||||
5690: '4',
|
||||
5691: '5',
|
||||
5692: '6',
|
||||
5693: '7',
|
||||
5694: '8',
|
||||
5695: '9',
|
||||
5696: '10',
|
||||
5697: '11',
|
||||
5698: '12',
|
||||
5699: '16',
|
||||
5700: '18',
|
||||
5701: '20',
|
||||
5702: '21',
|
||||
5703: '22',
|
||||
5704: '23',
|
||||
5705: '24',
|
||||
5706: '25',
|
||||
5707: '26',
|
||||
5708: '27',
|
||||
5709: '33',
|
||||
5710: '34',
|
||||
5711: '35',
|
||||
5712: '41',
|
||||
5713: '42',
|
||||
5714: '43',
|
||||
5715: '44',
|
||||
5716: '51',
|
||||
5717: '52',
|
||||
5718: '55',
|
||||
5719: '56',
|
||||
5720: '99',
|
||||
5731: 'X-IND',
|
||||
5780: '2',
|
||||
5781: '3',
|
||||
5782: '4',
|
||||
5783: '5',
|
||||
5784: '6',
|
||||
5785: '7',
|
||||
5786: '8',
|
||||
5787: '9',
|
||||
5789: '11',
|
||||
5790: '12',
|
||||
5791: '16',
|
||||
5792: '18',
|
||||
5793: '20',
|
||||
5794: '21',
|
||||
5795: '22',
|
||||
5796: '23',
|
||||
5797: '24',
|
||||
5798: '25',
|
||||
5799: '26',
|
||||
5800: '27',
|
||||
5801: '33',
|
||||
5802: '34',
|
||||
5803: '35',
|
||||
5805: '42',
|
||||
5806: '43',
|
||||
5807: '44',
|
||||
5808: '51',
|
||||
5809: '52',
|
||||
5811: '56',
|
||||
5812: '99',
|
||||
5820: 'TC230',
|
||||
5823: 'X IND',
|
||||
5824: 'TC-700',
|
||||
5825: 'TC-300',
|
||||
5826: 'CAN',
|
||||
5827: 'PEACH',
|
||||
5828: '1',
|
||||
5829: '10',
|
||||
5830: '41',
|
||||
5831: '55',
|
||||
5832: 'TC300',
|
||||
5833: 'TC700',
|
||||
5834: 'TC730',
|
||||
5835: 'ANCFAIR',
|
||||
5836: 'ROCKTON',
|
||||
});
|
||||
|
||||
/**
|
||||
* Hamilton route id to the number a rider would say.
|
||||
* @param {string|null} routeId
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function hamiltonRouteLabel(routeId) {
|
||||
return HAMILTON_ROUTE_NAMES[routeId] || routeId;
|
||||
}
|
||||
|
||||
/**
|
||||
* YRT publishes Viva bus rapid transit by route id (`601`), while every rider,
|
||||
* station sign and map calls it "Viva Blue". Plain local routes are already the
|
||||
* number on the bus and are left alone, unpadded: `105` reads better than the
|
||||
* `005` of `routes.txt`.
|
||||
*
|
||||
* Branches are the exception. YRT suffixes a two-digit branch onto the base
|
||||
* route — the scheme Viva's own `60102` follows — so a branch id reaches a
|
||||
* rider as "8301" unless it is translated. Names and colours are from York
|
||||
* Region's own published GTFS `routes.txt`; Viva Green (604) and Pink (606)
|
||||
* are not in current service and so are absent here too.
|
||||
*
|
||||
* Only branches observed live AND published in `routes.txt` are listed. `9101`
|
||||
* runs but is unpublished, so it is deliberately missing: the pattern suggests
|
||||
* "91A", and suggesting is not knowing.
|
||||
*/
|
||||
const YRT_ROUTE_NAMES = Object.freeze({
|
||||
601: 'Viva Blue',
|
||||
60102: 'Viva Blue B',
|
||||
603: 'Viva Purple',
|
||||
60301: 'Viva Purple A',
|
||||
605: 'Viva Orange',
|
||||
607: 'Viva Yellow',
|
||||
8301: '83A',
|
||||
9002: '90B',
|
||||
10702: '107B',
|
||||
});
|
||||
|
||||
/**
|
||||
* YRT route id to the name a rider would say.
|
||||
* @param {string|null} routeId
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function yrtRouteLabel(routeId) {
|
||||
return YRT_ROUTE_NAMES[routeId] || routeId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Burlington publishes the internal key of a route, and the keys are a trap:
|
||||
* `351` is route 1 while `3510` is route 10, so trimming a `35` prefix gets
|
||||
* the wrong answer on half the network. The join is the only correct route.
|
||||
* Ids and numbers are from the City's own published GTFS `routes.txt`, which
|
||||
* sits in the same directory as the realtime feed under the same terms.
|
||||
*/
|
||||
const BURLINGTON_ROUTE_NAMES = Object.freeze({
|
||||
351: '1',
|
||||
352: '2',
|
||||
353: '3',
|
||||
354: '4',
|
||||
356: '6',
|
||||
3510: '10',
|
||||
3511: '11',
|
||||
3512: '12',
|
||||
3525: '25',
|
||||
3548: '48',
|
||||
3550: '50',
|
||||
3551: '51',
|
||||
3552: '52',
|
||||
3580: '80',
|
||||
3581: '81',
|
||||
3587: '87',
|
||||
});
|
||||
|
||||
/**
|
||||
* Burlington route id to the number a rider would say.
|
||||
* @param {string|null} routeId
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function burlingtonRouteLabel(routeId) {
|
||||
return BURLINGTON_ROUTE_NAMES[routeId] || routeId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry of feeds. Order is presentation order in the stats/credit text.
|
||||
* `loadRadiusKm` is the distance from `center` inside which the feed is polled.
|
||||
@@ -270,6 +529,204 @@ export const TRANSIT_FEED_REGISTRY = Object.freeze([
|
||||
}),
|
||||
defaultMode: 'bus',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'ttc-toronto',
|
||||
name: 'TTC',
|
||||
operator: 'Toronto Transit Commission',
|
||||
region: 'Toronto, ON',
|
||||
center: Object.freeze({ lat: 43.6532, lon: -79.3832 }),
|
||||
loadRadiusKm: 45,
|
||||
url: 'https://bustime.ttc.ca/gtfsrt/vehicles',
|
||||
license: 'Open Government Licence \u2013 Toronto',
|
||||
licenseUrl: 'https://open.toronto.ca/open-data-licence/',
|
||||
attribution:
|
||||
'Toronto Transit Commission \u2014 City of Toronto Open Data (Open Government Licence \u2013 Toronto)',
|
||||
defaultEnabled: true,
|
||||
terms: Object.freeze({
|
||||
quote:
|
||||
'The Information Provider grants you a worldwide, royalty-free, perpetual, non-exclusive licence to use the Information, including for commercial purposes ... Acknowledge the source of the Information by including any attribution statement specified by the Information Provider.',
|
||||
note: 'The City publishes this endpoint from the TTC BusTime NVAS catalogue entry, whose own licence field reads \"License not specified\"; the quote above is the portal-wide Open Government Licence \u2013 Toronto the City applies to its open data, so the credit carries the licence name the way the Metro Transit entry carries its public-domain note. BusTime is the surface system: buses and streetcars report real positions, the subway reports none, so Lines 1/2/4 are simply absent rather than inferred. The endpoint sends no ETag or Last-Modified, so conditional requests fall through to the proxy snapshot TTL.',
|
||||
}),
|
||||
defaultMode: 'bus',
|
||||
// Toronto rides streetcars. The vehicles are the shared `tram` mode and
|
||||
// share its silhouette and speed limits; only the word a reader sees
|
||||
// changes, because "tram" is not what anyone at Queen and Spadina says.
|
||||
modeWords: Object.freeze({
|
||||
tram: Object.freeze({ word: 'Streetcar', abbr: 'STREETCAR' }),
|
||||
}),
|
||||
routeMode: ttcRouteMode,
|
||||
vehicleMode: ttcVehicleMode,
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'miway-mississauga',
|
||||
name: 'MiWay',
|
||||
operator: 'MiWay (City of Mississauga)',
|
||||
region: 'Mississauga, ON',
|
||||
center: Object.freeze({ lat: 43.589, lon: -79.6441 }),
|
||||
loadRadiusKm: 30,
|
||||
url: 'https://www.miapp.ca/GTFS_RT/Vehicle/VehiclePositions.pb',
|
||||
license: 'City of Mississauga Open Data Terms of Use',
|
||||
licenseUrl:
|
||||
'http://www5.mississauga.ca/research_catalogue/CityofMississauga_TermsofUse.pdf',
|
||||
attribution: 'MiWay \u2014 City of Mississauga Open Data',
|
||||
defaultEnabled: true,
|
||||
terms: Object.freeze({
|
||||
quote:
|
||||
'The City of Mississauga (the City) now grants you a world-wide, royalty-free, non-exclusive, revocable licence to use, modify, and distribute the Datasets in all current and future media and formats for any lawful purpose.',
|
||||
note: 'Two conditions shape how this ships. The licence is REVOCABLE, and it asks that anyone who provides access to the data pass on the Terms of Use URL \u2014 so `licenseUrl` points at the terms themselves rather than at a catalogue page, and the attribution popover carries the link. Credit is optional here ("you are not required to credit the City"), and is given anyway as a courtesy, the way the other municipal feeds are.',
|
||||
}),
|
||||
defaultMode: 'bus',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'hsr-hamilton',
|
||||
name: 'Hamilton HSR',
|
||||
operator: 'Hamilton Street Railway (City of Hamilton)',
|
||||
region: 'Hamilton, ON',
|
||||
center: Object.freeze({ lat: 43.2557, lon: -79.8711 }),
|
||||
loadRadiusKm: 30,
|
||||
url: 'https://opendata.hamilton.ca/GTFS-RT/GTFS_VehiclePositions.pb',
|
||||
license: 'City of Hamilton Open Data Licence',
|
||||
licenseUrl:
|
||||
'https://www.hamilton.ca/city-initiatives/strategies-actions/open-data-licence-terms-and-conditions',
|
||||
attribution:
|
||||
'Contains public sector Data made available under the City of Hamilton\u2019s Open Data Licence',
|
||||
defaultEnabled: true,
|
||||
terms: Object.freeze({
|
||||
quote:
|
||||
'The City of Hamilton grants you a worldwide, royalty-free, perpetual, non-exclusive licence to use the Data, including for commercial purposes ... you must acknowledge the source of the Data by including the following attribution statement: \u201cContains public sector Data made available under the City of Hamilton\u2019s Open Data Licence\u201d.',
|
||||
note: 'The attribution wording is prescribed by the licence, so it is used verbatim as the credit rather than paraphrased to an operator name. Names, crests, logos and marks are excluded from the grant, so the credit stays text.',
|
||||
}),
|
||||
defaultMode: 'bus',
|
||||
routeLabel: hamiltonRouteLabel,
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'durham-region',
|
||||
name: 'Durham Region Transit',
|
||||
operator: 'Durham Region Transit (Regional Municipality of Durham)',
|
||||
region: 'Durham Region, ON',
|
||||
center: Object.freeze({ lat: 43.9496, lon: -78.9365 }),
|
||||
loadRadiusKm: 45,
|
||||
url: 'https://drtonline.durhamregiontransit.com/gtfsrealtime/VehiclePositions',
|
||||
license: 'Region of Durham Open Data Licence v.1.0',
|
||||
licenseUrl: 'https://www.durham.ca/en/regional-government/open-data.aspx',
|
||||
attribution:
|
||||
"Contains public sector information made available under The Regional Municipality of Durham's Open Data Licence",
|
||||
defaultEnabled: true,
|
||||
terms: Object.freeze({
|
||||
quote:
|
||||
'Durham Region grants you a worldwide, royalty-free, perpetual, non-exclusive licence to use the information ... You are free to: Copy, publish, distribute and transmit the information ... Use the information commercially.',
|
||||
note: 'The licence link the open-data catalogue publishes for this feed (OpenDataLicenceAgreement.pdf) is dead after a site relaunch; `licenseUrl` points at the live Open Data page carrying the same licence v.1.0 text, which is where the quote above was read. Credit is optional and the suggested statement is used verbatim.',
|
||||
}),
|
||||
defaultMode: 'bus',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'guelph-transit',
|
||||
name: 'Guelph Transit',
|
||||
operator: 'Guelph Transit (City of Guelph)',
|
||||
region: 'Guelph, ON',
|
||||
center: Object.freeze({ lat: 43.5325, lon: -80.2482 }),
|
||||
loadRadiusKm: 15,
|
||||
url: 'https://glphprdtmgtfs.glphtrpcloud.com/tmgtfsrealtimewebservice/vehicle/vehiclepositions.pb',
|
||||
license: 'Open Government Licence \u2013 City of Guelph (v2.0)',
|
||||
licenseUrl: 'https://explore.guelph.ca/pages/open-data-license',
|
||||
attribution:
|
||||
'Contains information licensed under the Open Government Licence \u2013 City of Guelph',
|
||||
defaultEnabled: true,
|
||||
terms: Object.freeze({
|
||||
quote:
|
||||
'The Information Provider grants you a worldwide, royalty-free, perpetual, non-exclusive licence to use the Information including for commercial purposes subject to the terms below. ... Acknowledge the source of the Information by including any attribution statement specified by the Information Provider(s) and, where possible, provide a link to this licence.',
|
||||
note: 'The endpoint sits on a vendor host, so provenance matters: the City publishes this exact URL on its own open-data developer page, https://explore.guelph.ca/pages/transit-gtfs-data, under an open-data initiative heading (that page is JavaScript-rendered; its readable source is the ArcGIS item JSON behind it). The licence is a verbatim OGL-Canada 2.0 derivative and the attribution wording above is its prescribed fallback, so it is used as written. The transit datasets carry a BLANK licence field in the portal catalogue; the Terms of Use supply it by default ("Except where otherwise noted this is the Open Government Licence"), the same shape of gap the TTC entry records. The Terms also publish a traffic limit \u2014 no concurrent requests, at most 5 per second \u2014 which one poll every 15 s satisfies many times over. Crests and logos are excluded from the grant, so the credit stays text.',
|
||||
}),
|
||||
defaultMode: 'bus',
|
||||
routeLabel: guelphRouteLabel,
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'kingston-transit',
|
||||
name: 'Kingston Transit',
|
||||
operator: 'Kingston Transit (The Corporation of the City of Kingston)',
|
||||
region: 'Kingston, ON',
|
||||
center: Object.freeze({ lat: 44.24, lon: -76.57 }),
|
||||
loadRadiusKm: 20,
|
||||
url: 'https://api.cityofkingston.ca/gtfs-realtime/vehicleupdates.pb',
|
||||
license: 'City of Kingston Open Data Licence 1.0',
|
||||
licenseUrl:
|
||||
'https://www.cityofkingston.ca/media/wtpgpkb0/gis_license_opendata.pdf',
|
||||
attribution:
|
||||
'Contains information licensed under the Open Data Licence \u2013 City of Kingston',
|
||||
defaultEnabled: true,
|
||||
terms: Object.freeze({
|
||||
quote:
|
||||
'The Information Provider, the City of Kingston, grants you a worldwide, royalty-free, perpetual, non-exclusive licence to use the information provided for any purpose, including for commercial purposes, subject to the terms below. ... Acknowledge the source of the information by including any attribution statement specified by the City of Kingston and, where possible, provide a link to this licence.',
|
||||
note: 'The City catalogues this exact URL as an open-data dataset, so provenance is direct rather than inferred. `licenseUrl` is the canonical location of the licence PDF; the address the catalogue prints is a legacy path that currently redirects there, and pinning the redirect target is what survives the next site relaunch \u2014 the Durham entry records what happens when it does not. Names, crests and logos are excluded from the grant, so the credit stays text. Roughly one entity in ten carries no usable position (the position field absent, occasionally a momentary 0,0 GPS dropout); both are discarded before rendering.',
|
||||
}),
|
||||
defaultMode: 'bus',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'barrie-transit',
|
||||
name: 'Barrie Transit',
|
||||
operator: 'Barrie Transit (City of Barrie)',
|
||||
region: 'Barrie, ON',
|
||||
center: Object.freeze({ lat: 44.371, lon: -79.6733 }),
|
||||
loadRadiusKm: 10,
|
||||
url: 'https://www.myridebarrie.ca/gtfs/GTFS_VehiclePositions.pb',
|
||||
license: 'Barrie Transit Data licence',
|
||||
licenseUrl:
|
||||
'https://www.barrie.ca/services-payments/transportation-parking/barrie-transit/barrie-gtfs',
|
||||
attribution:
|
||||
'Realtime vehicle data \u00a9 City of Barrie, used under the Barrie Transit Data licence',
|
||||
defaultEnabled: true,
|
||||
terms: Object.freeze({
|
||||
quote:
|
||||
'Barrie Transit hereby grants you a non-exclusive, limited and revocable rights to use, reproduce, and redistribute Barrie Transit Data (scheduled and real-time data) subject to the following terms: Barrie Transit trademarks and copyrighted materials, including any confusingly similar variants, may not be used in association with Data. ... Barrie Transit maintains title, ownership, rights and interest in and to Data.',
|
||||
note: 'The City offers this licence for acceptance behind a click-through checkbox, and the licence body also makes use itself acceptance: \u201cBy using Barrie Transit Data, you agree to be bound by these terms.\u201d This deployment\u2019s owner took that clause as sufficient on 2026-09-16 and switched the feed on; nothing gates the endpoint, which needs no key, token or cookie. Two oddities worth knowing: the licence requires NO attribution at all (the credit above is a courtesy), and its trademark clause is broad enough that the credit names the City rather than Barrie Transit. The grant is expressly REVOCABLE and unversioned, so the quote above is the text as read on 2026-09-16.',
|
||||
}),
|
||||
defaultMode: 'bus',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'yrt-york',
|
||||
name: 'YRT/Viva',
|
||||
operator:
|
||||
'York Region Transit (YRT/Viva), The Regional Municipality of York',
|
||||
region: 'York Region, ON',
|
||||
center: Object.freeze({ lat: 44.025, lon: -79.436 }),
|
||||
loadRadiusKm: 38,
|
||||
url: 'https://rtu.york.ca/gtfsrealtime/VehiclePositions',
|
||||
license: 'YRT Open Data Licence v2.0 (UK OGL-derived)',
|
||||
licenseUrl:
|
||||
'https://www.yrt.ca/en/about-us/open-data-licence-agreement.aspx',
|
||||
attribution:
|
||||
"Contains public transit Information made available under YRT's Open Data Licence",
|
||||
defaultEnabled: true,
|
||||
terms: Object.freeze({
|
||||
quote:
|
||||
'YRT grants You a worldwide, royalty-free, perpetual, non-exclusive licence to Use the Information subject to the conditions below ... Use the Information commercially ... Provide credit to YRT when using Information under this licence but are not required to. ... Please note: You must agree with and accept the above stated License Agreement in order to download YRT GTFS or real-time GTFS data.',
|
||||
note: 'The operator asks that its licence be accepted through a form (forms.yrt.ca/YRT-GTFS-Data, which collects a name, company, phone and email and issues no credential), while the licence body says acceptance is by use: \u201cYour Use of Information indicates Your acceptance of the terms and conditions below.\u201d This deployment\u2019s owner took that clause as sufficient on 2026-09-16 and switched the feed on. Nothing gates the endpoint. The credit above is the licence\u2019s own wording and must be reproduced as given; attribution is optional here, and given anyway. Logos, trademarks and crests are carved out, so the credit stays text. Two data notes: no vehicle carries a bearing, so heading comes from observed movement; and one stuck unit reported a position ten hours old, which the layer shows as a stale report rather than hides.',
|
||||
}),
|
||||
defaultMode: 'bus',
|
||||
routeLabel: yrtRouteLabel,
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'burlington-transit',
|
||||
name: 'Burlington Transit',
|
||||
operator: 'Burlington Transit (City of Burlington)',
|
||||
region: 'Burlington, ON',
|
||||
center: Object.freeze({ lat: 43.3342, lon: -79.8089 }),
|
||||
loadRadiusKm: 12,
|
||||
url: 'https://opendata.burlington.ca/gtfs-rt/GTFS_VehiclePositions.pb',
|
||||
license: 'City of Burlington Open Data Terms of Use',
|
||||
licenseUrl:
|
||||
'https://opendata.burlington.ca/opendata-terms-of-use/City%20of%20Burlington%20-%20Open%20Data%20Terms%20of%20Use.pdf',
|
||||
attribution:
|
||||
'Transit data \u00a9 City of Burlington (Burlington Transit), used under the City of Burlington Open Data Terms of Use',
|
||||
defaultEnabled: true,
|
||||
terms: Object.freeze({
|
||||
quote:
|
||||
'The Corporation of the City of Burlington (the City) now grants you a worldwide, royalty-free, non-exclusive, revocable licence to use, reproduce, modify, and distribute the datasets in all current and future media and formats for any lawful purpose. ... If you distribute or provide access to these datasets to any other person ... you agree to include a copy of, or this Uniform Resource Locator (URL) for, these Terms of Use.',
|
||||
note: 'Same 2011-era municipal template as Mississauga, and the same two consequences: the grant is REVOCABLE, and the terms have to travel with the data, so `licenseUrl` is the Terms document itself rather than a catalogue page and the attribution popover carries the link. Credit is optional here and given anyway. Crests, logos and marks are excluded and the terms forbid implying any association, so the credit stays plain text. The catalogue record for this data leads with a licence link that 404s; the URL above is the live one. Plain http:// has no listener at all \u2014 not a redirect, a hang \u2014 so the https:// URL is mandatory rather than preferred. The feed updates once a minute and answers conditional requests with an ETag.',
|
||||
}),
|
||||
defaultMode: 'bus',
|
||||
routeLabel: burlingtonRouteLabel,
|
||||
}),
|
||||
]);
|
||||
|
||||
/**
|
||||
@@ -353,14 +810,32 @@ export function transitFeedsInRange(lat, lon, slackKm = 0) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Mode for a vehicle: the feed's route hint when it has one, else its default.
|
||||
* The feed's own hint for a vehicle: its fleet number when the feed maps
|
||||
* those (`vehicleMode`), else its route (`routeMode`). Null when neither knows.
|
||||
* @param {object} feed Registry entry.
|
||||
* @param {string|null} routeId
|
||||
* @param {string|null} vehicleId
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function hintedMode(feed, routeId, vehicleId) {
|
||||
const byVehicle =
|
||||
typeof feed?.vehicleMode === 'function'
|
||||
? feed.vehicleMode(vehicleId)
|
||||
: null;
|
||||
if (byVehicle && byVehicle !== 'unknown') return byVehicle;
|
||||
return typeof feed?.routeMode === 'function' ? feed.routeMode(routeId) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mode for a vehicle: the feed's fleet or route hint when it has one, else
|
||||
* its default.
|
||||
* @param {object} feed Registry entry.
|
||||
* @param {string|null} routeId GTFS route_id from the vehicle's trip.
|
||||
* @param {string|null} [vehicleId] GTFS-RT vehicle id.
|
||||
* @returns {string} One of TRANSIT_MODES.
|
||||
*/
|
||||
export function transitModeFor(feed, routeId) {
|
||||
const hinted =
|
||||
typeof feed?.routeMode === 'function' ? feed.routeMode(routeId) : null;
|
||||
export function transitModeFor(feed, routeId, vehicleId = null) {
|
||||
const hinted = hintedMode(feed, routeId, vehicleId);
|
||||
const mode =
|
||||
hinted && hinted !== 'unknown' ? hinted : feed?.defaultMode || 'unknown';
|
||||
return TRANSIT_MODES.includes(mode) ? mode : 'unknown';
|
||||
@@ -373,16 +848,32 @@ export function transitModeFor(feed, routeId) {
|
||||
* not hold a defaulted vehicle to a bus's limits.
|
||||
* @param {object} feed Registry entry.
|
||||
* @param {string|null} routeId
|
||||
* @param {string|null} [vehicleId]
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function transitModeResolved(feed, routeId) {
|
||||
const hinted =
|
||||
typeof feed?.routeMode === 'function' ? feed.routeMode(routeId) : null;
|
||||
export function transitModeResolved(feed, routeId, vehicleId = null) {
|
||||
const hinted = hintedMode(feed, routeId, vehicleId);
|
||||
return (
|
||||
Boolean(hinted) && hinted !== 'unknown' && TRANSIT_MODES.includes(hinted)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The route as a rider would say it. Most feeds publish that already, so this
|
||||
* is identity for them; a feed that publishes internal keys supplies its own
|
||||
* `routeLabel`. Never invents a label: an id the feed does not know survives
|
||||
* unchanged, because a raw id a person can compare against the bus is more
|
||||
* use than a blank.
|
||||
* @param {object|null} feed Registry entry.
|
||||
* @param {string|null} routeId
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function transitRouteLabel(feed, routeId) {
|
||||
if (!routeId || typeof feed?.routeLabel !== 'function') return routeId;
|
||||
const named = feed.routeLabel(routeId);
|
||||
return typeof named === 'string' && named ? named : routeId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Public catalog shape served by `/api/transit/feeds` — everything the browser
|
||||
* needs to gate polling and credit the source, and nothing it could misuse.
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
transitFeedsInRange,
|
||||
transitModeFor,
|
||||
transitModeResolved,
|
||||
transitRouteLabel,
|
||||
} from './transitFeeds.js';
|
||||
|
||||
test('every registered feed is keyless, https, licensed, and uniquely identified', () => {
|
||||
@@ -239,3 +240,245 @@ test('published transit history scope agrees with the catalog capability', () =>
|
||||
/four fixes|0\.95x|guaranteed lower|one poll interval behind/,
|
||||
);
|
||||
});
|
||||
|
||||
test('the TTC feed is registered, routable, and covers Toronto', () => {
|
||||
const ttc = getTransitFeed('ttc-toronto');
|
||||
assert.equal(ttc?.url, 'https://bustime.ttc.ca/gtfsrt/vehicles');
|
||||
assert.equal(ttc.defaultEnabled, true);
|
||||
assert.match(ttc.attribution, /Toronto Transit Commission/);
|
||||
assert.equal(ttc.historyRetention, undefined, 'no proxy retention');
|
||||
assert.ok(
|
||||
transitFeedsInRange(43.6532, -79.3832).some(
|
||||
(feed) => feed.id === 'ttc-toronto',
|
||||
),
|
||||
'downtown Toronto is inside the load radius',
|
||||
);
|
||||
});
|
||||
|
||||
test('TTC route numbers tell a streetcar from a bus', () => {
|
||||
// BusTime is the surface feed: 500-series are streetcar routes and the
|
||||
// streetcar-operated Blue Night routes keep their own numbers. Everything
|
||||
// else on this feed is a bus, so the mode is established, not defaulted.
|
||||
const ttc = getTransitFeed('ttc-toronto');
|
||||
assert.equal(transitModeFor(ttc, '501'), 'tram');
|
||||
assert.equal(transitModeFor(ttc, '512'), 'tram');
|
||||
assert.equal(transitModeFor(ttc, '304'), 'tram');
|
||||
assert.equal(transitModeFor(ttc, '36'), 'bus');
|
||||
// 52 Lawrence West is a bus. Only the three-digit 500-series is streetcar,
|
||||
// so the rule must not match on a leading 5 alone.
|
||||
assert.equal(transitModeFor(ttc, '52'), 'bus');
|
||||
assert.equal(transitModeFor(ttc, '59'), 'bus');
|
||||
assert.equal(transitModeFor(ttc, '900'), 'bus');
|
||||
assert.equal(transitModeFor(ttc, '352'), 'bus');
|
||||
assert.equal(transitModeResolved(ttc, '501'), true);
|
||||
assert.equal(transitModeResolved(ttc, null), false);
|
||||
assert.equal(
|
||||
transitModeFor(ttc, null),
|
||||
'bus',
|
||||
'an unrouted vehicle falls back to the feed default, unresolved',
|
||||
);
|
||||
});
|
||||
|
||||
test('a TTC fleet number outranks the route', () => {
|
||||
// Buses replace streetcars on 5xx routes during track work, and streetcars
|
||||
// out of service report no route; the Flexity fleet is 4400–4699.
|
||||
const ttc = getTransitFeed('ttc-toronto');
|
||||
assert.equal(transitModeFor(ttc, '506', '8515'), 'bus');
|
||||
assert.equal(transitModeFor(ttc, '512', '3101'), 'bus');
|
||||
assert.equal(transitModeFor(ttc, null, '4507'), 'tram');
|
||||
assert.equal(transitModeResolved(ttc, null, '4507'), true);
|
||||
assert.equal(transitModeFor(ttc, '504', '4400'), 'tram');
|
||||
assert.equal(transitModeFor(ttc, '29', '4699'), 'tram');
|
||||
assert.equal(transitModeFor(ttc, '501', '4700'), 'bus');
|
||||
// A vehicle id that is not a fleet number leaves the route to decide.
|
||||
assert.equal(transitModeFor(ttc, '501', 'abc'), 'tram');
|
||||
assert.equal(transitModeFor(ttc, '501'), 'tram');
|
||||
});
|
||||
|
||||
test('the TTC row records that BusTime carries no subway', () => {
|
||||
const sources = readFileSync(
|
||||
new URL('../../DATA_SOURCES.md', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const row = sources.split('\n').find((line) => line.startsWith('| **TTC**'));
|
||||
assert.ok(row, 'TTC has a DATA_SOURCES row');
|
||||
assert.match(row, /Toronto/);
|
||||
assert.match(sources, /surface vehicles \(buses and streetcars\) only/);
|
||||
assert.match(sources, /Open Government Licence – Toronto/);
|
||||
});
|
||||
|
||||
test('the Golden Horseshoe neighbours are registered, keyless and routable', () => {
|
||||
const expected = {
|
||||
'miway-mississauga':
|
||||
'https://www.miapp.ca/GTFS_RT/Vehicle/VehiclePositions.pb',
|
||||
'hsr-hamilton':
|
||||
'https://opendata.hamilton.ca/GTFS-RT/GTFS_VehiclePositions.pb',
|
||||
'durham-region':
|
||||
'https://drtonline.durhamregiontransit.com/gtfsrealtime/VehiclePositions',
|
||||
};
|
||||
for (const [id, url] of Object.entries(expected)) {
|
||||
const feed = getTransitFeed(id);
|
||||
assert.ok(feed, `${id} is routable`);
|
||||
assert.equal(feed.url, url);
|
||||
assert.equal(feed.defaultEnabled, true);
|
||||
assert.equal(feed.defaultMode, 'bus');
|
||||
assert.equal(feed.historyRetention, undefined, `${id} retains nothing`);
|
||||
assert.ok(feed.terms?.quote, `${id} records the terms it ships on`);
|
||||
}
|
||||
});
|
||||
|
||||
test('each Golden Horseshoe feed covers its own city and not its neighbours', () => {
|
||||
const near = (lat, lon) => transitFeedsInRange(lat, lon).map((f) => f.id);
|
||||
// Mississauga City Centre, downtown Hamilton, Oshawa.
|
||||
assert.ok(near(43.589, -79.6441).includes('miway-mississauga'));
|
||||
assert.ok(near(43.2557, -79.8711).includes('hsr-hamilton'));
|
||||
assert.ok(near(43.8971, -78.8658).includes('durham-region'));
|
||||
// Hamilton is 60 km from Toronto: far enough that the TTC feed is not polled
|
||||
// for it, which is the whole point of a per-feed radius.
|
||||
assert.equal(near(43.2557, -79.8711).includes('ttc-toronto'), false);
|
||||
});
|
||||
|
||||
test('a bus-only feed never claims a mode its route ids cannot establish', () => {
|
||||
for (const id of ['miway-mississauga', 'hsr-hamilton', 'durham-region']) {
|
||||
const feed = getTransitFeed(id);
|
||||
assert.equal(transitModeFor(feed, '1'), 'bus');
|
||||
assert.equal(
|
||||
transitModeResolved(feed, '1'),
|
||||
false,
|
||||
`${id} defaults rather than resolves`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('every Golden Horseshoe feed is documented with its licence', () => {
|
||||
const sources = readFileSync(
|
||||
new URL('../../DATA_SOURCES.md', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
for (const name of ['MiWay', 'Hamilton HSR', 'Durham Region Transit']) {
|
||||
assert.ok(
|
||||
sources.split('\n').some((line) => line.startsWith(`| **${name}`)),
|
||||
`${name} has a DATA_SOURCES row`,
|
||||
);
|
||||
}
|
||||
assert.match(sources, /Durham Region Transit publishes no bearing/);
|
||||
});
|
||||
|
||||
test('a feed whose route ids are internal database keys can name its routes', () => {
|
||||
// Guelph's realtime feed emits `2991` where the rider sees route `1`. A feed
|
||||
// that knows the difference translates it; every other feed is unaffected.
|
||||
const guelph = getTransitFeed('guelph-transit');
|
||||
assert.equal(transitRouteLabel(guelph, '2991'), '1');
|
||||
assert.equal(transitRouteLabel(guelph, '3016'), '99');
|
||||
assert.equal(transitRouteLabel(guelph, '3007'), '50 U');
|
||||
assert.equal(transitRouteLabel(guelph, '3017'), '99Lite');
|
||||
});
|
||||
|
||||
test('an unknown or absent route id survives translation unchanged', () => {
|
||||
const guelph = getTransitFeed('guelph-transit');
|
||||
// A route added after this table was built must still render its raw id
|
||||
// rather than vanish or read as "undefined".
|
||||
assert.equal(transitRouteLabel(guelph, '4242'), '4242');
|
||||
assert.equal(transitRouteLabel(guelph, null), null);
|
||||
assert.equal(transitRouteLabel(guelph, ''), '');
|
||||
});
|
||||
|
||||
test('a feed with rider-facing route ids is left alone', () => {
|
||||
// TTC and Kingston publish the route a rider would say, so translation is
|
||||
// identity for them and no table is carried.
|
||||
assert.equal(transitRouteLabel(getTransitFeed('ttc-toronto'), '501'), '501');
|
||||
assert.equal(transitRouteLabel(getTransitFeed('mbta'), 'Red'), 'Red');
|
||||
assert.equal(transitRouteLabel(null, '7'), '7');
|
||||
});
|
||||
|
||||
test('the feeds whose licences are accepted by use are switched on', () => {
|
||||
// Barrie and YRT each ask for acceptance through a form, and each licence
|
||||
// also says that using the data is itself acceptance. That reading was
|
||||
// taken deliberately, so both are routable and both record the reasoning
|
||||
// in their terms note rather than leaving it to be re-derived later.
|
||||
for (const id of ['barrie-transit', 'yrt-york']) {
|
||||
assert.ok(getTransitFeed(id), `${id} is routable`);
|
||||
assert.ok(
|
||||
publicTransitCatalog().some((feed) => feed.id === id),
|
||||
`${id} is offered to the browser`,
|
||||
);
|
||||
assert.match(
|
||||
getRegisteredTransitFeed(id).terms.note,
|
||||
/acceptance is by use|use itself acceptance|took that clause as sufficient/,
|
||||
`${id} records why it ships on`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('every registered feed now ships on, and the gate still decides that', () => {
|
||||
// The enabled set must stay a filter over the registry, not a copy of it:
|
||||
// flipping one feed off has to make it unreachable.
|
||||
assert.equal(getRegisteredTransitFeed('yrt-york')?.name, 'YRT/Viva');
|
||||
assert.deepEqual(
|
||||
TRANSIT_FEED_REGISTRY.filter((feed) => feed.defaultEnabled !== true),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('Viva bus rapid transit is named by its colour, not its route number', () => {
|
||||
// YRT publishes `601` where every rider, map and station sign says
|
||||
// "Viva Blue". The numeric local routes already read correctly and are
|
||||
// left alone.
|
||||
const yrt = getTransitFeed('yrt-york');
|
||||
assert.equal(transitRouteLabel(yrt, '601'), 'Viva Blue');
|
||||
assert.equal(transitRouteLabel(yrt, '60301'), 'Viva Purple A');
|
||||
assert.equal(transitRouteLabel(yrt, '607'), 'Viva Yellow');
|
||||
assert.equal(transitRouteLabel(yrt, '105'), '105', 'a local bus is a number');
|
||||
assert.equal(transitRouteLabel(yrt, '9899'), '9899');
|
||||
});
|
||||
|
||||
test('YRT local branches read as the branch letter, not the padded id', () => {
|
||||
// YRT suffixes a two-digit branch onto the base route, the same scheme the
|
||||
// Viva table already relies on for `60102`. Unsuffixed, that leaks to a rider
|
||||
// as "8301". The letters are York Region's own published GTFS
|
||||
// `routes.txt` short names, unpadded to match the local routes beside them.
|
||||
const yrt = getTransitFeed('yrt-york');
|
||||
assert.equal(transitRouteLabel(yrt, '10702'), '107B');
|
||||
assert.equal(transitRouteLabel(yrt, '8301'), '83A');
|
||||
assert.equal(transitRouteLabel(yrt, '9002'), '90B');
|
||||
// `9101` runs in the realtime feed but is absent from routes.txt, so there is
|
||||
// no published name to give it. Inventing "91A" from the pattern is exactly
|
||||
// the guess the registry refuses to make; it stays opaque until it is
|
||||
// published.
|
||||
assert.equal(transitRouteLabel(yrt, '9101'), '9101');
|
||||
});
|
||||
|
||||
test('Hamilton route ids are internal keys, read as the number on the bus', () => {
|
||||
// Not one of HSR's 80 route ids equals its published short name, so every
|
||||
// vehicle reads as "Route 5783" until the table translates it. Hamilton also
|
||||
// carries two generations of id for the same route — `5688` and `5780` are
|
||||
// both the 2 BARTON — so both must land on the same answer.
|
||||
const hsr = getTransitFeed('hsr-hamilton');
|
||||
assert.equal(transitRouteLabel(hsr, '5780'), '2');
|
||||
assert.equal(transitRouteLabel(hsr, '5688'), '2', 'the older id agrees');
|
||||
assert.equal(transitRouteLabel(hsr, '5687'), '1');
|
||||
assert.equal(transitRouteLabel(hsr, '5829'), '10', 'the B-Line');
|
||||
assert.equal(transitRouteLabel(hsr, '5793'), '20', 'the A-Line');
|
||||
// Published names that are not numbers are already what a rider would say,
|
||||
// so they survive as published rather than being forced into a number.
|
||||
assert.equal(transitRouteLabel(hsr, '5820'), 'TC230');
|
||||
assert.equal(transitRouteLabel(hsr, '9999'), '9999', 'unknown survives');
|
||||
});
|
||||
|
||||
test('Burlington route ids are joined, never prefix-stripped', () => {
|
||||
// `351` is route 1 and `3510` is route 10, so no amount of trimming a `35`
|
||||
// prefix works — the table is the only correct answer.
|
||||
const bur = getTransitFeed('burlington-transit');
|
||||
assert.equal(transitRouteLabel(bur, '351'), '1');
|
||||
assert.equal(transitRouteLabel(bur, '3510'), '10');
|
||||
assert.equal(transitRouteLabel(bur, '3512'), '12');
|
||||
assert.equal(transitRouteLabel(bur, '3587'), '87');
|
||||
assert.equal(transitRouteLabel(bur, '9999'), '9999', 'unknown survives');
|
||||
});
|
||||
|
||||
test('Burlington is live and carries its terms link, which its licence requires', () => {
|
||||
const bur = getTransitFeed('burlington-transit');
|
||||
assert.equal(bur.defaultEnabled, true);
|
||||
assert.match(bur.licenseUrl, /Open%20Data%20Terms%20of%20Use\.pdf$/);
|
||||
assert.match(bur.url, /^https:\/\//, 'port 80 has no listener at all');
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
defaultGatewayFromRouteTable,
|
||||
unwritableRootWarning,
|
||||
resolveTrustedPeers,
|
||||
} from '../scripts/docker-start.mjs';
|
||||
|
||||
// The shape of /proc/net/route inside a bridge-networked container:
|
||||
// little-endian hex addresses, the default route first, an interface route
|
||||
// after.
|
||||
const BRIDGE_ROUTES = [
|
||||
'Iface\tDestination\tGateway \tFlags\tRefCnt\tUse\tMetric\tMask\t\tMTU\tWindow\tIRTT',
|
||||
'eth0\t00000000\t0100A8C0\t0003\t0\t0\t0\t00000000\t0\t0\t0',
|
||||
'eth0\t0000A8C0\t00000000\t0001\t0\t0\t0\t00F0FFFF\t0\t0\t0',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
test('the container gateway is decoded from the kernel routing table', () => {
|
||||
assert.equal(defaultGatewayFromRouteTable(BRIDGE_ROUTES), '192.168.0.1', 'little-endian hex, default route only');
|
||||
assert.equal(
|
||||
defaultGatewayFromRouteTable(BRIDGE_ROUTES.replace('0100A8C0', '011FAC')),
|
||||
null,
|
||||
'a malformed gateway field is not an address',
|
||||
);
|
||||
assert.equal(
|
||||
defaultGatewayFromRouteTable(BRIDGE_ROUTES.replace('\t0003\t', '\t0002\t')),
|
||||
null,
|
||||
'a default route that is not up is ignored',
|
||||
);
|
||||
assert.equal(
|
||||
defaultGatewayFromRouteTable(BRIDGE_ROUTES.replace('0100A8C0', '00000000')),
|
||||
null,
|
||||
'a zero gateway is not an address',
|
||||
);
|
||||
assert.equal(
|
||||
defaultGatewayFromRouteTable(
|
||||
BRIDGE_ROUTES.split('\n').filter((line) => !line.includes('00000000\t0100A8C0')).join('\n'),
|
||||
),
|
||||
null,
|
||||
'--network none has no default route and must not invent one',
|
||||
);
|
||||
assert.equal(defaultGatewayFromRouteTable(''), null);
|
||||
assert.equal(defaultGatewayFromRouteTable(undefined), null);
|
||||
});
|
||||
|
||||
test('an operator list wins, and no gateway trusts nothing', () => {
|
||||
assert.equal(resolveTrustedPeers({ env: {}, routeTable: BRIDGE_ROUTES }), '192.168.0.1');
|
||||
assert.equal(resolveTrustedPeers({ env: { GEV_KEY_SETUP_TRUSTED_PEERS: ' 10.9.8.7 ' }, routeTable: BRIDGE_ROUTES }), '10.9.8.7');
|
||||
assert.equal(
|
||||
resolveTrustedPeers({ env: { GEV_KEY_SETUP_TRUSTED_PEERS: ' ' }, routeTable: BRIDGE_ROUTES }),
|
||||
'192.168.0.1',
|
||||
'a blank override falls through to the gateway',
|
||||
);
|
||||
assert.equal(resolveTrustedPeers({ env: {}, routeTable: '' }), '', 'loopback-only when there is no gateway');
|
||||
});
|
||||
|
||||
test('an unwritable checkout is named at start instead of failing quietly', () => {
|
||||
const ids = { owner: { uid: 0, gid: 0 }, runAs: { uid: 1000, gid: 1000 } };
|
||||
assert.equal(unwritableRootWarning({ root: '/app', writable: true, ...ids }), null, 'a writable checkout says nothing');
|
||||
const warning = unwritableRootWarning({ root: '/app', writable: false, ...ids });
|
||||
assert.match(warning, /owned by 0:0/, 'names the owner');
|
||||
assert.match(warning, /runs as 1000:1000/, 'names the ids the container runs as');
|
||||
assert.match(warning, /chown -R 1000:1000/, 'offers handing the checkout to the container user first');
|
||||
assert.match(warning, /env UID=0 GID=0 docker compose up/, 'or the env form, which bash does not ignore');
|
||||
});
|
||||
|
||||
test('the Docker install path keeps its loopback-only, launcher-owned contract', () => {
|
||||
const dockerfile = readFileSync(new URL('../Dockerfile', import.meta.url), 'utf8');
|
||||
assert.match(dockerfile, /^CMD \["node", "scripts\/docker-start\.mjs"\]$/m, 'the launcher is the container entry point');
|
||||
|
||||
const compose = readFileSync(new URL('../compose.yaml', import.meta.url), 'utf8');
|
||||
assert.match(compose, /^\s+- "127\.0\.0\.1:4173:4173"$/m, 'the port is published on the host loopback only');
|
||||
assert.doesNotMatch(compose, /GEV_KEY_SETUP_TRUSTED_PEERS/, 'trust is derived at launch, never hard-coded');
|
||||
assert.doesNotMatch(compose, /network_mode/, 'the container keeps its own network namespace');
|
||||
});
|
||||
@@ -674,10 +674,11 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
|
||||
// schema, which may not carry top-level anyOf), and Contacts requested-radius
|
||||
// list descriptions; the missions still ride existing tools.
|
||||
// Street Level and GNSS extend the manifest; broadcast caveats describe tracked and analyst aircraft.
|
||||
assert.equal(block.length, 30511, 'serialized tool schema length drifted');
|
||||
// Air quality (ECCC AQHI) extends the manifest.
|
||||
assert.equal(block.length, 30655, 'serialized tool schema length drifted');
|
||||
assert.equal(
|
||||
crypto.createHash('sha256').update(block).digest('hex'),
|
||||
'62d5df9cedf413403e4fd0a7cbe94bb2cd13736c3d8fedc84bcccd63ad253eb6',
|
||||
'3b1d5e77c10a92f6543a4f9d1ff1870101a0306b65e5b8a6113111c81eda371e',
|
||||
'the first-run missions must ride EXISTING tools: no schema edit, no cache bust',
|
||||
);
|
||||
const instructions = fs.readFileSync(new URL('../server/providers/openai/instructions.js', import.meta.url), 'utf8');
|
||||
|
||||
+33
-2
@@ -126,6 +126,32 @@ const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
|
||||
/** Socket addresses that count as this machine. */
|
||||
const LOOPBACK_ADDRESSES = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
|
||||
|
||||
/**
|
||||
* Whether a socket address is one the operator has explicitly declared to be
|
||||
* this machine through GEV_KEY_SETUP_TRUSTED_PEERS: a comma-separated list of
|
||||
* EXACT addresses, never wildcards, prefixes or hostnames. Unset trusts
|
||||
* nothing, which is every launcher but one.
|
||||
*
|
||||
* The shipped use is the Docker launcher (scripts/docker-start.mjs). A port
|
||||
* published on the host's loopback still reaches the container FROM the
|
||||
* container network's gateway, never from 127.0.0.1, so the loopback check
|
||||
* alone refuses the very machine running the server. Admitting that one
|
||||
* address relaxes nothing else: the sharing, Host, Origin, Content-Type and
|
||||
* proxy-header checks all still apply to it.
|
||||
*/
|
||||
function isTrustedPeerAddress(remoteAddress, env) {
|
||||
const normalize = (value) =>
|
||||
String(value || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^::ffff:/, '');
|
||||
const address = normalize(remoteAddress);
|
||||
if (!address) return false;
|
||||
return String(env?.GEV_KEY_SETUP_TRUSTED_PEERS || '')
|
||||
.split(',')
|
||||
.some((entry) => entry.trim() !== '' && normalize(entry) === address);
|
||||
}
|
||||
|
||||
/** Parse an exact local request authority from a Host header. */
|
||||
function localAuthority(hostHeader, protocol) {
|
||||
const raw = String(hostHeader || '')
|
||||
@@ -224,7 +250,9 @@ export function isSharingEnabled(env = {}) {
|
||||
* outright — a credential-writing endpoint has no business existing on a
|
||||
* shared instance, and tunnel traffic reaches the server FROM loopback, so
|
||||
* the socket check below cannot carry that boundary alone;
|
||||
* - loopback socket: refuses LAN peers when the server is bound wide;
|
||||
* - loopback socket: refuses LAN peers when the server is bound wide. The
|
||||
* only exception is an exact address listed in
|
||||
* GEV_KEY_SETUP_TRUSTED_PEERS, and every check below still applies to it;
|
||||
* - local Host header: tunnel and DNS-rebinding traffic carries a foreign
|
||||
* Host even when the socket says loopback;
|
||||
* - exact same Origin on POST: a hostile web page can make a browser POST to
|
||||
@@ -274,7 +302,10 @@ export function admitKeySetupRequest({
|
||||
error: 'Provider Settings is disabled while sharing is enabled',
|
||||
};
|
||||
}
|
||||
if (!LOOPBACK_ADDRESSES.has(String(remoteAddress || ''))) {
|
||||
if (
|
||||
!LOOPBACK_ADDRESSES.has(String(remoteAddress || '')) &&
|
||||
!isTrustedPeerAddress(remoteAddress, env)
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 403,
|
||||
|
||||
@@ -259,6 +259,23 @@ test('the admission gate refuses every non-local shape, one assertion per refusa
|
||||
assert.equal(admitKeySetupRequest({ ...local, env: { PINOKIO_SHARE_LOCAL: '1' } }).ok, false, 'LAN sharing disables the surface');
|
||||
// A LAN peer reaching a wide-bound server.
|
||||
assert.equal(admitKeySetupRequest({ ...local, remoteAddress: '192.168.1.20' }).ok, false, 'non-loopback socket refused');
|
||||
// A container publishes its port on the host's loopback, yet the host's own
|
||||
// browser still arrives from the container network's gateway. Only an exact
|
||||
// address the operator listed admits it, and nothing else is relaxed.
|
||||
const trusted = { GEV_KEY_SETUP_TRUSTED_PEERS: ' 10.0.0.9 , 172.29.73.1 ' };
|
||||
const gateway = { ...local, remoteAddress: '172.29.73.1' };
|
||||
assert.equal(admitKeySetupRequest(gateway).ok, false, 'container gateway refused without the opt-in');
|
||||
assert.equal(admitKeySetupRequest({ ...gateway, env: trusted }).ok, true, 'listed peer admitted');
|
||||
assert.equal(admitKeySetupRequest({ ...gateway, remoteAddress: '::ffff:172.29.73.1', env: trusted }).ok, true, 'IPv4-mapped form of a listed peer admitted');
|
||||
assert.equal(admitKeySetupRequest({ ...local, remoteAddress: '192.168.1.20', env: trusted }).ok, false, 'unlisted peer still refused');
|
||||
assert.equal(admitKeySetupRequest({ ...gateway, env: { GEV_KEY_SETUP_TRUSTED_PEERS: '*' } }).ok, false, 'wildcards are not addresses');
|
||||
assert.equal(admitKeySetupRequest({ ...gateway, env: { GEV_KEY_SETUP_TRUSTED_PEERS: '172.29.73.0/24' } }).ok, false, 'prefixes are not addresses');
|
||||
assert.equal(admitKeySetupRequest({ ...gateway, env: { GEV_KEY_SETUP_TRUSTED_PEERS: '' } }).ok, false, 'an empty list trusts nothing');
|
||||
assert.equal(admitKeySetupRequest({ ...gateway, env: { GEV_KEY_SETUP_TRUSTED_PEERS: ' , , ' } }).ok, false, 'blank entries trust nothing');
|
||||
assert.equal(admitKeySetupRequest({ ...gateway, env: { ...trusted, PINOKIO_SHARE_LOCAL: '1' } }).ok, false, 'sharing still disables the surface for a listed peer');
|
||||
assert.equal(admitKeySetupRequest({ ...gateway, env: trusted, hostHeader: 'abc.trycloudflare.com' }).ok, false, 'a listed peer still needs a local Host');
|
||||
assert.equal(admitKeySetupRequest({ ...gateway, env: trusted, origin: 'https://evil.example' }).ok, false, 'a listed peer still needs the exact Origin');
|
||||
assert.equal(admitKeySetupRequest({ ...gateway, env: trusted, proxyHeaders: { 'x-forwarded-for': '203.0.113.5' } }).ok, false, 'a listed peer still refuses proxied requests');
|
||||
// Tunnel and DNS-rebinding traffic carries a foreign Host over a loopback socket.
|
||||
assert.equal(admitKeySetupRequest({ ...local, hostHeader: 'abc.trycloudflare.com' }).ok, false, 'foreign Host refused');
|
||||
assert.equal(admitKeySetupRequest({ ...local, hostHeader: 'workstation.local:4173' }).ok, false, 'non-localhost hostnames refused');
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
AIR_QUALITY_BANDS,
|
||||
AIR_QUALITY_OVERLAY_SOURCE_ID,
|
||||
AIR_QUALITY_OVERLAY_COHORT_LIMIT,
|
||||
AIR_QUALITY_OVERLAY_COLLISION_CAPACITY,
|
||||
AIR_QUALITY_PROVIDERS,
|
||||
createAirQualityOverlayEntry,
|
||||
mapAnalystRecord,
|
||||
newestObservation,
|
||||
readingsAreStale,
|
||||
readingsInView,
|
||||
selectAirQualityOverlayCohort,
|
||||
} from './model.js';
|
||||
export * from './model.js';
|
||||
export { createEcccAirQualitySource } from './source.js';
|
||||
|
||||
/** Station markers are a point reading, not an extent. */
|
||||
const STATION_MARKER_RADIUS_M = 6_000;
|
||||
/** Readings listed in the readout card. */
|
||||
const READOUT_LIMIT = 5;
|
||||
|
||||
/** Own one air-quality display, its refresh lifecycle, and its readout card. */
|
||||
export function createAirQualityLayer({ source, overlayHost } = {}) {
|
||||
if (typeof source?.getSnapshot !== 'function')
|
||||
throw new TypeError('Air quality requires a snapshot source');
|
||||
if (!overlayHost) throw new TypeError('Air quality requires an overlay host');
|
||||
let _viewer = null;
|
||||
let _request = null;
|
||||
let _dataSource = null;
|
||||
let _readings = [];
|
||||
let _count = 0;
|
||||
let _lastUpdate = null;
|
||||
let _lastError = null;
|
||||
let _enabled = false;
|
||||
let rowControlsListener = null;
|
||||
|
||||
const notify = () => rowControlsListener?.();
|
||||
|
||||
/** The camera's lon/lat rectangle in degrees, or null when unresolvable. */
|
||||
const viewRectangle = () => {
|
||||
const rectangle = _viewer?.camera?.computeViewRectangle?.();
|
||||
if (!rectangle) return null;
|
||||
const toDeg = Cesium.Math.toDegrees;
|
||||
return {
|
||||
west: toDeg(rectangle.west),
|
||||
south: toDeg(rectangle.south),
|
||||
east: toDeg(rectangle.east),
|
||||
north: toDeg(rectangle.north),
|
||||
};
|
||||
};
|
||||
|
||||
const layer = {
|
||||
id: 'air-quality',
|
||||
name: 'Air quality',
|
||||
icon: '◍',
|
||||
source: 'ECCC MSC GeoMet',
|
||||
updateInterval: 1_200_000,
|
||||
|
||||
init(viewer) {
|
||||
if (_viewer) throw new Error('Air quality layer is already initialized');
|
||||
_viewer = viewer;
|
||||
_dataSource = new Cesium.CustomDataSource('air-quality');
|
||||
_dataSource.show = false;
|
||||
viewer.dataSources.add(_dataSource);
|
||||
_readings = [];
|
||||
_count = 0;
|
||||
_lastUpdate = null;
|
||||
_lastError = null;
|
||||
_enabled = false;
|
||||
overlayHost.setVisible(AIR_QUALITY_OVERLAY_SOURCE_ID, false);
|
||||
},
|
||||
|
||||
enable() {
|
||||
_enabled = true;
|
||||
if (_dataSource) _dataSource.show = true;
|
||||
overlayHost.setVisible(AIR_QUALITY_OVERLAY_SOURCE_ID, true);
|
||||
notify();
|
||||
},
|
||||
|
||||
disable() {
|
||||
// Abort in flight: a response arriving after disable must not redraw.
|
||||
_request?.abort();
|
||||
_request = null;
|
||||
_enabled = false;
|
||||
if (_dataSource) _dataSource.show = false;
|
||||
overlayHost.clearSource(AIR_QUALITY_OVERLAY_SOURCE_ID);
|
||||
overlayHost.setVisible(AIR_QUALITY_OVERLAY_SOURCE_ID, false);
|
||||
notify();
|
||||
},
|
||||
|
||||
async update() {
|
||||
if (!_enabled || !_dataSource) return false;
|
||||
_request?.abort();
|
||||
const request = new AbortController();
|
||||
_request = request;
|
||||
try {
|
||||
const readings = await source.getSnapshot({ signal: request.signal });
|
||||
// A superseded or aborted request must not touch the scene, and neither
|
||||
// must one that lands after the layer was switched off.
|
||||
if (request.signal.aborted || _request !== request || !_enabled)
|
||||
return false;
|
||||
_readings = readings;
|
||||
this._render();
|
||||
_count = _readings.length;
|
||||
_lastUpdate = Date.now();
|
||||
_lastError = null;
|
||||
notify();
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (request.signal.aborted || _request !== request) return false;
|
||||
_lastError = error?.message || 'Air quality request failed';
|
||||
console.warn('[Data:AirQuality] Refresh failed:', _lastError);
|
||||
notify();
|
||||
return false;
|
||||
} finally {
|
||||
if (_request === request) _request = null;
|
||||
}
|
||||
},
|
||||
|
||||
/** Rebuild entities and overlay labels from the current readings. */
|
||||
_render() {
|
||||
if (!_dataSource) return;
|
||||
_dataSource.entities.removeAll();
|
||||
const overlayEntries = [];
|
||||
for (const reading of _readings) {
|
||||
const position = Cesium.Cartesian3.fromDegrees(
|
||||
reading.lon,
|
||||
reading.lat,
|
||||
);
|
||||
const color = Cesium.Color.fromCssColorString(reading.color);
|
||||
_dataSource.entities.add(
|
||||
new Cesium.Entity({
|
||||
id: `air-quality:${reading.id}`,
|
||||
position,
|
||||
ellipse: {
|
||||
// Static axes, as in the earthquake layer: a per-frame axis
|
||||
// re-tessellates the clamped ground primitive every frame.
|
||||
semiMajorAxis: STATION_MARKER_RADIUS_M,
|
||||
semiMinorAxis: STATION_MARKER_RADIUS_M,
|
||||
material: new Cesium.ColorMaterialProperty(color.withAlpha(0.35)),
|
||||
outline: true,
|
||||
outlineColor: color.withAlpha(0.9),
|
||||
outlineWidth: 2,
|
||||
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
|
||||
},
|
||||
properties: {
|
||||
provider: reading.provider,
|
||||
scale: reading.scale,
|
||||
value: reading.value,
|
||||
band: reading.band,
|
||||
name: reading.name,
|
||||
zone: reading.zone,
|
||||
risk: reading.risk,
|
||||
observedMs: reading.observedMs,
|
||||
note: reading.note,
|
||||
lat: reading.lat,
|
||||
lon: reading.lon,
|
||||
},
|
||||
}),
|
||||
);
|
||||
overlayEntries.push(
|
||||
createAirQualityOverlayEntry({
|
||||
id: reading.id,
|
||||
position,
|
||||
title: `${reading.name} ${reading.label}`,
|
||||
accent: reading.color,
|
||||
value: reading.value,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (_enabled) {
|
||||
overlayHost.setEntries(
|
||||
AIR_QUALITY_OVERLAY_SOURCE_ID,
|
||||
selectAirQualityOverlayCohort(overlayEntries),
|
||||
{
|
||||
cohortLimit: AIR_QUALITY_OVERLAY_COHORT_LIMIT,
|
||||
collisionCapacity: AIR_QUALITY_OVERLAY_COLLISION_CAPACITY,
|
||||
moving: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
destroy(viewer = _viewer) {
|
||||
_request?.abort();
|
||||
_request = null;
|
||||
_enabled = false;
|
||||
overlayHost.clearSource(AIR_QUALITY_OVERLAY_SOURCE_ID);
|
||||
overlayHost.setVisible(AIR_QUALITY_OVERLAY_SOURCE_ID, false);
|
||||
if (_dataSource) {
|
||||
viewer?.dataSources?.remove(_dataSource, true);
|
||||
_dataSource = null;
|
||||
}
|
||||
_viewer = null;
|
||||
_readings = [];
|
||||
_count = 0;
|
||||
_lastUpdate = null;
|
||||
_lastError = null;
|
||||
rowControlsListener = null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Readout descriptor for the Weather panel: the band legend, and the worst
|
||||
* readings currently in view.
|
||||
*
|
||||
* `readout: true` renders the toggle and source line only; the card owns
|
||||
* the rest. Coverage is stated plainly because this layer is not global.
|
||||
*/
|
||||
getRowControls() {
|
||||
const provider = AIR_QUALITY_PROVIDERS.eccc;
|
||||
const inView = readingsInView(_readings, viewRectangle(), READOUT_LIMIT);
|
||||
const newest = _readings.reduce(
|
||||
(max, reading) => Math.max(max, reading.observedMs || 0),
|
||||
0,
|
||||
);
|
||||
return {
|
||||
readout: true,
|
||||
summary: {
|
||||
label: provider.label,
|
||||
coverage: provider.coverage,
|
||||
validTime: newest ? new Date(newest).toISOString() : undefined,
|
||||
detail: `${provider.network} · ${provider.scale}`,
|
||||
status: _lastError
|
||||
? _lastError
|
||||
: _count === 0
|
||||
? _lastUpdate === null
|
||||
? 'No readings available'
|
||||
: 'No current readings'
|
||||
: `${_count} stations reporting`,
|
||||
units: provider.scale,
|
||||
},
|
||||
legend: AIR_QUALITY_BANDS.map((band) => ({
|
||||
color: band.color,
|
||||
label: band.label,
|
||||
})),
|
||||
legendCategorical: true,
|
||||
list: {
|
||||
ariaLabel: 'Highest air-quality readings in view',
|
||||
items: inView.map((reading, index) => ({
|
||||
id: reading.id,
|
||||
ordinal: index + 1,
|
||||
lead: `${reading.scale} ${reading.label}`,
|
||||
text: `${reading.name} · ${reading.risk}`,
|
||||
accent: reading.color,
|
||||
})),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
setRowControlsListener(listener) {
|
||||
rowControlsListener = typeof listener === 'function' ? listener : null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Snapshot readings as JSON-safe records for the analyst query engine.
|
||||
* @param {number} [maxCount=500]
|
||||
* @returns {Array<object>}
|
||||
*/
|
||||
getAnalystRecords(maxCount = 500) {
|
||||
if (!_enabled || !_readings.length) return [];
|
||||
const limit = Number.isFinite(maxCount)
|
||||
? Math.max(1, Math.floor(maxCount))
|
||||
: 500;
|
||||
return _readings
|
||||
.slice(0, limit)
|
||||
.map((reading, index) => mapAnalystRecord(reading, index));
|
||||
},
|
||||
|
||||
getStats() {
|
||||
// `lastUpdate` is when a response was received; `observedAt` is when the
|
||||
// network actually measured. Freshness is judged on the latter, so a feed
|
||||
// that keeps answering with hours-old observations reports STALE rather
|
||||
// than being narrated as live by the HUD and voice provenance envelope.
|
||||
return {
|
||||
count: _count,
|
||||
lastUpdate: _lastUpdate,
|
||||
error: _lastError,
|
||||
observedAt: newestObservation(_readings),
|
||||
// Only a completed refresh can be stale; before one there is no feed.
|
||||
stale: _lastUpdate !== null && readingsAreStale(_readings),
|
||||
};
|
||||
},
|
||||
};
|
||||
return layer;
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
/**
|
||||
* @module layers/airQuality/model
|
||||
*
|
||||
* Air-quality readings, normalized across networks. Pure — no Cesium, no
|
||||
* network.
|
||||
*
|
||||
* Air-quality indices are not interchangeable numbers. Canada's AQHI runs 1 to
|
||||
* 10+ on health risk; the US AQI runs 0 to 500 on concentration breakpoints;
|
||||
* Europe's EAQI is a five-step category. A reading is therefore carried as
|
||||
* `{ provider, scale, value, band }` — who measured it, which index it is, the
|
||||
* value on that index's own terms, and the band it falls in — so a second
|
||||
* network can be added without retrofitting a shared numeric meaning that does
|
||||
* not exist.
|
||||
*
|
||||
* Only ECCC's AQHI is implemented here; `AIR_QUALITY_PROVIDERS` is the seam a
|
||||
* later provider registers against.
|
||||
*/
|
||||
|
||||
/** Band identifiers shared across providers, in ascending severity. */
|
||||
export const AIR_QUALITY_BANDS = Object.freeze([
|
||||
Object.freeze({ id: 'low', label: 'Low', color: '#4cc9f0' }),
|
||||
Object.freeze({ id: 'moderate', label: 'Moderate', color: '#ffd43b' }),
|
||||
Object.freeze({ id: 'high', label: 'High', color: '#ff6b35' }),
|
||||
Object.freeze({ id: 'very-high', label: 'Very High', color: '#c1121f' }),
|
||||
]);
|
||||
|
||||
const BAND_BY_ID = new Map(AIR_QUALITY_BANDS.map((band) => [band.id, band]));
|
||||
|
||||
/** Colour used when a station reports no usable reading. */
|
||||
export const AIR_QUALITY_UNKNOWN_COLOR = '#6b7785';
|
||||
|
||||
export const AIR_QUALITY_OVERLAY_SOURCE_ID = 'air-quality';
|
||||
export const AIR_QUALITY_OVERLAY_COHORT_LIMIT = 48;
|
||||
export const AIR_QUALITY_OVERLAY_COLLISION_CAPACITY = 32;
|
||||
|
||||
/**
|
||||
* A reading this old is stale enough that displaying it would misinform.
|
||||
* AQHI publishes hourly, so six hours is five missed publications.
|
||||
*/
|
||||
export const OBSERVATION_MAX_AGE_MS = 6 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* How old the newest observation may be before the layer reports itself stale.
|
||||
*
|
||||
* This is deliberately separate from the age at which a reading is dropped.
|
||||
* `lastUpdate` records when we last *received* a response, which stays current
|
||||
* even when the network publishes nothing new; freshness has to be judged on
|
||||
* the source's own observation time instead. AQHI publishes hourly, so a newest
|
||||
* reading beyond two hours means at least one publication was missed and the
|
||||
* feed should not be described as live.
|
||||
*/
|
||||
export const STALE_AFTER_MS = 2 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* The newest observation time across a set of readings, or null when empty.
|
||||
* @param {Array<object>} readings
|
||||
* @returns {number|null} Epoch ms.
|
||||
*/
|
||||
export function newestObservation(readings) {
|
||||
if (!Array.isArray(readings) || readings.length === 0) return null;
|
||||
let newest = null;
|
||||
for (const reading of readings) {
|
||||
const at = Number(reading?.observedMs);
|
||||
if (Number.isFinite(at) && (newest === null || at > newest)) newest = at;
|
||||
}
|
||||
return newest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is a set of readings stale, judged on source observation time? An empty
|
||||
* set is stale: a refresh that kept no current observation has nothing live.
|
||||
* @param {Array<object>} readings
|
||||
* @param {number} [now=Date.now()]
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function readingsAreStale(readings, now = Date.now()) {
|
||||
const newest = newestObservation(readings);
|
||||
// No current observation at all — every row was dropped as too old or
|
||||
// unusable — is the stalest a feed can be, not a nominal one.
|
||||
if (newest === null) return true;
|
||||
return now - newest > STALE_AFTER_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registered provider descriptors. `coverage` is stated plainly because the
|
||||
* layer is not global and the row must say so.
|
||||
*/
|
||||
export const AIR_QUALITY_PROVIDERS = Object.freeze({
|
||||
eccc: Object.freeze({
|
||||
id: 'eccc',
|
||||
scale: 'AQHI',
|
||||
label: 'Air quality',
|
||||
network: 'Environment and Climate Change Canada',
|
||||
coverage: 'Canada only · ECCC AQHI network',
|
||||
/** ECCC's published band thresholds, evaluated in order. */
|
||||
bands: Object.freeze([
|
||||
Object.freeze({ id: 'low', max: 3 }),
|
||||
Object.freeze({ id: 'moderate', max: 6 }),
|
||||
Object.freeze({ id: 'high', max: 10 }),
|
||||
Object.freeze({ id: 'very-high', max: Infinity }),
|
||||
]),
|
||||
}),
|
||||
});
|
||||
|
||||
/** Band descriptor for an id, or null. */
|
||||
export function airQualityBandById(bandId) {
|
||||
return BAND_BY_ID.get(String(bandId ?? '')) || null;
|
||||
}
|
||||
|
||||
/** Accent colour for a band id, falling back to neutral grey. */
|
||||
export function airQualityColor(bandId) {
|
||||
return airQualityBandById(bandId)?.color || AIR_QUALITY_UNKNOWN_COLOR;
|
||||
}
|
||||
|
||||
/**
|
||||
* The published AQHI value for a raw ECCC reading: an integer from 1 up.
|
||||
*
|
||||
* ECCC reports whole numbers with a floor of 1 — there is no "AQHI 0" and no
|
||||
* "AQHI 1.08". A raw value below 1 is a legitimate low reading, not an error,
|
||||
* and is published as 1.
|
||||
*
|
||||
* @param {number|string|null|undefined} raw - Raw `aqhi` value from the API.
|
||||
* @returns {number|null} Published integer AQHI, or null when unusable.
|
||||
*/
|
||||
export function aqhiDisplayValue(raw) {
|
||||
// Guard the empty forms BEFORE coercing: Number(null) and Number('') are both
|
||||
// 0, which would otherwise floor to a confident "AQHI 1" for a station that
|
||||
// reported nothing at all.
|
||||
if (raw === null || raw === undefined || raw === '') return null;
|
||||
if (typeof raw !== 'number' && typeof raw !== 'string') return null;
|
||||
if (typeof raw === 'string' && raw.trim() === '') return null;
|
||||
const value = Number(raw);
|
||||
if (!Number.isFinite(value) || value < 0) return null;
|
||||
return Math.max(1, Math.round(value));
|
||||
}
|
||||
|
||||
/**
|
||||
* The band id for a value on a provider's scale.
|
||||
* @param {string} providerId
|
||||
* @param {number|null} value - Already in the provider's published form.
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function bandForValue(providerId, value) {
|
||||
const provider = AIR_QUALITY_PROVIDERS[String(providerId ?? '')];
|
||||
if (!provider || !Number.isFinite(value)) return null;
|
||||
return provider.bands.find((band) => value <= band.max)?.id ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display text for a value on a provider's scale.
|
||||
* AQHI collapses everything above ten into the open-ended "10+".
|
||||
* @param {string} providerId
|
||||
* @param {number|null} value
|
||||
* @returns {string}
|
||||
*/
|
||||
export function valueLabel(providerId, value) {
|
||||
if (!Number.isFinite(value)) return '--';
|
||||
if (providerId === 'eccc') return value > 10 ? '10+' : String(value);
|
||||
return String(value);
|
||||
}
|
||||
|
||||
/** Plain-language risk phrase for a band id. */
|
||||
export function bandRiskText(bandId) {
|
||||
const band = airQualityBandById(bandId);
|
||||
return band ? `${band.label} health risk` : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a station FeatureCollection into an id-keyed catalog.
|
||||
*
|
||||
* Atomic: a structurally broken response returns null so the caller keeps its
|
||||
* last good catalog. A station missing coordinates is skipped, since one
|
||||
* unusable station is not evidence the catalog is broken.
|
||||
*
|
||||
* @param {object} geojson - GeoJSON FeatureCollection from `aqhi-stations`.
|
||||
* @returns {Map<string, object>|null} Stations by location_id, or null.
|
||||
*/
|
||||
export function normalizeAqhiStations(geojson) {
|
||||
if (!Array.isArray(geojson?.features)) return null;
|
||||
const stations = new Map();
|
||||
for (const feature of geojson.features) {
|
||||
const properties = feature?.properties;
|
||||
if (
|
||||
!properties ||
|
||||
typeof properties !== 'object' ||
|
||||
Array.isArray(properties)
|
||||
)
|
||||
return null;
|
||||
const coordinates = feature?.geometry?.coordinates;
|
||||
if (!Array.isArray(coordinates) || coordinates.length < 2) continue;
|
||||
const [lon, lat] = coordinates.map(Number);
|
||||
if (!Number.isFinite(lon) || !Number.isFinite(lat)) continue;
|
||||
if (Math.abs(lat) > 90 || Math.abs(lon) > 180) return null;
|
||||
const id = String(properties.location_id ?? feature.id ?? '').trim();
|
||||
if (!id) continue;
|
||||
stations.set(id, {
|
||||
id,
|
||||
name: String(properties.location_name_en || id).trim(),
|
||||
// ECCC's administrative zone: atl, que, ont, pnr, pyr.
|
||||
zone: String(properties['eccc_administrative-zone'] || '').trim(),
|
||||
lat,
|
||||
lon,
|
||||
});
|
||||
}
|
||||
return stations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Join latest ECCC observations onto known stations into provider-shaped rows.
|
||||
*
|
||||
* The catalog is the only thing that supplies a reading its coordinates, so an
|
||||
* observation without a matching station cannot be placed and is dropped.
|
||||
* Readings older than OBSERVATION_MAX_AGE_MS are dropped too — a six-hour-old
|
||||
* air-quality number presented as current is worse than no number.
|
||||
*
|
||||
* @param {object} geojson - FeatureCollection from `aqhi-observations-realtime`.
|
||||
* @param {Map<string, object>} stations - Known stations by location_id.
|
||||
* @param {number} [now=Date.now()] - Clock seam for tests.
|
||||
* @returns {Array<object>|null} Normalized readings, or null when malformed.
|
||||
*/
|
||||
export function normalizeAqhiObservations(geojson, stations, now = Date.now()) {
|
||||
if (!Array.isArray(geojson?.features)) return null;
|
||||
if (!(stations instanceof Map)) return null;
|
||||
const provider = AIR_QUALITY_PROVIDERS.eccc;
|
||||
const newest = new Map();
|
||||
for (const feature of geojson.features) {
|
||||
const properties = feature?.properties;
|
||||
if (
|
||||
!properties ||
|
||||
typeof properties !== 'object' ||
|
||||
Array.isArray(properties)
|
||||
)
|
||||
return null;
|
||||
const stationId = String(properties.location_id ?? '').trim();
|
||||
const station = stations.get(stationId);
|
||||
if (!station) continue;
|
||||
|
||||
const value = aqhiDisplayValue(properties.aqhi);
|
||||
if (value === null) continue;
|
||||
const observedMs = Date.parse(properties.observation_datetime);
|
||||
if (!Number.isFinite(observedMs)) continue;
|
||||
if (now - observedMs > OBSERVATION_MAX_AGE_MS) continue;
|
||||
|
||||
const previous = newest.get(stationId);
|
||||
if (previous && previous.observedMs >= observedMs) continue;
|
||||
const band = bandForValue(provider.id, value);
|
||||
newest.set(stationId, {
|
||||
id: `${provider.id}:${stationId}`,
|
||||
provider: provider.id,
|
||||
scale: provider.scale,
|
||||
value,
|
||||
band,
|
||||
stationId,
|
||||
name: station.name,
|
||||
zone: station.zone,
|
||||
lat: station.lat,
|
||||
lon: station.lon,
|
||||
observedMs,
|
||||
color: airQualityColor(band),
|
||||
label: valueLabel(provider.id, value),
|
||||
risk: bandRiskText(band),
|
||||
note: String(properties.special_notes_en || '').trim() || null,
|
||||
});
|
||||
}
|
||||
return [...newest.values()].sort(
|
||||
(a, b) => b.value - a.value || a.stationId.localeCompare(b.stationId),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the source-owned presentation for one station label.
|
||||
* @param {object} input
|
||||
* @returns {object}
|
||||
*/
|
||||
export function createAirQualityOverlayEntry({
|
||||
id,
|
||||
position,
|
||||
title,
|
||||
accent,
|
||||
value,
|
||||
}) {
|
||||
return {
|
||||
id: String(id),
|
||||
position,
|
||||
variant: 'label',
|
||||
title,
|
||||
accent,
|
||||
// Worse air wins the label budget — the reading people need to see first.
|
||||
priority: Math.round(Number(value) || 0) * 1000,
|
||||
collisionGroup: 'ambient-label',
|
||||
paintLane: 'ambient-label',
|
||||
interactive: false,
|
||||
edgeFade: 'keyhole',
|
||||
horizonCull: true,
|
||||
terrainOcclusion: false,
|
||||
gapPx: 14,
|
||||
verticalOnly: true,
|
||||
placement: 'above',
|
||||
};
|
||||
}
|
||||
|
||||
/** Keep the worst readings, with stable identity as the tie-break. */
|
||||
export function selectAirQualityOverlayCohort(
|
||||
entries,
|
||||
limit = AIR_QUALITY_OVERLAY_COHORT_LIMIT,
|
||||
) {
|
||||
const cap = Math.max(
|
||||
0,
|
||||
Math.min(AIR_QUALITY_OVERLAY_COHORT_LIMIT, Math.floor(Number(limit) || 0)),
|
||||
);
|
||||
if (!Array.isArray(entries) || cap === 0) return [];
|
||||
return entries
|
||||
.slice()
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.priority - a.priority || String(a.id).localeCompare(String(b.id)),
|
||||
)
|
||||
.slice(0, cap);
|
||||
}
|
||||
|
||||
/**
|
||||
* The worst readings inside a lon/lat rectangle, for the readout card.
|
||||
*
|
||||
* A null rectangle means the camera could not resolve one (a fully oblique or
|
||||
* space-level view); everything is then in scope rather than nothing, so the
|
||||
* card degrades to the worst readings overall instead of going blank.
|
||||
*
|
||||
* @param {Array<object>} readings
|
||||
* @param {?{west:number, south:number, east:number, north:number}} rect - Degrees.
|
||||
* @param {number} [limit=5]
|
||||
* @returns {Array<object>}
|
||||
*/
|
||||
export function readingsInView(readings, rect, limit = 5) {
|
||||
if (!Array.isArray(readings)) return [];
|
||||
const cap = Math.max(0, Math.floor(Number(limit) || 0));
|
||||
if (cap === 0) return [];
|
||||
const inside = !rect
|
||||
? readings
|
||||
: readings.filter((reading) => {
|
||||
if (reading.lat < rect.south || reading.lat > rect.north) return false;
|
||||
// A rectangle crossing the antimeridian has west > east.
|
||||
return rect.west <= rect.east
|
||||
? reading.lon >= rect.west && reading.lon <= rect.east
|
||||
: reading.lon >= rect.west || reading.lon <= rect.east;
|
||||
});
|
||||
return inside
|
||||
.slice()
|
||||
.sort((a, b) => b.value - a.value || a.stationId.localeCompare(b.stationId))
|
||||
.slice(0, cap);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map one reading to a JSON-safe analyst record. Pure — no Cesium types.
|
||||
* @param {object|null|undefined} raw
|
||||
* @param {number} [index=0]
|
||||
* @returns {object}
|
||||
*/
|
||||
export function mapAnalystRecord(raw, index = 0) {
|
||||
const num = (v) => (Number.isFinite(v) ? v : null);
|
||||
const text = (v) => {
|
||||
const t = String(v ?? '').trim();
|
||||
return t || null;
|
||||
};
|
||||
return {
|
||||
id: text(raw?.id) || `AIR-${String(index).padStart(4, '0')}`,
|
||||
provider: text(raw?.provider),
|
||||
scale: text(raw?.scale),
|
||||
value: num(raw?.value),
|
||||
band: text(raw?.band),
|
||||
name: text(raw?.name),
|
||||
zone: text(raw?.zone),
|
||||
risk: text(raw?.risk),
|
||||
lat: num(raw?.lat),
|
||||
lon: num(raw?.lon),
|
||||
observedMs: num(raw?.observedMs),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
AIR_QUALITY_BANDS,
|
||||
AIR_QUALITY_PROVIDERS,
|
||||
AIR_QUALITY_UNKNOWN_COLOR,
|
||||
OBSERVATION_MAX_AGE_MS,
|
||||
STALE_AFTER_MS,
|
||||
airQualityColor,
|
||||
aqhiDisplayValue,
|
||||
bandForValue,
|
||||
bandRiskText,
|
||||
mapAnalystRecord,
|
||||
normalizeAqhiObservations,
|
||||
normalizeAqhiStations,
|
||||
readingsAreStale,
|
||||
readingsInView,
|
||||
selectAirQualityOverlayCohort,
|
||||
valueLabel,
|
||||
} from './model.js';
|
||||
|
||||
const NOW = Date.parse('2026-09-24T18:30:00Z');
|
||||
const RECENT = '2026-09-24T18:00:00Z';
|
||||
const collection = (features) => ({ type: 'FeatureCollection', features });
|
||||
const station = (id, name, zone, lon, lat) => ({
|
||||
type: 'Feature',
|
||||
geometry: { type: 'Point', coordinates: [lon, lat] },
|
||||
properties: {
|
||||
location_id: id,
|
||||
location_name_en: name,
|
||||
'eccc_administrative-zone': zone,
|
||||
},
|
||||
});
|
||||
const observation = (id, aqhi, when = RECENT) => ({
|
||||
type: 'Feature',
|
||||
properties: { location_id: id, aqhi, observation_datetime: when },
|
||||
});
|
||||
|
||||
const STATIONS = collection([
|
||||
station('IAKID', 'Calgary', 'pnr', -114.0575, 51.0458),
|
||||
station('OAAAA', 'Toronto', 'ont', -79.3832, 43.6532),
|
||||
station('AAAAA', 'Halifax', 'atl', -63.5752, 44.6488),
|
||||
]);
|
||||
|
||||
// ── provider-shaped records ────────────────────────────────────────────────
|
||||
|
||||
test('readings carry the provider-neutral {provider, scale, value, band} shape', () => {
|
||||
// Indices are not interchangeable numbers: AQHI is 1-10+ on health risk,
|
||||
// US AQI is 0-500 on concentration. The shape names which index a value is on
|
||||
// so a second network can be added without inventing a shared meaning.
|
||||
const stations = normalizeAqhiStations(STATIONS);
|
||||
const [row] = normalizeAqhiObservations(
|
||||
collection([observation('IAKID', 2.32)]),
|
||||
stations,
|
||||
NOW,
|
||||
);
|
||||
assert.equal(row.provider, 'eccc');
|
||||
assert.equal(row.scale, 'AQHI');
|
||||
assert.equal(row.value, 2);
|
||||
assert.equal(row.band, 'low');
|
||||
});
|
||||
|
||||
test('bands map from a value on a named provider scale', () => {
|
||||
assert.equal(bandForValue('eccc', 1), 'low');
|
||||
assert.equal(bandForValue('eccc', 3), 'low');
|
||||
assert.equal(bandForValue('eccc', 4), 'moderate');
|
||||
assert.equal(bandForValue('eccc', 7), 'high');
|
||||
assert.equal(bandForValue('eccc', 11), 'very-high');
|
||||
// An unregistered provider cannot be banded rather than being guessed at.
|
||||
assert.equal(bandForValue('unknown-network', 5), null);
|
||||
assert.equal(bandForValue('eccc', null), null);
|
||||
});
|
||||
|
||||
test('the provider states its coverage plainly', () => {
|
||||
assert.match(AIR_QUALITY_PROVIDERS.eccc.coverage, /Canada only/);
|
||||
assert.equal(AIR_QUALITY_PROVIDERS.eccc.scale, 'AQHI');
|
||||
});
|
||||
|
||||
test("values above ten collapse to the scale's open-ended label", () => {
|
||||
assert.equal(valueLabel('eccc', 10), '10');
|
||||
assert.equal(valueLabel('eccc', 11), '10+');
|
||||
assert.equal(valueLabel('eccc', null), '--');
|
||||
});
|
||||
|
||||
test('every band has a distinct colour, and an unknown band is neutral', () => {
|
||||
const colors = AIR_QUALITY_BANDS.map((b) => b.color);
|
||||
assert.equal(new Set(colors).size, colors.length);
|
||||
assert.equal(airQualityColor('nonsense'), AIR_QUALITY_UNKNOWN_COLOR);
|
||||
assert.equal(bandRiskText('high'), 'High health risk');
|
||||
assert.equal(bandRiskText(null), null);
|
||||
});
|
||||
|
||||
// ── published form ─────────────────────────────────────────────────────────
|
||||
|
||||
test('AQHI rounds to the published integer form and floors at 1', () => {
|
||||
assert.equal(aqhiDisplayValue(1.08), 1);
|
||||
assert.equal(aqhiDisplayValue(3.5), 4);
|
||||
assert.equal(aqhiDisplayValue(0), 1);
|
||||
assert.equal(aqhiDisplayValue('3'), 3);
|
||||
});
|
||||
|
||||
test('an absent reading never becomes a confident AQHI 1', () => {
|
||||
// Number(null) and Number('') are both 0, which would floor to 1.
|
||||
for (const empty of [null, undefined, '', ' ', NaN, true, {}, [], -1]) {
|
||||
assert.equal(aqhiDisplayValue(empty), null, `${JSON.stringify(empty)}`);
|
||||
}
|
||||
});
|
||||
|
||||
// ── snapshot validation ────────────────────────────────────────────────────
|
||||
|
||||
test('stations normalize into an id-keyed catalog carrying their zone', () => {
|
||||
const stations = normalizeAqhiStations(STATIONS);
|
||||
assert.equal(stations.size, 3);
|
||||
assert.equal(stations.get('OAAAA').zone, 'ont');
|
||||
});
|
||||
|
||||
test('malformed payloads are rejected atomically', () => {
|
||||
assert.equal(normalizeAqhiStations(null), null);
|
||||
assert.equal(normalizeAqhiStations(collection([{ properties: null }])), null);
|
||||
const stations = normalizeAqhiStations(STATIONS);
|
||||
assert.equal(normalizeAqhiObservations(null, stations, NOW), null);
|
||||
assert.equal(
|
||||
normalizeAqhiObservations(collection([]), 'not a map', NOW),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('an observation with no matching station is dropped', () => {
|
||||
const stations = normalizeAqhiStations(STATIONS);
|
||||
const rows = normalizeAqhiObservations(
|
||||
collection([observation('IAKID', 2), observation('ZZZZZ', 9)]),
|
||||
stations,
|
||||
NOW,
|
||||
);
|
||||
assert.equal(rows.length, 1);
|
||||
});
|
||||
|
||||
test('a stale reading is dropped rather than shown as current', () => {
|
||||
const stations = normalizeAqhiStations(STATIONS);
|
||||
const old = new Date(NOW - OBSERVATION_MAX_AGE_MS - 60_000).toISOString();
|
||||
const rows = normalizeAqhiObservations(
|
||||
collection([observation('IAKID', 3, old), observation('OAAAA', 4)]),
|
||||
stations,
|
||||
NOW,
|
||||
);
|
||||
assert.deepEqual(
|
||||
rows.map((r) => r.stationId),
|
||||
['OAAAA'],
|
||||
);
|
||||
});
|
||||
|
||||
test('a snapshot whose every observation was too old is stale, not nominal', () => {
|
||||
const stations = normalizeAqhiStations(STATIONS);
|
||||
const old = new Date(NOW - OBSERVATION_MAX_AGE_MS - 60_000).toISOString();
|
||||
const rows = normalizeAqhiObservations(
|
||||
collection([observation('IAKID', 3, old), observation('OAAAA', 4, old)]),
|
||||
stations,
|
||||
NOW,
|
||||
);
|
||||
assert.deepEqual(rows, []);
|
||||
assert.equal(readingsAreStale(rows, NOW), true);
|
||||
assert.equal(readingsAreStale([{ observedMs: NOW - 60_000 }], NOW), false);
|
||||
assert.equal(
|
||||
readingsAreStale([{ observedMs: NOW - STALE_AFTER_MS - 1 }], NOW),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('the newest reading per station wins and worst air sorts first', () => {
|
||||
const stations = normalizeAqhiStations(STATIONS);
|
||||
const rows = normalizeAqhiObservations(
|
||||
collection([
|
||||
observation('IAKID', 3, '2026-09-24T16:00:00Z'),
|
||||
observation('IAKID', 7, '2026-09-24T18:00:00Z'),
|
||||
observation('OAAAA', 5),
|
||||
]),
|
||||
stations,
|
||||
NOW,
|
||||
);
|
||||
assert.deepEqual(
|
||||
rows.map((r) => r.value),
|
||||
[7, 5],
|
||||
);
|
||||
});
|
||||
|
||||
// ── readout selection ──────────────────────────────────────────────────────
|
||||
|
||||
const reading = (id, value, lat, lon) => ({
|
||||
id,
|
||||
stationId: id,
|
||||
value,
|
||||
lat,
|
||||
lon,
|
||||
name: id,
|
||||
});
|
||||
|
||||
test('the readout lists the worst readings inside the view rectangle', () => {
|
||||
const rows = [
|
||||
reading('west', 9, 51.0, -114.0),
|
||||
reading('east', 4, 43.6, -79.3),
|
||||
reading('mild', 2, 51.1, -114.1),
|
||||
];
|
||||
const inView = readingsInView(
|
||||
rows,
|
||||
{ west: -115, south: 50, east: -113, north: 52 },
|
||||
5,
|
||||
);
|
||||
assert.deepEqual(
|
||||
inView.map((r) => r.id),
|
||||
['west', 'mild'],
|
||||
);
|
||||
});
|
||||
|
||||
test('an unresolvable view rectangle degrades to the worst overall, not to blank', () => {
|
||||
// A space-level or fully oblique camera cannot produce a rectangle; the card
|
||||
// should still say something useful.
|
||||
const rows = [reading('a', 3, 0, 0), reading('b', 8, 10, 10)];
|
||||
assert.deepEqual(
|
||||
readingsInView(rows, null, 5).map((r) => r.id),
|
||||
['b', 'a'],
|
||||
);
|
||||
assert.deepEqual(readingsInView(rows, null, 0), []);
|
||||
assert.deepEqual(readingsInView(null, null, 5), []);
|
||||
});
|
||||
|
||||
test('a view rectangle crossing the antimeridian still selects correctly', () => {
|
||||
// west > east is how a dateline-crossing rectangle is expressed.
|
||||
const rows = [reading('near', 5, 0, 179), reading('far', 9, 0, 100)];
|
||||
const inView = readingsInView(
|
||||
rows,
|
||||
{ west: 170, south: -10, east: -170, north: 10 },
|
||||
5,
|
||||
);
|
||||
assert.deepEqual(
|
||||
inView.map((r) => r.id),
|
||||
['near'],
|
||||
);
|
||||
});
|
||||
|
||||
test('the overlay cohort is capped and deterministic', () => {
|
||||
const entries = Array.from({ length: 120 }, (_, i) => ({
|
||||
id: `s${i}`,
|
||||
priority: i % 11,
|
||||
}));
|
||||
const cohort = selectAirQualityOverlayCohort(entries);
|
||||
assert.ok(cohort.length <= 48);
|
||||
assert.deepEqual(
|
||||
cohort.map((e) => e.id),
|
||||
selectAirQualityOverlayCohort(entries).map((e) => e.id),
|
||||
);
|
||||
});
|
||||
|
||||
test('analyst records are JSON-safe and name the provider and scale', () => {
|
||||
const stations = normalizeAqhiStations(STATIONS);
|
||||
const [row] = normalizeAqhiObservations(
|
||||
collection([observation('IAKID', 2)]),
|
||||
stations,
|
||||
NOW,
|
||||
);
|
||||
const record = mapAnalystRecord(row, 0);
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(record)), record);
|
||||
assert.equal(record.provider, 'eccc');
|
||||
assert.equal(record.scale, 'AQHI');
|
||||
const empty = mapAnalystRecord(undefined, 7);
|
||||
assert.equal(empty.id, 'AIR-0007');
|
||||
for (const [key, value] of Object.entries(empty)) {
|
||||
assert.notEqual(value, undefined, `${key} must not be undefined`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,275 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createAirQualityLayer } from './index.js';
|
||||
import { layerFeedState } from '../../data/feedState.js';
|
||||
import { STALE_AFTER_MS } from './model.js';
|
||||
|
||||
function harness(source) {
|
||||
const sources = [];
|
||||
const events = [];
|
||||
const viewer = {
|
||||
dataSources: {
|
||||
add(value) {
|
||||
sources.push(value);
|
||||
},
|
||||
remove(value) {
|
||||
sources.splice(sources.indexOf(value), 1);
|
||||
},
|
||||
},
|
||||
};
|
||||
const layer = createAirQualityLayer({
|
||||
source,
|
||||
overlayHost: {
|
||||
setEntries(...args) {
|
||||
events.push(args);
|
||||
},
|
||||
setVisible() {},
|
||||
clearSource() {},
|
||||
},
|
||||
});
|
||||
layer.init(viewer);
|
||||
layer.enable(viewer);
|
||||
return { layer, viewer, sources, events };
|
||||
}
|
||||
|
||||
const reading = (id, value, overrides = {}) => ({
|
||||
id: `eccc:${id}`,
|
||||
provider: 'eccc',
|
||||
scale: 'AQHI',
|
||||
value,
|
||||
band: 'low',
|
||||
stationId: id,
|
||||
name: id,
|
||||
zone: 'ont',
|
||||
lat: 43.65,
|
||||
lon: -79.38,
|
||||
observedMs: 1_700_000_000_000,
|
||||
color: '#4cc9f0',
|
||||
label: String(value),
|
||||
risk: 'Low health risk',
|
||||
note: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const staticSource = (rows) => ({ getSnapshot: async () => rows });
|
||||
|
||||
test('a layer requires both a snapshot source and an overlay host', () => {
|
||||
assert.throws(() => createAirQualityLayer(), /snapshot source/);
|
||||
assert.throws(
|
||||
() => createAirQualityLayer({ source: staticSource([]) }),
|
||||
/overlay host/,
|
||||
);
|
||||
});
|
||||
|
||||
test('initialization is single-use and owns exactly one data source', () => {
|
||||
const { layer, viewer, sources } = harness(staticSource([]));
|
||||
assert.equal(sources.length, 1);
|
||||
assert.throws(() => layer.init(viewer), /already initialized/);
|
||||
layer.destroy(viewer);
|
||||
assert.equal(sources.length, 0);
|
||||
});
|
||||
|
||||
test('a refresh renders readings and publishes an overlay cohort', async () => {
|
||||
const { layer, events } = harness(
|
||||
staticSource([reading('A', 2), reading('B', 7)]),
|
||||
);
|
||||
assert.equal(await layer.update(), true);
|
||||
assert.equal(layer.getStats().count, 2);
|
||||
assert.equal(layer.getStats().error, null);
|
||||
assert.equal(events.at(-1)[0], 'air-quality');
|
||||
});
|
||||
|
||||
// ── the abort + enabled guard ──────────────────────────────────────────────
|
||||
|
||||
test('a response landing after disable cannot redraw the scene', async () => {
|
||||
// The failure this prevents: disable() clears the overlay, then an in-flight
|
||||
// response resolves and repopulates it behind the user's back.
|
||||
let release;
|
||||
const gate = new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const { layer, events } = harness({
|
||||
getSnapshot: async ({ signal } = {}) => {
|
||||
await gate;
|
||||
signal?.throwIfAborted();
|
||||
return [reading('A', 5)];
|
||||
},
|
||||
});
|
||||
const pending = layer.update();
|
||||
layer.disable();
|
||||
const publishedBefore = events.length;
|
||||
release();
|
||||
assert.equal(await pending, false, 'a late response must not be applied');
|
||||
assert.equal(
|
||||
events.length,
|
||||
publishedBefore,
|
||||
'no overlay write after disable',
|
||||
);
|
||||
assert.equal(layer.getStats().count, 0);
|
||||
});
|
||||
|
||||
test('disable aborts the request in flight', async () => {
|
||||
let seenSignal = null;
|
||||
const { layer } = harness({
|
||||
getSnapshot: async ({ signal } = {}) => {
|
||||
seenSignal = signal;
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
signal?.throwIfAborted();
|
||||
return [];
|
||||
},
|
||||
});
|
||||
const pending = layer.update();
|
||||
layer.disable();
|
||||
await pending;
|
||||
assert.ok(seenSignal, 'a signal was passed through to the source');
|
||||
assert.equal(seenSignal.aborted, true);
|
||||
});
|
||||
|
||||
test('a superseded refresh does not clobber the newer one', async () => {
|
||||
let call = 0;
|
||||
const { layer } = harness({
|
||||
getSnapshot: async ({ signal } = {}) => {
|
||||
call += 1;
|
||||
const mine = call;
|
||||
await new Promise((resolve) => setTimeout(resolve, mine === 1 ? 40 : 1));
|
||||
signal?.throwIfAborted();
|
||||
return [reading(`call-${mine}`, mine)];
|
||||
},
|
||||
});
|
||||
const first = layer.update();
|
||||
const second = layer.update();
|
||||
const [firstResult, secondResult] = await Promise.all([first, second]);
|
||||
assert.equal(firstResult, false, 'the superseded call reports no update');
|
||||
assert.equal(secondResult, true);
|
||||
assert.equal(layer.getAnalystRecords()[0].name, 'call-2');
|
||||
});
|
||||
|
||||
test('update is a no-op while the layer is disabled', async () => {
|
||||
const { layer } = harness(staticSource([reading('A', 2)]));
|
||||
layer.disable();
|
||||
assert.equal(await layer.update(), false);
|
||||
assert.equal(layer.getStats().count, 0);
|
||||
});
|
||||
|
||||
// ── readout descriptor ─────────────────────────────────────────────────────
|
||||
|
||||
test('the readout descriptor carries the band legend and coverage', async () => {
|
||||
const { layer } = harness(staticSource([reading('A', 2), reading('B', 9)]));
|
||||
await layer.update();
|
||||
const controls = layer.getRowControls();
|
||||
assert.equal(controls.readout, true);
|
||||
assert.match(controls.summary.coverage, /Canada only/);
|
||||
// The Weather panel's legend shape: one {color, label} per band.
|
||||
assert.equal(controls.legendCategorical, true);
|
||||
assert.deepEqual(
|
||||
controls.legend.map((entry) => entry.label),
|
||||
['Low', 'Moderate', 'High', 'Very High'],
|
||||
);
|
||||
assert.ok(
|
||||
controls.legend.every((entry) => /^#[0-9a-f]{6}$/i.test(entry.color)),
|
||||
);
|
||||
// Worst first, and every item keyed.
|
||||
assert.deepEqual(
|
||||
controls.list.items.map((i) => i.text),
|
||||
['B · Low health risk', 'A · Low health risk'],
|
||||
);
|
||||
assert.ok(controls.list.items.every((i) => i.id));
|
||||
assert.match(controls.summary.status, /2 stations reporting/);
|
||||
});
|
||||
|
||||
test('the readout reports an error rather than a silent empty card', async () => {
|
||||
const { layer } = harness({
|
||||
getSnapshot: async () => {
|
||||
throw new Error('ECCC HTTP 503');
|
||||
},
|
||||
});
|
||||
await layer.update();
|
||||
assert.match(layer.getRowControls().summary.status, /503/);
|
||||
});
|
||||
|
||||
test('row-control listeners are notified on refresh and teardown-safe', async () => {
|
||||
const { layer, viewer } = harness(staticSource([reading('A', 2)]));
|
||||
let calls = 0;
|
||||
layer.setRowControlsListener(() => {
|
||||
calls += 1;
|
||||
});
|
||||
await layer.update();
|
||||
assert.ok(calls >= 1, 'a refresh notifies the card');
|
||||
layer.destroy(viewer);
|
||||
const after = calls;
|
||||
layer.setRowControlsListener(() => {
|
||||
calls += 1;
|
||||
});
|
||||
assert.equal(calls, after);
|
||||
});
|
||||
|
||||
// ── feed-state honesty ─────────────────────────────────────────────────────
|
||||
|
||||
test('freshness is judged on observation time, not on when we last fetched', async () => {
|
||||
// lastUpdate stays current every time the network answers, even when it
|
||||
// answers with nothing new. Judging freshness on it would let the HUD and
|
||||
// voice describe hours-old air quality as live.
|
||||
const aged = Date.now() - STALE_AFTER_MS - 60_000;
|
||||
const { layer } = harness(
|
||||
staticSource([reading('A', 2, { observedMs: aged })]),
|
||||
);
|
||||
await layer.update();
|
||||
const stats = layer.getStats();
|
||||
assert.ok(
|
||||
stats.lastUpdate >= Date.now() - 5_000,
|
||||
'we did just receive a response',
|
||||
);
|
||||
assert.equal(stats.observedAt, aged, 'but the observation itself is old');
|
||||
assert.equal(stats.stale, true);
|
||||
assert.equal(
|
||||
layerFeedState(stats),
|
||||
'stale',
|
||||
'provenance must not read this as nominal',
|
||||
);
|
||||
});
|
||||
|
||||
test('a current reading reports nominal', async () => {
|
||||
const { layer } = harness(
|
||||
staticSource([reading('A', 2, { observedMs: Date.now() - 10 * 60_000 })]),
|
||||
);
|
||||
await layer.update();
|
||||
const stats = layer.getStats();
|
||||
assert.equal(stats.stale, false);
|
||||
assert.equal(layerFeedState(stats), 'nominal');
|
||||
});
|
||||
|
||||
test('a refresh that kept no current observation reports stale, not nominal', async () => {
|
||||
// Every observation older than OBSERVATION_MAX_AGE_MS is dropped, so a feed
|
||||
// that only has hours-old rows yields an empty snapshot. That is the
|
||||
// stalest a feed can be; it must not read as a healthy empty layer.
|
||||
const { layer } = harness(staticSource([]));
|
||||
assert.equal(layer.getStats().stale, false, 'no refresh yet, no verdict');
|
||||
await layer.update();
|
||||
const stats = layer.getStats();
|
||||
assert.equal(stats.count, 0);
|
||||
assert.equal(stats.observedAt, null);
|
||||
assert.equal(stats.stale, true);
|
||||
assert.equal(layerFeedState(stats), 'stale');
|
||||
assert.equal(layer.getRowControls().summary.status, 'No current readings');
|
||||
});
|
||||
|
||||
test('a failed refresh over good readings reports degraded, not nominal', async () => {
|
||||
let fail = false;
|
||||
const { layer } = harness({
|
||||
getSnapshot: async () => {
|
||||
if (fail) throw new Error('ECCC HTTP 503');
|
||||
return [reading('A', 2, { observedMs: Date.now() - 10 * 60_000 })];
|
||||
},
|
||||
});
|
||||
await layer.update();
|
||||
assert.equal(layerFeedState(layer.getStats()), 'nominal');
|
||||
fail = true;
|
||||
await layer.update();
|
||||
const stats = layer.getStats();
|
||||
assert.ok(stats.count > 0, 'the previous readings are still displayed');
|
||||
assert.equal(
|
||||
layerFeedState(stats),
|
||||
'degraded',
|
||||
'showing old data after a failure is degraded',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { readResponseJsonCapped } from '../../sources/httpBody.js';
|
||||
import { normalizeAqhiStations, normalizeAqhiObservations } from './model.js';
|
||||
|
||||
const COLLECTIONS_ROOT = 'https://api.weather.gc.ca/collections';
|
||||
const STATIONS_URL = `${COLLECTIONS_ROOT}/aqhi-stations/items`;
|
||||
const OBSERVATIONS_URL = `${COLLECTIONS_ROOT}/aqhi-observations-realtime/items`;
|
||||
|
||||
/** Comfortably above Canada's ~134 stations without inviting a huge page. */
|
||||
const STATION_LIMIT = 500;
|
||||
const OBSERVATION_LIMIT = 500;
|
||||
/** Station geometry is near-static — a few changes a year. Refetch twice a day. */
|
||||
const STATION_TTL_MS = 12 * 60 * 60 * 1000;
|
||||
/** A full 500-row GeoJSON page is a few hundred KB; anything past this is not a page. */
|
||||
const MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Request and validate a complete ECCC AQHI snapshot before it can replace
|
||||
* displayed readings. The first registered air-quality provider.
|
||||
*
|
||||
* Two collections are involved. `aqhi-stations` is the catalog — it supplies
|
||||
* every reading its coordinates and is cached for twelve hours because it
|
||||
* barely changes. `aqhi-observations-realtime` is the hourly reading feed,
|
||||
* requested with `latest=true` so the response is current values rather than
|
||||
* the full observation history, which is roughly 2,000 rows nationally.
|
||||
*
|
||||
* The station catalog is only replaced when a refresh actually returns
|
||||
* stations, so a transient empty response cannot blank the layer.
|
||||
*
|
||||
* @param {object} [options]
|
||||
* @param {Function} [options.fetchImpl] - Injected fetch, for tests.
|
||||
* @param {() => number} [options.now] - Clock seam, for tests.
|
||||
* @param {number} [options.timeoutMs=15000] - Deadline for each request.
|
||||
* @returns {{getSnapshot: Function}}
|
||||
*/
|
||||
export function createEcccAirQualitySource({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
now = () => Date.now(),
|
||||
timeoutMs = 15_000,
|
||||
} = {}) {
|
||||
/** @type {Map<string, object>} */
|
||||
let stations = new Map();
|
||||
let stationsFetchedAt = 0;
|
||||
|
||||
/** One bounded GeoJSON page: deadline, caller abort, byte cap, row cap. */
|
||||
const getJson = async (url, limit, signal) => {
|
||||
const controller = new AbortController();
|
||||
const abort = () => controller.abort(signal.reason);
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
const timer = setTimeout(
|
||||
() => controller.abort(new Error('ECCC request timed out')),
|
||||
timeoutMs,
|
||||
);
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
const response = await fetchImpl(url, {
|
||||
headers: { Accept: 'application/json' },
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) throw new Error(`ECCC HTTP ${response.status}`);
|
||||
const payload = await readResponseJsonCapped(
|
||||
response,
|
||||
MAX_RESPONSE_BYTES,
|
||||
controller.signal,
|
||||
);
|
||||
// The server's `limit` is a request, not a guarantee.
|
||||
if (Array.isArray(payload?.features) && payload.features.length > limit)
|
||||
return { ...payload, features: payload.features.slice(0, limit) };
|
||||
return payload;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener('abort', abort);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
async getSnapshot({ signal } = {}) {
|
||||
const clock = now();
|
||||
if (stations.size === 0 || clock - stationsFetchedAt > STATION_TTL_MS) {
|
||||
const payload = await getJson(
|
||||
`${STATIONS_URL}?f=json&limit=${STATION_LIMIT}`,
|
||||
STATION_LIMIT,
|
||||
signal,
|
||||
);
|
||||
const next = normalizeAqhiStations(payload);
|
||||
if (!next) throw new Error('Malformed AQHI station response');
|
||||
// An empty refresh must not wipe a good catalog.
|
||||
if (next.size > 0) {
|
||||
stations = next;
|
||||
stationsFetchedAt = clock;
|
||||
}
|
||||
}
|
||||
if (stations.size === 0) throw new Error('No AQHI stations available');
|
||||
|
||||
const payload = await getJson(
|
||||
`${OBSERVATIONS_URL}?f=json&latest=true&limit=${OBSERVATION_LIMIT}`,
|
||||
OBSERVATION_LIMIT,
|
||||
signal,
|
||||
);
|
||||
const readings = normalizeAqhiObservations(payload, stations, clock);
|
||||
if (!readings) throw new Error('Malformed AQHI observation response');
|
||||
return readings;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createEcccAirQualitySource } from './source.js';
|
||||
|
||||
const NOW = Date.parse('2026-09-24T18:30:00Z');
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
const station = (id) => ({
|
||||
type: 'Feature',
|
||||
geometry: { type: 'Point', coordinates: [-79.38, 43.65] },
|
||||
properties: { location_id: id, location_name_en: id },
|
||||
});
|
||||
const observation = (id, at = NOW, aqhi = 3) => ({
|
||||
type: 'Feature',
|
||||
properties: {
|
||||
location_id: id,
|
||||
aqhi,
|
||||
observation_datetime: new Date(at - 10 * 60_000).toISOString(),
|
||||
},
|
||||
});
|
||||
const json = (body, init) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
...init,
|
||||
});
|
||||
|
||||
/** A fetch that serves the station catalog and observations from mutable state. */
|
||||
function feed() {
|
||||
const state = {
|
||||
clock: NOW,
|
||||
stations: [station('A')],
|
||||
stationIds: null,
|
||||
urls: [],
|
||||
};
|
||||
state.source = createEcccAirQualitySource({
|
||||
now: () => state.clock,
|
||||
fetchImpl: async (url) => {
|
||||
state.urls.push(url);
|
||||
return json({
|
||||
type: 'FeatureCollection',
|
||||
// Every known station reports ten minutes before the current clock.
|
||||
features: url.includes('aqhi-stations')
|
||||
? state.stations
|
||||
: (state.stationIds || ['A']).map((id) =>
|
||||
observation(id, state.clock),
|
||||
),
|
||||
});
|
||||
},
|
||||
});
|
||||
state.stationRequests = () =>
|
||||
state.urls.filter((url) => url.includes('aqhi-stations')).length;
|
||||
return state;
|
||||
}
|
||||
|
||||
test('the station catalog is cached for twelve hours, then refetched', async () => {
|
||||
const state = feed();
|
||||
await state.source.getSnapshot();
|
||||
state.clock = NOW + 12 * HOUR;
|
||||
await state.source.getSnapshot();
|
||||
assert.equal(state.stationRequests(), 1, 'within the TTL the cache serves');
|
||||
state.clock = NOW + 12 * HOUR + 1;
|
||||
await state.source.getSnapshot();
|
||||
assert.equal(
|
||||
state.stationRequests(),
|
||||
2,
|
||||
'past the TTL the catalog refreshes',
|
||||
);
|
||||
});
|
||||
|
||||
test('an empty catalog refresh keeps the previous catalog', async () => {
|
||||
const state = feed();
|
||||
await state.source.getSnapshot();
|
||||
state.stations = [];
|
||||
state.clock = NOW + 13 * HOUR;
|
||||
const readings = await state.source.getSnapshot();
|
||||
assert.deepEqual(
|
||||
readings.map((reading) => reading.stationId),
|
||||
['A'],
|
||||
);
|
||||
// The empty refresh did not count as fresh, so the next call retries it.
|
||||
await state.source.getSnapshot();
|
||||
assert.equal(state.stationRequests(), 3);
|
||||
});
|
||||
|
||||
test('an empty first catalog is an error, not an empty layer', async () => {
|
||||
const state = feed();
|
||||
state.stations = [];
|
||||
await assert.rejects(state.source.getSnapshot(), /No AQHI stations/);
|
||||
});
|
||||
|
||||
test('requests ask for JSON, the latest observations only, and a bounded page', async () => {
|
||||
const state = feed();
|
||||
await state.source.getSnapshot();
|
||||
const [stations, observations] = state.urls.map((url) => new URL(url));
|
||||
assert.equal(stations.pathname, '/collections/aqhi-stations/items');
|
||||
assert.equal(stations.searchParams.get('f'), 'json');
|
||||
assert.equal(stations.searchParams.get('limit'), '500');
|
||||
assert.equal(
|
||||
observations.pathname,
|
||||
'/collections/aqhi-observations-realtime/items',
|
||||
);
|
||||
// Without latest=true the collection returns the full history (~2,000 rows).
|
||||
assert.equal(observations.searchParams.get('latest'), 'true');
|
||||
assert.equal(observations.searchParams.get('limit'), '500');
|
||||
});
|
||||
|
||||
test('rows past the requested limit are ignored', async () => {
|
||||
const state = feed();
|
||||
state.stations = Array.from({ length: 600 }, (_, index) =>
|
||||
station(`S${String(index).padStart(3, '0')}`),
|
||||
);
|
||||
state.stationIds = state.stations.map((row) => row.properties.location_id);
|
||||
const readings = await state.source.getSnapshot();
|
||||
assert.equal(readings.length, 500);
|
||||
});
|
||||
|
||||
test('a malformed catalog and HTTP errors reject', async () => {
|
||||
await assert.rejects(
|
||||
createEcccAirQualitySource({
|
||||
fetchImpl: async () => json({ features: 'no' }),
|
||||
}).getSnapshot(),
|
||||
/Malformed AQHI station/,
|
||||
);
|
||||
await assert.rejects(
|
||||
createEcccAirQualitySource({
|
||||
fetchImpl: async () => json({}, { status: 503 }),
|
||||
}).getSnapshot(),
|
||||
/ECCC HTTP 503/,
|
||||
);
|
||||
});
|
||||
|
||||
test('an oversized response is refused before it is parsed', async () => {
|
||||
const big = 'x'.repeat(4 * 1024 * 1024 + 1);
|
||||
await assert.rejects(
|
||||
createEcccAirQualitySource({
|
||||
fetchImpl: async () =>
|
||||
new Response(new Blob([big]).stream(), {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
}).getSnapshot(),
|
||||
(error) => error.code === 'RESPONSE_TOO_LARGE',
|
||||
);
|
||||
await assert.rejects(
|
||||
createEcccAirQualitySource({
|
||||
fetchImpl: async () =>
|
||||
json({}, { headers: { 'content-length': String(5 * 1024 * 1024) } }),
|
||||
}).getSnapshot(),
|
||||
(error) => error.code === 'RESPONSE_TOO_LARGE',
|
||||
);
|
||||
});
|
||||
|
||||
test('a caller abort cancels the request and detaches its listener', async () => {
|
||||
const caller = new AbortController();
|
||||
let requestSignal = null;
|
||||
const source = createEcccAirQualitySource({
|
||||
fetchImpl: (_url, { signal }) => {
|
||||
requestSignal = signal;
|
||||
return new Promise((_resolve, reject) =>
|
||||
signal.addEventListener('abort', () => reject(signal.reason), {
|
||||
once: true,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
const pending = source.getSnapshot({ signal: caller.signal });
|
||||
caller.abort(new Error('layer disabled'));
|
||||
await assert.rejects(pending, /layer disabled/);
|
||||
assert.equal(requestSignal.aborted, true);
|
||||
const already = new AbortController();
|
||||
already.abort(new Error('before start'));
|
||||
let calls = 0;
|
||||
await assert.rejects(
|
||||
createEcccAirQualitySource({
|
||||
fetchImpl: async () => {
|
||||
calls += 1;
|
||||
return json({ features: [] });
|
||||
},
|
||||
}).getSnapshot({ signal: already.signal }),
|
||||
/before start/,
|
||||
);
|
||||
assert.equal(calls, 0);
|
||||
});
|
||||
|
||||
test('a request that outlives its deadline is aborted', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const source = createEcccAirQualitySource({
|
||||
timeoutMs: 1_000,
|
||||
fetchImpl: (_url, { signal }) =>
|
||||
new Promise((_resolve, reject) =>
|
||||
signal.addEventListener('abort', () => reject(signal.reason), {
|
||||
once: true,
|
||||
}),
|
||||
),
|
||||
});
|
||||
const pending = source.getSnapshot();
|
||||
t.mock.timers.tick(1_000);
|
||||
await assert.rejects(pending, /ECCC request timed out/);
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { CITY_BY_ID } from './registry.js';
|
||||
import { createModel } from './model.js';
|
||||
import { createViewport } from './viewport.js';
|
||||
import { isAllowedGbfsHost, isAllowedGbfsPath } from '../../data/gbfsSource.js';
|
||||
|
||||
// Each Bike Itaú city's PBSC host, and the station farthest from its
|
||||
// registered centre in the live feed (checked 2026-10-15).
|
||||
const CITIES = {
|
||||
'sao-paulo-bike-itau': {
|
||||
host: 'saopaulo.publicbikesystem.net',
|
||||
stations: [{ name: '260 - P3', lat: -23.568235, lon: -46.740715 }],
|
||||
},
|
||||
'rio-de-janeiro-bike-itau': {
|
||||
host: 'riodejaneiro.publicbikesystem.net',
|
||||
stations: [{ name: '293 - Praia da Macumba', lat: -23.03135, lon: -43.479584 }],
|
||||
},
|
||||
'salvador-bike-itau': {
|
||||
host: 'salvador.publicbikesystem.net',
|
||||
stations: [{ name: '49 - Stella Maris', lat: -12.93645, lon: -38.32701 }],
|
||||
},
|
||||
'recife-bike-itau': {
|
||||
host: 'recife.publicbikesystem.net',
|
||||
stations: [{ name: '69 - Praia de Piedade', lat: -8.186205, lon: -34.918371 }],
|
||||
},
|
||||
'porto-alegre-bike-itau': {
|
||||
host: 'portoalegre.publicbikesystem.net',
|
||||
stations: [{ name: '72 - Avenida Tramandaí', lat: -30.135915, lon: -51.22684 }],
|
||||
},
|
||||
'brasilia-bike-itau': {
|
||||
host: 'brasilia.publicbikesystem.net',
|
||||
stations: [{ name: "60 - Parque Olhos d'Água", lat: -15.7431, lon: -47.88667 }],
|
||||
},
|
||||
'curitiba-bike-itau': {
|
||||
host: 'curitiba.publicbikesystem.net',
|
||||
stations: [{ name: '42 - Terminal Portão', lat: -25.476885, lon: -49.292608 }],
|
||||
},
|
||||
};
|
||||
|
||||
// The production range check, with no viewer or fetches behind it.
|
||||
const parts = { model: createModel({ state: {}, services: {}, parts: {}, source: null }) };
|
||||
const { computeInRangeCities } = createViewport({
|
||||
state: {},
|
||||
services: { render: { governorRequestRender() {} } },
|
||||
parts,
|
||||
source: null,
|
||||
});
|
||||
|
||||
test('the Bike Itaú systems are registered and readable through the proxy', () => {
|
||||
for (const [id, expected] of Object.entries(CITIES)) {
|
||||
const city = CITY_BY_ID.get(id);
|
||||
assert.ok(city, `${id} is registered`);
|
||||
assert.equal(city.provider, 'Bike Itaú');
|
||||
assert.deepEqual(city.hosts, [expected.host]);
|
||||
for (const feed of [city.stationInformationUrl, city.stationStatusUrl]) {
|
||||
const url = new URL(feed);
|
||||
assert.ok(isAllowedGbfsHost(url.hostname), `${id} host ${url.hostname}`);
|
||||
assert.ok(isAllowedGbfsPath(url.pathname), `${id} path ${url.pathname}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('a camera over each outermost station activates its system', () => {
|
||||
for (const [id, expected] of Object.entries(CITIES)) {
|
||||
for (const station of expected.stations) {
|
||||
assert.ok(
|
||||
computeInRangeCities({ lat: station.lat, lon: station.lon }).has(id),
|
||||
`${station.name} activates ${id}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
Vendored
+16
-2
@@ -56,7 +56,7 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
|
||||
/**
|
||||
* Return current layer statistics for the UI status display.
|
||||
* @returns {{ count: number, lastUpdate: number|null, loading: boolean, loadingLabel?: string, error?: string }}
|
||||
* @returns {{ count: number, lastUpdate: number|null, loading: boolean, loadingLabel?: string, error?: string, degraded?: boolean, stale?: boolean }}
|
||||
*/
|
||||
getStats() {
|
||||
const stats = {
|
||||
@@ -70,7 +70,21 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
? `syncing ${layerState._activeCityIds.size} city feeds...`
|
||||
: 'scanning nearby systems...';
|
||||
}
|
||||
if (layerState._error) stats.error = layerState._error;
|
||||
// A failed city is only the layer's failure when it left nothing on the
|
||||
// map. While other cities still render, the partial failure reads
|
||||
// DEGRADED; when every in-range city failed and only previously drawn
|
||||
// points remain, they read STALE.
|
||||
if (layerState._failedCityIds.size > 0) {
|
||||
if (layerState._stationRenderMap.size === 0) {
|
||||
stats.error = 'GBFS fetch error';
|
||||
} else {
|
||||
stats.degraded = true;
|
||||
const allFailed = Array.from(layerState._activeCityIds).every(
|
||||
(cityId) => layerState._failedCityIds.has(cityId),
|
||||
);
|
||||
if (allFailed) stats.stale = true;
|
||||
}
|
||||
}
|
||||
return stats;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
// BIKESHARE FAILURE REPORTING — one dead city is not a dead layer.
|
||||
//
|
||||
// City feeds are fetched independently, but the layer reported failure through
|
||||
// a single flag. A city whose operator had retired its feed set that flag on
|
||||
// every proximity check, and the only code that cleared it ran on a successful
|
||||
// *activation* — which an already-loaded city never repeats. The result was a
|
||||
// permanent LOAD FAILED over a map drawing a thousand live stations.
|
||||
//
|
||||
// These cases pin the rule that replaced it: the layer reports failure only
|
||||
// when it has nothing to show, and a city that comes back clears its own
|
||||
// failure rather than waiting for a neighbour to succeed.
|
||||
//
|
||||
// Run with: npm test
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createState } from './state.js';
|
||||
import { createControls } from './controls.js';
|
||||
|
||||
function makeLayer() {
|
||||
const services = {
|
||||
overlays: {
|
||||
setOverlayEntries() {},
|
||||
setOverlaySourceVisible() {},
|
||||
clearOverlaySource() {},
|
||||
},
|
||||
};
|
||||
const state = createState({ services });
|
||||
const parts = { queries: { collectDetectableStations: () => [] } };
|
||||
const { methods } = createControls({ state, services, parts, source: {} });
|
||||
return { state, methods };
|
||||
}
|
||||
|
||||
/** Stand in for a rendered station without reaching into the renderer. */
|
||||
function renderStations(state, count) {
|
||||
for (let i = 0; i < count; i += 1) state._stationRenderMap.set(`s${i}`, {});
|
||||
state._count = state._stationRenderMap.size;
|
||||
}
|
||||
|
||||
test('a failed city does not fail a layer that is drawing stations', () => {
|
||||
const { state, methods } = makeLayer();
|
||||
renderStations(state, 1069);
|
||||
state._failedCityIds.add('buffalo-reddy');
|
||||
|
||||
const stats = methods.getStats();
|
||||
assert.equal(stats.count, 1069);
|
||||
assert.equal(
|
||||
stats.error,
|
||||
undefined,
|
||||
'a layer drawing 1069 stations reported LOAD FAILED',
|
||||
);
|
||||
});
|
||||
|
||||
test('the layer reports failure when a failed city left it with nothing', () => {
|
||||
const { state, methods } = makeLayer();
|
||||
state._failedCityIds.add('buffalo-reddy');
|
||||
|
||||
const stats = methods.getStats();
|
||||
assert.equal(stats.count, 0);
|
||||
assert.equal(stats.error, 'GBFS fetch error');
|
||||
});
|
||||
|
||||
test('a layer with nothing to draw and nothing failed is not an error', () => {
|
||||
const { state, methods } = makeLayer();
|
||||
|
||||
// Out of range of every city is empty, not broken.
|
||||
assert.equal(methods.getStats().error, undefined);
|
||||
});
|
||||
|
||||
test('a recovered city clears its own failure', () => {
|
||||
const { state, methods } = makeLayer();
|
||||
state._failedCityIds.add('buffalo-reddy');
|
||||
assert.equal(methods.getStats().error, 'GBFS fetch error');
|
||||
|
||||
state._failedCityIds.delete('buffalo-reddy');
|
||||
assert.equal(
|
||||
methods.getStats().error,
|
||||
undefined,
|
||||
'the failure outlived the city that caused it',
|
||||
);
|
||||
});
|
||||
|
||||
// The cases above pin how getStats() reads failure state. The cases below
|
||||
// drive the real proximity check, status poll and lifecycle against a fake
|
||||
// GBFS source, so the failure state they read is the state the layer builds.
|
||||
|
||||
import * as Cesium from 'cesium';
|
||||
import { CITY_BY_ID } from './registry.js';
|
||||
import { createIngestion } from './ingestion.js';
|
||||
import { createModel } from './model.js';
|
||||
import { createViewport } from './viewport.js';
|
||||
import { createRendering } from './rendering.js';
|
||||
import { createLifecycle } from './lifecycle.js';
|
||||
|
||||
const TORONTO = 'toronto-bike-share';
|
||||
const BUFFALO = 'buffalo-reddy';
|
||||
// Within range of both Toronto and Buffalo.
|
||||
const BETWEEN = { lat: 43.25, lon: -79.1 };
|
||||
// Within range of Toronto only.
|
||||
const TORONTO_ONLY = { lat: 43.7, lon: -79.6 };
|
||||
// Within range of Buffalo only.
|
||||
const BUFFALO_ONLY = { lat: 42.5, lon: -78.0 };
|
||||
const LOW_M = 10_000;
|
||||
|
||||
// disable() detaches a keydown listener; node has no document.
|
||||
globalThis.document ??= {
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
};
|
||||
|
||||
/** A GBFS source whose per-city answers each test can change. */
|
||||
function makeSource() {
|
||||
const cityOf = (url) => {
|
||||
for (const [id, city] of CITY_BY_ID) {
|
||||
if (url === city.stationInformationUrl) return { id, kind: 'info' };
|
||||
if (url === city.stationStatusUrl) return { id, kind: 'status' };
|
||||
}
|
||||
throw new Error(`unexpected GBFS url ${url}`);
|
||||
};
|
||||
const source = {
|
||||
dead: new Set(),
|
||||
deadStatus: new Set(),
|
||||
/** Optional per-call override: (cityId, kind) => Promise | undefined. */
|
||||
hook: null,
|
||||
async getStations(url, { signal } = {}) {
|
||||
const { id, kind } = cityOf(url);
|
||||
const hooked = source.hook?.(id, kind);
|
||||
if (hooked) return hooked;
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
signal?.throwIfAborted();
|
||||
if (source.dead.has(id)) throw new Error('GBFS HTTP 404');
|
||||
if (kind === 'status' && source.deadStatus.has(id))
|
||||
throw new Error('GBFS HTTP 503');
|
||||
const city = CITY_BY_ID.get(id);
|
||||
if (kind === 'info') {
|
||||
return {
|
||||
data: {
|
||||
stations: [
|
||||
{
|
||||
station_id: `${id}-1`,
|
||||
name: `${id} dock`,
|
||||
lat: city.centerLat,
|
||||
lon: city.centerLon,
|
||||
capacity: 10,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
data: {
|
||||
stations: [
|
||||
{
|
||||
station_id: `${id}-1`,
|
||||
num_bikes_available: 3,
|
||||
num_docks_available: 7,
|
||||
is_installed: true,
|
||||
is_renting: true,
|
||||
is_returning: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
return source;
|
||||
}
|
||||
|
||||
/** Assemble the layer from its real parts around a camera the test moves. */
|
||||
function makeLiveLayer() {
|
||||
const source = makeSource();
|
||||
const services = {
|
||||
render: { governorRequestRender() {} },
|
||||
overlays: {
|
||||
setOverlayEntries() {},
|
||||
setOverlaySourceVisible() {},
|
||||
clearOverlaySource() {},
|
||||
},
|
||||
sprites: { registerSpriteCollection() {}, restoreSpriteOrder() {} },
|
||||
picking: { registerPickOwner() {}, unregisterPickOwner() {} },
|
||||
};
|
||||
const state = createState({ services });
|
||||
const parts = {};
|
||||
const context = { state, services, parts, source };
|
||||
parts.ingestion = createIngestion(context);
|
||||
parts.model = createModel(context);
|
||||
parts.viewport = createViewport(context);
|
||||
parts.rendering = createRendering(context);
|
||||
// Selection owns a DOM click handler; nothing here selects a station.
|
||||
parts.selection = {
|
||||
_installClickHandler() {},
|
||||
_clearSelection() {},
|
||||
_onKeyDown() {},
|
||||
};
|
||||
parts.queries = { collectDetectableStations: () => [] };
|
||||
parts.lifecycle = createLifecycle(context);
|
||||
const { methods: controls } = createControls(context);
|
||||
|
||||
const camera = { ...BETWEEN, height: LOW_M };
|
||||
const viewer = {
|
||||
camera: {
|
||||
get positionCartographic() {
|
||||
return Cesium.Cartographic.fromDegrees(
|
||||
camera.lon,
|
||||
camera.lat,
|
||||
camera.height,
|
||||
);
|
||||
},
|
||||
computeViewRectangle: () => undefined,
|
||||
changed: { addEventListener() {}, removeEventListener() {} },
|
||||
},
|
||||
scene: { globe: {}, primitives: { add() {}, remove() {} } },
|
||||
};
|
||||
parts.lifecycle.methods.init(viewer);
|
||||
|
||||
return {
|
||||
state,
|
||||
source,
|
||||
stats: () => controls.getStats(),
|
||||
moveTo(place, height = LOW_M) {
|
||||
Object.assign(camera, place, { height });
|
||||
return parts.viewport.runProximityCheck();
|
||||
},
|
||||
async enable() {
|
||||
parts.lifecycle.methods.enable(viewer);
|
||||
await settle();
|
||||
},
|
||||
disable: () => parts.lifecycle.methods.disable(viewer),
|
||||
poll: () => parts.ingestion.methods.update(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Let fire-and-forget activation started by enable() finish. */
|
||||
async function settle() {
|
||||
for (let i = 0; i < 5; i += 1)
|
||||
await new Promise((resolve) => setTimeout(resolve, 2));
|
||||
}
|
||||
|
||||
function deferred() {
|
||||
let resolve, reject;
|
||||
const promise = new Promise((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
test('a city that fails beside one that renders reads DEGRADED, not healthy', async () => {
|
||||
const layer = makeLiveLayer();
|
||||
layer.source.dead.add(BUFFALO);
|
||||
await layer.enable();
|
||||
|
||||
const stats = layer.stats();
|
||||
assert.equal(stats.count, 1, 'Toronto should still draw');
|
||||
assert.equal(stats.error, undefined);
|
||||
assert.equal(stats.degraded, true, 'the Buffalo failure was hidden');
|
||||
assert.equal(stats.stale, undefined);
|
||||
});
|
||||
|
||||
test('a failed city that comes back clears its own failure', async () => {
|
||||
const layer = makeLiveLayer();
|
||||
layer.source.dead.add(BUFFALO);
|
||||
await layer.enable();
|
||||
assert.equal(layer.stats().degraded, true);
|
||||
|
||||
layer.source.dead.clear();
|
||||
await layer.moveTo(BETWEEN);
|
||||
const stats = layer.stats();
|
||||
assert.equal(stats.count, 2);
|
||||
assert.equal(stats.degraded, undefined);
|
||||
assert.equal(stats.error, undefined);
|
||||
});
|
||||
|
||||
test('a status poll that recovers clears the failure the last poll set', async () => {
|
||||
const layer = makeLiveLayer();
|
||||
await layer.moveTo(TORONTO_ONLY);
|
||||
await layer.enable();
|
||||
layer.source.deadStatus.add(TORONTO);
|
||||
await layer.poll();
|
||||
assert.equal(layer.stats().degraded, true);
|
||||
|
||||
layer.source.deadStatus.clear();
|
||||
await layer.poll();
|
||||
assert.equal(layer.stats().degraded, undefined);
|
||||
assert.equal(layer.stats().stale, undefined);
|
||||
});
|
||||
|
||||
test('a failure does not outlive its city leaving range', async () => {
|
||||
const layer = makeLiveLayer();
|
||||
layer.source.dead.add(BUFFALO);
|
||||
await layer.enable();
|
||||
await layer.moveTo(BUFFALO_ONLY);
|
||||
assert.equal(layer.stats().error, 'GBFS fetch error');
|
||||
|
||||
// Low over the open Atlantic: nothing in range is empty, not broken.
|
||||
await layer.moveTo({ lat: 30, lon: -40 });
|
||||
const stats = layer.stats();
|
||||
assert.equal(stats.count, 0);
|
||||
assert.equal(stats.error, undefined, 'LOAD FAILED over empty ocean');
|
||||
assert.equal(stats.degraded, undefined);
|
||||
});
|
||||
|
||||
test('zooming out above the altitude gate drops a city failure', async () => {
|
||||
const layer = makeLiveLayer();
|
||||
layer.source.dead.add(BUFFALO);
|
||||
await layer.enable();
|
||||
await layer.moveTo(BUFFALO_ONLY);
|
||||
assert.equal(layer.stats().error, 'GBFS fetch error');
|
||||
|
||||
await layer.moveTo(BUFFALO_ONLY, 200_000);
|
||||
const stats = layer.stats();
|
||||
assert.equal(stats.count, 0);
|
||||
assert.equal(stats.error, undefined, 'LOAD FAILED above the gate');
|
||||
});
|
||||
|
||||
test('a late rejection from before a disable/re-enable is ignored', async () => {
|
||||
const layer = makeLiveLayer();
|
||||
await layer.moveTo(BUFFALO_ONLY);
|
||||
const first = deferred();
|
||||
// The first info request hangs and ignores its abort signal.
|
||||
layer.source.hook = (id, kind) => {
|
||||
if (id !== BUFFALO || kind !== 'info') return undefined;
|
||||
layer.source.hook = null;
|
||||
return first.promise;
|
||||
};
|
||||
await layer.enable();
|
||||
layer.disable();
|
||||
await layer.enable();
|
||||
assert.equal(layer.stats().count, 1, 're-enable should draw Buffalo');
|
||||
|
||||
first.reject(new Error('GBFS HTTP 500'));
|
||||
await settle();
|
||||
const stats = layer.stats();
|
||||
assert.equal(stats.count, 1, 'a stale rejection tore down live points');
|
||||
assert.equal(stats.error, undefined);
|
||||
assert.equal(stats.degraded, undefined);
|
||||
});
|
||||
|
||||
test('a late status poll rejection from before a disable/re-enable is ignored', async () => {
|
||||
const layer = makeLiveLayer();
|
||||
await layer.moveTo(TORONTO_ONLY);
|
||||
await layer.enable();
|
||||
const first = deferred();
|
||||
layer.source.hook = (id, kind) => {
|
||||
if (id !== TORONTO || kind !== 'status') return undefined;
|
||||
layer.source.hook = null;
|
||||
return first.promise;
|
||||
};
|
||||
const poll = layer.poll();
|
||||
layer.disable();
|
||||
await layer.enable();
|
||||
|
||||
first.reject(new Error('GBFS HTTP 500'));
|
||||
await poll;
|
||||
const stats = layer.stats();
|
||||
assert.equal(stats.count, 1);
|
||||
assert.equal(stats.degraded, undefined);
|
||||
assert.equal(stats.error, undefined);
|
||||
});
|
||||
|
||||
test('every status poll failing over cached points reads STALE, not fresh', async () => {
|
||||
const layer = makeLiveLayer();
|
||||
await layer.moveTo(TORONTO_ONLY);
|
||||
await layer.enable();
|
||||
const loadedAt = layer.stats().lastUpdate;
|
||||
assert.ok(loadedAt, 'activation should record freshness');
|
||||
|
||||
layer.source.deadStatus.add(TORONTO);
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
await layer.poll();
|
||||
const stats = layer.stats();
|
||||
assert.equal(stats.count, 1, 'the cached points stay drawn');
|
||||
assert.equal(stats.error, undefined);
|
||||
assert.equal(stats.degraded, true);
|
||||
assert.equal(stats.stale, true);
|
||||
assert.equal(stats.lastUpdate, loadedAt, 'a failed poll advanced freshness');
|
||||
});
|
||||
@@ -171,30 +171,34 @@ export function createIngestion({
|
||||
|
||||
const generation = layerState._proximityGeneration;
|
||||
const cityIds = Array.from(layerState._activeCityIds);
|
||||
const isCurrent = (cityId) =>
|
||||
layerState._enabled &&
|
||||
layerState._activeCityIds.has(cityId) &&
|
||||
generation === layerState._proximityGeneration;
|
||||
let refreshed = false;
|
||||
await Promise.all(
|
||||
cityIds.map(async (cityId) => {
|
||||
try {
|
||||
const statusMap = await loadCityStationStatus(cityId, generation);
|
||||
if (
|
||||
!layerState._enabled ||
|
||||
!layerState._activeCityIds.has(cityId) ||
|
||||
generation !== layerState._proximityGeneration
|
||||
)
|
||||
return;
|
||||
if (!isCurrent(cityId)) return;
|
||||
parts.rendering.applyStatusToPoints(cityId, statusMap);
|
||||
layerState._failedCityIds.delete(cityId);
|
||||
refreshed = true;
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') return;
|
||||
if (error?.name === 'AbortError' || !isCurrent(cityId)) return;
|
||||
console.warn(
|
||||
`[Data:Bikeshare] ${cityId} status update error:`,
|
||||
error,
|
||||
);
|
||||
layerState._error = 'GBFS status update failed';
|
||||
layerState._failedCityIds.add(cityId);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
layerState._count = layerState._stationRenderMap.size;
|
||||
if (layerState._count > 0) layerState._lastUpdate = Date.now();
|
||||
// Freshness advances only on a poll that actually refreshed a city.
|
||||
if (refreshed && layerState._count > 0)
|
||||
layerState._lastUpdate = Date.now();
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ export function createLifecycle({
|
||||
layerState._lastUpdate = null;
|
||||
layerState._loading = false;
|
||||
layerState._loadingOps = 0;
|
||||
layerState._error = null;
|
||||
layerState._failedCityIds.clear();
|
||||
layerState._limitWarned = false;
|
||||
|
||||
layerState._overlayHost.setVisible(
|
||||
@@ -75,7 +75,7 @@ export function createLifecycle({
|
||||
*/
|
||||
enable(viewer) {
|
||||
layerState._enabled = true;
|
||||
layerState._error = null;
|
||||
layerState._failedCityIds.clear();
|
||||
layerState._pointCollection.show = true;
|
||||
layerState._overlayHost.setVisible(
|
||||
BIKESHARE_SELECTED_OVERLAY_SOURCE_ID,
|
||||
|
||||
@@ -234,18 +234,6 @@ const RAW_GBFS_CITY_REGISTRY = [
|
||||
'https://hon.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
|
||||
provider: 'Biki',
|
||||
},
|
||||
{
|
||||
id: 'columbus-cogo',
|
||||
city: 'Columbus, OH',
|
||||
centerLat: 39.9612,
|
||||
centerLon: -82.9988,
|
||||
loadRadiusKm: 90,
|
||||
stationInformationUrl:
|
||||
'https://gbfs.cogobikeshare.com/gbfs/2.3/en/station_information.json',
|
||||
stationStatusUrl:
|
||||
'https://gbfs.cogobikeshare.com/gbfs/2.3/en/station_status.json',
|
||||
provider: 'CoGo',
|
||||
},
|
||||
{
|
||||
id: 'chattanooga-bikechatt',
|
||||
city: 'Chattanooga, TN',
|
||||
@@ -258,6 +246,102 @@ const RAW_GBFS_CITY_REGISTRY = [
|
||||
'https://chat.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
|
||||
provider: 'Bike Chattanooga',
|
||||
},
|
||||
{
|
||||
id: 'sao-paulo-bike-itau',
|
||||
city: 'São Paulo, SP',
|
||||
centerLat: -23.5505,
|
||||
centerLon: -46.6333,
|
||||
loadRadiusKm: 100,
|
||||
stationInformationUrl:
|
||||
'https://saopaulo.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
|
||||
stationStatusUrl:
|
||||
'https://saopaulo.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
|
||||
provider: 'Bike Itaú',
|
||||
},
|
||||
{
|
||||
id: 'rio-de-janeiro-bike-itau',
|
||||
city: 'Rio de Janeiro, RJ',
|
||||
centerLat: -22.9068,
|
||||
centerLon: -43.1729,
|
||||
loadRadiusKm: 100,
|
||||
stationInformationUrl:
|
||||
'https://riodejaneiro.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
|
||||
stationStatusUrl:
|
||||
'https://riodejaneiro.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
|
||||
provider: 'Bike Itaú',
|
||||
},
|
||||
{
|
||||
id: 'salvador-bike-itau',
|
||||
city: 'Salvador, BA',
|
||||
centerLat: -12.9777,
|
||||
centerLon: -38.5016,
|
||||
loadRadiusKm: 90,
|
||||
stationInformationUrl:
|
||||
'https://salvador.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
|
||||
stationStatusUrl:
|
||||
'https://salvador.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
|
||||
provider: 'Bike Itaú',
|
||||
},
|
||||
{
|
||||
id: 'recife-bike-itau',
|
||||
city: 'Recife, PE',
|
||||
centerLat: -8.0476,
|
||||
centerLon: -34.877,
|
||||
loadRadiusKm: 90,
|
||||
stationInformationUrl:
|
||||
'https://recife.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
|
||||
stationStatusUrl:
|
||||
'https://recife.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
|
||||
provider: 'Bike Itaú',
|
||||
},
|
||||
{
|
||||
id: 'porto-alegre-bike-itau',
|
||||
city: 'Porto Alegre, RS',
|
||||
centerLat: -30.0346,
|
||||
centerLon: -51.2177,
|
||||
loadRadiusKm: 90,
|
||||
stationInformationUrl:
|
||||
'https://portoalegre.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
|
||||
stationStatusUrl:
|
||||
'https://portoalegre.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
|
||||
provider: 'Bike Itaú',
|
||||
},
|
||||
{
|
||||
id: 'brasilia-bike-itau',
|
||||
city: 'Brasília, DF',
|
||||
centerLat: -15.7939,
|
||||
centerLon: -47.8828,
|
||||
loadRadiusKm: 90,
|
||||
stationInformationUrl:
|
||||
'https://brasilia.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
|
||||
stationStatusUrl:
|
||||
'https://brasilia.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
|
||||
provider: 'Bike Itaú',
|
||||
},
|
||||
{
|
||||
id: 'curitiba-bike-itau',
|
||||
city: 'Curitiba, PR',
|
||||
centerLat: -25.4284,
|
||||
centerLon: -49.2733,
|
||||
loadRadiusKm: 90,
|
||||
stationInformationUrl:
|
||||
'https://curitiba.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
|
||||
stationStatusUrl:
|
||||
'https://curitiba.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
|
||||
provider: 'Bike Itaú',
|
||||
},
|
||||
{
|
||||
id: 'toronto-bike-share',
|
||||
city: 'Toronto, ON',
|
||||
centerLat: 43.6532,
|
||||
centerLon: -79.3832,
|
||||
loadRadiusKm: 90,
|
||||
stationInformationUrl:
|
||||
'https://toronto.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
|
||||
stationStatusUrl:
|
||||
'https://toronto.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
|
||||
provider: 'Bike Share Toronto',
|
||||
},
|
||||
bcycleEntry({
|
||||
id: 'boulder-bcycle',
|
||||
city: 'Boulder, CO',
|
||||
@@ -325,14 +409,6 @@ const RAW_GBFS_CITY_REGISTRY = [
|
||||
systemId: 'bcycle_pacersbikeshare',
|
||||
provider: 'Pacers Bikeshare',
|
||||
}),
|
||||
bcycleEntry({
|
||||
id: 'fort-lauderdale-broward',
|
||||
city: 'Fort Lauderdale, FL',
|
||||
centerLat: 26.1224,
|
||||
centerLon: -80.1373,
|
||||
systemId: 'bcycle_broward',
|
||||
provider: 'Broward B-cycle',
|
||||
}),
|
||||
bcycleEntry({
|
||||
id: 'memphis-bcycle',
|
||||
city: 'Memphis, TN',
|
||||
@@ -347,22 +423,32 @@ const RAW_GBFS_CITY_REGISTRY = [
|
||||
centerLon: -93.625,
|
||||
systemId: 'bcycle_desmoines',
|
||||
}),
|
||||
bcycleEntry({
|
||||
{
|
||||
// Tugo moved off BCycle to PBSC; the bcycle_tugo feed is gone (404).
|
||||
id: 'tucson-tugo',
|
||||
city: 'Tucson, AZ',
|
||||
centerLat: 32.2226,
|
||||
centerLon: -110.9747,
|
||||
systemId: 'bcycle_tugo',
|
||||
loadRadiusKm: 100,
|
||||
stationInformationUrl:
|
||||
'https://tucson.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
|
||||
stationStatusUrl:
|
||||
'https://tucson.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
|
||||
provider: 'Tugo',
|
||||
}),
|
||||
bcycleEntry({
|
||||
},
|
||||
{
|
||||
// Trinity Metro moved off BCycle to PBSC; the bcycle_fortworth feed is gone.
|
||||
id: 'fort-worth-trinity',
|
||||
city: 'Fort Worth, TX',
|
||||
centerLat: 32.7555,
|
||||
centerLon: -97.3308,
|
||||
systemId: 'bcycle_fortworth',
|
||||
loadRadiusKm: 100,
|
||||
stationInformationUrl:
|
||||
'https://fortworth.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
|
||||
stationStatusUrl:
|
||||
'https://fortworth.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
|
||||
provider: 'Trinity Metro',
|
||||
}),
|
||||
},
|
||||
bcycleEntry({
|
||||
id: 'omaha-heartland',
|
||||
city: 'Omaha, NE',
|
||||
@@ -386,14 +472,20 @@ const RAW_GBFS_CITY_REGISTRY = [
|
||||
centerLon: -82.394,
|
||||
systemId: 'bcycle_greenville',
|
||||
}),
|
||||
bcycleEntry({
|
||||
{
|
||||
// Reddy left BCycle for Social Bicycles; the bcycle_reddy feed is gone
|
||||
// (404). This is the endpoint the MobilityData GBFS registry lists.
|
||||
id: 'buffalo-reddy',
|
||||
city: 'Buffalo, NY',
|
||||
centerLat: 42.8864,
|
||||
centerLon: -78.8784,
|
||||
systemId: 'bcycle_reddy',
|
||||
loadRadiusKm: 100,
|
||||
stationInformationUrl:
|
||||
'https://reddybikeshare.socialbicycles.com/opendata/station_information.json',
|
||||
stationStatusUrl:
|
||||
'https://reddybikeshare.socialbicycles.com/opendata/station_status.json',
|
||||
provider: 'Reddy Bikeshare',
|
||||
}),
|
||||
},
|
||||
bcycleEntry({
|
||||
id: 'las-vegas-rtc-bike-share',
|
||||
city: 'Las Vegas, NV',
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// BIKESHARE REGISTRY — a city entry is only useful if the proxy will serve it.
|
||||
//
|
||||
// A registry entry and the GBFS proxy allowlist are edited in different files,
|
||||
// so a city can look correctly registered while every one of its requests is
|
||||
// rejected at the proxy. These cases hold the two together: each entry is
|
||||
// checked against the same host and path predicates the proxy enforces, so a
|
||||
// new city that the proxy would refuse fails here rather than silently
|
||||
// rendering an empty layer.
|
||||
//
|
||||
// Run with: npm test
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { GBFS_CITY_REGISTRY, CITY_BY_ID } from './registry.js';
|
||||
import { isAllowedGbfsHost, isAllowedGbfsPath } from '../../data/gbfsSource.js';
|
||||
|
||||
test('every registered city is reachable through the GBFS proxy allowlist', () => {
|
||||
for (const city of GBFS_CITY_REGISTRY) {
|
||||
for (const raw of [city.stationInformationUrl, city.stationStatusUrl]) {
|
||||
const url = new URL(raw);
|
||||
assert.equal(
|
||||
isAllowedGbfsHost(url.hostname),
|
||||
true,
|
||||
`${city.id}: host ${url.hostname} is not allowlisted`,
|
||||
);
|
||||
assert.equal(
|
||||
isAllowedGbfsPath(url.pathname, url.hostname),
|
||||
true,
|
||||
`${city.id}: path ${url.pathname} is not allowlisted`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('Bike Share Toronto is registered against the PBSC v2 feeds', () => {
|
||||
const toronto = CITY_BY_ID.get('toronto-bike-share');
|
||||
assert.ok(toronto, 'toronto-bike-share is missing from the registry');
|
||||
assert.equal(toronto.city, 'Toronto, ON');
|
||||
assert.equal(toronto.provider, 'Bike Share Toronto');
|
||||
assert.equal(
|
||||
toronto.stationInformationUrl,
|
||||
'https://toronto.publicbikesystem.net/customer/gbfs/v2/en/station_information.json',
|
||||
);
|
||||
assert.equal(
|
||||
toronto.stationStatusUrl,
|
||||
'https://toronto.publicbikesystem.net/customer/gbfs/v2/en/station_status.json',
|
||||
);
|
||||
});
|
||||
|
||||
test('the Toronto center sits inside the system service area', () => {
|
||||
const toronto = CITY_BY_ID.get('toronto-bike-share');
|
||||
// Bounding box of the 1,069 live stations, sampled from the published
|
||||
// station_information feed: 43.588..43.813 N, -79.603..-79.123 W.
|
||||
assert.ok(toronto.centerLat > 43.588 && toronto.centerLat < 43.813);
|
||||
assert.ok(toronto.centerLon > -79.603 && toronto.centerLon < -79.123);
|
||||
// The system spans roughly 37 km east-west, so the load radius has to clear
|
||||
// half that from the center or downtown pans lose the outer stations.
|
||||
assert.ok(toronto.loadRadiusKm >= 25);
|
||||
});
|
||||
@@ -99,9 +99,17 @@ export function createState({ services }) {
|
||||
|
||||
layerState._loadingOps = 0;
|
||||
|
||||
/** Most recent error message string, or null. */
|
||||
/**
|
||||
* @type {Set<string>} City ids whose most recent fetch failed.
|
||||
*
|
||||
* Per city rather than one layer-wide flag: feeds are fetched independently,
|
||||
* so a retired operator must not mark a layer that other cities are still
|
||||
* populating as failed. Entries are dropped when their city leaves range.
|
||||
* The layer reports failure only when these cost it everything it had to
|
||||
* draw, and degraded otherwise — see `getStats()` in controls.js.
|
||||
*/
|
||||
|
||||
layerState._error = null;
|
||||
layerState._failedCityIds = new Set();
|
||||
|
||||
/** Whether the MAX_TOTAL_POINTS cap warning has already been logged. */
|
||||
|
||||
|
||||
@@ -122,6 +122,9 @@ export function createViewport({ state: layerState, services, parts, source }) {
|
||||
const cityIds = Array.from(layerState._activeCityIds);
|
||||
for (const cityId of cityIds) deactivateCity(cityId);
|
||||
layerState._activeCityIds.clear();
|
||||
// A failure belongs to a city in range; none are once everything is off.
|
||||
layerState._failedCityIds.clear();
|
||||
layerState._count = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -168,11 +171,19 @@ export function createViewport({ state: layerState, services, parts, source }) {
|
||||
return;
|
||||
parts.rendering.applyStatusToPoints(cityId, statusMap);
|
||||
layerState._lastUpdate = Date.now();
|
||||
layerState._error = null;
|
||||
layerState._failedCityIds.delete(cityId);
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') return;
|
||||
// A rejection from superseded work must not mark (or tear down) a city
|
||||
// that a newer proximity check or re-enable now owns.
|
||||
if (
|
||||
!layerState._enabled ||
|
||||
!layerState._activeCityIds.has(cityId) ||
|
||||
generation !== layerState._proximityGeneration
|
||||
)
|
||||
return;
|
||||
console.warn(`[Data:Bikeshare] ${cityId} activate error:`, error);
|
||||
layerState._error = 'GBFS fetch error';
|
||||
layerState._failedCityIds.add(cityId);
|
||||
deactivateCity(cityId);
|
||||
layerState._activeCityIds.delete(cityId);
|
||||
} finally {
|
||||
@@ -208,6 +219,10 @@ export function createViewport({ state: layerState, services, parts, source }) {
|
||||
deactivateCity(cityId);
|
||||
}
|
||||
}
|
||||
// A failure never outlives its city's range.
|
||||
for (const cityId of layerState._failedCityIds) {
|
||||
if (!nextActive.has(cityId)) layerState._failedCityIds.delete(cityId);
|
||||
}
|
||||
|
||||
layerState._activeCityIds = nextActive;
|
||||
if (layerState._activeCityIds.size === 0) {
|
||||
|
||||
@@ -1,4 +1,41 @@
|
||||
import { CAMERA_SEEDS, SOURCE_ENDPOINT } from './policy.js';
|
||||
import {
|
||||
lookupModelSpec,
|
||||
horizontalFovRangeDeg,
|
||||
fixedHorizontalFovDeg,
|
||||
} from './modelSpecs.js';
|
||||
|
||||
/**
|
||||
* Applies datasheet enrichment to a camera in place: when it names a known
|
||||
* hardware `model` (and, when published, a matching `manufacturer`), the spec
|
||||
* record is attached for the HUD and the datasheet's horizontal FOV range
|
||||
* rides along as `specFovMinDeg`/`specFovMaxDeg`. Only fixed-lens hardware
|
||||
* fills the FOV, and only when none was given: an explicit source/seed
|
||||
* `fovDeg` or a curated pose is never overwritten. A PTZ/varifocal range says
|
||||
* what the lens CAN do, not where it is zoomed now.
|
||||
* Unknown models — the common case — change nothing.
|
||||
* @param {Object} camera - Camera being built (mutated).
|
||||
* @param {{model?:string, manufacturer?:string, explicitFov?:boolean}} hardware
|
||||
* - Hardware model/manufacturer from a source or seed, and whether that
|
||||
* source or seed stated its own `fovDeg`.
|
||||
* @param {(v:number,min:number,max:number)=>number} clamp - Pose clamp helper.
|
||||
*/
|
||||
function applyModelEnrichment(camera, hardware, clamp) {
|
||||
const { model, manufacturer, explicitFov } = hardware;
|
||||
const spec = model ? lookupModelSpec(model, manufacturer) : null;
|
||||
if (!spec) return;
|
||||
const range = horizontalFovRangeDeg(spec);
|
||||
if (range) {
|
||||
camera.specFovMinDeg = range.minDeg;
|
||||
camera.specFovMaxDeg = range.maxDeg;
|
||||
}
|
||||
const fixedFov = fixedHorizontalFovDeg(spec);
|
||||
if (fixedFov && !explicitFov && camera.poseSource !== 'curated') {
|
||||
camera.fovDeg = clamp(fixedFov, 20, 125);
|
||||
camera.fovSource = 'datasheet';
|
||||
}
|
||||
camera.spec = spec;
|
||||
}
|
||||
|
||||
export function createCatalog({ state: layerState, services, parts, source }) {
|
||||
const { CITY_POIS } = services.locations;
|
||||
@@ -45,6 +82,11 @@ export function createCatalog({ state: layerState, services, parts, source }) {
|
||||
parts.model.clamp(seed.elevationM ?? 22, 8, 80),
|
||||
pitchDeg: parts.model.clamp(seed.pitchDeg ?? -17, -40, -4),
|
||||
};
|
||||
applyModelEnrichment(
|
||||
camera,
|
||||
{ model: seed.model, explicitFov: seed.fovDeg != null },
|
||||
parts.model.clamp,
|
||||
);
|
||||
parts.model.ensureCameraPose(camera);
|
||||
catalog.push(camera);
|
||||
}
|
||||
@@ -204,7 +246,22 @@ export function createCatalog({ state: layerState, services, parts, source }) {
|
||||
? source.groundHeights
|
||||
: null,
|
||||
poseSource,
|
||||
model: String(source.model || ''),
|
||||
manufacturer: String(source.manufacturer || ''),
|
||||
};
|
||||
applyModelEnrichment(
|
||||
camera,
|
||||
{
|
||||
model: source.model || seed?.model,
|
||||
manufacturer: source.model ? source.manufacturer : '',
|
||||
explicitFov:
|
||||
(source.fovDeg != null && Number.isFinite(Number(source.fovDeg))) ||
|
||||
CAMERA_SEEDS.some(
|
||||
(entry) => entry.id === id && entry.fovDeg != null,
|
||||
),
|
||||
},
|
||||
parts.model.clamp,
|
||||
);
|
||||
parts.model.ensureCameraPose(camera);
|
||||
catalog.push(camera);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,531 @@
|
||||
/**
|
||||
* @module cctvModelSpecs
|
||||
*
|
||||
* Hardware-model spec enrichment for the CCTV layer.
|
||||
*
|
||||
* When a catalog camera names its hardware model (source packs may set
|
||||
* `model`; hand-authored seeds may too), the model resolves to a datasheet
|
||||
* record and the HUD meta line gains the model's real specs. A fixed-lens
|
||||
* model's horizontal FOV replaces the frustum's estimate; a PTZ/varifocal or
|
||||
* multi-sensor range is carried as capability metadata beside the estimate,
|
||||
* because the lens's current zoom is unknown.
|
||||
* Cameras without an identified model are untouched — unknown never implies
|
||||
* anything.
|
||||
*
|
||||
* Spec records are vendored verbatim from the CCTV Camera Database
|
||||
* (https://github.com/ch-bas/cctv-camera-database, CC0-1.0) — an open dataset
|
||||
* of 28,000+ camera models transcribed from manufacturer datasheets (84% with
|
||||
* FOV, as of v2.26.0). This module vendors the records that the currently integrated public
|
||||
* catalogs publish hardware for — King County WA (Cohu, Bosch AUTODOME, Axis
|
||||
* Q6135-LE), plus the Bosch AUTODOME and Axis PTZ/multisensor models that
|
||||
* appear in Sarasota County FL and Sioux Falls SD open data; packs that
|
||||
* publish other hardware models can grow it, or vendor the full extract.
|
||||
*
|
||||
* Scope guardrail: records describe camera PRODUCTS (optics, resolution,
|
||||
* night vision) — never endpoints, stream paths, or access/config data.
|
||||
*/
|
||||
|
||||
/** Verbatim records from the dataset's model-enrichment extract (v2.26.0). */
|
||||
const RECORDS = [
|
||||
{
|
||||
id: 'axis-q6135-le',
|
||||
brand: 'Axis',
|
||||
model: 'Q6135-LE',
|
||||
type: 'ptz',
|
||||
url: 'https://www.cctv-database.com/camera/axis-q6135-le/',
|
||||
aliases: ['01959-004'],
|
||||
fov_deg: '58.3-2.4 horizontal',
|
||||
resolution: { mp: 2, w: 1920, h: 1080, label: '1080p' },
|
||||
ptz: true,
|
||||
autotracking: true,
|
||||
varifocal: true,
|
||||
night_vision: { type: 'ir', range_m: 250 },
|
||||
environment: ['outdoor'],
|
||||
ndaa_compliant: true,
|
||||
},
|
||||
{
|
||||
id: 'hanwha-xnp-6400rw',
|
||||
brand: 'Hanwha',
|
||||
model: 'XNP-6400RW',
|
||||
type: 'ptz',
|
||||
url: 'https://www.cctv-database.com/camera/hanwha-xnp-6400rw/',
|
||||
aliases: ['Wisenet PTZ Plus 2MP 40x IR PTZ'],
|
||||
fov_deg: '65.66-1.88 horizontal / 39.4-1.09 vertical',
|
||||
resolution: { mp: 2, w: 1920, h: 1080, label: '1080p' },
|
||||
ptz: true,
|
||||
autotracking: true,
|
||||
varifocal: true,
|
||||
night_vision: { type: 'ir', range_m: 200 },
|
||||
environment: ['outdoor'],
|
||||
ndaa_compliant: true,
|
||||
},
|
||||
{
|
||||
id: 'hikvision-ds-2cd2087g3-li2uy',
|
||||
brand: 'Hikvision',
|
||||
model: 'DS-2CD2087G3-LI2UY',
|
||||
type: 'bullet',
|
||||
url: 'https://www.cctv-database.com/camera/hikvision-ds-2cd2087g3-li2uy/',
|
||||
fov_deg: '108.8 horizontal (2.8mm) / 93.3 horizontal (4mm)',
|
||||
resolution: { mp: 8, w: 3840, h: 2160, label: '8MP (4K)' },
|
||||
night_vision: { type: 'color', range_m: 40 },
|
||||
environment: ['outdoor'],
|
||||
ndaa_compliant: false,
|
||||
},
|
||||
{
|
||||
id: 'axis-f4105-lre',
|
||||
brand: 'Axis',
|
||||
model: 'F4105-LRE',
|
||||
type: 'dome',
|
||||
url: 'https://www.cctv-database.com/camera/axis-f4105-lre/',
|
||||
fov_deg: '110 horizontal, 60 vertical',
|
||||
resolution: { mp: 2, w: 1920, h: 1080, label: '1080p HD' },
|
||||
night_vision: { type: 'ir', range_m: 10 },
|
||||
environment: ['outdoor'],
|
||||
ndaa_compliant: true,
|
||||
},
|
||||
{
|
||||
id: 'axis-p3707-pe',
|
||||
brand: 'Axis',
|
||||
model: 'P3707-PE',
|
||||
type: 'panoramic',
|
||||
url: 'https://www.cctv-database.com/camera/axis-p3707-pe/',
|
||||
aliases: ['AXIS P3707-PE', 'P3707-PE'],
|
||||
fov_deg: '108-54 horizontal per sensor (4x 1080p)',
|
||||
resolution: {
|
||||
mp: 8,
|
||||
w: 1920,
|
||||
h: 1080,
|
||||
label: '4x 2MP (8MP total; 4x 1080p)',
|
||||
},
|
||||
lens_count: 4,
|
||||
varifocal: true,
|
||||
environment: ['outdoor'],
|
||||
},
|
||||
{
|
||||
id: 'axis-q6000-e-mk-ii',
|
||||
brand: 'Axis',
|
||||
model: 'Q6000-E Mk II',
|
||||
type: 'panoramic',
|
||||
url: 'https://www.cctv-database.com/camera/axis-q6000-e-mk-ii/',
|
||||
aliases: [
|
||||
'AXIS Q6000-E Mk II',
|
||||
'Q6000-E Mk II 50 Hz',
|
||||
'Q6000-E Mk II 60 Hz',
|
||||
],
|
||||
fov_deg: 'up to 360 combined; 113-152 horizontal per sensor',
|
||||
resolution: {
|
||||
mp: 8,
|
||||
w: 1920,
|
||||
h: 1080,
|
||||
label: '4x 2MP (8MP total; 4x 1080p)',
|
||||
},
|
||||
lens_count: 4,
|
||||
environment: ['outdoor'],
|
||||
},
|
||||
{
|
||||
id: 'axis-q6054-e',
|
||||
brand: 'Axis',
|
||||
model: 'Q6054-E',
|
||||
type: 'ptz',
|
||||
url: 'https://www.cctv-database.com/camera/axis-q6054-e/',
|
||||
aliases: ['AXIS Q6054-E', 'Q6054-E 50 Hz', 'Q6054-E 60 Hz'],
|
||||
fov_deg: '62.9-2.2 horizontal',
|
||||
resolution: { mp: 1, w: 1280, h: 720, label: '720p HD' },
|
||||
ptz: true,
|
||||
autotracking: true,
|
||||
varifocal: true,
|
||||
environment: ['outdoor'],
|
||||
},
|
||||
{
|
||||
id: 'axis-q6155-e',
|
||||
brand: 'Axis',
|
||||
model: 'Q6155-E',
|
||||
type: 'ptz',
|
||||
url: 'https://www.cctv-database.com/camera/axis-q6155-e/',
|
||||
aliases: ['AXIS Q6155-E', 'Q6155-E 50 Hz', 'Q6155-E 60 Hz'],
|
||||
fov_deg: '66.7-2.36 horizontal',
|
||||
resolution: { mp: 2, w: 1920, h: 1080, label: '1080p HD' },
|
||||
ptz: true,
|
||||
autotracking: true,
|
||||
varifocal: true,
|
||||
environment: ['outdoor'],
|
||||
},
|
||||
{
|
||||
id: 'bosch-autodome-500i',
|
||||
brand: 'Bosch',
|
||||
model: 'AutoDome 500i Series (VG4)',
|
||||
type: 'ptz',
|
||||
url: 'https://www.cctv-database.com/camera/bosch-autodome-500i/',
|
||||
aliases: [
|
||||
'VG4 AUTODOME',
|
||||
'VG4 AUTODOME H.264',
|
||||
'AutoDome 500i',
|
||||
'AutoDome 500i Series',
|
||||
'VG4-500i',
|
||||
'VG4-500',
|
||||
],
|
||||
fov_deg: '57.8-1.7 horizontal',
|
||||
resolution: { mp: 0.4, label: '540 TVL (NTSC/PAL)' },
|
||||
ptz: true,
|
||||
autotracking: true,
|
||||
varifocal: true,
|
||||
environment: ['outdoor'],
|
||||
},
|
||||
{
|
||||
id: 'bosch-autodome-ip-dynamic-7000-hd',
|
||||
brand: 'Bosch',
|
||||
model: 'AUTODOME IP dynamic 7000 HD',
|
||||
type: 'ptz',
|
||||
url: 'https://www.cctv-database.com/camera/bosch-autodome-ip-dynamic-7000-hd/',
|
||||
aliases: ['AUTODOME IP dynamic 7000 HD', 'AUTODOME 7000 dynamic HD'],
|
||||
fov_deg: '65-2.3 horizontal',
|
||||
resolution: { mp: 2, w: 1920, h: 1080, label: '1080p HD (2MP)' },
|
||||
ptz: true,
|
||||
autotracking: true,
|
||||
varifocal: true,
|
||||
environment: ['outdoor'],
|
||||
},
|
||||
{
|
||||
id: 'bosch-autodome-ip-starlight-7000-hd',
|
||||
brand: 'Bosch',
|
||||
model: 'AUTODOME IP starlight 7000 HD',
|
||||
type: 'ptz',
|
||||
url: 'https://www.cctv-database.com/camera/bosch-autodome-ip-starlight-7000-hd/',
|
||||
aliases: [
|
||||
'AUTODOME IP starlight 7000 HD',
|
||||
'Starlight 7000HD',
|
||||
'VG5-7130-CPT4',
|
||||
'VG5-7130',
|
||||
],
|
||||
fov_deg: '59-2.1 horizontal',
|
||||
resolution: { mp: 1, w: 1280, h: 720, label: '720p HD (1.3MP sensor)' },
|
||||
ptz: true,
|
||||
autotracking: true,
|
||||
varifocal: true,
|
||||
environment: ['outdoor'],
|
||||
},
|
||||
{
|
||||
id: 'bosch-ndp-5502-30',
|
||||
brand: 'Bosch',
|
||||
model: 'AUTODOME IP 5000i (NDP-5502-Z30)',
|
||||
type: 'ptz',
|
||||
url: 'https://www.cctv-database.com/camera/bosch-ndp-5502-30/',
|
||||
aliases: [
|
||||
'AUTODOME IP 5000i',
|
||||
'NDP-5502-Z30',
|
||||
'NDP-5502-Z30C',
|
||||
'NDP-5502-30',
|
||||
],
|
||||
fov_deg: '60.9-2.4 horizontal',
|
||||
resolution: { mp: 2, w: 1920, h: 1080, label: '1080p HD (2MP)' },
|
||||
ptz: true,
|
||||
varifocal: true,
|
||||
environment: ['outdoor'],
|
||||
},
|
||||
{
|
||||
id: 'bosch-ndp-7512-z30',
|
||||
brand: 'Bosch',
|
||||
model: 'AUTODOME IP starlight 7000i (NDP-7512-Z30)',
|
||||
type: 'ptz',
|
||||
url: 'https://www.cctv-database.com/camera/bosch-ndp-7512-z30/',
|
||||
aliases: [
|
||||
'AUTODOME IP starlight 7000i',
|
||||
'NDP-7512-Z30',
|
||||
'NDP-7512-Z30C',
|
||||
'NDP-7512-Z30K',
|
||||
],
|
||||
fov_deg: '64.7-2.3 horizontal',
|
||||
resolution: { mp: 2, w: 1920, h: 1080, label: '1080p HD (2MP)' },
|
||||
ptz: true,
|
||||
autotracking: true,
|
||||
varifocal: true,
|
||||
environment: ['outdoor'],
|
||||
},
|
||||
{
|
||||
id: 'bosch-vg5-836-ecev',
|
||||
brand: 'Bosch',
|
||||
model: 'AUTODOME 800 Series HD PTZ (VG5-836-ECEV)',
|
||||
type: 'ptz',
|
||||
url: 'https://www.cctv-database.com/camera/bosch-vg5-836-ecev/',
|
||||
aliases: ['VG5-836-ECEV', 'VG5-836', 'AUTODOME 800 Series'],
|
||||
resolution: { mp: 2, label: '1080p' },
|
||||
ptz: true,
|
||||
autotracking: true,
|
||||
environment: ['outdoor'],
|
||||
},
|
||||
{
|
||||
id: 'cohu-3920-idome',
|
||||
brand: 'Cohu',
|
||||
model: '3920 iDome',
|
||||
type: 'ptz',
|
||||
url: 'https://www.cctv-database.com/camera/cohu-3920-idome/',
|
||||
aliases: [
|
||||
'3920',
|
||||
'3920 Series',
|
||||
'3923',
|
||||
'3925',
|
||||
'3925 Dome',
|
||||
'iDome 3925',
|
||||
'Cohu iDome 3920 Series',
|
||||
],
|
||||
resolution: { mp: 0.4, label: 'NTSC / 470 TVL' },
|
||||
ptz: true,
|
||||
varifocal: true,
|
||||
environment: ['outdoor'],
|
||||
},
|
||||
{
|
||||
id: 'cohu-3950-iview',
|
||||
brand: 'Cohu',
|
||||
model: '3950 iVIEW',
|
||||
type: 'ptz',
|
||||
url: 'https://www.cctv-database.com/camera/cohu-3950-iview/',
|
||||
aliases: ['3950', '3950 Series', 'Cohu 3950 Series iVIEW'],
|
||||
resolution: { mp: 0.4, label: 'NTSC / 470 TVL' },
|
||||
ptz: true,
|
||||
varifocal: true,
|
||||
environment: ['outdoor'],
|
||||
},
|
||||
{
|
||||
id: 'cohu-3960',
|
||||
brand: 'Cohu',
|
||||
model: '3960',
|
||||
type: 'ptz',
|
||||
url: 'https://www.cctv-database.com/camera/cohu-3960/',
|
||||
aliases: ['3960 Series', '3964', '3965', 'Cohu 3960 iView'],
|
||||
resolution: { mp: 0.4, label: 'NTSC/PAL / 470 TVL' },
|
||||
ptz: true,
|
||||
varifocal: true,
|
||||
environment: ['outdoor'],
|
||||
},
|
||||
];
|
||||
|
||||
/** Lowercase + collapse whitespace, the shared normal form for matching. */
|
||||
function normalize(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Whitespace words of a name, lowercased, with surrounding punctuation trimmed
|
||||
* so "(NDP-7512-Z30)" reads as "ndp-7512-z30". Inner hyphens and dots stay:
|
||||
* "f4105-lre-unknown" is one word, never "f4105-lre" plus noise.
|
||||
* @param {string} value
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function words(value) {
|
||||
return normalize(value)
|
||||
.split(' ')
|
||||
.map((raw) => raw.replace(/^[^a-z0-9]+|[^a-z0-9]+$/g, ''))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Descriptor words that never identify hardware: product-type nouns, codec
|
||||
* and resolution/zoom/frequency tokens ("h.264", "2mp", "40x", "1080p").
|
||||
*/
|
||||
const GENERIC_WORDS = new Set([
|
||||
'network',
|
||||
'camera',
|
||||
'cam',
|
||||
'ip',
|
||||
'ptz',
|
||||
'dome',
|
||||
'bullet',
|
||||
'box',
|
||||
'hd',
|
||||
'ir',
|
||||
'outdoor',
|
||||
'indoor',
|
||||
'series',
|
||||
]);
|
||||
|
||||
function isGeneric(word) {
|
||||
return (
|
||||
GENERIC_WORDS.has(word) ||
|
||||
/^h\.?\d+$/.test(word) ||
|
||||
/^\d+(?:\.\d+)?(?:mp|p|k|x|mm|hz|tvl)$/.test(word)
|
||||
);
|
||||
}
|
||||
|
||||
/** A model code: letters AND digits, and not a generic descriptor. */
|
||||
function isModelCode(word) {
|
||||
return /[a-z]/.test(word) && /\d/.test(word) && !isGeneric(word);
|
||||
}
|
||||
|
||||
/** Hyphenated variant suffixes that do not change the hardware. */
|
||||
const VARIANT_SUFFIXES = new Set(['50hz', '60hz', 'ntsc', 'pal']);
|
||||
|
||||
/** "q6155-e-60hz" → "q6155-e"; "f4105-lre-unknown" is left as is. */
|
||||
function stripVariantSuffix(word) {
|
||||
let out = word;
|
||||
for (;;) {
|
||||
const cut = out.lastIndexOf('-');
|
||||
if (cut <= 0 || !VARIANT_SUFFIXES.has(out.slice(cut + 1))) return out;
|
||||
out = out.slice(0, cut);
|
||||
}
|
||||
}
|
||||
|
||||
/** Manufacturer names (lowercased words) accepted for each record brand. */
|
||||
const BRAND_ALIASES = {
|
||||
Hanwha: ['wisenet', 'samsung'],
|
||||
Hikvision: ['hiwatch'],
|
||||
};
|
||||
|
||||
function brandWords(record) {
|
||||
return new Set([
|
||||
...words(record.brand),
|
||||
...(BRAND_ALIASES[record.brand] || []),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* One entry per record name (model + aliases). `required` is every
|
||||
* non-generic word of the name — all must appear in a query, so a generation
|
||||
* or suffix ("Mk II", "7000i" vs "7000") is never dropped. `hasCode` marks
|
||||
* names carrying a letter+digit model code; names without one ("3950",
|
||||
* "01959-004", "AUTODOME 800 Series") match only when the brand does.
|
||||
*/
|
||||
const NAME_INDEX = RECORDS.flatMap((record) => {
|
||||
const brand = brandWords(record);
|
||||
return [record.model, ...(record.aliases || [])].map((name) => {
|
||||
const nameWords = words(name);
|
||||
return {
|
||||
record,
|
||||
brand,
|
||||
allowed: new Set(nameWords),
|
||||
required: nameWords.filter((word) => !isGeneric(word)),
|
||||
hasCode: nameWords.some(isModelCode),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Looks up a spec record for a hardware-model string.
|
||||
*
|
||||
* A record name matches when every non-generic word of that name appears in
|
||||
* the query (case-insensitive, punctuation-trimmed) and every other query word
|
||||
* is generic or the record's brand. That lets a VERBOSE registry string
|
||||
* resolve — "AXIS Q6155-E PTZ Dome Network Camera" finds the Q6155-E — while
|
||||
* an unrelated code ("Q1615"), a generation suffix ("Mk II") or a longer part
|
||||
* number ("F4105-LRE-UNKNOWN") rejects the match. Codec, resolution and
|
||||
* product-type words never identify hardware on their own. A name with no
|
||||
* letter+digit model code ("3950") needs the brand, from `manufacturer` or the
|
||||
* query itself. A `manufacturer` that is not the record's brand always
|
||||
* rejects. Matches on two different records are ambiguous → null (never
|
||||
* guesses).
|
||||
* @param {string} model - Hardware model as named by a source pack or seed.
|
||||
* @param {string} [manufacturer] - The source's manufacturer, when published.
|
||||
* @returns {Object|null} The verbatim spec record, or null when unknown.
|
||||
*/
|
||||
export function lookupModelSpec(model, manufacturer) {
|
||||
const query = words(model).map(stripVariantSuffix);
|
||||
if (!query.length) return null;
|
||||
const maker = words(manufacturer);
|
||||
const found = new Set();
|
||||
for (const entry of NAME_INDEX) {
|
||||
const makerMatches = maker.some((word) => entry.brand.has(word));
|
||||
if (maker.length && !makerMatches) continue;
|
||||
if (
|
||||
!entry.hasCode &&
|
||||
!makerMatches &&
|
||||
!query.some((word) => entry.brand.has(word))
|
||||
)
|
||||
continue;
|
||||
if (!entry.required.length) continue;
|
||||
if (!entry.required.every((word) => query.includes(word))) continue;
|
||||
if (
|
||||
!query.every(
|
||||
(word) =>
|
||||
entry.allowed.has(word) || entry.brand.has(word) || isGeneric(word),
|
||||
)
|
||||
)
|
||||
continue;
|
||||
found.add(entry.record);
|
||||
}
|
||||
return found.size === 1 ? [...found][0] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Datasheet horizontal FOV as the range the hardware can cover. Only
|
||||
* segments stating a horizontal figure count — vertical and "combined"
|
||||
* panoramic figures never do — and parenthesized lens/sensor notes are
|
||||
* ignored. A zoom ('58.3-2.4 horizontal') and a lens-option list
|
||||
* ('108.8 horizontal (2.8mm) / 93.3 horizontal (4mm)') both yield a range.
|
||||
* @param {Object} spec - A record from this module.
|
||||
* @returns {{minDeg:number, maxDeg:number}|null} Null when unstated.
|
||||
*/
|
||||
export function horizontalFovRangeDeg(spec) {
|
||||
const values = [];
|
||||
for (const segment of String(spec?.fov_deg || '').split(/[/;,]/)) {
|
||||
if (!/horizontal/i.test(segment)) continue;
|
||||
const figures = segment.replace(/\([^)]*\)/g, '').split(/horizontal/i)[0];
|
||||
for (const match of figures.matchAll(/\d+(?:\.\d+)?/g)) {
|
||||
const value = Number(match[0]);
|
||||
if (Number.isFinite(value) && value > 0) values.push(value);
|
||||
}
|
||||
}
|
||||
if (!values.length) return null;
|
||||
return { minDeg: Math.min(...values), maxDeg: Math.max(...values) };
|
||||
}
|
||||
|
||||
/**
|
||||
* The datasheet horizontal FOV as the camera's CURRENT optical state, which
|
||||
* holds only for fixed single-lens hardware stating one figure. PTZ,
|
||||
* varifocal and multi-sensor models publish a capability range whose in-use
|
||||
* value is unknown — known hardware capability is not known pose state — so
|
||||
* they return null and keep their estimated FOV.
|
||||
* @param {Object} spec - A record from this module.
|
||||
* @returns {number|null} Fixed horizontal FOV in degrees, or null.
|
||||
*/
|
||||
export function fixedHorizontalFovDeg(spec) {
|
||||
if (!spec || spec.ptz || spec.varifocal || (spec.lens_count || 1) > 1)
|
||||
return null;
|
||||
const range = horizontalFovRangeDeg(spec);
|
||||
return range && range.minDeg === range.maxDeg ? range.minDeg : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the camera's CURRENT FOV is the datasheet figure: it was filled
|
||||
* from a fixed-lens datasheet and no manual calibration offset has moved it.
|
||||
* @param {Object} camera - Catalog camera (see applyModelEnrichment).
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isDatasheetFov(camera) {
|
||||
return camera?.fovSource === 'datasheet' && !camera.calibration?.fovDeg;
|
||||
}
|
||||
|
||||
/**
|
||||
* HUD token for a datasheet FOV that describes capability rather than the
|
||||
* camera's current state, e.g. 'SPEC FOV 2.4–58° (CAPABILITY)'.
|
||||
* @param {Object} camera - Catalog camera (see applyModelEnrichment).
|
||||
* @returns {string|null} Null when the datasheet FOV already IS the camera's
|
||||
* FOV (fixed lens) or no datasheet range is known.
|
||||
*/
|
||||
export function specFovCapabilityToken(camera) {
|
||||
if (!camera || camera.fovSource === 'datasheet') return null;
|
||||
const { specFovMinDeg: min, specFovMaxDeg: max } = camera;
|
||||
if (!Number.isFinite(min) || !Number.isFinite(max)) return null;
|
||||
const fmt = (deg) => (deg < 10 ? deg.toFixed(1) : String(Math.round(deg)));
|
||||
const span = min === max ? fmt(max) : `${fmt(min)}–${fmt(max)}`;
|
||||
return `SPEC FOV ${span}° (CAPABILITY)`;
|
||||
}
|
||||
|
||||
/**
|
||||
* One-line spec summary for HUD meta text.
|
||||
* @param {Object} spec - A record from this module.
|
||||
* @returns {string} e.g. 'Axis Q6135-LE · 1080p · IR 250m · PTZ' ('' for null).
|
||||
*/
|
||||
export function specSummary(spec) {
|
||||
if (!spec) return '';
|
||||
const parts = [`${spec.brand} ${spec.model}`];
|
||||
if (spec.resolution?.label) parts.push(spec.resolution.label);
|
||||
if (spec.night_vision) {
|
||||
parts.push(
|
||||
`${spec.night_vision.type.toUpperCase()}${spec.night_vision.range_m ? ` ${spec.night_vision.range_m}m` : ''}`,
|
||||
);
|
||||
}
|
||||
if (spec.ptz) parts.push('PTZ');
|
||||
return parts.join(' · ');
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
// src/layers/cctv/modelSpecs.test.mjs
|
||||
// Hardware-model spec enrichment: lookup semantics (case-insensitive, alias
|
||||
// matching, unknown → null — never a guess), datasheet-FOV parsing across the
|
||||
// real fov_deg string shapes, and the HUD spec-summary line.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
lookupModelSpec,
|
||||
horizontalFovRangeDeg,
|
||||
fixedHorizontalFovDeg,
|
||||
specFovCapabilityToken,
|
||||
isDatasheetFov,
|
||||
specSummary,
|
||||
} from './modelSpecs.js';
|
||||
import { createCatalog } from './catalog.js';
|
||||
|
||||
test('lookupModelSpec matches case-insensitively and trims', () => {
|
||||
const spec = lookupModelSpec(' q6135-le ');
|
||||
assert.ok(spec);
|
||||
assert.equal(spec.brand, 'Axis');
|
||||
assert.equal(lookupModelSpec('Q6135-LE'), spec);
|
||||
});
|
||||
|
||||
test('lookupModelSpec matches aliases', () => {
|
||||
// A part number without letters needs the brand.
|
||||
const byAlias = lookupModelSpec('01959-004', 'Axis');
|
||||
assert.ok(byAlias);
|
||||
assert.equal(byAlias.model, 'Q6135-LE');
|
||||
});
|
||||
|
||||
test('vendored table covers the integrated catalogs published hardware', () => {
|
||||
// Models the live packs publish: King County (Cohu, AUTODOME IP 5000i),
|
||||
// Sarasota (Bosch AUTODOME 7000/VG4), Sioux Falls (Axis).
|
||||
// King County publishes "3950" with Manufacturer "Cohu".
|
||||
const cohu = lookupModelSpec('3950', 'Cohu');
|
||||
assert.equal(cohu?.brand, 'Cohu');
|
||||
const autodome = lookupModelSpec('AUTODOME IP 5000i');
|
||||
assert.ok(autodome && horizontalFovRangeDeg(autodome).maxDeg > 0);
|
||||
const vg4 = lookupModelSpec('VG4 AUTODOME H.264'); // Sarasota's string
|
||||
assert.ok(vg4);
|
||||
const axis = lookupModelSpec('Q6155-E'); // Sioux Falls
|
||||
assert.deepEqual(horizontalFovRangeDeg(axis), { minDeg: 2.36, maxDeg: 66.7 });
|
||||
});
|
||||
|
||||
test('verbose registry strings resolve via distinctive-token fallback', () => {
|
||||
// Sioux Falls publishes the full product title, not the bare model.
|
||||
assert.equal(
|
||||
lookupModelSpec('AXIS Q6155-E PTZ Dome Network Camera')?.model,
|
||||
'Q6155-E',
|
||||
);
|
||||
assert.equal(lookupModelSpec('AXIS Q6000-E Mk II')?.model, 'Q6000-E Mk II');
|
||||
assert.equal(
|
||||
lookupModelSpec('AUTODOME IP starlight 7000i camera')?.id,
|
||||
'bosch-ndp-7512-z30',
|
||||
);
|
||||
// Punctuation around the model token doesn't defeat the match.
|
||||
assert.equal(
|
||||
lookupModelSpec('camera (P3707-PE), outdoor')?.model,
|
||||
'P3707-PE',
|
||||
);
|
||||
});
|
||||
|
||||
test('token fallback never matches on generic words or wrong models', () => {
|
||||
// Only generic descriptors — no distinctive hardware token — stays null.
|
||||
assert.equal(lookupModelSpec('PTZ Dome Camera'), null);
|
||||
assert.equal(lookupModelSpec('HD Network Camera'), null);
|
||||
assert.equal(lookupModelSpec('Outdoor Dome Camera'), null);
|
||||
// A real-looking but unknown model resolves to nothing, not a near neighbour.
|
||||
assert.equal(lookupModelSpec('AXIS FooBar-9 Network Camera'), null);
|
||||
// A generation token alone must not collide with a similar one: "7000"
|
||||
// belongs to two records (dynamic / starlight 7000 HD), so a bare
|
||||
// "AUTODOME 7000" is ambiguous → null rather than an arbitrary pick.
|
||||
assert.equal(lookupModelSpec('AUTODOME 7000'), null);
|
||||
});
|
||||
|
||||
test('exact match still wins over the token fallback', () => {
|
||||
// "3965" is an alias of the 3960, not the 3950.
|
||||
assert.equal(lookupModelSpec('3965', 'Cohu')?.id, 'cohu-3960');
|
||||
assert.equal(lookupModelSpec('AUTODOME IP 5000i')?.id, 'bosch-ndp-5502-30');
|
||||
});
|
||||
|
||||
test('lookupModelSpec returns null for unknown or empty input — never guesses', () => {
|
||||
assert.equal(lookupModelSpec('NOT-A-REAL-MODEL'), null);
|
||||
assert.equal(lookupModelSpec(''), null);
|
||||
assert.equal(lookupModelSpec(null), null);
|
||||
assert.equal(lookupModelSpec(undefined), null);
|
||||
});
|
||||
|
||||
test('horizontalFovRangeDeg reads the full range across real fov_deg shapes', () => {
|
||||
const range = (model) => horizontalFovRangeDeg(lookupModelSpec(model));
|
||||
// PTZ zoom: both ends, not just the wide one.
|
||||
assert.deepEqual(range('Q6135-LE'), { minDeg: 2.4, maxDeg: 58.3 });
|
||||
// Vertical figures never count.
|
||||
assert.deepEqual(range('XNP-6400RW'), { minDeg: 1.88, maxDeg: 65.66 });
|
||||
// Lens options: parenthesized focal lengths are not FOV figures.
|
||||
assert.deepEqual(range('DS-2CD2087G3-LI2UY'), {
|
||||
minDeg: 93.3,
|
||||
maxDeg: 108.8,
|
||||
});
|
||||
assert.deepEqual(range('F4105-LRE'), { minDeg: 110, maxDeg: 110 });
|
||||
// The "360 combined" panoramic figure is not a horizontal lens FOV.
|
||||
assert.deepEqual(range('Q6000-E Mk II'), { minDeg: 113, maxDeg: 152 });
|
||||
assert.deepEqual(range('P3707-PE'), { minDeg: 54, maxDeg: 108 });
|
||||
});
|
||||
|
||||
test('horizontalFovRangeDeg returns null when no numeric FOV is stated', () => {
|
||||
assert.equal(horizontalFovRangeDeg({ fov_deg: '' }), null);
|
||||
assert.equal(horizontalFovRangeDeg({ fov_deg: 'wide dynamic range' }), null);
|
||||
assert.equal(horizontalFovRangeDeg({ fov_deg: '60 vertical' }), null);
|
||||
assert.equal(horizontalFovRangeDeg({}), null);
|
||||
assert.equal(horizontalFovRangeDeg(null), null);
|
||||
});
|
||||
|
||||
test('fixedHorizontalFovDeg is the current FOV only for fixed single-lens hardware', () => {
|
||||
// Fixed dome, one horizontal figure: the datasheet IS the optical state.
|
||||
assert.equal(fixedHorizontalFovDeg(lookupModelSpec('F4105-LRE')), 110);
|
||||
// Known hardware capability != known pose state: PTZ, multi-sensor, and a
|
||||
// model sold in several lens options all stay estimated.
|
||||
for (const model of [
|
||||
'Q6135-LE',
|
||||
'XNP-6400RW',
|
||||
'AUTODOME IP 5000i',
|
||||
'P3707-PE',
|
||||
'Q6000-E Mk II',
|
||||
'DS-2CD2087G3-LI2UY',
|
||||
]) {
|
||||
assert.equal(fixedHorizontalFovDeg(lookupModelSpec(model)), null, model);
|
||||
}
|
||||
assert.equal(fixedHorizontalFovDeg(null), null);
|
||||
});
|
||||
|
||||
test('specFovCapabilityToken labels a datasheet range as capability, never as current FOV', () => {
|
||||
assert.equal(
|
||||
specFovCapabilityToken({ specFovMinDeg: 2.4, specFovMaxDeg: 58.3 }),
|
||||
'SPEC FOV 2.4–58° (CAPABILITY)',
|
||||
);
|
||||
assert.equal(
|
||||
specFovCapabilityToken({ specFovMinDeg: 113, specFovMaxDeg: 113 }),
|
||||
'SPEC FOV 113° (CAPABILITY)',
|
||||
);
|
||||
// Already the camera's FOV (fixed lens) or nothing known: no token.
|
||||
assert.equal(
|
||||
specFovCapabilityToken({
|
||||
fovSource: 'datasheet',
|
||||
specFovMinDeg: 110,
|
||||
specFovMaxDeg: 110,
|
||||
}),
|
||||
null,
|
||||
);
|
||||
assert.equal(specFovCapabilityToken({}), null);
|
||||
assert.equal(specFovCapabilityToken(null), null);
|
||||
});
|
||||
|
||||
/** Browser catalog with the pose math stubbed: only enrichment is under test. */
|
||||
function browserCatalog() {
|
||||
const model = {
|
||||
safeNumber: (value, fallback = NaN) =>
|
||||
Number.isFinite(Number(value)) ? Number(value) : fallback,
|
||||
normalizeHeading: (deg) => ((deg % 360) + 360) % 360,
|
||||
clamp: (value, lo, hi) => Math.min(hi, Math.max(lo, value)),
|
||||
headingFromId: () => 0,
|
||||
normalizeFeedType: (type) => type,
|
||||
ensureCameraPose: () => {},
|
||||
};
|
||||
return createCatalog({
|
||||
state: {},
|
||||
services: { locations: { CITY_POIS: {} } },
|
||||
parts: { model },
|
||||
source: {},
|
||||
});
|
||||
}
|
||||
|
||||
test('catalog keeps the estimated FOV for PTZ hardware and fills only fixed-lens datasheets', () => {
|
||||
const [ptz, fixed, unknown] = browserCatalog().buildCatalogFromSources([
|
||||
{ id: 'ptz', lat: 47.7, lon: -122, fovDeg: 44, model: 'Q6135-LE' },
|
||||
{ id: 'fixed', lat: 47.7, lon: -122, model: 'F4105-LRE' },
|
||||
{ id: 'unknown', lat: 47.7, lon: -122, fovDeg: 44, model: 'NOPE-1' },
|
||||
]);
|
||||
|
||||
// PTZ: the frustum keeps its estimate; the datasheet range is metadata.
|
||||
assert.equal(ptz.fovDeg, 44);
|
||||
assert.equal(ptz.fovSource, undefined);
|
||||
assert.equal(ptz.specFovMinDeg, 2.4);
|
||||
assert.equal(ptz.specFovMaxDeg, 58.3);
|
||||
assert.equal(ptz.spec.model, 'Q6135-LE');
|
||||
|
||||
// Fixed lens: the datasheet figure is the optical state.
|
||||
assert.equal(fixed.fovDeg, 110);
|
||||
assert.equal(fixed.fovSource, 'datasheet');
|
||||
|
||||
// Unknown hardware changes nothing.
|
||||
assert.equal(unknown.fovDeg, 44);
|
||||
assert.equal(unknown.spec, undefined);
|
||||
assert.equal(unknown.specFovMaxDeg, undefined);
|
||||
});
|
||||
|
||||
test('codec, format and product words never identify hardware', () => {
|
||||
// "h.264" is in the VG4 AUTODOME alias; it is a codec, not a model code.
|
||||
assert.equal(lookupModelSpec('Axis Q1615 H.264 Network Camera'), null);
|
||||
assert.equal(lookupModelSpec('H.264 Network Camera'), null);
|
||||
assert.equal(lookupModelSpec('H.265 IP Camera'), null);
|
||||
});
|
||||
|
||||
test('generation and suffix distinctions are preserved', () => {
|
||||
// A Mk II is not the vendored (Mk I) Q6135-LE.
|
||||
assert.equal(lookupModelSpec('AXIS Q6135-LE Mk II'), null);
|
||||
// A longer part number is a different product...
|
||||
assert.equal(lookupModelSpec('F4105-LRE-UNKNOWN'), null);
|
||||
// ...unless the rest is a mains-frequency variant suffix.
|
||||
assert.equal(lookupModelSpec('Q6155-E-60Hz')?.id, 'axis-q6155-e');
|
||||
});
|
||||
|
||||
test('manufacturer must agree with the record brand', () => {
|
||||
// Purely numeric models need a brand match.
|
||||
assert.equal(lookupModelSpec('3950'), null);
|
||||
assert.equal(lookupModelSpec('Pelco 3950'), null);
|
||||
assert.equal(lookupModelSpec('3950', 'Pelco'), null);
|
||||
// A model code under the wrong manufacturer is not that record either.
|
||||
assert.equal(lookupModelSpec('Q6135-LE', 'Hikvision'), null);
|
||||
// Case-insensitive, with company suffixes and aliases.
|
||||
assert.equal(lookupModelSpec('3950', 'COHU')?.id, 'cohu-3950-iview');
|
||||
assert.equal(
|
||||
lookupModelSpec('Q6135-LE', 'Axis Communications AB')?.id,
|
||||
'axis-q6135-le',
|
||||
);
|
||||
assert.equal(
|
||||
lookupModelSpec('XNP-6400RW', 'Hanwha Vision')?.id,
|
||||
'hanwha-xnp-6400rw',
|
||||
);
|
||||
assert.equal(
|
||||
lookupModelSpec('XNP-6400RW', 'Wisenet')?.id,
|
||||
'hanwha-xnp-6400rw',
|
||||
);
|
||||
});
|
||||
|
||||
test('King County snapshot model strings still resolve', () => {
|
||||
// Model/Manufacturer pairs as published in the King County feed.
|
||||
assert.equal(lookupModelSpec('3950', 'Cohu')?.id, 'cohu-3950-iview');
|
||||
assert.equal(lookupModelSpec('3965', 'Cohu')?.id, 'cohu-3960');
|
||||
assert.equal(lookupModelSpec('3925 Dome', 'Cohu')?.id, 'cohu-3920-idome');
|
||||
assert.equal(lookupModelSpec('Q6135-LE', 'Axis')?.id, 'axis-q6135-le');
|
||||
assert.equal(
|
||||
lookupModelSpec('AUTODOME IP 5000i', 'BOSCH')?.id,
|
||||
'bosch-ndp-5502-30',
|
||||
);
|
||||
});
|
||||
|
||||
test('catalog never overwrites an explicit FOV or a curated pose', () => {
|
||||
const [explicit, curated, cohu, pelco] =
|
||||
browserCatalog().buildCatalogFromSources([
|
||||
{ id: 'explicit', lat: 47.7, lon: -122, fovDeg: 44, model: 'F4105-LRE' },
|
||||
{
|
||||
id: 'curated',
|
||||
lat: 47.7,
|
||||
lon: -122,
|
||||
model: 'F4105-LRE',
|
||||
poseSource: 'curated',
|
||||
},
|
||||
{ id: 'cohu', lat: 47.7, lon: -122, model: '3950', manufacturer: 'Cohu' },
|
||||
{
|
||||
id: 'pelco',
|
||||
lat: 47.7,
|
||||
lon: -122,
|
||||
model: '3950',
|
||||
manufacturer: 'Pelco',
|
||||
},
|
||||
]);
|
||||
assert.equal(explicit.fovDeg, 44);
|
||||
assert.equal(explicit.fovSource, undefined);
|
||||
assert.equal(explicit.spec.model, 'F4105-LRE');
|
||||
assert.equal(curated.fovDeg, 74);
|
||||
assert.equal(curated.fovSource, undefined);
|
||||
// The source's manufacturer reaches the lookup.
|
||||
assert.equal(cohu.spec?.id, 'cohu-3950-iview');
|
||||
assert.equal(pelco.spec, undefined);
|
||||
});
|
||||
|
||||
test('the DATASHEET label drops once calibration moves the FOV', () => {
|
||||
const camera = { fovSource: 'datasheet', fovDeg: 110 };
|
||||
assert.equal(isDatasheetFov(camera), true);
|
||||
assert.equal(isDatasheetFov({ ...camera, calibration: { fovDeg: 0 } }), true);
|
||||
assert.equal(
|
||||
isDatasheetFov({ ...camera, calibration: { fovDeg: 6 } }),
|
||||
false,
|
||||
);
|
||||
assert.equal(isDatasheetFov({ fovDeg: 74 }), false);
|
||||
assert.equal(isDatasheetFov(null), false);
|
||||
});
|
||||
|
||||
test('specSummary renders the HUD line and degrades by omission', () => {
|
||||
assert.equal(
|
||||
specSummary(lookupModelSpec('Q6135-LE')),
|
||||
'Axis Q6135-LE · 1080p · IR 250m · PTZ',
|
||||
);
|
||||
// No night vision / no PTZ → those segments are simply absent.
|
||||
assert.equal(
|
||||
specSummary({ brand: 'Axis', model: 'X', resolution: { label: '4K' } }),
|
||||
'Axis X · 4K',
|
||||
);
|
||||
assert.equal(specSummary(null), '');
|
||||
});
|
||||
@@ -1,6 +1,11 @@
|
||||
import { CCTV_AMBIENT_CARD_MAX } from '../../data/cctvLod.js';
|
||||
import { ACTIVE_FRAME_REFRESH_MS, IDLE_FRAME_REFRESH_MS } from './policy.js';
|
||||
import { headingHudToken, isHeadingEstimated } from './headingConfidence.js';
|
||||
import {
|
||||
specSummary,
|
||||
specFovCapabilityToken,
|
||||
isDatasheetFov,
|
||||
} from './modelSpecs.js';
|
||||
|
||||
export function createPresentation({
|
||||
state: layerState,
|
||||
@@ -39,7 +44,12 @@ export function createPresentation({
|
||||
// A synthetic bearing (headingConfidence 'low', no human calibration)
|
||||
// is tagged so a hashed guess never reads as a surveyed facing (#639).
|
||||
headingHudToken(active.camera),
|
||||
`FOV ${Math.round(active.camera.fovDeg)}°`,
|
||||
// Identified hardware labels its FOV as datasheet-sourced (catalog.js)
|
||||
// only while no manual calibration offset has moved it.
|
||||
`FOV ${Math.round(active.camera.fovDeg)}°${isDatasheetFov(active.camera) ? ' (DATASHEET)' : ''}`,
|
||||
// A PTZ/varifocal datasheet range is what the lens can do, not its
|
||||
// current zoom, so it is shown beside the estimate, never as it.
|
||||
specFovCapabilityToken(active.camera),
|
||||
`COVERAGE ${area.toFixed(2)}km²`,
|
||||
overlapCount > 0 ? `OVERLAP ${overlapCount} cams` : 'ISOLATED VIEW',
|
||||
`PROJ ${layerState._showProjection ? 'MONITOR' : 'OFF'}`,
|
||||
@@ -49,6 +59,10 @@ export function createPresentation({
|
||||
? `SRC ${String(health.sourceKind).toUpperCase()}`
|
||||
: `SRC ${String(active.camera.feedType || 'image').toUpperCase()}`,
|
||||
`${viewBand.toUpperCase()} CONTEXT`,
|
||||
// Identified hardware: append the model's datasheet specs + attribution.
|
||||
active.camera.spec
|
||||
? `${specSummary(active.camera.spec).toUpperCase()} · SPECS: CCTV-DATABASE.COM`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
@@ -86,6 +100,15 @@ export function createPresentation({
|
||||
headingEstimated: isHeadingEstimated(camera),
|
||||
pitchDeg: camera.pitchDeg,
|
||||
fovDeg: camera.fovDeg,
|
||||
// Identified-hardware enrichment passthrough (catalog.js / modelSpecs.js).
|
||||
fovSource: isDatasheetFov(camera)
|
||||
? 'datasheet'
|
||||
: camera.fovSource === 'datasheet'
|
||||
? 'calibrated'
|
||||
: camera.fovSource || 'estimated',
|
||||
specFovMinDeg: camera.specFovMinDeg ?? null,
|
||||
specFovMaxDeg: camera.specFovMaxDeg ?? null,
|
||||
spec: camera.spec || null,
|
||||
rangeM: camera.rangeM,
|
||||
elevationM: camera.absoluteHeightM,
|
||||
mountHeightM: camera.mountHeightM,
|
||||
|
||||
@@ -386,6 +386,7 @@ export function createCyclonesLayer({
|
||||
})),
|
||||
},
|
||||
chips: [],
|
||||
legendCategorical: true,
|
||||
legend: storm
|
||||
? [
|
||||
{ label: 'Advisory center / forecast track', color: '#7fe6ed' },
|
||||
|
||||
@@ -275,10 +275,12 @@ export class FlightRecords {
|
||||
return { icao24, prevMeta, meta, groundFlipped, fixEpochMs };
|
||||
}
|
||||
|
||||
absence(id, { complete, likelyLanded }) {
|
||||
// Partial admissions do not prove absence, but retention remains bounded.
|
||||
absence(id, { complete, retainIds, likelyLanded }) {
|
||||
// Partial admissions and position-less rows do not prove absence, but
|
||||
// retention remains bounded; a likely-landed position-less contact
|
||||
// still takes the landed limit.
|
||||
if (
|
||||
!complete &&
|
||||
(!complete || (retainIds?.has(id) && !likelyLanded)) &&
|
||||
Date.now() - (this.data.get(id)?.observedReceiptMs ?? 0) < 300000
|
||||
)
|
||||
return 'retain';
|
||||
|
||||
@@ -107,6 +107,30 @@ test('ground transitions preserve last render height while a surface cell is col
|
||||
assert.equal(next.meta.wasAirborne, true);
|
||||
});
|
||||
|
||||
test('complete snapshots age out vanished aircraft and retain position-less ones unless landed', () => {
|
||||
const store = records();
|
||||
for (const id of ['abc123', 'def456', 'ghi789'])
|
||||
store.receive(observation({ id, reference: id }), view());
|
||||
// abc123 and ghi789 are still listed without a position; def456 vanished.
|
||||
const absent = (id, likelyLanded = false) =>
|
||||
store.absence(id, {
|
||||
complete: true,
|
||||
retainIds: new Set(['abc123', 'ghi789']),
|
||||
likelyLanded,
|
||||
});
|
||||
assert.deepEqual(
|
||||
[absent('def456'), absent('def456'), absent('def456')],
|
||||
['stale', 'stale', 'remove'],
|
||||
);
|
||||
assert.equal(absent('abc123'), 'retain');
|
||||
assert.equal(store.missingPolls.has('abc123'), false);
|
||||
// A landed contact that lost its position takes the landed fast-cull.
|
||||
assert.equal(absent('ghi789', true), 'remove');
|
||||
// Position-less retention stays bounded like partial admission.
|
||||
store.data.get('abc123').observedReceiptMs = Date.now() - 300001;
|
||||
assert.equal(absent('abc123'), 'stale');
|
||||
});
|
||||
|
||||
test('civil emergency status follows the latest poll instead of sticking', () => {
|
||||
const store = records();
|
||||
const first = store.receive(observation({ squawk: '7700' }), view());
|
||||
|
||||
@@ -243,6 +243,7 @@ export function createFlightSnapshotRenderer({
|
||||
if (currentIcaos.has(icao24)) continue;
|
||||
const absence = records.absence(icao24, {
|
||||
complete: snapshot.complete,
|
||||
retainIds: snapshot.retainIds,
|
||||
likelyLanded: queries._likelyLanded(icao24),
|
||||
});
|
||||
if (absence === 'retain') continue;
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* An image's pixel size read from its header, before anything decodes it:
|
||||
* a picture host can serve a small file that declares enormous dimensions,
|
||||
* and a decode of it alone can take the tab (or the GPU process) down.
|
||||
* JPEG (SOFn), PNG (IHDR), WebP (VP8, VP8L, VP8X) and GIF; null for any
|
||||
* other format, or a header this does not reach.
|
||||
* @param {Uint8Array} bytes The start of the file (a JPEG's EXIF can push its SOF past 64 kB).
|
||||
* @returns {{width: number, height: number}|null}
|
||||
*/
|
||||
export function imageSize(bytes) {
|
||||
if (!bytes || bytes.length < 12) return null;
|
||||
const b = bytes;
|
||||
const be16 = (i) => (b[i] << 8) | b[i + 1];
|
||||
const le16 = (i) => b[i] | (b[i + 1] << 8);
|
||||
const le24 = (i) => b[i] | (b[i + 1] << 8) | (b[i + 2] << 16);
|
||||
const ascii = (i, text) =>
|
||||
[...text].every((char, k) => b[i + k] === char.charCodeAt(0));
|
||||
// JPEG: walk the markers to the first start-of-frame.
|
||||
if (b[0] === 0xff && b[1] === 0xd8) {
|
||||
let i = 2;
|
||||
while (i + 9 < b.length) {
|
||||
if (b[i] !== 0xff) return null;
|
||||
const marker = b[i + 1];
|
||||
// Fill bytes, and markers with no length.
|
||||
if (marker === 0xff) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd9)) {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
const isFrame =
|
||||
marker >= 0xc0 &&
|
||||
marker <= 0xcf &&
|
||||
marker !== 0xc4 &&
|
||||
marker !== 0xc8 &&
|
||||
marker !== 0xcc;
|
||||
if (isFrame) return { width: be16(i + 7), height: be16(i + 5) };
|
||||
i += 2 + be16(i + 2);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// PNG: IHDR is the first chunk.
|
||||
if (b[0] === 0x89 && ascii(1, 'PNG') && ascii(12, 'IHDR')) {
|
||||
const u32 = (i) =>
|
||||
((b[i] << 24) | (b[i + 1] << 16) | (b[i + 2] << 8) | b[i + 3]) >>> 0;
|
||||
return { width: u32(16), height: u32(20) };
|
||||
}
|
||||
if (ascii(0, 'RIFF') && ascii(8, 'WEBP') && b.length >= 30) {
|
||||
if (ascii(12, 'VP8 '))
|
||||
return { width: le16(26) & 0x3fff, height: le16(28) & 0x3fff };
|
||||
if (ascii(12, 'VP8L'))
|
||||
return {
|
||||
width: 1 + (((b[22] & 0x3f) << 8) | b[21]),
|
||||
height:
|
||||
1 + (((b[24] & 0x0f) << 10) | (b[23] << 2) | ((b[22] & 0xc0) >> 6)),
|
||||
};
|
||||
if (ascii(12, 'VP8X')) return { width: 1 + le24(24), height: 1 + le24(27) };
|
||||
return null;
|
||||
}
|
||||
if (ascii(0, 'GIF8')) return { width: le16(6), height: le16(8) };
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { imageSize } from './imageSize.js';
|
||||
|
||||
const bytes = (...parts) =>
|
||||
Uint8Array.from(
|
||||
parts.flat().map((v) => (typeof v === 'string' ? v.charCodeAt(0) : v)),
|
||||
);
|
||||
const ascii = (text) => [...text];
|
||||
|
||||
test('a JPEG’s size is read from its first frame, past an EXIF segment', () => {
|
||||
const exif = [0xff, 0xe1, 0x00, 0x10, ...new Array(14).fill(0)];
|
||||
const sof = [0xff, 0xc0, 0x00, 0x11, 0x08, 0x0b, 0xc0, 0x0f, 0xc0];
|
||||
const jpeg = bytes([0xff, 0xd8], exif, sof, new Array(20).fill(0));
|
||||
assert.deepEqual(imageSize(jpeg), { width: 0x0fc0, height: 0x0bc0 });
|
||||
// Progressive (SOF2) too; a DHT (C4) is not a frame.
|
||||
const dht = [0xff, 0xc4, 0x00, 0x04, 0, 0];
|
||||
const sof2 = [0xff, 0xc2, 0x00, 0x11, 0x08, 0x00, 0x10, 0x00, 0x20];
|
||||
assert.deepEqual(
|
||||
imageSize(bytes([0xff, 0xd8], dht, sof2, new Array(10).fill(0))),
|
||||
{
|
||||
width: 32,
|
||||
height: 16,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('PNG, WebP (lossy, lossless, extended) and GIF sizes', () => {
|
||||
const png = bytes(
|
||||
[0x89],
|
||||
ascii('PNG'),
|
||||
[0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13],
|
||||
ascii('IHDR'),
|
||||
[0, 0, 0x10, 0, 0, 0, 0x08, 0],
|
||||
);
|
||||
assert.deepEqual(imageSize(png), { width: 4096, height: 2048 });
|
||||
const riff = (chunk, rest) =>
|
||||
bytes(ascii('RIFF'), [0, 0, 0, 0], ascii('WEBP'), ascii(chunk), rest);
|
||||
const vp8 = riff('VP8 ', [...new Array(10).fill(0), 0x00, 0x08, 0x00, 0x04]);
|
||||
assert.deepEqual(imageSize(vp8), { width: 2048, height: 1024 });
|
||||
// VP8L: 14-bit width-1 and height-1 packed after the 0x2f signature.
|
||||
const w = 99;
|
||||
const h = 49;
|
||||
const packed = w | (h << 14);
|
||||
const vp8l = riff('VP8L', [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0x2f,
|
||||
packed & 0xff,
|
||||
(packed >> 8) & 0xff,
|
||||
(packed >> 16) & 0xff,
|
||||
(packed >> 24) & 0xff,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
]);
|
||||
assert.deepEqual(imageSize(vp8l), { width: 100, height: 50 });
|
||||
const vp8x = riff(
|
||||
'VP8X',
|
||||
[0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0x0f, 0x00, 0xff, 0x07, 0x00],
|
||||
);
|
||||
assert.deepEqual(imageSize(vp8x), { width: 4096, height: 2048 });
|
||||
const gif = bytes(ascii('GIF89a'), [0x40, 0x01, 0xf0, 0x00], [0, 0]);
|
||||
assert.deepEqual(imageSize(gif), { width: 320, height: 240 });
|
||||
});
|
||||
|
||||
test('anything else, or a header cut short, has no size', () => {
|
||||
assert.equal(imageSize(null), null);
|
||||
assert.equal(imageSize(new Uint8Array(4)), null);
|
||||
assert.equal(imageSize(bytes(ascii('<html><body>'))), null);
|
||||
assert.equal(
|
||||
imageSize(
|
||||
bytes([0xff, 0xd8, 0xff, 0xe1, 0xff, 0xff], new Array(8).fill(0)),
|
||||
),
|
||||
null,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* A gate letting at most `limit` tasks run at once, first in first out, or
|
||||
* newest first with `lifo` (the last request is the one the user waits on).
|
||||
*/
|
||||
export function createLimiter(limit, { lifo = false } = {}) {
|
||||
let running = 0;
|
||||
const queue = [];
|
||||
function next() {
|
||||
while (running < limit && queue.length) {
|
||||
const { task, resolve, reject, signal } = lifo
|
||||
? queue.pop()
|
||||
: queue.shift();
|
||||
if (signal?.aborted) {
|
||||
reject(signal.reason);
|
||||
continue;
|
||||
}
|
||||
running++;
|
||||
Promise.resolve()
|
||||
.then(task)
|
||||
.then(resolve, reject)
|
||||
.finally(() => {
|
||||
running--;
|
||||
next();
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
run(task, { signal } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const item = { task, resolve, reject, signal };
|
||||
queue.push(item);
|
||||
signal?.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
const at = queue.indexOf(item);
|
||||
if (at === -1) return;
|
||||
queue.splice(at, 1);
|
||||
reject(signal.reason);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
next();
|
||||
});
|
||||
},
|
||||
get pending() {
|
||||
return running + queue.length;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createLimiter } from './limiter.js';
|
||||
|
||||
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
/** A task that runs until `finish()`. */
|
||||
function held(log, name) {
|
||||
let finish;
|
||||
const done = new Promise((resolve) => (finish = resolve));
|
||||
return {
|
||||
task: () => {
|
||||
log.push(name);
|
||||
return done.then(() => name);
|
||||
},
|
||||
finish: () => finish(),
|
||||
};
|
||||
}
|
||||
|
||||
test('at most `limit` tasks run; queued ones start first in, first out', async () => {
|
||||
const log = [];
|
||||
const gate = createLimiter(2);
|
||||
const tasks = ['a', 'b', 'c', 'd'].map((name) => held(log, name));
|
||||
const results = tasks.map(({ task }) => gate.run(task));
|
||||
await tick();
|
||||
assert.deepEqual(log, ['a', 'b']);
|
||||
assert.equal(gate.pending, 4);
|
||||
tasks[0].finish();
|
||||
await tick();
|
||||
assert.deepEqual(log, ['a', 'b', 'c']);
|
||||
for (const { finish } of tasks) finish();
|
||||
assert.deepEqual(await Promise.all(results), ['a', 'b', 'c', 'd']);
|
||||
assert.equal(gate.pending, 0);
|
||||
});
|
||||
|
||||
test('lifo starts the newest queued task next', async () => {
|
||||
const log = [];
|
||||
const gate = createLimiter(1, { lifo: true });
|
||||
const tasks = ['a', 'b', 'c'].map((name) => held(log, name));
|
||||
const results = tasks.map(({ task }) => gate.run(task));
|
||||
await tick();
|
||||
tasks[0].finish();
|
||||
await tick();
|
||||
assert.deepEqual(log, ['a', 'c']);
|
||||
tasks[2].finish();
|
||||
tasks[1].finish();
|
||||
await Promise.all(results);
|
||||
});
|
||||
|
||||
test('a queued task whose signal aborts never runs, leaves the queue and rejects with the reason', async () => {
|
||||
const log = [];
|
||||
const gate = createLimiter(1);
|
||||
const first = held(log, 'a');
|
||||
const running = gate.run(first.task);
|
||||
const controller = new AbortController();
|
||||
const queued = gate.run(held(log, 'b').task, { signal: controller.signal });
|
||||
await tick();
|
||||
assert.equal(gate.pending, 2);
|
||||
controller.abort(new Error('overtaken'));
|
||||
await assert.rejects(queued, /overtaken/);
|
||||
assert.equal(gate.pending, 1, 'out of the queue');
|
||||
first.finish();
|
||||
await running;
|
||||
await tick();
|
||||
assert.deepEqual(log, ['a'], 'b never ran');
|
||||
});
|
||||
|
||||
test('a task already aborted when its turn comes is skipped', async () => {
|
||||
const log = [];
|
||||
const gate = createLimiter(1);
|
||||
const first = held(log, 'a');
|
||||
const running = gate.run(first.task);
|
||||
const signal = {
|
||||
aborted: false,
|
||||
reason: new Error('gone'),
|
||||
addEventListener() {},
|
||||
};
|
||||
const queued = gate.run(held(log, 'b').task, { signal });
|
||||
signal.aborted = true;
|
||||
first.finish();
|
||||
await running;
|
||||
await assert.rejects(queued, /gone/);
|
||||
assert.deepEqual(log, ['a']);
|
||||
});
|
||||
@@ -12,6 +12,8 @@ import { densifyLine, MESH_DENSIFY_DEG } from './groundCast.js';
|
||||
import { MESH_SAMPLE_RADIUS_M } from './meshSampler.js';
|
||||
import { tileBounds, tilesForBbox } from './tileMath.js';
|
||||
|
||||
/** A tile's shared point cap must move by this much to redraw it. */
|
||||
const SHARE_HYSTERESIS = 0.1;
|
||||
/** Draped lines stay at most this long while a tile's cast lines build. */
|
||||
export const SWAP_MAX_WAIT_MS = 4000;
|
||||
/** Tiles touched by new mesh samples are redrawn at most this often. */
|
||||
@@ -72,6 +74,8 @@ export function meshBoxInRange(box, centre) {
|
||||
* @property {number} viewRangeMinM Street-zoom coverage reaches at least this far…
|
||||
* @property {number} viewRangePerHeight …or camera height times this.
|
||||
* @property {number} viewNearM Ground always covered around a tilted camera.
|
||||
* @property {boolean} [keepOrder] Cap a tile's lines in the order decoded (default: newest first).
|
||||
* @property {number} [maxPoints] Line points drawn at once, over every tile (default: no limit).
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -94,6 +98,7 @@ export function meshBoxInRange(box, centre) {
|
||||
* @param {LineCoverageTuning} options.tuning
|
||||
* @param {{keyRejected?: string, rateLimited: string, tooHigh?: string}} options.messages
|
||||
* @param {(sequence: object, filter: object) => boolean} [options.passes] Whether the imagery filter keeps a line or point.
|
||||
* @param {(point: object) => number} [options.overviewPointSize] An overview point's size in pixels (default `tuning.overviewPointPx`).
|
||||
*/
|
||||
export function createLineCoverage({
|
||||
state,
|
||||
@@ -105,9 +110,13 @@ export function createLineCoverage({
|
||||
tuning,
|
||||
messages,
|
||||
passes = passesImageryFilter,
|
||||
overviewPointSize = null,
|
||||
}) {
|
||||
const { render } = state.services;
|
||||
const perTileSequenceCap = Math.floor(tuning.maxSequences / tuning.maxTiles);
|
||||
const perTilePointCap = tuning.maxPoints
|
||||
? Math.floor(tuning.maxPoints / tuning.maxTiles)
|
||||
: Infinity;
|
||||
|
||||
/** Colour for a line: the provider's, GEV cyan while selected. */
|
||||
function sequenceColor({ selected = false } = {}) {
|
||||
@@ -143,18 +152,114 @@ export function createLineCoverage({
|
||||
|
||||
/**
|
||||
* A tile's sequences, filtered then capped: capping first could leave a
|
||||
* dense tile of newer flat captures with no 360° lines at all.
|
||||
* dense tile of newer flat captures with no 360° lines at all. `measure`
|
||||
* only records what the lines need, for a draw that follows.
|
||||
*/
|
||||
function drawnSequences(entry) {
|
||||
function drawnSequences(entry, { measure = false } = {}) {
|
||||
const drawn = [];
|
||||
const current = filter();
|
||||
const selectedId = state.sequence.selectedId ?? null;
|
||||
const cap = entry.pointCap ?? perTilePointCap;
|
||||
let points = 0;
|
||||
let demand = 0;
|
||||
for (const sequence of entry.sequenceList) {
|
||||
if (drawn.length >= perTileSequenceCap) break;
|
||||
if (passes(sequence, current)) drawn.push(sequence);
|
||||
if (!passes(sequence, current)) continue;
|
||||
const size = pointsOf(sequence);
|
||||
demand += size;
|
||||
// The selected sequence is always drawn: its highlight is the answer
|
||||
// to a click, whatever the caps leave out. It is outside the cap, so
|
||||
// it is not counted against it.
|
||||
if (sequence.id === selectedId) {
|
||||
drawn.push(sequence);
|
||||
continue;
|
||||
}
|
||||
if (drawn.length >= perTileSequenceCap) continue;
|
||||
// A line past the point budget is skipped; a shorter one may still fit.
|
||||
if (points + size > cap) continue;
|
||||
points += size;
|
||||
drawn.push(sequence);
|
||||
}
|
||||
// What every passing line would take: the budget is shared by it.
|
||||
entry.pointDemand = demand;
|
||||
if (!measure) {
|
||||
entry.pointsDrawn = points;
|
||||
// The cap this draw used: a share is judged against it, not against
|
||||
// later shares that redrew nothing (small steps add up).
|
||||
entry.drawnCap = cap;
|
||||
}
|
||||
return drawn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Share the point budget across the tiles in view (water-filling): a
|
||||
* sparse tile keeps what it needs and the rest goes to the dense ones,
|
||||
* so a city centre is not drawn thinner than its suburbs. Sets each
|
||||
* tile's cap; returns the tiles whose cap changes what they draw.
|
||||
*/
|
||||
function shareCaps() {
|
||||
const changed = [];
|
||||
if (!tuning.maxPoints) return changed;
|
||||
const entries = [...state.coverage.tiles.values()].filter(
|
||||
(entry) => entry.kind === 'sequence',
|
||||
);
|
||||
const byDemand = [...entries].sort(
|
||||
(a, b) => (a.pointDemand || 0) - (b.pointDemand || 0),
|
||||
);
|
||||
let left = tuning.maxPoints;
|
||||
byDemand.forEach((entry, i) => {
|
||||
const share = left / (byDemand.length - i);
|
||||
const cap = Math.floor(Math.min(entry.pointDemand || 0, share));
|
||||
left -= cap;
|
||||
const before = entry.drawnCap ?? perTilePointCap;
|
||||
entry.pointCap = cap;
|
||||
// Only a change that alters what is drawn, by enough to be worth a
|
||||
// rebuild: more room for a tile that had lines left out, or well
|
||||
// under what it draws now (a small overshoot stays until the next
|
||||
// natural redraw).
|
||||
const grows =
|
||||
cap > before * (1 + SHARE_HYSTERESIS) &&
|
||||
entry.pointsDrawn < entry.pointDemand;
|
||||
const shrinks = cap < entry.pointsDrawn * (1 - SHARE_HYSTERESIS);
|
||||
if (grows || shrinks) changed.push(entry);
|
||||
});
|
||||
return changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Once loading settles: redraw the tiles the shared budget changes,
|
||||
* keeping their old lines until the new ones are ready (no blink).
|
||||
*/
|
||||
function shareBudget() {
|
||||
const changed = shareCaps();
|
||||
for (const entry of changed) redrawEntry(entry, { swap: true });
|
||||
if (changed.length) {
|
||||
requestRender();
|
||||
notify();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw one tile again from its decoded lines (filter, caps or selection
|
||||
* changed). With `swap`, its old lines stay until the new ones are ready.
|
||||
*/
|
||||
function redrawEntry(entry, { swap = false } = {}) {
|
||||
cancelCast(entry);
|
||||
entry.castRequested = false;
|
||||
entry.meshParts = null;
|
||||
if (swap && entry.kind === 'sequence' && entry.primitives?.length) {
|
||||
redrawTile(entry);
|
||||
return;
|
||||
}
|
||||
detachPrimitive(entry);
|
||||
attachPrimitive(entry);
|
||||
}
|
||||
|
||||
function pointsOf(sequence) {
|
||||
let count = 0;
|
||||
for (const part of sequence.parts) count += part.length;
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batched primitives for a tile's sequence parts: cast polylines where
|
||||
* terrain heights are cached, draped ground lines otherwise. Each batch
|
||||
@@ -267,7 +372,22 @@ export function createLineCoverage({
|
||||
|
||||
/** Rebuild a tile's lines; the old ones stay up until the new are ready. */
|
||||
function redrawTile(entry) {
|
||||
const previous = entry.primitives;
|
||||
let previous = entry.primitives;
|
||||
if (entry.swap) {
|
||||
// A swap still building: what is on screen is its old lines and
|
||||
// whichever new ones are ready. Those stay; the rest never showed.
|
||||
const shown = [...entry.swap.old];
|
||||
const building = [];
|
||||
for (const record of previous)
|
||||
(record.primitive.ready && !record.primitive.isDestroyed?.()
|
||||
? shown
|
||||
: building
|
||||
).push(record);
|
||||
entry.swap.stop();
|
||||
entry.swap = null;
|
||||
removePrimitives(building);
|
||||
previous = shown;
|
||||
}
|
||||
entry.primitives = [];
|
||||
attachPrimitive(entry);
|
||||
removeWhenReady(entry, previous);
|
||||
@@ -426,7 +546,7 @@ export function createLineCoverage({
|
||||
collection.add({
|
||||
position: Cesium.Cartesian3.fromDegrees(point.lon, point.lat),
|
||||
color: tint,
|
||||
pixelSize: tuning.overviewPointPx,
|
||||
pixelSize: overviewPointSize?.(point) ?? tuning.overviewPointPx,
|
||||
// Google 3D terrain and clouds must not hide the near side's dots;
|
||||
// the horizon cull hides the far side's, which this lets through.
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
@@ -559,10 +679,13 @@ export function createLineCoverage({
|
||||
north: bounds.north + pad,
|
||||
};
|
||||
if (kind === 'sequence') {
|
||||
// Newest first; the per-tile cap applies to what passes the filter.
|
||||
entry.sequenceList = decoded.sequences.sort(
|
||||
(a, b) => (b.capturedAt || 0) - (a.capturedAt || 0),
|
||||
);
|
||||
// Newest first, unless the provider ordered them itself; the
|
||||
// per-tile cap applies to what passes the filter.
|
||||
entry.sequenceList = tuning.keepOrder
|
||||
? decoded.sequences
|
||||
: decoded.sequences.sort(
|
||||
(a, b) => (b.capturedAt || 0) - (a.capturedAt || 0),
|
||||
);
|
||||
entry.sequences = new Map();
|
||||
entry.total = decoded.sequences.length;
|
||||
} else {
|
||||
@@ -594,7 +717,10 @@ export function createLineCoverage({
|
||||
// must not drop a newer request's entry (which is what counts as loading).
|
||||
if (state.coverage.pending.get(key) === controller) {
|
||||
state.coverage.pending.delete(key);
|
||||
if (!state.coverage.pending.size) purgeStale();
|
||||
if (!state.coverage.pending.size) {
|
||||
purgeStale();
|
||||
shareBudget();
|
||||
}
|
||||
}
|
||||
notify();
|
||||
}
|
||||
@@ -734,7 +860,10 @@ export function createLineCoverage({
|
||||
state.coverage.pending.delete(key);
|
||||
}
|
||||
for (const tile of tiles) loadTile(tile, kind);
|
||||
if (!state.coverage.pending.size) purgeStale();
|
||||
if (!state.coverage.pending.size) {
|
||||
purgeStale();
|
||||
shareBudget();
|
||||
}
|
||||
// The camera moved: cells that were out of the sampler's range may not be,
|
||||
// and lines left draped by a failed, partial or dropped cast get another try.
|
||||
if (terrainMode())
|
||||
@@ -787,13 +916,12 @@ export function createLineCoverage({
|
||||
/** Rebuild every loaded tile from its decoded cache (after a filter change). */
|
||||
function rebuild() {
|
||||
purgeStale();
|
||||
for (const entry of state.coverage.tiles.values()) {
|
||||
cancelCast(entry);
|
||||
entry.castRequested = false;
|
||||
entry.meshParts = null;
|
||||
detachPrimitive(entry);
|
||||
attachPrimitive(entry);
|
||||
}
|
||||
// What each tile's lines need under the new filter, then the shares,
|
||||
// then one redraw each.
|
||||
for (const entry of state.coverage.tiles.values())
|
||||
if (entry.kind === 'sequence') drawnSequences(entry, { measure: true });
|
||||
shareCaps();
|
||||
for (const entry of state.coverage.tiles.values()) redrawEntry(entry);
|
||||
requestRender();
|
||||
notify();
|
||||
}
|
||||
@@ -847,6 +975,17 @@ export function createLineCoverage({
|
||||
|
||||
/** Recolour one sequence, every part of it, in place (selection highlight). */
|
||||
function recolorSequence(id, selected) {
|
||||
// Selected but left out by a cap: draw it (it is always admitted).
|
||||
if (selected && (tuning.maxPoints || 0) > 0)
|
||||
for (const entry of state.coverage.tiles.values())
|
||||
if (
|
||||
entry.kind === 'sequence' &&
|
||||
!entry.sequences.has(id) &&
|
||||
entry.sequenceList.some(
|
||||
(sequence) => sequence.id === id && passes(sequence, filter()),
|
||||
)
|
||||
)
|
||||
redrawEntry(entry, { swap: true });
|
||||
for (const entry of drawnEntries()) {
|
||||
const sequence = entry.sequences.get(id);
|
||||
if (!sequence) continue;
|
||||
|
||||
@@ -25,7 +25,15 @@ const TUNING = Object.freeze({
|
||||
*/
|
||||
function setup(
|
||||
t,
|
||||
{ zoomFor, messages = { rateLimited: 'busy' }, passes, fail } = {},
|
||||
{
|
||||
zoomFor,
|
||||
messages = { rateLimited: 'busy' },
|
||||
passes,
|
||||
fail,
|
||||
decoded = null,
|
||||
tuning = TUNING,
|
||||
overviewPointSize = null,
|
||||
} = {},
|
||||
) {
|
||||
const centre = tileCentre(-121.4944, 38.5816);
|
||||
const viewer = coverageViewer(
|
||||
@@ -68,6 +76,7 @@ function setup(
|
||||
fetchTile: async (tile) => {
|
||||
asked.push(tile);
|
||||
if (fail) throw fail;
|
||||
if (decoded) return decoded(centre, tile);
|
||||
return {
|
||||
sequences: [
|
||||
{
|
||||
@@ -94,9 +103,10 @@ function setup(
|
||||
colors: { coverage: '#635bff', selected: '#00e5ff' },
|
||||
pickPrefix: 'test:line:',
|
||||
renderKey: 'test-coverage',
|
||||
tuning: TUNING,
|
||||
tuning,
|
||||
messages,
|
||||
...(passes ? { passes } : {}),
|
||||
...(overviewPointSize ? { overviewPointSize } : {}),
|
||||
});
|
||||
t.after(() => {
|
||||
coverage.clear();
|
||||
@@ -140,3 +150,383 @@ test('a refused key without its own message keeps the error it came with', async
|
||||
assert.ok(await until(() => state.keyRejected === true));
|
||||
assert.equal(state.coverage.lastError, 'the source refused the key');
|
||||
});
|
||||
|
||||
/** Three lines in one tile: an old long one, then two newer short ones. */
|
||||
function threeLines(centre) {
|
||||
const line = (id, capturedAt, span) => ({
|
||||
id,
|
||||
parts: [
|
||||
[
|
||||
[centre.lon - span, centre.lat],
|
||||
[centre.lon + span, centre.lat],
|
||||
],
|
||||
],
|
||||
capturedAt,
|
||||
isPano: false,
|
||||
});
|
||||
return {
|
||||
sequences: [
|
||||
line('old-long', 1, 0.002),
|
||||
line('new-short', 2, 0.0002),
|
||||
line('newer-short', 3, 0.0001),
|
||||
],
|
||||
overview: [],
|
||||
};
|
||||
}
|
||||
|
||||
test('the per-tile cap keeps the newest lines, or the provider’s own order with keepOrder', async (t) => {
|
||||
const capped = { ...TUNING, maxTiles: 1, maxSequences: 2 };
|
||||
const drawnIds = (state) =>
|
||||
[...state.coverage.tiles.values()].flatMap((tile) => [
|
||||
...tile.sequences.keys(),
|
||||
]);
|
||||
const newest = setup(t, { decoded: threeLines, tuning: capped });
|
||||
newest.coverage.refresh();
|
||||
assert.ok(await until(() => newest.state.coverage.tiles.size === 1));
|
||||
assert.deepEqual(drawnIds(newest.state).sort(), ['new-short', 'newer-short']);
|
||||
const kept = setup(t, {
|
||||
decoded: threeLines,
|
||||
tuning: { ...capped, keepOrder: true },
|
||||
});
|
||||
kept.coverage.refresh();
|
||||
assert.ok(await until(() => kept.state.coverage.tiles.size === 1));
|
||||
assert.deepEqual(drawnIds(kept.state), ['old-long', 'new-short']);
|
||||
});
|
||||
|
||||
test('overview points take the provider’s size, each its own', async (t) => {
|
||||
const { state, coverage, viewer } = setup(t, {
|
||||
zoomFor: () => ({ zoom: 3, kind: 'overview' }),
|
||||
tuning: { ...TUNING, overviewMaxTiles: 4 },
|
||||
decoded: (centre) => ({
|
||||
sequences: [],
|
||||
overview: [
|
||||
{ id: 'a', lon: centre.lon, lat: centre.lat, weight: 1 },
|
||||
{ id: 'b', lon: centre.lon + 0.01, lat: centre.lat, weight: 50_000 },
|
||||
],
|
||||
}),
|
||||
overviewPointSize: (point) => (point.weight > 1 ? 7 : 3),
|
||||
});
|
||||
coverage.refresh();
|
||||
assert.ok(await until(() => state.coverage.tiles.size > 0));
|
||||
const sizes = [];
|
||||
for (const primitive of viewer.scene.primitives.items)
|
||||
for (let i = 0; i < (primitive.length ?? 0); i++)
|
||||
sizes.push(primitive.get(i).pixelSize);
|
||||
assert.deepEqual(sizes.sort(), [3, 7]);
|
||||
});
|
||||
|
||||
test('a point budget skips lines past it, and a shorter one later may still fit', async (t) => {
|
||||
const { state, coverage } = setup(t, {
|
||||
tuning: {
|
||||
...TUNING,
|
||||
maxTiles: 1,
|
||||
maxSequences: 10,
|
||||
maxPoints: 5,
|
||||
keepOrder: true,
|
||||
},
|
||||
decoded: (centre) => {
|
||||
const line = (id, n) => ({
|
||||
id,
|
||||
parts: [
|
||||
Array.from({ length: n }, (_, i) => [
|
||||
centre.lon - 0.001 + i * 0.0001,
|
||||
centre.lat,
|
||||
]),
|
||||
],
|
||||
capturedAt: 0,
|
||||
isPano: false,
|
||||
});
|
||||
return {
|
||||
sequences: [line('three', 3), line('four', 4), line('two', 2)],
|
||||
overview: [],
|
||||
};
|
||||
},
|
||||
});
|
||||
coverage.refresh();
|
||||
assert.ok(await until(() => state.coverage.tiles.size === 1));
|
||||
const [tile] = state.coverage.tiles.values();
|
||||
assert.deepEqual([...tile.sequences.keys()], ['three', 'two']);
|
||||
});
|
||||
|
||||
/** A line of `n` points, its id unique to the tile. */
|
||||
function pointsLine(id, n, tile, centre) {
|
||||
return {
|
||||
id,
|
||||
parts: [
|
||||
Array.from({ length: n }, (_, i) => [
|
||||
centre.lon - 0.001 + i * 0.00001,
|
||||
centre.lat,
|
||||
]),
|
||||
],
|
||||
capturedAt: 0,
|
||||
isPano: false,
|
||||
};
|
||||
}
|
||||
|
||||
test('the point budget is shared: a sparse tile keeps its few, a dense one gets the rest', async (t) => {
|
||||
const tuning = {
|
||||
...TUNING,
|
||||
maxTiles: 9,
|
||||
maxSequences: 900,
|
||||
maxPoints: 90,
|
||||
keepOrder: true,
|
||||
};
|
||||
const { state, coverage } = setup(t, {
|
||||
// Street zoom far enough in that several tiles are in view.
|
||||
zoomFor: () => ({ zoom: 18, kind: 'sequence' }),
|
||||
tuning,
|
||||
decoded: (centre, tile) => {
|
||||
const dense = tile.x % 2 === 0 && tile.y % 2 === 0;
|
||||
const lines = dense
|
||||
? Array.from({ length: 30 }, (_, i) =>
|
||||
pointsLine(`d${tile.x}/${tile.y}/${i}`, 5, tile, centre),
|
||||
)
|
||||
: [pointsLine(`s${tile.x}/${tile.y}`, 2, tile, centre)];
|
||||
return { sequences: lines, overview: [] };
|
||||
},
|
||||
});
|
||||
coverage.refresh();
|
||||
assert.ok(
|
||||
await until(
|
||||
() => state.coverage.tiles.size > 1 && !state.coverage.pending.size,
|
||||
),
|
||||
);
|
||||
const entries = [...state.coverage.tiles.values()];
|
||||
const drawn = (entry) => entry.pointsDrawn;
|
||||
const total = entries.reduce((sum, entry) => sum + drawn(entry), 0);
|
||||
assert.ok(total <= tuning.maxPoints, `within the budget: ${total}`);
|
||||
const sparse = entries.filter((entry) => entry.pointDemand === 2);
|
||||
const dense = entries.filter((entry) => entry.pointDemand === 150);
|
||||
assert.ok(sparse.length && dense.length, 'both kinds in view');
|
||||
for (const entry of sparse)
|
||||
assert.equal(drawn(entry), 2, 'a sparse tile keeps all');
|
||||
const fair = Math.floor(tuning.maxPoints / tuning.maxTiles);
|
||||
const left = tuning.maxPoints - sparse.length * 2;
|
||||
for (const entry of dense)
|
||||
assert.ok(
|
||||
drawn(entry) > fair || dense.length * fair >= left,
|
||||
`a dense tile gets more than the even share: ${drawn(entry)}`,
|
||||
);
|
||||
assert.ok(
|
||||
total > tuning.maxPoints - 5 * dense.length,
|
||||
'little budget unused',
|
||||
);
|
||||
});
|
||||
|
||||
test('a selected sequence the budget left out is drawn, highlighted', async (t) => {
|
||||
const { state, coverage } = setup(t, {
|
||||
tuning: {
|
||||
...TUNING,
|
||||
maxTiles: 1,
|
||||
maxSequences: 10,
|
||||
maxPoints: 5,
|
||||
keepOrder: true,
|
||||
},
|
||||
decoded: (centre, tile) => ({
|
||||
sequences: [
|
||||
pointsLine('long', 5, tile, centre),
|
||||
pointsLine('short', 3, tile, centre),
|
||||
],
|
||||
overview: [],
|
||||
}),
|
||||
});
|
||||
coverage.refresh();
|
||||
assert.ok(await until(() => state.coverage.tiles.size === 1));
|
||||
const [tile] = state.coverage.tiles.values();
|
||||
assert.deepEqual([...tile.sequences.keys()], ['long']);
|
||||
state.sequence.selectedId = 'short';
|
||||
coverage.recolorSequence('short', true);
|
||||
assert.deepEqual([...tile.sequences.keys()].sort(), ['long', 'short']);
|
||||
});
|
||||
|
||||
/** Count the line primitives a coverage adds to the scene. */
|
||||
function countAdds(viewer) {
|
||||
const counts = { added: 0 };
|
||||
for (const collection of [
|
||||
viewer.scene.groundPrimitives,
|
||||
viewer.scene.primitives,
|
||||
]) {
|
||||
const add = collection.add.bind(collection);
|
||||
collection.add = (primitive) => {
|
||||
counts.added++;
|
||||
return add(primitive);
|
||||
};
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
test('a tile redrawn for a new share keeps its old lines until the new ones are ready', async (t) => {
|
||||
const tuning = {
|
||||
...TUNING,
|
||||
maxTiles: 9,
|
||||
maxSequences: 900,
|
||||
maxPoints: 90,
|
||||
keepOrder: true,
|
||||
};
|
||||
const { state, coverage, viewer } = setup(t, {
|
||||
zoomFor: () => ({ zoom: 18, kind: 'sequence' }),
|
||||
tuning,
|
||||
decoded: (centre, tile) => {
|
||||
const dense = tile.x % 2 === 0 && tile.y % 2 === 0;
|
||||
return {
|
||||
sequences: dense
|
||||
? Array.from({ length: 30 }, (_, i) =>
|
||||
pointsLine(`d${tile.x}/${tile.y}/${i}`, 5, tile, centre),
|
||||
)
|
||||
: [pointsLine(`s${tile.x}/${tile.y}`, 2, tile, centre)],
|
||||
overview: [],
|
||||
};
|
||||
},
|
||||
});
|
||||
coverage.refresh();
|
||||
assert.ok(
|
||||
await until(
|
||||
() => state.coverage.tiles.size > 1 && !state.coverage.pending.size,
|
||||
),
|
||||
);
|
||||
const redrawn = [...state.coverage.tiles.values()].filter(
|
||||
(entry) => entry.swap,
|
||||
);
|
||||
assert.ok(redrawn.length > 0, 'a dense tile took a bigger share');
|
||||
for (const entry of redrawn)
|
||||
for (const { primitive } of entry.swap.old)
|
||||
assert.ok(
|
||||
viewer.scene.groundPrimitives.items.has(primitive) ||
|
||||
viewer.scene.primitives.items.has(primitive),
|
||||
'the old lines are still on the globe while the new ones build',
|
||||
);
|
||||
});
|
||||
|
||||
test('a second redraw while a swap still builds keeps the lines on screen', async (t) => {
|
||||
const { state, coverage, viewer } = setup(t, {
|
||||
zoomFor: () => ({ zoom: 18, kind: 'sequence' }),
|
||||
tuning: {
|
||||
...TUNING,
|
||||
maxTiles: 9,
|
||||
maxSequences: 900,
|
||||
maxPoints: 90,
|
||||
keepOrder: true,
|
||||
},
|
||||
decoded: (centre, tile) => {
|
||||
const dense = tile.x % 2 === 0 && tile.y % 2 === 0;
|
||||
return {
|
||||
sequences: dense
|
||||
? Array.from({ length: 30 }, (_, i) =>
|
||||
pointsLine(`d${tile.x}/${tile.y}/${i}`, 5, tile, centre),
|
||||
)
|
||||
: [pointsLine(`s${tile.x}/${tile.y}`, 2, tile, centre)],
|
||||
overview: [],
|
||||
};
|
||||
},
|
||||
});
|
||||
coverage.refresh();
|
||||
assert.ok(
|
||||
await until(
|
||||
() => state.coverage.tiles.size > 1 && !state.coverage.pending.size,
|
||||
),
|
||||
);
|
||||
// A tile mid-swap (its new lines never become ready here) with a line
|
||||
// the cap left out: selecting that line redraws the tile again.
|
||||
const entry = [...state.coverage.tiles.values()].find(
|
||||
(tile) =>
|
||||
tile.swap &&
|
||||
tile.sequenceList.some((sequence) => !tile.sequences.has(sequence.id)),
|
||||
);
|
||||
assert.ok(entry, 'a swapping tile with a line left out');
|
||||
const onScreen = [...entry.swap.old];
|
||||
const building = entry.primitives.map(({ primitive }) => primitive);
|
||||
const left = entry.sequenceList.find(
|
||||
(sequence) => !entry.sequences.has(sequence.id),
|
||||
);
|
||||
state.sequence.selectedId = left.id;
|
||||
coverage.recolorSequence(left.id, true);
|
||||
assert.ok(entry.sequences.has(left.id), 'the selected line is drawn');
|
||||
const onGlobe = (primitive) =>
|
||||
viewer.scene.groundPrimitives.items.has(primitive) ||
|
||||
viewer.scene.primitives.items.has(primitive);
|
||||
for (const { primitive } of onScreen)
|
||||
assert.ok(
|
||||
onGlobe(primitive),
|
||||
'the lines on screen stay until the new ones are ready',
|
||||
);
|
||||
for (const primitive of building)
|
||||
assert.ok(!onGlobe(primitive), 'the first swap’s unready lines are gone');
|
||||
assert.ok(entry.swap, 'still swapping');
|
||||
});
|
||||
|
||||
test('a selected line outside the cap does not make its tile redraw on every settle, nor does one the filter hides', async (t) => {
|
||||
let pano = 'all';
|
||||
const { state, coverage, viewer } = setup(t, {
|
||||
tuning: {
|
||||
...TUNING,
|
||||
maxTiles: 1,
|
||||
maxSequences: 10,
|
||||
maxPoints: 5,
|
||||
keepOrder: true,
|
||||
},
|
||||
decoded: (centre, tile) => ({
|
||||
sequences: [
|
||||
pointsLine('long', 5, tile, centre),
|
||||
{ ...pointsLine('short', 3, tile, centre), isPano: true },
|
||||
],
|
||||
overview: [],
|
||||
}),
|
||||
});
|
||||
state.context.getFilter = () => ({ pano, sinceMs: null });
|
||||
coverage.refresh();
|
||||
assert.ok(await until(() => state.coverage.tiles.size === 1));
|
||||
state.sequence.selectedId = 'short';
|
||||
coverage.recolorSequence('short', true);
|
||||
const counts = countAdds(viewer);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
coverage.refresh();
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
assert.equal(counts.added, 0, 'settles redraw nothing');
|
||||
// A selection the filter hides is not drawn, so it is not redrawn for.
|
||||
pano = 'flat';
|
||||
coverage.rebuild();
|
||||
const after = countAdds(viewer);
|
||||
for (let i = 0; i < 3; i++) coverage.recolorSequence('short', true);
|
||||
assert.equal(after.added, 0);
|
||||
});
|
||||
|
||||
test('small share increases add up: a retained tile redraws once its share has grown past the threshold since its last draw', async (t) => {
|
||||
const tuning = {
|
||||
...TUNING,
|
||||
maxTiles: 1,
|
||||
maxSequences: 900,
|
||||
maxPoints: 100,
|
||||
keepOrder: true,
|
||||
};
|
||||
const { state, coverage } = setup(t, {
|
||||
tuning,
|
||||
decoded: (centre, tile) => ({
|
||||
sequences: Array.from({ length: 30 }, (_, i) =>
|
||||
pointsLine(`l${i}`, 4, tile, centre),
|
||||
),
|
||||
overview: [],
|
||||
}),
|
||||
});
|
||||
coverage.refresh();
|
||||
assert.ok(
|
||||
await until(
|
||||
() => state.coverage.tiles.size === 1 && !state.coverage.pending.size,
|
||||
),
|
||||
);
|
||||
const [tile] = state.coverage.tiles.values();
|
||||
assert.equal(tile.pointsDrawn, 100);
|
||||
const settle = async () => {
|
||||
coverage.refresh();
|
||||
await until(() => !state.coverage.pending.size);
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
};
|
||||
// +8%: under the threshold, nothing is redrawn.
|
||||
tuning.maxPoints = 108;
|
||||
await settle();
|
||||
assert.equal(tile.pointsDrawn, 100);
|
||||
// +8% again: +16% since the tile was last drawn, so it is redrawn.
|
||||
tuning.maxPoints = 116;
|
||||
await settle();
|
||||
assert.equal(tile.pointsDrawn, 116, 'the grown share is drawn');
|
||||
});
|
||||
+3
-1
@@ -188,13 +188,15 @@ export function createPanoramaRenderer(canvas) {
|
||||
gl.UNSIGNED_BYTE,
|
||||
image,
|
||||
);
|
||||
// Read before the close: a closed bitmap measures 0×0.
|
||||
const { width, height } = image;
|
||||
// The pixels are on the GPU now; a bitmap's copy can go.
|
||||
image.close?.();
|
||||
// Linear only: a mipmapped lookup draws a seam where longitude wraps.
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
||||
const repeat =
|
||||
isPowerOfTwo(image.width) && isPowerOfTwo(image.height)
|
||||
isPowerOfTwo(width) && isPowerOfTwo(height)
|
||||
? gl.REPEAT
|
||||
: gl.CLAMP_TO_EDGE;
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, repeat);
|
||||
+30
@@ -167,3 +167,33 @@ test('an image uploads as RGBA and its bitmap is released; closing hands the tex
|
||||
);
|
||||
assert.equal(renderer.hasImage(), false);
|
||||
});
|
||||
|
||||
test('a power-of-two bitmap wraps in longitude even though it is closed once uploaded', (t) => {
|
||||
const { gl, renderer } = stubPanorama(t);
|
||||
const wraps = [];
|
||||
gl.texParameteri = (target, name, value) => wraps.push([name, value]);
|
||||
gl.TEXTURE_WRAP_S = 'WRAP_S';
|
||||
gl.REPEAT = 'REPEAT';
|
||||
gl.CLAMP_TO_EDGE = 'CLAMP';
|
||||
const bitmap = {
|
||||
width: 2048,
|
||||
height: 1024,
|
||||
// A closed ImageBitmap measures 0×0.
|
||||
close() {
|
||||
this.width = 0;
|
||||
this.height = 0;
|
||||
},
|
||||
};
|
||||
renderer.setImage(bitmap);
|
||||
assert.deepEqual(
|
||||
wraps.find(([name]) => name === 'WRAP_S'),
|
||||
['WRAP_S', 'REPEAT'],
|
||||
);
|
||||
wraps.length = 0;
|
||||
renderer.setImage({ width: 2000, height: 1000 });
|
||||
assert.deepEqual(
|
||||
wraps.find(([name]) => name === 'WRAP_S'),
|
||||
['WRAP_S', 'CLAMP'],
|
||||
'other sizes clamp (WebGL 1 repeats powers of two only)',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,922 @@
|
||||
import {
|
||||
createPanoramaRenderer,
|
||||
PANO_FOV_DEFAULT_DEG,
|
||||
wrapDegrees,
|
||||
} from './panorama.js';
|
||||
import { imageSize } from './imageSize.js';
|
||||
|
||||
/** A photo download (tens of kilobytes to a few megabytes). */
|
||||
export const IMAGE_TIMEOUT_MS = 15_000;
|
||||
|
||||
/** A picture file or decoded side past these is refused (a photo is a few MB). */
|
||||
export const MAX_IMAGE_BYTES = 48 * 1024 * 1024;
|
||||
export const MAX_IMAGE_SIDE = 16_384;
|
||||
/** 64 MP: 256 MB once decoded to RGBA. A 16,384 px square is four times that. */
|
||||
export const MAX_IMAGE_PIXELS = 8_192 * 8_192;
|
||||
/** Where in a file to look for its dimensions (a JPEG's EXIF comes first). */
|
||||
const HEADER_BYTES = 256 * 1024;
|
||||
|
||||
/** Whether a picture this size is past what the viewer decodes. */
|
||||
export function tooManyPixels(width, height) {
|
||||
return (
|
||||
width > MAX_IMAGE_SIDE ||
|
||||
height > MAX_IMAGE_SIDE ||
|
||||
width * height > MAX_IMAGE_PIXELS
|
||||
);
|
||||
}
|
||||
|
||||
/** Panorama texture widths: enough for a docked viewer, and for an expanded one. */
|
||||
export const PANO_MID_SIZE = Object.freeze([2048, 1024]);
|
||||
export const PANO_FULL_SIZE = Object.freeze([4096, 2048]);
|
||||
|
||||
/**
|
||||
* Load an image within the timeout. A CORS-clean one (for WebGL) is decoded
|
||||
* off the main thread into an ImageBitmap where the browser can: uploading
|
||||
* a decoded bitmap costs a fraction of uploading an <img> (90 ms → 15 ms for
|
||||
* 4096×2048 on a phone-class CPU). `resize` shrinks a bitmap while decoding
|
||||
* it (a panorama larger than any texture the viewer uploads).
|
||||
* @param {string} url
|
||||
* @param {{crossOrigin?: boolean, timeoutMs?: number, resize?: [number, number]|null, failure?: string}} [options]
|
||||
*/
|
||||
export function loadImage(
|
||||
url,
|
||||
{
|
||||
crossOrigin = true,
|
||||
timeoutMs = IMAGE_TIMEOUT_MS,
|
||||
resize = null,
|
||||
failure = 'The photo could not be loaded',
|
||||
signal = null,
|
||||
} = {},
|
||||
) {
|
||||
if (
|
||||
crossOrigin &&
|
||||
url &&
|
||||
typeof globalThis.createImageBitmap === 'function' &&
|
||||
typeof globalThis.fetch === 'function'
|
||||
)
|
||||
return loadBitmap(url, timeoutMs, resize, failure, signal);
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!url) return reject(new Error(failure));
|
||||
if (signal?.aborted) return reject(new Error(failure));
|
||||
const image = new Image();
|
||||
if (crossOrigin) image.crossOrigin = 'anonymous';
|
||||
image.decoding = 'async';
|
||||
// Stop the download: another photo opened, or the time ran out.
|
||||
const stop = () => {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener?.('abort', stop);
|
||||
image.onload = image.onerror = null;
|
||||
image.src = '';
|
||||
reject(new Error(failure));
|
||||
};
|
||||
const timer = setTimeout(stop, timeoutMs);
|
||||
signal?.addEventListener?.('abort', stop, { once: true });
|
||||
const settle = () => {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener?.('abort', stop);
|
||||
};
|
||||
image.onload = () => {
|
||||
settle();
|
||||
// An <img> reads no header first: refuse what it decoded too large.
|
||||
if (tooManyPixels(image.naturalWidth || 0, image.naturalHeight || 0)) {
|
||||
image.src = '';
|
||||
reject(oversized(failure));
|
||||
return;
|
||||
}
|
||||
resolve(image);
|
||||
};
|
||||
image.onerror = () => {
|
||||
settle();
|
||||
reject(new Error(failure));
|
||||
};
|
||||
image.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
async function loadBitmap(url, timeoutMs, resize, failure, signal) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
// The caller's abort (the photo changed) stops a multi-megabyte download.
|
||||
const onAbort = () => controller.abort();
|
||||
if (signal?.aborted) controller.abort();
|
||||
signal?.addEventListener?.('abort', onAbort, { once: true });
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
mode: 'cors',
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) throw new Error(failure);
|
||||
// Picture hosts are not ours: refuse a file or an image past any real
|
||||
// photo's size before it is decoded.
|
||||
const length = Number(response.headers?.get?.('content-length'));
|
||||
if (length > MAX_IMAGE_BYTES) throw oversized(failure);
|
||||
const blob = await boundedBlob(response, failure);
|
||||
// Its declared size, before anything decodes it.
|
||||
const size = imageSize(
|
||||
new Uint8Array(await blob.slice(0, HEADER_BYTES).arrayBuffer()),
|
||||
);
|
||||
if (size && tooManyPixels(size.width, size.height))
|
||||
throw oversized(failure);
|
||||
const bitmap = await (resize
|
||||
? createImageBitmap(blob, {
|
||||
resizeWidth: resize[0],
|
||||
resizeHeight: resize[1],
|
||||
resizeQuality: 'high',
|
||||
})
|
||||
: createImageBitmap(blob));
|
||||
// (A resized decode reports its new size: this guards unresized ones.)
|
||||
if (tooManyPixels(bitmap.width, bitmap.height)) {
|
||||
bitmap.close?.();
|
||||
throw oversized(failure);
|
||||
}
|
||||
return bitmap;
|
||||
} catch (error) {
|
||||
// A size refusal stays one: the viewer must not fetch it another way.
|
||||
if (error?.tooLarge) throw error;
|
||||
throw new Error(failure);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener?.('abort', onAbort);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A picture refused for its size (`tooLarge`): the viewer will not load
|
||||
* that file another way, an <img> having no size limit at all.
|
||||
*/
|
||||
export function oversized(failure) {
|
||||
return Object.assign(new Error(failure), { tooLarge: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* The body as a Blob, read with a running count so a host that sends no
|
||||
* length (or a false one) cannot fill memory before the cap is checked.
|
||||
*/
|
||||
async function boundedBlob(response, failure) {
|
||||
const reader = response.body?.getReader?.();
|
||||
if (!reader) {
|
||||
const blob = await response.blob();
|
||||
if (blob.size > MAX_IMAGE_BYTES) throw oversized(failure);
|
||||
return blob;
|
||||
}
|
||||
const chunks = [];
|
||||
let total = 0;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
total += value.byteLength;
|
||||
if (total > MAX_IMAGE_BYTES) {
|
||||
reader.cancel().catch(() => {});
|
||||
throw oversized(failure);
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
return new Blob(chunks, {
|
||||
type: response.headers?.get?.('content-type') || 'image/jpeg',
|
||||
});
|
||||
}
|
||||
|
||||
/** The panorama size a canvas this wide can show sharply at this field of view. */
|
||||
export function panoSizeFor(canvasPixels, fovDeg) {
|
||||
const needed = (Math.max(1, canvasPixels) * 360) / Math.max(1, fovDeg);
|
||||
return needed > PANO_MID_SIZE[0] ? PANO_FULL_SIZE : PANO_MID_SIZE;
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {object} PhotoSources Where a provider's photo comes from.
|
||||
* @property {(photo: object) => boolean} hasPano A 360° photo with an equirectangular image.
|
||||
* @property {(photo: object) => {url: string, width: number, resize?: [number, number]|null}|null} panoFirst
|
||||
* The panorama shown first, its texture width (0 when it is only a preview), and a size to shrink it to.
|
||||
* @property {(photo: object, size: [number, number], view: {expanded: boolean}) => {url: string, resize?: [number, number]|null, timeoutMs?: number}|null} panoSharp
|
||||
* A sharper panorama for a canvas that needs `size`, or null; `expanded` while the viewer is.
|
||||
* @property {(photo: object, view: {expanded: boolean}) => Array<string>} flatFirst Flat versions to try in turn (`expanded` while the viewer is).
|
||||
* @property {(photo: object, pixels: number) => string|null} flatSharp
|
||||
* A sharper flat version for a picture this many device pixels wide, or null.
|
||||
* @property {(photo: object) => {title?: string|null, creator?: string|null, license?: string|null, externalUrl: string, capturedAtPrecision?: 'day'|'month'}} describe
|
||||
* @property {(photo: object) => number|null} [bearingOf] Where the camera looked (default `photo.heading`).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Viewer adapter for providers whose photos are plain image files: a flat
|
||||
* photo in an <img>, a 360° photo on a WebGL panorama (drag to look, wheel
|
||||
* or pinch to zoom), and ‹ › to step along the sequence (also ← →). Poses
|
||||
* follow the view direction.
|
||||
*
|
||||
* Photos are normalized by the provider: `{id, sequenceId, lon, lat,
|
||||
* heading, isPano, capturedAt}` plus whatever its `sources` read.
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {string} options.providerId
|
||||
* @param {string} options.name Provider name for labels and messages, e.g. 'KartaView'.
|
||||
* @param {PhotoSources} options.sources
|
||||
* @param {(photoId: string) => Promise<object>} options.getPhoto A normalized photo.
|
||||
* @param {(photo: object, delta: -1|1) => Promise<string|null>} options.step The photo `delta` along the sequence, or null at its end.
|
||||
* @param {(imageId: string) => void} options.onStep Open the photo ‹ › chose (through the core, so loading and errors show).
|
||||
* @param {(photo: object) => void} [options.onShown] A photo is on screen.
|
||||
* @param {(photo: object) => void} [options.onShowing] The photo this open shows, just before its pose.
|
||||
* @param {(imageId: string) => void} [options.onReopen] Draw the same photo again after a GPU reset (default: `onStep`).
|
||||
* @param {() => boolean} [options.onEscape] Esc in the docked viewer: true when it was used (e.g. a selection cleared).
|
||||
* @param {object} [options.render] Render governor.
|
||||
* @param {(url: string, options?: object) => Promise<HTMLImageElement|ImageBitmap>} [options.load]
|
||||
* @param {(canvas: HTMLCanvasElement) => object} [options.createRenderer]
|
||||
* @returns {import('./registry.js').ViewerAdapter}
|
||||
*/
|
||||
export function createPhotoViewer({
|
||||
providerId,
|
||||
name,
|
||||
sources,
|
||||
getPhoto,
|
||||
step,
|
||||
onStep,
|
||||
onShown = () => {},
|
||||
onShowing = () => {},
|
||||
onReopen = onStep,
|
||||
onEscape = () => false,
|
||||
render,
|
||||
load = loadImage,
|
||||
createRenderer = createPanoramaRenderer,
|
||||
}) {
|
||||
const LOAD_FAILED = `The ${name} photo could not be loaded`;
|
||||
const listeners = new Set();
|
||||
let root = null;
|
||||
let picture = null;
|
||||
let canvas = null;
|
||||
let prevButton = null;
|
||||
let nextButton = null;
|
||||
let renderer = null;
|
||||
/** WebGL failed once: 360° photos show their front view instead. */
|
||||
let rendererFailed = false;
|
||||
let photo = null;
|
||||
/** What shows: 'flat', 'pano' or null. */
|
||||
let showing = null;
|
||||
let openSeq = 0;
|
||||
let renderMode = 'letterbox';
|
||||
let drag = null;
|
||||
let poseFrame = 0;
|
||||
let removeListeners = [];
|
||||
/** The document the viewer was built in (new canvases come from it). */
|
||||
let documentOf = null;
|
||||
/** A context loss's look direction, for the reopen of that photo. */
|
||||
let restoreView = null;
|
||||
/** The panel's viewer box, where Esc is listened for (null: the root). */
|
||||
let escapeTarget = null;
|
||||
/** Width of the panorama texture on screen (0: the preview). */
|
||||
let panoWidth = 0;
|
||||
/** The sharper panorama on its way, for which open (resizes come in bursts). */
|
||||
let panoComing = null;
|
||||
/** A resize came while it loaded: look again once it lands. */
|
||||
let lookAgain = false;
|
||||
/** The sharper flat version on screen, and the one on its way. */
|
||||
let flatSharp = { shown: null, loading: null };
|
||||
/** The open in progress: another open, a close or an unmount stops its downloads. */
|
||||
let openAbort = null;
|
||||
/** The photo an open is fetching: ‹ › step on from it, not the old one. */
|
||||
let openingId = null;
|
||||
/** ‹ › chose this photo: keep the panorama's look direction for it. */
|
||||
let keepViewFor = null;
|
||||
/** ‹ › presses run one after another, each from where the last one went. */
|
||||
let steps = Promise.resolve();
|
||||
|
||||
/** Files refused for their size: never loaded again, by any path. */
|
||||
const refused = new Set();
|
||||
async function loadAs(url, options) {
|
||||
if (refused.has(url)) throw oversized(LOAD_FAILED);
|
||||
try {
|
||||
return await load(url, { failure: LOAD_FAILED, ...options });
|
||||
} catch (error) {
|
||||
if (error?.tooLarge) refused.add(url);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function requestRender() {
|
||||
render?.governorRequestRender?.(`${providerId}-viewer`);
|
||||
}
|
||||
|
||||
function emit() {
|
||||
if (!photo || !showing) return;
|
||||
// A lost GPU context, still being recovered: the open that redraws the
|
||||
// panorama poses once it is on screen.
|
||||
if (showing === 'pano' && !renderer) return;
|
||||
const view = showing === 'pano' ? renderer.getView() : null;
|
||||
const heading = sources.bearingOf
|
||||
? sources.bearingOf(photo)
|
||||
: photo.heading;
|
||||
const described = sources.describe(photo);
|
||||
const pose = {
|
||||
providerId,
|
||||
imageId: photo.id,
|
||||
position: { lon: photo.lon, lat: photo.lat },
|
||||
bearing:
|
||||
heading === null || heading === undefined
|
||||
? null
|
||||
: view
|
||||
? wrapDegrees(heading + view.yaw)
|
||||
: heading,
|
||||
tilt: view ? view.pitch : 0,
|
||||
altitude: null,
|
||||
isPano: showing === 'pano',
|
||||
capturedAt: photo.capturedAt,
|
||||
capturedAtPrecision: described.capturedAtPrecision || 'day',
|
||||
title: described.title ?? null,
|
||||
creator: described.creator ?? null,
|
||||
license: described.license ?? null,
|
||||
sequenceId: photo.sequenceId,
|
||||
externalUrl: described.externalUrl,
|
||||
};
|
||||
for (const listener of [...listeners]) {
|
||||
try {
|
||||
listener(pose);
|
||||
} catch {
|
||||
/* listener errors are the core's to log */
|
||||
}
|
||||
}
|
||||
requestRender();
|
||||
}
|
||||
|
||||
/** Re-emit on the next frame: a drag moves the view many times a frame. */
|
||||
function emitSoon() {
|
||||
if (poseFrame) return;
|
||||
poseFrame = requestAnimationFrame(() => {
|
||||
poseFrame = 0;
|
||||
emit();
|
||||
});
|
||||
}
|
||||
|
||||
function syncButtons() {
|
||||
const enabled = Boolean(photo?.sequenceId) && showing !== null;
|
||||
if (prevButton) prevButton.disabled = !enabled;
|
||||
if (nextButton) nextButton.disabled = !enabled;
|
||||
if (enabled) checkEnds(photo);
|
||||
}
|
||||
|
||||
/** Disable ‹ or › at once where the sequence ends (it also warms the neighbours). */
|
||||
function checkEnds(target) {
|
||||
for (const [delta, button] of [
|
||||
[-1, prevButton],
|
||||
[1, nextButton],
|
||||
])
|
||||
step(target, delta).then(
|
||||
(id) => {
|
||||
if (photo === target && button && !id) disable(button);
|
||||
},
|
||||
() => {},
|
||||
);
|
||||
}
|
||||
|
||||
/** Disable a step button, keeping keyboard focus in the viewer. */
|
||||
function disable(button) {
|
||||
if (button.ownerDocument?.activeElement === button) root?.focus?.();
|
||||
button.disabled = true;
|
||||
}
|
||||
|
||||
function show(kind) {
|
||||
showing = kind;
|
||||
if (picture) picture.hidden = kind !== 'flat';
|
||||
if (canvas) canvas.hidden = kind !== 'pano';
|
||||
if (root) root.dataset.kind = kind || '';
|
||||
syncButtons();
|
||||
}
|
||||
|
||||
function applyRenderMode() {
|
||||
if (picture)
|
||||
picture.style.objectFit = renderMode === 'fill' ? 'cover' : 'contain';
|
||||
}
|
||||
|
||||
function ensureRenderer() {
|
||||
if (renderer || rendererFailed || !canvas) return renderer;
|
||||
try {
|
||||
renderer = createRenderer(canvas);
|
||||
} catch {
|
||||
rendererFailed = true;
|
||||
renderer = null;
|
||||
}
|
||||
return renderer;
|
||||
}
|
||||
|
||||
/**
|
||||
* A lost WebGL context (GPU reset, memory pressure) takes the panorama
|
||||
* with it: drop the renderer, and show the photo again on a new one.
|
||||
*/
|
||||
function onContextLost(event) {
|
||||
event.preventDefault?.();
|
||||
const lost = renderer;
|
||||
renderer = null;
|
||||
// The new renderer starts facing the photo's heading: keep the user's look.
|
||||
if (showing === 'pano' && photo && lost)
|
||||
restoreView = { id: photo.id, view: lost.getView() };
|
||||
try {
|
||||
lost?.destroy();
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
// A lost canvas hands back its lost context: draw on a new element.
|
||||
const fresh = makeCanvas(documentOf);
|
||||
fresh.hidden = canvas.hidden;
|
||||
canvas.replaceWith?.(fresh);
|
||||
canvas = fresh;
|
||||
// Show the photo again through the core, unless a newer one is opening.
|
||||
if (showing === 'pano' && photo && openingId === null) {
|
||||
const again = photo.id;
|
||||
show(null);
|
||||
openingId = again;
|
||||
onReopen(again);
|
||||
}
|
||||
}
|
||||
|
||||
/** The panorama canvas, wired for drag, pinch, wheel and context loss. */
|
||||
function makeCanvas(doc) {
|
||||
const element = doc.createElement('canvas');
|
||||
element.className = 'sl-photo-pano';
|
||||
element.hidden = true;
|
||||
listen(element, 'pointerdown', onPointerDown);
|
||||
listen(element, 'pointermove', onPointerMove);
|
||||
listen(element, 'pointerup', onPointerUp);
|
||||
listen(element, 'pointercancel', onPointerUp);
|
||||
listen(element, 'wheel', onWheel, { passive: false });
|
||||
listen(element, 'webglcontextlost', onContextLost);
|
||||
return element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Step along the sequence from the photo showing, or from the one on its
|
||||
* way after an earlier press, and open it through the core.
|
||||
*/
|
||||
function stepBy(delta) {
|
||||
steps = steps.then(async () => {
|
||||
const fromId = openingId ?? photo?.id;
|
||||
if (!fromId || !root) return;
|
||||
let from;
|
||||
let nextId = null;
|
||||
try {
|
||||
from = await getPhoto(fromId);
|
||||
if (from.sequenceId) nextId = await step(from, delta);
|
||||
} catch {
|
||||
// Not the end of the sequence, just no answer: the press is lost.
|
||||
return;
|
||||
}
|
||||
// Closed, or another photo was opened meanwhile.
|
||||
if ((openingId ?? photo?.id) !== fromId) return;
|
||||
if (!nextId) {
|
||||
const button = delta < 0 ? prevButton : nextButton;
|
||||
if (button && photo?.id === fromId) disable(button);
|
||||
return;
|
||||
}
|
||||
openingId = nextId;
|
||||
keepViewFor = nextId;
|
||||
onStep(nextId);
|
||||
});
|
||||
}
|
||||
|
||||
/** Pointers on the panorama: one drags the view, two pinch the zoom. */
|
||||
const pointers = new Map();
|
||||
let pinch = null;
|
||||
|
||||
function pinchSpan() {
|
||||
const [a, b] = [...pointers.values()];
|
||||
return Math.max(1, Math.hypot(a.x - b.x, a.y - b.y));
|
||||
}
|
||||
|
||||
function onPointerDown(event) {
|
||||
if (showing !== 'pano' || !renderer) return;
|
||||
if (event.pointerType === 'mouse' && event.button !== 0) return;
|
||||
pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
||||
canvas.setPointerCapture?.(event.pointerId);
|
||||
if (pointers.size === 2) {
|
||||
drag = null;
|
||||
pinch = { span: pinchSpan(), fov: renderer.getView().fov };
|
||||
return;
|
||||
}
|
||||
drag = {
|
||||
id: event.pointerId,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
view: renderer.getView(),
|
||||
};
|
||||
canvas.classList.add('sl-photo-dragging');
|
||||
}
|
||||
|
||||
function onPointerMove(event) {
|
||||
if (pointers.has(event.pointerId))
|
||||
pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
||||
if (pinch && pointers.size === 2 && renderer) {
|
||||
renderer.setView({ fov: pinch.fov * (pinch.span / pinchSpan()) });
|
||||
return;
|
||||
}
|
||||
if (!drag || event.pointerId !== drag.id || !renderer) return;
|
||||
const { fov } = drag.view;
|
||||
const width = Math.max(1, canvas.clientWidth);
|
||||
const height = Math.max(1, canvas.clientHeight);
|
||||
const vertical = fov;
|
||||
const horizontal = fov * (width / height);
|
||||
renderer.setView({
|
||||
yaw: drag.view.yaw - ((event.clientX - drag.x) / width) * horizontal,
|
||||
pitch: drag.view.pitch + ((event.clientY - drag.y) / height) * vertical,
|
||||
});
|
||||
emitSoon();
|
||||
}
|
||||
|
||||
function onPointerUp(event) {
|
||||
pointers.delete(event.pointerId);
|
||||
if (pointers.size < 2) pinch = null;
|
||||
if (!drag || event.pointerId !== drag.id) return;
|
||||
drag = null;
|
||||
canvas.classList.remove('sl-photo-dragging');
|
||||
}
|
||||
|
||||
function onWheel(event) {
|
||||
if (showing !== 'pano' || !renderer) return;
|
||||
event.preventDefault();
|
||||
const { fov } = renderer.getView();
|
||||
renderer.setView({ fov: fov * Math.exp(event.deltaY * 0.001) });
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
function onEscapeKey(event) {
|
||||
if (
|
||||
event.key !== 'Escape' ||
|
||||
event.shiftKey ||
|
||||
event.defaultPrevented ||
|
||||
root?.closest?.('.sl-viewer-wrap-expanded') ||
|
||||
!onEscape()
|
||||
)
|
||||
return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
function onKeyDown(event) {
|
||||
if (!escapeTarget) onEscapeKey(event);
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
event.altKey ||
|
||||
event.ctrlKey ||
|
||||
event.metaKey
|
||||
)
|
||||
return;
|
||||
const pano = showing === 'pano' && renderer;
|
||||
const turn = { ArrowLeft: -1, ArrowRight: 1 }[event.key];
|
||||
// Shift+← → look around a panorama; ← → step along the sequence.
|
||||
if (turn && event.shiftKey && pano) {
|
||||
event.preventDefault();
|
||||
const { yaw, fov } = renderer.getView();
|
||||
renderer.setView({ yaw: yaw + (turn * fov) / 4 });
|
||||
emitSoon();
|
||||
} else if (turn) {
|
||||
event.preventDefault();
|
||||
stepBy(turn);
|
||||
} else if ((event.key === 'ArrowUp' || event.key === 'ArrowDown') && pano) {
|
||||
event.preventDefault();
|
||||
const { pitch, fov } = renderer.getView();
|
||||
renderer.setView({
|
||||
pitch: pitch + ((event.key === 'ArrowUp' ? 1 : -1) * fov) / 6,
|
||||
});
|
||||
emitSoon();
|
||||
} else if (['+', '=', '-', '_'].includes(event.key) && pano) {
|
||||
event.preventDefault();
|
||||
const { fov } = renderer.getView();
|
||||
renderer.setView({
|
||||
fov: fov * (event.key === '+' || event.key === '=' ? 0.85 : 1 / 0.85),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function listen(target, type, handler, options) {
|
||||
target.addEventListener(type, handler, options);
|
||||
removeListeners.push(() =>
|
||||
target.removeEventListener(type, handler, options),
|
||||
);
|
||||
}
|
||||
|
||||
function build(host) {
|
||||
const doc = host.ownerDocument;
|
||||
documentOf = doc;
|
||||
root = doc.createElement('div');
|
||||
root.className = 'sl-photo-viewer';
|
||||
root.dataset.provider = providerId;
|
||||
root.tabIndex = 0;
|
||||
root.setAttribute('role', 'group');
|
||||
root.setAttribute(
|
||||
'aria-label',
|
||||
`${name} photo. ← and → step along the sequence. On a 360° photo, drag or use Shift+← →, ↑ ↓ to look around, and + − to zoom`,
|
||||
);
|
||||
picture = doc.createElement('img');
|
||||
picture.className = 'sl-photo-flat';
|
||||
picture.alt = `${name} street-level photo`;
|
||||
picture.draggable = false;
|
||||
picture.hidden = true;
|
||||
canvas = makeCanvas(doc);
|
||||
const nav = (label, glyph, delta) => {
|
||||
const button = doc.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = `sl-photo-step sl-photo-step-${delta < 0 ? 'prev' : 'next'}`;
|
||||
button.textContent = glyph;
|
||||
button.title = label;
|
||||
button.setAttribute('aria-label', label);
|
||||
button.disabled = true;
|
||||
listen(button, 'click', (event) => {
|
||||
event.stopPropagation();
|
||||
stepBy(delta);
|
||||
});
|
||||
return button;
|
||||
};
|
||||
prevButton = nav('Previous photo in this sequence (←)', '‹', -1);
|
||||
nextButton = nav('Next photo in this sequence (→)', '›', 1);
|
||||
root.append(picture, canvas, prevButton, nextButton);
|
||||
host.append(root);
|
||||
listen(root, 'keydown', onKeyDown);
|
||||
// The viewer's box in the panel; without one, the root's own handler
|
||||
// takes Esc (one listener per element and type).
|
||||
escapeTarget = host.closest?.('.sl-viewer-wrap') ?? null;
|
||||
if (escapeTarget) listen(escapeTarget, 'keydown', onEscapeKey);
|
||||
applyRenderMode();
|
||||
}
|
||||
|
||||
function teardown() {
|
||||
pointers.clear();
|
||||
pinch = null;
|
||||
for (const remove of removeListeners) remove();
|
||||
removeListeners = [];
|
||||
if (poseFrame) cancelAnimationFrame(poseFrame);
|
||||
poseFrame = 0;
|
||||
drag = null;
|
||||
renderer?.destroy();
|
||||
renderer = null;
|
||||
root?.remove();
|
||||
root = picture = canvas = prevButton = nextButton = null;
|
||||
showing = null;
|
||||
photo = null;
|
||||
}
|
||||
|
||||
/** Device pixels across the flat photo's frame (its longer side). */
|
||||
function viewerPixels() {
|
||||
const ratio = globalThis.devicePixelRatio || 1;
|
||||
const element = showing === 'pano' ? canvas : picture;
|
||||
const side = Math.max(
|
||||
element?.clientWidth || 0,
|
||||
element?.clientHeight || 0,
|
||||
);
|
||||
return (side || root?.clientWidth || 0) * ratio;
|
||||
}
|
||||
|
||||
/** A sharper version once the first is on screen, if nothing replaced it. */
|
||||
async function upgrade(target, seq, kind) {
|
||||
// A 360° photo shown flat (no WebGL) keeps its front view.
|
||||
if (kind === 'flat' && target.isPano) return;
|
||||
// Another photo is opening: the one still shown is not worth more bytes.
|
||||
if (openingId !== null && openingId !== target.id) return;
|
||||
const signal = openAbort?.signal;
|
||||
if (kind === 'pano') {
|
||||
const size = panoSizeNow();
|
||||
if (size[0] <= panoWidth) return;
|
||||
// Resizes come in bursts while the panel animates: one load at a time
|
||||
// for this photo, and one more look once it lands.
|
||||
if (panoComing?.seq === seq) {
|
||||
lookAgain = true;
|
||||
return;
|
||||
}
|
||||
const sharp = sources.panoSharp(target, size, {
|
||||
expanded: isExpanded(),
|
||||
});
|
||||
if (!sharp?.url) return;
|
||||
const coming = { seq, width: size[0], controller: new AbortController() };
|
||||
panoComing = coming;
|
||||
lookAgain = false;
|
||||
try {
|
||||
const image = await loadAs(sharp.url, {
|
||||
crossOrigin: true,
|
||||
signal: coming.controller.signal,
|
||||
resize: sharp.resize ?? null,
|
||||
...(sharp.timeoutMs ? { timeoutMs: sharp.timeoutMs } : {}),
|
||||
});
|
||||
if (seq !== openSeq || photo !== target || showing !== 'pano') {
|
||||
image.close?.();
|
||||
return;
|
||||
}
|
||||
if (!renderer || size[0] <= panoWidth) {
|
||||
image.close?.();
|
||||
return;
|
||||
}
|
||||
renderer.setImage(image);
|
||||
panoWidth = size[0];
|
||||
requestRender();
|
||||
} catch {
|
||||
/* the first version stays */
|
||||
} finally {
|
||||
if (panoComing === coming) {
|
||||
panoComing = null;
|
||||
if (lookAgain && seq === openSeq && photo === target) {
|
||||
lookAgain = false;
|
||||
upgrade(target, seq, 'pano');
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
const url = sources.flatSharp(target, viewerPixels());
|
||||
if (!url || url === flatSharp.shown || url === flatSharp.loading) return;
|
||||
const sharp = flatSharp;
|
||||
sharp.loading = url;
|
||||
try {
|
||||
const image = await loadAs(url, { crossOrigin: false, signal });
|
||||
if (seq !== openSeq || photo !== target || showing !== 'flat') return;
|
||||
picture.src = image.src;
|
||||
sharp.shown = url;
|
||||
requestRender();
|
||||
} catch {
|
||||
/* the first version stays; a later resize may try again */
|
||||
} finally {
|
||||
// Discarded or failed: nothing is on its way, so it may be asked again.
|
||||
if (sharp.loading === url) sharp.loading = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The viewer is the expanded dialog, not docked in the panel. */
|
||||
function isExpanded() {
|
||||
return Boolean(root?.closest?.('.sl-viewer-wrap-expanded'));
|
||||
}
|
||||
|
||||
/** Another photo, or none: a sharper panorama still on its way is not needed. */
|
||||
function dropSharpPanorama() {
|
||||
panoComing?.controller?.abort();
|
||||
panoComing = null;
|
||||
lookAgain = false;
|
||||
}
|
||||
|
||||
/** The sharp panorama this viewer needs now: 2,048 docked, 4,096 expanded. */
|
||||
function panoSizeNow() {
|
||||
const ratio = Math.min(globalThis.devicePixelRatio || 1, 2);
|
||||
const width = (canvas?.clientWidth || 0) * ratio;
|
||||
return panoSizeFor(width, renderer?.getView().fov ?? PANO_FOV_DEFAULT_DEG);
|
||||
}
|
||||
|
||||
async function showPano(target, seq, keepView, signal) {
|
||||
// If the panorama fails, the caller shows the front view rather than
|
||||
// waiting on it twice.
|
||||
const first = sources.panoFirst(target);
|
||||
if (!first?.url) throw new Error(LOAD_FAILED);
|
||||
const image = await loadAs(first.url, {
|
||||
crossOrigin: true,
|
||||
signal,
|
||||
// Never decoded at more than the texture it becomes.
|
||||
resize: first.resize ?? PANO_MID_SIZE,
|
||||
});
|
||||
if (seq !== openSeq) {
|
||||
image.close?.();
|
||||
return false;
|
||||
}
|
||||
// The context may have been lost while this loaded: build on the new
|
||||
// canvas, or let the caller show the front view.
|
||||
if (!ensureRenderer()) {
|
||||
image.close?.();
|
||||
throw new Error(LOAD_FAILED);
|
||||
}
|
||||
const restore = restoreView?.id === target.id ? restoreView.view : null;
|
||||
restoreView = null;
|
||||
if (restore) renderer.setView(restore);
|
||||
else if (!keepView || showing !== 'pano')
|
||||
renderer.setView({ yaw: 0, pitch: 0, fov: PANO_FOV_DEFAULT_DEG });
|
||||
photo = target;
|
||||
// Shown first: a hidden canvas measures 0 and would be sized 1×1.
|
||||
show('pano');
|
||||
renderer.setImage(image);
|
||||
panoWidth = first.width || 0;
|
||||
// A sharper version of the photo before was dropped by `open`; one of
|
||||
// this photo, asked by a resize meanwhile, is still worth having.
|
||||
return true;
|
||||
}
|
||||
|
||||
async function showFlat(target, seq, signal) {
|
||||
const urls = sources
|
||||
.flatFirst(target, { expanded: isExpanded() })
|
||||
.filter(Boolean);
|
||||
let image;
|
||||
let failure = null;
|
||||
for (const url of urls) {
|
||||
try {
|
||||
image = await loadAs(url, { crossOrigin: false, signal });
|
||||
break;
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
if (seq !== openSeq) return false;
|
||||
}
|
||||
}
|
||||
if (!image) throw failure || new Error(LOAD_FAILED);
|
||||
if (seq !== openSeq) return false;
|
||||
picture.src = image.src;
|
||||
flatSharp = { shown: null, loading: null };
|
||||
photo = target;
|
||||
show('flat');
|
||||
return true;
|
||||
}
|
||||
|
||||
async function openPhoto(id, seq, keepView, signal) {
|
||||
const target = await getPhoto(id, { signal });
|
||||
if (seq !== openSeq) return;
|
||||
let shown = false;
|
||||
if (sources.hasPano(target) && ensureRenderer()) {
|
||||
try {
|
||||
shown = await showPano(target, seq, keepView, signal);
|
||||
} catch {
|
||||
// The panorama failed: its front view is better than nothing.
|
||||
if (seq !== openSeq) return;
|
||||
shown = await showFlat(target, seq, signal);
|
||||
}
|
||||
} else shown = await showFlat(target, seq, signal);
|
||||
if (!shown) return;
|
||||
onShowing(target);
|
||||
emit();
|
||||
onShown(target);
|
||||
upgrade(target, seq, showing);
|
||||
}
|
||||
|
||||
const adapter = {
|
||||
async mount(host) {
|
||||
if (!host) throw new Error('Street Level viewer has no host element');
|
||||
if (root && root.parentNode === host) return;
|
||||
teardown();
|
||||
rendererFailed = false;
|
||||
build(host);
|
||||
},
|
||||
|
||||
/** Resolves once the photo is on screen and its pose was emitted. */
|
||||
async open(imageId) {
|
||||
if (!root) throw new Error(`${name} viewer is not mounted`);
|
||||
const id = String(imageId);
|
||||
const seq = ++openSeq;
|
||||
dropSharpPanorama();
|
||||
// The previous open's downloads stop: only this photo is wanted now.
|
||||
openAbort?.abort();
|
||||
const opening = (openAbort = new AbortController());
|
||||
const keepView = keepViewFor === id;
|
||||
keepViewFor = null;
|
||||
openingId = id;
|
||||
try {
|
||||
await openPhoto(id, seq, keepView, opening.signal);
|
||||
} catch (error) {
|
||||
// A later open, or a close, stopped this one: that is not a failure.
|
||||
if (seq !== openSeq) return;
|
||||
throw error;
|
||||
} finally {
|
||||
if (openingId === id && seq === openSeq) openingId = null;
|
||||
}
|
||||
},
|
||||
|
||||
close() {
|
||||
openSeq++;
|
||||
dropSharpPanorama();
|
||||
openAbort?.abort();
|
||||
openAbort = null;
|
||||
openingId = null;
|
||||
keepViewFor = null;
|
||||
photo = null;
|
||||
flatSharp = { shown: null, loading: null };
|
||||
show(null);
|
||||
if (picture) picture.removeAttribute('src');
|
||||
renderer?.clear?.();
|
||||
},
|
||||
|
||||
unmount() {
|
||||
openSeq++;
|
||||
dropSharpPanorama();
|
||||
openAbort?.abort();
|
||||
openAbort = null;
|
||||
refused.clear();
|
||||
openingId = null;
|
||||
keepViewFor = null;
|
||||
flatSharp = { shown: null, loading: null };
|
||||
teardown();
|
||||
},
|
||||
|
||||
resize() {
|
||||
renderer?.resize();
|
||||
// Expanded: a docked viewer's panorama or flat version is now too soft.
|
||||
if (showing === 'pano' && photo && panoSizeNow()[0] > panoWidth)
|
||||
upgrade(photo, openSeq, 'pano');
|
||||
else if (showing === 'flat' && photo) upgrade(photo, openSeq, 'flat');
|
||||
},
|
||||
|
||||
/** FIT / FILL frame a flat photo; a panorama has no frame to fit. */
|
||||
supportsRenderModes: () => showing !== 'pano',
|
||||
|
||||
setRenderMode(mode) {
|
||||
renderMode = mode === 'fill' ? 'fill' : 'letterbox';
|
||||
applyRenderMode();
|
||||
},
|
||||
|
||||
/** The photo's direction became known (its drive loaded): pose again. */
|
||||
refreshPose() {
|
||||
emit();
|
||||
},
|
||||
|
||||
onPose(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
};
|
||||
return adapter;
|
||||
}
|
||||
@@ -0,0 +1,538 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createPhotoViewer } from './photoViewer.js';
|
||||
import { fakeHost, until } from '../../testSupport/kartaViewFakes.mjs';
|
||||
|
||||
const PHOTOS = {
|
||||
a: { id: 'a', sequenceId: 's', lon: 1, lat: 2, heading: 0, isPano: true },
|
||||
b: { id: 'b', sequenceId: 's', lon: 1, lat: 2, heading: 0, isPano: true },
|
||||
f: { id: 'f', sequenceId: 's', lon: 1, lat: 2, heading: 0, isPano: false },
|
||||
g: { id: 'g', sequenceId: 's', lon: 1, lat: 2, heading: 0, isPano: false },
|
||||
};
|
||||
|
||||
/** Sources whose panoramas and sharper flat versions are named after the photo. */
|
||||
const SOURCES = {
|
||||
hasPano: (photo) => photo.isPano,
|
||||
panoFirst: (photo) => ({ url: `${photo.id}-first`, width: 2048 }),
|
||||
panoSharp: (photo) => ({ url: `${photo.id}-sharp` }),
|
||||
flatFirst: (photo) => [`${photo.id}-first`],
|
||||
flatSharp: (photo, pixels) => (pixels > 1000 ? `${photo.id}-sharp` : null),
|
||||
describe: () => ({ externalUrl: 'https://example.org' }),
|
||||
};
|
||||
|
||||
/** A viewer whose loads wait until `release(url)` (or fail with `fail(url)`). */
|
||||
function setup() {
|
||||
const pending = new Map();
|
||||
const loads = [];
|
||||
const images = [];
|
||||
const viewer = createPhotoViewer({
|
||||
providerId: 'test',
|
||||
name: 'Test',
|
||||
sources: SOURCES,
|
||||
getPhoto: async (id) => PHOTOS[id],
|
||||
step: async () => null,
|
||||
onStep() {},
|
||||
load: (url) =>
|
||||
new Promise((resolve, reject) => {
|
||||
loads.push(url);
|
||||
pending.set(url, { resolve, reject });
|
||||
}),
|
||||
createRenderer: () => ({
|
||||
setImage: (image) => images.push(image.src),
|
||||
setView() {},
|
||||
getView: () => ({ yaw: 0, pitch: 0, fov: 80 }),
|
||||
resize() {},
|
||||
clear() {},
|
||||
destroy() {},
|
||||
}),
|
||||
});
|
||||
const release = (url) => {
|
||||
const closed = { value: false };
|
||||
pending.get(url).resolve({ src: url, close: () => (closed.value = true) });
|
||||
pending.delete(url);
|
||||
return closed;
|
||||
};
|
||||
const fail = (url) => {
|
||||
pending.get(url).reject(new Error('gone'));
|
||||
pending.delete(url);
|
||||
};
|
||||
return { viewer, loads, images, release, fail, host: fakeHost() };
|
||||
}
|
||||
|
||||
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
const parts = (host) => {
|
||||
const [picture, canvas] = host.children[0].children;
|
||||
return { picture, canvas };
|
||||
};
|
||||
|
||||
test('a panorama that arrives after another photo opened is released, not shown', async () => {
|
||||
const env = setup();
|
||||
await env.viewer.mount(env.host);
|
||||
const first = env.viewer.open('a');
|
||||
await tick();
|
||||
const second = env.viewer.open('b');
|
||||
await tick();
|
||||
const stale = env.release('a-first');
|
||||
await first;
|
||||
assert.equal(stale.value, true, 'its bitmap is closed');
|
||||
env.release('b-first');
|
||||
await second;
|
||||
assert.deepEqual(env.images, ['b-first']);
|
||||
});
|
||||
|
||||
test('one sharp panorama download per open, even across a quick switch and a burst of resizes', async (t) => {
|
||||
const env = setup();
|
||||
t.after(() => delete globalThis.devicePixelRatio);
|
||||
globalThis.devicePixelRatio = 1;
|
||||
await env.viewer.mount(env.host);
|
||||
const { canvas } = parts(env.host);
|
||||
canvas.clientWidth = 1800;
|
||||
const opening = env.viewer.open('a');
|
||||
await tick();
|
||||
env.release('a-first');
|
||||
await opening;
|
||||
await tick();
|
||||
assert.ok(env.loads.includes('a-sharp'), 'expanded: the sharp one is asked');
|
||||
// Photo b opens while a's sharp version is still on its way.
|
||||
const next = env.viewer.open('b');
|
||||
await tick();
|
||||
env.release('b-first');
|
||||
await next;
|
||||
await tick();
|
||||
assert.ok(env.loads.includes('b-sharp'));
|
||||
// a's download finishes late; resizes must not ask for b's again.
|
||||
env.release('a-sharp');
|
||||
await tick();
|
||||
env.viewer.resize();
|
||||
env.viewer.resize();
|
||||
await tick();
|
||||
assert.equal(env.loads.filter((url) => url === 'b-sharp').length, 1);
|
||||
env.release('b-sharp');
|
||||
await tick();
|
||||
assert.deepEqual(env.images, ['a-first', 'b-first', 'b-sharp']);
|
||||
});
|
||||
|
||||
test('a sharper flat version that failed is tried again on the next resize', async (t) => {
|
||||
const env = setup();
|
||||
t.after(() => delete globalThis.devicePixelRatio);
|
||||
globalThis.devicePixelRatio = 1;
|
||||
await env.viewer.mount(env.host);
|
||||
const { picture } = parts(env.host);
|
||||
picture.clientWidth = 1200;
|
||||
const opening = env.viewer.open('f');
|
||||
await tick();
|
||||
env.release('f-first');
|
||||
await opening;
|
||||
await tick();
|
||||
env.fail('f-sharp');
|
||||
await tick();
|
||||
assert.equal(picture.src, 'f-first');
|
||||
env.viewer.resize();
|
||||
await tick();
|
||||
assert.equal(env.loads.filter((url) => url === 'f-sharp').length, 2);
|
||||
env.release('f-sharp');
|
||||
assert.ok(await until(() => picture.src === 'f-sharp'));
|
||||
});
|
||||
|
||||
test('Esc: the docked viewer offers it to the provider first; expanded, it is the dialog’s', 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];
|
||||
const press = () => {
|
||||
const event = { key: 'Escape', prevented: false, stopped: false };
|
||||
event.preventDefault = () => (event.prevented = true);
|
||||
event.stopPropagation = () => (event.stopped = true);
|
||||
root.fire('keydown', event);
|
||||
return event;
|
||||
};
|
||||
const docked = press();
|
||||
assert.equal(asked, 1);
|
||||
assert.equal(docked.prevented && docked.stopped, true);
|
||||
root.closest = (selector) =>
|
||||
selector.includes('sl-viewer-wrap-expanded') ? {} : null;
|
||||
const expanded = press();
|
||||
assert.equal(asked, 1, 'not asked while expanded');
|
||||
assert.equal(expanded.prevented, false);
|
||||
});
|
||||
|
||||
test('a sharp panorama still downloading when another photo opens is aborted', async (t) => {
|
||||
const signals = [];
|
||||
const viewer = createPhotoViewer({
|
||||
providerId: 'test',
|
||||
name: 'Test',
|
||||
sources: SOURCES,
|
||||
getPhoto: async (id) => PHOTOS[id],
|
||||
step: async () => null,
|
||||
onStep() {},
|
||||
load: (url, options) =>
|
||||
url.endsWith('-sharp')
|
||||
? new Promise((_, reject) => {
|
||||
signals.push(options.signal);
|
||||
options.signal?.addEventListener('abort', () =>
|
||||
reject(new Error('aborted')),
|
||||
);
|
||||
})
|
||||
: Promise.resolve({ src: url }),
|
||||
createRenderer: () => ({
|
||||
setImage() {},
|
||||
setView() {},
|
||||
getView: () => ({ yaw: 0, pitch: 0, fov: 80 }),
|
||||
resize() {},
|
||||
clear() {},
|
||||
destroy() {},
|
||||
}),
|
||||
});
|
||||
t.after(() => delete globalThis.devicePixelRatio);
|
||||
globalThis.devicePixelRatio = 1;
|
||||
const host = fakeHost();
|
||||
await viewer.mount(host);
|
||||
parts(host).canvas.clientWidth = 1800;
|
||||
await viewer.open('a');
|
||||
await tick();
|
||||
assert.equal(signals.length, 1);
|
||||
assert.equal(signals[0].aborted, false);
|
||||
await viewer.open('f');
|
||||
assert.equal(signals[0].aborted, true, 'not needed any more');
|
||||
});
|
||||
|
||||
test('a lost WebGL context while the first panorama loads releases its bitmap', async () => {
|
||||
let closed = false;
|
||||
let built = 0;
|
||||
let land = null;
|
||||
const viewer = createPhotoViewer({
|
||||
providerId: 'test',
|
||||
name: 'Test',
|
||||
sources: SOURCES,
|
||||
getPhoto: async (id) => PHOTOS[id],
|
||||
step: async () => null,
|
||||
onStep() {},
|
||||
load: (url) =>
|
||||
new Promise((resolve) => {
|
||||
land = () => resolve({ src: url, close: () => (closed = true) });
|
||||
}),
|
||||
// The first renderer is lost mid-load; a new one cannot be built.
|
||||
createRenderer: () => {
|
||||
built++;
|
||||
if (built > 1) throw new Error('context lost');
|
||||
return {
|
||||
setImage() {},
|
||||
setView() {},
|
||||
getView: () => ({ yaw: 0, pitch: 0, fov: 80 }),
|
||||
resize() {},
|
||||
clear() {},
|
||||
destroy() {},
|
||||
};
|
||||
},
|
||||
});
|
||||
const host = fakeHost();
|
||||
await viewer.mount(host);
|
||||
const opening = viewer.open('a');
|
||||
assert.ok(await until(() => land !== null), 'the panorama is loading');
|
||||
parts(host).canvas.fire('webglcontextlost', {});
|
||||
const first = land;
|
||||
first();
|
||||
// The panorama failed: the front view is loaded flat instead.
|
||||
assert.ok(await until(() => land !== first), 'the flat fallback loads');
|
||||
land();
|
||||
await opening;
|
||||
assert.equal(built, 2, 'a new renderer was tried');
|
||||
assert.equal(closed, true, 'the bitmap no renderer took is closed');
|
||||
});
|
||||
|
||||
test('docked, Esc anywhere in the viewer’s box (toolbar, caption) is offered to the provider', 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();
|
||||
const wrap = fakeHost();
|
||||
host.closest = (selector) => (selector === '.sl-viewer-wrap' ? wrap : null);
|
||||
await viewer.mount(host);
|
||||
const flags = { stopped: false, prevented: false };
|
||||
// Focus back on EXPAND after a shrink: the key reaches the wrap, not the photo.
|
||||
wrap.fire('keydown', {
|
||||
key: 'Escape',
|
||||
stopPropagation: () => (flags.stopped = true),
|
||||
preventDefault: () => (flags.prevented = true),
|
||||
});
|
||||
assert.equal(asked, 1);
|
||||
assert.deepEqual(flags, { stopped: true, prevented: true });
|
||||
viewer.unmount();
|
||||
wrap.fire('keydown', { key: 'Escape' });
|
||||
assert.equal(asked, 1, 'unmounted: the listener is gone');
|
||||
});
|
||||
|
||||
test('a picture file or bitmap past any real photo’s size is refused', async (t) => {
|
||||
const { loadImage, MAX_IMAGE_BYTES, MAX_IMAGE_SIDE } =
|
||||
await import('./photoViewer.js');
|
||||
const saved = {
|
||||
fetch: globalThis.fetch,
|
||||
createImageBitmap: globalThis.createImageBitmap,
|
||||
};
|
||||
t.after(() => Object.assign(globalThis, saved));
|
||||
let closed = 0;
|
||||
const bitmap = (width, height) => ({ width, height, close: () => closed++ });
|
||||
globalThis.createImageBitmap = async (blob) => blob.bitmap;
|
||||
const respond = ({ length, size, width = 100, height = 50 }) =>
|
||||
(globalThis.fetch = async () => ({
|
||||
ok: true,
|
||||
headers: new Map([['content-length', String(length ?? '')]]),
|
||||
blob: async () => ({
|
||||
size: size ?? 10,
|
||||
bitmap: bitmap(width, height),
|
||||
slice: () => ({ arrayBuffer: async () => new ArrayBuffer(0) }),
|
||||
}),
|
||||
}));
|
||||
const refusedForSize = (error) => {
|
||||
assert.match(error.message, /could not be loaded/);
|
||||
assert.equal(error.tooLarge, true, 'marked as a size refusal');
|
||||
return true;
|
||||
};
|
||||
respond({ length: MAX_IMAGE_BYTES + 1 });
|
||||
await assert.rejects(loadImage('https://x/a.jpg'), refusedForSize);
|
||||
respond({ size: MAX_IMAGE_BYTES + 1 });
|
||||
await assert.rejects(loadImage('https://x/a.jpg'), refusedForSize);
|
||||
respond({ width: MAX_IMAGE_SIDE + 1 });
|
||||
await assert.rejects(loadImage('https://x/a.jpg'), refusedForSize);
|
||||
assert.equal(closed, 1, 'the oversize bitmap is released');
|
||||
// No length given, a body that keeps coming: cut off while it streams.
|
||||
let pulled = 0;
|
||||
globalThis.fetch = async () => ({
|
||||
ok: true,
|
||||
headers: new Map(),
|
||||
body: new ReadableStream({
|
||||
pull(controller) {
|
||||
pulled++;
|
||||
controller.enqueue(new Uint8Array(8 * 1024 * 1024));
|
||||
},
|
||||
}),
|
||||
});
|
||||
await assert.rejects(loadImage('https://x/a.jpg'), refusedForSize);
|
||||
assert.ok(pulled * 8 * 1024 * 1024 <= MAX_IMAGE_BYTES + 16 * 1024 * 1024);
|
||||
// Other failures are not size refusals.
|
||||
globalThis.fetch = async () => ({ ok: false, headers: new Map() });
|
||||
await assert.rejects(
|
||||
loadImage('https://x/a.jpg'),
|
||||
(error) => !error.tooLarge,
|
||||
);
|
||||
respond({});
|
||||
assert.equal((await loadImage('https://x/a.jpg')).width, 100);
|
||||
});
|
||||
|
||||
/** A viewer whose loads are held, recording each load's signal by url. */
|
||||
function heldViewer() {
|
||||
const pending = new Map();
|
||||
const signals = new Map();
|
||||
const loads = [];
|
||||
const viewer = createPhotoViewer({
|
||||
providerId: 'test',
|
||||
name: 'Test',
|
||||
sources: SOURCES,
|
||||
getPhoto: async (id) => PHOTOS[id],
|
||||
step: async () => null,
|
||||
onStep() {},
|
||||
load: (url, options = {}) =>
|
||||
new Promise((resolve, reject) => {
|
||||
loads.push(url);
|
||||
signals.set(url, options.signal);
|
||||
options.signal?.addEventListener('abort', () =>
|
||||
reject(new Error('aborted')),
|
||||
);
|
||||
pending.set(url, { resolve, reject });
|
||||
}),
|
||||
});
|
||||
const release = (url) => {
|
||||
pending.get(url).resolve({ src: url });
|
||||
pending.delete(url);
|
||||
};
|
||||
return { viewer, pending, signals, loads, release, host: fakeHost() };
|
||||
}
|
||||
|
||||
test('a new open, a close or an unmount stops the downloads of the open before it', async () => {
|
||||
const env = heldViewer();
|
||||
await env.viewer.mount(env.host);
|
||||
const first = env.viewer.open('f');
|
||||
assert.ok(await until(() => env.signals.has('f-first')));
|
||||
const second = env.viewer.open('g');
|
||||
assert.equal(env.signals.get('f-first').aborted, true, 'f is not wanted now');
|
||||
await first.catch(() => {});
|
||||
assert.ok(await until(() => env.signals.has('g-first')));
|
||||
env.viewer.close();
|
||||
assert.equal(env.signals.get('g-first').aborted, true, 'closed');
|
||||
await second.catch(() => {});
|
||||
const third = env.viewer.open('f');
|
||||
assert.ok(await until(() => env.pending.has('f-first')));
|
||||
env.viewer.unmount();
|
||||
assert.equal(env.signals.get('f-first').aborted, true, 'unmounted');
|
||||
await third.catch(() => {});
|
||||
});
|
||||
|
||||
test('a sharper flat version on its way stops when another photo opens, and is asked again later', async (t) => {
|
||||
t.after(() => delete globalThis.devicePixelRatio);
|
||||
globalThis.devicePixelRatio = 1;
|
||||
const env = heldViewer();
|
||||
await env.viewer.mount(env.host);
|
||||
parts(env.host).picture.clientWidth = 1200;
|
||||
const opening = env.viewer.open('f');
|
||||
assert.ok(await until(() => env.pending.has('f-first')));
|
||||
env.release('f-first');
|
||||
await opening;
|
||||
assert.ok(
|
||||
await until(() => env.pending.has('f-sharp')),
|
||||
'the 1,200 px viewer asks for more',
|
||||
);
|
||||
// Resizes while it loads (the panel animating) do not ask again.
|
||||
env.viewer.resize();
|
||||
env.viewer.resize();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(env.loads.filter((url) => url === 'f-sharp').length, 1);
|
||||
// g opens (and fails): f's sharp download stops, f stays on screen.
|
||||
const failing = env.viewer.open('g');
|
||||
assert.equal(env.signals.get('f-sharp').aborted, true);
|
||||
assert.ok(await until(() => env.pending.has('g-first')));
|
||||
env.pending.get('g-first').reject(new Error('gone'));
|
||||
env.pending.delete('g-first');
|
||||
await failing.catch(() => {});
|
||||
// The discarded response does not block f's sharp version for good.
|
||||
env.signals.delete('f-sharp');
|
||||
env.viewer.resize();
|
||||
assert.ok(await until(() => env.signals.has('f-sharp')), 'asked again');
|
||||
});
|
||||
|
||||
test('a resize while another photo opens does not upgrade the photo still shown', async (t) => {
|
||||
t.after(() => delete globalThis.devicePixelRatio);
|
||||
globalThis.devicePixelRatio = 1;
|
||||
const env = heldViewer();
|
||||
await env.viewer.mount(env.host);
|
||||
const opening = env.viewer.open('f');
|
||||
assert.ok(await until(() => env.pending.has('f-first')));
|
||||
env.release('f-first');
|
||||
await opening;
|
||||
// g is opening; EXPAND resizes the viewer meanwhile.
|
||||
const next = env.viewer.open('g');
|
||||
assert.ok(await until(() => env.pending.has('g-first')));
|
||||
parts(env.host).picture.clientWidth = 1200;
|
||||
env.viewer.resize();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(env.pending.has('f-sharp'), false, 'f is on its way out');
|
||||
env.release('g-first');
|
||||
await next;
|
||||
assert.ok(await until(() => env.pending.has('g-sharp')), 'g gets it');
|
||||
});
|
||||
|
||||
test('a panorama is always decoded at a bounded size', async () => {
|
||||
const env = heldViewer();
|
||||
const options = [];
|
||||
const viewer = createPhotoViewer({
|
||||
providerId: 'test',
|
||||
name: 'Test',
|
||||
sources: SOURCES,
|
||||
getPhoto: async (id) => PHOTOS[id],
|
||||
step: async () => null,
|
||||
onStep() {},
|
||||
load: async (url, opts) => {
|
||||
options.push(opts);
|
||||
return { src: url };
|
||||
},
|
||||
createRenderer: () => ({
|
||||
setImage() {},
|
||||
setView() {},
|
||||
getView: () => ({ yaw: 0, pitch: 0, fov: 80 }),
|
||||
resize() {},
|
||||
clear() {},
|
||||
destroy() {},
|
||||
}),
|
||||
});
|
||||
await viewer.mount(env.host);
|
||||
await viewer.open('a');
|
||||
// SOURCES' panoFirst names no size: the viewer still bounds it.
|
||||
assert.deepEqual(options[0].resize, [2048, 1024]);
|
||||
});
|
||||
|
||||
test('a header that declares a huge image is refused before anything decodes it', async (t) => {
|
||||
const { loadImage } = await import('./photoViewer.js');
|
||||
const saved = {
|
||||
fetch: globalThis.fetch,
|
||||
createImageBitmap: globalThis.createImageBitmap,
|
||||
};
|
||||
t.after(() => Object.assign(globalThis, saved));
|
||||
let decoded = 0;
|
||||
globalThis.createImageBitmap = async () => {
|
||||
decoded++;
|
||||
return { width: 10, height: 10 };
|
||||
};
|
||||
// A 1 kB PNG that says it is 16,384 × 16,384.
|
||||
const png = new Uint8Array(1024);
|
||||
png.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13]);
|
||||
png.set(
|
||||
[...'IHDR'].map((c) => c.charCodeAt(0)),
|
||||
12,
|
||||
);
|
||||
png.set([0, 0, 0x40, 0, 0, 0, 0x40, 0], 16);
|
||||
globalThis.fetch = async () => new Response(png);
|
||||
await assert.rejects(
|
||||
loadImage('https://x/bomb.png'),
|
||||
(error) => error.tooLarge === true,
|
||||
);
|
||||
assert.equal(decoded, 0, 'never decoded');
|
||||
});
|
||||
|
||||
test('an <img> that turns out too large is refused and let go', async (t) => {
|
||||
const { loadImage, MAX_IMAGE_SIDE } = await import('./photoViewer.js');
|
||||
const saved = globalThis.Image;
|
||||
t.after(() => (globalThis.Image = saved));
|
||||
let size = [MAX_IMAGE_SIDE + 1, 100];
|
||||
const images = [];
|
||||
globalThis.Image = class {
|
||||
constructor() {
|
||||
images.push(this);
|
||||
}
|
||||
set src(value) {
|
||||
this._src = value;
|
||||
if (value)
|
||||
queueMicrotask(() => {
|
||||
[this.naturalWidth, this.naturalHeight] = size;
|
||||
this.onload?.();
|
||||
});
|
||||
}
|
||||
get src() {
|
||||
return this._src;
|
||||
}
|
||||
};
|
||||
await assert.rejects(
|
||||
loadImage('https://x/a.jpg', { crossOrigin: false }),
|
||||
(error) => error.tooLarge === true,
|
||||
);
|
||||
assert.equal(images.at(-1).src, '', 'its pixels let go');
|
||||
size = [4000, 3000];
|
||||
const ok = await loadImage('https://x/b.jpg', { crossOrigin: false });
|
||||
assert.equal(ok.naturalWidth, 4000);
|
||||
// An aborted <img> load stops and rejects.
|
||||
size = [10, 10];
|
||||
const controller = new AbortController();
|
||||
const aborted = loadImage('https://x/c.jpg', {
|
||||
crossOrigin: false,
|
||||
signal: controller.signal,
|
||||
});
|
||||
controller.abort();
|
||||
await assert.rejects(aborted);
|
||||
assert.equal(images.at(-1).src, '', 'its download stops');
|
||||
});
|
||||
@@ -9,6 +9,7 @@ export const PROVIDER_COLORS = Object.freeze({
|
||||
mapillary: '#05cb63',
|
||||
google: '#4285f4',
|
||||
kartaview: '#635bff',
|
||||
panoramax: '#ff6f00',
|
||||
});
|
||||
|
||||
/** Colours every provider shares: the selection highlight and the marker. */
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { createLineCoverage } from '../../lineCoverage.js';
|
||||
import { createLru, createTileCache } from '../../tileCache.js';
|
||||
|
||||
export { createTileCache } from '../../tileCache.js';
|
||||
import { createSequenceCones, thinImages } from '../../sequenceCones.js';
|
||||
import { metresBetween } from '../../view.js';
|
||||
import {
|
||||
@@ -41,9 +44,6 @@ import {
|
||||
const PHOTO_CACHE_SIZE = 4000;
|
||||
/** Sequence pages kept in memory. */
|
||||
const PAGE_CACHE_SIZE = 12;
|
||||
/** Decoded coverage tiles kept, and for how long (masks too). */
|
||||
const TILE_CACHE_SIZE = 24;
|
||||
const TILE_CACHE_TTL_MS = 10 * 60_000;
|
||||
|
||||
/** z14 near the street, z13 a little higher, nothing above. */
|
||||
export function kartaViewZoomFor(heightM) {
|
||||
@@ -54,54 +54,6 @@ export function kartaViewZoomFor(heightM) {
|
||||
};
|
||||
}
|
||||
|
||||
/** Least-recently-used map: reading an entry renews it. */
|
||||
function createLru(limit) {
|
||||
const map = new Map();
|
||||
return {
|
||||
get(key) {
|
||||
if (!map.has(key)) return undefined;
|
||||
const value = map.get(key);
|
||||
map.delete(key);
|
||||
map.set(key, value);
|
||||
return value;
|
||||
},
|
||||
set(key, value) {
|
||||
map.delete(key);
|
||||
map.set(key, value);
|
||||
while (map.size > limit) map.delete(map.keys().next().value);
|
||||
},
|
||||
delete: (key) => map.delete(key),
|
||||
clear: () => map.clear(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Loaded coverage tiles by `z/x/y`, at most `size`, each for `ttlMs`. KartaView
|
||||
* sends tiles uncompressed and uncacheable (hundreds of kB, seconds each), so
|
||||
* a pan back or a zoom across z13/z14 would fetch them all again.
|
||||
*/
|
||||
export function createTileCache({
|
||||
load,
|
||||
size = TILE_CACHE_SIZE,
|
||||
ttlMs = TILE_CACHE_TTL_MS,
|
||||
now = Date.now,
|
||||
keyOf = (tile) => `${tile.z}/${tile.x}/${tile.y}`,
|
||||
}) {
|
||||
const kept = createLru(size);
|
||||
return {
|
||||
async get(tile, options) {
|
||||
const key = keyOf(tile);
|
||||
const entry = kept.get(key);
|
||||
if (entry && now() - entry.at < ttlMs) return entry.tile;
|
||||
const loaded = await load(tile, options);
|
||||
kept.set(key, { at: now(), tile: loaded });
|
||||
return loaded;
|
||||
},
|
||||
/** Drop every tile (the provider was switched off). */
|
||||
clear: () => kept.clear(),
|
||||
};
|
||||
}
|
||||
|
||||
/** The 1-based page of a sequence that holds `index`, if no photo is missing. */
|
||||
function pageOf(index) {
|
||||
return Math.floor(Math.max(0, index) / SEQUENCE_PAGE_SIZE) + 1;
|
||||
@@ -494,6 +446,13 @@ export function createKartaViewProvider({
|
||||
error?.message || 'The KartaView photo could not be loaded',
|
||||
),
|
||||
),
|
||||
// Esc in the viewer clears the selected drive, as the panel says.
|
||||
onEscape: () => {
|
||||
if (!state.sequence.selectedId) return false;
|
||||
cones.clearSelection();
|
||||
drawnPage = null;
|
||||
return true;
|
||||
},
|
||||
render: context.services?.render,
|
||||
...viewerOptions,
|
||||
});
|
||||
|
||||
@@ -100,9 +100,6 @@ export const SEQUENCE_PAGE_EDGE = 12;
|
||||
|
||||
/** Equirectangular panorama sizes: a quick preview, then the sharp one. */
|
||||
export const PANO_PREVIEW_SIZE = Object.freeze([1024, 512]);
|
||||
/** Enough for a docked viewer; an expanded one gets the full size. */
|
||||
export const PANO_MID_SIZE = Object.freeze([2048, 1024]);
|
||||
export const PANO_FULL_SIZE = Object.freeze([4096, 2048]);
|
||||
/** A flat photo's first version, then its sharp one. */
|
||||
export const FLAT_VIEW_SIZE = Object.freeze([1280, 1280]);
|
||||
export const FLAT_FULL_SIZE = Object.freeze([2560, 2560]);
|
||||
|
||||
@@ -896,6 +896,27 @@ test('a photo no page lists steps to the photos either side of its index', async
|
||||
assert.equal(env.poses.at(-1), 's-4');
|
||||
});
|
||||
|
||||
test('Esc in the docked viewer clears the selected drive and is consumed; with none, it goes on to the panel', async (t) => {
|
||||
const env = await mounted({ sequences: { s: [0, 1, 2, 3] } });
|
||||
await withCones(t, env);
|
||||
await env.instance.viewer.open('s-1');
|
||||
await env.instance.selectSequence('s');
|
||||
assert.ok(await until(() => env.instance.sequenceStats().images === 4));
|
||||
const root = env.next.parentNode;
|
||||
const press = () => {
|
||||
const flags = { stopped: false, prevented: false };
|
||||
root.fire('keydown', {
|
||||
key: 'Escape',
|
||||
stopPropagation: () => (flags.stopped = true),
|
||||
preventDefault: () => (flags.prevented = true),
|
||||
});
|
||||
return flags;
|
||||
};
|
||||
assert.deepEqual(press(), { stopped: true, prevented: true });
|
||||
assert.equal(env.instance.sequenceStats().selectedId, null, 'drive cleared');
|
||||
assert.deepEqual(press(), { stopped: false, prevented: false });
|
||||
});
|
||||
|
||||
test('the panel counts the drive’s photos, not the thinned page of them the cones show', async (t) => {
|
||||
const env = await mounted({
|
||||
sequences: { s: Array.from({ length: 40 }, (_, i) => i) },
|
||||
|
||||
@@ -21,6 +21,9 @@ import {
|
||||
TILE_TIMEOUT_MS,
|
||||
photoFilterParams,
|
||||
} from './policy.js';
|
||||
import { createLimiter } from '../../limiter.js';
|
||||
|
||||
export { createLimiter } from '../../limiter.js';
|
||||
|
||||
/**
|
||||
* An HTTP or API failure; `retryAfterSec` is set while KartaView rate-limits,
|
||||
@@ -70,56 +73,6 @@ function retryAfterSeconds(response) {
|
||||
return Number.isFinite(value) && value >= 0 ? value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A gate letting at most `limit` tasks run at once, first in first out, or
|
||||
* newest first with `lifo` (the last request is the one the user waits on).
|
||||
*/
|
||||
export function createLimiter(limit, { lifo = false } = {}) {
|
||||
let running = 0;
|
||||
const queue = [];
|
||||
function next() {
|
||||
while (running < limit && queue.length) {
|
||||
const { task, resolve, reject, signal } = lifo
|
||||
? queue.pop()
|
||||
: queue.shift();
|
||||
if (signal?.aborted) {
|
||||
reject(signal.reason);
|
||||
continue;
|
||||
}
|
||||
running++;
|
||||
Promise.resolve()
|
||||
.then(task)
|
||||
.then(resolve, reject)
|
||||
.finally(() => {
|
||||
running--;
|
||||
next();
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
run(task, { signal } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const item = { task, resolve, reject, signal };
|
||||
queue.push(item);
|
||||
signal?.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
const at = queue.indexOf(item);
|
||||
if (at === -1) return;
|
||||
queue.splice(at, 1);
|
||||
reject(signal.reason);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
next();
|
||||
});
|
||||
},
|
||||
get pending() {
|
||||
return running + queue.length;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* KartaView's public API, browser-side and keyless (it allows any origin).
|
||||
* It speaks HTTP/1.1, so the browser opens six connections to it: tiles take
|
||||
|
||||
@@ -1,698 +1,90 @@
|
||||
import {
|
||||
createPanoramaRenderer,
|
||||
PANO_FOV_DEFAULT_DEG,
|
||||
wrapDegrees,
|
||||
} from './panorama.js';
|
||||
createPhotoViewer,
|
||||
loadImage as loadAnyImage,
|
||||
} from '../../photoViewer.js';
|
||||
import {
|
||||
FLAT_FULL_SIZE,
|
||||
FLAT_VIEW_SIZE,
|
||||
IMAGE_TIMEOUT_MS,
|
||||
KARTAVIEW_NAME,
|
||||
KARTAVIEW_PROVIDER_ID,
|
||||
PANO_FULL_SIZE,
|
||||
PANO_MID_SIZE,
|
||||
PANO_PREVIEW_SIZE,
|
||||
cameraBearing,
|
||||
kartaViewPhotoUrl,
|
||||
resizedImageUrl,
|
||||
} from './policy.js';
|
||||
|
||||
export { panoSizeFor } from '../../photoViewer.js';
|
||||
|
||||
const LOAD_FAILED = 'The KartaView photo could not be loaded';
|
||||
|
||||
/**
|
||||
* Load an image within the timeout. A CORS-clean one (for WebGL) is decoded
|
||||
* off the main thread into an ImageBitmap where the browser can: uploading
|
||||
* a decoded bitmap costs a fraction of uploading an <img> (90 ms → 15 ms for
|
||||
* 4096×2048 on a phone-class CPU).
|
||||
*/
|
||||
export function loadImage(
|
||||
url,
|
||||
{ crossOrigin = true, timeoutMs = IMAGE_TIMEOUT_MS } = {},
|
||||
) {
|
||||
if (
|
||||
crossOrigin &&
|
||||
url &&
|
||||
typeof globalThis.createImageBitmap === 'function' &&
|
||||
typeof globalThis.fetch === 'function'
|
||||
)
|
||||
return loadBitmap(url, timeoutMs);
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!url) return reject(new Error(LOAD_FAILED));
|
||||
const image = new Image();
|
||||
if (crossOrigin) image.crossOrigin = 'anonymous';
|
||||
image.decoding = 'async';
|
||||
const timer = setTimeout(() => {
|
||||
image.src = '';
|
||||
reject(new Error(LOAD_FAILED));
|
||||
}, timeoutMs);
|
||||
image.onload = () => {
|
||||
clearTimeout(timer);
|
||||
resolve(image);
|
||||
};
|
||||
image.onerror = () => {
|
||||
clearTimeout(timer);
|
||||
reject(new Error(LOAD_FAILED));
|
||||
};
|
||||
image.src = url;
|
||||
/** Load a KartaView photo (see photoViewer.js `loadImage`). */
|
||||
export function loadImage(url, options = {}) {
|
||||
return loadAnyImage(url, {
|
||||
timeoutMs: IMAGE_TIMEOUT_MS,
|
||||
failure: LOAD_FAILED,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
async function loadBitmap(url, timeoutMs) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
mode: 'cors',
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) throw new Error(LOAD_FAILED);
|
||||
return await createImageBitmap(await response.blob());
|
||||
} catch {
|
||||
throw new Error(LOAD_FAILED);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/** Free a decoded bitmap nobody will draw (an <img> has nothing to free). */
|
||||
function release(image) {
|
||||
image?.close?.();
|
||||
}
|
||||
|
||||
/** The panorama size a canvas this wide can show sharply at this field of view. */
|
||||
export function panoSizeFor(canvasPixels, fovDeg) {
|
||||
const needed = (Math.max(1, canvasPixels) * 360) / Math.max(1, fovDeg);
|
||||
return needed > PANO_MID_SIZE[0] ? PANO_FULL_SIZE : PANO_MID_SIZE;
|
||||
}
|
||||
/**
|
||||
* Where KartaView photos come from. Only KartaView's image proxy can shrink
|
||||
* a 13,000 px panorama, so panoramas and flat upgrades go through it.
|
||||
* @type {import('../../photoViewer.js').PhotoSources}
|
||||
*/
|
||||
const KARTAVIEW_SOURCES = Object.freeze({
|
||||
hasPano: (photo) => Boolean(photo.isPano && photo.urls.pano),
|
||||
panoFirst: (photo) => ({
|
||||
url: resizedImageUrl(photo.urls.pano, PANO_PREVIEW_SIZE),
|
||||
width: 0,
|
||||
resize: PANO_PREVIEW_SIZE,
|
||||
}),
|
||||
panoSharp: (photo, size) => ({
|
||||
url: resizedImageUrl(photo.urls.pano, size),
|
||||
}),
|
||||
// The proxy's 1,280 px version is a few hundred kB; KartaView's own
|
||||
// "large thumbnail" can be 1.7 MB. Either serves when the other fails.
|
||||
// A 360° photo's front view is only on storage.
|
||||
flatFirst: (photo) => [
|
||||
photo.isPano ? null : resizedImageUrl(photo.urls.full, FLAT_VIEW_SIZE),
|
||||
photo.urls.view,
|
||||
],
|
||||
// A docked viewer shows the 1,280 px version in full: the 2,560 px one
|
||||
// (~0.6 MB a photo) waits until the viewer is bigger than that.
|
||||
flatSharp: (photo, pixels) =>
|
||||
pixels > FLAT_VIEW_SIZE[0]
|
||||
? resizedImageUrl(photo.urls.full, FLAT_FULL_SIZE)
|
||||
: null,
|
||||
// The camera's direction, which the drive's track may refine.
|
||||
bearingOf: (photo) => cameraBearing(photo),
|
||||
describe: (photo) => ({
|
||||
// KartaView's only place name is the sequence's, where the drive began.
|
||||
title: null,
|
||||
creator: null,
|
||||
externalUrl: kartaViewPhotoUrl(photo),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Viewer adapter for KartaView photos: a flat photo in an <img>, a 360°
|
||||
* photo on a WebGL panorama (drag to look, wheel to zoom), and ‹ › to step
|
||||
* along the sequence (also ← →). Poses follow the view direction.
|
||||
* photo on a WebGL panorama, and ‹ › along the sequence (photoViewer.js).
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {(photoId: string) => Promise<object>} options.getPhoto A normalized photo (policy.js normalizePhoto).
|
||||
* @param {(photo: object, delta: -1|1) => Promise<string|null>} options.step The photo `delta` along the sequence, or null at its end.
|
||||
* @param {(imageId: string) => void} options.onStep Open the photo ‹ › chose (through the core, so loading and errors show).
|
||||
* @param {(photo: object) => void} [options.onShown] A photo is on screen.
|
||||
* @param {(imageId: string) => void} [options.onReopen] Draw the same photo again after a GPU reset (default: `onStep`).
|
||||
* @param {object} [options.render] Render governor.
|
||||
* @param {(photo: object, delta: -1|1) => Promise<string|null>} options.step
|
||||
* @param {(imageId: string) => void} options.onStep
|
||||
* @param {(photo: object) => void} [options.onShown]
|
||||
* @param {object} [options.render]
|
||||
* @param {(url: string, options?: object) => Promise<HTMLImageElement>} [options.load]
|
||||
* @param {(canvas: HTMLCanvasElement) => object} [options.createRenderer]
|
||||
* @returns {import('../../registry.js').ViewerAdapter}
|
||||
*/
|
||||
export function createKartaViewViewer({
|
||||
getPhoto,
|
||||
step,
|
||||
onStep,
|
||||
onShown = () => {},
|
||||
onReopen = onStep,
|
||||
render,
|
||||
load = loadImage,
|
||||
createRenderer = createPanoramaRenderer,
|
||||
}) {
|
||||
const listeners = new Set();
|
||||
let root = null;
|
||||
let picture = null;
|
||||
let canvas = null;
|
||||
let prevButton = null;
|
||||
let nextButton = null;
|
||||
let renderer = null;
|
||||
/** WebGL failed once: 360° photos show their front view instead. */
|
||||
let rendererFailed = false;
|
||||
let photo = null;
|
||||
/** What shows: 'flat', 'pano' or null. */
|
||||
let showing = null;
|
||||
let openSeq = 0;
|
||||
let renderMode = 'letterbox';
|
||||
let drag = null;
|
||||
let poseFrame = 0;
|
||||
let removeListeners = [];
|
||||
/** The document the viewer was built in (new canvases come from it). */
|
||||
let documentOf = null;
|
||||
/** A context loss's look direction, for the reopen of that photo. */
|
||||
let restoreView = null;
|
||||
/** Width of the panorama texture on screen (0: the preview). */
|
||||
let panoWidth = 0;
|
||||
/** The flat photo on screen is the 2,560 px version. */
|
||||
let flatSharp = false;
|
||||
/** The sharper version loading ({seq, kind}): a resize starts no second one. */
|
||||
let upgrading = null;
|
||||
/** A resize came while it loaded: look again once it lands. */
|
||||
let upgradeAgain = false;
|
||||
/** The photo an open is fetching: ‹ › step on from it, not the old one. */
|
||||
let openingId = null;
|
||||
/** ‹ › chose this photo: keep the panorama's look direction for it. */
|
||||
let keepViewFor = null;
|
||||
/** ‹ › presses run one after another, each from where the last one went. */
|
||||
let steps = Promise.resolve();
|
||||
|
||||
function requestRender() {
|
||||
render?.governorRequestRender?.('kartaview-viewer');
|
||||
}
|
||||
|
||||
function emit() {
|
||||
if (!photo || !showing) return;
|
||||
// A lost GPU context, still being recovered: the open that redraws the
|
||||
// panorama poses once it is on screen.
|
||||
if (showing === 'pano' && !renderer) return;
|
||||
const view = showing === 'pano' ? renderer.getView() : null;
|
||||
const heading = cameraBearing(photo);
|
||||
const pose = {
|
||||
providerId: KARTAVIEW_PROVIDER_ID,
|
||||
imageId: photo.id,
|
||||
position: { lon: photo.lon, lat: photo.lat },
|
||||
bearing:
|
||||
heading === null
|
||||
? null
|
||||
: view
|
||||
? wrapDegrees(heading + view.yaw)
|
||||
: heading,
|
||||
tilt: view ? view.pitch : 0,
|
||||
altitude: null,
|
||||
isPano: showing === 'pano',
|
||||
capturedAt: photo.capturedAt,
|
||||
capturedAtPrecision: 'day',
|
||||
// KartaView's only place name is the sequence's, where the drive began.
|
||||
title: null,
|
||||
creator: null,
|
||||
sequenceId: photo.sequenceId,
|
||||
externalUrl: kartaViewPhotoUrl(photo),
|
||||
};
|
||||
for (const listener of [...listeners]) {
|
||||
try {
|
||||
listener(pose);
|
||||
} catch {
|
||||
/* listener errors are the core's to log */
|
||||
}
|
||||
}
|
||||
requestRender();
|
||||
}
|
||||
|
||||
/** Re-emit on the next frame: a drag moves the view many times a frame. */
|
||||
function emitSoon() {
|
||||
if (poseFrame) return;
|
||||
poseFrame = requestAnimationFrame(() => {
|
||||
poseFrame = 0;
|
||||
emit();
|
||||
});
|
||||
}
|
||||
|
||||
function syncButtons() {
|
||||
const enabled = Boolean(photo?.sequenceId) && showing !== null;
|
||||
if (prevButton) prevButton.disabled = !enabled;
|
||||
if (nextButton) nextButton.disabled = !enabled;
|
||||
if (enabled) checkEnds(photo);
|
||||
}
|
||||
|
||||
/** Disable ‹ or › at once where the sequence ends (it also warms the neighbours). */
|
||||
function checkEnds(target) {
|
||||
for (const [delta, button] of [
|
||||
[-1, prevButton],
|
||||
[1, nextButton],
|
||||
])
|
||||
step(target, delta).then(
|
||||
(id) => {
|
||||
if (photo === target && button && !id) disable(button);
|
||||
},
|
||||
() => {},
|
||||
);
|
||||
}
|
||||
|
||||
/** Disable a step button, keeping keyboard focus in the viewer. */
|
||||
function disable(button) {
|
||||
if (button.ownerDocument?.activeElement === button) root?.focus?.();
|
||||
button.disabled = true;
|
||||
}
|
||||
|
||||
function show(kind) {
|
||||
showing = kind;
|
||||
if (picture) picture.hidden = kind !== 'flat';
|
||||
if (canvas) canvas.hidden = kind !== 'pano';
|
||||
if (root) root.dataset.kind = kind || '';
|
||||
syncButtons();
|
||||
}
|
||||
|
||||
function applyRenderMode() {
|
||||
if (picture)
|
||||
picture.style.objectFit = renderMode === 'fill' ? 'cover' : 'contain';
|
||||
}
|
||||
|
||||
function ensureRenderer() {
|
||||
if (renderer || rendererFailed || !canvas) return renderer;
|
||||
try {
|
||||
renderer = createRenderer(canvas);
|
||||
} catch {
|
||||
rendererFailed = true;
|
||||
renderer = null;
|
||||
}
|
||||
return renderer;
|
||||
}
|
||||
|
||||
/**
|
||||
* A lost WebGL context (GPU reset, memory pressure) takes the panorama
|
||||
* with it: drop the renderer, and show the photo again on a new one.
|
||||
*/
|
||||
function onContextLost(event) {
|
||||
event.preventDefault?.();
|
||||
const lost = renderer;
|
||||
renderer = null;
|
||||
// The new renderer starts facing the photo's heading: keep the user's look.
|
||||
if (showing === 'pano' && photo && lost)
|
||||
restoreView = { id: photo.id, view: lost.getView() };
|
||||
try {
|
||||
lost?.destroy();
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
// A lost canvas hands back its lost context: draw on a new element.
|
||||
const fresh = makeCanvas(documentOf);
|
||||
fresh.hidden = canvas.hidden;
|
||||
canvas.replaceWith?.(fresh);
|
||||
canvas = fresh;
|
||||
// Show the photo again through the core, unless a newer one is opening.
|
||||
if (showing === 'pano' && photo && openingId === null) {
|
||||
const again = photo.id;
|
||||
show(null);
|
||||
openingId = again;
|
||||
onReopen(again);
|
||||
}
|
||||
}
|
||||
|
||||
/** The panorama canvas, wired for drag, pinch, wheel and context loss. */
|
||||
function makeCanvas(doc) {
|
||||
const element = doc.createElement('canvas');
|
||||
element.className = 'sl-kv-pano';
|
||||
element.hidden = true;
|
||||
listen(element, 'pointerdown', onPointerDown);
|
||||
listen(element, 'pointermove', onPointerMove);
|
||||
listen(element, 'pointerup', onPointerUp);
|
||||
listen(element, 'pointercancel', onPointerUp);
|
||||
listen(element, 'wheel', onWheel, { passive: false });
|
||||
listen(element, 'webglcontextlost', onContextLost);
|
||||
return element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Step along the sequence from the photo showing, or from the one on its
|
||||
* way after an earlier press, and open it through the core.
|
||||
*/
|
||||
function stepBy(delta) {
|
||||
steps = steps.then(async () => {
|
||||
const fromId = openingId ?? photo?.id;
|
||||
if (!fromId || !root) return;
|
||||
let from;
|
||||
let nextId = null;
|
||||
try {
|
||||
from = await getPhoto(fromId);
|
||||
if (from.sequenceId) nextId = await step(from, delta);
|
||||
} catch {
|
||||
// Not the end of the sequence, just no answer: the press is lost.
|
||||
return;
|
||||
}
|
||||
// Closed, or another photo was opened meanwhile.
|
||||
if ((openingId ?? photo?.id) !== fromId) return;
|
||||
if (!nextId) {
|
||||
const button = delta < 0 ? prevButton : nextButton;
|
||||
if (button && photo?.id === fromId) disable(button);
|
||||
return;
|
||||
}
|
||||
openingId = nextId;
|
||||
keepViewFor = nextId;
|
||||
onStep(nextId);
|
||||
});
|
||||
}
|
||||
|
||||
/** Pointers on the panorama: one drags the view, two pinch the zoom. */
|
||||
const pointers = new Map();
|
||||
let pinch = null;
|
||||
|
||||
function pinchSpan() {
|
||||
const [a, b] = [...pointers.values()];
|
||||
return Math.max(1, Math.hypot(a.x - b.x, a.y - b.y));
|
||||
}
|
||||
|
||||
function onPointerDown(event) {
|
||||
if (showing !== 'pano' || !renderer) return;
|
||||
if (event.pointerType === 'mouse' && event.button !== 0) return;
|
||||
pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
||||
canvas.setPointerCapture?.(event.pointerId);
|
||||
if (pointers.size === 2) {
|
||||
drag = null;
|
||||
pinch = { span: pinchSpan(), fov: renderer.getView().fov };
|
||||
return;
|
||||
}
|
||||
drag = {
|
||||
id: event.pointerId,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
view: renderer.getView(),
|
||||
};
|
||||
canvas.classList.add('sl-kv-dragging');
|
||||
}
|
||||
|
||||
function onPointerMove(event) {
|
||||
if (pointers.has(event.pointerId))
|
||||
pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
||||
if (pinch && pointers.size === 2 && renderer) {
|
||||
renderer.setView({ fov: pinch.fov * (pinch.span / pinchSpan()) });
|
||||
return;
|
||||
}
|
||||
if (!drag || event.pointerId !== drag.id || !renderer) return;
|
||||
const { fov } = drag.view;
|
||||
const width = Math.max(1, canvas.clientWidth);
|
||||
const height = Math.max(1, canvas.clientHeight);
|
||||
const vertical = fov;
|
||||
const horizontal = fov * (width / height);
|
||||
renderer.setView({
|
||||
yaw: drag.view.yaw - ((event.clientX - drag.x) / width) * horizontal,
|
||||
pitch: drag.view.pitch + ((event.clientY - drag.y) / height) * vertical,
|
||||
});
|
||||
emitSoon();
|
||||
}
|
||||
|
||||
function onPointerUp(event) {
|
||||
pointers.delete(event.pointerId);
|
||||
if (pointers.size < 2) pinch = null;
|
||||
if (!drag || event.pointerId !== drag.id) return;
|
||||
drag = null;
|
||||
canvas.classList.remove('sl-kv-dragging');
|
||||
}
|
||||
|
||||
function onWheel(event) {
|
||||
if (showing !== 'pano' || !renderer) return;
|
||||
event.preventDefault();
|
||||
const { fov } = renderer.getView();
|
||||
renderer.setView({ fov: fov * Math.exp(event.deltaY * 0.001) });
|
||||
}
|
||||
|
||||
function onKeyDown(event) {
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
event.altKey ||
|
||||
event.ctrlKey ||
|
||||
event.metaKey
|
||||
)
|
||||
return;
|
||||
const pano = showing === 'pano' && renderer;
|
||||
const turn = { ArrowLeft: -1, ArrowRight: 1 }[event.key];
|
||||
// Shift+← → look around a panorama; ← → step along the sequence.
|
||||
if (turn && event.shiftKey && pano) {
|
||||
event.preventDefault();
|
||||
const { yaw, fov } = renderer.getView();
|
||||
renderer.setView({ yaw: yaw + (turn * fov) / 4 });
|
||||
emitSoon();
|
||||
} else if (turn) {
|
||||
event.preventDefault();
|
||||
stepBy(turn);
|
||||
} else if ((event.key === 'ArrowUp' || event.key === 'ArrowDown') && pano) {
|
||||
event.preventDefault();
|
||||
const { pitch, fov } = renderer.getView();
|
||||
renderer.setView({
|
||||
pitch: pitch + ((event.key === 'ArrowUp' ? 1 : -1) * fov) / 6,
|
||||
});
|
||||
emitSoon();
|
||||
} else if (['+', '=', '-', '_'].includes(event.key) && pano) {
|
||||
event.preventDefault();
|
||||
const { fov } = renderer.getView();
|
||||
renderer.setView({
|
||||
fov: fov * (event.key === '+' || event.key === '=' ? 0.85 : 1 / 0.85),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function listen(target, type, handler, options) {
|
||||
target.addEventListener(type, handler, options);
|
||||
removeListeners.push(() =>
|
||||
target.removeEventListener(type, handler, options),
|
||||
);
|
||||
}
|
||||
|
||||
function build(host) {
|
||||
const doc = host.ownerDocument;
|
||||
documentOf = doc;
|
||||
root = doc.createElement('div');
|
||||
root.className = 'sl-kv-viewer';
|
||||
root.tabIndex = 0;
|
||||
root.setAttribute('role', 'group');
|
||||
root.setAttribute(
|
||||
'aria-label',
|
||||
'KartaView photo. ← and → step along the sequence. On a 360° photo, drag or use Shift+← →, ↑ ↓ to look around, and + − to zoom',
|
||||
);
|
||||
picture = doc.createElement('img');
|
||||
picture.className = 'sl-kv-photo';
|
||||
picture.alt = 'KartaView street-level photo';
|
||||
picture.draggable = false;
|
||||
picture.hidden = true;
|
||||
canvas = makeCanvas(doc);
|
||||
const nav = (label, glyph, delta) => {
|
||||
const button = doc.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = `sl-kv-step sl-kv-step-${delta < 0 ? 'prev' : 'next'}`;
|
||||
button.textContent = glyph;
|
||||
button.title = label;
|
||||
button.setAttribute('aria-label', label);
|
||||
button.disabled = true;
|
||||
listen(button, 'click', (event) => {
|
||||
event.stopPropagation();
|
||||
stepBy(delta);
|
||||
});
|
||||
return button;
|
||||
};
|
||||
prevButton = nav('Previous photo in this sequence (←)', '‹', -1);
|
||||
nextButton = nav('Next photo in this sequence (→)', '›', 1);
|
||||
root.append(picture, canvas, prevButton, nextButton);
|
||||
host.append(root);
|
||||
listen(root, 'keydown', onKeyDown);
|
||||
applyRenderMode();
|
||||
}
|
||||
|
||||
function teardown() {
|
||||
pointers.clear();
|
||||
pinch = null;
|
||||
for (const remove of removeListeners) remove();
|
||||
removeListeners = [];
|
||||
if (poseFrame) cancelAnimationFrame(poseFrame);
|
||||
poseFrame = 0;
|
||||
drag = null;
|
||||
renderer?.destroy();
|
||||
renderer = null;
|
||||
root?.remove();
|
||||
root = picture = canvas = prevButton = nextButton = null;
|
||||
showing = null;
|
||||
photo = null;
|
||||
}
|
||||
|
||||
/** A sharper version once the first is on screen, if nothing replaced it. */
|
||||
async function upgrade(target, seq, kind) {
|
||||
// A 360° photo shown flat (no WebGL) keeps its front view.
|
||||
if (kind === 'flat' && target.isPano) return;
|
||||
const size = kind === 'pano' ? panoSizeNow() : FLAT_FULL_SIZE;
|
||||
if (kind === 'pano' && size[0] <= panoWidth) return;
|
||||
// A docked viewer shows the 1,280 px version in full: the 2,560 px one
|
||||
// (~0.6 MB a photo) waits until the viewer is bigger than that.
|
||||
if (kind === 'flat' && (flatSharp || flatPixels() <= FLAT_VIEW_SIZE[0]))
|
||||
return;
|
||||
const url =
|
||||
kind === 'pano'
|
||||
? resizedImageUrl(target.urls.pano, size)
|
||||
: resizedImageUrl(target.urls.full, size);
|
||||
if (!url) return;
|
||||
// Resizes come in bursts while the panel animates: one load at a time
|
||||
// for this photo, and one more look once it lands.
|
||||
if (upgrading?.seq === seq && upgrading.kind === kind) {
|
||||
upgradeAgain = true;
|
||||
return;
|
||||
}
|
||||
const pending = (upgrading = { seq, kind });
|
||||
upgradeAgain = false;
|
||||
let image = null;
|
||||
try {
|
||||
image = await load(url, { crossOrigin: kind === 'pano' });
|
||||
if (seq !== openSeq || photo !== target || showing !== kind) return;
|
||||
if (kind === 'pano') {
|
||||
if (!renderer || size[0] <= panoWidth) return;
|
||||
renderer.setImage(image);
|
||||
image = null;
|
||||
panoWidth = size[0];
|
||||
} else {
|
||||
picture.src = image.src;
|
||||
image = null;
|
||||
flatSharp = true;
|
||||
}
|
||||
requestRender();
|
||||
} catch {
|
||||
/* the first version stays */
|
||||
} finally {
|
||||
release(image);
|
||||
if (upgrading === pending) {
|
||||
upgrading = null;
|
||||
if (upgradeAgain && seq === openSeq && photo === target) {
|
||||
upgradeAgain = false;
|
||||
upgrade(target, seq, showing);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Device pixels across the flat photo's frame. */
|
||||
function flatPixels() {
|
||||
const ratio = globalThis.devicePixelRatio || 1;
|
||||
return (
|
||||
Math.max(picture?.clientWidth || 0, picture?.clientHeight || 0) * ratio
|
||||
);
|
||||
}
|
||||
|
||||
/** The sharp panorama this viewer needs now: 2,048 docked, 4,096 expanded. */
|
||||
function panoSizeNow() {
|
||||
const ratio = Math.min(globalThis.devicePixelRatio || 1, 2);
|
||||
const width = (canvas?.clientWidth || 0) * ratio;
|
||||
return panoSizeFor(width, renderer?.getView().fov ?? PANO_FOV_DEFAULT_DEG);
|
||||
}
|
||||
|
||||
async function showPano(target, seq, keepView) {
|
||||
// Only the proxy can shrink a 13,000 px panorama: if it fails, the
|
||||
// caller shows the front view rather than waiting on it twice.
|
||||
const image = await load(
|
||||
resizedImageUrl(target.urls.pano, PANO_PREVIEW_SIZE),
|
||||
{ crossOrigin: true },
|
||||
);
|
||||
if (seq !== openSeq) {
|
||||
release(image);
|
||||
return false;
|
||||
}
|
||||
// The context may have been lost while this loaded: build on the new
|
||||
// canvas, or let the caller show the front view.
|
||||
if (!ensureRenderer()) {
|
||||
release(image);
|
||||
throw new Error(LOAD_FAILED);
|
||||
}
|
||||
const restore = restoreView?.id === target.id ? restoreView.view : null;
|
||||
restoreView = null;
|
||||
if (restore) renderer.setView(restore);
|
||||
else if (!keepView || showing !== 'pano')
|
||||
renderer.setView({ yaw: 0, pitch: 0, fov: PANO_FOV_DEFAULT_DEG });
|
||||
photo = target;
|
||||
// Shown first: a hidden canvas measures 0 and would be sized 1×1.
|
||||
show('pano');
|
||||
renderer.setImage(image);
|
||||
panoWidth = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
async function showFlat(target, seq) {
|
||||
// The proxy's 1,280 px version is a few hundred kB; KartaView's own
|
||||
// "large thumbnail" can be 1.7 MB. Either serves when the other fails.
|
||||
// A 360° photo's front view is only on storage.
|
||||
const proxied = target.isPano
|
||||
? null
|
||||
: resizedImageUrl(target.urls.full, FLAT_VIEW_SIZE);
|
||||
const sources = [proxied, target.urls.view].filter(Boolean);
|
||||
let image;
|
||||
let failure = null;
|
||||
for (const url of sources) {
|
||||
try {
|
||||
image = await load(url, { crossOrigin: false });
|
||||
break;
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
if (seq !== openSeq) return false;
|
||||
}
|
||||
}
|
||||
if (!image) throw failure || new Error(LOAD_FAILED);
|
||||
if (seq !== openSeq) return false;
|
||||
picture.src = image.src;
|
||||
flatSharp = false;
|
||||
photo = target;
|
||||
show('flat');
|
||||
return true;
|
||||
}
|
||||
|
||||
async function openPhoto(id, seq, keepView) {
|
||||
const target = await getPhoto(id);
|
||||
if (seq !== openSeq) return;
|
||||
let shown = false;
|
||||
if (target.isPano && target.urls.pano && ensureRenderer()) {
|
||||
try {
|
||||
shown = await showPano(target, seq, keepView);
|
||||
} catch {
|
||||
// The panorama failed: its front view is better than nothing.
|
||||
if (seq !== openSeq) return;
|
||||
shown = await showFlat(target, seq);
|
||||
}
|
||||
} else shown = await showFlat(target, seq);
|
||||
if (!shown) return;
|
||||
emit();
|
||||
onShown(target);
|
||||
upgrade(target, seq, showing);
|
||||
}
|
||||
|
||||
const adapter = {
|
||||
async mount(host) {
|
||||
if (!host) throw new Error('Street Level viewer has no host element');
|
||||
if (root && root.parentNode === host) return;
|
||||
teardown();
|
||||
rendererFailed = false;
|
||||
build(host);
|
||||
},
|
||||
|
||||
/** Resolves once the photo is on screen and its pose was emitted. */
|
||||
async open(imageId) {
|
||||
if (!root) throw new Error('KartaView viewer is not mounted');
|
||||
const id = String(imageId);
|
||||
const seq = ++openSeq;
|
||||
const keepView = keepViewFor === id;
|
||||
keepViewFor = null;
|
||||
openingId = id;
|
||||
try {
|
||||
await openPhoto(id, seq, keepView);
|
||||
} finally {
|
||||
if (openingId === id && seq === openSeq) openingId = null;
|
||||
}
|
||||
},
|
||||
|
||||
close() {
|
||||
openSeq++;
|
||||
openingId = null;
|
||||
keepViewFor = null;
|
||||
photo = null;
|
||||
show(null);
|
||||
if (picture) picture.removeAttribute('src');
|
||||
renderer?.clear?.();
|
||||
},
|
||||
|
||||
unmount() {
|
||||
openSeq++;
|
||||
openingId = null;
|
||||
keepViewFor = null;
|
||||
teardown();
|
||||
},
|
||||
|
||||
resize() {
|
||||
renderer?.resize();
|
||||
// Expanded: a docked viewer's first version is now too soft.
|
||||
if (showing === 'pano' && photo && panoSizeNow()[0] > panoWidth)
|
||||
upgrade(photo, openSeq, 'pano');
|
||||
if (showing === 'flat' && photo && !flatSharp)
|
||||
upgrade(photo, openSeq, 'flat');
|
||||
},
|
||||
|
||||
/** FIT / FILL frame a flat photo; a panorama has no frame to fit. */
|
||||
supportsRenderModes: () => showing !== 'pano',
|
||||
|
||||
setRenderMode(mode) {
|
||||
renderMode = mode === 'fill' ? 'fill' : 'letterbox';
|
||||
applyRenderMode();
|
||||
},
|
||||
|
||||
/** The photo's direction became known (its drive loaded): pose again. */
|
||||
refreshPose() {
|
||||
emit();
|
||||
},
|
||||
|
||||
onPose(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
};
|
||||
return adapter;
|
||||
export function createKartaViewViewer({ load = loadImage, ...options }) {
|
||||
return createPhotoViewer({
|
||||
providerId: KARTAVIEW_PROVIDER_ID,
|
||||
name: KARTAVIEW_NAME,
|
||||
sources: KARTAVIEW_SOURCES,
|
||||
load,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
@@ -883,6 +883,11 @@ test('the panorama is as sharp as the viewer needs: 2,048 docked, 4,096 once exp
|
||||
});
|
||||
|
||||
test('a CORS image is decoded off the main thread into an ImageBitmap where the browser can', async (t) => {
|
||||
// A blob whose header names no size (the viewer then reads none).
|
||||
const fakeBlob = {
|
||||
size: 4,
|
||||
slice: () => ({ arrayBuffer: async () => new ArrayBuffer(0) }),
|
||||
};
|
||||
const saved = {
|
||||
fetch: globalThis.fetch,
|
||||
bitmap: globalThis.createImageBitmap,
|
||||
@@ -896,7 +901,7 @@ test('a CORS image is decoded off the main thread into an ImageBitmap where the
|
||||
fetched.push({ url, mode: init.mode });
|
||||
return url.includes('gone')
|
||||
? { ok: false, status: 409 }
|
||||
: { ok: true, blob: async () => 'blob' };
|
||||
: { ok: true, blob: async () => fakeBlob };
|
||||
};
|
||||
globalThis.createImageBitmap = async (blob) => ({
|
||||
blob,
|
||||
@@ -906,7 +911,7 @@ test('a CORS image is decoded off the main thread into an ImageBitmap where the
|
||||
const bitmap = await loadImage('https://cdn.kartaview.org/a', {
|
||||
crossOrigin: true,
|
||||
});
|
||||
assert.deepEqual(bitmap, { blob: 'blob', width: 4096, height: 2048 });
|
||||
assert.deepEqual(bitmap, { blob: fakeBlob, width: 4096, height: 2048 });
|
||||
assert.deepEqual(fetched, [
|
||||
{ url: 'https://cdn.kartaview.org/a', mode: 'cors' },
|
||||
]);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { PbfWriter } from 'pbf';
|
||||
|
||||
const zigzag = (value) => (value << 1) ^ (value >> 31);
|
||||
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) {
|
||||
@@ -13,7 +13,7 @@ export function lonLatToTileLocal([lon, lat], { x, y, z }, extent = 4096) {
|
||||
}
|
||||
|
||||
/** One MVT layer writer with deduplicated keys and appended values. */
|
||||
function layerWriter(name, extent) {
|
||||
export function layerWriter(name, extent) {
|
||||
const keys = new Map();
|
||||
const values = [];
|
||||
const features = [];
|
||||
@@ -62,7 +62,7 @@ function layerWriter(name, extent) {
|
||||
}
|
||||
|
||||
/** MVT MoveTo/LineTo commands; the cursor carries over between parts. */
|
||||
function lineGeometry(parts) {
|
||||
export function lineGeometry(parts) {
|
||||
const out = [];
|
||||
let cx = 0;
|
||||
let cy = 0;
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
import { PbfReader } from 'pbf';
|
||||
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';
|
||||
|
||||
/**
|
||||
* Douglas–Peucker in tile units: the points of `line` ({x, y}) that keep
|
||||
* it within `tolerance` of the original (measured to each segment, so
|
||||
* dead ends and U-turns stay), first and last always kept.
|
||||
* Iterative, so a long track cannot overflow the stack.
|
||||
*/
|
||||
export function simplifyLine(line, tolerance) {
|
||||
if (line.length < 3 || !(tolerance > 0)) return line;
|
||||
const keep = new Uint8Array(line.length);
|
||||
keep[0] = 1;
|
||||
keep[line.length - 1] = 1;
|
||||
const spans = [[0, line.length - 1]];
|
||||
while (spans.length) {
|
||||
const [from, to] = spans.pop();
|
||||
const a = line[from];
|
||||
const b = line[to];
|
||||
const dx = b.x - a.x;
|
||||
const dy = b.y - a.y;
|
||||
const span2 = dx * dx + dy * dy;
|
||||
let far = -1;
|
||||
let farthest = tolerance;
|
||||
for (let i = from + 1; i < to; i++) {
|
||||
const p = line[i];
|
||||
// Distance to the segment, not the infinite line: a dead end or a
|
||||
// U-turn (a point beyond the chord's ends) is kept.
|
||||
const t = span2
|
||||
? Math.max(
|
||||
0,
|
||||
Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / span2),
|
||||
)
|
||||
: 0;
|
||||
const d = Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy));
|
||||
if (d > farthest) {
|
||||
farthest = d;
|
||||
far = i;
|
||||
}
|
||||
}
|
||||
if (far === -1) continue;
|
||||
keep[far] = 1;
|
||||
spans.push([from, far], [far, to]);
|
||||
}
|
||||
return line.filter((_, i) => keep[i]);
|
||||
}
|
||||
|
||||
/** Epoch ms of a sequence's day ('2025-08-11'), or 0 when undated. */
|
||||
function dayMs(value) {
|
||||
return panoramaxTimeMs(typeof value === 'string' ? value : '') ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a Panoramax coverage tile (`/api/map/{z}/{x}/{y}.mvt`).
|
||||
*
|
||||
* - `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.
|
||||
* - `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}>}}
|
||||
*/
|
||||
export function decodeCoverageTile(bytes, address) {
|
||||
const steps = coverageSteps(bytes, address);
|
||||
let step = steps.next();
|
||||
while (!step.done) step = steps.next();
|
||||
return step.value;
|
||||
}
|
||||
|
||||
/** Give the page a turn: input and rendering run before the next slice. */
|
||||
function nextTask() {
|
||||
if (typeof globalThis.scheduler?.yield === 'function')
|
||||
return globalThis.scheduler.yield();
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* `decodeCoverageTile` in slices of about `sliceMs`, the page getting a
|
||||
* turn between them: a 2 MB z10 tile over Paris is ~200 ms of decoding on
|
||||
* a fast laptop, more on a phone, and would freeze the globe while the
|
||||
* camera approaches. Stops at the next slice once `signal` aborts.
|
||||
* @param {Uint8Array} bytes
|
||||
* @param {{x: number, y: number, z: number}} address
|
||||
* @param {{signal?: AbortSignal, sliceMs?: number, pause?: () => Promise<void>, now?: () => number}} [options]
|
||||
*/
|
||||
export async function decodeCoverageTileSliced(
|
||||
bytes,
|
||||
address,
|
||||
{
|
||||
signal,
|
||||
sliceMs = DECODE_SLICE_MS,
|
||||
pause = nextTask,
|
||||
now = () => performance.now(),
|
||||
} = {},
|
||||
) {
|
||||
const steps = coverageSteps(bytes, address);
|
||||
let started = now();
|
||||
let step = steps.next();
|
||||
while (!step.done) {
|
||||
if (now() - started >= sliceMs) {
|
||||
await pause();
|
||||
signal?.throwIfAborted();
|
||||
started = now();
|
||||
}
|
||||
step = steps.next();
|
||||
}
|
||||
return step.value;
|
||||
}
|
||||
|
||||
/** The decode, a step a feature (see `decodeCoverageTile`). */
|
||||
function* coverageSteps(bytes, address) {
|
||||
const result = { sequences: [], overview: [] };
|
||||
if (!bytes || !bytes.length) return result;
|
||||
const tile = new VectorTile(new PbfReader(bytes));
|
||||
const { x, y, z } = address;
|
||||
|
||||
const sequences = tile.layers.sequences;
|
||||
if (sequences) {
|
||||
const measured = [];
|
||||
for (let i = 0; i < sequences.length; i++) {
|
||||
const feature = sequences.feature(i);
|
||||
const props = feature.properties || {};
|
||||
const parts = [];
|
||||
let length = 0;
|
||||
const tolerance = (SIMPLIFY_TOLERANCE_UNITS * sequences.extent) / 4096;
|
||||
for (const line of feature.loadGeometry()) {
|
||||
if (line.length < 2) continue;
|
||||
for (let k = 1; k < line.length; k++)
|
||||
length += Math.hypot(
|
||||
line[k].x - line[k - 1].x,
|
||||
line[k].y - line[k - 1].y,
|
||||
);
|
||||
// Simplified before anything else: GPS tracks carry a point every
|
||||
// few metres, and each becomes ~8 ground-line vertices on the GPU.
|
||||
parts.push(
|
||||
simplifyLine(line, tolerance).map((point) =>
|
||||
tileLocalToLonLat(point.x, point.y, sequences.extent, x, y, z),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (!parts.length || props.id == null) continue;
|
||||
const sequence = {
|
||||
id: String(props.id),
|
||||
parts,
|
||||
capturedAt: dayMs(props.date),
|
||||
// A sequence with no camera type is neither (the filters keep it).
|
||||
isPano:
|
||||
props.type === 'equirectangular'
|
||||
? true
|
||||
: props.type === 'flat'
|
||||
? false
|
||||
: null,
|
||||
};
|
||||
measured.push({ sequence, length });
|
||||
yield;
|
||||
}
|
||||
// A turn before the sort: over a city it is thousands of lines.
|
||||
yield;
|
||||
measured.sort((a, b) => b.length - a.length);
|
||||
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;
|
||||
}
|
||||
|
||||
/** A picture's sequence ids: the tile stores them as a JSON array in a string. */
|
||||
function sequenceIdsOf(props) {
|
||||
const raw = props.sequences;
|
||||
if (typeof raw === 'string' && raw.startsWith('[')) {
|
||||
try {
|
||||
const ids = JSON.parse(raw);
|
||||
if (Array.isArray(ids)) return ids.map(String);
|
||||
} catch {
|
||||
/* fall through to the first sequence */
|
||||
}
|
||||
}
|
||||
return props.first_sequence != null ? [String(props.first_sequence)] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* The pictures of a z15 tile that belong to `sequenceId`, as cones draw
|
||||
* them (unsorted).
|
||||
* @param {Uint8Array} bytes
|
||||
* @param {{x: number, y: number, z: number}} address
|
||||
* @param {string} sequenceId
|
||||
* @returns {Array<{id: string, lon: number, lat: number, compassAngle: number, capturedAt: number, isPano: boolean}>}
|
||||
*/
|
||||
export function decodePictures(bytes, address, sequenceId) {
|
||||
if (!bytes || !bytes.length) return [];
|
||||
const tile = new VectorTile(new PbfReader(bytes));
|
||||
const layer = tile.layers.pictures;
|
||||
if (!layer) return [];
|
||||
const { x, y, z } = address;
|
||||
const wanted = String(sequenceId);
|
||||
const out = [];
|
||||
for (let i = 0; i < layer.length; i++) {
|
||||
const feature = layer.feature(i);
|
||||
const props = feature.properties || {};
|
||||
// A cheap test first: most pictures in a tile are other sequences'.
|
||||
if (
|
||||
typeof props.sequences === 'string' &&
|
||||
!props.sequences.includes(wanted)
|
||||
)
|
||||
continue;
|
||||
if (!sequenceIdsOf(props).includes(wanted) || props.id == null) continue;
|
||||
const point = feature.loadGeometry()?.[0]?.[0];
|
||||
if (!point) continue;
|
||||
const [lon, lat] = tileLocalToLonLat(
|
||||
point.x,
|
||||
point.y,
|
||||
layer.extent,
|
||||
x,
|
||||
y,
|
||||
z,
|
||||
);
|
||||
const heading = Number(props.heading);
|
||||
out.push({
|
||||
id: String(props.id),
|
||||
lon,
|
||||
lat,
|
||||
compassAngle: Number.isFinite(heading) ? heading : 0,
|
||||
capturedAt: panoramaxTimeMs(String(props.ts ?? '')) ?? 0,
|
||||
isPano: props.type === 'equirectangular',
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
decodeCoverageTile,
|
||||
decodeCoverageTileSliced,
|
||||
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';
|
||||
|
||||
const near = (a, b, eps = 1e-4) => Math.abs(a - b) < eps;
|
||||
|
||||
test('sequence lines keep their parts apart, with their day and camera type', () => {
|
||||
const { tile, lon, lat } = tileCentre(2.35, 48.85);
|
||||
const bytes = encodePanoramaxTile(tile, {
|
||||
sequences: [
|
||||
{
|
||||
id: 'pano-seq',
|
||||
type: 'equirectangular',
|
||||
date: '2024-03-05',
|
||||
parts: [
|
||||
[
|
||||
[lon - 0.001, lat],
|
||||
[lon, lat],
|
||||
],
|
||||
[
|
||||
[lon + 0.0005, lat],
|
||||
[lon + 0.001, lat],
|
||||
],
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'flat-seq',
|
||||
parts: [
|
||||
[
|
||||
[lon, lat - 0.001],
|
||||
[lon, lat],
|
||||
],
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
const { sequences, overview } = decodeCoverageTile(bytes, tile);
|
||||
assert.equal(overview.length, 0);
|
||||
assert.equal(sequences.length, 2);
|
||||
const pano = sequences.find((s) => s.id === 'pano-seq');
|
||||
const flat = sequences.find((s) => s.id === 'flat-seq');
|
||||
assert.equal(pano.isPano, true);
|
||||
assert.equal(pano.capturedAt, Date.UTC(2024, 2, 5));
|
||||
assert.equal(pano.parts.length, 2, 'the gap is not bridged');
|
||||
assert.ok(near(pano.parts[1][1][0], lon + 0.001));
|
||||
assert.equal(flat.isPano, false);
|
||||
const untyped = decodeCoverageTile(
|
||||
encodePanoramaxTile(tile, {
|
||||
sequences: [
|
||||
{
|
||||
id: 'u',
|
||||
type: '',
|
||||
parts: [
|
||||
[
|
||||
[lon, lat],
|
||||
[lon + 0.001, lat],
|
||||
],
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
tile,
|
||||
).sequences[0];
|
||||
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', () => {
|
||||
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,
|
||||
);
|
||||
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', () => {
|
||||
const { tile, lon, lat } = tileCentre(2.35, 48.85);
|
||||
const span = (id, metres) => ({
|
||||
id,
|
||||
parts: [
|
||||
[
|
||||
[lon, lat],
|
||||
[lon + metres / 73_000, lat],
|
||||
],
|
||||
],
|
||||
});
|
||||
const { sequences } = decodeCoverageTile(
|
||||
encodePanoramaxTile(tile, {
|
||||
sequences: [span('short', 20), span('long', 900), span('mid', 300)],
|
||||
}),
|
||||
tile,
|
||||
);
|
||||
assert.deepEqual(
|
||||
sequences.map((s) => s.id),
|
||||
['long', 'mid', 'short'],
|
||||
);
|
||||
});
|
||||
|
||||
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);
|
||||
const sequences = Array.from({ length: 200 }, (_, i) => ({
|
||||
id: `s${i}`,
|
||||
parts: [
|
||||
[
|
||||
[lon, lat + i / 1e6],
|
||||
[lon + (i + 1) / 73_000, lat + i / 1e6],
|
||||
],
|
||||
],
|
||||
}));
|
||||
const grid = Array.from({ length: 500 }, (_, i) => ({
|
||||
lon: lon + (i % 25) / 1e4,
|
||||
lat: lat + Math.floor(i / 25) / 1e4,
|
||||
}));
|
||||
return { tile, bytes: encodePanoramaxTile(tile, { sequences, grid }) };
|
||||
}
|
||||
|
||||
test('a sliced decode gives the page turns and decodes the same as one in one go', async () => {
|
||||
const { tile, bytes } = busyTile();
|
||||
let clock = 0;
|
||||
let pauses = 0;
|
||||
const sliced = await decodeCoverageTileSliced(bytes, tile, {
|
||||
sliceMs: 8,
|
||||
// Each step "takes" a millisecond: a turn every eight features.
|
||||
now: () => clock++,
|
||||
pause: async () => {
|
||||
pauses++;
|
||||
},
|
||||
});
|
||||
assert.ok(pauses > 20, `${pauses} turns`);
|
||||
assert.deepEqual(sliced, decodeCoverageTile(bytes, tile));
|
||||
});
|
||||
|
||||
test('a sliced decode stops at the next turn once aborted', async () => {
|
||||
const { tile, bytes } = busyTile();
|
||||
const controller = new AbortController();
|
||||
let clock = 0;
|
||||
let pauses = 0;
|
||||
await assert.rejects(
|
||||
decodeCoverageTileSliced(bytes, tile, {
|
||||
signal: controller.signal,
|
||||
now: () => (clock += 10),
|
||||
pause: async () => {
|
||||
if (++pauses === 2) controller.abort();
|
||||
},
|
||||
}),
|
||||
{ name: 'AbortError' },
|
||||
);
|
||||
assert.equal(pauses, 2, 'no work after the abort');
|
||||
});
|
||||
|
||||
test('a fast sliced decode takes no turns', async () => {
|
||||
const { tile, bytes } = busyTile();
|
||||
let pauses = 0;
|
||||
await decodeCoverageTileSliced(bytes, tile, {
|
||||
now: () => 0,
|
||||
pause: async () => {
|
||||
pauses++;
|
||||
},
|
||||
});
|
||||
assert.equal(pauses, 0);
|
||||
});
|
||||
|
||||
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'),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('a z15 tile’s pictures of one sequence become cones; other sequences’ are skipped', () => {
|
||||
const { tile, lon, lat } = tileCentre(2.35, 48.85, 15);
|
||||
const bytes = encodePanoramaxTile(tile, {
|
||||
pictures: [
|
||||
{
|
||||
id: 'a',
|
||||
lon,
|
||||
lat,
|
||||
sequenceId: 's1',
|
||||
heading: 45,
|
||||
type: 'equirectangular',
|
||||
ts: '2023-12-12 07:56:37+00',
|
||||
},
|
||||
{ id: 'b', lon: lon + 0.0001, lat, sequenceId: 's2' },
|
||||
{ id: 'c', lon: lon + 0.0002, lat, sequenceId: 's1' },
|
||||
],
|
||||
});
|
||||
const images = decodePictures(bytes, tile, 's1');
|
||||
assert.deepEqual(
|
||||
images.map((i) => i.id),
|
||||
['a', 'c'],
|
||||
);
|
||||
assert.equal(images[0].compassAngle, 45);
|
||||
assert.equal(images[0].isPano, true);
|
||||
assert.equal(images[0].capturedAt, Date.UTC(2023, 11, 12, 7, 56, 37));
|
||||
assert.ok(near(images[1].lon, lon + 0.0002));
|
||||
assert.deepEqual(decodePictures(bytes, tile, 'nope'), []);
|
||||
// A tile without pictures (below z15) has none to give.
|
||||
const lines = encodePanoramaxTile(tile, {
|
||||
sequences: [
|
||||
{
|
||||
id: 's1',
|
||||
parts: [
|
||||
[
|
||||
[lon, lat],
|
||||
[lon + 0.001, lat],
|
||||
],
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.deepEqual(decodePictures(lines, tile, 's1'), []);
|
||||
});
|
||||
|
||||
test('lines are simplified within a few tile units, ends and corners kept', () => {
|
||||
const zigzag = Array.from({ length: 1000 }, (_, i) => ({
|
||||
x: i,
|
||||
y: i % 2 ? 1 : 0,
|
||||
}));
|
||||
assert.deepEqual(
|
||||
simplifyLine(zigzag, 4),
|
||||
[zigzag[0], zigzag.at(-1)],
|
||||
'a 1-unit wobble goes',
|
||||
);
|
||||
const corner = [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 50, y: 1 },
|
||||
{ x: 100, y: 0 },
|
||||
{ x: 100, y: 100 },
|
||||
];
|
||||
assert.deepEqual(simplifyLine(corner, 4), [corner[0], corner[2], corner[3]]);
|
||||
assert.equal(simplifyLine(corner.slice(0, 2), 4).length, 2);
|
||||
// A loop back to its start (no chord) keeps its far point.
|
||||
const loop = [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 40, y: 0 },
|
||||
{ x: 0, y: 0 },
|
||||
];
|
||||
assert.equal(simplifyLine(loop, 4).length, 3);
|
||||
assert.equal(simplifyLine(zigzag, 0).length, 1000, 'no tolerance, no change');
|
||||
// A dead end: out to 100 and back to 50 keeps its far end.
|
||||
const deadEnd = [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 100, y: 0 },
|
||||
{ x: 50, y: 0 },
|
||||
];
|
||||
assert.deepEqual(simplifyLine(deadEnd, 4), deadEnd);
|
||||
});
|
||||
|
||||
test('a decoded GPS track keeps a fraction of its points', () => {
|
||||
const { tile, lon, lat } = tileCentre(2.35, 48.85);
|
||||
const track = Array.from({ length: 500 }, (_, i) => [
|
||||
lon - 0.004 + i * 0.000016,
|
||||
lat + Math.sin(i / 40) * 0.0004,
|
||||
]);
|
||||
const [sequence] = decodeCoverageTile(
|
||||
encodePanoramaxTile(tile, { sequences: [{ id: 'gps', parts: [track] }] }),
|
||||
tile,
|
||||
).sequences;
|
||||
assert.ok(
|
||||
sequence.parts[0].length < 100,
|
||||
`${sequence.parts[0].length} points`,
|
||||
);
|
||||
assert.ok(sequence.parts[0].length > 5);
|
||||
});
|
||||
@@ -0,0 +1,642 @@
|
||||
import { createLineCoverage, sequenceIdFromPick } from '../../lineCoverage.js';
|
||||
import { createSequenceCones, thinImages } from '../../sequenceCones.js';
|
||||
import { passesImageryFilter } from '../../filter.js';
|
||||
import {
|
||||
lonToTileX,
|
||||
latToTileY,
|
||||
overviewZoomForHeight,
|
||||
tileBounds,
|
||||
} from '../../tileMath.js';
|
||||
import { metresBetween } from '../../view.js';
|
||||
import { createLru, createTileCache } from '../../tileCache.js';
|
||||
import { decodeCoverageTileSliced, decodePictures } from './decode.js';
|
||||
import { createPanoramaxSource } from './source.js';
|
||||
import { createPanoramaxViewer } from './viewer.js';
|
||||
import {
|
||||
COLORS,
|
||||
COVERAGE_LINES_BELOW_M,
|
||||
COVERAGE_TUNING,
|
||||
COVERAGE_Z11_BELOW_M,
|
||||
COVERAGE_Z12_BELOW_M,
|
||||
COVERAGE_Z13_BELOW_M,
|
||||
COVERAGE_Z14_BELOW_M,
|
||||
GONE_MESSAGE,
|
||||
LINE_CLICK_HINT,
|
||||
LINE_CLICK_RADIUS_MAX_M,
|
||||
LINE_CLICK_RADIUS_PER_HEIGHT,
|
||||
NEAREST_LIMIT,
|
||||
NEAREST_RADIUS_M,
|
||||
FILTERED_HINT,
|
||||
NO_COVERAGE_HINT,
|
||||
OVERVIEW_HINT,
|
||||
PANORAMAX_CREDIT_HTML,
|
||||
PANORAMAX_LABEL,
|
||||
PANORAMAX_NAME,
|
||||
PANORAMAX_PROVIDER_ID,
|
||||
PICK_PREFIX,
|
||||
PICTURE_TILE_EDGE_M,
|
||||
PICTURE_TILE_ZOOM,
|
||||
RATE_LIMITED_MESSAGE,
|
||||
UNREADABLE_TILE_MESSAGE,
|
||||
normalizeItem,
|
||||
} from './policy.js';
|
||||
|
||||
export { PANORAMAX_PROVIDER_ID } from './policy.js';
|
||||
|
||||
/**
|
||||
* A tile's bytes decoded by `decode`; a body that is not a vector tile
|
||||
* (an HTML error page, a cut-off answer) reads as such, not as a parser's
|
||||
* "Unimplemented type: 4".
|
||||
*/
|
||||
async function readable(decode) {
|
||||
try {
|
||||
return await decode();
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError' || error?.status !== undefined)
|
||||
throw error;
|
||||
throw new Error(UNREADABLE_TILE_MESSAGE);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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. */
|
||||
const ANCHOR_CACHE_SIZE = 50;
|
||||
/** Decoded coverage tiles kept (a simplified dense z10 tile is ~4 MB). */
|
||||
const COVERAGE_TILE_CACHE_SIZE = 16;
|
||||
/** One sequence's pictures in a z15 tile, kept for re-centring. */
|
||||
const PICTURE_LIST_CACHE_SIZE = 32;
|
||||
|
||||
/**
|
||||
* Sequence lines at z10–14 below 60 km; above, the density grid at z5 and
|
||||
* coarser.
|
||||
*/
|
||||
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',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
/** The z15 tile holding a point. */
|
||||
function pictureTileOf({ lon, lat }) {
|
||||
const z = PICTURE_TILE_ZOOM;
|
||||
return { x: lonToTileX(lon, z), y: latToTileY(lat, z), z };
|
||||
}
|
||||
|
||||
/**
|
||||
* The z15 tiles whose pictures surround a point: its own first, and the
|
||||
* neighbours it sits within PICTURE_TILE_EDGE_M of (at most nine).
|
||||
*/
|
||||
export function pictureTilesAround(point) {
|
||||
const home = pictureTileOf(point);
|
||||
const box = tileBounds(home.x, home.y, home.z);
|
||||
const near = (lon, lat) =>
|
||||
metresBetween(point, { lon, lat }) <= PICTURE_TILE_EDGE_M;
|
||||
// Near both edges at once where tiles are narrow (high latitudes).
|
||||
const dxs = [0];
|
||||
if (near(box.west, point.lat)) dxs.push(-1);
|
||||
if (near(box.east, point.lat)) dxs.push(1);
|
||||
const dys = [0];
|
||||
if (near(point.lon, box.north)) dys.push(-1);
|
||||
if (near(point.lon, box.south)) dys.push(1);
|
||||
const max = 2 ** home.z;
|
||||
const tiles = [];
|
||||
for (const dy of dys)
|
||||
for (const dx of dxs) {
|
||||
const y = home.y + dy;
|
||||
if (y < 0 || y >= max) continue;
|
||||
tiles.push({ x: (home.x + dx + max) % max, y, z: home.z });
|
||||
}
|
||||
return tiles;
|
||||
}
|
||||
|
||||
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
|
||||
* 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.
|
||||
* @param {{source?: ReturnType<typeof createPanoramaxSource>, viewerOptions?: object}} [options]
|
||||
* @returns {import('../../registry.js').StreetLevelProvider}
|
||||
*/
|
||||
export function createPanoramaxProvider({
|
||||
source = createPanoramaxSource(),
|
||||
viewerOptions = {},
|
||||
} = {}) {
|
||||
return Object.freeze({
|
||||
id: PANORAMAX_PROVIDER_ID,
|
||||
name: PANORAMAX_NAME,
|
||||
label: PANORAMAX_LABEL,
|
||||
requiresKeyId: null,
|
||||
pickPrefix: PICK_PREFIX.root,
|
||||
colors: COLORS,
|
||||
credit: Object.freeze({ html: PANORAMAX_CREDIT_HTML }),
|
||||
defaultOn: false,
|
||||
coverageLabel: Object.freeze({
|
||||
noun: 'sequences',
|
||||
action: LINE_CLICK_HINT,
|
||||
}),
|
||||
|
||||
create(context) {
|
||||
const state = {
|
||||
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,
|
||||
},
|
||||
sequence: {
|
||||
selectedId: null,
|
||||
images: [],
|
||||
cache: new Map(),
|
||||
collection: null,
|
||||
loading: false,
|
||||
abort: null,
|
||||
},
|
||||
};
|
||||
const photos = createLru(PHOTO_CACHE_SIZE);
|
||||
/** Where each sequence's cones centre: the picture last opened in it, or a click. */
|
||||
const anchors = createLru(ANCHOR_CACHE_SIZE);
|
||||
/** The z15 tiles the cones were drawn from. */
|
||||
let drawn = null;
|
||||
/** A line click: its sequence's pictures win the next nearest lookup there. */
|
||||
let preferred = null;
|
||||
|
||||
function remember(item) {
|
||||
const photo = normalizeItem(item);
|
||||
if (photo) photos.set(photo.id, photo);
|
||||
return photo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lookups in flight, shared: ‹ › and the viewer ask for the same
|
||||
* picture. A lookup stops once every caller waiting on it has given up
|
||||
* (its search would hold one of the two API connections for nothing).
|
||||
*/
|
||||
const inflight = new Map();
|
||||
function getPhoto(photoId, { signal } = {}) {
|
||||
const id = String(photoId);
|
||||
const cached = photos.get(id);
|
||||
if (cached) return Promise.resolve(cached);
|
||||
if (signal?.aborted) return Promise.reject(signal.reason);
|
||||
let lookup = inflight.get(id);
|
||||
if (!lookup) {
|
||||
const controller = new AbortController();
|
||||
lookup = { controller, waiting: 0 };
|
||||
lookup.promise = source
|
||||
.getItem(id, { signal: controller.signal })
|
||||
.catch((error) => {
|
||||
// Ids are UUIDs: the API answers 400 to anything else.
|
||||
if (error?.status === 400) return null;
|
||||
throw error;
|
||||
})
|
||||
.then((item) => {
|
||||
const photo = item && remember(item);
|
||||
if (!photo) throw new Error(GONE_MESSAGE);
|
||||
return photo;
|
||||
})
|
||||
.finally(() => {
|
||||
if (inflight.get(id) === lookup) inflight.delete(id);
|
||||
});
|
||||
lookup.promise.catch(() => {});
|
||||
inflight.set(id, lookup);
|
||||
}
|
||||
const shared = lookup;
|
||||
shared.waiting++;
|
||||
if (!signal) shared.waiting = Infinity;
|
||||
return new Promise((resolve, reject) => {
|
||||
const leave = () => {
|
||||
reject(signal.reason);
|
||||
shared.waiting--;
|
||||
// A caller in the same tick (the same photo opened again) may
|
||||
// still join: stop only if nobody has.
|
||||
queueMicrotask(() => {
|
||||
if (shared.waiting > 0) return;
|
||||
shared.controller.abort(signal.reason);
|
||||
if (inflight.get(id) === shared) inflight.delete(id);
|
||||
});
|
||||
};
|
||||
signal?.addEventListener('abort', leave, { once: true });
|
||||
shared.promise.then(
|
||||
(photo) => {
|
||||
signal?.removeEventListener('abort', leave);
|
||||
resolve(photo);
|
||||
},
|
||||
(error) => {
|
||||
signal?.removeEventListener('abort', leave);
|
||||
reject(error);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* How far from a line click its sequence's pictures are looked for:
|
||||
* from high up the click may land tens of metres off the line.
|
||||
*/
|
||||
function lineClickRadius() {
|
||||
const height = state.viewer?.camera?.positionCartographic?.height;
|
||||
if (!Number.isFinite(height)) return NEAREST_RADIUS_M;
|
||||
return Math.min(
|
||||
LINE_CLICK_RADIUS_MAX_M,
|
||||
Math.max(NEAREST_RADIUS_M, height * LINE_CLICK_RADIUS_PER_HEIGHT),
|
||||
);
|
||||
}
|
||||
|
||||
/** The picture `delta` along its sequence, from its own links (null at an end). */
|
||||
async function step(photo, delta) {
|
||||
if (!photo?.sequenceId) return null;
|
||||
return (delta < 0 ? photo.prevId : photo.nextId) || null;
|
||||
}
|
||||
|
||||
// Decoded tiles, kept a while: panning back, or across a zoom band and
|
||||
// back, neither fetches nor decodes them again.
|
||||
const coverageTiles = createTileCache({
|
||||
size: COVERAGE_TILE_CACHE_SIZE,
|
||||
load: (tile, { signal }) =>
|
||||
readable(async () =>
|
||||
decodeCoverageTileSliced(
|
||||
await source.getTile(tile, { signal }),
|
||||
tile,
|
||||
{ signal },
|
||||
),
|
||||
),
|
||||
});
|
||||
/** A z15 tile's pictures of one sequence, by `z/x/y:sequence`. */
|
||||
const pictureLists = createLru(PICTURE_LIST_CACHE_SIZE);
|
||||
/**
|
||||
* Only finished lists are kept: a load in flight belongs to its own
|
||||
* selection, whose abort (a re-centre) must not empty the next one's.
|
||||
*/
|
||||
async function picturesIn(tile, sequenceId, signal) {
|
||||
const key = `${tileKey(tile)}:${sequenceId}`;
|
||||
const kept = pictureLists.get(key);
|
||||
if (kept) return kept;
|
||||
const list = await readable(async () =>
|
||||
decodePictures(
|
||||
await source.getTile(tile, { signal, lane: 'pictures' }),
|
||||
tile,
|
||||
sequenceId,
|
||||
),
|
||||
);
|
||||
pictureLists.set(key, list);
|
||||
return list;
|
||||
}
|
||||
|
||||
const coverage = createLineCoverage({
|
||||
state,
|
||||
fetchTile: (tile, options) => coverageTiles.get(tile, options),
|
||||
zoomFor: panoramaxZoomFor,
|
||||
colors: COLORS,
|
||||
pickPrefix: PICK_PREFIX.sequence,
|
||||
renderKey: 'panoramax-coverage',
|
||||
tuning: COVERAGE_TUNING,
|
||||
messages: { rateLimited: RATE_LIMITED_MESSAGE },
|
||||
passes: passesCoverage,
|
||||
overviewPointSize,
|
||||
});
|
||||
|
||||
const cones = createSequenceCones({
|
||||
state,
|
||||
async loadImages(sequenceId, { signal }) {
|
||||
const anchor = anchors.get(sequenceId);
|
||||
if (!anchor) return [];
|
||||
const tiles = pictureTilesAround(anchor);
|
||||
// Known from the start: a picture opening while these load is
|
||||
// judged against them (re-centred if outside, left alone inside).
|
||||
const covering = { sequenceId, tiles: new Set(tiles.map(tileKey)) };
|
||||
drawn = covering;
|
||||
const conesOf = (lists) =>
|
||||
thinImages(
|
||||
[
|
||||
...new Map(
|
||||
lists.flat().map((image) => [image.id, image]),
|
||||
).values(),
|
||||
].sort((a, b) => a.capturedAt - b.capturedAt),
|
||||
);
|
||||
const keyOf = (tile) => `${tileKey(tile)}:${sequenceId}`;
|
||||
// The home tile is needed now (it holds the open picture).
|
||||
const home = await picturesIn(tiles[0], sequenceId, signal);
|
||||
signal?.throwIfAborted();
|
||||
// Neighbours already read draw with it; the rest (each up to
|
||||
// 1.4 MB in central Paris) join the cones as they arrive.
|
||||
const neighbours = tiles.slice(1);
|
||||
const kept = neighbours
|
||||
.map((tile) => pictureLists.get(keyOf(tile)))
|
||||
.filter(Boolean);
|
||||
const missing = neighbours.filter(
|
||||
(tile) => !pictureLists.get(keyOf(tile)),
|
||||
);
|
||||
if (missing.length)
|
||||
Promise.allSettled(
|
||||
missing.map((tile) => picturesIn(tile, sequenceId, signal)),
|
||||
).then((lists) => {
|
||||
if (signal?.aborted || drawn !== covering) return;
|
||||
dropFailed(covering, missing, lists);
|
||||
// Only a tile that arrived now redraws (no loop on failures).
|
||||
if (lists.some((list) => list.status === 'fulfilled'))
|
||||
cones.select(sequenceId, { force: true });
|
||||
});
|
||||
return conesOf([home, ...kept]);
|
||||
},
|
||||
colors: COLORS,
|
||||
pickPrefix: PICK_PREFIX.image,
|
||||
spriteId: 'street-level:panoramax-cones',
|
||||
renderKey: 'panoramax-sequence',
|
||||
onHighlight: (sequenceId, selected) =>
|
||||
coverage.recolorSequence(sequenceId, selected),
|
||||
});
|
||||
|
||||
/**
|
||||
* A tile that failed is not covered: a picture opening in it later
|
||||
* re-centres the cones there (and asks for the tile again).
|
||||
*/
|
||||
function dropFailed(covering, tiles, lists) {
|
||||
lists.forEach((list, i) => {
|
||||
if (list.status === 'rejected')
|
||||
covering.tiles.delete(tileKey(tiles[i]));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The cones show the z15 tiles around one picture: once a picture of
|
||||
* the selected sequence opens outside them (‹ ›, a cone), re-centre.
|
||||
*/
|
||||
function recentreCones(photo) {
|
||||
if (!photo?.sequenceId) return;
|
||||
if (state.sequence.selectedId !== photo.sequenceId) return;
|
||||
if (drawn?.sequenceId !== photo.sequenceId) return;
|
||||
if (drawn.tiles.has(tileKey(pictureTileOf(photo)))) return;
|
||||
cones.select(photo.sequenceId, { force: true });
|
||||
}
|
||||
|
||||
const viewer = createPanoramaxViewer({
|
||||
getPhoto,
|
||||
// The cones centre on the picture actually shown, never on a
|
||||
// lookup that came back after the user moved on.
|
||||
onShowing: (photo) => {
|
||||
if (photo.sequenceId) anchors.set(photo.sequenceId, photo);
|
||||
},
|
||||
// After a GPU reset the same picture is drawn again by the viewer
|
||||
// itself: through the core it would cancel a lookup the user
|
||||
// started. A failure still shows.
|
||||
onReopen: (photoId) =>
|
||||
viewer
|
||||
.open(photoId)
|
||||
.catch((error) =>
|
||||
context.actions.reportError(
|
||||
error?.message || 'The Panoramax photo could not be loaded',
|
||||
),
|
||||
),
|
||||
step,
|
||||
onStep: (photoId) =>
|
||||
context.actions.openImage(photoId, { frame: false }),
|
||||
onShown: recentreCones,
|
||||
// Esc in the viewer clears the selected sequence, as the panel says.
|
||||
onEscape: () => {
|
||||
if (!state.sequence.selectedId) return false;
|
||||
cones.clearSelection();
|
||||
drawn = null;
|
||||
return true;
|
||||
},
|
||||
render: context.services?.render,
|
||||
...viewerOptions,
|
||||
});
|
||||
|
||||
/** Nearest of `items` within `radius` metres of a point, or null. */
|
||||
function nearestOf(items, point, radius = NEAREST_RADIUS_M) {
|
||||
let best = null;
|
||||
for (const item of items) {
|
||||
const photo = remember(item);
|
||||
if (!photo) continue;
|
||||
const metres = metresBetween(point, photo);
|
||||
if (metres > radius) continue;
|
||||
if (!best || metres < best.metres) best = { photo, metres };
|
||||
}
|
||||
return best?.photo ?? null;
|
||||
}
|
||||
|
||||
return {
|
||||
async status() {
|
||||
return { configured: true };
|
||||
},
|
||||
|
||||
init(cesiumViewer) {
|
||||
state.viewer = cesiumViewer;
|
||||
cones.ensureCollections(cesiumViewer);
|
||||
cones.setVisible(false);
|
||||
},
|
||||
|
||||
activate(cesiumViewer) {
|
||||
state.viewer = cesiumViewer;
|
||||
cones.setVisible(true);
|
||||
coverage.attach(cesiumViewer);
|
||||
},
|
||||
|
||||
deactivate() {
|
||||
coverage.detach();
|
||||
coverage.clear();
|
||||
coverage.resetErrors();
|
||||
cones.clearSelection();
|
||||
cones.setVisible(false);
|
||||
drawn = null;
|
||||
preferred = null;
|
||||
// Off: decoded tiles and lists go (several MB each over a city).
|
||||
coverageTiles.clear();
|
||||
pictureLists.clear();
|
||||
},
|
||||
|
||||
destroy(cesiumViewer) {
|
||||
coverage.detach();
|
||||
coverage.clear();
|
||||
coverage.resetErrors();
|
||||
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;
|
||||
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)
|
||||
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,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* A line opens its sequence's picture nearest the click (the cones
|
||||
* follow once it is on screen); a cone opens its picture.
|
||||
*/
|
||||
handlePick(pickId, { groundPoint } = {}) {
|
||||
if (pickId.startsWith(PICK_PREFIX.image)) {
|
||||
context.actions.openImage(pickId.slice(PICK_PREFIX.image.length));
|
||||
return true;
|
||||
}
|
||||
const sequenceId = sequenceIdFromPick(pickId, PICK_PREFIX.sequence);
|
||||
if (!sequenceId) return false;
|
||||
const point = groundPoint?.();
|
||||
// No ground under the click (the sky beyond a line): nothing to open.
|
||||
if (!point) return true;
|
||||
anchors.set(sequenceId, { ...point, sequenceId });
|
||||
preferred = {
|
||||
sequenceId,
|
||||
lon: point.lon,
|
||||
lat: point.lat,
|
||||
radius: lineClickRadius(),
|
||||
};
|
||||
context.actions.openNearest(point);
|
||||
return true;
|
||||
},
|
||||
|
||||
selectSequence: (sequenceId) =>
|
||||
cones.select(sequenceId, { force: true }),
|
||||
clearSequence: () => {
|
||||
cones.clearSelection();
|
||||
drawn = null;
|
||||
},
|
||||
sequenceStats: () => ({
|
||||
selectedId: state.sequence.selectedId,
|
||||
images: state.sequence.images.length,
|
||||
loading: state.sequence.loading,
|
||||
}),
|
||||
|
||||
/**
|
||||
* The nearest picture within the search radius that passes the
|
||||
* imagery filter, or null. After a click on a line, that line's
|
||||
* sequence is asked first.
|
||||
*/
|
||||
async nearestImage({ lat, lon }, { signal } = {}) {
|
||||
const point = { lat, lon };
|
||||
const filter = context.getFilter();
|
||||
// Only the lookup the click itself asked for (the same point).
|
||||
const wanted =
|
||||
preferred && metresBetween(preferred, point) < 1 ? preferred : null;
|
||||
preferred = null;
|
||||
const ask = (collections, radius = NEAREST_RADIUS_M) =>
|
||||
source.nearestItems(
|
||||
{
|
||||
lat,
|
||||
lon,
|
||||
radius,
|
||||
limit: NEAREST_LIMIT,
|
||||
collections,
|
||||
pano: filter.pano,
|
||||
sinceMs: filter.sinceMs,
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
if (wanted) {
|
||||
const hit = nearestOf(
|
||||
await ask(wanted.sequenceId, wanted.radius),
|
||||
point,
|
||||
wanted.radius,
|
||||
);
|
||||
if (hit) return hit.id;
|
||||
}
|
||||
return nearestOf(await ask(null), point)?.id ?? null;
|
||||
},
|
||||
|
||||
viewer,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
import { COLORS as SHARED_COLORS, PROVIDER_COLORS } from '../../policy.js';
|
||||
|
||||
export { NEAREST_RADIUS_M } from '../../policy.js';
|
||||
|
||||
/** Identity and tuning for the Panoramax street-level provider (keyless). */
|
||||
export const PANORAMAX_PROVIDER_ID = 'panoramax';
|
||||
export const PANORAMAX_NAME = 'Panoramax';
|
||||
export const PANORAMAX_LABEL = 'PANORAMAX';
|
||||
|
||||
/**
|
||||
* The Panoramax meta-catalogue: one keyless, CORS-enabled STAC API over
|
||||
* every federated instance (OpenStreetMap France, IGN, MapComplete…). Its
|
||||
* pictures are served by the instance that holds them.
|
||||
*/
|
||||
export const PANORAMAX_API = 'https://api.panoramax.xyz/api';
|
||||
/** The meta-catalogue's web viewer, where the caption link goes. */
|
||||
export const PANORAMAX_VIEWER = 'https://api.panoramax.xyz/';
|
||||
|
||||
/** Stable id prefixes for picked primitives; every one starts with `pnx:`. */
|
||||
export const PICK_PREFIX = Object.freeze({
|
||||
root: 'pnx:',
|
||||
sequence: 'pnx:seq:',
|
||||
image: 'pnx:img:',
|
||||
});
|
||||
|
||||
export const COLORS = Object.freeze({
|
||||
coverage: PROVIDER_COLORS.panoramax,
|
||||
selected: SHARED_COLORS.selected,
|
||||
});
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export const COVERAGE_Z14_BELOW_M = 1_800;
|
||||
export const COVERAGE_Z13_BELOW_M = 5_000;
|
||||
export const COVERAGE_Z12_BELOW_M = 12_000;
|
||||
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.
|
||||
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
|
||||
* 4,096 extent: about a pixel where each zoom is drawn (1.6 m at z14,
|
||||
* within GPS noise), and 6–10% of the points GPS tracks carry.
|
||||
*/
|
||||
export const SIMPLIFY_TOLERANCE_UNITS = 4;
|
||||
|
||||
/**
|
||||
* Coverage tiles decode in slices of this many ms, the page getting a turn
|
||||
* between them (decode.js `decodeCoverageTileSliced`): under a frame.
|
||||
*/
|
||||
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 =
|
||||
'Panoramax is busy — coverage resumes on its own';
|
||||
export const NO_COVERAGE_HINT = 'no Panoramax photos in view';
|
||||
/** Lines in view, all hidden by the imagery filter. */
|
||||
export const FILTERED_HINT = 'no Panoramax photos here match the filters';
|
||||
export const UNREADABLE_TILE_MESSAGE =
|
||||
'Panoramax sent a map tile that could not be read';
|
||||
export const GONE_MESSAGE = 'This Panoramax photo is no longer available';
|
||||
/** From high up the dots are where pictures are, not sequences to click. */
|
||||
export const OVERVIEW_HINT = 'zoom in to see Panoramax coverage';
|
||||
|
||||
/** Cones come from z15 tiles' `pictures` layer (~110 bytes a picture). */
|
||||
export const PICTURE_TILE_ZOOM = 15;
|
||||
/** Within this of a z15 tile's edge, the neighbouring tile's pictures join. */
|
||||
export const PICTURE_TILE_EDGE_M = 150;
|
||||
|
||||
export const TILE_TIMEOUT_MS = 20_000;
|
||||
/**
|
||||
* Once an answer has started, it may take as long as it keeps coming: a
|
||||
* 2 MB tile on a slow link outlasts any whole-request timeout, so only a
|
||||
* stream silent this long is given up.
|
||||
*/
|
||||
export const STREAM_IDLE_TIMEOUT_MS = 15_000;
|
||||
/** Busy (429/503) without a Retry-After the browser can read: back off from this. */
|
||||
export const BUSY_BACKOFF_BASE_SEC = 5;
|
||||
export const API_TIMEOUT_MS = 12_000;
|
||||
/** Real tiles are under 6 MB (z6 over France); refuse anything absurd. */
|
||||
export const MAX_RESPONSE_BYTES = 16 * 1024 * 1024;
|
||||
/**
|
||||
* The meta-catalogue speaks HTTP/1.1 only, so the browser opens six
|
||||
* connections to it: coverage tiles take three, searches two and the
|
||||
* cones' tiles one, so a click's lookup never queues behind megabyte tiles
|
||||
* (it timed out doing so).
|
||||
*/
|
||||
export const API_CONCURRENCY = 2;
|
||||
export const TILE_CONCURRENCY = 3;
|
||||
/** The selected sequence's z15 tiles (cones) take the sixth. */
|
||||
export const PICTURE_TILE_CONCURRENCY = 1;
|
||||
|
||||
/** Pictures asked for around a point; the API returns them nearest first. */
|
||||
export const NEAREST_LIMIT = 10;
|
||||
/**
|
||||
* The widest the API allows: the point may be beside a picture, but a
|
||||
* picture looking away from it is still left out (the bounding-box search
|
||||
* beside it has those).
|
||||
*/
|
||||
export const NEAREST_FOV_TOLERANCE_DEG = 180;
|
||||
/**
|
||||
* Pictures in the bounding box around a click, at most. The API returns
|
||||
* them roughly nearest first (the first ten of 100 are the first ten of
|
||||
* 10), and the browser ranks them exactly: 20 is ~60 kB, 100 ~600 kB.
|
||||
*/
|
||||
export const NEAREST_BOX_LIMIT = 20;
|
||||
/**
|
||||
* A line clicked from high up may be tens of metres off the click: its
|
||||
* sequence is searched within the camera height / 100 (50–400 m).
|
||||
*/
|
||||
export const LINE_CLICK_RADIUS_PER_HEIGHT = 0.01;
|
||||
export const LINE_CLICK_RADIUS_MAX_M = 400;
|
||||
|
||||
/** A full-resolution panorama is only fetched up to this width (memory). */
|
||||
export const PANO_HD_MAX_WIDTH = 8_192;
|
||||
/** An HD panorama can be several megabytes. */
|
||||
export const HD_TIMEOUT_MS = 30_000;
|
||||
/** Panoramax's SD derivative is 2,048 px wide, its thumbnail 500 px. */
|
||||
export const SD_WIDTH = 2_048;
|
||||
|
||||
/** On-globe credit: licences are per picture, named in the caption. */
|
||||
export const PANORAMAX_CREDIT_HTML =
|
||||
'Street Level: imagery © <a href="https://panoramax.fr" target="_blank" rel="noopener">Panoramax</a> contributors, licence per picture';
|
||||
|
||||
export const LINE_CLICK_HINT = 'click a line for its nearest photo';
|
||||
|
||||
/** Licence ids Panoramax uses, as the caption names them. */
|
||||
const LICENSE_LABELS = Object.freeze({
|
||||
'CC-BY-SA-4.0': 'CC BY-SA 4.0',
|
||||
'CC-BY-4.0': 'CC BY 4.0',
|
||||
'CC0-1.0': 'CC0',
|
||||
'etalab-2.0': 'Licence Ouverte 2.0',
|
||||
});
|
||||
|
||||
/** The caption's licence name for a STAC licence id, or null. */
|
||||
export function licenseLabel(license) {
|
||||
if (typeof license !== 'string' || !license.trim()) return null;
|
||||
const id = license.trim();
|
||||
if (id === 'proprietary' || id === 'other') return null;
|
||||
return LICENSE_LABELS[id] || id.slice(0, 40);
|
||||
}
|
||||
|
||||
/** Deep link to a picture in the Panoramax web viewer. */
|
||||
export function panoramaxPictureUrl(pictureId) {
|
||||
const id = String(pictureId ?? '').trim();
|
||||
if (!id) return PANORAMAX_VIEWER;
|
||||
return `${PANORAMAX_VIEWER}#${new URLSearchParams({ pic: id, focus: 'pic' })}`;
|
||||
}
|
||||
|
||||
/** Epoch ms for a capture time ('2023-12-12T07:54:47+00:00', '2023-12-12 07:56:37+00'), or null. */
|
||||
export function panoramaxTimeMs(value) {
|
||||
if (typeof value !== 'string' || !value) return null;
|
||||
// Tile timestamps are Postgres text: a space and an hour-only offset.
|
||||
const iso = /\d[T ]\d/.test(value)
|
||||
? value
|
||||
.replace(' ', 'T')
|
||||
.replace(/([+-]\d{2})$/, '$1:00')
|
||||
.replace(/\.(\d{3})\d+/, '.$1')
|
||||
: value;
|
||||
const ms = Date.parse(iso);
|
||||
return Number.isFinite(ms) && ms > Date.UTC(1990, 0, 1) ? ms : null;
|
||||
}
|
||||
|
||||
/** An https address, or null (asset links come from federated instances). */
|
||||
function httpsUrl(value) {
|
||||
try {
|
||||
const url = new URL(String(value));
|
||||
return url.protocol === 'https:' ? url.href : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Last path segment of an item's `rel` link: the linked picture's id. */
|
||||
function linkedId(item, rel) {
|
||||
const href = item?.links?.find?.((link) => link?.rel === rel)?.href;
|
||||
if (typeof href !== 'string') return null;
|
||||
const id = href.split(/[?#]/)[0].split('/').filter(Boolean).pop();
|
||||
if (!id) return null;
|
||||
try {
|
||||
return decodeURIComponent(id);
|
||||
} catch {
|
||||
// A malformed escape is no link, not a failed lookup.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An email address anywhere in a name, at any of its '@'s. Linear: names
|
||||
* come from any instance, and each run between '@'s is scanned once.
|
||||
*/
|
||||
function looksLikeEmail(name) {
|
||||
return /[^\s@]@[^\s@]+\.[^\s@]/.test(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Who took the picture. Producers are account names, and some accounts
|
||||
* are named by an email address: those are not repeated in the caption.
|
||||
*/
|
||||
function creatorOf(item) {
|
||||
const props = item.properties || {};
|
||||
const names = [
|
||||
props['geovisio:producer'],
|
||||
...(Array.isArray(item.providers)
|
||||
? item.providers
|
||||
.filter((p) => p?.roles?.includes?.('producer'))
|
||||
.map((p) => p.name)
|
||||
: []),
|
||||
];
|
||||
// Every producer is credited (an uploading app and its photographer),
|
||||
// each name once, none that is an email address.
|
||||
const credited = [];
|
||||
const seen = new Set();
|
||||
for (const value of names) {
|
||||
if (typeof value !== 'string') continue;
|
||||
const name = value.trim().slice(0, 80);
|
||||
if (!name || looksLikeEmail(value)) continue;
|
||||
const key = name.toLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
credited.push(name);
|
||||
}
|
||||
return credited.length ? credited.join(', ').slice(0, 120) : null;
|
||||
}
|
||||
|
||||
/** The federated instance holding a picture (its `via` link's host), or null. */
|
||||
function instanceOf(item) {
|
||||
const href = item?.links?.find?.((link) => link?.rel === 'via')?.href;
|
||||
try {
|
||||
const url = new URL(String(href));
|
||||
return url.protocol === 'https:' ? url.hostname.slice(0, 80) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** A 360° picture: a full-sphere field of view or an equirectangular projection. */
|
||||
export function isEquirectangular(item) {
|
||||
const props = item?.properties || {};
|
||||
const orientation = props['pers:interior_orientation'];
|
||||
if (Number(orientation?.field_of_view) >= 360) return true;
|
||||
return props['pers:projection'] === 'equirectangular';
|
||||
}
|
||||
|
||||
/**
|
||||
* A STAC item (from search or a collection) in the shape the provider and
|
||||
* viewer use, or null without an id or a position.
|
||||
* @returns {{id: string, sequenceId: string|null, lon: number, lat: number, heading: number|null, isPano: boolean, capturedAt: number|null, creator: string|null, license: string|null, width: number|null, prevId: string|null, nextId: string|null, urls: {thumb: string|null, sd: string|null, hd: string|null}}|null}
|
||||
*/
|
||||
export function normalizeItem(item) {
|
||||
if (!item || item.id == null) return null;
|
||||
// Only numbers: Number(null) and Number('') would put a picture at 0,0.
|
||||
const [lon, lat] = Array.isArray(item.geometry?.coordinates)
|
||||
? item.geometry.coordinates.map((value) =>
|
||||
typeof value === 'number' ? value : NaN,
|
||||
)
|
||||
: [NaN, NaN];
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
|
||||
if (Math.abs(lat) > 90 || Math.abs(lon) > 180) return null;
|
||||
const props = item.properties || {};
|
||||
const assets = item.assets || {};
|
||||
const azimuth =
|
||||
props['view:azimuth'] === '' || props['view:azimuth'] == null
|
||||
? NaN
|
||||
: Number(props['view:azimuth']);
|
||||
const width = Number(
|
||||
props['pers:interior_orientation']?.sensor_array_dimensions?.[0],
|
||||
);
|
||||
return {
|
||||
id: String(item.id),
|
||||
sequenceId: item.collection != null ? String(item.collection) : null,
|
||||
lon,
|
||||
lat,
|
||||
heading: Number.isFinite(azimuth) ? ((azimuth % 360) + 360) % 360 : null,
|
||||
isPano: isEquirectangular(item),
|
||||
capturedAt: panoramaxTimeMs(props.datetime),
|
||||
creator: creatorOf(item),
|
||||
// Licences ask for the source to be named: where no author can be
|
||||
// named, the caption names the instance holding the picture.
|
||||
via: instanceOf(item),
|
||||
license: licenseLabel(props.license),
|
||||
width: Number.isFinite(width) && width > 0 ? width : null,
|
||||
prevId: linkedId(item, 'prev'),
|
||||
nextId: linkedId(item, 'next'),
|
||||
urls: {
|
||||
thumb: httpsUrl(assets.thumb?.href),
|
||||
sd: httpsUrl(assets.sd?.href),
|
||||
hd: httpsUrl(assets.hd?.href),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
COLORS,
|
||||
PICK_PREFIX,
|
||||
isEquirectangular,
|
||||
licenseLabel,
|
||||
normalizeItem,
|
||||
panoramaxPictureUrl,
|
||||
panoramaxTimeMs,
|
||||
} from './policy.js';
|
||||
import { PROVIDER_COLORS } from '../../policy.js';
|
||||
import { stacItem } from '../../../../testSupport/panoramaxFixtures.mjs';
|
||||
|
||||
test('Panoramax draws in its own orange, and every pick id starts pnx:', () => {
|
||||
assert.equal(COLORS.coverage, '#ff6f00');
|
||||
assert.equal(PROVIDER_COLORS.panoramax, '#ff6f00');
|
||||
for (const prefix of Object.values(PICK_PREFIX))
|
||||
assert.ok(prefix.startsWith('pnx:'));
|
||||
// One colour per source.
|
||||
assert.equal(
|
||||
new Set(Object.values(PROVIDER_COLORS)).size,
|
||||
Object.keys(PROVIDER_COLORS).length,
|
||||
);
|
||||
});
|
||||
|
||||
test('capture times parse in the API’s ISO form, the tiles’ Postgres form, and as bare days', () => {
|
||||
assert.equal(
|
||||
panoramaxTimeMs('2023-12-12T07:54:47+00:00'),
|
||||
Date.UTC(2023, 11, 12, 7, 54, 47),
|
||||
);
|
||||
assert.equal(
|
||||
panoramaxTimeMs('2023-12-12 07:56:37+00'),
|
||||
Date.UTC(2023, 11, 12, 7, 56, 37),
|
||||
);
|
||||
assert.equal(
|
||||
panoramaxTimeMs('2025-08-13 07:45:51.98+00'),
|
||||
Date.UTC(2025, 7, 13, 7, 45, 51, 980),
|
||||
);
|
||||
// Microseconds are cut to what Date.parse reads.
|
||||
assert.equal(
|
||||
panoramaxTimeMs('2025-08-03T14:38:51.838000+00:00'),
|
||||
Date.UTC(2025, 7, 3, 14, 38, 51, 838),
|
||||
);
|
||||
// A sequence's day: its trailing "-11" is the day, not a UTC offset.
|
||||
assert.equal(panoramaxTimeMs('2025-08-11'), Date.UTC(2025, 7, 11));
|
||||
assert.equal(
|
||||
panoramaxTimeMs('2025-08-13 09:00:00+02'),
|
||||
Date.UTC(2025, 7, 13, 7),
|
||||
);
|
||||
for (const bad of ['', 'junk', '1970-01-01', null, 42])
|
||||
assert.equal(panoramaxTimeMs(bad), null, String(bad));
|
||||
});
|
||||
|
||||
test('a STAC item becomes a picture: position, heading, 360°, licence, links and assets', () => {
|
||||
const photo = normalizeItem(
|
||||
stacItem({
|
||||
id: 'a',
|
||||
lon: 2.35,
|
||||
lat: 48.85,
|
||||
sequenceId: 's',
|
||||
heading: 370,
|
||||
pano: true,
|
||||
license: 'etalab-2.0',
|
||||
prevId: 'p',
|
||||
nextId: 'n',
|
||||
}),
|
||||
);
|
||||
assert.deepEqual(
|
||||
{
|
||||
id: photo.id,
|
||||
sequenceId: photo.sequenceId,
|
||||
lon: photo.lon,
|
||||
lat: photo.lat,
|
||||
heading: photo.heading,
|
||||
isPano: photo.isPano,
|
||||
license: photo.license,
|
||||
width: photo.width,
|
||||
prevId: photo.prevId,
|
||||
nextId: photo.nextId,
|
||||
creator: photo.creator,
|
||||
},
|
||||
{
|
||||
id: 'a',
|
||||
sequenceId: 's',
|
||||
lon: 2.35,
|
||||
lat: 48.85,
|
||||
heading: 10,
|
||||
isPano: true,
|
||||
license: 'Licence Ouverte 2.0',
|
||||
width: 5760,
|
||||
prevId: 'p',
|
||||
nextId: 'n',
|
||||
creator: 'tester',
|
||||
},
|
||||
);
|
||||
assert.equal(
|
||||
photo.urls.sd,
|
||||
'https://panoramax.example.org/pictures/a/sd.jpg',
|
||||
);
|
||||
assert.equal(photo.capturedAt, Date.UTC(2025, 5, 1, 10));
|
||||
});
|
||||
|
||||
test('a picture without links, heading or a usable position', () => {
|
||||
const bare = normalizeItem({
|
||||
id: 7,
|
||||
geometry: { coordinates: [1, 2] },
|
||||
properties: {},
|
||||
});
|
||||
assert.equal(bare.id, '7');
|
||||
assert.equal(bare.heading, null);
|
||||
assert.equal(bare.prevId, null);
|
||||
assert.equal(bare.nextId, null);
|
||||
assert.equal(bare.sequenceId, null);
|
||||
assert.equal(bare.isPano, false);
|
||||
assert.equal(bare.capturedAt, null);
|
||||
assert.deepEqual(bare.urls, { thumb: null, sd: null, hd: null });
|
||||
for (const item of [
|
||||
null,
|
||||
{ geometry: { coordinates: [1, 2] } },
|
||||
{ id: 'x', geometry: { coordinates: ['a', 2] } },
|
||||
{ id: 'x', geometry: { coordinates: [181, 2] } },
|
||||
{ id: 'x', geometry: { coordinates: [1, 91] } },
|
||||
{ id: 'x' },
|
||||
])
|
||||
assert.equal(normalizeItem(item), null, JSON.stringify(item));
|
||||
});
|
||||
|
||||
test('asset links that are not https are dropped', () => {
|
||||
const item = stacItem({ id: 'a', lon: 1, lat: 2 });
|
||||
item.assets.sd.href = 'http://insecure.example/sd.jpg';
|
||||
item.assets.hd.href = 'javascript:alert(1)';
|
||||
const photo = normalizeItem(item);
|
||||
assert.equal(photo.urls.sd, null);
|
||||
assert.equal(photo.urls.hd, null);
|
||||
assert.ok(photo.urls.thumb.startsWith('https://'));
|
||||
});
|
||||
|
||||
test('an account named by an email address is not shown as the creator', () => {
|
||||
const named = (producer) =>
|
||||
normalizeItem(stacItem({ id: 'a', lon: 1, lat: 2, producer })).creator;
|
||||
assert.equal(named('[email protected]'), null);
|
||||
assert.equal(named('Robot8A'), 'Robot8A');
|
||||
assert.equal(named(' padded '), 'padded');
|
||||
assert.equal(named('x'.repeat(200)).length, 80);
|
||||
// The producer role among the providers serves when the property is missing.
|
||||
const item = stacItem({ id: 'a', lon: 1, lat: 2 });
|
||||
delete item.properties['geovisio:producer'];
|
||||
item.providers = [
|
||||
{ name: 'host', roles: ['host'] },
|
||||
{ name: 'mapper', roles: ['producer'] },
|
||||
];
|
||||
assert.equal(normalizeItem(item).creator, 'mapper');
|
||||
});
|
||||
|
||||
test('360°: a full field of view, or the equirectangular projection', () => {
|
||||
assert.equal(
|
||||
isEquirectangular({
|
||||
properties: { 'pers:interior_orientation': { field_of_view: 360 } },
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isEquirectangular({ properties: { 'pers:projection': 'equirectangular' } }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isEquirectangular({
|
||||
properties: { 'pers:interior_orientation': { field_of_view: 92 } },
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(isEquirectangular(null), false);
|
||||
});
|
||||
|
||||
test('licences read as the caption names them', () => {
|
||||
assert.equal(licenseLabel('CC-BY-SA-4.0'), 'CC BY-SA 4.0');
|
||||
assert.equal(licenseLabel('etalab-2.0'), 'Licence Ouverte 2.0');
|
||||
assert.equal(licenseLabel('CC0-1.0'), 'CC0');
|
||||
assert.equal(licenseLabel('ODbL-1.0'), 'ODbL-1.0');
|
||||
assert.equal(licenseLabel('proprietary'), null);
|
||||
assert.equal(licenseLabel(''), null);
|
||||
assert.equal(licenseLabel(undefined), null);
|
||||
assert.equal(licenseLabel('x'.repeat(100)).length, 40);
|
||||
});
|
||||
|
||||
test('the caption link opens the picture in the Panoramax viewer', () => {
|
||||
assert.equal(
|
||||
panoramaxPictureUrl('d5467bbb-4357'),
|
||||
'https://api.panoramax.xyz/#pic=d5467bbb-4357&focus=pic',
|
||||
);
|
||||
assert.equal(panoramaxPictureUrl(''), 'https://api.panoramax.xyz/');
|
||||
assert.equal(panoramaxPictureUrl(null), 'https://api.panoramax.xyz/');
|
||||
assert.ok(panoramaxPictureUrl('a&b=c').includes('pic=a%26b%3Dc'));
|
||||
});
|
||||
|
||||
test('hostile item data: a malformed link escape, null coordinates, a huge producer name', () => {
|
||||
const item = stacItem({ id: 'a', lon: 1, lat: 2, nextId: 'n' });
|
||||
item.links.push({
|
||||
rel: 'prev',
|
||||
href: 'https://api.panoramax.xyz/api/collections/s/items/%E0%A4%A',
|
||||
});
|
||||
const photo = normalizeItem(item);
|
||||
assert.equal(photo.prevId, null, 'no link, no throw');
|
||||
assert.equal(photo.nextId, 'n');
|
||||
for (const coordinates of [[null, null], ['', ''], ['2.35', '48.85'], [2.35]])
|
||||
assert.equal(
|
||||
normalizeItem({ id: 'x', geometry: { coordinates } }),
|
||||
null,
|
||||
JSON.stringify(coordinates),
|
||||
);
|
||||
// A million characters without spaces is checked in linear time.
|
||||
const started = Date.now();
|
||||
const huge = normalizeItem(
|
||||
stacItem({ id: 'a', lon: 1, lat: 2, producer: 'a'.repeat(1_000_000) }),
|
||||
);
|
||||
assert.ok(Date.now() - started < 500, `${Date.now() - started} ms`);
|
||||
assert.equal(huge.creator.length, 80);
|
||||
const named = (producer) =>
|
||||
normalizeItem(stacItem({ id: 'a', lon: 1, lat: 2, producer })).creator;
|
||||
assert.equal(named(`${'x'.repeat(200_000)}@example.org`), null);
|
||||
assert.equal(
|
||||
named('Carto@Cité'),
|
||||
'Carto@Cité',
|
||||
'an @ without a domain is a name',
|
||||
);
|
||||
// The address need not follow the first '@'.
|
||||
assert.equal(named('@team [email protected]'), null);
|
||||
assert.equal(named('Carto@Cité ([email protected])'), null);
|
||||
assert.equal(named(`${'@'.repeat(100_000)}x`), `${'@'.repeat(80)}`);
|
||||
});
|
||||
|
||||
test('thousands of producers are credited in linear time', () => {
|
||||
const item = stacItem({ id: 'a', lon: 1, lat: 2 });
|
||||
item.providers = Array.from({ length: 50_000 }, (_, i) => ({
|
||||
name: `producer-${i}`,
|
||||
roles: ['producer'],
|
||||
}));
|
||||
const started = performance.now();
|
||||
const { creator } = normalizeItem(item);
|
||||
assert.ok(performance.now() - started < 1000);
|
||||
assert.ok(creator.startsWith('tester, producer-0, producer-1'));
|
||||
assert.ok(creator.length <= 120);
|
||||
});
|
||||
|
||||
test('the instance holding a picture is named (its via host), never as its author', () => {
|
||||
const item = stacItem({
|
||||
id: 'a',
|
||||
lon: 1,
|
||||
lat: 2,
|
||||
producer: '[email protected]',
|
||||
});
|
||||
item.links.push({ rel: 'via', href: 'https://panoramax.ign.fr' });
|
||||
assert.equal(normalizeItem(item).creator, null);
|
||||
assert.equal(normalizeItem(item).via, 'panoramax.ign.fr');
|
||||
item.links.at(-1).href = 'http://insecure.example';
|
||||
assert.equal(normalizeItem(item).via, null);
|
||||
item.links.at(-1).href = 'not a url';
|
||||
assert.equal(normalizeItem(item).via, null);
|
||||
});
|
||||
|
||||
test('every producer is credited once: the uploading app and its photographer', () => {
|
||||
const item = stacItem({ id: 'a', lon: 1, lat: 2, producer: 'mapcomplete' });
|
||||
item.providers = [
|
||||
{ name: 'mapcomplete', roles: ['producer'] },
|
||||
{ name: 'pyrog', roles: ['producer'] },
|
||||
{ name: 'MapComplete', roles: ['producer'] },
|
||||
{ name: '[email protected]', roles: ['producer'] },
|
||||
{ name: 'host', roles: ['host'] },
|
||||
];
|
||||
assert.equal(normalizeItem(item).creator, 'mapcomplete, pyrog');
|
||||
const hidden = stacItem({ id: 'b', lon: 1, lat: 2, producer: '[email protected]' });
|
||||
hidden.providers = [{ name: '[email protected]', roles: ['producer'] }];
|
||||
assert.equal(normalizeItem(hidden).creator, null);
|
||||
const many = stacItem({ id: 'c', lon: 1, lat: 2 });
|
||||
many.providers = Array.from({ length: 50 }, (_, i) => ({
|
||||
name: `producer-${i}`,
|
||||
roles: ['producer'],
|
||||
}));
|
||||
assert.ok(normalizeItem(many).creator.length <= 120);
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,301 @@
|
||||
import { createLimiter } from '../../limiter.js';
|
||||
import {
|
||||
API_CONCURRENCY,
|
||||
API_TIMEOUT_MS,
|
||||
BUSY_BACKOFF_BASE_SEC,
|
||||
MAX_RESPONSE_BYTES,
|
||||
NEAREST_BOX_LIMIT,
|
||||
NEAREST_FOV_TOLERANCE_DEG,
|
||||
NEAREST_LIMIT,
|
||||
NEAREST_RADIUS_M,
|
||||
PANORAMAX_API,
|
||||
PICTURE_TILE_CONCURRENCY,
|
||||
STREAM_IDLE_TIMEOUT_MS,
|
||||
TILE_CONCURRENCY,
|
||||
TILE_TIMEOUT_MS,
|
||||
} from './policy.js';
|
||||
|
||||
/** Longest wait a Retry-After (or the backoff) asks for. */
|
||||
const RETRY_AFTER_MAX_SEC = 300;
|
||||
|
||||
/** An HTTP or API failure; `retryAfterSec` is set while Panoramax is busy. */
|
||||
export class PanoramaxSourceError extends Error {
|
||||
constructor(message, { status = 0, retryAfterSec = null } = {}) {
|
||||
super(message);
|
||||
this.name = 'PanoramaxSourceError';
|
||||
this.status = status;
|
||||
this.retryAfterSec = retryAfterSec;
|
||||
}
|
||||
}
|
||||
|
||||
const NO_ANSWER = 'Panoramax did not answer in time — try again';
|
||||
const UNREACHABLE = 'Panoramax could not be reached — check the connection';
|
||||
|
||||
/**
|
||||
* Seconds from a Retry-After header (delay seconds or an HTTP date), or
|
||||
* null. The meta-catalogue exposes no headers to the browser, so in the
|
||||
* app this is usually null and the caller backs off on its own.
|
||||
*/
|
||||
export function retryAfterSeconds(response, now = Date.now()) {
|
||||
const header = response.headers?.get?.('retry-after');
|
||||
if (header == null || header === '') return null;
|
||||
let value = Number(header);
|
||||
if (!Number.isFinite(value)) {
|
||||
const at = Date.parse(header);
|
||||
if (!Number.isFinite(at)) return null;
|
||||
value = Math.ceil((at - now) / 1000);
|
||||
}
|
||||
if (value < 0) return null;
|
||||
// A maintenance page's hour must not freeze coverage for an hour.
|
||||
return Math.min(RETRY_AFTER_MAX_SEC, Math.max(1, value));
|
||||
}
|
||||
|
||||
/**
|
||||
* The body as bytes, refusing more than MAX_RESPONSE_BYTES whatever the
|
||||
* headers say. `onChunk` hears each piece arrive (the idle timeout).
|
||||
*/
|
||||
async function readBounded(response, onChunk = () => {}) {
|
||||
const tooMuch = () =>
|
||||
new PanoramaxSourceError('Panoramax sent too much data', {
|
||||
status: response.status,
|
||||
});
|
||||
const length = Number(response.headers?.get?.('content-length'));
|
||||
if (length > MAX_RESPONSE_BYTES) throw tooMuch();
|
||||
const reader = response.body?.getReader?.();
|
||||
if (!reader) {
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
if (bytes.byteLength > MAX_RESPONSE_BYTES) throw tooMuch();
|
||||
return bytes;
|
||||
}
|
||||
const chunks = [];
|
||||
let total = 0;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
onChunk();
|
||||
total += value.byteLength;
|
||||
if (total > MAX_RESPONSE_BYTES) {
|
||||
reader.cancel().catch(() => {});
|
||||
throw tooMuch();
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
const body = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
body.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every network path the Panoramax provider uses, browser-side and keyless:
|
||||
* the meta-catalogue allows any origin. Vector tiles for coverage and
|
||||
* cones, STAC search for single pictures and nearest ones.
|
||||
* @param {{fetchImpl?: typeof fetch, api?: string}} [options]
|
||||
*/
|
||||
export function createPanoramaxSource({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
api = PANORAMAX_API,
|
||||
} = {}) {
|
||||
const root = api.replace(/\/+$/, '');
|
||||
const apiLimiter = createLimiter(API_CONCURRENCY, { lifo: true });
|
||||
const tileLimiter = createLimiter(TILE_CONCURRENCY);
|
||||
// The cones' z15 tiles have their own connection: a selection never
|
||||
// waits behind coverage tiles.
|
||||
const pictureLimiter = createLimiter(PICTURE_TILE_CONCURRENCY, {
|
||||
lifo: true,
|
||||
});
|
||||
|
||||
/** Busy answers in a row with no readable Retry-After (the backoff grows). */
|
||||
let busyStreak = 0;
|
||||
|
||||
/**
|
||||
* GET: its headers within `timeoutMs`, then a body that may take as long
|
||||
* as it keeps arriving (silent for STREAM_IDLE_TIMEOUT_MS is given up).
|
||||
* The caller's abort wins over both.
|
||||
*/
|
||||
async function get(url, { signal, timeoutMs, empty = [] }) {
|
||||
const controller = new AbortController();
|
||||
const onAbort = () => controller.abort(signal.reason);
|
||||
if (signal?.aborted) throw signal.reason;
|
||||
signal?.addEventListener('abort', onAbort, { once: true });
|
||||
const giveUp = () => controller.abort(new PanoramaxSourceError(NO_ANSWER));
|
||||
let timer = setTimeout(giveUp, timeoutMs);
|
||||
try {
|
||||
const response = await fetchImpl(url, { signal: controller.signal });
|
||||
if (empty.includes(response.status)) {
|
||||
busyStreak = 0;
|
||||
return null;
|
||||
}
|
||||
if (response.status === 429 || response.status === 503) {
|
||||
const told = retryAfterSeconds(response);
|
||||
// Not told how long: back off, longer each time (5, 10, 20… 300 s).
|
||||
const wait =
|
||||
told ??
|
||||
Math.min(
|
||||
RETRY_AFTER_MAX_SEC,
|
||||
BUSY_BACKOFF_BASE_SEC * 2 ** Math.min(busyStreak, 8),
|
||||
);
|
||||
busyStreak++;
|
||||
throw new PanoramaxSourceError('Panoramax is busy', {
|
||||
status: response.status,
|
||||
retryAfterSec: wait,
|
||||
});
|
||||
}
|
||||
if (!response.ok)
|
||||
throw new PanoramaxSourceError(
|
||||
`Panoramax answered HTTP ${response.status}`,
|
||||
{ status: response.status },
|
||||
);
|
||||
busyStreak = 0;
|
||||
// The answer has begun: from here only silence is timed.
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(giveUp, STREAM_IDLE_TIMEOUT_MS);
|
||||
return await readBounded(response, () => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(giveUp, STREAM_IDLE_TIMEOUT_MS);
|
||||
});
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw signal.reason;
|
||||
if (controller.signal.aborted) throw controller.signal.reason;
|
||||
if (error instanceof PanoramaxSourceError) throw error;
|
||||
// Offline, or a CORS or TLS failure: not a timeout, not worth a retry.
|
||||
throw new PanoramaxSourceError(UNREACHABLE);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A STAC search, through the API gate (newest request first). One that
|
||||
* timed out is asked once more: a search is quick once the connection
|
||||
* is free.
|
||||
*/
|
||||
async function search(params, { signal } = {}) {
|
||||
const url = `${root}/search?${new URLSearchParams(params)}`;
|
||||
const once = () =>
|
||||
apiLimiter.run(() => get(url, { signal, timeoutMs: API_TIMEOUT_MS }), {
|
||||
signal,
|
||||
});
|
||||
let bytes;
|
||||
try {
|
||||
bytes = await once();
|
||||
} catch (error) {
|
||||
if (signal?.aborted || error?.message !== NO_ANSWER) throw error;
|
||||
bytes = await once();
|
||||
}
|
||||
let payload;
|
||||
try {
|
||||
payload = JSON.parse(new TextDecoder().decode(bytes));
|
||||
} catch {
|
||||
throw new PanoramaxSourceError('Panoramax sent an unreadable answer');
|
||||
}
|
||||
return Array.isArray(payload?.features) ? payload.features : [];
|
||||
}
|
||||
|
||||
return {
|
||||
api: root,
|
||||
|
||||
/** Raw vector-tile bytes; an empty array where there is no coverage. */
|
||||
async getTile({ x, y, z }, { signal, lane = 'coverage' } = {}) {
|
||||
const url = `${root}/map/${z}/${x}/${y}.mvt`;
|
||||
const gate = lane === 'pictures' ? pictureLimiter : tileLimiter;
|
||||
const bytes = await gate.run(
|
||||
() =>
|
||||
get(url, { signal, timeoutMs: TILE_TIMEOUT_MS, empty: [204, 404] }),
|
||||
{ signal },
|
||||
);
|
||||
return bytes ?? new Uint8Array(0);
|
||||
},
|
||||
|
||||
/** One picture (a STAC item) by id, or null. */
|
||||
async getItem(id, { signal } = {}) {
|
||||
const [item] = await search({ ids: String(id), limit: '1' }, { signal });
|
||||
return item && String(item.id) === String(id) ? item : null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Pictures within `radius` metres of a point, unranked: the caller ranks
|
||||
* them by distance. Two searches side by side, since neither finds them
|
||||
* all: `place_position` (nearest first) leaves out pictures looking
|
||||
* away from the point, whatever the tolerance; a bounding box has them,
|
||||
* roughly nearest first (not exactly: the caller ranks the union).
|
||||
* `collections` keeps one sequence's, `pano` and `sinceMs` filter as
|
||||
* the imagery filter does.
|
||||
*/
|
||||
async nearestItems(
|
||||
{
|
||||
lat,
|
||||
lon,
|
||||
radius = NEAREST_RADIUS_M,
|
||||
limit = NEAREST_LIMIT,
|
||||
collections = null,
|
||||
pano = 'all',
|
||||
sinceMs = null,
|
||||
},
|
||||
{ signal } = {},
|
||||
) {
|
||||
const metres = Math.min(1000, Math.max(1, Math.round(radius)));
|
||||
const shared = {};
|
||||
if (collections) shared.collections = String(collections);
|
||||
if (pano === 'pano') shared.filter = 'field_of_view=360';
|
||||
else if (pano === 'flat') shared.filter = 'field_of_view<360';
|
||||
if (Number.isFinite(sinceMs) && sinceMs > 0)
|
||||
shared.datetime = `${new Date(sinceMs).toISOString()}/..`;
|
||||
const dLat = metres / 111_320;
|
||||
const dLon =
|
||||
metres / (111_320 * Math.max(0.01, Math.cos((lat * Math.PI) / 180)));
|
||||
// Clamped to the map: the API finds nothing in a box past ±180°.
|
||||
const box = [
|
||||
Math.max(-180, lon - dLon),
|
||||
Math.max(-90, lat - dLat),
|
||||
Math.min(180, lon + dLon),
|
||||
Math.min(90, lat + dLat),
|
||||
].map((v) => v.toFixed(7));
|
||||
// One search failing (busy, no answer) leaves the other's pictures;
|
||||
// only both failing fails the lookup. The caller's abort stops both.
|
||||
const both = new AbortController();
|
||||
const onAbort = () => both.abort(signal.reason);
|
||||
if (signal?.aborted) throw signal.reason;
|
||||
signal?.addEventListener('abort', onAbort, { once: true });
|
||||
const run = (params) =>
|
||||
search(params, { signal: both.signal }).catch((error) => {
|
||||
if (!both.signal.aborted && !signal?.aborted) failures.push(error);
|
||||
throw error;
|
||||
});
|
||||
const failures = [];
|
||||
let settled;
|
||||
try {
|
||||
settled = await Promise.allSettled([
|
||||
run({
|
||||
place_position: `${Number(lon).toFixed(7)},${Number(lat).toFixed(7)}`,
|
||||
place_distance: `0-${metres}`,
|
||||
place_fov_tolerance: String(NEAREST_FOV_TOLERANCE_DEG),
|
||||
limit: String(Math.min(100, Math.max(1, limit))),
|
||||
...shared,
|
||||
}),
|
||||
run({
|
||||
bbox: box.join(','),
|
||||
limit: String(NEAREST_BOX_LIMIT),
|
||||
...shared,
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
}
|
||||
if (signal?.aborted) throw signal.reason;
|
||||
const found = settled
|
||||
.filter((result) => result.status === 'fulfilled')
|
||||
.map((result) => result.value);
|
||||
if (!found.length) throw failures[0] ?? settled[0].reason;
|
||||
const byId = new Map();
|
||||
for (const item of found.flat())
|
||||
if (item?.id != null && !byId.has(String(item.id)))
|
||||
byId.set(String(item.id), item);
|
||||
return [...byId.values()];
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
PanoramaxSourceError,
|
||||
createPanoramaxSource,
|
||||
retryAfterSeconds,
|
||||
} from './source.js';
|
||||
import {
|
||||
MAX_RESPONSE_BYTES,
|
||||
NEAREST_BOX_LIMIT,
|
||||
STREAM_IDLE_TIMEOUT_MS,
|
||||
TILE_TIMEOUT_MS,
|
||||
} from './policy.js';
|
||||
|
||||
/** A fetch answering from `respond(url)`, recording every URL asked. */
|
||||
function fakeFetch(respond) {
|
||||
const urls = [];
|
||||
const fetchImpl = async (url, { signal } = {}) => {
|
||||
urls.push(url);
|
||||
const answer = await respond(url, signal);
|
||||
if (answer instanceof Response) return answer;
|
||||
const { status = 200, body = '', headers = {} } = answer;
|
||||
return new Response(status === 204 ? null : body, { status, headers });
|
||||
};
|
||||
return { urls, fetchImpl };
|
||||
}
|
||||
|
||||
const json = (value) => ({ body: JSON.stringify(value) });
|
||||
|
||||
test('a tile is the raw vector-tile bytes; 204 and 404 mean no coverage', async () => {
|
||||
const { urls, fetchImpl } = fakeFetch((url) =>
|
||||
url.includes('/14/')
|
||||
? { body: new Uint8Array([1, 2, 3]) }
|
||||
: { status: 204 },
|
||||
);
|
||||
const source = createPanoramaxSource({ fetchImpl });
|
||||
assert.deepEqual(
|
||||
[...(await source.getTile({ x: 8299, y: 5634, z: 14 }))],
|
||||
[1, 2, 3],
|
||||
);
|
||||
assert.equal(urls[0], 'https://api.panoramax.xyz/api/map/14/8299/5634.mvt');
|
||||
assert.equal((await source.getTile({ x: 1, y: 1, z: 13 })).length, 0);
|
||||
const missing = createPanoramaxSource({
|
||||
fetchImpl: fakeFetch(() => ({ status: 404 })).fetchImpl,
|
||||
});
|
||||
assert.equal((await missing.getTile({ x: 1, y: 1, z: 13 })).length, 0);
|
||||
});
|
||||
|
||||
test('a busy API says so and when to come back; other failures name the status', async () => {
|
||||
const busy = createPanoramaxSource({
|
||||
fetchImpl: fakeFetch(() => ({
|
||||
status: 429,
|
||||
headers: { 'retry-after': '12' },
|
||||
})).fetchImpl,
|
||||
});
|
||||
await assert.rejects(busy.getTile({ x: 0, y: 0, z: 0 }), (error) => {
|
||||
assert.ok(error instanceof PanoramaxSourceError);
|
||||
assert.equal(error.status, 429);
|
||||
assert.equal(error.retryAfterSec, 12);
|
||||
return true;
|
||||
});
|
||||
const down = createPanoramaxSource({
|
||||
fetchImpl: fakeFetch(() => ({ status: 503 })).fetchImpl,
|
||||
});
|
||||
await assert.rejects(down.getTile({ x: 0, y: 0, z: 0 }), (error) => {
|
||||
assert.equal(error.retryAfterSec, 5, 'a default wait without the header');
|
||||
return true;
|
||||
});
|
||||
const broken = createPanoramaxSource({
|
||||
fetchImpl: fakeFetch(() => ({ status: 500 })).fetchImpl,
|
||||
});
|
||||
await assert.rejects(broken.getItem('x'), /HTTP 500/);
|
||||
});
|
||||
|
||||
test('an oversized answer is refused while it streams', async () => {
|
||||
let pulled = 0;
|
||||
const huge = new ReadableStream({
|
||||
pull(controller) {
|
||||
pulled++;
|
||||
controller.enqueue(new Uint8Array(4 * 1024 * 1024));
|
||||
},
|
||||
});
|
||||
const source = createPanoramaxSource({
|
||||
fetchImpl: async () => new Response(huge, { status: 200 }),
|
||||
});
|
||||
await assert.rejects(source.getTile({ x: 0, y: 0, z: 0 }), /too much data/);
|
||||
assert.ok(pulled * 4 * 1024 * 1024 <= MAX_RESPONSE_BYTES + 8 * 1024 * 1024);
|
||||
// A declared length over the cap is refused before reading.
|
||||
const declared = createPanoramaxSource({
|
||||
fetchImpl: fakeFetch(() => ({
|
||||
body: 'x',
|
||||
headers: { 'content-length': String(MAX_RESPONSE_BYTES + 1) },
|
||||
})).fetchImpl,
|
||||
});
|
||||
await assert.rejects(declared.getItem('a'), /too much data/);
|
||||
});
|
||||
|
||||
test('a picture by id is a STAC search; another id in the answer is not taken for it', async () => {
|
||||
const { urls, fetchImpl } = fakeFetch((url) =>
|
||||
url.includes('ids=good')
|
||||
? json({ features: [{ id: 'good' }] })
|
||||
: json({ features: [{ id: 'other' }] }),
|
||||
);
|
||||
const source = createPanoramaxSource({ fetchImpl });
|
||||
assert.deepEqual(await source.getItem('good'), { id: 'good' });
|
||||
assert.equal(await source.getItem('bad'), null);
|
||||
const asked = new URL(urls[0]);
|
||||
assert.equal(asked.pathname, '/api/search');
|
||||
assert.equal(asked.searchParams.get('ids'), 'good');
|
||||
assert.equal(asked.searchParams.get('limit'), '1');
|
||||
});
|
||||
|
||||
test('nearest pictures: a place search and a box search side by side, both filtered as the imagery filter asks', async () => {
|
||||
const { urls, fetchImpl } = fakeFetch(() => json({ features: [] }));
|
||||
const source = createPanoramaxSource({ fetchImpl });
|
||||
await source.nearestItems({ lat: 48.85, lon: 2.35 });
|
||||
await source.nearestItems({
|
||||
lat: 48.85,
|
||||
lon: 2.35,
|
||||
collections: 'seq',
|
||||
pano: 'pano',
|
||||
sinceMs: Date.UTC(2025, 0, 1),
|
||||
radius: 5000,
|
||||
limit: 500,
|
||||
});
|
||||
await source.nearestItems({ lat: 48.85, lon: 2.35, pano: 'flat' });
|
||||
const asked = urls.map((u) => new URL(u).searchParams);
|
||||
assert.equal(asked.length, 6, 'two searches a lookup');
|
||||
const place = asked.filter((q) => q.has('place_position'));
|
||||
const box = asked.filter((q) => q.has('bbox'));
|
||||
assert.equal(place.length, 3);
|
||||
assert.equal(box.length, 3);
|
||||
const [plain, filtered, flat] = place;
|
||||
assert.equal(plain.get('place_position'), '2.3500000,48.8500000');
|
||||
assert.equal(plain.get('place_distance'), '0-50');
|
||||
assert.equal(plain.get('place_fov_tolerance'), '180');
|
||||
assert.equal(plain.get('collections'), null);
|
||||
assert.equal(plain.get('filter'), null);
|
||||
assert.equal(plain.get('datetime'), null);
|
||||
assert.equal(filtered.get('collections'), 'seq');
|
||||
assert.equal(filtered.get('filter'), 'field_of_view=360');
|
||||
assert.equal(filtered.get('datetime'), '2025-01-01T00:00:00.000Z/..');
|
||||
assert.equal(filtered.get('place_distance'), '0-1000', 'capped');
|
||||
assert.equal(filtered.get('limit'), '100', 'capped');
|
||||
assert.equal(flat.get('filter'), 'field_of_view<360');
|
||||
// The box spans the radius each way, and carries the same filters.
|
||||
const [w, s, e, n] = box[0].get('bbox').split(',').map(Number);
|
||||
assert.ok(Math.abs((n - s) * 111_320 - 100) < 1, `${n - s}°`);
|
||||
assert.ok(
|
||||
Math.abs((e - w) * 111_320 * Math.cos((48.85 * Math.PI) / 180) - 100) < 1,
|
||||
);
|
||||
assert.equal(box[1].get('collections'), 'seq');
|
||||
assert.equal(box[1].get('filter'), 'field_of_view=360');
|
||||
assert.equal(box[1].get('datetime'), '2025-01-01T00:00:00.000Z/..');
|
||||
assert.equal(box[2].get('filter'), 'field_of_view<360');
|
||||
});
|
||||
|
||||
test('a picture both searches find is listed once', async () => {
|
||||
const source = createPanoramaxSource({
|
||||
fetchImpl: fakeFetch((url) =>
|
||||
url.includes('bbox')
|
||||
? json({ features: [{ id: 'b' }, { id: 'a' }, { id: null }] })
|
||||
: json({ features: [{ id: 'a' }] }),
|
||||
).fetchImpl,
|
||||
});
|
||||
const items = await source.nearestItems({ lat: 1, lon: 2 });
|
||||
assert.deepEqual(
|
||||
items.map((i) => i.id),
|
||||
['a', 'b'],
|
||||
);
|
||||
});
|
||||
|
||||
test('the newest search goes first when the API gate is full', async () => {
|
||||
const waiting = [];
|
||||
const order = [];
|
||||
const source = createPanoramaxSource({
|
||||
fetchImpl: async (url) => {
|
||||
order.push(new URL(url).searchParams.get('ids'));
|
||||
await new Promise((resolve) => waiting.push(resolve));
|
||||
return new Response(JSON.stringify({ features: [] }));
|
||||
},
|
||||
});
|
||||
const all = ['1', '2', '3', '4', '5', '6'].map((id) => source.getItem(id));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(order, ['1', '2'], 'two at once');
|
||||
waiting.shift()();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(order.at(-1), '6', 'the latest waiting one next');
|
||||
let done = false;
|
||||
Promise.allSettled(all).then(() => (done = true));
|
||||
while (!done) {
|
||||
waiting.splice(0).forEach((go) => go());
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
});
|
||||
|
||||
test('an unreadable search answer, or one with no features, is handled', async () => {
|
||||
const garbled = createPanoramaxSource({
|
||||
fetchImpl: fakeFetch(() => ({ body: '<html>' })).fetchImpl,
|
||||
});
|
||||
await assert.rejects(garbled.getItem('a'), /unreadable/);
|
||||
const empty = createPanoramaxSource({
|
||||
fetchImpl: fakeFetch(() => json({ type: 'FeatureCollection' })).fetchImpl,
|
||||
});
|
||||
assert.deepEqual(await empty.nearestItems({ lat: 0, lon: 0 }), []);
|
||||
});
|
||||
|
||||
test('the caller’s abort wins; a silent API times out with a readable message', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const hang = (url, signal) =>
|
||||
new Promise((_, reject) =>
|
||||
signal.addEventListener('abort', () => reject(signal.reason)),
|
||||
);
|
||||
const source = createPanoramaxSource({
|
||||
fetchImpl: fakeFetch(hang).fetchImpl,
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const asked = source.getItem('a', { signal: controller.signal });
|
||||
await Promise.resolve();
|
||||
controller.abort(new Error('overtaken'));
|
||||
await assert.rejects(asked, /overtaken/);
|
||||
const slow = source.getTile({ x: 0, y: 0, z: 0 });
|
||||
// Let the request start before the clock runs out.
|
||||
for (let i = 0; i < 5; i++) await Promise.resolve();
|
||||
t.mock.timers.tick(60_000);
|
||||
await assert.rejects(slow, /did not answer in time/);
|
||||
});
|
||||
|
||||
test('a network failure reads as unreachable, not as a stack trace', async () => {
|
||||
const source = createPanoramaxSource({
|
||||
fetchImpl: async () => {
|
||||
throw new TypeError('Failed to fetch');
|
||||
},
|
||||
});
|
||||
await assert.rejects(source.getTile({ x: 0, y: 0, z: 0 }), (error) => {
|
||||
assert.ok(error instanceof PanoramaxSourceError);
|
||||
assert.match(error.message, /could not be reached/);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
test('a search that timed out is asked once more; a second timeout is reported', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
let calls = 0;
|
||||
const hang = (signal) =>
|
||||
new Promise((_, reject) =>
|
||||
signal.addEventListener('abort', () => reject(signal.reason)),
|
||||
);
|
||||
const source = createPanoramaxSource({
|
||||
fetchImpl: async (url, { signal }) => {
|
||||
calls++;
|
||||
if (calls === 1) return hang(signal);
|
||||
return new Response(JSON.stringify({ features: [{ id: 'a' }] }));
|
||||
},
|
||||
});
|
||||
const asked = source.getItem('a');
|
||||
for (let i = 0; i < 5; i++) await Promise.resolve();
|
||||
t.mock.timers.tick(60_000);
|
||||
assert.deepEqual(await asked, { id: 'a' });
|
||||
assert.equal(calls, 2);
|
||||
const stuck = createPanoramaxSource({
|
||||
fetchImpl: async (url, { signal }) => hang(signal),
|
||||
});
|
||||
const never = stuck.getItem('b');
|
||||
for (let round = 0; round < 2; round++) {
|
||||
for (let i = 0; i < 10; i++) await Promise.resolve();
|
||||
t.mock.timers.tick(60_000);
|
||||
}
|
||||
await assert.rejects(never, /did not answer in time/);
|
||||
});
|
||||
|
||||
test('the HTTP/1.1 API never gets more than five requests at once: three tiles, two searches', async () => {
|
||||
const { API_CONCURRENCY, TILE_CONCURRENCY } = await import('./policy.js');
|
||||
assert.ok(API_CONCURRENCY + TILE_CONCURRENCY <= 5);
|
||||
let open = 0;
|
||||
let most = 0;
|
||||
const waiting = [];
|
||||
const source = createPanoramaxSource({
|
||||
fetchImpl: async (url) => {
|
||||
open++;
|
||||
most = Math.max(most, open);
|
||||
await new Promise((resolve) => waiting.push(resolve));
|
||||
open--;
|
||||
return url.includes('/map/')
|
||||
? new Response(null, { status: 204 })
|
||||
: new Response(JSON.stringify({ features: [] }));
|
||||
},
|
||||
});
|
||||
const all = [
|
||||
...Array.from({ length: 8 }, (_, i) =>
|
||||
source.getTile({ x: i, y: 0, z: 14 }),
|
||||
),
|
||||
...Array.from({ length: 4 }, (_, i) => source.getItem(String(i))),
|
||||
];
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(most, 5);
|
||||
let done = false;
|
||||
Promise.allSettled(all).then(() => (done = true));
|
||||
while (!done) {
|
||||
waiting.splice(0).forEach((go) => go());
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
assert.equal(most, 5, 'never more');
|
||||
});
|
||||
|
||||
test('one of the two nearest searches failing leaves the other’s pictures; both failing fails the lookup', async () => {
|
||||
const half = createPanoramaxSource({
|
||||
fetchImpl: fakeFetch((url) =>
|
||||
url.includes('bbox')
|
||||
? { status: 503 }
|
||||
: json({ features: [{ id: 'facing' }] }),
|
||||
).fetchImpl,
|
||||
});
|
||||
assert.deepEqual(
|
||||
(await half.nearestItems({ lat: 1, lon: 2 })).map((i) => i.id),
|
||||
['facing'],
|
||||
);
|
||||
const none = createPanoramaxSource({
|
||||
fetchImpl: fakeFetch(() => ({ status: 503 })).fetchImpl,
|
||||
});
|
||||
await assert.rejects(none.nearestItems({ lat: 1, lon: 2 }), /busy/);
|
||||
});
|
||||
|
||||
test('only a timeout is asked again: a busy or failing API is asked once, an unreachable one says so', async () => {
|
||||
for (const status of [429, 500, 503]) {
|
||||
const { urls, fetchImpl } = fakeFetch(() => ({ status }));
|
||||
const source = createPanoramaxSource({ fetchImpl });
|
||||
await assert.rejects(source.getItem('a'));
|
||||
assert.equal(urls.length, 1, `HTTP ${status}: one fetch`);
|
||||
}
|
||||
let calls = 0;
|
||||
const offline = createPanoramaxSource({
|
||||
fetchImpl: async () => {
|
||||
calls++;
|
||||
throw new TypeError('Failed to fetch');
|
||||
},
|
||||
});
|
||||
await assert.rejects(offline.getItem('a'), /could not be reached/);
|
||||
assert.equal(calls, 1, 'not retried');
|
||||
});
|
||||
|
||||
test('a Retry-After is honoured up to five minutes', async () => {
|
||||
const wait = async (header) => {
|
||||
const source = createPanoramaxSource({
|
||||
fetchImpl: fakeFetch(() => ({
|
||||
status: 503,
|
||||
headers: { 'retry-after': header },
|
||||
})).fetchImpl,
|
||||
});
|
||||
return source
|
||||
.getTile({ x: 0, y: 0, z: 0 })
|
||||
.catch((error) => error.retryAfterSec);
|
||||
};
|
||||
assert.equal(await wait('3600'), 300);
|
||||
assert.equal(await wait('99999999999'), 300);
|
||||
assert.equal(await wait('0'), 1);
|
||||
assert.equal(await wait('12'), 12);
|
||||
});
|
||||
|
||||
test('a Retry-After may be an HTTP date', () => {
|
||||
const now = Date.parse('2026-10-15T12:00:00Z');
|
||||
const response = (value) =>
|
||||
new Response(null, { status: 429, headers: { 'retry-after': value } });
|
||||
assert.equal(
|
||||
retryAfterSeconds(response('Thu, 15 Oct 2026 12:01:00 GMT'), now),
|
||||
60,
|
||||
);
|
||||
assert.equal(
|
||||
retryAfterSeconds(response('Thu, 15 Oct 2026 14:00:00 GMT'), now),
|
||||
300,
|
||||
);
|
||||
assert.equal(
|
||||
retryAfterSeconds(response('Thu, 15 Oct 2026 11:00:00 GMT'), now),
|
||||
null,
|
||||
'a date already past',
|
||||
);
|
||||
assert.equal(retryAfterSeconds(response('soon'), now), null);
|
||||
assert.equal(
|
||||
retryAfterSeconds(new Response(null, { status: 429 }), now),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('busy without a readable Retry-After (the browser hides it): the wait doubles, and resets once answered', async () => {
|
||||
let busy = true;
|
||||
const source = createPanoramaxSource({
|
||||
fetchImpl: fakeFetch(() =>
|
||||
busy ? { status: 429 } : { body: new Uint8Array([1]) },
|
||||
).fetchImpl,
|
||||
});
|
||||
const wait = () =>
|
||||
source.getTile({ x: 0, y: 0, z: 0 }).then(
|
||||
() => 'ok',
|
||||
(error) => error.retryAfterSec,
|
||||
);
|
||||
const waits = [];
|
||||
for (let i = 0; i < 9; i++) waits.push(await wait());
|
||||
assert.deepEqual(waits, [5, 10, 20, 40, 80, 160, 300, 300, 300]);
|
||||
busy = false;
|
||||
assert.equal(await wait(), 'ok');
|
||||
busy = true;
|
||||
assert.equal(await wait(), 5, 'back to the first wait');
|
||||
});
|
||||
|
||||
test(
|
||||
'a body that keeps arriving is not cut off by the request timeout; one that stalls is',
|
||||
{ timeout: 10_000 },
|
||||
async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const settle = async () => {
|
||||
for (let i = 0; i < 20; i++) await Promise.resolve();
|
||||
};
|
||||
// Headers at once, then a chunk every 10 s for twice the tile timeout.
|
||||
const chunks = Math.ceil((2 * TILE_TIMEOUT_MS) / 10_000);
|
||||
let push;
|
||||
const slowBody = (signal) =>
|
||||
new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
push = (last) => {
|
||||
controller.enqueue(new Uint8Array([7]));
|
||||
if (last) controller.close();
|
||||
};
|
||||
signal.addEventListener('abort', () =>
|
||||
controller.error(signal.reason),
|
||||
);
|
||||
},
|
||||
}),
|
||||
{ status: 200 },
|
||||
);
|
||||
const source = createPanoramaxSource({
|
||||
fetchImpl: async (url, { signal }) => slowBody(signal),
|
||||
});
|
||||
const slow = source.getTile({ x: 0, y: 0, z: 0 });
|
||||
await settle();
|
||||
for (let i = 1; i <= chunks; i++) {
|
||||
t.mock.timers.tick(10_000);
|
||||
push(i === chunks);
|
||||
await settle();
|
||||
}
|
||||
assert.equal((await slow).length, chunks, 'every chunk read');
|
||||
const stalled = source.getTile({ x: 0, y: 0, z: 0 });
|
||||
await settle();
|
||||
push(false);
|
||||
await settle();
|
||||
t.mock.timers.tick(STREAM_IDLE_TIMEOUT_MS);
|
||||
await assert.rejects(stalled, /did not answer in time/);
|
||||
},
|
||||
);
|
||||
|
||||
test('the box search asks for a few pictures: the API returns them nearest first', async () => {
|
||||
const { urls, fetchImpl } = fakeFetch(() => json({ features: [] }));
|
||||
const source = createPanoramaxSource({ fetchImpl });
|
||||
await source.nearestItems({ lon: 2.35, lat: 48.85 });
|
||||
const box = urls
|
||||
.map((url) => new URL(url))
|
||||
.find((url) => url.searchParams.has('bbox'));
|
||||
assert.ok(box, 'a box search');
|
||||
assert.equal(box.searchParams.get('limit'), String(NEAREST_BOX_LIMIT));
|
||||
assert.ok(NEAREST_BOX_LIMIT <= 20);
|
||||
});
|
||||
|
||||
test('the cones’ tiles have their own connection: never behind coverage tiles, the newest first', async () => {
|
||||
const { PICTURE_TILE_CONCURRENCY, TILE_CONCURRENCY } =
|
||||
await import('./policy.js');
|
||||
assert.equal(PICTURE_TILE_CONCURRENCY, 1);
|
||||
const held = new Map();
|
||||
const asked = [];
|
||||
const source = createPanoramaxSource({
|
||||
fetchImpl: (url) => {
|
||||
asked.push(url.replace(/^.*\/map\//, ''));
|
||||
return new Promise((resolve) =>
|
||||
held.set(url.replace(/^.*\/map\//, ''), () =>
|
||||
resolve(new Response(null, { status: 204 })),
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
// Coverage fills its lane and queues.
|
||||
const coverage = Array.from({ length: TILE_CONCURRENCY + 2 }, (_, i) =>
|
||||
source.getTile({ x: i, y: 0, z: 10 }),
|
||||
);
|
||||
await settle();
|
||||
assert.equal(asked.length, TILE_CONCURRENCY);
|
||||
// A cone tile goes at once; two more wait, the newer first.
|
||||
const pictures = [1, 2, 3].map((x) =>
|
||||
source.getTile({ x, y: 9, z: 15 }, { lane: 'pictures' }),
|
||||
);
|
||||
await settle();
|
||||
assert.deepEqual(asked.slice(TILE_CONCURRENCY), ['15/1/9.mvt']);
|
||||
held.get('15/1/9.mvt')();
|
||||
await settle();
|
||||
assert.deepEqual(asked.slice(TILE_CONCURRENCY), ['15/1/9.mvt', '15/3/9.mvt']);
|
||||
for (let round = 0; round < 6; round++) {
|
||||
for (const go of held.values()) go();
|
||||
await settle();
|
||||
}
|
||||
await Promise.all([...coverage, ...pictures]);
|
||||
});
|
||||
|
||||
test('a lookup near the antimeridian keeps its box on the map', async () => {
|
||||
const { urls, fetchImpl } = fakeFetch(() => json({ features: [] }));
|
||||
const source = createPanoramaxSource({ fetchImpl });
|
||||
await source.nearestItems({ lat: -16, lon: 179.9999 });
|
||||
const box = urls
|
||||
.map((u) => new URL(u).searchParams.get('bbox'))
|
||||
.find(Boolean)
|
||||
.split(',')
|
||||
.map(Number);
|
||||
assert.equal(box[2], 180);
|
||||
assert.ok(box[0] < 180);
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
PANO_FULL_SIZE,
|
||||
createPhotoViewer,
|
||||
loadImage,
|
||||
} from '../../photoViewer.js';
|
||||
import {
|
||||
HD_TIMEOUT_MS,
|
||||
PANORAMAX_NAME,
|
||||
PANORAMAX_PROVIDER_ID,
|
||||
PANO_HD_MAX_WIDTH,
|
||||
SD_WIDTH,
|
||||
panoramaxPictureUrl,
|
||||
} from './policy.js';
|
||||
|
||||
/** An HD original small enough to decode (its width is known and under the cap). */
|
||||
function hdDecodable(photo) {
|
||||
return Boolean(
|
||||
photo.urls.hd && photo.width && photo.width <= PANO_HD_MAX_WIDTH,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where Panoramax pictures come from: the instance holding each one serves
|
||||
* a 2,048 px SD derivative (equirectangular for a 360° picture) and the HD
|
||||
* original. SD shows first; HD only once the viewer is wide enough to use
|
||||
* it. A 360° original is shrunk to the texture size while it decodes.
|
||||
* @type {import('../../photoViewer.js').PhotoSources}
|
||||
*/
|
||||
export const PANORAMAX_SOURCES = Object.freeze({
|
||||
hasPano: (photo) =>
|
||||
Boolean(photo.isPano && (photo.urls.sd || hdDecodable(photo))),
|
||||
// Without an SD derivative the original goes straight to the texture size.
|
||||
panoFirst: (photo) => {
|
||||
// Decoded at SD's own size, whatever the host actually serves.
|
||||
if (photo.urls.sd)
|
||||
return {
|
||||
url: photo.urls.sd,
|
||||
width: SD_WIDTH,
|
||||
resize: [SD_WIDTH, SD_WIDTH / 2],
|
||||
};
|
||||
if (!hdDecodable(photo)) return null;
|
||||
const width = Math.min(photo.width, PANO_FULL_SIZE[0]);
|
||||
return { url: photo.urls.hd, width, resize: [width, width / 2] };
|
||||
},
|
||||
// The original is several megabytes with no server to shrink it: only
|
||||
// for the expanded viewer (a docked one on a dense screen asks too).
|
||||
panoSharp: (photo, size, { expanded = false } = {}) => {
|
||||
const width = photo.width || 0;
|
||||
if (!expanded || !photo.urls.hd || !photo.urls.sd) return null;
|
||||
// An original no wider than the SD one adds nothing; a huge one would
|
||||
// take hundreds of megabytes to decode.
|
||||
if (width <= SD_WIDTH || width > PANO_HD_MAX_WIDTH) return null;
|
||||
const target = Math.min(size[0], width);
|
||||
return {
|
||||
url: photo.urls.hd,
|
||||
resize: [target, target / 2],
|
||||
timeoutMs: HD_TIMEOUT_MS,
|
||||
};
|
||||
},
|
||||
// A 360° picture shown flat (no WebGL) is its whole equirectangular image.
|
||||
// When SD fails the thumbnail stands in; the original (megabytes) only
|
||||
// for the expanded viewer, and only one of a size it may decode.
|
||||
flatFirst: (photo, { expanded = false } = {}) => [
|
||||
photo.urls.sd,
|
||||
expanded && hdDecodable(photo) ? photo.urls.hd : null,
|
||||
photo.urls.thumb,
|
||||
],
|
||||
// The original once the viewer is wider than SD, if its width is known
|
||||
// and decodable (a 200 MP phone photo is 16,000 px across).
|
||||
flatSharp: (photo, pixels) =>
|
||||
photo.urls.sd && hdDecodable(photo) && pixels > SD_WIDTH
|
||||
? photo.urls.hd
|
||||
: null,
|
||||
describe: (photo) => ({
|
||||
// Where no author can be named, the instance holding the photo is.
|
||||
title: !photo.creator && photo.via ? `via ${photo.via}` : null,
|
||||
creator: photo.creator,
|
||||
license: photo.license,
|
||||
externalUrl: panoramaxPictureUrl(photo.id),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Viewer adapter for Panoramax pictures: flat in an <img>, 360° on the
|
||||
* shared WebGL panorama, ‹ › along the sequence (photoViewer.js).
|
||||
* @param {object} options See photoViewer.js `createPhotoViewer`.
|
||||
* @returns {import('../../registry.js').ViewerAdapter}
|
||||
*/
|
||||
export function createPanoramaxViewer({ load = loadImage, ...options }) {
|
||||
return createPhotoViewer({
|
||||
providerId: PANORAMAX_PROVIDER_ID,
|
||||
name: PANORAMAX_NAME,
|
||||
sources: PANORAMAX_SOURCES,
|
||||
load,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { PANORAMAX_SOURCES, createPanoramaxViewer } from './viewer.js';
|
||||
import { normalizeItem } from './policy.js';
|
||||
import { fakeHost, until } from '../../../../testSupport/kartaViewFakes.mjs';
|
||||
import { stacItem } from '../../../../testSupport/panoramaxFixtures.mjs';
|
||||
|
||||
const photo = (options) =>
|
||||
normalizeItem(stacItem({ lon: 2.35, lat: 48.85, ...options }));
|
||||
|
||||
const PHOTOS = {
|
||||
flat: photo({ id: 'flat', prevId: 'p0', nextId: 'flat2', heading: 30 }),
|
||||
flat2: photo({ id: 'flat2', prevId: 'flat', heading: 40 }),
|
||||
pano: photo({ id: 'pano', pano: true, heading: 100, width: 5760 }),
|
||||
bigPano: photo({ id: 'bigPano', pano: true, width: 11_000 }),
|
||||
smallPano: photo({ id: 'smallPano', pano: true, width: 2048 }),
|
||||
email: photo({ id: 'email', producer: '[email protected]' }),
|
||||
};
|
||||
|
||||
function fakeRenderer() {
|
||||
const view = { yaw: 0, pitch: 0, fov: 80 };
|
||||
const images = [];
|
||||
return {
|
||||
images,
|
||||
setImage: (image) => images.push(image),
|
||||
setView(next) {
|
||||
Object.assign(view, next);
|
||||
},
|
||||
getView: () => ({ ...view }),
|
||||
resize() {},
|
||||
clear() {},
|
||||
destroy() {},
|
||||
};
|
||||
}
|
||||
|
||||
function setup() {
|
||||
const loads = [];
|
||||
const renderers = [];
|
||||
const stepped = [];
|
||||
const viewer = createPanoramaxViewer({
|
||||
getPhoto: async (id) => {
|
||||
if (!PHOTOS[id]) throw new Error('no such picture');
|
||||
return PHOTOS[id];
|
||||
},
|
||||
step: async (from, delta) => (delta < 0 ? from.prevId : from.nextId),
|
||||
onStep: (id) => {
|
||||
stepped.push(id);
|
||||
viewer.open(id).catch(() => {});
|
||||
},
|
||||
load: async (url, options) => {
|
||||
loads.push({ url, ...options });
|
||||
return { src: url, width: 1, height: 1 };
|
||||
},
|
||||
createRenderer: () => {
|
||||
const renderer = fakeRenderer();
|
||||
renderers.push(renderer);
|
||||
return renderer;
|
||||
},
|
||||
});
|
||||
const poses = [];
|
||||
viewer.onPose((pose) => poses.push(pose));
|
||||
return { viewer, poses, loads, renderers, stepped, host: fakeHost() };
|
||||
}
|
||||
|
||||
const parts = (host) => {
|
||||
const root = host.children[0];
|
||||
const [picture, canvas, prev, next] = root.children;
|
||||
return { root, picture, canvas, prev, next };
|
||||
};
|
||||
|
||||
test('a flat picture shows its SD version with author, licence and a Panoramax link', async () => {
|
||||
const { viewer, poses, loads, host } = setup();
|
||||
await viewer.mount(host);
|
||||
const { root, picture, canvas } = parts(host);
|
||||
assert.equal(root.dataset.provider, 'panoramax', 'its colour in the CSS');
|
||||
assert.match(root.attributes['aria-label'], /^Panoramax photo/);
|
||||
await viewer.open('flat');
|
||||
assert.equal(picture.hidden, false);
|
||||
assert.equal(canvas.hidden, true);
|
||||
assert.equal(
|
||||
picture.src,
|
||||
'https://panoramax.example.org/pictures/flat/sd.jpg',
|
||||
);
|
||||
assert.deepEqual(
|
||||
loads.map((l) => l.url),
|
||||
[PHOTOS.flat.urls.sd],
|
||||
);
|
||||
const pose = poses.at(-1);
|
||||
assert.equal(pose.providerId, 'panoramax');
|
||||
assert.equal(pose.bearing, 30);
|
||||
assert.equal(pose.isPano, false);
|
||||
assert.equal(pose.creator, 'tester');
|
||||
assert.equal(pose.license, 'CC BY-SA 4.0');
|
||||
assert.equal(pose.sequenceId, 'seq-1');
|
||||
assert.equal(
|
||||
pose.externalUrl,
|
||||
'https://api.panoramax.xyz/#pic=flat&focus=pic',
|
||||
);
|
||||
assert.equal(viewer.supportsRenderModes(), true);
|
||||
});
|
||||
|
||||
test('no creator in the caption for an account named by an email address', async () => {
|
||||
const { viewer, poses, host } = setup();
|
||||
await viewer.mount(host);
|
||||
await viewer.open('email');
|
||||
assert.equal(poses.at(-1).creator, null);
|
||||
});
|
||||
|
||||
test('HD replaces SD only once the viewer is wide enough to use it', async (t) => {
|
||||
const { viewer, loads, host } = setup();
|
||||
await viewer.mount(host);
|
||||
const { picture, root } = parts(host);
|
||||
t.after(() => delete globalThis.devicePixelRatio);
|
||||
globalThis.devicePixelRatio = 1;
|
||||
picture.clientWidth = 400;
|
||||
await viewer.open('flat');
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(loads.length, 1, 'docked: SD is sharp enough');
|
||||
// EXPAND on a wide screen.
|
||||
picture.clientWidth = root.clientWidth = 2600;
|
||||
viewer.resize();
|
||||
assert.ok(await until(() => picture.src === PHOTOS.flat.urls.hd));
|
||||
viewer.resize();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(
|
||||
loads.filter((l) => l.url === PHOTOS.flat.urls.hd).length,
|
||||
1,
|
||||
'asked once',
|
||||
);
|
||||
});
|
||||
|
||||
test('a 360° picture shows its SD equirectangular image, then HD shrunk to 4,096 when expanded', async (t) => {
|
||||
const { viewer, poses, loads, renderers, host } = setup();
|
||||
await viewer.mount(host);
|
||||
const { canvas, picture } = parts(host);
|
||||
t.after(() => delete globalThis.devicePixelRatio);
|
||||
globalThis.devicePixelRatio = 1;
|
||||
canvas.clientWidth = 400;
|
||||
await viewer.open('pano');
|
||||
assert.equal(canvas.hidden, false);
|
||||
assert.equal(picture.hidden, true);
|
||||
assert.equal(viewer.supportsRenderModes(), false, 'no frame to fit');
|
||||
const { signal, ...first } = loads[0];
|
||||
assert.deepEqual(first, {
|
||||
url: PHOTOS.pano.urls.sd,
|
||||
crossOrigin: true,
|
||||
resize: [2048, 1024],
|
||||
failure: 'The Panoramax photo could not be loaded',
|
||||
});
|
||||
assert.equal(signal.aborted, false, 'the open can stop it');
|
||||
assert.equal(poses.at(-1).isPano, true);
|
||||
assert.equal(poses.at(-1).bearing, 100);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(loads.length, 1, 'docked: the 2,048 px SD is enough');
|
||||
// Docked on a dense screen the canvas wants more, but the original is
|
||||
// megabytes: not until the viewer is expanded.
|
||||
canvas.clientWidth = 1800;
|
||||
viewer.resize();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(loads.length, 1, 'docked: still SD only');
|
||||
const root = canvas.parentNode;
|
||||
root.closest = (selector) =>
|
||||
selector.includes('sl-viewer-wrap-expanded') ? {} : null;
|
||||
// EXPAND resizes more than once while the panel settles.
|
||||
viewer.resize();
|
||||
viewer.resize();
|
||||
viewer.resize();
|
||||
assert.ok(await until(() => renderers[0].images.length === 2));
|
||||
assert.equal(
|
||||
loads.filter((l) => l.url === PHOTOS.pano.urls.hd).length,
|
||||
1,
|
||||
'the original is fetched once',
|
||||
);
|
||||
const hd = loads.at(-1);
|
||||
assert.equal(hd.url, PHOTOS.pano.urls.hd);
|
||||
assert.deepEqual(hd.resize, [4096, 2048]);
|
||||
assert.equal(hd.timeoutMs, 30_000);
|
||||
assert.ok(hd.signal, 'abortable');
|
||||
});
|
||||
|
||||
test('HD panoramas too big to decode, or no bigger than SD, are never fetched; nor while docked', () => {
|
||||
const size = [4096, 2048];
|
||||
const expanded = { expanded: true };
|
||||
assert.equal(
|
||||
PANORAMAX_SOURCES.panoSharp(PHOTOS.pano, size, { expanded: false }),
|
||||
null,
|
||||
);
|
||||
assert.equal(PANORAMAX_SOURCES.panoSharp(PHOTOS.pano, size), null);
|
||||
assert.equal(
|
||||
PANORAMAX_SOURCES.panoSharp(PHOTOS.bigPano, size, expanded),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
PANORAMAX_SOURCES.panoSharp(PHOTOS.smallPano, size, expanded),
|
||||
null,
|
||||
);
|
||||
assert.deepEqual(
|
||||
PANORAMAX_SOURCES.panoSharp({ ...PHOTOS.pano, width: 3000 }, size, expanded)
|
||||
.resize,
|
||||
[3000, 1500],
|
||||
'never enlarged',
|
||||
);
|
||||
// Without an SD derivative the HD one is the first and only.
|
||||
const hdOnly = { ...PHOTOS.pano, urls: { ...PHOTOS.pano.urls, sd: null } };
|
||||
assert.deepEqual(PANORAMAX_SOURCES.panoFirst(hdOnly), {
|
||||
url: hdOnly.urls.hd,
|
||||
width: 4096,
|
||||
resize: [4096, 2048],
|
||||
});
|
||||
// …unless it is too big to decode: then no panorama, and the thumbnail.
|
||||
const hugeOnly = { ...hdOnly, width: 11_904 };
|
||||
assert.equal(PANORAMAX_SOURCES.panoFirst(hugeOnly), null);
|
||||
assert.equal(PANORAMAX_SOURCES.hasPano(hugeOnly), false);
|
||||
assert.deepEqual(PANORAMAX_SOURCES.flatFirst(hugeOnly).filter(Boolean), [
|
||||
hugeOnly.urls.thumb,
|
||||
]);
|
||||
assert.equal(PANORAMAX_SOURCES.panoSharp(hdOnly, size, expanded), null);
|
||||
assert.equal(PANORAMAX_SOURCES.flatSharp(PHOTOS.flat, 2048), null);
|
||||
assert.equal(
|
||||
PANORAMAX_SOURCES.flatSharp(PHOTOS.flat, 2049),
|
||||
PHOTOS.flat.urls.hd,
|
||||
);
|
||||
assert.equal(
|
||||
PANORAMAX_SOURCES.hasPano({ isPano: true, urls: { sd: null, hd: null } }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('› and ‹ follow the picture’s own links; the ends disable the buttons', async () => {
|
||||
const { viewer, poses, stepped, host } = setup();
|
||||
await viewer.mount(host);
|
||||
const { next, prev } = parts(host);
|
||||
await viewer.open('flat');
|
||||
next.fire('click');
|
||||
assert.ok(await until(() => poses.at(-1)?.imageId === 'flat2'));
|
||||
assert.deepEqual(stepped, ['flat2']);
|
||||
assert.ok(await until(() => next.disabled), 'flat2 is the last');
|
||||
assert.equal(prev.disabled, false);
|
||||
});
|
||||
|
||||
test('without WebGL a 360° picture shows its whole equirectangular image flat', async () => {
|
||||
const loads = [];
|
||||
const viewer = createPanoramaxViewer({
|
||||
getPhoto: async () => PHOTOS.pano,
|
||||
step: async () => null,
|
||||
onStep() {},
|
||||
load: async (url) => {
|
||||
loads.push(url);
|
||||
return { src: url };
|
||||
},
|
||||
createRenderer: () => {
|
||||
throw new Error('no WebGL');
|
||||
},
|
||||
});
|
||||
const host = fakeHost();
|
||||
await viewer.mount(host);
|
||||
await viewer.open('pano');
|
||||
const { picture } = parts(host);
|
||||
assert.equal(picture.hidden, false);
|
||||
assert.equal(picture.src, PHOTOS.pano.urls.sd);
|
||||
});
|
||||
|
||||
test('a picture whose SD fails falls back to the thumbnail; the original only for the expanded viewer', async () => {
|
||||
const tried = [];
|
||||
const viewer = createPanoramaxViewer({
|
||||
getPhoto: async () => PHOTOS.flat,
|
||||
step: async () => null,
|
||||
onStep() {},
|
||||
load: async (url) => {
|
||||
tried.push(url);
|
||||
if (!url.endsWith('thumb.jpg')) throw new Error('gone');
|
||||
return { src: url };
|
||||
},
|
||||
});
|
||||
const host = fakeHost();
|
||||
await viewer.mount(host);
|
||||
await viewer.open('flat');
|
||||
assert.deepEqual(tried, [PHOTOS.flat.urls.sd, PHOTOS.flat.urls.thumb]);
|
||||
assert.equal(parts(host).picture.src, PHOTOS.flat.urls.thumb);
|
||||
// Expanded, the original is worth its megabytes.
|
||||
tried.length = 0;
|
||||
parts(host).root.closest = (selector) =>
|
||||
selector.includes('sl-viewer-wrap-expanded') ? {} : null;
|
||||
await viewer.open('flat');
|
||||
assert.deepEqual(tried, [
|
||||
PHOTOS.flat.urls.sd,
|
||||
PHOTOS.flat.urls.hd,
|
||||
PHOTOS.flat.urls.thumb,
|
||||
]);
|
||||
});
|
||||
|
||||
test('a photo with no author to name is captioned with the instance holding it', () => {
|
||||
const photo = normalizeItem(
|
||||
stacItem({ id: 'x', lon: 1, lat: 2, producer: '[email protected]' }),
|
||||
);
|
||||
photo.via = 'panoramax.ign.fr';
|
||||
assert.deepEqual(PANORAMAX_SOURCES.describe(photo), {
|
||||
title: 'via panoramax.ign.fr',
|
||||
creator: null,
|
||||
license: 'CC BY-SA 4.0',
|
||||
externalUrl: 'https://api.panoramax.xyz/#pic=x&focus=pic',
|
||||
});
|
||||
const named = { ...photo, creator: 'Robot8A' };
|
||||
assert.equal(PANORAMAX_SOURCES.describe(named).title, null);
|
||||
});
|
||||
|
||||
test('an SD panorama refused for its size is not fetched again as a flat photo', async () => {
|
||||
const loads = [];
|
||||
const viewer = createPanoramaxViewer({
|
||||
getPhoto: async () => PHOTOS.pano,
|
||||
step: async () => null,
|
||||
onStep() {},
|
||||
load: async (url, options = {}) => {
|
||||
loads.push({ url, crossOrigin: options.crossOrigin });
|
||||
// The picture host's SD file is past the viewer's size limits.
|
||||
if (url === PHOTOS.pano.urls.sd)
|
||||
throw Object.assign(new Error('too big'), { tooLarge: true });
|
||||
return { src: url };
|
||||
},
|
||||
createRenderer: () => ({
|
||||
setImage() {},
|
||||
setView() {},
|
||||
getView: () => ({ yaw: 0, pitch: 0, fov: 80 }),
|
||||
resize() {},
|
||||
clear() {},
|
||||
destroy() {},
|
||||
}),
|
||||
});
|
||||
const host = fakeHost();
|
||||
await viewer.mount(host);
|
||||
await viewer.open('pano');
|
||||
const sdLoads = loads.filter((l) => l.url === PHOTOS.pano.urls.sd);
|
||||
assert.equal(sdLoads.length, 1, 'tried once, as a bitmap, and refused');
|
||||
assert.equal(sdLoads[0].crossOrigin, true);
|
||||
const { picture } = parts(host);
|
||||
assert.equal(picture.hidden, false, 'the flat fallback shows');
|
||||
assert.notEqual(picture.src, PHOTOS.pano.urls.sd);
|
||||
// Opened again later: the refused file is still not asked for.
|
||||
await viewer.open('pano');
|
||||
assert.equal(loads.filter((l) => l.url === PHOTOS.pano.urls.sd).length, 1);
|
||||
});
|
||||
@@ -83,6 +83,7 @@ export function filtersOf(provider) {
|
||||
* @property {'day'|'month'} [capturedAtPrecision] How exactly the provider dates images (default 'day').
|
||||
* @property {string|null} [title] A place name for the caption, e.g. the address.
|
||||
* @property {string|null} creator
|
||||
* @property {string|null} [license] The image's licence when it is per image, e.g. 'CC BY-SA 4.0'.
|
||||
* @property {string|null} sequenceId
|
||||
* @property {string} externalUrl Deep link to the image on the provider's site.
|
||||
*/
|
||||
|
||||
@@ -26,6 +26,7 @@ export function freshStreet() {
|
||||
renderModes: true,
|
||||
sequenceId: null,
|
||||
creator: null,
|
||||
license: null,
|
||||
externalUrl: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/** Decoded coverage tiles kept, and for how long. */
|
||||
export const TILE_CACHE_SIZE = 24;
|
||||
export const TILE_CACHE_TTL_MS = 10 * 60_000;
|
||||
|
||||
/** Least-recently-used map: reading an entry renews it. */
|
||||
export function createLru(limit) {
|
||||
const map = new Map();
|
||||
return {
|
||||
get(key) {
|
||||
if (!map.has(key)) return undefined;
|
||||
const value = map.get(key);
|
||||
map.delete(key);
|
||||
map.set(key, value);
|
||||
return value;
|
||||
},
|
||||
set(key, value) {
|
||||
map.delete(key);
|
||||
map.set(key, value);
|
||||
while (map.size > limit) map.delete(map.keys().next().value);
|
||||
},
|
||||
delete: (key) => map.delete(key),
|
||||
clear: () => map.clear(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Loaded (decoded) coverage tiles by `keyOf` (default `z/x/y`), at most `size`, each for
|
||||
* `ttlMs`: a pan back, or a zoom across bands and back, neither fetches nor
|
||||
* decodes them again. A load that fails is not kept.
|
||||
*/
|
||||
export function createTileCache({
|
||||
load,
|
||||
size = TILE_CACHE_SIZE,
|
||||
ttlMs = TILE_CACHE_TTL_MS,
|
||||
now = Date.now,
|
||||
keyOf = (tile) => `${tile.z}/${tile.x}/${tile.y}`,
|
||||
}) {
|
||||
const kept = createLru(size);
|
||||
return {
|
||||
async get(tile, options) {
|
||||
const key = keyOf(tile);
|
||||
const entry = kept.get(key);
|
||||
if (entry && now() - entry.at < ttlMs) return entry.tile;
|
||||
const loaded = await load(tile, options);
|
||||
kept.set(key, { at: now(), tile: loaded });
|
||||
return loaded;
|
||||
},
|
||||
/** Forget every tile (the layer is off: its memory goes back). */
|
||||
clear: () => kept.clear(),
|
||||
};
|
||||
}
|
||||
@@ -38,6 +38,7 @@ export function createViewerHost({ state, parts }) {
|
||||
title: pose.title || null,
|
||||
sequenceId: pose.sequenceId ?? null,
|
||||
creator: pose.creator || null,
|
||||
license: pose.license || null,
|
||||
externalUrl: pose.externalUrl || null,
|
||||
// FIT / FILL, unless this image has no frame to fit (a panorama).
|
||||
renderModes:
|
||||
|
||||
@@ -673,3 +673,15 @@ test('an adapter that can frame but says this image has no frame offers no FIT /
|
||||
await host.open('mapillary', 'flat-1');
|
||||
assert.equal(state.street.renderModes, true, 'a flat image can be framed');
|
||||
});
|
||||
|
||||
test('a pose’s licence reaches the caption state, and a closed viewer forgets it', async () => {
|
||||
const { state, host, google } = twoProviders();
|
||||
await host.open('google', 'g-1');
|
||||
google.emitPose('g-1', { providerId: 'google', license: 'CC BY-SA 4.0' });
|
||||
assert.equal(state.street.license, 'CC BY-SA 4.0');
|
||||
google.emitPose('g-1', { providerId: 'google' });
|
||||
assert.equal(state.street.license, null, 'none given: none shown');
|
||||
google.emitPose('g-1', { providerId: 'google', license: 'CC0' });
|
||||
host.close();
|
||||
assert.equal(state.street.license, null);
|
||||
});
|
||||
@@ -221,8 +221,12 @@ export function createIngestion({ state, services, parts, source }) {
|
||||
for (const record of records) {
|
||||
if (isStaleVehicleFix(record, now, fetchedAt)) continue;
|
||||
const key = transitVehicleKey(feed.id, record.id);
|
||||
const mode = transitModeFor(feed, record.routeId);
|
||||
const modeInferred = !transitModeResolved(feed, record.routeId);
|
||||
const mode = transitModeFor(feed, record.routeId, record.id);
|
||||
const modeInferred = !transitModeResolved(
|
||||
feed,
|
||||
record.routeId,
|
||||
record.id,
|
||||
);
|
||||
// A stamp ahead of the fetch is a clock fault; the fetch time bounds it.
|
||||
const stamped = vehicleReportTimeMs(record, fetchedAt);
|
||||
const reportAt =
|
||||
|
||||
@@ -3586,6 +3586,7 @@ test('actual Traffic, Bikeshare and Transit lifecycles retain sensitivity in eve
|
||||
_pointCollection: {},
|
||||
_overlayHost: { setVisible: noop },
|
||||
_cityRuntime: new Map(),
|
||||
_failedCityIds: new Set(),
|
||||
},
|
||||
services: {
|
||||
sprites: { restoreSpriteOrder: noop },
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
* scene state, no network: everything here is exercised by node:test.
|
||||
*/
|
||||
|
||||
import { TRANSIT_MODE_ICON } from '../../data/transitFeeds.js';
|
||||
import {
|
||||
TRANSIT_MODE_ICON,
|
||||
transitRouteLabel,
|
||||
} from '../../data/transitFeeds.js';
|
||||
import { displayMotion, playbackPosition } from './movement.js';
|
||||
|
||||
export const TRANSIT_SELECTED_OVERLAY_SOURCE_ID = 'transit-selected';
|
||||
@@ -337,7 +340,7 @@ export function buildTransitSelectionCopy(
|
||||
) {
|
||||
const icon = TRANSIT_MODE_ICON[mode] || TRANSIT_MODE_ICON.unknown;
|
||||
const routeLabel = record.routeId
|
||||
? `Route ${record.routeId}`
|
||||
? `Route ${transitRouteLabel(feed, record.routeId)}`
|
||||
: record.label
|
||||
? `Vehicle ${record.label}`
|
||||
: `Vehicle ${record.id}`;
|
||||
@@ -345,7 +348,7 @@ export function buildTransitSelectionCopy(
|
||||
// The mode is said in words on its own line. A glyph and a route id do not
|
||||
// tell a reader that the dot on Tremont Street is a subway train, and a
|
||||
// subway drawn on a street reads as a bug until the card says what it is.
|
||||
const kindWord = TRANSIT_MODE_WORD[mode] || TRANSIT_MODE_WORD.unknown;
|
||||
const kindWord = transitModeWord(mode, feed);
|
||||
const details = [`${kindWord} · ${feed.name} · ${feed.region}`];
|
||||
const motion = [];
|
||||
// What the SCREEN is doing leads, because that is what the reader can check.
|
||||
@@ -479,17 +482,50 @@ export const TRANSIT_MODE_ABBR = Object.freeze({
|
||||
unknown: 'TRANSIT',
|
||||
});
|
||||
|
||||
/**
|
||||
* The mode in the operator's own vocabulary. A Toronto 501 and a Helsinki 1010
|
||||
* are both the `tram` mode, but only one of them is called a tram by the people
|
||||
* who ride it. A feed may name the modes it runs; everything else falls through
|
||||
* to the shared table, so an override renames one word and nothing else.
|
||||
* @param {string} mode Resolved transit mode.
|
||||
* @param {object|null} [feed] Registry entry.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function transitModeWord(mode, feed = null) {
|
||||
return (
|
||||
feed?.modeWords?.[mode]?.word ||
|
||||
TRANSIT_MODE_WORD[mode] ||
|
||||
TRANSIT_MODE_WORD.unknown
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The operator's short mode word for the fixed-width detection fields.
|
||||
* @param {string} mode Resolved transit mode.
|
||||
* @param {object|null} [feed] Registry entry.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function transitModeAbbr(mode, feed = null) {
|
||||
return (
|
||||
feed?.modeWords?.[mode]?.abbr ||
|
||||
TRANSIT_MODE_ABBR[mode] ||
|
||||
TRANSIT_MODE_ABBR.unknown
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The bright first line of a vehicle's detection label: its route when the feed
|
||||
* names one, else the fleet number, else the raw id. What a person standing at
|
||||
* the stop would call it.
|
||||
* @param {object} record Normalized vehicle record.
|
||||
* @param {object|null} [feed] Registry entry, for feeds whose route ids are
|
||||
* internal keys rather than the number on the vehicle.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function transitDetectionId(record) {
|
||||
export function transitDetectionId(record, feed = null) {
|
||||
const route =
|
||||
typeof record?.routeId === 'string' ? record.routeId.trim() : '';
|
||||
if (route) return elideRouteText(route);
|
||||
if (route) return elideRouteText(transitRouteLabel(feed, route));
|
||||
const label = typeof record?.label === 'string' ? record.label.trim() : '';
|
||||
if (label) return elideRouteText(label);
|
||||
return elideRouteText(String(record?.id || 'TRANSIT'));
|
||||
@@ -521,7 +557,7 @@ export function elideRouteText(text) {
|
||||
* @returns {string}
|
||||
*/
|
||||
export function transitDetectionClass(mode, feed) {
|
||||
const kind = TRANSIT_MODE_ABBR[mode] || TRANSIT_MODE_ABBR.unknown;
|
||||
const kind = transitModeAbbr(mode, feed);
|
||||
const operator =
|
||||
typeof feed?.name === 'string' ? feed.name.toUpperCase() : '';
|
||||
return operator ? `${kind} ${operator}`.slice(0, 20) : kind;
|
||||
@@ -538,10 +574,17 @@ export function transitDetectionClass(mode, feed) {
|
||||
*
|
||||
* @param {object} record Normalized vehicle record.
|
||||
* @param {string} mode Resolved transit mode.
|
||||
* @param {number} [nowMs]
|
||||
* @param {object|null} [feed] Registry entry, for its own mode vocabulary.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function transitDetectionMetric(entry, mode, nowMs = Date.now()) {
|
||||
const kind = TRANSIT_MODE_ABBR[mode] || TRANSIT_MODE_ABBR.unknown;
|
||||
export function transitDetectionMetric(
|
||||
entry,
|
||||
mode,
|
||||
nowMs = Date.now(),
|
||||
feed = null,
|
||||
) {
|
||||
const kind = transitModeAbbr(mode, feed);
|
||||
const state = transitVehicleState(entry, nowMs);
|
||||
return state ? `${kind} ${state}` : kind;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,20 @@ import {
|
||||
} from '../../data/transitFeeds.js';
|
||||
import { transitModeTier } from '../../data/transitPresetStyle.js';
|
||||
|
||||
/**
|
||||
* The registry entry behind a vehicle. Live feeds answer first so a feed the
|
||||
* session disabled stops supplying vocabulary; the registry is the fallback,
|
||||
* because a vehicle can outlive the poll that brought it.
|
||||
* @param {object} state Layer state.
|
||||
* @param {object} entry Vehicle entry.
|
||||
* @returns {object|null}
|
||||
*/
|
||||
function feedForEntry(state, entry) {
|
||||
const id = entry?.feedId;
|
||||
if (!id) return null;
|
||||
return state._activeFeeds.get(id) || getRegisteredTransitFeed(id) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The layer row's answer to "what is on screen and is it honest?".
|
||||
* @param {object} context
|
||||
@@ -101,6 +115,7 @@ export function createQueries({ state, parts }) {
|
||||
contact._entry,
|
||||
contact._entry.mode,
|
||||
now,
|
||||
feedForEntry(state, contact._entry),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -128,14 +143,17 @@ export function createQueries({ state, parts }) {
|
||||
// Route and operator text change with the record and the mode, both of
|
||||
// which arrive on polls — the same events that rebuild this list.
|
||||
if (!entry.labelId || entry.labelRecord !== entry.record) {
|
||||
entry.labelId = transitDetectionId(entry.record);
|
||||
entry.labelId = transitDetectionId(
|
||||
entry.record,
|
||||
feedForEntry(state, entry),
|
||||
);
|
||||
entry.labelRecord = entry.record;
|
||||
}
|
||||
if (!entry.labelClass || entry.labelMode !== entry.mode) {
|
||||
const feed =
|
||||
state._activeFeeds.get(entry.feedId) ||
|
||||
getRegisteredTransitFeed(entry.feedId);
|
||||
entry.labelClass = transitDetectionClass(entry.mode, feed);
|
||||
entry.labelClass = transitDetectionClass(
|
||||
entry.mode,
|
||||
feedForEntry(state, entry),
|
||||
);
|
||||
entry.labelMode = entry.mode;
|
||||
}
|
||||
const contact = {
|
||||
@@ -146,7 +164,12 @@ export function createQueries({ state, parts }) {
|
||||
id: entry.labelId,
|
||||
type: 'VEH',
|
||||
klass: entry.labelClass,
|
||||
metric: transitDetectionMetric(entry, entry.mode, now),
|
||||
metric: transitDetectionMetric(
|
||||
entry,
|
||||
entry.mode,
|
||||
now,
|
||||
feedForEntry(state, entry),
|
||||
),
|
||||
// The bracket carries the mode colour in every preset: the detection
|
||||
// canvas sits above the post-FX chain, where the sprites' own colour
|
||||
// is lost under NVG and FLIR. Keyless too — the feeds are keyless.
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
buildTransitSelectionCopy,
|
||||
transitDetectionClass,
|
||||
transitDetectionId,
|
||||
transitDetectionMetric,
|
||||
transitModeAbbr,
|
||||
transitModeWord,
|
||||
} from './policy.js';
|
||||
import { getTransitFeed } from '../../data/transitFeeds.js';
|
||||
|
||||
const ttc = () => getTransitFeed('ttc-toronto');
|
||||
const hsl = () => getTransitFeed('hsl-helsinki');
|
||||
|
||||
test('an operator that names its own vehicles overrides the shared mode word', () => {
|
||||
// A Toronto 501 is a streetcar. A Helsinki 1010 is a tram. Both are the
|
||||
// `tram` mode, so the word a reader sees has to come from the operator,
|
||||
// not from the mode.
|
||||
assert.equal(transitModeWord('tram', ttc()), 'Streetcar');
|
||||
assert.equal(transitModeAbbr('tram', ttc()), 'STREETCAR');
|
||||
assert.equal(transitModeWord('tram', hsl()), 'Tram');
|
||||
assert.equal(transitModeAbbr('tram', hsl()), 'TRAM');
|
||||
});
|
||||
|
||||
test('an override renames one mode, never the rest of the feed', () => {
|
||||
assert.equal(transitModeWord('bus', ttc()), 'Bus');
|
||||
assert.equal(transitModeWord('subway', ttc()), 'Subway');
|
||||
assert.equal(transitModeAbbr('bus', ttc()), 'BUS');
|
||||
});
|
||||
|
||||
test('a missing feed or unknown mode falls back to the shared table', () => {
|
||||
assert.equal(transitModeWord('tram', null), 'Tram');
|
||||
assert.equal(transitModeWord('spaceship', ttc()), 'Transit vehicle');
|
||||
assert.equal(transitModeAbbr('spaceship', ttc()), 'TRANSIT');
|
||||
assert.equal(transitModeWord('tram', { name: 'No override' }), 'Tram');
|
||||
});
|
||||
|
||||
test('the selection card calls a Toronto 501 a streetcar', () => {
|
||||
const record = { id: '4400', routeId: '501', timestamp: 0 };
|
||||
const copy = buildTransitSelectionCopy(ttc(), record, 'tram', 0, null, null);
|
||||
assert.match(copy.details.join(' | '), /Streetcar · TTC/);
|
||||
assert.doesNotMatch(copy.details.join(' | '), /Tram/);
|
||||
});
|
||||
|
||||
test('detection labels carry the operator vocabulary within the field width', () => {
|
||||
const klass = transitDetectionClass('tram', ttc());
|
||||
assert.equal(klass, 'STREETCAR TTC');
|
||||
assert.ok(klass.length <= 20, 'fits the detection class field');
|
||||
assert.equal(transitDetectionClass('tram', hsl()), 'TRAM HSL');
|
||||
const metric = transitDetectionMetric({ mode: 'tram' }, 'tram', 0, ttc());
|
||||
assert.match(metric, /^STREETCAR/);
|
||||
});
|
||||
|
||||
test('a Guelph vehicle is labelled with the number on the bus, not the database key', () => {
|
||||
const guelph = getTransitFeed('guelph-transit');
|
||||
const record = { id: '196', routeId: '2991', timestamp: 0 };
|
||||
const copy = buildTransitSelectionCopy(guelph, record, 'bus', 0, null, null);
|
||||
assert.match(copy.title, /Route 1\b/);
|
||||
assert.doesNotMatch(copy.title, /2991/);
|
||||
assert.equal(transitDetectionId(record, guelph), '1');
|
||||
});
|
||||
|
||||
test('a feed that already speaks rider route numbers is untouched', () => {
|
||||
const record = { id: '4400', routeId: '501', timestamp: 0 };
|
||||
assert.equal(transitDetectionId(record, ttc()), '501');
|
||||
const copy = buildTransitSelectionCopy(ttc(), record, 'tram', 0, null, null);
|
||||
assert.match(copy.title, /Route 501/);
|
||||
// No feed at all still renders the raw route rather than throwing.
|
||||
assert.equal(transitDetectionId(record), '501');
|
||||
});
|
||||
|
||||
test('a vehicle with no route still falls back to its fleet number', () => {
|
||||
const guelph = getTransitFeed('guelph-transit');
|
||||
assert.equal(transitDetectionId({ id: '196', label: '196' }, guelph), '196');
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import { EARTHQUAKE_OVERLAY_COHORT_LIMIT } from '../data/earthquakes.js';
|
||||
import { ROCKET_MISSION_AMBIENT_OVERLAY_COHORT_LIMIT } from '../data/rocketLaunches.js';
|
||||
import { RADIO_OVERLAY_COHORT_LIMIT } from '../data/radio.js';
|
||||
import { CABLE_REFERENCE_LABEL_WINNER_CAP } from '../data/telegeographySubmarineCables.js';
|
||||
import { AIR_QUALITY_OVERLAY_COHORT_LIMIT } from '../layers/airQuality/model.js';
|
||||
import { isCalibratedAllocationRuntime } from '../../scripts/run-unit-tests.mjs';
|
||||
|
||||
/**
|
||||
@@ -34,6 +35,7 @@ import { isCalibratedAllocationRuntime } from '../../scripts/run-unit-tests.mjs'
|
||||
* ---------------------+---------+------------+---------+----------------+-------------+-------------
|
||||
* generic below cap | 60 | 60 | 60 | 3182 | 53.0 | 4,100
|
||||
* generic above cap | 250 | 250 | 96 | 10022 | 40.1 | 13,000
|
||||
* air quality | 48 | 48 | 32 | 3900 | 81.3 | 5,200
|
||||
* local infrastructure | 320 | 320 | 192 | 39478 | 123.4 | 49,000
|
||||
* Phase 3 + FIRMS | 338 | 338 | 210 | 41220 | 122.0 | 53,600
|
||||
* Phase 3 + vessels | 451 | 451 | 311 | 67252 | 149.1 | 87,500
|
||||
@@ -259,6 +261,23 @@ const WORKLOADS = [
|
||||
maxBytesPerFrame: 19_000,
|
||||
saturated: true,
|
||||
},
|
||||
{
|
||||
// The air-quality station cohort on its own. Measured standalone rather
|
||||
// than stacked on the Phase-5 host (the submarine-cable precedent): these
|
||||
// stations are sparse and, with the single registered provider,
|
||||
// continental, so what matters is its own cohort cost.
|
||||
//
|
||||
// Re-measured against the current worker: median 3,900 B/frame, max 4,114,
|
||||
// and 85.7 B/candidate at the ceiling — inside the shared 154 B/candidate
|
||||
// bound, so no per-source exception is taken. The 5,200 frame budget
|
||||
// carries 26.4% headroom over the measured maximum.
|
||||
name: 'with the air-quality station cohort live',
|
||||
profile: 'air-quality',
|
||||
entries: AIR_QUALITY_OVERLAY_COHORT_LIMIT,
|
||||
candidates: AIR_QUALITY_OVERLAY_COHORT_LIMIT,
|
||||
maxBytesPerFrame: 5_200,
|
||||
saturated: true,
|
||||
},
|
||||
{
|
||||
name: 'with the Dense detection lane active over 5,000 observations',
|
||||
profile: 'phase6-detection',
|
||||
|
||||
@@ -26,6 +26,12 @@ import {
|
||||
TRACKED_OVERLAY_SOURCE_OPTIONS,
|
||||
} from '../data/trackedReadout.js';
|
||||
import { CCTV_AMBIENT_CARD_MAX } from '../data/cctvLod.js';
|
||||
import {
|
||||
AIR_QUALITY_OVERLAY_COHORT_LIMIT,
|
||||
AIR_QUALITY_OVERLAY_COLLISION_CAPACITY,
|
||||
AIR_QUALITY_OVERLAY_SOURCE_ID,
|
||||
createAirQualityOverlayEntry,
|
||||
} from '../layers/airQuality/model.js';
|
||||
import {
|
||||
CCTV_OVERLAY_SOURCE_ID,
|
||||
createCctvThumbnailOverlayEntry,
|
||||
@@ -1101,6 +1107,68 @@ function buildSubmarineCablesWorkload(count) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The air-quality station cohort at full size.
|
||||
*
|
||||
* Measured standalone (the submarine-cable precedent) rather than stacked on
|
||||
* the Phase-5 host: air-quality stations are sparse and, with the single
|
||||
* currently-registered provider, continental — so the question a budget has to
|
||||
* answer is what its own cohort costs, not what it adds to a worldwide worst
|
||||
* case it never shares a viewport with. The entry shape is the same static
|
||||
* `ambient-label` the earthquake layer publishes, so the per-candidate ceiling
|
||||
* is the meaningful gate.
|
||||
*/
|
||||
function buildAirQualityWorkload(count) {
|
||||
if (count !== AIR_QUALITY_OVERLAY_COHORT_LIMIT) {
|
||||
throw new Error(
|
||||
`air-quality requires ${AIR_QUALITY_OVERLAY_COHORT_LIMIT} entries`,
|
||||
);
|
||||
}
|
||||
const workload = {
|
||||
entries: [],
|
||||
positions: [],
|
||||
drifts: [],
|
||||
registrations: [],
|
||||
};
|
||||
const columns = Math.max(1, Math.ceil(Math.sqrt((count * 16) / 9)));
|
||||
const rows = Math.max(1, Math.ceil(count / columns));
|
||||
const spanX = columns > 1 ? 1.68 / (columns - 1) : 0;
|
||||
const spanY = rows > 1 ? 1.56 / (rows - 1) : 0;
|
||||
const stations = [];
|
||||
for (let index = 0; index < count; index++) {
|
||||
const column = index % columns;
|
||||
const row = Math.floor(index / columns);
|
||||
const baseX = -0.84 + column * spanX;
|
||||
const baseY = -0.78 + row * spanY;
|
||||
const position = new Cesium.Cartesian3(baseX, baseY, 0);
|
||||
workload.positions.push(position);
|
||||
workload.drifts.push({
|
||||
baseX,
|
||||
baseY,
|
||||
phase: index * 0.27,
|
||||
rate: 0.32 + (index % 6) * 0.04,
|
||||
});
|
||||
const entry = createAirQualityOverlayEntry({
|
||||
id: `air-${index}`,
|
||||
position,
|
||||
title: `Station ${index} ${1 + (index % 10)}`,
|
||||
accent: '#4cc9f0',
|
||||
value: 1 + (index % 10),
|
||||
});
|
||||
entry.horizonCull = false;
|
||||
stations.push(entry);
|
||||
}
|
||||
workload.registrations.push({
|
||||
sourceId: AIR_QUALITY_OVERLAY_SOURCE_ID,
|
||||
entries: stations,
|
||||
options: {
|
||||
cohortLimit: AIR_QUALITY_OVERLAY_COHORT_LIMIT,
|
||||
collisionCapacity: AIR_QUALITY_OVERLAY_COLLISION_CAPACITY,
|
||||
},
|
||||
});
|
||||
return workload;
|
||||
}
|
||||
|
||||
function buildPhase6DetectionWorkload(count) {
|
||||
const random = makeRandom(0xd37ec710);
|
||||
const positions = [];
|
||||
@@ -1206,9 +1274,13 @@ function main() {
|
||||
? buildPhase5RocketMissionWorkload(ENTRY_COUNT)
|
||||
: PROFILE === 'all-live-radio'
|
||||
? buildAllLiveRadioWorkload(ENTRY_COUNT)
|
||||
: PROFILE === 'submarine-cables'
|
||||
? buildSubmarineCablesWorkload(ENTRY_COUNT)
|
||||
: buildWorkload(ENTRY_COUNT);
|
||||
: PROFILE === 'air-quality'
|
||||
? buildAirQualityWorkload(ENTRY_COUNT)
|
||||
: PROFILE === 'submarine-cables'
|
||||
? buildSubmarineCablesWorkload(
|
||||
ENTRY_COUNT,
|
||||
)
|
||||
: buildWorkload(ENTRY_COUNT);
|
||||
const { entries, positions, drifts } = workload;
|
||||
const solveIntervalMs = Number(process.env.GEV_ALLOC_SOLVE_MS) || 125;
|
||||
const detectionActive = !!workload.detectionLayer;
|
||||
|
||||
@@ -55,7 +55,9 @@ export async function readResponseJsonCapped(response, maxBytes, signal) {
|
||||
|
||||
/**
|
||||
* Read a fetch() Response body as bytes with the same hard cap as
|
||||
* readResponseTextCapped — for protobuf upstreams (GTFS-Realtime).
|
||||
* readResponseTextCapped — for protobuf and tile upstreams (GTFS-Realtime,
|
||||
* TomTom flow). Same early Content-Length rejection, running cap while
|
||||
* streaming and cancellation on abort, so callers can treat the two alike.
|
||||
* Throws { code:'RESPONSE_TOO_LARGE' }.
|
||||
*/
|
||||
export async function readResponseBytesCapped(response, maxBytes, signal) {
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readResponseBytesCapped } from './httpBody.js';
|
||||
|
||||
/**
|
||||
* A Response that streams `chunk` forever until the reader cancels it. The
|
||||
* zero high-water mark keeps the stream from pulling eagerly at construction,
|
||||
* so `pulls()` counts only what a reader actually asked for.
|
||||
*/
|
||||
function endlessResponse(chunk, onCancel, headers = {}) {
|
||||
let pulls = 0;
|
||||
const stream = new ReadableStream(
|
||||
{
|
||||
pull(controller) {
|
||||
pulls += 1;
|
||||
controller.enqueue(chunk);
|
||||
},
|
||||
cancel: onCancel,
|
||||
},
|
||||
{ highWaterMark: 0 },
|
||||
);
|
||||
return { response: new Response(stream, { headers }), pulls: () => pulls };
|
||||
}
|
||||
|
||||
function chunkedResponse(chunks, headers = {}) {
|
||||
const stream = new ReadableStream(
|
||||
{
|
||||
start(controller) {
|
||||
for (const chunk of chunks) controller.enqueue(chunk);
|
||||
controller.close();
|
||||
},
|
||||
},
|
||||
{ highWaterMark: 0 },
|
||||
);
|
||||
return new Response(stream, { headers });
|
||||
}
|
||||
|
||||
const bytes = (n, fill) => new Uint8Array(n).fill(fill);
|
||||
|
||||
test('returns the exact bytes of a body that fits, untouched by text decoding', async () => {
|
||||
// 0xFF and 0xFE are invalid UTF-8 lead bytes; a text path would mangle them.
|
||||
const payload = Uint8Array.from([0xff, 0xfe, 0x00, 0x7f, 0x80, 0x01]);
|
||||
const buf = await readResponseBytesCapped(chunkedResponse([payload]), 1024);
|
||||
assert.ok(buf instanceof Uint8Array);
|
||||
assert.deepEqual([...buf], [...payload]);
|
||||
});
|
||||
|
||||
test('joins chunks in order and reports the total length', async () => {
|
||||
const buf = await readResponseBytesCapped(
|
||||
chunkedResponse([bytes(3, 1), bytes(2, 2), bytes(1, 3)]),
|
||||
1024,
|
||||
);
|
||||
assert.deepEqual([...buf], [1, 1, 1, 2, 2, 3]);
|
||||
});
|
||||
|
||||
test('refuses on a declared Content-Length without pulling the body', async () => {
|
||||
let cancelled = false;
|
||||
const { response, pulls } = endlessResponse(
|
||||
bytes(512, 0),
|
||||
() => {
|
||||
cancelled = true;
|
||||
},
|
||||
{ 'content-length': '4096' },
|
||||
);
|
||||
await assert.rejects(readResponseBytesCapped(response, 1024), {
|
||||
code: 'RESPONSE_TOO_LARGE',
|
||||
});
|
||||
assert.equal(pulls(), 0);
|
||||
assert.ok(cancelled);
|
||||
});
|
||||
|
||||
test('refuses a chunked body the moment it passes the cap and stops pulling', async () => {
|
||||
let cancelled = false;
|
||||
const { response, pulls } = endlessResponse(bytes(512, 0), () => {
|
||||
cancelled = true;
|
||||
});
|
||||
await assert.rejects(readResponseBytesCapped(response, 1024), {
|
||||
code: 'RESPONSE_TOO_LARGE',
|
||||
});
|
||||
// Three 512-byte chunks is the first total over 1024; an uncapped read of
|
||||
// this never-ending stream would not terminate at all.
|
||||
assert.ok(pulls() <= 3, `pulled ${pulls()} chunks`);
|
||||
assert.ok(cancelled);
|
||||
});
|
||||
|
||||
test('accepts a body exactly at the cap', async () => {
|
||||
const buf = await readResponseBytesCapped(
|
||||
chunkedResponse([bytes(512, 7), bytes(512, 7)]),
|
||||
1024,
|
||||
);
|
||||
assert.equal(buf.byteLength, 1024);
|
||||
});
|
||||
|
||||
test('cancels a body still streaming when the signal aborts, and never returns a partial one', async () => {
|
||||
const controller = new AbortController();
|
||||
let cancelled = false;
|
||||
let pulls = 0;
|
||||
const stream = new ReadableStream(
|
||||
{
|
||||
pull(ctrl) {
|
||||
pulls += 1;
|
||||
ctrl.enqueue(bytes(64, 0));
|
||||
if (pulls === 2) controller.abort();
|
||||
},
|
||||
cancel() {
|
||||
cancelled = true;
|
||||
},
|
||||
},
|
||||
{ highWaterMark: 0 },
|
||||
);
|
||||
await assert.rejects(
|
||||
readResponseBytesCapped(new Response(stream), 1 << 20, controller.signal),
|
||||
);
|
||||
assert.ok(cancelled);
|
||||
assert.ok(pulls <= 3, `pulled ${pulls} chunks after abort`);
|
||||
});
|
||||
|
||||
test('refuses immediately when the signal is already aborted', async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const { response, pulls } = endlessResponse(bytes(8, 0), () => {});
|
||||
await assert.rejects(
|
||||
readResponseBytesCapped(response, 1024, controller.signal),
|
||||
);
|
||||
assert.equal(pulls(), 0);
|
||||
});
|
||||
|
||||
test('falls back to arrayBuffer() for a body with no stream reader', async () => {
|
||||
const response = new Response(bytes(16, 9));
|
||||
const buf = await readResponseBytesCapped(response, 1024);
|
||||
assert.equal(buf.byteLength, 16);
|
||||
assert.equal(buf[0], 9);
|
||||
});
|
||||
@@ -7,10 +7,12 @@ import {
|
||||
finite,
|
||||
} from './contract.js';
|
||||
|
||||
const openSkyId = (row) =>
|
||||
Array.isArray(row) ? cleanText(row[0]).toLowerCase() : '';
|
||||
|
||||
/** Normalize the OpenSky state-vector route, including its regional fallback. */
|
||||
export function normalizeOpenSkyAircraft(row) {
|
||||
if (!Array.isArray(row)) return null;
|
||||
const id = cleanText(row[0]).toLowerCase();
|
||||
const id = openSkyId(row);
|
||||
if (!id || !coordinates(row[6], row[5])) return null;
|
||||
return {
|
||||
id,
|
||||
@@ -77,6 +79,16 @@ export function normalizeReadsbAircraft(row, snapshotTimeMs) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenSky allows null longitude/latitude for a contact that is still
|
||||
* broadcasting. Strict nulls also reject rows truncated before the position.
|
||||
*/
|
||||
function openSkyUnpositionedId(row) {
|
||||
return Array.isArray(row) && row[5] === null && row[6] === null
|
||||
? openSkyId(row)
|
||||
: '';
|
||||
}
|
||||
|
||||
export function openSkySnapshot(
|
||||
payload,
|
||||
{
|
||||
@@ -96,6 +108,7 @@ export function openSkySnapshot(
|
||||
return record;
|
||||
},
|
||||
'OpenSky',
|
||||
openSkyUnpositionedId,
|
||||
);
|
||||
const observedAtMs = epoch(payload?.time, 1000);
|
||||
const ageMs = observedAtMs == null ? null : Math.max(0, now - observedAtMs);
|
||||
|
||||
@@ -53,17 +53,30 @@ export function coordinates(latitude, longitude) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Admit a snapshot atomically; an all-invalid nonempty feed is unavailable. */
|
||||
export function admitRecords(rows, normalize, label) {
|
||||
/**
|
||||
* Admit a snapshot atomically; an all-invalid nonempty feed is unavailable.
|
||||
* `unpositionedId(row)` names a rejected row that is a valid contact without
|
||||
* a position: it is returned in `retainIds` and does not make the snapshot
|
||||
* incomplete.
|
||||
*/
|
||||
export function admitRecords(rows, normalize, label, unpositionedId) {
|
||||
if (!Array.isArray(rows))
|
||||
throw new LiveSourceError('malformed', `Malformed ${label} response`);
|
||||
const records = [];
|
||||
const ids = new Set();
|
||||
const retainIds = new Set();
|
||||
let unpositioned = 0;
|
||||
for (const row of rows) {
|
||||
const record = normalize(row);
|
||||
if (record && !ids.has(record.id)) {
|
||||
records.push(record);
|
||||
ids.add(record.id);
|
||||
} else if (!record) {
|
||||
const id = unpositionedId?.(row);
|
||||
if (id) {
|
||||
retainIds.add(id);
|
||||
unpositioned += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (rows.length && !records.length) {
|
||||
@@ -71,7 +84,8 @@ export function admitRecords(rows, normalize, label) {
|
||||
}
|
||||
return {
|
||||
records,
|
||||
complete: records.length === rows.length,
|
||||
retainIds,
|
||||
complete: records.length + unpositioned === rows.length,
|
||||
rejectedCount: rows.length - records.length,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -67,6 +67,50 @@ test('mixed-invalid and duplicate admissions are explicitly incomplete; all-inva
|
||||
assert.equal(openSkySnapshot({ states: [] }).complete, true);
|
||||
});
|
||||
|
||||
test('position-less OpenSky rows are seen, not missing; malformed rows stay incomplete', () => {
|
||||
// OpenSky's spec allows null longitude/latitude; every live /states/all
|
||||
// carries such rows.
|
||||
const unpositioned = ['DEF456', ...aircraft.slice(1)];
|
||||
unpositioned[5] = null;
|
||||
unpositioned[6] = null;
|
||||
const snapshot = openSkySnapshot({ states: [aircraft, unpositioned] });
|
||||
assert.equal(snapshot.records.length, 1);
|
||||
assert.equal(snapshot.complete, true);
|
||||
assert.deepEqual([...snapshot.retainIds], ['def456']);
|
||||
assert.equal(snapshot.rejectedCount, 1);
|
||||
const malformed = [...aircraft];
|
||||
malformed[5] = 181;
|
||||
const garbled = openSkySnapshot({
|
||||
states: [aircraft, unpositioned, malformed],
|
||||
});
|
||||
assert.equal(garbled.complete, false);
|
||||
assert.deepEqual([...garbled.retainIds], ['def456']);
|
||||
// No usable id, only half a position, a truncated row, or no row at all.
|
||||
for (const row of [
|
||||
[' ', ...unpositioned.slice(1)],
|
||||
Object.assign([...unpositioned], { 5: -97 }),
|
||||
['DEF456'],
|
||||
null,
|
||||
])
|
||||
assert.equal(openSkySnapshot({ states: [aircraft, row] }).complete, false);
|
||||
// Repeated position-less rows stay complete.
|
||||
const repeated = openSkySnapshot({
|
||||
states: [aircraft, unpositioned, unpositioned],
|
||||
});
|
||||
assert.equal(repeated.complete, true);
|
||||
// A feed with no positions at all still cannot prove absence.
|
||||
assert.throws(() => openSkySnapshot({ states: [unpositioned] }), {
|
||||
code: 'malformed',
|
||||
});
|
||||
// Military readsb admission keeps its existing semantics.
|
||||
const military = readsbSnapshot(
|
||||
{ ac: [{ hex: 'abc123', lat: 30, lon: -97 }, { hex: 'def456' }] },
|
||||
{ observedAtMs: null },
|
||||
);
|
||||
assert.equal(military.complete, false);
|
||||
assert.equal(military.retainIds.size, 0);
|
||||
});
|
||||
|
||||
test('readsb units and position/contact ages are normalized once against the source epoch', () => {
|
||||
const row = normalizeReadsbAircraft(
|
||||
{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createUsgsEarthquakeSource } from '../layers/earthquakes/source.js';
|
||||
import { createEcccAirQualitySource } from '../layers/airQuality/source.js';
|
||||
import { createWfigsPerimeterSource } from '../layers/perimeters/source.js';
|
||||
import { createBundledCableSource } from '../layers/submarineCables/bundledSource.js';
|
||||
import { createAdsbGnssSource } from '../layers/gnss/source.js';
|
||||
@@ -7,6 +8,7 @@ import { createAdsbGnssSource } from '../layers/gnss/source.js';
|
||||
export function createReferenceSources() {
|
||||
return {
|
||||
earthquakes: createUsgsEarthquakeSource(),
|
||||
'air-quality': createEcccAirQualitySource(),
|
||||
'fire-perimeters': createWfigsPerimeterSource(),
|
||||
cables: createBundledCableSource(),
|
||||
'gnss-integrity': createAdsbGnssSource(),
|
||||
|
||||
@@ -14,15 +14,18 @@ test('reference factories retain compatibility without starting acquisition or s
|
||||
const second = createReferenceSources();
|
||||
assert.deepEqual(Object.keys(first), [
|
||||
'earthquakes',
|
||||
'air-quality',
|
||||
'fire-perimeters',
|
||||
'cables',
|
||||
'gnss-integrity',
|
||||
]);
|
||||
assert.notEqual(first.earthquakes, second.earthquakes);
|
||||
assert.notEqual(first['air-quality'], second['air-quality']);
|
||||
assert.notEqual(first['fire-perimeters'], second['fire-perimeters']);
|
||||
assert.notEqual(first.cables, second.cables);
|
||||
assert.notEqual(first['gnss-integrity'], second['gnss-integrity']);
|
||||
assert.equal(typeof first.earthquakes.getSnapshot, 'function');
|
||||
assert.equal(typeof first['air-quality'].getSnapshot, 'function');
|
||||
assert.equal(typeof first['fire-perimeters'].getSnapshot, 'function');
|
||||
assert.equal(typeof first.cables.fetch, 'function');
|
||||
assert.equal(typeof first['gnss-integrity'].getSnapshot, 'function');
|
||||
|
||||
@@ -328,8 +328,12 @@ export function createTransitHistory({
|
||||
t > now + 10000
|
||||
)
|
||||
continue;
|
||||
const byVehicle = feed.vehicleMode?.(record.id);
|
||||
const mode =
|
||||
feed.routeMode?.(record.routeId) || feed.defaultMode || 'unknown';
|
||||
(byVehicle !== 'unknown' && byVehicle) ||
|
||||
feed.routeMode?.(record.routeId) ||
|
||||
feed.defaultMode ||
|
||||
'unknown';
|
||||
const context = contextOf(record, mode);
|
||||
if (!context) continue;
|
||||
const key = `${feed.id}\u0000${record.id}`;
|
||||
|
||||
Loaded 100 of 154 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user