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:
Said Turksever committed 2026-11-03 15:31:08 +00:00
1 parent fa4ce4b2a5
commit db8afbae81
43 files changed
+1327 -1110

No files matched your search

+3 -3
View File
@@ -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);
});
+65
View File
@@ -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);
+6 -3
View File
@@ -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,
+4 -4
View File
@@ -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(
+3 -3
View File
@@ -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);
});
+87 -32
View File
@@ -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);
});
+76 -1
View File
@@ -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);
});
+3 -3
View File
@@ -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);
});
+306
View File
@@ -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');
});
+2 -3
View File
@@ -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 الخاص بك."
}
+2 -3
View File
@@ -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."
}
+2 -3
View File
@@ -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."
}
+2 -3
View File
@@ -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."
}
+2 -3
View File
@@ -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."
}
+2 -3
View File
@@ -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 アドレスが見えます。"
}
+2 -3
View File
@@ -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."
}
+2 -3
View File
@@ -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-адрес."
}
+2 -3
View File
@@ -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-адресу."
}
+2 -3
View File
@@ -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 地址。"
}
+2 -3
View File
@@ -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 位址。"
}
+11 -33
View File
@@ -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"
]
}
-114
View File
@@ -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;
}
-319
View File
@@ -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 };
}
-136
View File
@@ -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
View File
@@ -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();
+164 -43
View File
@@ -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);
});