mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-12 14:22:21 +00:00
fix(cctv): key-gate Ottawa, opt-in Québec video, keep shared slots
Review fixes for #1506. - Ottawa: use the documented camera list and the city's Traffic Camera Images API. The pack is skipped with no request unless OTTAWA_CAMERA_CERTIFICATE is set; the certificate is added only at fetch time inside the pinned /opendata/ path, and each camera is fetched at most once a minute through the shared frame cache. - Québec 511 video is opt-in (CCTV_QUEBEC_VIDEO_ENABLED=1) and loads only after the viewer presses Play; selection, auto-hop, scenes and voice no longer contact Québec 511. Credit splits locations (MTQ, CC BY 4.0) from video (Québec 511). Interrupted play() is no longer reported as an unavailable source; the per-frame listener runs only while a Québec camera is selected and the canvas rect is cached. - Catalog cap: reserved Québec and World slots come only from capacity above the 4,000 shared slots, so a lower CCTV_MAX_SOURCES thins every pack instead of starving the existing ones. dev-fresh and .env.example default to 7,516. - Throttled frames (World, Ottawa): inside the refresh floor with no cached frame the proxy serves the placeholder, never Street View; a failed fetch backs off for a minute; the cache is bounded by bytes. - MJPEG first-frame extraction runs only for sources registered as MJPEG (Taiwan Highway Bureau), caps a frame at 2 MB, reads in linear time, accepts a CRLF preamble and a boundary that already starts with "--", and ends a part without Content-Length at the next delimiter. THB hosts are registered in the redirect pin list. - Tests: World loader policy, Québec loader and opt-in, proxy cooldown without Street View, Ottawa signing, cap reservations at 7,516, 4,000 and 48, overlay Play gate, refresh, visibility and teardown. Guard tests check the 4,000 shared slots again. - Remove the unused standalone Québec preview module and public page. - Docs: DATA_SOURCES, SECURITY, CURRENT-STATE, CCTV_INTEGRATION, README, .env.example, changelog fragment renamed to its full scope.
This commit is contained in:
1 parent
fa4ce4b2a5
commit
db8afbae81
43 files changed
+1327
-1110
No files matched your search
@@ -12,7 +12,7 @@ import {
|
||||
CALGARY_IMAGE_ORIGIN,
|
||||
CALGARY_MAX_CATALOG_BYTES,
|
||||
DEFAULT_CALGARY_ROWS_URL,
|
||||
DEFAULT_CCTV_MAX_SOURCES,
|
||||
CCTV_SHARED_SOURCE_SLOTS,
|
||||
} 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';
|
||||
@@ -384,6 +384,6 @@ test('CCTV_CALGARY_ENABLED=0 keeps the lane from being loaded at all', async (t)
|
||||
}
|
||||
});
|
||||
|
||||
test('the default catalog preserves shared, Québec, and World image slots', () => {
|
||||
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 7516);
|
||||
test('the shared catalog slots are not raised to make room for this pack', () => {
|
||||
assert.equal(CCTV_SHARED_SOURCE_SLOTS, 4000);
|
||||
});
|
||||
@@ -6,9 +6,12 @@ import {
|
||||
} from '../../server/providers/cctv/cap.js';
|
||||
import {
|
||||
CCTV_MAX_SOURCES_CEILING,
|
||||
CCTV_SHARED_SOURCE_SLOTS,
|
||||
CCTV_SOURCE_CACHE_MS,
|
||||
DEFAULT_CCTV_MAX_SOURCES,
|
||||
WORLD_CCTV_SOURCE_COUNT,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import { QUEBEC_CAMERA_SOURCE_COUNT } from '../../server/providers/cctv/quebec.js';
|
||||
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
|
||||
|
||||
const pack = (name, count, prefix = name) => ({
|
||||
@@ -99,6 +102,68 @@ test('duplicate ids resolve last-pack-wins and count against the winning pack',
|
||||
]);
|
||||
});
|
||||
|
||||
/** Shared packs plus two reserved ones, shaped like the default catalog. */
|
||||
const reservedFixture = () => [
|
||||
...Array.from({ length: 20 }, (_, i) => pack(`shared${i}`, 250)),
|
||||
{ ...pack('quebec', 1120), reservedCount: 1120 },
|
||||
{ ...pack('world', 2396), reservedCount: 2396 },
|
||||
];
|
||||
const keptByName = (packs) =>
|
||||
Object.fromEntries(packs.map(({ name, kept }) => [name, kept]));
|
||||
|
||||
test('the default cap is the shared floor plus the reserved Québec and World packs', () => {
|
||||
assert.equal(
|
||||
DEFAULT_CCTV_MAX_SOURCES,
|
||||
CCTV_SHARED_SOURCE_SLOTS +
|
||||
QUEBEC_CAMERA_SOURCE_COUNT +
|
||||
WORLD_CCTV_SOURCE_COUNT,
|
||||
);
|
||||
});
|
||||
|
||||
test('at the default cap reserved packs are whole and shared packs keep the floor', () => {
|
||||
const { sources, packs } = allocateSourceCap(
|
||||
reservedFixture(),
|
||||
DEFAULT_CCTV_MAX_SOURCES,
|
||||
);
|
||||
const kept = keptByName(packs);
|
||||
assert.equal(sources.length, DEFAULT_CCTV_MAX_SOURCES);
|
||||
assert.equal(kept.quebec, 1120);
|
||||
assert.equal(kept.world, 2396);
|
||||
for (let i = 0; i < 20; i++) assert.equal(kept[`shared${i}`], 200);
|
||||
assert.match(
|
||||
sources[0].id,
|
||||
/^shared0-/,
|
||||
'the first camera stays in a shared pack',
|
||||
);
|
||||
});
|
||||
|
||||
test('a cap at the shared floor reserves nothing and thins every pack evenly', () => {
|
||||
const { packs } = allocateSourceCap(reservedFixture(), 4000);
|
||||
const kept = keptByName(packs);
|
||||
// 22 lanes share 4,000 round-robin: nobody is wiped out by the reservation.
|
||||
for (const count of Object.values(kept))
|
||||
assert.ok(count >= 181 && count <= 182);
|
||||
});
|
||||
|
||||
test('a small cap (dev-secure 48) still reaches the shared packs first', () => {
|
||||
const { sources, packs } = allocateSourceCap(reservedFixture(), 48);
|
||||
const kept = keptByName(packs);
|
||||
assert.equal(sources.length, 48);
|
||||
for (let i = 0; i < 20; i++) assert.ok(kept[`shared${i}`] >= 2);
|
||||
assert.match(sources[0].id, /^shared0-/);
|
||||
});
|
||||
|
||||
test('reservations fill only the room above the floor, round-robin between reserved packs', () => {
|
||||
const { packs } = allocateSourceCap(reservedFixture(), 4100);
|
||||
const kept = keptByName(packs);
|
||||
const shared = Object.entries(kept)
|
||||
.filter(([name]) => name.startsWith('shared'))
|
||||
.reduce((sum, [, count]) => sum + count, 0);
|
||||
// 100 reserved slots split between Québec and World, then 4,000 round-robin.
|
||||
assert.ok(shared >= 4000 - 2 * 182, `shared packs kept ${shared}`);
|
||||
assert.equal(shared + kept.quebec + kept.world, 4100);
|
||||
});
|
||||
|
||||
test('resolveCatalogCap bounds the env value and falls back to the default', () => {
|
||||
assert.equal(resolveCatalogCap(undefined), DEFAULT_CCTV_MAX_SOURCES);
|
||||
assert.equal(resolveCatalogCap('nonsense'), DEFAULT_CCTV_MAX_SOURCES);
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
DEFAULT_FINTRAFFIC_MAX_SOURCES,
|
||||
DEFAULT_TXDOT_MAX_SOURCES,
|
||||
DEFAULT_OTTAWA_MAX_SOURCES,
|
||||
CCTV_SHARED_SOURCE_SLOTS,
|
||||
DEFAULT_TALLINN_MAX_SOURCES,
|
||||
DEFAULT_TARKTEE_MAX_SOURCES,
|
||||
DEFAULT_NSW_MAX_SOURCES,
|
||||
@@ -223,7 +224,7 @@ test('CCTV catalog merges DriveBC cameras and CCTV_DRIVEBC_ENABLED=0 skips the r
|
||||
);
|
||||
});
|
||||
|
||||
test('default caps of the original packs fit inside the default catalog cap', () => {
|
||||
test('default caps of the original packs fit inside the shared catalog slots', () => {
|
||||
// Every default pack at its own cap (Warendorf ships 1 curated camera).
|
||||
const packs =
|
||||
DEFAULT_AUSTIN_MAX_SOURCES +
|
||||
@@ -239,9 +240,11 @@ test('default caps of the original packs fit inside the default catalog cap', ()
|
||||
DEFAULT_NSW_MAX_SOURCES +
|
||||
DEFAULT_CATALONIA_MAX_SOURCES +
|
||||
1;
|
||||
// The reserved Québec and World packs sit above the shared floor, so the
|
||||
// shared packs must fit the floor itself, not the whole default cap.
|
||||
assert.ok(
|
||||
packs <= DEFAULT_CCTV_MAX_SOURCES,
|
||||
`default packs (${packs}) would be thinned by the catalog cap (${DEFAULT_CCTV_MAX_SOURCES})`,
|
||||
packs <= CCTV_SHARED_SOURCE_SLOTS,
|
||||
`default packs (${packs}) would be thinned by the shared slots (${CCTV_SHARED_SOURCE_SLOTS})`,
|
||||
);
|
||||
assert.ok(
|
||||
DEFAULT_CCTV_MAX_SOURCES <= CCTV_MAX_SOURCES_CEILING,
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
parseHkCameraLocationsXml,
|
||||
} from '../../server/providers/cctv/sources.js';
|
||||
import {
|
||||
DEFAULT_CCTV_MAX_SOURCES,
|
||||
CCTV_SHARED_SOURCE_SLOTS,
|
||||
DEFAULT_HK_CAMERAS_URL,
|
||||
DEFAULT_HK_MAX_SOURCES,
|
||||
HK_ANCHORS,
|
||||
@@ -396,8 +396,8 @@ test('CCTV_HK_ENABLED=0 keeps the lane from being loaded at all', async (t) => {
|
||||
}
|
||||
});
|
||||
|
||||
test('the default catalog preserves shared, Québec, and World image slots', () => {
|
||||
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 7516);
|
||||
test('the shared catalog slots are not raised to make room for this pack', () => {
|
||||
assert.equal(CCTV_SHARED_SOURCE_SLOTS, 4000);
|
||||
});
|
||||
|
||||
test('the HK pack default sits above the full TD catalog and anchors the New Territories', () => {
|
||||
@@ -406,7 +406,7 @@ test('the HK pack default sits above the full TD catalog and anchors the New Ter
|
||||
'default cap should keep nearly all published TD cameras',
|
||||
);
|
||||
assert.ok(
|
||||
DEFAULT_HK_MAX_SOURCES <= DEFAULT_CCTV_MAX_SOURCES,
|
||||
DEFAULT_HK_MAX_SOURCES <= CCTV_SHARED_SOURCE_SLOTS,
|
||||
'a single pack must not outgrow the catalog-wide ceiling alone',
|
||||
);
|
||||
const places = HK_ANCHORS.map(
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
nycCameraToSource,
|
||||
} from '../../server/providers/cctv/sources.js';
|
||||
import {
|
||||
DEFAULT_CCTV_MAX_SOURCES,
|
||||
CCTV_SHARED_SOURCE_SLOTS,
|
||||
DEFAULT_NYC_MAX_SOURCES,
|
||||
NYC_CAMERAS_URL,
|
||||
NYC_GROUND_ELEVATION_M,
|
||||
@@ -375,8 +375,8 @@ for (const [label, flag] of [
|
||||
});
|
||||
}
|
||||
|
||||
test('the default catalog preserves shared, Québec, and World image slots', () => {
|
||||
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 7516);
|
||||
test('the shared catalog slots are not raised to make room for this pack', () => {
|
||||
assert.equal(CCTV_SHARED_SOURCE_SLOTS, 4000);
|
||||
assert.equal(DEFAULT_NYC_MAX_SOURCES, 250);
|
||||
});
|
||||
|
||||
|
||||
@@ -12,7 +12,13 @@ import {
|
||||
fallbackHeadingFromId,
|
||||
pinnedCameraUrl,
|
||||
} from '../../server/providers/cctv/normalize.js';
|
||||
import { loadOttawaSourcesFromOpenData } from '../../server/providers/cctv/ottawa.js';
|
||||
import {
|
||||
_resetOttawaCertificateWarningForTest,
|
||||
loadOttawaSourcesFromOpenData,
|
||||
ottawaAuthorizedFrameUrl,
|
||||
} from '../../server/providers/cctv/ottawa.js';
|
||||
|
||||
const CERT = { OTTAWA_CAMERA_CERTIFICATE: 'CERT123' };
|
||||
|
||||
/** Set (or, for `undefined`, delete) environment variables for one test. */
|
||||
function withEnv(t, env) {
|
||||
@@ -33,35 +39,77 @@ function quiet(t) {
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
}
|
||||
|
||||
/** One `/map/camera_list` row carrying the fields the loader reads. */
|
||||
function ottawaRow(id, lat, lon, overrides = {}) {
|
||||
/** One documented `/map/service/camera` row carrying the fields the loader reads. */
|
||||
function ottawaRow(number, lat, lon, overrides = {}) {
|
||||
return {
|
||||
id,
|
||||
number: 2000 + id,
|
||||
description: `Camera ${id}`,
|
||||
descriptionFr: `Caméra ${id}`,
|
||||
type: 'City',
|
||||
id: number + 10000,
|
||||
camera_number: number,
|
||||
name: `Camera ${number}`,
|
||||
name_french: `Caméra ${number}`,
|
||||
cameraOwner: 'CITY',
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('Ottawa loader keeps in-city cameras and builds frame URLs from the id', async (t) => {
|
||||
/** The feed's envelope around its rows. */
|
||||
const ottawaPayload = (rows) => Response.json({ cameras: rows });
|
||||
|
||||
test('without OTTAWA_CAMERA_CERTIFICATE the Ottawa pack is skipped with no request, logged once', async (t) => {
|
||||
_resetOttawaCertificateWarningForTest();
|
||||
const lines = [];
|
||||
t.mock.method(console, 'warn', (line) => lines.push(String(line)));
|
||||
const fetchMock = t.mock.method(globalThis, 'fetch', async () => {
|
||||
throw new Error('no request expected');
|
||||
});
|
||||
assert.deepEqual(await loadOttawaSourcesFromOpenData({}, {}), []);
|
||||
assert.deepEqual(
|
||||
await loadOttawaSourcesFromOpenData({}, { OTTAWA_CAMERA_CERTIFICATE: ' ' }),
|
||||
[],
|
||||
);
|
||||
assert.equal(fetchMock.mock.callCount(), 0);
|
||||
assert.equal(lines.length, 1);
|
||||
assert.match(lines[0], /OTTAWA_CAMERA_CERTIFICATE/);
|
||||
});
|
||||
|
||||
test('Ottawa frame URLs are signed only inside the opendata camera path', () => {
|
||||
assert.equal(
|
||||
ottawaAuthorizedFrameUrl('https://traffic.ottawa.ca/opendata/camera?c=16', CERT),
|
||||
'https://traffic.ottawa.ca/opendata/camera?c=16&certificate=CERT123&id=gev',
|
||||
);
|
||||
assert.equal(
|
||||
ottawaAuthorizedFrameUrl('https://traffic.ottawa.ca/opendata/camera?c=16', {}),
|
||||
'',
|
||||
'no certificate, no signed URL',
|
||||
);
|
||||
for (const url of [
|
||||
'https://traffic.ottawa.ca/opendata/camera?c=16x',
|
||||
'https://traffic.ottawa.ca/opendata/other?c=16',
|
||||
'https://traffic.ottawa.ca/map/camera?id=16',
|
||||
'https://traffic.ottawa.ca.evil.test/opendata/camera?c=16',
|
||||
'http://traffic.ottawa.ca/opendata/camera?c=16',
|
||||
])
|
||||
assert.equal(ottawaAuthorizedFrameUrl(url, CERT), '', url);
|
||||
});
|
||||
|
||||
test('Ottawa loader keeps in-city cameras and builds keyless frame URLs from the camera number', async (t) => {
|
||||
quiet(t);
|
||||
withEnv(t, { CCTV_OTTAWA_MAX_SOURCES: undefined });
|
||||
const requested = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
requested.push(String(url));
|
||||
return Response.json([
|
||||
ottawaRow(309, 45.341343, -75.81467, {
|
||||
description: '(MTO) Hwy 416 NB ramp to Hwy 417 East',
|
||||
type: 'MTO',
|
||||
return ottawaPayload([
|
||||
ottawaRow(2025, 45.341343, -75.81467, {
|
||||
name: '(MTO) Hwy 416 NB ramp to Hwy 417 East',
|
||||
cameraOwner: 'MTO',
|
||||
}),
|
||||
ottawaRow(1, 45.4215, -75.6972, {
|
||||
description: 'Bank St at Somerset St',
|
||||
type: 'City',
|
||||
name: 'Bank St at Somerset St',
|
||||
camera_owner: 'CITY',
|
||||
cameraOwner: undefined,
|
||||
}),
|
||||
ottawaRow(1, 45.4216, -75.6973, { name: 'duplicate number' }),
|
||||
ottawaRow(0, 45.4215, -75.6972),
|
||||
ottawaRow(-3, 45.4215, -75.6972),
|
||||
ottawaRow(7, null, -75.7),
|
||||
@@ -71,18 +119,21 @@ test('Ottawa loader keeps in-city cameras and builds frame URLs from the id', as
|
||||
]);
|
||||
});
|
||||
|
||||
const cameras = await loadOttawaSourcesFromOpenData();
|
||||
const cameras = await loadOttawaSourcesFromOpenData({}, CERT);
|
||||
|
||||
assert.deepEqual(requested, [OTTAWA_CAMERAS_URL]);
|
||||
assert.deepEqual(cameras.map((camera) => camera.id).sort(), [
|
||||
'ottawa-1',
|
||||
'ottawa-309',
|
||||
'ottawa-2025',
|
||||
]);
|
||||
|
||||
const mto = cameras.find((camera) => camera.id === 'ottawa-309');
|
||||
const mto = cameras.find((camera) => camera.id === 'ottawa-2025');
|
||||
const city = cameras.find((camera) => camera.id === 'ottawa-1');
|
||||
assert.equal(mto.url, 'https://traffic.ottawa.ca/map/camera?id=309');
|
||||
assert.equal(mto.url, 'https://traffic.ottawa.ca/opendata/camera?c=2025');
|
||||
assert.doesNotMatch(mto.url, /certificate/, 'the catalog URL carries no key');
|
||||
assert.equal(mto.snapshotUrl, mto.url);
|
||||
assert.equal(mto.frameRefreshMs, 60000, 'the API allows one fetch a minute');
|
||||
assert.equal(city.name, 'Bank St at Somerset St');
|
||||
assert.equal(mto.name, '(MTO) Hwy 416 NB ramp to Hwy 417 East');
|
||||
assert.equal(mto.city, 'Ottawa');
|
||||
assert.equal(mto.provider, 'City of Ottawa');
|
||||
@@ -91,7 +142,7 @@ test('Ottawa loader keeps in-city cameras and builds frame URLs from the id', as
|
||||
assert.equal(mto.headingConfidence, 'low');
|
||||
assert.equal(
|
||||
mto.headingDeg,
|
||||
fallbackHeadingFromId('ottawa-309'),
|
||||
fallbackHeadingFromId('ottawa-2025'),
|
||||
'the feed publishes no bearing, so the id-hash fallback is deterministic',
|
||||
);
|
||||
assert.match(mto.license, /City of Ottawa/);
|
||||
@@ -115,9 +166,9 @@ test('Ottawa loader keeps the cameras nearest downtown under its cap', async (t)
|
||||
(lat, i) => ottawaRow(200 + i, lat, -75.7),
|
||||
),
|
||||
];
|
||||
t.mock.method(globalThis, 'fetch', async () => Response.json(rows));
|
||||
t.mock.method(globalThis, 'fetch', async () => ottawaPayload(rows));
|
||||
|
||||
const cameras = await loadOttawaSourcesFromOpenData();
|
||||
const cameras = await loadOttawaSourcesFromOpenData({}, CERT);
|
||||
|
||||
assert.equal(cameras.length, 8);
|
||||
assert.deepEqual(
|
||||
@@ -134,15 +185,16 @@ test('Ottawa loader fails soft on HTTP errors, unexpected payloads and network e
|
||||
for (respond of [
|
||||
async () => new Response('unavailable', { status: 503 }),
|
||||
async () => Response.json({ cameras: [] }),
|
||||
async () => Response.json([ottawaRow(1, 45.4215, -75.6972)]),
|
||||
async () => {
|
||||
throw new TypeError('fetch failed');
|
||||
},
|
||||
]) {
|
||||
assert.deepEqual(await loadOttawaSourcesFromOpenData(), []);
|
||||
assert.deepEqual(await loadOttawaSourcesFromOpenData({}, CERT), []);
|
||||
}
|
||||
});
|
||||
|
||||
test('CCTV catalog merges Ottawa cameras and CCTV_OTTAWA_ENABLED=0 skips the request', async (t) => {
|
||||
test('CCTV catalog merges Ottawa cameras with a certificate and CCTV_OTTAWA_ENABLED=0 skips the request', async (t) => {
|
||||
quiet(t);
|
||||
const sourceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gev-cctv-ottawa-'));
|
||||
t.after(() => fs.rmSync(sourceRoot, { recursive: true, force: true }));
|
||||
@@ -154,13 +206,16 @@ test('CCTV catalog merges Ottawa cameras and CCTV_OTTAWA_ENABLED=0 skips the req
|
||||
CCTV_MAX_SOURCES: undefined,
|
||||
CCTV_OTTAWA_ENABLED: undefined,
|
||||
CCTV_OTTAWA_MAX_SOURCES: undefined,
|
||||
CCTV_QUEBEC_ENABLED: '0',
|
||||
CCTV_WORLD_CAMERAS_ENABLED: '0',
|
||||
OTTAWA_CAMERA_CERTIFICATE: 'CERT123',
|
||||
});
|
||||
const requested = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
requested.push(String(url));
|
||||
// Every other live pack is down; only Ottawa answers.
|
||||
return String(url) === OTTAWA_CAMERAS_URL
|
||||
? Response.json([ottawaRow(1, 45.4215, -75.6972)])
|
||||
? ottawaPayload([ottawaRow(1, 45.4215, -75.6972)])
|
||||
: new Response('unavailable', { status: 503 });
|
||||
});
|
||||
|
||||
@@ -169,7 +224,7 @@ test('CCTV catalog merges Ottawa cameras and CCTV_OTTAWA_ENABLED=0 skips the req
|
||||
sources.map((source) => source.id),
|
||||
['ottawa-1'],
|
||||
);
|
||||
assert.equal(sources[0].url, 'https://traffic.ottawa.ca/map/camera?id=1');
|
||||
assert.equal(sources[0].url, 'https://traffic.ottawa.ca/opendata/camera?c=1');
|
||||
|
||||
process.env.CCTV_OTTAWA_ENABLED = '0';
|
||||
requested.length = 0;
|
||||
@@ -185,16 +240,16 @@ test('CCTV catalog merges Ottawa cameras and CCTV_OTTAWA_ENABLED=0 skips the req
|
||||
test('Ottawa frames stay inside the city image directory', () => {
|
||||
assert.ok(
|
||||
pinnedCameraUrl(
|
||||
'https://traffic.ottawa.ca/map/camera?id=309',
|
||||
'https://traffic.ottawa.ca/opendata/camera?c=309',
|
||||
OTTAWA_IMAGE_ORIGIN,
|
||||
),
|
||||
);
|
||||
for (const url of [
|
||||
'https://traffic.ottawa.ca.evil.test/map/camera?id=309',
|
||||
'https://traffic.ottawa.ca/other/camera?id=309',
|
||||
'https://user:[email protected]/map/camera?id=309',
|
||||
'https://traffic.ottawa.ca:8443/map/camera?id=309',
|
||||
'http://traffic.ottawa.ca/map/camera?id=309',
|
||||
'https://traffic.ottawa.ca.evil.test/opendata/camera?c=309',
|
||||
'https://traffic.ottawa.ca/map/camera?id=309',
|
||||
'https://user:[email protected]/opendata/camera?c=309',
|
||||
'https://traffic.ottawa.ca:8443/opendata/camera?c=309',
|
||||
'http://traffic.ottawa.ca/opendata/camera?c=309',
|
||||
])
|
||||
assert.equal(pinnedCameraUrl(url, OTTAWA_IMAGE_ORIGIN), null, url);
|
||||
});
|
||||
@@ -365,6 +365,7 @@ test('CCTV upstream frame fetch extracts only the first JPEG from MJPEG', async
|
||||
const result = await fetchCctvImageFromUpstream(
|
||||
'https://cctv-ss02.thb.gov.tw/T62-9K+020',
|
||||
{
|
||||
allowMjpeg: true,
|
||||
maxBytes: 1024,
|
||||
fetchImpl: async () =>
|
||||
multipartResponse(
|
||||
@@ -397,6 +398,7 @@ test('CCTV upstream frame fetch rejects malformed and non-JPEG MJPEG parts', asy
|
||||
const result = await fetchCctvImageFromUpstream(
|
||||
'https://cctv-ss02.thb.gov.tw/T62-9K+020',
|
||||
{
|
||||
allowMjpeg: true,
|
||||
fetchImpl: async () =>
|
||||
multipartResponse(type, chunks, { onCancel: () => { cancelled = true; } }),
|
||||
},
|
||||
@@ -418,6 +420,7 @@ test('CCTV upstream frame fetch rejects truncated or oversized MJPEG parts', asy
|
||||
const result = await fetchCctvImageFromUpstream(
|
||||
'https://cctv-ss02.thb.gov.tw/T62-9K+020',
|
||||
{
|
||||
allowMjpeg: true,
|
||||
maxBytes,
|
||||
fetchImpl: async () =>
|
||||
multipartResponse(
|
||||
@@ -456,6 +459,7 @@ test('CCTV upstream frame fetch does not cross into a later part when the first
|
||||
const result = await fetchCctvImageFromUpstream(
|
||||
'https://cctv-ss02.thb.gov.tw/T62-9K+020',
|
||||
{
|
||||
allowMjpeg: true,
|
||||
maxBytes: 1024,
|
||||
fetchImpl: async () =>
|
||||
multipartResponse(
|
||||
@@ -468,4 +472,75 @@ test('CCTV upstream frame fetch does not cross into a later part when the first
|
||||
|
||||
assert.equal(result, null);
|
||||
assert.equal(cancelled, true);
|
||||
});
|
||||
});
|
||||
test('CCTV upstream frame fetch ignores MJPEG unless the source is registered as MJPEG', async () => {
|
||||
const boundary = 'DIGIEVER';
|
||||
const jpeg = Buffer.from([0xff, 0xd8, 0x11, 0x22, 0xff, 0xd9]);
|
||||
let cancelled = false;
|
||||
const result = await fetchCctvImageFromUpstream(
|
||||
'https://cctv-ss02.thb.gov.tw/T62-9K+020',
|
||||
{
|
||||
fetchImpl: async () =>
|
||||
multipartResponse(
|
||||
'multipart/x-mixed-replace; boundary=' + boundary,
|
||||
[jpegPart(boundary, jpeg)],
|
||||
{ onCancel: () => { cancelled = true; } },
|
||||
),
|
||||
},
|
||||
);
|
||||
assert.equal(result, null);
|
||||
assert.equal(cancelled, true);
|
||||
});
|
||||
|
||||
test('CCTV upstream frame fetch accepts a CRLF preamble and a boundary that already starts with --', async () => {
|
||||
const jpeg = Buffer.from([0xff, 0xd8, 0x11, 0x22, 0xff, 0xd9]);
|
||||
for (const [type, wire] of [
|
||||
[
|
||||
'multipart/x-mixed-replace; boundary=DIGIEVER',
|
||||
Buffer.concat([Buffer.from('\r\n'), jpegPart('DIGIEVER', jpeg)]),
|
||||
],
|
||||
[
|
||||
'multipart/x-mixed-replace; boundary=--myboundary',
|
||||
Buffer.concat([
|
||||
Buffer.from(
|
||||
'--myboundary\r\nContent-Type: image/jpeg\r\nContent-Length: ' +
|
||||
jpeg.length +
|
||||
'\r\n\r\n',
|
||||
),
|
||||
jpeg,
|
||||
Buffer.from('\r\n--myboundary\r\n'),
|
||||
]),
|
||||
],
|
||||
]) {
|
||||
const result = await fetchCctvImageFromUpstream(
|
||||
'https://cctv-ss02.thb.gov.tw/T62-9K+020',
|
||||
{
|
||||
allowMjpeg: true,
|
||||
fetchImpl: async () => multipartResponse(type, [wire]),
|
||||
},
|
||||
);
|
||||
assert.deepEqual(result?.body, jpeg, type);
|
||||
}
|
||||
});
|
||||
|
||||
test('CCTV MJPEG part without Content-Length ends at the next delimiter, not at an inner JPEG end marker', async () => {
|
||||
const boundary = 'DIGIEVER';
|
||||
// An EXIF thumbnail's own FF D9 sits inside the image.
|
||||
const jpeg = Buffer.from([0xff, 0xd8, 0x01, 0xff, 0xd9, 0x02, 0x03, 0xff, 0xd9]);
|
||||
const wire = Buffer.concat([
|
||||
Buffer.from('--' + boundary + '\r\nContent-Type: image/jpeg\r\n\r\n'),
|
||||
jpeg,
|
||||
Buffer.from('\r\n--' + boundary + '\r\nContent-Type: image/jpeg\r\n\r\n'),
|
||||
]);
|
||||
// Delivered one byte at a time, as a trickling upstream would.
|
||||
const chunks = [...wire].map((byte) => Buffer.from([byte]));
|
||||
const result = await fetchCctvImageFromUpstream(
|
||||
'https://cctv-ss02.thb.gov.tw/T62-9K+020',
|
||||
{
|
||||
allowMjpeg: true,
|
||||
fetchImpl: async () =>
|
||||
multipartResponse('multipart/x-mixed-replace; boundary=' + boundary, chunks),
|
||||
},
|
||||
);
|
||||
assert.deepEqual(result?.body, jpeg);
|
||||
});
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
TORONTO_RESCU_LIST_URL,
|
||||
TORONTO_RESCU_IMAGE_ORIGIN,
|
||||
TORONTO_MAX_CATALOG_BYTES,
|
||||
DEFAULT_CCTV_MAX_SOURCES,
|
||||
CCTV_SHARED_SOURCE_SLOTS,
|
||||
} 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';
|
||||
@@ -418,6 +418,6 @@ test('CCTV_TORONTO_ENABLED=0 keeps the lane from being loaded at all', async (t)
|
||||
}
|
||||
});
|
||||
|
||||
test('the default catalog preserves shared, Québec, and World image slots', () => {
|
||||
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 7516);
|
||||
test('the shared catalog slots are not raised to make room for this pack', () => {
|
||||
assert.equal(CCTV_SHARED_SOURCE_SLOTS, 4000);
|
||||
});
|
||||
@@ -0,0 +1,306 @@
|
||||
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 {
|
||||
DEFAULT_WORLD_CCTV_SOURCE_FILE,
|
||||
WORLD_CCTV_SOURCE_COUNT,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import {
|
||||
isMlitRiverAdjustmentImage,
|
||||
loadWorldCameraSources,
|
||||
} from '../../server/providers/cctv/worldCameras.js';
|
||||
import {
|
||||
loadQuebecSources,
|
||||
QUEBEC_CAMERA_SOURCE_COUNT,
|
||||
quebec511VideoUrl,
|
||||
quebecBrowserVideoEnabled,
|
||||
} from '../../server/providers/cctv/quebec.js';
|
||||
import { cctvProxy } from '../../server/providers/cctv.js';
|
||||
|
||||
const REPO_ROOT = path.resolve(import.meta.dirname, '../..');
|
||||
const JPEG = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0xff, 0xd9]);
|
||||
|
||||
/** Silence the loaders' progress and failure logging. */
|
||||
function quiet(t) {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
}
|
||||
|
||||
/** A temporary source root holding the given World and Québec catalogs. */
|
||||
function sourceRoot(t, { world = [], quebec = null } = {}) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gev-cctv-world-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const worldFile = path.join(root, DEFAULT_WORLD_CCTV_SOURCE_FILE);
|
||||
fs.mkdirSync(path.dirname(worldFile), { recursive: true });
|
||||
fs.writeFileSync(worldFile, JSON.stringify(world));
|
||||
if (quebec) {
|
||||
fs.mkdirSync(path.join(root, 'public'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(root, 'public/quebec-cameras.json'),
|
||||
JSON.stringify({ cameras: quebec }),
|
||||
);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
const icelandRow = (overrides = {}) => ({
|
||||
id: 'world-is-7075',
|
||||
name: 'Almannaskarð - norður',
|
||||
provider: 'Vegagerðin road cameras',
|
||||
lat: 64.291877,
|
||||
lon: -15.028035,
|
||||
sourceKind: 'vegagerdin-road',
|
||||
url: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/almannaskard_1.jpg',
|
||||
snapshotUrl:
|
||||
'https://www.vegagerdin.is/vgdata/vefmyndavelar/almannaskard_1.jpg',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test('the shipped World catalog loads every curated row', (t) => {
|
||||
quiet(t);
|
||||
const sources = loadWorldCameraSources({ sourceRoot: REPO_ROOT });
|
||||
assert.equal(sources.length, WORLD_CCTV_SOURCE_COUNT);
|
||||
const byKind = Object.groupBy(sources, (source) => source.sourceKind);
|
||||
assert.equal(byKind['vegagerdin-road'].length, 166);
|
||||
assert.equal(byKind['tripcheck-odot'].length, 1176);
|
||||
assert.equal(byKind['mlit-river'].length, 1054);
|
||||
for (const source of byKind['mlit-river'])
|
||||
assert.equal(source.frameRefreshMs, 10 * 60 * 1000);
|
||||
});
|
||||
|
||||
test('the World loader accepts a pinned row and rejects anything outside its policy', (t) => {
|
||||
quiet(t);
|
||||
const rejected = [
|
||||
['unknown kind', { sourceKind: 'nzta-journeys' }],
|
||||
['wrong id prefix', { id: 'world-or-7075' }],
|
||||
['outside bounds', { lat: 40, lon: -15 }],
|
||||
['other host', { url: 'https://evil.test/vgdata/vefmyndavelar/a.jpg' }],
|
||||
['other directory', { url: 'https://www.vegagerdin.is/other/a.jpg' }],
|
||||
['query string', { url: icelandRow().url + '?x=1' }],
|
||||
['not a jpg', { url: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/a.png' }],
|
||||
['plain http', { url: icelandRow().url.replace('https:', 'http:') }],
|
||||
[
|
||||
'url and snapshot differ',
|
||||
{ snapshotUrl: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/b.jpg' },
|
||||
],
|
||||
];
|
||||
const root = sourceRoot(t, {
|
||||
world: [
|
||||
icelandRow(),
|
||||
icelandRow(), // duplicate id
|
||||
...rejected.map(([, overrides], i) =>
|
||||
icelandRow({ id: `world-is-${9000 + i}`, ...overrides }),
|
||||
),
|
||||
],
|
||||
});
|
||||
const sources = loadWorldCameraSources({ sourceRoot: root });
|
||||
assert.deepEqual(
|
||||
sources.map((source) => source.id),
|
||||
['world-is-7075'],
|
||||
);
|
||||
assert.equal(sources[0].frameRefreshMs, 5 * 60 * 1000);
|
||||
assert.equal(sources[0].feedType, 'image');
|
||||
});
|
||||
|
||||
test('the World loader fails soft on a missing or malformed catalog', (t) => {
|
||||
quiet(t);
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gev-cctv-world-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
assert.deepEqual(loadWorldCameraSources({ sourceRoot: root }), []);
|
||||
const file = path.join(root, DEFAULT_WORLD_CCTV_SOURCE_FILE);
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, '{not json');
|
||||
assert.deepEqual(loadWorldCameraSources({ sourceRoot: root }), []);
|
||||
});
|
||||
|
||||
test('only the exact MLIT adjustment image is recognised', () => {
|
||||
assert.equal(isMlitRiverAdjustmentImage({ ok: true, body: JPEG }), false);
|
||||
assert.equal(isMlitRiverAdjustmentImage(null), false);
|
||||
assert.equal(isMlitRiverAdjustmentImage({ ok: false, body: JPEG }), false);
|
||||
});
|
||||
|
||||
test('the shipped Québec catalog loads every location', (t) => {
|
||||
quiet(t);
|
||||
const sources = loadQuebecSources({ sourceRoot: REPO_ROOT });
|
||||
assert.equal(sources.length, QUEBEC_CAMERA_SOURCE_COUNT);
|
||||
for (const source of sources) {
|
||||
assert.equal(source.url, '', 'the server never fetches Québec media');
|
||||
assert.equal(source.snapshotUrl, '');
|
||||
}
|
||||
});
|
||||
|
||||
test('the Québec loader maps ids by kind, keeps https viewer links, and splits the MTQ credit', (t) => {
|
||||
quiet(t);
|
||||
const at = { lat: 45.51651, lon: -73.5502 };
|
||||
const root = sourceRoot(t, {
|
||||
quebec: [
|
||||
{ id: '3735', kind: 'traffic', name: 'Route 136', viewerUrl: 'https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3735', ...at },
|
||||
{ id: 'mtl-465', kind: 'montreal', name: 'Montréal 465', viewerUrl: 'http://ville.montreal.qc.ca/x', ...at },
|
||||
{ id: 'web-1', kind: 'webcam', name: 'Tremblant', operator: 'Station Mont-Tremblant', viewerUrl: 'https://user:[email protected]/', ...at },
|
||||
{ id: 'abc', kind: 'traffic', ...at }, // non-numeric traffic id
|
||||
{ id: '3735', kind: 'traffic', ...at }, // duplicate
|
||||
{ id: '9', kind: 'traffic', lat: 40, lon: -73.5 }, // outside Québec
|
||||
],
|
||||
});
|
||||
const sources = loadQuebecSources({ sourceRoot: root });
|
||||
const byId = new Map(sources.map((source) => [source.id, source]));
|
||||
assert.deepEqual([...byId.keys()], ['qc-511-3735', 'qc-mtl-465', 'qc-web-1']);
|
||||
|
||||
const mtq = byId.get('qc-511-3735');
|
||||
assert.equal(mtq.sourceKind, 'quebec-511-browser');
|
||||
assert.match(mtq.credit, /Locations: MTQ · CC BY 4\.0/);
|
||||
assert.match(mtq.credit, /Video: Québec 511/);
|
||||
assert.doesNotMatch(mtq.license, /^Données Québec \/ MTQ, CC BY 4\.0$/);
|
||||
assert.equal(byId.get('qc-mtl-465').sourceUrl, '', 'http viewer links are dropped');
|
||||
assert.equal(byId.get('qc-web-1').sourceUrl, '', 'credentialed links are dropped');
|
||||
assert.equal(
|
||||
quebec511VideoUrl('qc-511-3735'),
|
||||
'https://www.quebec511.info/Carte/Fenetres/camera.ashx?id=3735&format=mp4',
|
||||
);
|
||||
assert.equal(quebec511VideoUrl('qc-mtl-465'), '');
|
||||
});
|
||||
|
||||
test('Québec 511 browser video is opt-in', () => {
|
||||
assert.equal(quebecBrowserVideoEnabled({}), false);
|
||||
assert.equal(quebecBrowserVideoEnabled({ CCTV_QUEBEC_VIDEO_ENABLED: '0' }), false);
|
||||
assert.equal(quebecBrowserVideoEnabled({ CCTV_QUEBEC_VIDEO_ENABLED: ' 1 ' }), true);
|
||||
});
|
||||
|
||||
/** Mount the real proxy with a fixed fetch, and drive requests through it. */
|
||||
function mount(t, root, respond) {
|
||||
withEnv(t, {
|
||||
CCTV_SOURCES_FILE: 'absent-source-file.json',
|
||||
CCTV_SOURCES_JSON: undefined,
|
||||
CCTV_FORCE_AUSTIN: '0',
|
||||
CCTV_MAX_SOURCES: undefined,
|
||||
// A configured key makes any Street View fallback observable.
|
||||
GOOGLE_MAPS_SERVER_API_KEY: 'test-google-key',
|
||||
});
|
||||
const requests = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url, init = {}) => {
|
||||
requests.push(String(url));
|
||||
return respond(String(url), init) || new Response('down', { status: 503 });
|
||||
});
|
||||
let handler;
|
||||
cctvProxy({ sourceRoot: root }).configureServer({
|
||||
middlewares: {
|
||||
use(_route, fn) {
|
||||
handler = fn;
|
||||
},
|
||||
},
|
||||
});
|
||||
const call = async (url) => {
|
||||
const res = {
|
||||
writeHead(status, headers) {
|
||||
Object.assign(this, { status, headers });
|
||||
},
|
||||
end(body) {
|
||||
this.body = body;
|
||||
},
|
||||
on() {},
|
||||
once() {},
|
||||
};
|
||||
await handler({ url, method: 'GET', headers: {}, on() {} }, res);
|
||||
return res;
|
||||
};
|
||||
return { call, requests };
|
||||
}
|
||||
|
||||
test('/sources lists Québec browser media only when the operator opts in', async (t) => {
|
||||
quiet(t);
|
||||
const root = sourceRoot(t, {
|
||||
quebec: [{ id: '3735', kind: 'traffic', name: 'Route 136', lat: 45.5, lon: -73.5 }],
|
||||
});
|
||||
withEnv(t, { CCTV_QUEBEC_VIDEO_ENABLED: undefined });
|
||||
const app = mount(t, root, () => null);
|
||||
const off = JSON.parse((await app.call('/sources')).body).sources;
|
||||
assert.equal(off.find((s) => s.id === 'qc-511-3735').browserMediaUrl, '');
|
||||
|
||||
process.env.CCTV_QUEBEC_VIDEO_ENABLED = '1';
|
||||
const on = JSON.parse((await app.call('/sources')).body).sources;
|
||||
assert.match(on.find((s) => s.id === 'qc-511-3735').browserMediaUrl, /camera\.ashx\?id=3735&format=mp4$/);
|
||||
});
|
||||
|
||||
test('a World frame is fetched once per refresh floor and the cooldown never spends Street View', async (t) => {
|
||||
quiet(t);
|
||||
const root = sourceRoot(t, { world: [icelandRow()] });
|
||||
let answer = () => new Response(JPEG, { headers: { 'content-type': 'image/jpeg' } });
|
||||
const app = mount(t, root, (url) =>
|
||||
url === icelandRow().url ? answer() : null,
|
||||
);
|
||||
const frame = '/frame/world-is-7075';
|
||||
const upstreamHits = () =>
|
||||
app.requests.filter((url) => url === icelandRow().url).length;
|
||||
|
||||
const first = await app.call(frame);
|
||||
assert.equal(first.headers['X-CCTV-Source'], 'upstream-image');
|
||||
const second = await app.call(frame);
|
||||
assert.equal(second.headers['X-CCTV-Source'], 'upstream-image');
|
||||
assert.equal(upstreamHits(), 1, 'the second view reuses the cached frame');
|
||||
|
||||
// A camera whose first fetch fails is inside its failure backoff: it gets
|
||||
// the placeholder, not Google Street View, and no second upstream request.
|
||||
const failRoot = sourceRoot(t, { world: [icelandRow()] });
|
||||
answer = () => new Response('down', { status: 503 });
|
||||
const failing = mount(t, failRoot, (url) =>
|
||||
url === icelandRow().url ? answer() : null,
|
||||
);
|
||||
const failed = await failing.call(frame);
|
||||
assert.notEqual(failed.headers['X-CCTV-Source'], 'upstream-image');
|
||||
const retry = await failing.call(frame);
|
||||
assert.equal(retry.headers['X-CCTV-Source'], 'synthetic');
|
||||
assert.equal(
|
||||
failing.requests.filter((url) => url === icelandRow().url).length,
|
||||
1,
|
||||
'the backoff holds the provider cadence',
|
||||
);
|
||||
assert.equal(
|
||||
failing.requests.filter((url) => url.includes('maps.googleapis.com/maps/api/streetview')).length,
|
||||
1,
|
||||
'only the first miss, outside any cooldown, may try Street View',
|
||||
);
|
||||
});
|
||||
|
||||
test('Ottawa frames are signed with the certificate at fetch time only', async (t) => {
|
||||
quiet(t);
|
||||
const root = sourceRoot(t);
|
||||
withEnv(t, { OTTAWA_CAMERA_CERTIFICATE: 'CERT123' });
|
||||
const app = mount(t, root, (url) => {
|
||||
if (url === 'https://traffic.ottawa.ca/map/service/camera')
|
||||
return Response.json({
|
||||
cameras: [
|
||||
{ camera_number: 16, name: 'Bank & Hunt Club', latitude: 45.35, longitude: -75.65, cameraOwner: 'CITY' },
|
||||
],
|
||||
});
|
||||
if (url.startsWith('https://traffic.ottawa.ca/opendata/camera'))
|
||||
return new Response(JPEG, { headers: { 'content-type': 'image/jpeg' } });
|
||||
return null;
|
||||
});
|
||||
const sources = JSON.parse((await app.call('/sources')).body).sources;
|
||||
const ottawa = sources.find((source) => source.id === 'ottawa-16');
|
||||
assert.ok(ottawa);
|
||||
assert.doesNotMatch(JSON.stringify(sources), /CERT123/, 'no catalog field carries the certificate');
|
||||
|
||||
const frame = await app.call('/frame/ottawa-16');
|
||||
assert.equal(frame.headers['X-CCTV-Source'], 'upstream-image');
|
||||
await app.call('/frame/ottawa-16');
|
||||
const hits = app.requests.filter((url) => url.includes('/opendata/camera'));
|
||||
assert.deepEqual(hits, [
|
||||
'https://traffic.ottawa.ca/opendata/camera?c=16&certificate=CERT123&id=gev',
|
||||
], 'one signed request per camera per minute');
|
||||
});
|
||||
@@ -1,6 +1,4 @@
|
||||
{
|
||||
"MTQ · CC BY 4.0": "MTQ · CC BY 4.0",
|
||||
"OpenStreetMap contributors · ODbL": "OpenStreetMap contributors · ODbL",
|
||||
"Close camera preview": "إغلاق معاينة الكاميرا",
|
||||
"Refresh map still": "تحديث صورة الخريطة الثابتة",
|
||||
"The map still uses a separate request and may show a different moment.": "تستخدم صورة الخريطة طلبًا منفصلًا وقد تعرض لحظة مختلفة.",
|
||||
@@ -3228,5 +3226,6 @@
|
||||
"Audio": "الصوت",
|
||||
"UTILITIES": "الأدوات",
|
||||
"Exit cockpit to fly to a drawing": "اخرج من القمرة للانتقال إلى رسم",
|
||||
"Directions, drawing and your own files": "الاتجاهات والرسم وملفاتك الخاصة"
|
||||
"Directions, drawing and your own files": "الاتجاهات والرسم وملفاتك الخاصة",
|
||||
"Video plays from Québec 511, which sees your IP address.": "يُشغَّل الفيديو من Québec 511، الذي يرى عنوان IP الخاص بك."
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
{
|
||||
"MTQ · CC BY 4.0": "MTQ · CC BY 4.0",
|
||||
"OpenStreetMap contributors · ODbL": "OpenStreetMap contributors · ODbL",
|
||||
"Close camera preview": "Kameravorschau schließen",
|
||||
"Refresh map still": "Kartenstandbild aktualisieren",
|
||||
"The map still uses a separate request and may show a different moment.": "Das Kartenstandbild wird separat abgerufen und kann einen anderen Moment zeigen.",
|
||||
@@ -2948,5 +2946,6 @@
|
||||
"Audio": "Audio",
|
||||
"UTILITIES": "WERKZEUGE",
|
||||
"Exit cockpit to fly to a drawing": "Cockpit verlassen, um zu einer Zeichnung zu fliegen",
|
||||
"Directions, drawing and your own files": "Route, Zeichnen und eigene Dateien"
|
||||
"Directions, drawing and your own files": "Route, Zeichnen und eigene Dateien",
|
||||
"Video plays from Québec 511, which sees your IP address.": "Das Video wird von Québec 511 abgespielt, das Ihre IP-Adresse sieht."
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
{
|
||||
"MTQ · CC BY 4.0": "MTQ · CC BY 4.0",
|
||||
"OpenStreetMap contributors · ODbL": "OpenStreetMap contributors · ODbL",
|
||||
"Close camera preview": "Cerrar vista previa de la cámara",
|
||||
"Refresh map still": "Actualizar imagen fija del mapa",
|
||||
"The map still uses a separate request and may show a different moment.": "La imagen del mapa usa una solicitud independiente y puede mostrar otro momento.",
|
||||
@@ -3018,5 +3016,6 @@
|
||||
"Right panels": "Paneles derechos",
|
||||
"UTILITIES": "UTILIDADES",
|
||||
"Exit cockpit to fly to a drawing": "Sal de la cabina para volar a un dibujo",
|
||||
"Directions, drawing and your own files": "Indicaciones, dibujo y tus propios archivos"
|
||||
"Directions, drawing and your own files": "Indicaciones, dibujo y tus propios archivos",
|
||||
"Video plays from Québec 511, which sees your IP address.": "El vídeo se reproduce desde Québec 511, que ve tu dirección IP."
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
{
|
||||
"MTQ · CC BY 4.0": "MTQ · CC BY 4.0",
|
||||
"OpenStreetMap contributors · ODbL": "OpenStreetMap contributors · ODbL",
|
||||
"Close camera preview": "Fermer l’aperçu de la caméra",
|
||||
"Refresh map still": "Actualiser l’image fixe sur la carte",
|
||||
"The map still uses a separate request and may show a different moment.": "L’image fixe de la carte utilise une requête distincte et peut montrer un autre moment.",
|
||||
@@ -3018,5 +3016,6 @@
|
||||
"Audio": "Audio",
|
||||
"UTILITIES": "OUTILS",
|
||||
"Exit cockpit to fly to a drawing": "Quittez le cockpit pour rejoindre un dessin",
|
||||
"Directions, drawing and your own files": "Itinéraire, dessin et vos propres fichiers"
|
||||
"Directions, drawing and your own files": "Itinéraire, dessin et vos propres fichiers",
|
||||
"Video plays from Québec 511, which sees your IP address.": "La vidéo est lue depuis Québec 511, qui voit votre adresse IP."
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
{
|
||||
"MTQ · CC BY 4.0": "MTQ · CC BY 4.0",
|
||||
"OpenStreetMap contributors · ODbL": "OpenStreetMap contributors · ODbL",
|
||||
"Close camera preview": "Chiudi anteprima della telecamera",
|
||||
"Refresh map still": "Aggiorna immagine fissa sulla mappa",
|
||||
"The map still uses a separate request and may show a different moment.": "L’immagine sulla mappa usa una richiesta separata e può mostrare un momento diverso.",
|
||||
@@ -3018,5 +3016,6 @@
|
||||
"Audio": "Audio",
|
||||
"UTILITIES": "STRUMENTI",
|
||||
"Exit cockpit to fly to a drawing": "Esci dal cockpit per volare a un disegno",
|
||||
"Directions, drawing and your own files": "Indicazioni, disegno e i tuoi file"
|
||||
"Directions, drawing and your own files": "Indicazioni, disegno e i tuoi file",
|
||||
"Video plays from Québec 511, which sees your IP address.": "Il video viene riprodotto da Québec 511, che vede il tuo indirizzo IP."
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
{
|
||||
"MTQ · CC BY 4.0": "MTQ · CC BY 4.0",
|
||||
"OpenStreetMap contributors · ODbL": "OpenStreetMap contributors · ODbL",
|
||||
"Close camera preview": "カメラのプレビューを閉じる",
|
||||
"Refresh map still": "地図上の静止画を更新",
|
||||
"The map still uses a separate request and may show a different moment.": "地図上の静止画は別のリクエストで取得されるため、異なる時点を表示する場合があります。",
|
||||
@@ -2878,5 +2876,6 @@
|
||||
"Audio": "オーディオ",
|
||||
"UTILITIES": "ユーティリティ",
|
||||
"Exit cockpit to fly to a drawing": "描画の位置へ移動するにはコックピットを終了してください",
|
||||
"Directions, drawing and your own files": "ルート案内、描画、自分のファイル"
|
||||
"Directions, drawing and your own files": "ルート案内、描画、自分のファイル",
|
||||
"Video plays from Québec 511, which sees your IP address.": "動画は Québec 511 から再生され、Québec 511 にはあなたの IP アドレスが見えます。"
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
{
|
||||
"MTQ · CC BY 4.0": "MTQ · CC BY 4.0",
|
||||
"OpenStreetMap contributors · ODbL": "OpenStreetMap contributors · ODbL",
|
||||
"Close camera preview": "Fechar prévia da câmera",
|
||||
"Refresh map still": "Atualizar imagem fixa no mapa",
|
||||
"The map still uses a separate request and may show a different moment.": "A imagem no mapa usa uma solicitação separada e pode mostrar outro momento.",
|
||||
@@ -3018,5 +3016,6 @@
|
||||
"Audio": "Áudio",
|
||||
"UTILITIES": "UTILITÁRIOS",
|
||||
"Exit cockpit to fly to a drawing": "Saia da cabine para voar até um desenho",
|
||||
"Directions, drawing and your own files": "Rotas, desenho e seus próprios arquivos"
|
||||
"Directions, drawing and your own files": "Rotas, desenho e seus próprios arquivos",
|
||||
"Video plays from Québec 511, which sees your IP address.": "O vídeo é reproduzido a partir do Québec 511, que vê o seu endereço IP."
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
{
|
||||
"MTQ · CC BY 4.0": "MTQ · CC BY 4.0",
|
||||
"OpenStreetMap contributors · ODbL": "OpenStreetMap contributors · ODbL",
|
||||
"Close camera preview": "Закрыть просмотр камеры",
|
||||
"Refresh map still": "Обновить кадр на карте",
|
||||
"The map still uses a separate request and may show a different moment.": "Кадр на карте запрашивается отдельно и может показывать другой момент.",
|
||||
@@ -3088,5 +3086,6 @@
|
||||
"Right panels": "Правые панели",
|
||||
"UTILITIES": "ИНСТРУМЕНТЫ",
|
||||
"Exit cockpit to fly to a drawing": "Выйдите из кабины, чтобы перелететь к рисунку",
|
||||
"Directions, drawing and your own files": "Маршруты, рисование и ваши файлы"
|
||||
"Directions, drawing and your own files": "Маршруты, рисование и ваши файлы",
|
||||
"Video plays from Québec 511, which sees your IP address.": "Видео воспроизводится с Québec 511, который видит ваш IP-адрес."
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
{
|
||||
"MTQ · CC BY 4.0": "MTQ · CC BY 4.0",
|
||||
"OpenStreetMap contributors · ODbL": "OpenStreetMap contributors · ODbL",
|
||||
"Close camera preview": "Закрити перегляд камери",
|
||||
"Refresh map still": "Оновити кадр на карті",
|
||||
"The map still uses a separate request and may show a different moment.": "Кадр на карті запитується окремо та може показувати інший момент.",
|
||||
@@ -3088,5 +3086,6 @@
|
||||
"Audio": "Аудіо",
|
||||
"UTILITIES": "ІНСТРУМЕНТИ",
|
||||
"Exit cockpit to fly to a drawing": "Вийдіть із кабіни, щоб перелетіти до малюнка",
|
||||
"Directions, drawing and your own files": "Маршрути, малювання та ваші файли"
|
||||
"Directions, drawing and your own files": "Маршрути, малювання та ваші файли",
|
||||
"Video plays from Québec 511, which sees your IP address.": "Відео відтворюється з Québec 511, який бачить вашу IP-адресу."
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
{
|
||||
"MTQ · CC BY 4.0": "MTQ · CC BY 4.0",
|
||||
"OpenStreetMap contributors · ODbL": "OpenStreetMap contributors · ODbL",
|
||||
"Close camera preview": "关闭摄像头预览",
|
||||
"Refresh map still": "刷新地图静止画面",
|
||||
"The map still uses a separate request and may show a different moment.": "地图静止画面通过单独的请求获取,可能显示不同的时刻。",
|
||||
@@ -2878,5 +2876,6 @@
|
||||
"Right panels": "右侧面板",
|
||||
"UTILITIES": "工具",
|
||||
"Exit cockpit to fly to a drawing": "请先退出座舱,再飞往绘制位置",
|
||||
"Directions, drawing and your own files": "路线导航、绘制和你自己的文件"
|
||||
"Directions, drawing and your own files": "路线导航、绘制和你自己的文件",
|
||||
"Video plays from Québec 511, which sees your IP address.": "视频从 Québec 511 播放,Québec 511 会看到你的 IP 地址。"
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
{
|
||||
"MTQ · CC BY 4.0": "MTQ · CC BY 4.0",
|
||||
"OpenStreetMap contributors · ODbL": "OpenStreetMap contributors · ODbL",
|
||||
"Close camera preview": "關閉攝影機預覽",
|
||||
"Refresh map still": "重新整理地圖靜態畫面",
|
||||
"The map still uses a separate request and may show a different moment.": "地圖靜態畫面使用獨立請求,顯示的時間可能不同。",
|
||||
@@ -2878,5 +2876,6 @@
|
||||
"Right panels": "右側面板",
|
||||
"UTILITIES": "工具",
|
||||
"Exit cockpit to fly to a drawing": "請先退出座艙,再飛往繪製位置",
|
||||
"Directions, drawing and your own files": "路線導航、繪製和你自己的文件"
|
||||
"Directions, drawing and your own files": "路線導航、繪製和你自己的文件",
|
||||
"Video plays from Québec 511, which sees your IP address.": "影片從 Québec 511 播放,Québec 511 會看到你的 IP 位址。"
|
||||
}
|
||||
@@ -31,9 +31,7 @@
|
||||
"dB",
|
||||
"hPa",
|
||||
"{source} HTTP {status}",
|
||||
"▲ {frp} MW",
|
||||
"MTQ · CC BY 4.0",
|
||||
"OpenStreetMap contributors · ODbL"
|
||||
"▲ {frp} MW"
|
||||
],
|
||||
"de": [
|
||||
"1090 MHz",
|
||||
@@ -201,9 +199,7 @@
|
||||
"{value} KM/S",
|
||||
"ΔN",
|
||||
"Ⅱ PAUSE",
|
||||
"▲ {frp} MW",
|
||||
"MTQ · CC BY 4.0",
|
||||
"OpenStreetMap contributors · ODbL"
|
||||
"▲ {frp} MW"
|
||||
],
|
||||
"es": [
|
||||
"1090 MHz",
|
||||
@@ -331,9 +327,7 @@
|
||||
"{value} KM/S",
|
||||
"ΔE",
|
||||
"ΔN",
|
||||
"▲ {frp} MW",
|
||||
"MTQ · CC BY 4.0",
|
||||
"OpenStreetMap contributors · ODbL"
|
||||
"▲ {frp} MW"
|
||||
],
|
||||
"fr": [
|
||||
"1090 MHz",
|
||||
@@ -514,9 +508,7 @@
|
||||
"ΔE",
|
||||
"ΔN",
|
||||
"Ⅱ PAUSE",
|
||||
"▲ {frp} MW",
|
||||
"MTQ · CC BY 4.0",
|
||||
"OpenStreetMap contributors · ODbL"
|
||||
"▲ {frp} MW"
|
||||
],
|
||||
"it": [
|
||||
"1090 MHz",
|
||||
@@ -687,9 +679,7 @@
|
||||
"{value} conf",
|
||||
"ΔE",
|
||||
"ΔN",
|
||||
"▲ {frp} MW",
|
||||
"MTQ · CC BY 4.0",
|
||||
"OpenStreetMap contributors · ODbL"
|
||||
"▲ {frp} MW"
|
||||
],
|
||||
"ja": [
|
||||
"1090 MHz",
|
||||
@@ -738,9 +728,7 @@
|
||||
"{type} SPD: {speed} HDG: {heading}",
|
||||
"ΔE",
|
||||
"ΔN",
|
||||
"▲ {frp} MW",
|
||||
"MTQ · CC BY 4.0",
|
||||
"OpenStreetMap contributors · ODbL"
|
||||
"▲ {frp} MW"
|
||||
],
|
||||
"pt": [
|
||||
"1090 MHz",
|
||||
@@ -869,9 +857,7 @@
|
||||
"{value} KM/H",
|
||||
"{value} KM/S",
|
||||
"ΔN",
|
||||
"▲ {frp} MW",
|
||||
"MTQ · CC BY 4.0",
|
||||
"OpenStreetMap contributors · ODbL"
|
||||
"▲ {frp} MW"
|
||||
],
|
||||
"ru": [
|
||||
"AIS: --",
|
||||
@@ -889,9 +875,7 @@
|
||||
"S2 GhostMaps",
|
||||
"S2 · {name}",
|
||||
"VS",
|
||||
"WHOT/BHOT",
|
||||
"MTQ · CC BY 4.0",
|
||||
"OpenStreetMap contributors · ODbL"
|
||||
"WHOT/BHOT"
|
||||
],
|
||||
"uk": [
|
||||
"AIS: --",
|
||||
@@ -904,9 +888,7 @@
|
||||
"NVG",
|
||||
"REC",
|
||||
"S2 GhostMaps",
|
||||
"WHOT/BHOT",
|
||||
"MTQ · CC BY 4.0",
|
||||
"OpenStreetMap contributors · ODbL"
|
||||
"WHOT/BHOT"
|
||||
],
|
||||
"zh-CN": [
|
||||
"1090 MHz",
|
||||
@@ -963,9 +945,7 @@
|
||||
"{value} KM/S",
|
||||
"ΔE",
|
||||
"ΔN",
|
||||
"▲ {frp} MW",
|
||||
"MTQ · CC BY 4.0",
|
||||
"OpenStreetMap contributors · ODbL"
|
||||
"▲ {frp} MW"
|
||||
],
|
||||
"zh-Hant": [
|
||||
"1090 MHz",
|
||||
@@ -1022,8 +1002,6 @@
|
||||
"{value} KM/S",
|
||||
"ΔE",
|
||||
"ΔN",
|
||||
"▲ {frp} MW",
|
||||
"MTQ · CC BY 4.0",
|
||||
"OpenStreetMap contributors · ODbL"
|
||||
"▲ {frp} MW"
|
||||
]
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
.qc-preview-panel {
|
||||
position: fixed;
|
||||
z-index: 2000;
|
||||
top: 80px;
|
||||
right: 24px;
|
||||
width: min(360px, calc(100vw - 32px));
|
||||
overflow: hidden;
|
||||
border: 1px solid #3eabbc;
|
||||
border-radius: 8px;
|
||||
background: #071117;
|
||||
color: #e6f8fb;
|
||||
box-shadow: 0 12px 36px #000a;
|
||||
font: 12px/1.4 sans-serif;
|
||||
}
|
||||
.qc-preview-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 10px;
|
||||
cursor: move;
|
||||
touch-action: none;
|
||||
}
|
||||
.qc-preview-panel button {
|
||||
border: 1px solid #43818d;
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
background: #102830;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.qc-preview-panel button:focus-visible {
|
||||
outline: 2px solid #82e9f7;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.qc-preview-live {
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: #000;
|
||||
}
|
||||
.qc-preview-panel .qc-preview-live[hidden],
|
||||
.qc-preview-marker video[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.qc-preview-panel select {
|
||||
display: block;
|
||||
width: calc(100% - 20px);
|
||||
margin: 0 10px 8px;
|
||||
border: 1px solid #43818d;
|
||||
border-radius: 4px;
|
||||
padding: 6px 8px;
|
||||
background: #102830;
|
||||
color: inherit;
|
||||
}
|
||||
.qc-preview-coordinates {
|
||||
margin: 0 10px 8px;
|
||||
color: #a9c5ca;
|
||||
font:
|
||||
10px/1.4 'Cascadia Code',
|
||||
Consolas,
|
||||
monospace;
|
||||
}
|
||||
.qc-preview-panel .qc-preview-live[hidden],
|
||||
.qc-preview-marker video[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.qc-preview-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 8px 10px 0;
|
||||
}
|
||||
.qc-preview-panel p {
|
||||
margin: 8px 10px 10px;
|
||||
color: #a9c5ca;
|
||||
}
|
||||
.qc-preview-panel a {
|
||||
display: inline-block;
|
||||
margin: 0 10px 10px;
|
||||
color: #82e9f7;
|
||||
}
|
||||
.qc-preview-marker {
|
||||
position: fixed;
|
||||
z-index: 500;
|
||||
width: 160px;
|
||||
transform: translate(-50%, -100%) scale(var(--qc-preview-scale, 1));
|
||||
transform-origin: 50% 100%;
|
||||
pointer-events: none;
|
||||
padding: 3px;
|
||||
border: 1px solid #65d7e7;
|
||||
background: #071117;
|
||||
color: #d8f9ff;
|
||||
font: 10px/1.3 sans-serif;
|
||||
}
|
||||
.qc-preview-marker[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.qc-preview-marker video {
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: #000;
|
||||
}
|
||||
.qc-preview-marker span {
|
||||
display: block;
|
||||
padding: 3px;
|
||||
}
|
||||
.qc-preview-marker-link-only {
|
||||
width: max-content;
|
||||
max-width: 220px;
|
||||
}
|
||||
.qc-preview-marker-link-only {
|
||||
width: max-content;
|
||||
max-width: 220px;
|
||||
}
|
||||
@@ -1,319 +0,0 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { horizonOccluder } from '../data/iconOrientation.js';
|
||||
import { msg, t } from '../i18n/index.js';
|
||||
|
||||
const CAMERA_ORIGIN = 'https://www.quebec511.info/Carte/Fenetres/camera.ashx';
|
||||
// i18n-ignore: provider and licence labels are required source attribution.
|
||||
const MTQ_ATTRIBUTION = msg('MTQ · CC BY 4.0');
|
||||
// i18n-ignore: OpenStreetMap credit is shown in its standard form.
|
||||
const OSM_ATTRIBUTION = msg('OpenStreetMap contributors · ODbL');
|
||||
|
||||
function directVideoUrl(camera) {
|
||||
return camera.kind === 'traffic' && /^\d+$/.test(camera.id)
|
||||
? `${CAMERA_ORIGIN}?id=${camera.id}&format=mp4`
|
||||
: '';
|
||||
}
|
||||
|
||||
function safeViewerUrl(value) {
|
||||
try {
|
||||
const url = new URL(String(value || ''));
|
||||
return url.protocol === 'https:' && !url.username && !url.password
|
||||
? url.href
|
||||
: '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/** Install the opt-in Québec camera browser preview and return its cleanup. */
|
||||
export function installQuebecCameraPreview(
|
||||
viewer,
|
||||
{ initialId = '3735', fetchImpl = globalThis.fetch } = {},
|
||||
) {
|
||||
const panel = document.createElement('section');
|
||||
panel.className = 'qc-preview-panel';
|
||||
panel.setAttribute('role', 'dialog');
|
||||
panel.setAttribute('aria-label', t('CCTV camera'));
|
||||
|
||||
const header = document.createElement('header');
|
||||
header.className = 'qc-preview-header';
|
||||
const title = document.createElement('strong');
|
||||
const close = document.createElement('button');
|
||||
close.type = 'button';
|
||||
close.textContent = '×';
|
||||
close.setAttribute('aria-label', t('Close camera preview'));
|
||||
header.append(title, close);
|
||||
|
||||
const select = document.createElement('select');
|
||||
select.setAttribute('aria-label', t('CCTV camera'));
|
||||
const coordinates = document.createElement('p');
|
||||
coordinates.className = 'qc-preview-coordinates';
|
||||
const live = document.createElement('video');
|
||||
live.className = 'qc-preview-live';
|
||||
live.controls = true;
|
||||
live.setAttribute('aria-label', t('CCTV camera'));
|
||||
live.autoplay = true;
|
||||
live.muted = true;
|
||||
live.loop = true;
|
||||
live.playsInline = true;
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'qc-preview-actions';
|
||||
const fly = document.createElement('button');
|
||||
fly.type = 'button';
|
||||
fly.textContent = t('FOCUS');
|
||||
const refresh = document.createElement('button');
|
||||
refresh.type = 'button';
|
||||
refresh.textContent = t('Refresh map still');
|
||||
actions.append(fly, refresh);
|
||||
const note = document.createElement('p');
|
||||
const source = document.createElement('a');
|
||||
source.target = '_blank';
|
||||
source.rel = 'noopener noreferrer';
|
||||
const viewerLink = document.createElement('a');
|
||||
viewerLink.target = '_blank';
|
||||
viewerLink.rel = 'noopener noreferrer';
|
||||
viewerLink.textContent = t("Open this image on the provider's site");
|
||||
panel.append(
|
||||
header,
|
||||
select,
|
||||
coordinates,
|
||||
live,
|
||||
actions,
|
||||
note,
|
||||
source,
|
||||
viewerLink,
|
||||
);
|
||||
|
||||
const marker = document.createElement('div');
|
||||
marker.className = 'qc-preview-marker';
|
||||
marker.hidden = true;
|
||||
const still = document.createElement('video');
|
||||
still.setAttribute('aria-hidden', 'true');
|
||||
still.muted = true;
|
||||
still.playsInline = true;
|
||||
still.preload = 'auto';
|
||||
const label = document.createElement('span');
|
||||
marker.append(still, label);
|
||||
document.body.append(panel, marker);
|
||||
|
||||
const controller = new AbortController();
|
||||
let disposed = false;
|
||||
let selected = null;
|
||||
let cameraPosition = null;
|
||||
let mapStillUrl = '';
|
||||
const cameraData = [];
|
||||
|
||||
function setMedia(video, url) {
|
||||
if (video.src === url) return;
|
||||
video.pause();
|
||||
video.removeAttribute('src');
|
||||
if (!url) {
|
||||
video.load();
|
||||
return;
|
||||
}
|
||||
video.src = url;
|
||||
video.load();
|
||||
}
|
||||
|
||||
function refreshStill() {
|
||||
if (!mapStillUrl) return;
|
||||
still.load();
|
||||
void still.play().catch(() => {});
|
||||
}
|
||||
|
||||
function useCamera(camera) {
|
||||
selected = camera;
|
||||
cameraPosition = Cesium.Cartesian3.fromDegrees(camera.lon, camera.lat, 10);
|
||||
const mediaUrl = directVideoUrl(camera);
|
||||
const viewerUrl = safeViewerUrl(camera.viewerUrl);
|
||||
title.textContent = camera.nameEn || camera.name;
|
||||
select.value = camera.id;
|
||||
// i18n-ignore: coordinates, accuracy and provenance are camera data.
|
||||
coordinates.textContent = `${camera.lat.toFixed(camera.kind === 'traffic' ? 5 : 6)}, ${camera.lon.toFixed(camera.kind === 'traffic' ? 5 : 6)} · ${camera.accuracyM ? `±${camera.accuracyM} m` : camera.positionSource}`;
|
||||
live.hidden = !mediaUrl;
|
||||
refresh.hidden = !mediaUrl;
|
||||
still.hidden = !mediaUrl;
|
||||
if (mediaUrl) {
|
||||
note.textContent = t(
|
||||
'The map still uses a separate request and may show a different moment.',
|
||||
);
|
||||
setMedia(live, mediaUrl);
|
||||
live.play().catch(() => {});
|
||||
if (mapStillUrl !== mediaUrl) {
|
||||
mapStillUrl = mediaUrl;
|
||||
setMedia(still, mediaUrl);
|
||||
refreshStill();
|
||||
}
|
||||
// i18n-ignore: this suffix is provider-provided location provenance.
|
||||
label.textContent = `${t('PREVIEW')} · ${camera.positionSource}`;
|
||||
marker.classList.remove('qc-preview-marker-link-only');
|
||||
} else {
|
||||
note.textContent = viewerUrl
|
||||
? t("Open this image on the provider's site")
|
||||
: t('Source unavailable');
|
||||
setMedia(live, '');
|
||||
setMedia(still, '');
|
||||
mapStillUrl = '';
|
||||
// i18n-ignore: camera names and accuracy are supplied as catalog data.
|
||||
label.textContent = camera.accuracyM
|
||||
? `${camera.name} · ±${camera.accuracyM} m`
|
||||
: camera.name;
|
||||
marker.classList.add('qc-preview-marker-link-only');
|
||||
}
|
||||
|
||||
const osmPosition =
|
||||
camera.kind === 'montreal' ||
|
||||
/openstreetmap|\bosm\b/i.test(camera.positionSource);
|
||||
const attribution =
|
||||
camera.kind === 'traffic'
|
||||
? t('Source: {label}', { label: MTQ_ATTRIBUTION })
|
||||
: osmPosition
|
||||
? t('Source: {label}', { label: OSM_ATTRIBUTION })
|
||||
: t('Source: {label}', {
|
||||
label: camera.operator || camera.positionSource || t('Source'),
|
||||
});
|
||||
source.textContent = attribution;
|
||||
const attributionUrl =
|
||||
camera.kind === 'traffic'
|
||||
? 'https://www.donneesquebec.ca/recherche/dataset/camera-de-circulation'
|
||||
: osmPosition
|
||||
? 'https://www.openstreetmap.org/copyright'
|
||||
: viewerUrl;
|
||||
source.href = attributionUrl || '#';
|
||||
source.hidden = !attributionUrl;
|
||||
viewerLink.href = viewerUrl || '#';
|
||||
viewerLink.hidden = !viewerUrl;
|
||||
updateMarker();
|
||||
}
|
||||
|
||||
function updateMarker() {
|
||||
if (!selected || !cameraPosition) {
|
||||
marker.hidden = true;
|
||||
return;
|
||||
}
|
||||
const canvas = viewer.scene.canvas;
|
||||
const point = viewer.scene.cartesianToCanvasCoordinates(cameraPosition);
|
||||
const visible =
|
||||
point &&
|
||||
horizonOccluder(viewer.camera).isPointVisible(cameraPosition) &&
|
||||
point.x >= 0 &&
|
||||
point.y >= 0 &&
|
||||
point.x <= canvas.clientWidth &&
|
||||
point.y <= canvas.clientHeight;
|
||||
marker.hidden = !visible;
|
||||
if (!visible) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
marker.style.left = `${rect.left + point.x}px`;
|
||||
marker.style.top = `${rect.top + point.y}px`;
|
||||
const distance = Cesium.Cartesian3.distance(
|
||||
viewer.camera.positionWC,
|
||||
cameraPosition,
|
||||
);
|
||||
marker.style.setProperty(
|
||||
'--qc-preview-scale',
|
||||
String(Math.min(1, 600 / Math.max(1, distance))),
|
||||
);
|
||||
}
|
||||
|
||||
select.addEventListener('change', () => {
|
||||
const camera = cameraData.find((item) => item.id === select.value);
|
||||
if (camera) useCamera(camera);
|
||||
});
|
||||
still.addEventListener('loadeddata', () => still.pause());
|
||||
still.addEventListener('error', () => {
|
||||
label.textContent = t('Source unavailable');
|
||||
});
|
||||
live.addEventListener('error', () => {
|
||||
note.textContent = t('Source unavailable');
|
||||
});
|
||||
refresh.addEventListener('click', refreshStill);
|
||||
fly.addEventListener('click', () => {
|
||||
if (!selected) return;
|
||||
viewer.camera.flyTo({
|
||||
destination: Cesium.Cartesian3.fromDegrees(
|
||||
selected.lon,
|
||||
selected.lat,
|
||||
600,
|
||||
),
|
||||
orientation: { heading: 0, pitch: Cesium.Math.toRadians(-90), roll: 0 },
|
||||
duration: 1.5,
|
||||
});
|
||||
});
|
||||
|
||||
header.addEventListener('pointerdown', (event) => {
|
||||
if (event.target.closest('button')) return;
|
||||
const rect = panel.getBoundingClientRect();
|
||||
const offsetX = event.clientX - rect.left;
|
||||
const offsetY = event.clientY - rect.top;
|
||||
header.setPointerCapture(event.pointerId);
|
||||
const move = (moveEvent) => {
|
||||
panel.style.left = `${Math.max(0, Math.min(window.innerWidth - panel.offsetWidth, moveEvent.clientX - offsetX))}px`;
|
||||
panel.style.top = `${Math.max(0, Math.min(window.innerHeight - panel.offsetHeight, moveEvent.clientY - offsetY))}px`;
|
||||
panel.style.right = 'auto';
|
||||
};
|
||||
const stop = () => {
|
||||
header.removeEventListener('pointermove', move);
|
||||
header.removeEventListener('pointerup', stop);
|
||||
header.removeEventListener('pointercancel', stop);
|
||||
};
|
||||
header.addEventListener('pointermove', move);
|
||||
header.addEventListener('pointerup', stop);
|
||||
header.addEventListener('pointercancel', stop);
|
||||
});
|
||||
|
||||
const removeRender = viewer.scene.postRender.addEventListener(updateMarker);
|
||||
const ready = (async () => {
|
||||
try {
|
||||
const response = await fetchImpl('/quebec-cameras.json', {
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) throw new Error(`Camera list HTTP ${response.status}`);
|
||||
const payload = await response.json();
|
||||
const cameras = Array.isArray(payload?.cameras) ? payload.cameras : [];
|
||||
if (!cameras.length) throw new Error('Empty Québec camera list');
|
||||
cameraData.push(...cameras);
|
||||
// i18n-ignore: these group names are provider/source categories.
|
||||
for (const kind of ['traffic', 'montreal', 'webcam']) {
|
||||
const group = document.createElement('optgroup');
|
||||
group.label =
|
||||
kind === 'traffic'
|
||||
? 'MTQ / Québec 511'
|
||||
: kind === 'montreal'
|
||||
? 'Montréal / OpenStreetMap'
|
||||
: 'Scenic webcams';
|
||||
for (const camera of cameras.filter((item) => item.kind === kind)) {
|
||||
const option = document.createElement('option');
|
||||
option.value = camera.id;
|
||||
// i18n-ignore: camera names and regions are provider catalog data.
|
||||
option.textContent = `${camera.nameEn || camera.name} · ${camera.region || camera.category}`;
|
||||
group.append(option);
|
||||
}
|
||||
if (group.children.length) select.append(group);
|
||||
}
|
||||
const chosen =
|
||||
cameras.find((camera) => camera.id === initialId) ||
|
||||
cameras.find((camera) => camera.id === '3735') ||
|
||||
cameras[0];
|
||||
useCamera(chosen);
|
||||
} catch {
|
||||
if (!disposed) note.textContent = t('Source unavailable');
|
||||
}
|
||||
})();
|
||||
|
||||
const dispose = () => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
controller.abort();
|
||||
removeRender();
|
||||
for (const video of [live, still]) {
|
||||
video.pause();
|
||||
video.removeAttribute('src');
|
||||
video.load();
|
||||
}
|
||||
panel.remove();
|
||||
marker.remove();
|
||||
};
|
||||
close.addEventListener('click', dispose);
|
||||
return { dispose, ready };
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { Window } from 'happy-dom';
|
||||
import * as Cesium from 'cesium';
|
||||
import { installQuebecCameraPreview } from './quebecCameraPreview.js';
|
||||
|
||||
test('Québec preview selects direct-feed and viewer-only cameras and scales the map card', async () => {
|
||||
const window = new Window({ url: 'http://localhost:4173/?qc-preview=all' });
|
||||
const previousWindow = globalThis.window;
|
||||
const previousDocument = globalThis.document;
|
||||
globalThis.window = window;
|
||||
globalThis.document = window.document;
|
||||
window.HTMLMediaElement.prototype.load = function () {};
|
||||
window.HTMLMediaElement.prototype.play = function () {
|
||||
return Promise.resolve();
|
||||
};
|
||||
window.HTMLMediaElement.prototype.pause = function () {
|
||||
this.dataset.paused = 'yes';
|
||||
};
|
||||
let removed = 0;
|
||||
let destination;
|
||||
let render;
|
||||
let point = { x: 200, y: 150 };
|
||||
const viewer = {
|
||||
camera: {
|
||||
positionWC: Cesium.Cartesian3.fromDegrees(-73.5502, 45.51651, 1000),
|
||||
flyTo: (options) => {
|
||||
destination = options.destination;
|
||||
},
|
||||
},
|
||||
scene: {
|
||||
canvas: {
|
||||
clientWidth: 800,
|
||||
clientHeight: 600,
|
||||
getBoundingClientRect: () => ({ left: 30, top: 40 }),
|
||||
},
|
||||
cartesianToCanvasCoordinates: () => point,
|
||||
postRender: {
|
||||
addEventListener: (callback) => {
|
||||
render = callback;
|
||||
return () => {
|
||||
removed++;
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const cameras = [
|
||||
{
|
||||
id: '3735',
|
||||
kind: 'traffic',
|
||||
name: 'Route 136 at rue Panet',
|
||||
nameEn: 'Route 136 at rue Panet',
|
||||
lat: 45.51651,
|
||||
lon: -73.5502,
|
||||
viewerUrl:
|
||||
'https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3735',
|
||||
positionSource: 'MTQ WFS',
|
||||
accuracyM: null,
|
||||
},
|
||||
{
|
||||
id: 'mtl-465',
|
||||
kind: 'montreal',
|
||||
name: '1re Avenue / Sortie A-20',
|
||||
lat: 45.44462,
|
||||
lon: -73.663552,
|
||||
viewerUrl: 'https://ville.montreal.qc.ca/circulation/node/465',
|
||||
positionSource: 'OSM node',
|
||||
accuracyM: 15,
|
||||
},
|
||||
];
|
||||
try {
|
||||
const preview = installQuebecCameraPreview(viewer, {
|
||||
initialId: '3735',
|
||||
fetchImpl: async () => ({ ok: true, json: async () => ({ cameras }) }),
|
||||
});
|
||||
await preview.ready;
|
||||
const videos = [...window.document.querySelectorAll('video')];
|
||||
assert.equal(videos.length, 2);
|
||||
assert.equal(
|
||||
videos[0].src,
|
||||
'https://www.quebec511.info/Carte/Fenetres/camera.ashx?id=3735&format=mp4',
|
||||
);
|
||||
assert.equal(videos[1].src, videos[0].src);
|
||||
assert.equal(videos[0].hidden, false);
|
||||
assert.match(
|
||||
window.document.querySelector('.qc-preview-coordinates').textContent,
|
||||
/45\.51651, -73\.55020/,
|
||||
);
|
||||
videos[1].dispatchEvent(new window.Event('loadeddata'));
|
||||
assert.equal(videos[1].dataset.paused, 'yes');
|
||||
|
||||
const marker = window.document.querySelector('.qc-preview-marker');
|
||||
assert.equal(marker.hidden, false);
|
||||
assert.equal(marker.style.left, '230px');
|
||||
assert.equal(marker.style.top, '190px');
|
||||
const nearScale = Number(
|
||||
marker.style.getPropertyValue('--qc-preview-scale'),
|
||||
);
|
||||
viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees(
|
||||
-73.5502,
|
||||
45.51651,
|
||||
80000,
|
||||
);
|
||||
render();
|
||||
assert.ok(
|
||||
Number(marker.style.getPropertyValue('--qc-preview-scale')) < nearScale,
|
||||
);
|
||||
|
||||
const select = window.document.querySelector('select');
|
||||
select.value = 'mtl-465';
|
||||
select.dispatchEvent(new window.Event('change'));
|
||||
assert.equal(videos[0].hidden, true);
|
||||
assert.equal(videos[1].hidden, true);
|
||||
assert.match(
|
||||
window.document.querySelectorAll(
|
||||
'.qc-preview-panel a[target="_blank"]',
|
||||
)[1].href,
|
||||
/ville\.montreal\.qc\.ca/,
|
||||
);
|
||||
window.document.querySelector('.qc-preview-actions button').click();
|
||||
assert.ok(destination);
|
||||
|
||||
point = { x: -1, y: 150 };
|
||||
render();
|
||||
assert.equal(marker.hidden, true);
|
||||
window.document.querySelector('.qc-preview-header button').click();
|
||||
preview.dispose();
|
||||
assert.equal(removed, 1);
|
||||
assert.equal(window.document.querySelectorAll('video').length, 0);
|
||||
} finally {
|
||||
globalThis.window = previousWindow;
|
||||
globalThis.document = previousDocument;
|
||||
window.close();
|
||||
}
|
||||
});
|
||||
+115
-34
@@ -49,13 +49,29 @@ function setMedia(video, url) {
|
||||
video.load();
|
||||
}
|
||||
|
||||
/** Treat an interrupted play() (a pause or a new source) as no failure. */
|
||||
function onPlayFailure(mark) {
|
||||
return (error) => {
|
||||
if (error?.name !== 'AbortError') mark();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Québec 511 serves a new clip at the same URL every few minutes, so a refresh
|
||||
* adds a timestamp to get past the browser's media cache.
|
||||
*/
|
||||
function mediaRefreshUrl(value) {
|
||||
const url = new URL(value);
|
||||
url.searchParams.set('_', String(Date.now()));
|
||||
return url.href;
|
||||
}
|
||||
|
||||
/** Attach the active Québec source to a floating browser player and map preview. */
|
||||
/**
|
||||
* Attach the active Québec source to a floating browser player and map
|
||||
* preview. The browser contacts Québec 511 only after the viewer presses Play
|
||||
* for that camera; selecting it (by click, auto-hop, a scene or voice) only
|
||||
* shows its details and the play control.
|
||||
*/
|
||||
export function createQuebecCctvOverlay(viewer) {
|
||||
if (!viewer?.scene || typeof document === 'undefined') return null;
|
||||
|
||||
@@ -64,7 +80,7 @@ export function createQuebecCctvOverlay(viewer) {
|
||||
const panel = document.createElement('section');
|
||||
panel.className = 'qci-panel';
|
||||
panel.hidden = true;
|
||||
panel.setAttribute('role', 'dialog');
|
||||
panel.setAttribute('role', 'region');
|
||||
panel.setAttribute('aria-label', t('CCTV camera'));
|
||||
|
||||
const header = document.createElement('header');
|
||||
@@ -91,10 +107,14 @@ export function createQuebecCctvOverlay(viewer) {
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'qci-actions';
|
||||
const play = document.createElement('button');
|
||||
play.type = 'button';
|
||||
play.className = 'qci-play';
|
||||
play.textContent = t('Play');
|
||||
const refresh = document.createElement('button');
|
||||
refresh.type = 'button';
|
||||
refresh.textContent = t('Refresh map still');
|
||||
actions.append(refresh);
|
||||
actions.append(play, refresh);
|
||||
const note = document.createElement('p');
|
||||
note.className = 'qci-note';
|
||||
const sourceLink = document.createElement('a');
|
||||
@@ -110,7 +130,7 @@ export function createQuebecCctvOverlay(viewer) {
|
||||
still.className = 'qci-marker-still';
|
||||
still.muted = true;
|
||||
still.playsInline = true;
|
||||
still.preload = 'auto';
|
||||
still.preload = 'none';
|
||||
still.setAttribute('aria-hidden', 'true');
|
||||
const markerLabel = document.createElement('span');
|
||||
markerLabel.className = 'qci-marker-label';
|
||||
@@ -121,7 +141,11 @@ export function createQuebecCctvOverlay(viewer) {
|
||||
let selectedPosition = null;
|
||||
let closedWindowCameraId = null;
|
||||
let selectedMediaUrl = '';
|
||||
/** Camera id the viewer pressed Play for; media loads only for this one. */
|
||||
let playingCameraId = null;
|
||||
let mediaRefreshTimer = 0;
|
||||
let removeRender = null;
|
||||
let canvasRect = null;
|
||||
let liveUnavailable = false;
|
||||
let stillUnavailable = false;
|
||||
|
||||
@@ -144,8 +168,18 @@ export function createQuebecCctvOverlay(viewer) {
|
||||
markerLabel.textContent = t('Source unavailable');
|
||||
}
|
||||
|
||||
function startRender() {
|
||||
if (!removeRender)
|
||||
removeRender = viewer.scene.postRender.addEventListener(updateMarker);
|
||||
}
|
||||
|
||||
function stopRender() {
|
||||
removeRender?.();
|
||||
removeRender = null;
|
||||
}
|
||||
|
||||
function stopMediaRefresh() {
|
||||
clearInterval(mediaRefreshTimer);
|
||||
window.clearInterval(mediaRefreshTimer);
|
||||
mediaRefreshTimer = 0;
|
||||
}
|
||||
|
||||
@@ -156,7 +190,7 @@ export function createQuebecCctvOverlay(viewer) {
|
||||
() => {
|
||||
if (document.hidden || closedWindowCameraId === selected?.id) return;
|
||||
setMedia(live, mediaRefreshUrl(selectedMediaUrl));
|
||||
void live.play().catch(markLiveUnavailable);
|
||||
void live.play().catch(onPlayFailure(markLiveUnavailable));
|
||||
refreshStill();
|
||||
},
|
||||
2 * 60 * 1000,
|
||||
@@ -164,10 +198,33 @@ export function createQuebecCctvOverlay(viewer) {
|
||||
}
|
||||
|
||||
function refreshStill() {
|
||||
if (!selectedMediaUrl) return;
|
||||
if (!selectedMediaUrl || playingCameraId !== selected?.id) return;
|
||||
setMedia(still, '');
|
||||
setMedia(still, mediaRefreshUrl(selectedMediaUrl));
|
||||
void still.play().catch(markStillUnavailable);
|
||||
void still.play().catch(onPlayFailure(markStillUnavailable));
|
||||
}
|
||||
|
||||
/** Load and play the selected camera's media: only from a Play click. */
|
||||
function startPlayback() {
|
||||
if (!selected || !selectedMediaUrl) return;
|
||||
playingCameraId = selected.id;
|
||||
liveUnavailable = false;
|
||||
stillUnavailable = false;
|
||||
play.hidden = true;
|
||||
refresh.hidden = false;
|
||||
live.hidden = false;
|
||||
still.hidden = false;
|
||||
marker.classList.remove('qci-marker-link-only');
|
||||
note.textContent = t(
|
||||
'The map still uses a separate request and may show a different moment.',
|
||||
);
|
||||
note.hidden = false;
|
||||
markerLabel.textContent = markerLabelText(selected, true);
|
||||
setMedia(live, selectedMediaUrl);
|
||||
setMedia(still, selectedMediaUrl);
|
||||
void live.play().catch(onPlayFailure(markLiveUnavailable));
|
||||
void still.play().catch(onPlayFailure(markStillUnavailable));
|
||||
startMediaRefresh();
|
||||
}
|
||||
|
||||
function setCamera(camera, { reselected = false } = {}) {
|
||||
@@ -176,9 +233,11 @@ export function createQuebecCctvOverlay(viewer) {
|
||||
selectedPosition = null;
|
||||
closedWindowCameraId = null;
|
||||
selectedMediaUrl = '';
|
||||
playingCameraId = null;
|
||||
liveUnavailable = false;
|
||||
stillUnavailable = false;
|
||||
stopMediaRefresh();
|
||||
stopRender();
|
||||
panel.hidden = true;
|
||||
marker.hidden = true;
|
||||
setMedia(live, '');
|
||||
@@ -189,6 +248,7 @@ export function createQuebecCctvOverlay(viewer) {
|
||||
const changed = selected?.id !== camera.id;
|
||||
if (changed || reselected) closedWindowCameraId = null;
|
||||
selected = camera;
|
||||
startRender();
|
||||
selectedPosition = Cesium.Cartesian3.fromDegrees(
|
||||
Number(camera.lon),
|
||||
Number(camera.lat),
|
||||
@@ -205,7 +265,9 @@ export function createQuebecCctvOverlay(viewer) {
|
||||
if (changed || mediaUrl !== selectedMediaUrl) {
|
||||
liveUnavailable = false;
|
||||
stillUnavailable = false;
|
||||
playingCameraId = null;
|
||||
}
|
||||
const playing = Boolean(mediaUrl) && playingCameraId === camera.id;
|
||||
title.textContent = camera.name || t('CCTV camera');
|
||||
coordinates.textContent = [
|
||||
`${Number(camera.lat).toFixed(5)}, ${Number(camera.lon).toFixed(5)}`,
|
||||
@@ -218,16 +280,19 @@ export function createQuebecCctvOverlay(viewer) {
|
||||
.join(' · ');
|
||||
credit.textContent = camera.credit || camera.provider || '';
|
||||
credit.hidden = !credit.textContent;
|
||||
live.hidden = !mediaUrl;
|
||||
refresh.hidden = !mediaUrl;
|
||||
live.hidden = !playing;
|
||||
play.hidden = !mediaUrl || playing;
|
||||
refresh.hidden = !playing;
|
||||
actions.hidden = !mediaUrl;
|
||||
note.hidden = !mediaUrl;
|
||||
note.textContent = mediaUrl
|
||||
? liveUnavailable
|
||||
? t('Source unavailable')
|
||||
: t(
|
||||
'The map still uses a separate request and may show a different moment.',
|
||||
)
|
||||
? !playing
|
||||
? t('Video plays from Québec 511, which sees your IP address.')
|
||||
: liveUnavailable
|
||||
? t('Source unavailable')
|
||||
: t(
|
||||
'The map still uses a separate request and may show a different moment.',
|
||||
)
|
||||
: sourceUrl
|
||||
? ''
|
||||
: t('Source unavailable');
|
||||
@@ -236,27 +301,24 @@ export function createQuebecCctvOverlay(viewer) {
|
||||
sourceLink.hidden = !sourceUrl;
|
||||
markerLabel.textContent = stillUnavailable
|
||||
? t('Source unavailable')
|
||||
: markerLabelText(camera, mediaUrl);
|
||||
marker.classList.toggle('qci-marker-link-only', !mediaUrl);
|
||||
still.hidden = !mediaUrl;
|
||||
: markerLabelText(camera, playing);
|
||||
marker.classList.toggle('qci-marker-link-only', !playing);
|
||||
still.hidden = !playing;
|
||||
panel.hidden = false;
|
||||
|
||||
if (changed || mediaUrl !== selectedMediaUrl) {
|
||||
selectedMediaUrl = mediaUrl;
|
||||
setMedia(live, mediaUrl);
|
||||
setMedia(still, mediaUrl);
|
||||
if (mediaUrl) {
|
||||
void live.play().catch(markLiveUnavailable);
|
||||
void still.play().catch(markStillUnavailable);
|
||||
startMediaRefresh();
|
||||
} else {
|
||||
stopMediaRefresh();
|
||||
}
|
||||
} else if (reselected && mediaUrl) {
|
||||
// A new camera never inherits playback: the browser stays off 511
|
||||
// until the viewer presses Play for it.
|
||||
stopMediaRefresh();
|
||||
setMedia(live, '');
|
||||
setMedia(still, '');
|
||||
} else if (reselected && playing) {
|
||||
if (live.error || live.networkState === 3 || live.readyState < 2) {
|
||||
setMedia(live, mediaRefreshUrl(mediaUrl));
|
||||
}
|
||||
if (live.paused) void live.play().catch(markLiveUnavailable);
|
||||
if (live.paused)
|
||||
void live.play().catch(onPlayFailure(markLiveUnavailable));
|
||||
startMediaRefresh();
|
||||
if (still.error || still.networkState === 3 || still.readyState < 2)
|
||||
refreshStill();
|
||||
@@ -280,7 +342,10 @@ export function createQuebecCctvOverlay(viewer) {
|
||||
point.y <= canvas.clientHeight;
|
||||
marker.hidden = !visible;
|
||||
if (!visible) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
// Measured once per resize or scroll, not every frame, so the per-frame
|
||||
// style writes never force a layout.
|
||||
canvasRect ||= canvas.getBoundingClientRect();
|
||||
const rect = canvasRect;
|
||||
marker.style.left = `${rect.left + point.x}px`;
|
||||
marker.style.top = `${rect.top + point.y}px`;
|
||||
const distance = Cesium.Cartesian3.distance(
|
||||
@@ -320,6 +385,23 @@ export function createQuebecCctvOverlay(viewer) {
|
||||
{ signal },
|
||||
);
|
||||
refresh.addEventListener('click', refreshStill, { signal });
|
||||
play.addEventListener('click', startPlayback, { signal });
|
||||
const resetCanvasRect = () => {
|
||||
canvasRect = null;
|
||||
};
|
||||
window.addEventListener('resize', resetCanvasRect, { signal });
|
||||
window.addEventListener('scroll', resetCanvasRect, {
|
||||
signal,
|
||||
capture: true,
|
||||
passive: true,
|
||||
});
|
||||
panel.addEventListener(
|
||||
'keydown',
|
||||
(event) => {
|
||||
if (event.key === 'Escape') close.click();
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
close.addEventListener(
|
||||
'click',
|
||||
() => {
|
||||
@@ -359,25 +441,24 @@ export function createQuebecCctvOverlay(viewer) {
|
||||
document.addEventListener(
|
||||
'visibilitychange',
|
||||
() => {
|
||||
if (!selectedMediaUrl) return;
|
||||
if (!selectedMediaUrl || playingCameraId !== selected?.id) return;
|
||||
if (document.hidden) {
|
||||
live.pause();
|
||||
still.pause();
|
||||
} else if (closedWindowCameraId !== selected?.id) {
|
||||
void live.play().catch(markLiveUnavailable);
|
||||
void live.play().catch(onPlayFailure(markLiveUnavailable));
|
||||
refreshStill();
|
||||
}
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
|
||||
const removeRender = viewer.scene.postRender.addEventListener(updateMarker);
|
||||
return {
|
||||
setCamera,
|
||||
dispose() {
|
||||
controller.abort();
|
||||
stopMediaRefresh();
|
||||
removeRender();
|
||||
stopRender();
|
||||
setMedia(live, '');
|
||||
setMedia(still, '');
|
||||
panel.remove();
|
||||
|
||||
@@ -4,18 +4,48 @@ import { Window } from 'happy-dom';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createQuebecCctvOverlay } from './quebecCctvOverlay.js';
|
||||
|
||||
test('routine same-camera updates keep Québec feed errors visible until recovery', async () => {
|
||||
const MEDIA_URL =
|
||||
'https://www.quebec511.info/Carte/Fenetres/camera.ashx?id=3735&format=mp4';
|
||||
|
||||
const CAMERA = Object.freeze({
|
||||
id: 'qc-511-3735',
|
||||
sourceKind: 'quebec-511-browser',
|
||||
name: 'Route 136 at rue Panet',
|
||||
lat: 45.51651,
|
||||
lon: -73.5502,
|
||||
positionSource: 'MTQ georeferenced point',
|
||||
browserMediaUrl: MEDIA_URL,
|
||||
sourceUrl:
|
||||
'https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3735',
|
||||
});
|
||||
|
||||
/**
|
||||
* Mount the overlay in a happy-dom window with recorded media loads, timers
|
||||
* and postRender listeners. `play` decides what each play() returns.
|
||||
*/
|
||||
function mountOverlay(t, { play = () => Promise.resolve() } = {}) {
|
||||
const window = new Window({ url: 'http://localhost:4173/' });
|
||||
const previousWindow = globalThis.window;
|
||||
const previousDocument = globalThis.document;
|
||||
globalThis.window = window;
|
||||
globalThis.document = window.document;
|
||||
window.HTMLMediaElement.prototype.load = function () {};
|
||||
const loads = [];
|
||||
window.HTMLMediaElement.prototype.load = function () {
|
||||
if (this.getAttribute('src')) loads.push(this.getAttribute('src'));
|
||||
};
|
||||
window.HTMLMediaElement.prototype.play = function () {
|
||||
return Promise.resolve();
|
||||
return play(this);
|
||||
};
|
||||
window.HTMLMediaElement.prototype.pause = function () {};
|
||||
window.setInterval = () => 0;
|
||||
const intervals = new Map();
|
||||
let nextTimer = 1;
|
||||
window.setInterval = (fn) => {
|
||||
const id = nextTimer++;
|
||||
intervals.set(id, fn);
|
||||
return id;
|
||||
};
|
||||
window.clearInterval = (id) => intervals.delete(id);
|
||||
const renderListeners = new Set();
|
||||
const viewer = {
|
||||
camera: {
|
||||
positionWC: Cesium.Cartesian3.fromDegrees(-73.5502, 45.51651, 1000),
|
||||
@@ -23,49 +53,140 @@ test('routine same-camera updates keep Québec feed errors visible until recover
|
||||
scene: {
|
||||
canvas: { clientWidth: 800, clientHeight: 600 },
|
||||
cartesianToCanvasCoordinates: () => null,
|
||||
postRender: { addEventListener: () => () => {} },
|
||||
postRender: {
|
||||
addEventListener(fn) {
|
||||
renderListeners.add(fn);
|
||||
return () => renderListeners.delete(fn);
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
let overlay;
|
||||
|
||||
try {
|
||||
overlay = createQuebecCctvOverlay(viewer);
|
||||
const camera = {
|
||||
id: 'qc-511-3735',
|
||||
sourceKind: 'quebec-511-browser',
|
||||
name: 'Route 136 at rue Panet',
|
||||
lat: 45.51651,
|
||||
lon: -73.5502,
|
||||
positionSource: 'MTQ georeferenced point',
|
||||
browserMediaUrl:
|
||||
'https://www.quebec511.info/Carte/Fenetres/camera.ashx?id=3735&format=mp4',
|
||||
sourceUrl:
|
||||
'https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3735',
|
||||
};
|
||||
overlay.setCamera(camera);
|
||||
const [live, still] = window.document.querySelectorAll('video');
|
||||
live.dispatchEvent(new window.Event('error'));
|
||||
still.dispatchEvent(new window.Event('error'));
|
||||
|
||||
overlay.setCamera({ ...camera });
|
||||
|
||||
assert.equal(window.document.querySelector('.qci-note').textContent, 'Source unavailable');
|
||||
assert.equal(window.document.querySelector('.qci-marker-label').textContent, 'Source unavailable');
|
||||
|
||||
live.dispatchEvent(new window.Event('loadeddata'));
|
||||
still.dispatchEvent(new window.Event('loadeddata'));
|
||||
assert.match(
|
||||
window.document.querySelector('.qci-note').textContent,
|
||||
/separate request/,
|
||||
);
|
||||
assert.match(
|
||||
window.document.querySelector('.qci-marker-label').textContent,
|
||||
/^PREVIEW ·/,
|
||||
);
|
||||
} finally {
|
||||
const overlay = createQuebecCctvOverlay(viewer);
|
||||
t.after(() => {
|
||||
overlay?.dispose();
|
||||
globalThis.window = previousWindow;
|
||||
globalThis.document = previousDocument;
|
||||
window.close();
|
||||
}
|
||||
});
|
||||
const [live, still] = window.document.querySelectorAll('video');
|
||||
return {
|
||||
window,
|
||||
overlay,
|
||||
loads,
|
||||
intervals,
|
||||
renderListeners,
|
||||
live,
|
||||
still,
|
||||
note: () => window.document.querySelector('.qci-note').textContent,
|
||||
label: () => window.document.querySelector('.qci-marker-label').textContent,
|
||||
playButton: () => window.document.querySelector('.qci-play'),
|
||||
};
|
||||
}
|
||||
|
||||
test('selecting a Québec camera contacts Québec 511 only after Play', (t) => {
|
||||
const ui = mountOverlay(t);
|
||||
assert.equal(ui.renderListeners.size, 0, 'no per-frame work before a camera');
|
||||
|
||||
ui.overlay.setCamera(CAMERA);
|
||||
assert.deepEqual(ui.loads, [], 'selection alone loads no 511 media');
|
||||
assert.equal(ui.intervals.size, 0, 'and starts no refresh timer');
|
||||
assert.equal(ui.playButton().hidden, false);
|
||||
assert.match(ui.note(), /sees your IP address/);
|
||||
assert.equal(ui.renderListeners.size, 1);
|
||||
|
||||
ui.playButton().click();
|
||||
assert.deepEqual(ui.loads, [MEDIA_URL, MEDIA_URL], 'live player and map still');
|
||||
assert.equal(ui.playButton().hidden, true);
|
||||
assert.equal(ui.intervals.size, 1);
|
||||
|
||||
// A different camera never inherits playback.
|
||||
ui.loads.length = 0;
|
||||
ui.overlay.setCamera({
|
||||
...CAMERA,
|
||||
id: 'qc-511-4103',
|
||||
browserMediaUrl: MEDIA_URL.replace('3735', '4103'),
|
||||
});
|
||||
assert.deepEqual(ui.loads, []);
|
||||
assert.equal(ui.intervals.size, 0);
|
||||
assert.equal(ui.playButton().hidden, false);
|
||||
|
||||
ui.overlay.setCamera(null);
|
||||
assert.equal(ui.renderListeners.size, 0, 'deselect drops the per-frame listener');
|
||||
});
|
||||
|
||||
test('a camera without browser media shows only its provider link', (t) => {
|
||||
const ui = mountOverlay(t);
|
||||
ui.overlay.setCamera({ ...CAMERA, browserMediaUrl: '' });
|
||||
assert.equal(ui.playButton().hidden, true);
|
||||
assert.deepEqual(ui.loads, []);
|
||||
const link = ui.window.document.querySelector('.qci-panel a');
|
||||
assert.equal(link.hidden, false);
|
||||
assert.equal(link.href, CAMERA.sourceUrl);
|
||||
});
|
||||
|
||||
test('routine same-camera updates keep Québec feed errors visible until recovery', (t) => {
|
||||
const ui = mountOverlay(t);
|
||||
ui.overlay.setCamera(CAMERA);
|
||||
ui.playButton().click();
|
||||
ui.live.dispatchEvent(new ui.window.Event('error'));
|
||||
ui.still.dispatchEvent(new ui.window.Event('error'));
|
||||
|
||||
ui.overlay.setCamera({ ...CAMERA });
|
||||
|
||||
assert.equal(ui.note(), 'Source unavailable');
|
||||
assert.equal(ui.label(), 'Source unavailable');
|
||||
|
||||
ui.live.dispatchEvent(new ui.window.Event('loadeddata'));
|
||||
ui.still.dispatchEvent(new ui.window.Event('loadeddata'));
|
||||
assert.match(ui.note(), /separate request/);
|
||||
assert.match(ui.label(), /^PREVIEW ·/);
|
||||
});
|
||||
|
||||
test('an interrupted play() is not reported as an unavailable source', async (t) => {
|
||||
const abort = () =>
|
||||
Promise.reject(new DOMException('interrupted by pause()', 'AbortError'));
|
||||
const ui = mountOverlay(t, { play: abort });
|
||||
ui.overlay.setCamera(CAMERA);
|
||||
ui.playButton().click();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.match(ui.note(), /separate request/);
|
||||
assert.match(ui.label(), /^PREVIEW ·/);
|
||||
});
|
||||
|
||||
test('a real play() failure is reported', async (t) => {
|
||||
const fail = () =>
|
||||
Promise.reject(new DOMException('no supported source', 'NotSupportedError'));
|
||||
const ui = mountOverlay(t, { play: fail });
|
||||
ui.overlay.setCamera(CAMERA);
|
||||
ui.playButton().click();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(ui.note(), 'Source unavailable');
|
||||
});
|
||||
|
||||
test('the refresh timer reloads with a cache-buster, skips hidden tabs, and stops on dispose', (t) => {
|
||||
const ui = mountOverlay(t);
|
||||
ui.overlay.setCamera(CAMERA);
|
||||
ui.playButton().click();
|
||||
const [refresh] = ui.intervals.values();
|
||||
|
||||
ui.loads.length = 0;
|
||||
Object.defineProperty(ui.window.document, 'hidden', {
|
||||
configurable: true,
|
||||
get: () => true,
|
||||
});
|
||||
refresh();
|
||||
assert.deepEqual(ui.loads, [], 'a hidden tab does not refresh');
|
||||
|
||||
Object.defineProperty(ui.window.document, 'hidden', {
|
||||
configurable: true,
|
||||
get: () => false,
|
||||
});
|
||||
refresh();
|
||||
assert.equal(ui.loads.length, 2);
|
||||
for (const src of ui.loads) assert.match(src, /[?&]_=\d+$/);
|
||||
|
||||
ui.overlay.dispose();
|
||||
assert.equal(ui.intervals.size, 0);
|
||||
assert.equal(ui.renderListeners.size, 0);
|
||||
assert.equal(ui.window.document.querySelector('.qci-panel'), null);
|
||||
});
|
||||
Reference in new issue
Block a user