mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-12 14:22:21 +00:00
feat(cctv): add Quebec, Ottawa, and World feeds
This commit is contained in:
1 parent
72ceeb2fce
commit
fa4ce4b2a5
59 files changed
+40815
-88
No files matched your search
@@ -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);
|
||||
});
|
||||
@@ -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 +
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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...": "جارٍ إعداد العارض…",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.": "ピンを置く場所をクリックしてください。",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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...": "Инициализация систем...",
|
||||
|
||||
@@ -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.": "Клацніть там, де має бути мітка.",
|
||||
|
||||
@@ -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.": "点击图钉的放置位置。",
|
||||
|
||||
@@ -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.": "點擊圖釘的放置位置。",
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: {},
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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++;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user