feat(cctv): add Quebec, Ottawa, and World feeds

This commit is contained in:
InoukT committed 2026-11-02 22:57:22 -05:00
1 parent 72ceeb2fce
commit fa4ce4b2a5
59 files changed
+40815 -88

No files matched your search

+2 -2
View File
@@ -384,6 +384,6 @@ test('CCTV_CALGARY_ENABLED=0 keeps the lane from being loaded at all', async (t)
}
});
test('the shipped catalog ceiling is not raised to make room for this pack', () => {
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 4000);
test('the default catalog preserves shared, Québec, and World image slots', () => {
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 7516);
});
+2
View File
@@ -14,6 +14,7 @@ import {
DEFAULT_ONTARIO_MAX_SOURCES,
DEFAULT_FINTRAFFIC_MAX_SOURCES,
DEFAULT_TXDOT_MAX_SOURCES,
DEFAULT_OTTAWA_MAX_SOURCES,
DEFAULT_TALLINN_MAX_SOURCES,
DEFAULT_TARKTEE_MAX_SOURCES,
DEFAULT_NSW_MAX_SOURCES,
@@ -231,6 +232,7 @@ test('default caps of the original packs fit inside the default catalog cap', ()
DEFAULT_ONTARIO_MAX_SOURCES +
DEFAULT_FINTRAFFIC_MAX_SOURCES +
DEFAULT_DRIVEBC_MAX_SOURCES +
DEFAULT_OTTAWA_MAX_SOURCES +
DEFAULT_TXDOT_MAX_SOURCES +
DEFAULT_TALLINN_MAX_SOURCES +
DEFAULT_TARKTEE_MAX_SOURCES +
+2 -2
View File
@@ -396,8 +396,8 @@ test('CCTV_HK_ENABLED=0 keeps the lane from being loaded at all', async (t) => {
}
});
test('the shipped catalog ceiling is not raised to make room for this pack', () => {
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 4000);
test('the default catalog preserves shared, Québec, and World image slots', () => {
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 7516);
});
test('the HK pack default sits above the full TD catalog and anchors the New Territories', () => {
+2 -2
View File
@@ -375,8 +375,8 @@ for (const [label, flag] of [
});
}
test('the shipped catalog ceiling is not raised to make room for this pack', () => {
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 4000);
test('the default catalog preserves shared, Québec, and World image slots', () => {
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 7516);
assert.equal(DEFAULT_NYC_MAX_SOURCES, 250);
});
+200
View File
@@ -0,0 +1,200 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
import {
OTTAWA_CAMERAS_URL,
OTTAWA_IMAGE_ORIGIN,
} from '../../server/providers/cctv/constants.js';
import {
fallbackHeadingFromId,
pinnedCameraUrl,
} from '../../server/providers/cctv/normalize.js';
import { loadOttawaSourcesFromOpenData } from '../../server/providers/cctv/ottawa.js';
/** Set (or, for `undefined`, delete) environment variables for one test. */
function withEnv(t, env) {
for (const [name, value] of Object.entries(env)) {
const previous = process.env[name];
t.after(() => {
if (previous === undefined) delete process.env[name];
else process.env[name] = previous;
});
if (value === undefined) delete process.env[name];
else process.env[name] = value;
}
}
/** Silence the loader's progress and failure logging. */
function quiet(t) {
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
}
/** One `/map/camera_list` row carrying the fields the loader reads. */
function ottawaRow(id, lat, lon, overrides = {}) {
return {
id,
number: 2000 + id,
description: `Camera ${id}`,
descriptionFr: `Caméra ${id}`,
type: 'City',
latitude: lat,
longitude: lon,
...overrides,
};
}
test('Ottawa loader keeps in-city cameras and builds frame URLs from the id', 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',
}),
ottawaRow(1, 45.4215, -75.6972, {
description: 'Bank St at Somerset St',
type: 'City',
}),
ottawaRow(0, 45.4215, -75.6972),
ottawaRow(-3, 45.4215, -75.6972),
ottawaRow(7, null, -75.7),
ottawaRow(8, 45.4, null),
ottawaRow(9, 43.6532, -79.3832, { description: 'Toronto' }),
ottawaRow(10, 45.9, -75.7),
]);
});
const cameras = await loadOttawaSourcesFromOpenData();
assert.deepEqual(requested, [OTTAWA_CAMERAS_URL]);
assert.deepEqual(cameras.map((camera) => camera.id).sort(), [
'ottawa-1',
'ottawa-309',
]);
const mto = cameras.find((camera) => camera.id === 'ottawa-309');
const city = cameras.find((camera) => camera.id === 'ottawa-1');
assert.equal(mto.url, 'https://traffic.ottawa.ca/map/camera?id=309');
assert.equal(mto.snapshotUrl, mto.url);
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');
assert.equal(mto.feedType, 'image');
assert.equal(mto.sourceKind, 'ottawa-traffic');
assert.equal(mto.headingConfidence, 'low');
assert.equal(
mto.headingDeg,
fallbackHeadingFromId('ottawa-309'),
'the feed publishes no bearing, so the id-hash fallback is deterministic',
);
assert.match(mto.license, /City of Ottawa/);
assert.equal(
mto.credit,
'Ministry of Transportation of Ontario (MTO) camera',
'a provincial camera carried on the city feed names its owner',
);
assert.equal(city.credit, '', 'a city camera carries no partner credit');
});
test('Ottawa loader keeps the cameras nearest downtown under its cap', async (t) => {
quiet(t);
withEnv(t, { CCTV_OTTAWA_MAX_SOURCES: '8' });
const rows = [
// Far suburbs come first in payload order, so only distance can drop them.
...[45.55, 45.54, 45.53, 45.52].map((lat, i) =>
ottawaRow(100 + i, lat, -76.1),
),
...[45.42, 45.421, 45.422, 45.423, 45.424, 45.425, 45.426, 45.427].map(
(lat, i) => ottawaRow(200 + i, lat, -75.7),
),
];
t.mock.method(globalThis, 'fetch', async () => Response.json(rows));
const cameras = await loadOttawaSourcesFromOpenData();
assert.equal(cameras.length, 8);
assert.deepEqual(
cameras.map((camera) => camera.id).filter((id) => /^ottawa-10\d$/.test(id)),
[],
'the far-suburb cameras are the ones dropped',
);
});
test('Ottawa loader fails soft on HTTP errors, unexpected payloads and network errors', async (t) => {
quiet(t);
let respond;
t.mock.method(globalThis, 'fetch', (...args) => respond(...args));
for (respond of [
async () => new Response('unavailable', { status: 503 }),
async () => Response.json({ cameras: [] }),
async () => {
throw new TypeError('fetch failed');
},
]) {
assert.deepEqual(await loadOttawaSourcesFromOpenData(), []);
}
});
test('CCTV catalog merges Ottawa cameras 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 }));
withEnv(t, {
CCTV_SOURCES_FILE: undefined,
CCTV_SOURCES_JSON: undefined,
CCTV_FORCE_AUSTIN: undefined,
CCTV_PREFER_AUSTIN: undefined,
CCTV_MAX_SOURCES: undefined,
CCTV_OTTAWA_ENABLED: undefined,
CCTV_OTTAWA_MAX_SOURCES: undefined,
});
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)])
: new Response('unavailable', { status: 503 });
});
const sources = await createCctvCatalog({ sourceRoot })();
assert.deepEqual(
sources.map((source) => source.id),
['ottawa-1'],
);
assert.equal(sources[0].url, 'https://traffic.ottawa.ca/map/camera?id=1');
process.env.CCTV_OTTAWA_ENABLED = '0';
requested.length = 0;
assert.deepEqual(await createCctvCatalog({ sourceRoot })(), []);
assert.ok(requested.length > 0, 'the other live packs still load');
assert.equal(
requested.includes(OTTAWA_CAMERAS_URL),
false,
'a disabled pack makes no request',
);
});
test('Ottawa frames stay inside the city image directory', () => {
assert.ok(
pinnedCameraUrl(
'https://traffic.ottawa.ca/map/camera?id=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',
])
assert.equal(pinnedCameraUrl(url, OTTAWA_IMAGE_ORIGIN), null, url);
});
+2 -2
View File
@@ -418,6 +418,6 @@ test('CCTV_TORONTO_ENABLED=0 keeps the lane from being loaded at all', async (t)
}
});
test('the shipped catalog ceiling is not raised to make room for this pack', () => {
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 4000);
test('the default catalog preserves shared, Québec, and World image slots', () => {
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 7516);
});
+5
View File
@@ -1,4 +1,9 @@
{
"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.": "تستخدم صورة الخريطة طلبًا منفصلًا وقد تعرض لحظة مختلفة.",
"NO PLACE LEFT BEHIND": "لا مكان يُترك خلفنا",
"Initializing photorealistic world...": "جارٍ تهيئة العالم الواقعي…",
"Configuring viewer...": "جارٍ إعداد العارض…",
+5
View File
@@ -1,4 +1,9 @@
{
"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.",
"Flying to Austin, TX...": "Flug nach Austin, TX...",
"Restoring shared view...": "Geteilte Ansicht wird wiederhergestellt...",
"Configuring viewer...": "Viewer wird konfiguriert...",
+5
View File
@@ -1,4 +1,9 @@
{
"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.",
"NO PLACE LEFT BEHIND": "NINGÚN RINCÓN SIN VIGILAR",
"Initializing photorealistic world...": "Inicializando el mundo fotorrealista...",
"Configuring viewer...": "Configurando el visor...",
+5
View File
@@ -1,4 +1,9 @@
{
"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.",
"Pick a shape, then click the map.": "Choisissez une forme, puis cliquez sur la carte.",
"Enter to place the pin, Esc to cancel.": "Enter pour poser l'épingle, Esc pour annuler.",
"Click where the pin goes.": "Cliquez à l'endroit où poser l'épingle.",
+5
View File
@@ -1,4 +1,9 @@
{
"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.",
"Pick a shape, then click the map.": "Scegli una forma, poi fai clic sulla mappa.",
"Enter to place the pin, Esc to cancel.": "Premi Enter per posizionare il segnaposto, Esc per annullare.",
"Click where the pin goes.": "Fai clic dove va il segnaposto.",
+5
View File
@@ -1,4 +1,9 @@
{
"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.": "地図上の静止画は別のリクエストで取得されるため、異なる時点を表示する場合があります。",
"Pick a shape, then click the map.": "図形を選んでから、地図をクリックしてください。",
"Enter to place the pin, Esc to cancel.": "Enterでピンを配置、Escでキャンセル。",
"Click where the pin goes.": "ピンを置く場所をクリックしてください。",
+5
View File
@@ -1,4 +1,9 @@
{
"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.",
"Pick a shape, then click the map.": "Escolha uma forma e clique no mapa.",
"Enter to place the pin, Esc to cancel.": "Enter para colocar o marcador, Esc para cancelar.",
"Click where the pin goes.": "Clique onde o marcador deve ficar.",
+5
View File
@@ -1,4 +1,9 @@
{
"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.": "Кадр на карте запрашивается отдельно и может показывать другой момент.",
"Flying to Austin, TX...": "Перелёт в Остин, Техас...",
"Restoring shared view...": "Восстановление общего вида...",
"Initializing systems...": "Инициализация систем...",
+5
View File
@@ -1,4 +1,9 @@
{
"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.": "Кадр на карті запитується окремо та може показувати інший момент.",
"Pick a shape, then click the map.": "Виберіть фігуру й клацніть на карті.",
"Enter to place the pin, Esc to cancel.": "Enter — поставити мітку, Esc — скасувати.",
"Click where the pin goes.": "Клацніть там, де має бути мітка.",
+5
View File
@@ -1,4 +1,9 @@
{
"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.": "地图静止画面通过单独的请求获取,可能显示不同的时刻。",
"Pick a shape, then click the map.": "先选择形状,再在地图上点击。",
"Enter to place the pin, Esc to cancel.": "按 Enter 放置图钉,按 Esc 取消。",
"Click where the pin goes.": "点击图钉的放置位置。",
+5
View File
@@ -1,4 +1,9 @@
{
"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.": "地圖靜態畫面使用獨立請求,顯示的時間可能不同。",
"Pick a shape, then click the map.": "先選擇形狀,再在地圖上點擊。",
"Enter to place the pin, Esc to cancel.": "按 Enter 放置圖釘,按 Esc 取消。",
"Click where the pin goes.": "點擊圖釘的放置位置。",
+33 -11
View File
@@ -31,7 +31,9 @@
"dB",
"hPa",
"{source} HTTP {status}",
"▲ {frp} MW"
"▲ {frp} MW",
"MTQ · CC BY 4.0",
"OpenStreetMap contributors · ODbL"
],
"de": [
"1090 MHz",
@@ -199,7 +201,9 @@
"{value} KM/S",
"ΔN",
"Ⅱ PAUSE",
"▲ {frp} MW"
"▲ {frp} MW",
"MTQ · CC BY 4.0",
"OpenStreetMap contributors · ODbL"
],
"es": [
"1090 MHz",
@@ -327,7 +331,9 @@
"{value} KM/S",
"ΔE",
"ΔN",
"▲ {frp} MW"
"▲ {frp} MW",
"MTQ · CC BY 4.0",
"OpenStreetMap contributors · ODbL"
],
"fr": [
"1090 MHz",
@@ -508,7 +514,9 @@
"ΔE",
"ΔN",
"Ⅱ PAUSE",
"▲ {frp} MW"
"▲ {frp} MW",
"MTQ · CC BY 4.0",
"OpenStreetMap contributors · ODbL"
],
"it": [
"1090 MHz",
@@ -679,7 +687,9 @@
"{value} conf",
"ΔE",
"ΔN",
"▲ {frp} MW"
"▲ {frp} MW",
"MTQ · CC BY 4.0",
"OpenStreetMap contributors · ODbL"
],
"ja": [
"1090 MHz",
@@ -728,7 +738,9 @@
"{type} SPD: {speed} HDG: {heading}",
"ΔE",
"ΔN",
"▲ {frp} MW"
"▲ {frp} MW",
"MTQ · CC BY 4.0",
"OpenStreetMap contributors · ODbL"
],
"pt": [
"1090 MHz",
@@ -857,7 +869,9 @@
"{value} KM/H",
"{value} KM/S",
"ΔN",
"▲ {frp} MW"
"▲ {frp} MW",
"MTQ · CC BY 4.0",
"OpenStreetMap contributors · ODbL"
],
"ru": [
"AIS: --",
@@ -875,7 +889,9 @@
"S2 GhostMaps",
"S2 · {name}",
"VS",
"WHOT/BHOT"
"WHOT/BHOT",
"MTQ · CC BY 4.0",
"OpenStreetMap contributors · ODbL"
],
"uk": [
"AIS: --",
@@ -888,7 +904,9 @@
"NVG",
"REC",
"S2 GhostMaps",
"WHOT/BHOT"
"WHOT/BHOT",
"MTQ · CC BY 4.0",
"OpenStreetMap contributors · ODbL"
],
"zh-CN": [
"1090 MHz",
@@ -945,7 +963,9 @@
"{value} KM/S",
"ΔE",
"ΔN",
"▲ {frp} MW"
"▲ {frp} MW",
"MTQ · CC BY 4.0",
"OpenStreetMap contributors · ODbL"
],
"zh-Hant": [
"1090 MHz",
@@ -1002,6 +1022,8 @@
"{value} KM/S",
"ΔE",
"ΔN",
"▲ {frp} MW"
"▲ {frp} MW",
"MTQ · CC BY 4.0",
"OpenStreetMap contributors · ODbL"
]
}
+5 -3
View File
@@ -132,9 +132,11 @@ export function createCards({ state: layerState, services, parts, source }) {
record.camera.lon,
),
inView,
isVideo: parts.model.isVideoFeedType(
parts.model.normalizeFeedType(record.camera.feedType),
),
// Québec's browser-only sources have no registered frame endpoint;
// keep them out of still-card fill just like video feeds.
isVideo:
parts.model.isVideoFeedCamera(record.camera) ||
parts.model.isBrowserOverlayCamera(record.camera),
sx,
sy,
});
+5
View File
@@ -237,9 +237,14 @@ export function createCatalog({ state: layerState, services, parts, source }) {
groundElevationM,
absoluteHeightM: groundElevationM + mountHeightM,
pitchDeg,
frameRefreshMs: parts.model.safeNumber(source.frameRefreshMs, NaN),
license: String(source.license || source.licenseNote || ''),
credit: String(source.credit || ''),
code: String(source.code || ''),
sourceUrl: String(source.sourceUrl || ''),
browserMediaUrl: String(source.browserMediaUrl || ''),
accuracyM: parts.model.safeNumber(source.accuracyM, NaN),
positionSource: String(source.positionSource || ''),
// Shipped precompute (see server/providers/cctv/groundHeights.js).
groundHeights:
source.groundHeights && typeof source.groundHeights === 'object'
+11 -2
View File
@@ -255,11 +255,20 @@ export function createFrames({ state: layerState, services, parts, source }) {
// clears this latch so the next normal tick can refresh.
if (runtime.imageLoading) return;
const now = Date.now();
const refreshMs =
const defaultRefreshMs =
record.camera.id === layerState._activeCameraId
? PROJECTION_ACTIVE_REFRESH_MS
: PROJECTION_IDLE_REFRESH_MS;
if (!force && now - runtime.lastImageRefreshAt < refreshMs) return;
const sourceRefreshMs = parts.model.safeNumber(
record.camera.frameRefreshMs,
0,
);
const refreshMs = Math.max(defaultRefreshMs, sourceRefreshMs);
const sinceLastRefresh = now - runtime.lastImageRefreshAt;
// Forced activation may skip the normal active interval, but it cannot
// bypass a provider's explicit minimum between image requests.
if (sourceRefreshMs > 0 && sinceLastRefresh < sourceRefreshMs) return;
if (!force && sinceLastRefresh < refreshMs) return;
runtime.lastImageRefreshAt = now;
const frameUrl = frameUrlFor(record.camera, refreshMs);
+2 -3
View File
@@ -49,9 +49,8 @@ export function createHover({ state: layerState, services, parts, source }) {
!!record &&
cameraId !== layerState._activeCameraId &&
!layerState._cardIds.has(cameraId) &&
!parts.model.isVideoFeedType(
parts.model.normalizeFeedType(record.camera.feedType),
);
!parts.model.isBrowserOverlayCamera(record.camera) &&
!parts.model.isVideoFeedCamera(record.camera);
if (eligible) {
cancelHoverRelease();
layerState._hoverCardId = cameraId;
+21 -1
View File
@@ -96,6 +96,19 @@ export function createModel({ state: layerState, services, parts, source }) {
return feedType === 'mp4' || feedType === 'hls' || feedType === 'webm';
}
/** A browser-only source still counts as live-capable for map icon/LOD policy. */
function isVideoFeedCamera(camera) {
return (
isVideoFeedType(normalizeFeedType(camera?.feedType)) ||
Boolean(camera?.browserMediaUrl)
);
}
/** Québec source pages and browser-played MP4s use the DOM overlay path. */
function isBrowserOverlayCamera(camera) {
return String(camera?.sourceKind || '').startsWith('quebec-');
}
/**
* Coerces a value to a finite number or returns the fallback.
*
@@ -447,7 +460,12 @@ export function createModel({ state: layerState, services, parts, source }) {
function projectionLoopIsNeeded() {
if (!layerState._enabled || !layerState._viewer) return false;
if (layerState._showProjection && parts.selection.getActiveRecord())
const active = parts.selection.getActiveRecord();
if (
layerState._showProjection &&
active &&
!isBrowserOverlayCamera(active.camera)
)
return true;
return focusPassIsNeeded(
getFocusTarget(),
@@ -553,6 +571,8 @@ export function createModel({ state: layerState, services, parts, source }) {
angularDeltaAbs,
normalizeFeedType,
isVideoFeedType,
isVideoFeedCamera,
isBrowserOverlayCamera,
safeNumber,
headingFromId,
quantize,
+17 -6
View File
@@ -160,9 +160,14 @@ export function createPresentation({
const camera = record.camera;
const health = layerState._healthById.get(camera.id) || null;
const isActive = camera.id === resolvedActiveId;
const refreshMs = isActive
const defaultRefreshMs = isActive
? ACTIVE_FRAME_REFRESH_MS
: IDLE_FRAME_REFRESH_MS;
const sourceRefreshMs = Number(camera.frameRefreshMs);
const refreshMs =
Number.isFinite(sourceRefreshMs) && sourceRefreshMs > 0
? Math.max(defaultRefreshMs, sourceRefreshMs)
: defaultRefreshMs;
return {
id: camera.id,
name: camera.name,
@@ -192,13 +197,18 @@ export function createPresentation({
mountHeightM: camera.mountHeightM,
active: isActive,
feedType: camera.feedType,
sourceUrl: camera.sourceUrl || '',
browserMediaUrl: camera.browserMediaUrl || '',
accuracyM: Number.isFinite(camera.accuracyM) ? camera.accuracyM : null,
positionSource: camera.positionSource || '',
isVideo:
parts.model.isVideoFeedType(camera.feedType) &&
parts.model.isVideoFeedCamera(camera) &&
record.projection?.mode !== 'image',
sourceKind:
health?.sourceKind ||
camera.sourceKind ||
(camera.feedConfigured ? 'configured' : 'seed'),
sourceKind: camera.sourceKind?.startsWith('quebec-')
? camera.sourceKind
: health?.sourceKind ||
camera.sourceKind ||
(camera.feedConfigured ? 'configured' : 'seed'),
sourceStatus: health?.status || 'unknown',
sourceMessage: health?.message || '',
sourceLabel: health?.label || camera.provider || '',
@@ -276,6 +286,7 @@ export function createPresentation({
hoverId: layerState._hoverCardId,
},
activeCameraId: activeId,
activeCameraReselection: layerState._activeCameraReselection,
activeCamera: active ? getPublicCameraState(active, activeId) : null,
cameraIndex: cameraIndex(),
summary: buildSummaryText(),
+3
View File
@@ -171,6 +171,9 @@ export function createProjection({
function createProjectionRuntime(record) {
if (!layerState._viewer) return null;
// Québec 511 denies CORS pixel access; Québec map previews are native DOM
// videos positioned over the globe, never Cesium video textures.
if (parts.model.isBrowserOverlayCamera(record.camera)) return null;
const canvas = document.createElement('canvas');
canvas.width = PROJECTION_CANVAS_WIDTH;
canvas.height = PROJECTION_CANVAS_HEIGHT;
+5 -4
View File
@@ -35,8 +35,7 @@ export function createRendering({
function cameraColorFor(record) {
if (record.camera.id === layerState._activeCameraId)
return ACTIVE_CAMERA_COLOR;
if (parts.model.isVideoFeedType(record.camera.feedType))
return LIVE_CAMERA_COLOR;
if (parts.model.isVideoFeedCamera(record.camera)) return LIVE_CAMERA_COLOR;
return IDLE_CAMERA_COLOR;
}
@@ -405,8 +404,9 @@ export function createRendering({
if (
layerState._enabled &&
isActive &&
!parts.model.isBrowserOverlayCamera(record.camera) &&
(layerState._showProjection ||
parts.model.isVideoFeedType(record.camera.feedType))
parts.model.isVideoFeedCamera(record.camera))
) {
parts.projection.ensureProjectionRuntime(record);
}
@@ -416,7 +416,8 @@ export function createRendering({
const planeShowing = !!(
layerState._enabled &&
layerState._showProjection &&
isActive
isActive &&
!parts.model.isBrowserOverlayCamera(record.camera)
);
const inVisibleSet = coverageVisible.has(record.camera.id);
+6 -1
View File
@@ -56,7 +56,12 @@ test('focus de-emphasis fades each icon from its own color', () => {
const { refreshCctvFocusStyles } = createRendering({
state,
services: { focus },
parts: { model: { isVideoFeedType } },
parts: {
model: {
isVideoFeedCamera: (camera) =>
isVideoFeedType(camera.feedType) || Boolean(camera.browserMediaUrl),
},
},
source: {},
});
+6
View File
@@ -79,6 +79,12 @@ export function createSelection({
record,
)
) {
// Browser-only feeds have a separate player; a reselect retries it
// without re-running activation or touching the projection geometry.
if (parts.model.isBrowserOverlayCamera(record.camera)) {
layerState._activeCameraReselection += 1;
parts.presentation.notifyListeners();
}
return CCTV_ACTIVATION_RESULT.UNCHANGED;
}
layerState._activeCameraId = cameraId;
+54
View File
@@ -0,0 +1,54 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { CCTV_ACTIVATION_RESULT } from '../../cctvFocusRequest.js';
import { createSelection } from './selection.js';
function makeSelection(sourceKind) {
const camera = { id: 'same-camera', sourceKind };
const record = { camera };
const state = {
_activeCameraId: camera.id,
_activeCameraReselection: 0,
_recordById: new Map([[camera.id, record]]),
};
let notifications = 0;
const selection = createSelection({
state,
services: {
activation: { CCTV_ACTIVATION_RESULT },
picking: { resolvePickId: () => null },
},
parts: {
model: {
cctvRecordNeedsActivation: () => false,
isBrowserOverlayCamera: (value) =>
String(value?.sourceKind || '').startsWith('quebec-'),
},
presentation: { notifyListeners: () => notifications++ },
},
source: {},
});
return { selection, state, notifications: () => notifications };
}
test('same-camera selection does not notify or mutate snapshot-feed state', () => {
const fixture = makeSelection('tripcheck-odot');
assert.equal(
fixture.selection.setActiveCamera('same-camera'),
CCTV_ACTIVATION_RESULT.UNCHANGED,
);
assert.equal(fixture.state._activeCameraReselection, 0);
assert.equal(fixture.notifications(), 0);
});
test('same-camera selection notifies browser-overlay feeds so playback can retry', () => {
const fixture = makeSelection('quebec-511-browser');
assert.equal(
fixture.selection.setActiveCamera('same-camera'),
CCTV_ACTIVATION_RESULT.UNCHANGED,
);
assert.equal(fixture.state._activeCameraReselection, 1);
assert.equal(fixture.notifications(), 1);
});
+4
View File
@@ -29,6 +29,10 @@ export function createState({ services }) {
layerState._activeCameraId = null;
// Advances when a user reselects the already-active camera. The geometry
// activation remains a no-op, but browser-only camera UI can retry playback.
layerState._activeCameraReselection = 0;
layerState._coverageMode = 'on';
// 'off' | 'on' (wireframes) | 'viewshed' (color-coded volumes)
+114
View File
@@ -0,0 +1,114 @@
.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
@@ -0,0 +1,319 @@
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
@@ -0,0 +1,136 @@
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();
}
});
+1
View File
@@ -1139,6 +1139,7 @@ export class StyleManager extends ShellFacade {
_cctvSummary: this._cctvSummary,
},
cctv: cctvLayer,
viewer: this.viewer,
actions: {
isEnabled: () => this._dataManager?.isEnabled('cctv'),
setParams: (params, options) =>
+5
View File
@@ -23,6 +23,7 @@ import { useCctvStore } from './stores/cctv.js';
import { useStatusStore } from './stores/status.js';
import { applicationPinia } from './vueRuntime.js';
import { onLocaleChange } from '../i18n/index.js';
import { createQuebecCctvOverlay } from './quebecCctvOverlay.js';
/**
* Own camera-panel interaction and presentation; receive the camera port and
@@ -34,6 +35,7 @@ export class CctvControls {
constructor({
elements,
cctv,
viewer = null,
actions,
store = useCctvStore(applicationPinia()),
statusStore = useStatusStore(applicationPinia()),
@@ -42,11 +44,13 @@ export class CctvControls {
this.store = store;
this.statusStore = statusStore;
this.cctv = cctv;
this._quebecCctvOverlay = createQuebecCctvOverlay(viewer);
this.actions = actions;
this.destroyed = false;
this.listeners = new AbortController();
this._cctvUnsubscribe = null;
this._cctvState = null;
this._lastQuebecSelectionRevision = null;
this._cctvSummaryTypingTimer = null;
this._lastCctvSummaryText = '';
this._lastSeenCctvActiveId = null;
@@ -146,6 +150,7 @@ export class CctvControls {
if (this.destroyed) return;
this.destroyed = true;
this._offLocale();
this._quebecCctvOverlay?.dispose();
this._cctvVideoSurface?.stop();
this._videoVisibilityObserver?.disconnect();
this._actionGeneration++;
+19 -3
View File
@@ -154,6 +154,20 @@ export function _renderCctvState(state) {
const enabled = !!state?.enabled && !!this.actions.isEnabled();
const activeId = state?.activeCameraId || '';
const activeCamera = state?.activeCamera || null;
const selectionRevision = Number(state?.activeCameraReselection);
const reselected =
this._lastQuebecSelectionRevision !== null &&
Number.isFinite(selectionRevision) &&
selectionRevision !== this._lastQuebecSelectionRevision;
this._lastQuebecSelectionRevision = Number.isFinite(selectionRevision)
? selectionRevision
: null;
const isQuebecCamera =
enabled && activeCamera?.sourceKind?.startsWith('quebec-');
this._quebecCctvOverlay?.setCamera(isQuebecCamera ? activeCamera : null, {
reselected,
});
if (this._cctvFrameWrap) this._cctvFrameWrap.hidden = Boolean(isQuebecCamera);
// Auto-expand the panel when the active camera CHANGES to a new non-null
// id while the layer is enabled. Covers click-on-globe, panel controls,
@@ -241,7 +255,8 @@ export function _renderCctvState(state) {
this.store.publish(presentCctvPanel(state, enabled));
const liveIntent = enabled && !!activeCamera?.isVideo;
const liveIntent =
enabled && !!activeCamera?.isVideo && !activeCamera?.browserMediaUrl;
this.store.setLive(liveIntent);
if (this._cctvVideo) {
const visible =
@@ -261,8 +276,9 @@ export function _renderCctvState(state) {
}
if (this._cctvFrame && !liveIntent) {
const nextSrc = enabled ? activeCamera?.frameUrl : null;
const nextCameraId = enabled ? activeCamera?.id || '' : '';
const nextSrc = enabled && !isQuebecCamera ? activeCamera?.frameUrl : null;
const nextCameraId =
enabled && !isQuebecCamera ? activeCamera?.id || '' : '';
const cameraChanged = this._frame.cameraId !== nextCameraId;
const frameLoading = this._frame.loading;
// A same-camera refresh waits for the current image to settle. Replacing
+389
View File
@@ -0,0 +1,389 @@
import * as Cesium from 'cesium';
import { horizonOccluder } from '../data/iconOrientation.js';
import { t } from '../i18n/index.js';
function isQuebecCamera(camera) {
return String(camera?.sourceKind || '').startsWith('quebec-');
}
function safeSourceUrl(value) {
try {
const url = new URL(String(value || ''));
return url.protocol === 'https:' && !url.username && !url.password
? url.href
: '';
} catch {
return '';
}
}
function safeMediaUrl(value) {
try {
const url = new URL(String(value || ''));
if (
url.protocol !== 'https:' ||
url.hostname !== 'www.quebec511.info' ||
url.username ||
url.password ||
url.pathname !== '/Carte/Fenetres/camera.ashx' ||
url.searchParams.get('format') !== 'mp4' ||
!/^\d+$/.test(url.searchParams.get('id') || '')
)
return '';
return url.href;
} catch {
return '';
}
}
function setMedia(video, url) {
const current = video.currentSrc || video.src;
if (current === url) return;
video.pause();
video.removeAttribute('src');
if (!url) {
video.load();
return;
}
video.src = url;
video.load();
}
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. */
export function createQuebecCctvOverlay(viewer) {
if (!viewer?.scene || typeof document === 'undefined') return null;
const controller = new AbortController();
const { signal } = controller;
const panel = document.createElement('section');
panel.className = 'qci-panel';
panel.hidden = true;
panel.setAttribute('role', 'dialog');
panel.setAttribute('aria-label', t('CCTV camera'));
const header = document.createElement('header');
header.className = 'qci-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 coordinates = document.createElement('p');
coordinates.className = 'qci-coordinates';
const credit = document.createElement('p');
credit.className = 'qci-credit';
const live = document.createElement('video');
live.className = 'qci-live';
live.controls = true;
live.muted = true;
live.autoplay = true;
live.loop = true;
live.playsInline = true;
live.setAttribute('aria-label', t('CCTV camera'));
const actions = document.createElement('div');
actions.className = 'qci-actions';
const refresh = document.createElement('button');
refresh.type = 'button';
refresh.textContent = t('Refresh map still');
actions.append(refresh);
const note = document.createElement('p');
note.className = 'qci-note';
const sourceLink = document.createElement('a');
sourceLink.target = '_blank';
sourceLink.rel = 'noopener noreferrer';
sourceLink.textContent = t("Open this image on the provider's site");
panel.append(header, coordinates, credit, live, actions, note, sourceLink);
const marker = document.createElement('div');
marker.className = 'qci-marker';
marker.hidden = true;
const still = document.createElement('video');
still.className = 'qci-marker-still';
still.muted = true;
still.playsInline = true;
still.preload = 'auto';
still.setAttribute('aria-hidden', 'true');
const markerLabel = document.createElement('span');
markerLabel.className = 'qci-marker-label';
marker.append(still, markerLabel);
document.body.append(panel, marker);
let selected = null;
let selectedPosition = null;
let closedWindowCameraId = null;
let selectedMediaUrl = '';
let mediaRefreshTimer = 0;
let liveUnavailable = false;
let stillUnavailable = false;
function markerLabelText(camera, hasMedia) {
return hasMedia
? `${t('PREVIEW')} · ${camera.positionSource || camera.name}`
: Number.isFinite(camera.accuracyM) && camera.accuracyM > 0
? `${camera.name} · ±${Math.round(camera.accuracyM)} m`
: camera.name;
}
function markLiveUnavailable() {
liveUnavailable = true;
note.textContent = t('Source unavailable');
note.hidden = false;
}
function markStillUnavailable() {
stillUnavailable = true;
markerLabel.textContent = t('Source unavailable');
}
function stopMediaRefresh() {
clearInterval(mediaRefreshTimer);
mediaRefreshTimer = 0;
}
function startMediaRefresh() {
stopMediaRefresh();
if (!selectedMediaUrl) return;
mediaRefreshTimer = window.setInterval(
() => {
if (document.hidden || closedWindowCameraId === selected?.id) return;
setMedia(live, mediaRefreshUrl(selectedMediaUrl));
void live.play().catch(markLiveUnavailable);
refreshStill();
},
2 * 60 * 1000,
);
}
function refreshStill() {
if (!selectedMediaUrl) return;
setMedia(still, '');
setMedia(still, mediaRefreshUrl(selectedMediaUrl));
void still.play().catch(markStillUnavailable);
}
function setCamera(camera, { reselected = false } = {}) {
if (!isQuebecCamera(camera)) {
selected = null;
selectedPosition = null;
closedWindowCameraId = null;
selectedMediaUrl = '';
liveUnavailable = false;
stillUnavailable = false;
stopMediaRefresh();
panel.hidden = true;
marker.hidden = true;
setMedia(live, '');
setMedia(still, '');
return;
}
const changed = selected?.id !== camera.id;
if (changed || reselected) closedWindowCameraId = null;
selected = camera;
selectedPosition = Cesium.Cartesian3.fromDegrees(
Number(camera.lon),
Number(camera.lat),
Number(camera.elevationM) || 10,
);
if (closedWindowCameraId === camera.id && !reselected) {
panel.hidden = true;
updateMarker();
return;
}
const mediaUrl = safeMediaUrl(camera.browserMediaUrl);
const sourceUrl = safeSourceUrl(camera.sourceUrl);
if (changed || mediaUrl !== selectedMediaUrl) {
liveUnavailable = false;
stillUnavailable = false;
}
title.textContent = camera.name || t('CCTV camera');
coordinates.textContent = [
`${Number(camera.lat).toFixed(5)}, ${Number(camera.lon).toFixed(5)}`,
Number.isFinite(camera.accuracyM) && camera.accuracyM > 0
? `±${Math.round(camera.accuracyM)} m`
: '',
camera.positionSource || '',
]
.filter(Boolean)
.join(' · ');
credit.textContent = camera.credit || camera.provider || '';
credit.hidden = !credit.textContent;
live.hidden = !mediaUrl;
refresh.hidden = !mediaUrl;
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.',
)
: sourceUrl
? ''
: t('Source unavailable');
note.hidden = !note.textContent;
sourceLink.href = sourceUrl || '#';
sourceLink.hidden = !sourceUrl;
markerLabel.textContent = stillUnavailable
? t('Source unavailable')
: markerLabelText(camera, mediaUrl);
marker.classList.toggle('qci-marker-link-only', !mediaUrl);
still.hidden = !mediaUrl;
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) {
if (live.error || live.networkState === 3 || live.readyState < 2) {
setMedia(live, mediaRefreshUrl(mediaUrl));
}
if (live.paused) void live.play().catch(markLiveUnavailable);
startMediaRefresh();
if (still.error || still.networkState === 3 || still.readyState < 2)
refreshStill();
}
updateMarker();
}
function updateMarker() {
if (!selected || !selectedPosition) {
marker.hidden = true;
return;
}
const canvas = viewer.scene.canvas;
const point = viewer.scene.cartesianToCanvasCoordinates(selectedPosition);
const visible =
point &&
horizonOccluder(viewer.camera).isPointVisible(selectedPosition) &&
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,
selectedPosition,
);
marker.style.setProperty(
'--qci-scale',
String(Math.min(1, 600 / Math.max(1, distance))),
);
}
still.addEventListener(
'loadeddata',
() => {
stillUnavailable = false;
if (selected)
markerLabel.textContent = markerLabelText(
selected,
Boolean(selectedMediaUrl),
);
still.pause();
},
{ signal },
);
still.addEventListener('error', markStillUnavailable, { signal });
live.addEventListener('error', markLiveUnavailable, { signal });
live.addEventListener(
'loadeddata',
() => {
liveUnavailable = false;
note.textContent = t(
'The map still uses a separate request and may show a different moment.',
);
note.hidden = false;
},
{ signal },
);
refresh.addEventListener('click', refreshStill, { signal });
close.addEventListener(
'click',
() => {
closedWindowCameraId = selected?.id || null;
panel.hidden = true;
live.pause();
stopMediaRefresh();
// The floating player closes independently; keep the paused map still.
updateMarker();
},
{ signal },
);
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, { signal });
header.addEventListener('pointerup', stop, { once: true, signal });
header.addEventListener('pointercancel', stop, { once: true, signal });
},
{ signal },
);
document.addEventListener(
'visibilitychange',
() => {
if (!selectedMediaUrl) return;
if (document.hidden) {
live.pause();
still.pause();
} else if (closedWindowCameraId !== selected?.id) {
void live.play().catch(markLiveUnavailable);
refreshStill();
}
},
{ signal },
);
const removeRender = viewer.scene.postRender.addEventListener(updateMarker);
return {
setCamera,
dispose() {
controller.abort();
stopMediaRefresh();
removeRender();
setMedia(live, '');
setMedia(still, '');
panel.remove();
marker.remove();
selected = null;
selectedPosition = null;
},
};
}
+71
View File
@@ -0,0 +1,71 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
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 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 () {};
window.HTMLMediaElement.prototype.play = function () {
return Promise.resolve();
};
window.HTMLMediaElement.prototype.pause = function () {};
window.setInterval = () => 0;
const viewer = {
camera: {
positionWC: Cesium.Cartesian3.fromDegrees(-73.5502, 45.51651, 1000),
},
scene: {
canvas: { clientWidth: 800, clientHeight: 600 },
cartesianToCanvasCoordinates: () => null,
postRender: { addEventListener: () => () => {} },
},
};
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 {
overlay?.dispose();
globalThis.window = previousWindow;
globalThis.document = previousDocument;
window.close();
}
});
+98
View File
@@ -0,0 +1,98 @@
.qci-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;
}
.qci-panel[hidden],
.qci-live[hidden],
.qci-note[hidden],
.qci-panel a[hidden],
.qci-marker[hidden],
.qci-marker-still[hidden] {
display: none;
}
.qci-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 10px;
cursor: move;
touch-action: none;
}
.qci-panel button {
border: 1px solid #43818d;
border-radius: 4px;
padding: 4px 8px;
background: #102830;
color: inherit;
cursor: pointer;
}
.qci-panel button:focus-visible {
outline: 2px solid #82e9f7;
outline-offset: 2px;
}
.qci-coordinates,
.qci-credit,
.qci-note {
margin: 0 10px 8px;
color: #a9c5ca;
font: 10px/1.45 'Cascadia Code', Consolas, monospace;
}
.qci-credit {
color: #7fc7d2;
}
.qci-live {
display: block;
width: 100%;
aspect-ratio: 16 / 9;
background: #000;
}
.qci-actions {
display: flex;
gap: 8px;
padding: 8px 10px 0;
}
.qci-actions[hidden] {
display: none;
}
.qci-panel a {
display: inline-block;
margin: 0 10px 10px;
color: #82e9f7;
}
.qci-marker {
position: fixed;
z-index: 500;
width: 160px;
transform: translate(-50%, -100%) scale(var(--qci-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;
}
.qci-marker-still {
display: block;
width: 100%;
aspect-ratio: 16 / 9;
background: #000;
}
.qci-marker-label {
display: block;
padding: 3px;
}
.qci-marker-link-only {
width: max-content;
max-width: 220px;
}