Merge branch 'main' into denis/gev-news-layer

This commit is contained in:
Denis Groshev committed 2026-11-07 23:19:12 +01:00
commit a3902590cd
114 files changed
+41771 -305

No files matched your search

+2
View File
@@ -67,6 +67,8 @@ export const _extractPickedCameraIdForTest =
export const _setCctvCoverageStateForTest = layer._setCctvCoverageStateForTest;
export const focusCctvRecord = layer.focusCctvRecord;
export const maybeAutoHop = layer.maybeAutoHop;
export const openingCameraId = layer.openingCameraId;
export const reopenNearestCamera = layer.reopenNearestCamera;
export const cctvCycleIndex = layer.cctvCycleIndex;
export {
CCTV_CALIBRATION_STORAGE_KEY_V1,
+116
View File
@@ -61,6 +61,8 @@ import cctvLayer, {
materializeCctvActiveCoverageEntities,
materializeCctvVisibleCoverageEntities,
maybeAutoHop,
openingCameraId,
reopenNearestCamera,
prioritizeActiveCctvGeometryRecord,
processCctvGeometryDrainBatch,
processCctvGeometryQueueBatch,
@@ -1296,6 +1298,120 @@ test('CCTV null-active coverage, auto-hop, cycling, and panel targets stay hones
}
});
test('CCTV opens on the camera nearest the viewer, not the first in the catalog', () => {
// QA 2026-11-05: at King's Cross, turning CCTV on showed a Sydney camera,
// the first catalog record, instead of the TfL camera 0.3 km away.
const viewer = makeDeselectViewer();
viewer.camera.positionCartographic = Cesium.Cartographic.fromDegrees(
-0.1209,
51.5295,
187,
);
viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees(
-0.1209,
51.5295,
187,
);
const sydney = makeDeselectRecord('au-sydney');
sydney.camera = { ...sydney.camera, lat: -34.03, lon: 151.11 };
const london = makeDeselectRecord('gb-kings-cross');
london.camera = { ...london.camera, lat: 51.5318, lon: -0.1209 };
try {
_setCctvCoverageStateForTest({
viewer,
records: [sydney, london],
activeCameraId: null,
enabled: false,
});
assert.equal(openingCameraId(), 'gb-kings-cross');
_setCctvCoverageStateForTest({
viewer: null,
records: [sydney, london],
activeCameraId: null,
enabled: false,
});
assert.equal(
openingCameraId(),
'au-sydney',
'with no viewer position the first record is the fallback',
);
_setCctvCoverageStateForTest({ viewer, records: [], enabled: false });
assert.equal(openingCameraId(), null);
} finally {
_setCctvCoverageStateForTest({ enabled: false });
}
});
test('the opening camera follows a shared view that lands after CCTV turned on, until someone picks one', () => {
// A share link turns CCTV on before its flight lands, so enable() picks
// from the startup view; the flight's settled move re-picks near London.
const viewer = makeDeselectViewer();
viewer.camera.positionCartographic = Cesium.Cartographic.fromDegrees(
-0.1209,
51.5295,
187,
);
const sydney = makeDeselectRecord('au-sydney');
sydney.camera = { ...sydney.camera, lat: -34.03, lon: 151.11 };
const london = makeDeselectRecord('gb-kings-cross');
london.camera = { ...london.camera, lat: 51.5318, lon: -0.1209 };
for (const record of [sydney, london])
Object.assign(record, {
groundResolved: {},
groundSamples: {},
groundPrior: 0,
frustumGeometry: null,
frustumPositions: null,
probeClampRangeM: null,
});
_setCctvOverlayHostForTest({
setEntries() {},
setVisible() {},
clearSource() {},
});
// Activation builds the projection canvas and frame image.
const originalDocument = globalThis.document;
const originalImage = globalThis.Image;
globalThis.document = {
createElement: () => ({ getContext: () => null }),
};
globalThis.Image = class {};
try {
_setCctvCoverageStateForTest({
viewer,
records: [sydney, london],
activeCameraId: 'au-sydney',
enabled: true,
openingCameraProvisional: true,
});
assert.equal(reopenNearestCamera(), true);
assert.equal(cctvLayer.getUIState().activeCameraId, 'gb-kings-cross');
assert.equal(reopenNearestCamera(), false, 'already the nearest');
// Someone chooses a camera: settled moves leave it alone.
setActiveCamera('au-sydney');
assert.equal(reopenNearestCamera(), false);
assert.equal(cctvLayer.getUIState().activeCameraId, 'au-sydney');
// A deselect is a choice too.
_setCctvCoverageStateForTest({
viewer,
records: [sydney, london],
activeCameraId: 'au-sydney',
enabled: true,
openingCameraProvisional: true,
});
deactivateActiveCamera();
assert.equal(reopenNearestCamera(), false);
assert.equal(cctvLayer.getUIState().activeCameraId, null);
} finally {
globalThis.document = originalDocument;
globalThis.Image = originalImage;
_setCctvOverlayHostForTest();
_setCctvCoverageStateForTest({ enabled: false });
}
});
test('CCTV disable clears and hides its shared-host source through the real layer lifecycle', () => {
const calls = [];
_setCctvOverlayHostForTest({
+3 -3
View File
@@ -12,7 +12,7 @@ import {
CALGARY_IMAGE_ORIGIN,
CALGARY_MAX_CATALOG_BYTES,
DEFAULT_CALGARY_ROWS_URL,
DEFAULT_CCTV_MAX_SOURCES,
CCTV_SHARED_SOURCE_SLOTS,
} from '../../server/providers/cctv/constants.js';
import { CAMERA_CODE_MAX_CHARS } from '../../server/providers/cctv/normalize.js';
import { allocateSourceCap } from '../../server/providers/cctv/cap.js';
@@ -384,6 +384,6 @@ test('CCTV_CALGARY_ENABLED=0 keeps the lane from being loaded at all', async (t)
}
});
test('the shipped catalog ceiling is not raised to make room for this pack', () => {
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 4000);
test('the shared catalog slots are not raised to make room for this pack', () => {
assert.equal(CCTV_SHARED_SOURCE_SLOTS, 4000);
});
+65
View File
@@ -6,9 +6,12 @@ import {
} from '../../server/providers/cctv/cap.js';
import {
CCTV_MAX_SOURCES_CEILING,
CCTV_SHARED_SOURCE_SLOTS,
CCTV_SOURCE_CACHE_MS,
DEFAULT_CCTV_MAX_SOURCES,
WORLD_CCTV_SOURCE_COUNT,
} from '../../server/providers/cctv/constants.js';
import { QUEBEC_CAMERA_SOURCE_COUNT } from '../../server/providers/cctv/quebec.js';
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
const pack = (name, count, prefix = name) => ({
@@ -99,6 +102,68 @@ test('duplicate ids resolve last-pack-wins and count against the winning pack',
]);
});
/** Shared packs plus two reserved ones, shaped like the default catalog. */
const reservedFixture = () => [
...Array.from({ length: 20 }, (_, i) => pack(`shared${i}`, 250)),
{ ...pack('quebec', 1120), reservedCount: 1120 },
{ ...pack('world', 2230), reservedCount: 2230 },
];
const keptByName = (packs) =>
Object.fromEntries(packs.map(({ name, kept }) => [name, kept]));
test('the default cap is the shared floor plus the reserved Québec and World packs', () => {
assert.equal(
DEFAULT_CCTV_MAX_SOURCES,
CCTV_SHARED_SOURCE_SLOTS +
QUEBEC_CAMERA_SOURCE_COUNT +
WORLD_CCTV_SOURCE_COUNT,
);
});
test('at the default cap reserved packs are whole and shared packs keep the floor', () => {
const { sources, packs } = allocateSourceCap(
reservedFixture(),
DEFAULT_CCTV_MAX_SOURCES,
);
const kept = keptByName(packs);
assert.equal(sources.length, DEFAULT_CCTV_MAX_SOURCES);
assert.equal(kept.quebec, 1120);
assert.equal(kept.world, 2230);
for (let i = 0; i < 20; i++) assert.equal(kept[`shared${i}`], 200);
assert.match(
sources[0].id,
/^shared0-/,
'the first camera stays in a shared pack',
);
});
test('a cap at the shared floor reserves nothing and thins every pack evenly', () => {
const { packs } = allocateSourceCap(reservedFixture(), 4000);
const kept = keptByName(packs);
// 22 lanes share 4,000 round-robin: nobody is wiped out by the reservation.
for (const count of Object.values(kept))
assert.ok(count >= 181 && count <= 182);
});
test('a small cap (dev-secure 48) still reaches the shared packs first', () => {
const { sources, packs } = allocateSourceCap(reservedFixture(), 48);
const kept = keptByName(packs);
assert.equal(sources.length, 48);
for (let i = 0; i < 20; i++) assert.ok(kept[`shared${i}`] >= 2);
assert.match(sources[0].id, /^shared0-/);
});
test('reservations fill only the room above the floor, round-robin between reserved packs', () => {
const { packs } = allocateSourceCap(reservedFixture(), 4100);
const kept = keptByName(packs);
const shared = Object.entries(kept)
.filter(([name]) => name.startsWith('shared'))
.reduce((sum, [, count]) => sum + count, 0);
// 100 reserved slots split between Québec and World, then 4,000 round-robin.
assert.ok(shared >= 4000 - 2 * 182, `shared packs kept ${shared}`);
assert.equal(shared + kept.quebec + kept.world, 4100);
});
test('resolveCatalogCap bounds the env value and falls back to the default', () => {
assert.equal(resolveCatalogCap(undefined), DEFAULT_CCTV_MAX_SOURCES);
assert.equal(resolveCatalogCap('nonsense'), DEFAULT_CCTV_MAX_SOURCES);
+8 -3
View File
@@ -14,6 +14,8 @@ import {
DEFAULT_ONTARIO_MAX_SOURCES,
DEFAULT_FINTRAFFIC_MAX_SOURCES,
DEFAULT_TXDOT_MAX_SOURCES,
DEFAULT_OTTAWA_MAX_SOURCES,
CCTV_SHARED_SOURCE_SLOTS,
DEFAULT_TALLINN_MAX_SOURCES,
DEFAULT_TARKTEE_MAX_SOURCES,
DEFAULT_NSW_MAX_SOURCES,
@@ -222,7 +224,7 @@ test('CCTV catalog merges DriveBC cameras and CCTV_DRIVEBC_ENABLED=0 skips the r
);
});
test('default caps of the original packs fit inside the default catalog cap', () => {
test('default caps of the original packs fit inside the shared catalog slots', () => {
// Every default pack at its own cap (Warendorf ships 1 curated camera).
const packs =
DEFAULT_AUSTIN_MAX_SOURCES +
@@ -231,15 +233,18 @@ 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 +
DEFAULT_NSW_MAX_SOURCES +
DEFAULT_CATALONIA_MAX_SOURCES +
1;
// The reserved Québec and World packs sit above the shared floor, so the
// shared packs must fit the floor itself, not the whole default cap.
assert.ok(
packs <= DEFAULT_CCTV_MAX_SOURCES,
`default packs (${packs}) would be thinned by the catalog cap (${DEFAULT_CCTV_MAX_SOURCES})`,
packs <= CCTV_SHARED_SOURCE_SLOTS,
`default packs (${packs}) would be thinned by the shared slots (${CCTV_SHARED_SOURCE_SLOTS})`,
);
assert.ok(
DEFAULT_CCTV_MAX_SOURCES <= CCTV_MAX_SOURCES_CEILING,
+4 -4
View File
@@ -10,7 +10,7 @@ import {
parseHkCameraLocationsXml,
} from '../../server/providers/cctv/sources.js';
import {
DEFAULT_CCTV_MAX_SOURCES,
CCTV_SHARED_SOURCE_SLOTS,
DEFAULT_HK_CAMERAS_URL,
DEFAULT_HK_MAX_SOURCES,
HK_ANCHORS,
@@ -396,8 +396,8 @@ test('CCTV_HK_ENABLED=0 keeps the lane from being loaded at all', async (t) => {
}
});
test('the shipped catalog ceiling is not raised to make room for this pack', () => {
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 4000);
test('the shared catalog slots are not raised to make room for this pack', () => {
assert.equal(CCTV_SHARED_SOURCE_SLOTS, 4000);
});
test('the HK pack default sits above the full TD catalog and anchors the New Territories', () => {
@@ -406,7 +406,7 @@ test('the HK pack default sits above the full TD catalog and anchors the New Ter
'default cap should keep nearly all published TD cameras',
);
assert.ok(
DEFAULT_HK_MAX_SOURCES <= DEFAULT_CCTV_MAX_SOURCES,
DEFAULT_HK_MAX_SOURCES <= CCTV_SHARED_SOURCE_SLOTS,
'a single pack must not outgrow the catalog-wide ceiling alone',
);
const places = HK_ANCHORS.map(
+71
View File
@@ -10,6 +10,77 @@ const playlist =
'#EXTM3U\n#EXT-X-MEDIA-SEQUENCE:12\n#EXTINF:2,\na.ts\n#EXTINF:2,\nb.ts\n#EXTINF:2,\nc.ts\n';
const base = 'https://camera.example/live/list.m3u8';
test('high-resolution HLS segments play while the retained session cache stays bounded', async (t) => {
// Giroc publishes roughly ten-second TS segments; one measured 6,437,120
// bytes. The former 4 MiB download cap rejected it before playback began.
const segmentBytes = 6_437_120;
const master =
'#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=4570000\ntracks-v1a1/mono.m3u8\n';
const media =
'#EXTM3U\n#EXT-X-MEDIA-SEQUENCE:100\n' +
[0, 1, 2, 3, 4].map((i) => `#EXTINF:10.272,\n${i}.ts\n`).join('');
const manager = createHlsPuller({
limits: { ...HLS_LIMITS, pollMs: 100000 },
fetchImpl: async (url) => {
if (url === base) return new Response(master);
if (url === 'https://camera.example/live/tracks-v1a1/mono.m3u8')
return new Response(media);
const match =
/^https:\/\/camera\.example\/live\/tracks-v1a1\/([0-4])\.ts$/.exec(url);
if (!match) throw new Error('Unexpected playlist reference');
// No Content-Length: the actual streamed bytes must also be accepted.
return new Response(Buffer.alloc(segmentBytes, Number(match[1])));
},
});
t.after(() => manager.shutdown());
const entry = await manager.ensure('giroc', base);
await entry.polling;
const result = await manager.buildPlaylist(entry, 'giroc');
assert.ok(result, 'large valid segments should produce a playable playlist');
assert.match(result, /#EXT-X-MEDIA-SEQUENCE:2\n/);
assert.equal(manager.getSegment('giroc', entry.token, 0), null);
const newest = manager.getSegment('giroc', entry.token, 4);
assert.equal(newest.length, segmentBytes);
assert.equal(newest[0], 4);
assert.deepEqual(manager.stats(), { sessions: 1, bytes: 19_311_360 });
});
test('HLS downloads still reject declared and chunked bodies above 8 MiB', async () => {
for (const declared of [true, false]) {
let chunks = 0;
let cancelled = false;
await assert.rejects(
fetchHlsBytes(base, {
maxBytes: HLS_LIMITS.segmentBytes,
fetchImpl: async () =>
new Response(
declared
? 'small body'
: new ReadableStream({
pull(controller) {
// Each chunk fits on its own; only their cumulative size
// crosses the cap. Leave it open to verify cancellation.
controller.enqueue(
Buffer.alloc(4 * 1024 * 1024 + (++chunks === 2 ? 1 : 0)),
);
},
cancel() {
cancelled = true;
},
}),
{
headers: declared
? { 'content-length': String(8 * 1024 * 1024 + 1) }
: {},
},
),
}),
/HLS response too large/,
);
if (!declared) assert.equal(cancelled, true);
}
});
test('playlist parser uses sequence, refuses escaping and unsupported references', () => {
assert.deepEqual(
parseHlsMedia(playlist, base).map((s) => s.seq),
+3 -3
View File
@@ -7,7 +7,7 @@ import {
loadHoustonSourcesFromOpenData,
} from '../../server/providers/cctv/sources.js';
import {
DEFAULT_CCTV_MAX_SOURCES,
CCTV_SHARED_SOURCE_SLOTS,
DEFAULT_HOUSTON_MAX_SOURCES,
HOUSTON_CAMERAS_URL,
HOUSTON_GROUND_ELEVATION_M,
@@ -387,8 +387,8 @@ test('Houston stays unloaded unless CCTV_HOUSTON_ENABLED=1', 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 shared catalog slots are not raised to make room for this pack', () => {
assert.equal(CCTV_SHARED_SOURCE_SLOTS, 4000);
assert.equal(DEFAULT_HOUSTON_MAX_SOURCES, 250);
});
+429
View File
@@ -0,0 +1,429 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
import {
DEFAULT_ICELAND_MAX_SOURCES,
ICELAND_CAMERAS_URL,
ICELAND_IMAGE_ORIGIN,
ICELAND_MAX_CATALOG_BYTES,
} from '../../server/providers/cctv/constants.js';
import { fetchWithinHost } from '../../server/providers/cctv/media.js';
import { isLikelyIcelandCoordinate } from '../../server/providers/cctv/normalize.js';
import {
icelandCameraToSource,
icelandHeadingFromDescription,
loadIcelandSourcesFromOpenData,
} from '../../server/providers/cctv/sources.js';
/** Set (or, for `undefined`, delete) environment variables for one test. */
function withEnv(t, env) {
for (const [name, value] of Object.entries(env)) {
const previous = process.env[name];
t.after(() => {
if (previous === undefined) delete process.env[name];
else process.env[name] = previous;
});
if (value === undefined) delete process.env[name];
else process.env[name] = value;
}
}
/** One row shaped like the live `vefmyndavelar2014_1` payload. */
const row = (over = {}) => ({
Maelist_nr: 7001,
Myndavel: 'Hellisheiði',
Vegheiti: 'Hringvegur',
NrVegur: '1',
Skyring: 'Hellisheiði séð til vesturs',
Slod: `${ICELAND_IMAGE_ORIGIN}hellisheidi_1.jpg`,
PntX: 395000,
PntY: 390000,
Breidd: 64.0136,
Lengd: -21.4,
...over,
});
/** A live response body plus a flag that flips when the stream is cancelled. */
const streamingResponse = (init = {}) => {
const state = { cancelled: false };
const body = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode('['));
},
cancel() {
state.cancelled = true;
},
});
return { response: new Response(body, init), state };
};
const json = (value) =>
new Response(JSON.stringify(value), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
test('an Iceland row maps to a source on the pinned frame directory', () => {
const source = icelandCameraToSource(row());
assert.equal(source.id, 'is-vegagerdin-hellisheidi_1');
assert.equal(source.name, 'Hellisheiði séð til vesturs');
assert.equal(source.city, 'Iceland');
assert.equal(source.cityId, 'iceland');
assert.equal(source.provider, 'Vegagerðin (IRCA)');
assert.equal(source.lat, 64.0136);
assert.equal(source.lon, -21.4);
assert.equal(source.headingDeg, 270);
assert.equal(source.headingConfidence, 'high');
assert.equal(source.pitchDeg, -24);
assert.equal(source.fovDeg, 56);
assert.equal(source.rangeM, 210);
assert.equal(source.mountHeightM, 10);
assert.equal(source.feedType, 'image');
assert.equal(source.sourceKind, 'iceland-vegagerdin');
assert.equal(source.code, 'HELLISHEIÐI');
assert.match(source.license, /CC BY 4\.0/);
assert.ok(source.url.startsWith(ICELAND_IMAGE_ORIGIN));
assert.equal(source.snapshotUrl, source.url);
});
test('a view that states no facing takes the low-confidence id-hash pose', () => {
const source = icelandCameraToSource(
row({
Skyring: 'Hellisheiði séð niður á veg',
Slod: `${ICELAND_IMAGE_ORIGIN}hellisheidi_2.jpg`,
}),
);
assert.equal(source.headingConfidence, 'low');
assert.equal(source.headingDeg % 22.5, 0);
assert.equal(
source.headingDeg,
icelandCameraToSource(
row({ Slod: `${ICELAND_IMAGE_ORIGIN}hellisheidi_2.jpg` }),
).headingDeg,
'the fallback depends on the id, not on the text',
);
});
test('the label collapses the feed’s doubled spaces and repeated words', () => {
assert.equal(
icelandCameraToSource(
row({ Skyring: 'Strandarheiði séð niður á veg til til austurs' }),
).name,
'Strandarheiði séð niður á veg til austurs',
);
const label = (Skyring) => icelandCameraToSource(row({ Skyring })).name;
// Repeats of words with Icelandic letters collapse too.
assert.equal(
label('Hellisheiði séð séð til austurs'),
'Hellisheiði séð til austurs',
);
assert.equal(label('Brú á á Jökulsá'), 'Brú á Jökulsá');
// A word ending in a letter the next word starts with is not a repeat.
for (const text of [
'Þverá Árnessýslu',
'brú Úlfarsá',
'Vesturlandsvegur við Elliðaá Ártúnsbrekku',
])
assert.equal(label(text), text);
});
test('a row with no label, station or station number is named by its frame', () => {
const source = icelandCameraToSource(
row({ Skyring: '', Myndavel: '', Maelist_nr: undefined }),
);
assert.equal(source.name, 'Vegagerðin hellisheidi_1');
});
test('only an explicit facing sets a heading', () => {
const cases = [
['Hellisheiði séð til vesturs', 270],
['Holtavörðuheiði til norðurs', 0],
['Klettsháls í suður', 180],
['Arnarneshæð séð austur', 90],
['Bústaðabrú séð norður Kringlumýrarbraut', 0],
['Staður séð til norðausturs', 45],
['Staður séð til suðausturs', 135],
['Staður séð í suðvestur', 225],
['Staður séð til norðvesturs', 315],
['STAÐUR SÉÐ TIL VESTURS', 270],
['Brú séð til austurs og Selfoss', 90], // "og" then a place, not a facing
['Staður séð til austurs og Vesturlandsvegur', 90],
];
for (const [text, heading] of cases) {
assert.equal(icelandHeadingFromDescription(text), heading, text);
}
for (const text of [
'',
null,
'Gautsdalur niður á veg',
'Miklabraut austur', // a road direction, not a facing
'Biskupsháls - norður',
'Hringtorg norðan Selfoss',
'Norðurárdalur niður á veg', // a place name that contains a cardinal
'Staður séð til suðurlands', // a longer word, not "suður"
'Staður séð til norðurs og til suðurs', // two facings: ambiguous
'Staður séð til austurs og vesturs', // the second facing shares "til"
'Staður séð í norður og suður',
'Staður séð í norður og í suður',
'Norðfjarðargöng séð til Eskifjarðar',
]) {
assert.ok(Number.isNaN(icelandHeadingFromDescription(text)), String(text));
}
});
test('rows off the pinned directory, or with unusable frames or places, are dropped', () => {
const bad = [
{ Slod: 'https://example.com/vgdata/vefmyndavelar/a_1.jpg' },
{ Slod: 'http://www.vegagerdin.is/vgdata/vefmyndavelar/a_1.jpg' },
{ Slod: 'https://user:[email protected]/vgdata/vefmyndavelar/a_1.jpg' },
{ Slod: 'https://www.vegagerdin.is:8443/vgdata/vefmyndavelar/a_1.jpg' },
{ Slod: 'https://www.vegagerdin.is/vgdata/other/a_1.jpg' },
{ Slod: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/../x/a_1.jpg' },
{ Slod: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/a%2Fb_1.jpg' },
{ Slod: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/a_1.php' },
{ Slod: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/a_1' },
{ Slod: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/' },
{ Slod: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/a b_1.jpg' },
{ Slod: '' },
{ Slod: null },
{ Breidd: 0, Lengd: 0 },
{ Breidd: null, Lengd: null },
{ Breidd: '64.01', Lengd: '-21.4' },
{ Breidd: '64.01' }, // each coordinate must be a number on its own
{ Lengd: '-21.4' },
{ Breidd: 59.9, Lengd: 10.7 }, // Oslo
{ Breidd: 64.01, Lengd: 21.4 }, // wrong hemisphere
];
for (const over of bad) {
assert.equal(icelandCameraToSource(row(over)), null, JSON.stringify(over));
}
assert.equal(icelandCameraToSource(null), null);
assert.equal(icelandCameraToSource({}), null);
assert.equal(isLikelyIcelandCoordinate(64.1466, -21.9426), true);
assert.equal(isLikelyIcelandCoordinate(66.3, -14.0), true);
});
test('the Iceland loader reads the list with redirects refused and keeps the nearest cameras', async (t) => {
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
withEnv(t, { CCTV_ICELAND_MAX_SOURCES: '8' });
const rows = Array.from({ length: 12 }, (_, i) =>
row({
Maelist_nr: 8000 + i,
Slod: `${ICELAND_IMAGE_ORIGIN}far_${i}.jpg`,
Skyring: `Far ${i}`,
Breidd: 66.2,
Lengd: -15.9 + i * 0.01,
}),
);
// Reykjavík cameras come last in the feed but first in the priority order.
// A frame the feed repeats keeps its last row, as the other packs do.
rows.push(row({ Slod: `${ICELAND_IMAGE_ORIGIN}rvk_1.jpg`, Skyring: 'Old' }));
rows.push(
row({
Slod: `${ICELAND_IMAGE_ORIGIN}rvk_1.jpg`,
Skyring: 'Kringlumýrarbraut',
Breidd: 64.1466,
Lengd: -21.9426,
}),
);
const seen = [];
t.mock.method(globalThis, 'fetch', async (url, init) => {
seen.push([String(url), init.redirect]);
return json(rows);
});
const sources = await loadIcelandSourcesFromOpenData();
assert.deepEqual(seen, [[ICELAND_CAMERAS_URL, 'manual']]);
assert.equal(sources.length, 8);
assert.equal(sources[0].id, 'is-vegagerdin-rvk_1');
assert.equal(sources[0].name, 'Kringlumýrarbraut');
assert.equal(sources[0].lat, 64.1466);
assert.equal(
new Set(sources.map((s) => s.id)).size,
sources.length,
'a frame the feed repeats collapses to one camera',
);
assert.equal(sources.available, 13);
});
test('the default Iceland cap is DEFAULT_ICELAND_MAX_SOURCES', async (t) => {
t.mock.method(console, 'log', () => {});
withEnv(t, { CCTV_ICELAND_MAX_SOURCES: undefined });
const rows = Array.from(
{ length: DEFAULT_ICELAND_MAX_SOURCES + 40 },
(_, i) => row({ Slod: `${ICELAND_IMAGE_ORIGIN}cam_${i}.jpg` }),
);
t.mock.method(globalThis, 'fetch', async () => json(rows));
assert.equal(
(await loadIcelandSourcesFromOpenData()).length,
DEFAULT_ICELAND_MAX_SOURCES,
);
});
test('CCTV_ICELAND_MAX_SOURCES is clamped to 8..600 and ignored when not a number', async (t) => {
t.mock.method(console, 'log', () => {});
withEnv(t, { CCTV_ICELAND_MAX_SOURCES: undefined });
const rows = Array.from({ length: 650 }, (_, i) =>
row({ Slod: `${ICELAND_IMAGE_ORIGIN}cam_${i}.jpg` }),
);
t.mock.method(globalThis, 'fetch', async () => json(rows));
for (const [value, expected] of [
['0', 8],
['3', 8],
['1000', 600],
['abc', DEFAULT_ICELAND_MAX_SOURCES],
]) {
process.env.CCTV_ICELAND_MAX_SOURCES = value;
assert.equal(
(await loadIcelandSourcesFromOpenData()).length,
expected,
value,
);
}
});
test('a redirect on the Iceland frame host stays inside vefmyndavelar/', async () => {
const frame = `${ICELAND_IMAGE_ORIGIN}hellisheidi_1.jpg`;
const redirectTo = (location) => async (url) =>
url === frame
? new Response(null, { status: 302, headers: { location } })
: new Response('frame', { headers: { 'content-type': 'image/jpeg' } });
for (const location of [
'/vgdata/other/x.jpg',
'/vgdata/vefmyndavelar/../x.jpg',
'/',
])
assert.equal(
await fetchWithinHost(frame, {}, redirectTo(location)),
null,
location,
);
const followed = await fetchWithinHost(
frame,
{},
redirectTo('/vgdata/vefmyndavelar/hellisheidi_1b.jpg'),
);
assert.equal(await followed.text(), 'frame');
});
test('the Iceland loader fails soft on HTTP errors, bad payloads and network errors', async (t) => {
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
t.mock.method(
globalThis,
'fetch',
async () => new Response('x', { status: 503 }),
);
assert.deepEqual(await loadIcelandSourcesFromOpenData(), []);
t.mock.restoreAll();
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async () => json({ rows: [] }));
assert.deepEqual(await loadIcelandSourcesFromOpenData(), []);
t.mock.method(
globalThis,
'fetch',
async () => new Response('<html>', { status: 200 }),
);
assert.deepEqual(await loadIcelandSourcesFromOpenData(), []);
t.mock.method(globalThis, 'fetch', async () => {
throw new Error('network down');
});
assert.deepEqual(await loadIcelandSourcesFromOpenData(), []);
});
test('the Iceland catalog fetch refuses redirects and oversized bodies', async (t) => {
t.mock.method(console, 'warn', () => {});
const redirected = streamingResponse({
status: 302,
headers: {
location: 'http://gagnaveita.vegagerdin.is/api/vefmyndavelar2014_1',
},
});
t.mock.method(globalThis, 'fetch', async () => redirected.response);
assert.deepEqual(await loadIcelandSourcesFromOpenData(), []);
assert.equal(
redirected.state.cancelled,
true,
'the redirect body is cancelled',
);
t.mock.restoreAll();
t.mock.method(console, 'warn', () => {});
const failed = streamingResponse({ status: 500 });
t.mock.method(globalThis, 'fetch', async () => failed.response);
assert.deepEqual(await loadIcelandSourcesFromOpenData(), []);
assert.equal(failed.state.cancelled, true, 'the error body is cancelled');
const valid = JSON.stringify([row()]);
t.mock.restoreAll();
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async () => {
return new Response(valid, {
headers: { 'content-length': String(ICELAND_MAX_CATALOG_BYTES + 1) },
});
});
assert.deepEqual(await loadIcelandSourcesFromOpenData(), []);
t.mock.restoreAll();
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async () => {
const padded = valid + ' '.repeat(ICELAND_MAX_CATALOG_BYTES + 1024);
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(padded));
controller.close();
},
}),
);
});
assert.deepEqual(await loadIcelandSourcesFromOpenData(), []);
});
test('CCTV catalog merges Iceland cameras and CCTV_ICELAND_ENABLED=0 skips the request', async (t) => {
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
const sourceRoot = fs.mkdtempSync(
path.join(os.tmpdir(), 'gev-cctv-catalog-iceland-'),
);
t.after(() => fs.rmSync(sourceRoot, { recursive: true, force: true }));
withEnv(t, {
CCTV_SOURCES_FILE: undefined,
CCTV_SOURCES_JSON: undefined,
CCTV_FORCE_AUSTIN: undefined,
CCTV_PREFER_AUSTIN: undefined,
CCTV_MAX_SOURCES: undefined,
CCTV_CALTRANS_DISTRICTS: undefined,
CCTV_TFL_ENABLED: undefined,
CCTV_ICELAND_ENABLED: undefined,
CCTV_ICELAND_MAX_SOURCES: undefined,
});
const requested = [];
t.mock.method(globalThis, 'fetch', async (url, init) => {
requested.push([String(url), init.redirect]);
if (String(url) !== ICELAND_CAMERAS_URL) {
return new Response('unavailable', { status: 503 });
}
return json([row()]);
});
const sources = await createCctvCatalog({ sourceRoot })();
assert.deepEqual(
sources.map((source) => source.id),
['is-vegagerdin-hellisheidi_1'],
);
process.env.CCTV_ICELAND_ENABLED = '0';
requested.length = 0;
assert.deepEqual(await createCctvCatalog({ sourceRoot })(), []);
assert.ok(requested.length > 0, 'the other live packs still load');
assert.equal(
requested.some(([url]) => url === ICELAND_CAMERAS_URL),
false,
'a disabled pack makes no request',
);
});
+3 -3
View File
@@ -8,7 +8,7 @@ import {
nycCameraToSource,
} from '../../server/providers/cctv/sources.js';
import {
DEFAULT_CCTV_MAX_SOURCES,
CCTV_SHARED_SOURCE_SLOTS,
DEFAULT_NYC_MAX_SOURCES,
NYC_CAMERAS_URL,
NYC_GROUND_ELEVATION_M,
@@ -375,8 +375,8 @@ for (const [label, flag] of [
});
}
test('the shipped catalog ceiling is not raised to make room for this pack', () => {
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 4000);
test('the shared catalog slots are not raised to make room for this pack', () => {
assert.equal(CCTV_SHARED_SOURCE_SLOTS, 4000);
assert.equal(DEFAULT_NYC_MAX_SOURCES, 250);
});
+255
View File
@@ -0,0 +1,255 @@
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 {
_resetOttawaCertificateWarningForTest,
loadOttawaSourcesFromOpenData,
ottawaAuthorizedFrameUrl,
} from '../../server/providers/cctv/ottawa.js';
const CERT = { OTTAWA_CAMERA_CERTIFICATE: 'CERT123' };
/** Set (or, for `undefined`, delete) environment variables for one test. */
function withEnv(t, env) {
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 documented `/map/service/camera` row carrying the fields the loader reads. */
function ottawaRow(number, lat, lon, overrides = {}) {
return {
id: number + 10000,
camera_number: number,
name: `Camera ${number}`,
name_french: `Caméra ${number}`,
cameraOwner: 'CITY',
latitude: lat,
longitude: lon,
...overrides,
};
}
/** The feed's envelope around its rows. */
const ottawaPayload = (rows) => Response.json({ cameras: rows });
test('without OTTAWA_CAMERA_CERTIFICATE the Ottawa pack is skipped with no request, logged once', async (t) => {
_resetOttawaCertificateWarningForTest();
const lines = [];
t.mock.method(console, 'warn', (line) => lines.push(String(line)));
const fetchMock = t.mock.method(globalThis, 'fetch', async () => {
throw new Error('no request expected');
});
assert.deepEqual(await loadOttawaSourcesFromOpenData({}, {}), []);
assert.deepEqual(
await loadOttawaSourcesFromOpenData({}, { OTTAWA_CAMERA_CERTIFICATE: ' ' }),
[],
);
assert.equal(fetchMock.mock.callCount(), 0);
assert.equal(lines.length, 1);
assert.match(lines[0], /OTTAWA_CAMERA_CERTIFICATE/);
});
test('Ottawa frame URLs are signed only inside the opendata camera path', () => {
assert.equal(
ottawaAuthorizedFrameUrl('https://traffic.ottawa.ca/opendata/camera?c=16', CERT),
'https://traffic.ottawa.ca/opendata/camera?c=16&certificate=CERT123&id=gev',
);
assert.equal(
ottawaAuthorizedFrameUrl('https://traffic.ottawa.ca/opendata/camera?c=16', {}),
'',
'no certificate, no signed URL',
);
for (const url of [
'https://traffic.ottawa.ca/opendata/camera?c=16x',
'https://traffic.ottawa.ca/opendata/other?c=16',
'https://traffic.ottawa.ca/map/camera?id=16',
'https://traffic.ottawa.ca.evil.test/opendata/camera?c=16',
'http://traffic.ottawa.ca/opendata/camera?c=16',
])
assert.equal(ottawaAuthorizedFrameUrl(url, CERT), '', url);
});
test('Ottawa loader keeps in-city cameras and builds keyless frame URLs from the camera number', async (t) => {
quiet(t);
withEnv(t, { CCTV_OTTAWA_MAX_SOURCES: undefined });
const requested = [];
t.mock.method(globalThis, 'fetch', async (url) => {
requested.push(String(url));
return ottawaPayload([
ottawaRow(2025, 45.341343, -75.81467, {
name: '(MTO) Hwy 416 NB ramp to Hwy 417 East',
cameraOwner: 'MTO',
}),
ottawaRow(1, 45.4215, -75.6972, {
name: 'Bank St at Somerset St',
camera_owner: 'CITY',
cameraOwner: undefined,
}),
ottawaRow(1, 45.4216, -75.6973, { name: 'duplicate number' }),
ottawaRow(0, 45.4215, -75.6972),
ottawaRow(-3, 45.4215, -75.6972),
ottawaRow(7, null, -75.7),
ottawaRow(8, 45.4, null),
ottawaRow(9, 43.6532, -79.3832, { description: 'Toronto' }),
ottawaRow(10, 45.9, -75.7),
]);
});
const cameras = await loadOttawaSourcesFromOpenData({}, CERT);
assert.deepEqual(requested, [OTTAWA_CAMERAS_URL]);
assert.deepEqual(cameras.map((camera) => camera.id).sort(), [
'ottawa-1',
'ottawa-2025',
]);
const mto = cameras.find((camera) => camera.id === 'ottawa-2025');
const city = cameras.find((camera) => camera.id === 'ottawa-1');
assert.equal(mto.url, 'https://traffic.ottawa.ca/opendata/camera?c=2025');
assert.doesNotMatch(mto.url, /certificate/, 'the catalog URL carries no key');
assert.equal(mto.snapshotUrl, mto.url);
assert.equal(mto.frameRefreshMs, 60000, 'the API allows one fetch a minute');
assert.equal(city.name, 'Bank St at Somerset St');
assert.equal(mto.name, '(MTO) Hwy 416 NB ramp to Hwy 417 East');
assert.equal(mto.city, 'Ottawa');
assert.equal(mto.provider, 'City of Ottawa');
assert.equal(mto.feedType, 'image');
assert.equal(mto.sourceKind, 'ottawa-traffic');
assert.equal(mto.headingConfidence, 'low');
assert.equal(
mto.headingDeg,
fallbackHeadingFromId('ottawa-2025'),
'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 () => ottawaPayload(rows));
const cameras = await loadOttawaSourcesFromOpenData({}, CERT);
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 () => Response.json([ottawaRow(1, 45.4215, -75.6972)]),
async () => {
throw new TypeError('fetch failed');
},
]) {
assert.deepEqual(await loadOttawaSourcesFromOpenData({}, CERT), []);
}
});
test('CCTV catalog merges Ottawa cameras with a certificate and CCTV_OTTAWA_ENABLED=0 skips the request', async (t) => {
quiet(t);
const sourceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gev-cctv-ottawa-'));
t.after(() => fs.rmSync(sourceRoot, { recursive: true, force: true }));
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,
CCTV_QUEBEC_ENABLED: '0',
CCTV_WORLD_CAMERAS_ENABLED: '0',
OTTAWA_CAMERA_CERTIFICATE: 'CERT123',
});
const requested = [];
t.mock.method(globalThis, 'fetch', async (url) => {
requested.push(String(url));
// Every other live pack is down; only Ottawa answers.
return String(url) === OTTAWA_CAMERAS_URL
? ottawaPayload([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/opendata/camera?c=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/opendata/camera?c=309',
OTTAWA_IMAGE_ORIGIN,
),
);
for (const url of [
'https://traffic.ottawa.ca.evil.test/opendata/camera?c=309',
'https://traffic.ottawa.ca/map/camera?id=309',
'https://user:[email protected]/opendata/camera?c=309',
'https://traffic.ottawa.ca:8443/opendata/camera?c=309',
'http://traffic.ottawa.ca/opendata/camera?c=309',
])
assert.equal(pinnedCameraUrl(url, OTTAWA_IMAGE_ORIGIN), null, url);
});
+231
View File
@@ -313,3 +313,234 @@ test('CCTV upstream frame fetch accepts only raster image types and sends their
assert.equal(state.cancelled, true, `${contentType}: body cancelled`);
}
});
function multipartResponse(contentType, chunks, { onCancel = () => {} } = {}) {
let nextChunk = 0;
const body = new ReadableStream(
{
pull(controller) {
if (nextChunk >= chunks.length) {
controller.close();
return;
}
controller.enqueue(chunks[nextChunk++]);
},
cancel() {
onCancel();
},
},
{ highWaterMark: 0 },
);
return new Response(body, {
status: 200,
headers: { 'Content-Type': contentType },
});
}
function jpegPart(boundary, body, contentType = 'image/jpeg', length = body.length) {
return Buffer.concat([
Buffer.from(
'--' +
boundary +
'\r\nContent-Type: ' +
contentType +
'\r\nContent-Length: ' +
length +
'\r\n\r\n',
),
body,
Buffer.from('\r\n'),
]);
}
test('CCTV upstream frame fetch extracts only the first JPEG from MJPEG', async () => {
const boundary = 'DIGIEVER';
const first = Buffer.from([0xff, 0xd8, 0x11, 0x22, 0xff, 0xd9]);
const second = Buffer.from([0xff, 0xd8, 0x33, 0x44, 0xff, 0xd9]);
for (const type of [
'multipart/x-mixed-replace; boundary=DIGIEVER',
'multipart/x-mixed-replace; boundary="DIGIEVER"',
]) {
let cancelled = false;
const result = await fetchCctvImageFromUpstream(
'https://cctv-ss02.thb.gov.tw/T62-9K+020',
{
allowMjpeg: true,
maxBytes: 1024,
fetchImpl: async () =>
multipartResponse(
type,
[
jpegPart(boundary, first),
jpegPart(boundary, second),
Buffer.from('--' + boundary + '--\r\n'),
],
{ onCancel: () => { cancelled = true; } },
),
},
);
assert.equal(result?.contentType, 'image/jpeg', type);
assert.deepEqual(result?.body, first, type);
assert.equal(cancelled, true, type);
}
});
test('CCTV upstream frame fetch rejects malformed and non-JPEG MJPEG parts', async () => {
const jpeg = Buffer.from([0xff, 0xd8, 0x01, 0x02, 0xff, 0xd9]);
for (const [type, chunks] of [
['multipart/x-mixed-replace', []],
[
'multipart/x-mixed-replace; boundary=DIGIEVER',
[jpegPart('DIGIEVER', jpeg, 'image/png')],
],
]) {
let cancelled = false;
const result = await fetchCctvImageFromUpstream(
'https://cctv-ss02.thb.gov.tw/T62-9K+020',
{
allowMjpeg: true,
fetchImpl: async () =>
multipartResponse(type, chunks, { onCancel: () => { cancelled = true; } }),
},
);
assert.equal(result, null, type);
assert.equal(cancelled, true, type);
}
});
test('CCTV upstream frame fetch rejects truncated or oversized MJPEG parts', async () => {
const boundary = 'DIGIEVER';
const full = Buffer.from([0xff, 0xd8, 0x01, 0x02, 0xff, 0xd9]);
const truncated = Buffer.from([0xff, 0xd8, 0x01, 0x02]);
for (const [chunks, maxBytes] of [
[jpegPart(boundary, truncated, 'image/jpeg', full.length), 1024],
[jpegPart(boundary, full), 5],
]) {
let cancelled = false;
const result = await fetchCctvImageFromUpstream(
'https://cctv-ss02.thb.gov.tw/T62-9K+020',
{
allowMjpeg: true,
maxBytes,
fetchImpl: async () =>
multipartResponse(
'multipart/x-mixed-replace; boundary=' + boundary,
[chunks],
{ onCancel: () => { cancelled = true; } },
),
},
);
assert.equal(result, null);
assert.equal(cancelled, true);
}
});
test('CCTV upstream frame fetch does not cross into a later part when the first JPEG is truncated', async () => {
const boundary = 'DIGIEVER';
const truncated = Buffer.from([0xff, 0xd8, 0x01, 0x02]);
const valid = Buffer.from([0xff, 0xd8, 0x03, 0x04, 0xff, 0xd9]);
const wire = Buffer.concat([
Buffer.from(
'--' + boundary + '\r\nContent-Type: image/jpeg\r\n\r\n',
),
truncated,
Buffer.from(
'\r\n--' +
boundary +
'\r\nContent-Type: image/jpeg\r\nContent-Length: ' +
valid.length +
'\r\n\r\n',
),
valid,
Buffer.from('\r\n--' + boundary + '--\r\n'),
]);
let cancelled = false;
const result = await fetchCctvImageFromUpstream(
'https://cctv-ss02.thb.gov.tw/T62-9K+020',
{
allowMjpeg: true,
maxBytes: 1024,
fetchImpl: async () =>
multipartResponse(
'multipart/x-mixed-replace; boundary=' + boundary,
[wire],
{ onCancel: () => { cancelled = true; } },
),
},
);
assert.equal(result, null);
assert.equal(cancelled, true);
});
test('CCTV upstream frame fetch ignores MJPEG unless the source is registered as MJPEG', async () => {
const boundary = 'DIGIEVER';
const jpeg = Buffer.from([0xff, 0xd8, 0x11, 0x22, 0xff, 0xd9]);
let cancelled = false;
const result = await fetchCctvImageFromUpstream(
'https://cctv-ss02.thb.gov.tw/T62-9K+020',
{
fetchImpl: async () =>
multipartResponse(
'multipart/x-mixed-replace; boundary=' + boundary,
[jpegPart(boundary, jpeg)],
{ onCancel: () => { cancelled = true; } },
),
},
);
assert.equal(result, null);
assert.equal(cancelled, true);
});
test('CCTV upstream frame fetch accepts a CRLF preamble and a boundary that already starts with --', async () => {
const jpeg = Buffer.from([0xff, 0xd8, 0x11, 0x22, 0xff, 0xd9]);
for (const [type, wire] of [
[
'multipart/x-mixed-replace; boundary=DIGIEVER',
Buffer.concat([Buffer.from('\r\n'), jpegPart('DIGIEVER', jpeg)]),
],
[
'multipart/x-mixed-replace; boundary=--myboundary',
Buffer.concat([
Buffer.from(
'--myboundary\r\nContent-Type: image/jpeg\r\nContent-Length: ' +
jpeg.length +
'\r\n\r\n',
),
jpeg,
Buffer.from('\r\n--myboundary\r\n'),
]),
],
]) {
const result = await fetchCctvImageFromUpstream(
'https://cctv-ss02.thb.gov.tw/T62-9K+020',
{
allowMjpeg: true,
fetchImpl: async () => multipartResponse(type, [wire]),
},
);
assert.deepEqual(result?.body, jpeg, type);
}
});
test('CCTV MJPEG part without Content-Length ends at the next delimiter, not at an inner JPEG end marker', async () => {
const boundary = 'DIGIEVER';
// An EXIF thumbnail's own FF D9 sits inside the image.
const jpeg = Buffer.from([0xff, 0xd8, 0x01, 0xff, 0xd9, 0x02, 0x03, 0xff, 0xd9]);
const wire = Buffer.concat([
Buffer.from('--' + boundary + '\r\nContent-Type: image/jpeg\r\n\r\n'),
jpeg,
Buffer.from('\r\n--' + boundary + '\r\nContent-Type: image/jpeg\r\n\r\n'),
]);
// Delivered one byte at a time, as a trickling upstream would.
const chunks = [...wire].map((byte) => Buffer.from([byte]));
const result = await fetchCctvImageFromUpstream(
'https://cctv-ss02.thb.gov.tw/T62-9K+020',
{
allowMjpeg: true,
fetchImpl: async () =>
multipartResponse('multipart/x-mixed-replace; boundary=' + boundary, chunks),
},
);
assert.deepEqual(result?.body, jpeg);
});
+87 -3
View File
@@ -182,6 +182,86 @@ test('loader deduplicates cameras that share a camera number', async (t) => {
]);
});
test('falls back to Highway Bureau MJPEG cameras when the Freeway catalog fails', async (t) => {
const highwayUrl = 'https://cctv-maintain.thb.gov.tw/opendataCCTVs.xml';
const requests = [];
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async (url, options) => {
const href = String(url);
requests.push(href);
assert.equal(options.redirect, 'manual');
if (href === TAIWAN_FREEWAY_CCTV_URL)
return new Response('', { status: 503 });
if (href === highwayUrl) {
return new Response(
cctvXml(
block({
id: 'CCTV-14-0620-009-002',
stream: 'https://cctv-ss02.thb.gov.tw:443/T62-9K+020',
lat: '24.9967',
lon: '121.4521',
}),
),
{ headers: { 'content-type': 'application/xml' } },
);
}
throw new Error('Unexpected Taiwan feed URL: ' + href);
});
const sources = await loadTaiwanSources();
assert.deepEqual(requests, [TAIWAN_FREEWAY_CCTV_URL, highwayUrl]);
assert.equal(sources.length, 1);
assert.equal(sources[0].id, 'tw-thb-cctv-14-0620-009-002');
assert.equal(sources[0].cityId, 'taiwan');
assert.equal(sources[0].provider, 'Highway Bureau, MOTC');
assert.equal(sources[0].feedType, 'mjpeg');
assert.equal(sources[0].sourceKind, 'taiwan-highway-motc');
assert.equal(new URL(sources[0].url).hostname, 'cctv-ss02.thb.gov.tw');
assert.equal(new URL(sources[0].url).pathname, '/T62-9K+020');
});
test('the Highway Bureau fallback registers only approved HTTPS camera servers', async (t) => {
const highwayUrl = 'https://cctv-maintain.thb.gov.tw/opendataCCTVs.xml';
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async (url) => {
if (String(url) === TAIWAN_FREEWAY_CCTV_URL)
return new Response('', { status: 503 });
if (String(url) === highwayUrl) {
return new Response(
cctvXml(
block({
id: 'CCTV-14-0620-009-001',
stream: 'https://cctv-ss09.thb.gov.tw/T62-9K+019',
}),
block({
id: 'CCTV-14-0620-009-003',
stream: 'http://cctv-ss02.thb.gov.tw/T62-9K+021',
}),
block({
id: 'CCTV-14-0620-009-004',
stream: 'https://cctv-ss02.thb.gov.tw:8443/T62-9K+022',
}),
block({
id: 'CCTV-14-0620-009-002',
stream: 'https://cctv-ss02.thb.gov.tw:443/T62-9K+020',
}),
),
{ headers: { 'content-type': 'application/xml' } },
);
}
throw new Error('Unexpected Taiwan feed URL: ' + String(url));
});
const sources = await loadTaiwanSources();
assert.deepEqual(
sources.map((source) => source.id),
['tw-thb-cctv-14-0620-009-002'],
);
});
test('a redirected or failed catalog contributes an empty pack', async (t) => {
t.mock.method(console, 'warn', () => {});
for (const status of [302, 503]) {
@@ -214,13 +294,17 @@ test('the Taiwan pack is in the catalog by default', async (t) => {
);
});
test('CCTV_TAIWAN_ENABLED=0 makes no Freeway Bureau request', async (t) => {
test('CCTV_TAIWAN_ENABLED=0 makes no Taiwan catalog request', async (t) => {
const { requested, sources } = await runCatalogWithTaiwanFlag(t, '0');
assert.ok(requested.length > 0, 'the other packs still refreshed');
assert.equal(
requested.some((href) => href.includes('freeway.gov.tw')),
false,
);
assert.equal(
requested.some((href) => href.includes('cctv-maintain.thb.gov.tw')),
false,
);
assert.ok(!sources.some((c) => c.cityId === 'taiwan'));
});
@@ -250,12 +334,12 @@ test('a failed download is not retried until the backoff ends', async (t) => {
t.mock.method(Date, 'now', () => 1_000_000);
assert.deepEqual(await loadTaiwanSources(), []);
assert.deepEqual(await loadTaiwanSources(), []);
assert.equal(calls, 1, 'the second refresh made no request');
assert.equal(calls, 2, 'the second refresh made no request');
Date.now.mock.mockImplementation(
() => 1_000_000 + TAIWAN_RETRY_AFTER_FAILURE_MS + 1,
);
await loadTaiwanSources();
assert.equal(calls, 2, 'the backoff ends after an hour');
assert.equal(calls, 4, 'the backoff ends after an hour');
});
test('a failed download serves the last good table', async (t) => {
+3 -3
View File
@@ -10,7 +10,7 @@ import {
TORONTO_RESCU_LIST_URL,
TORONTO_RESCU_IMAGE_ORIGIN,
TORONTO_MAX_CATALOG_BYTES,
DEFAULT_CCTV_MAX_SOURCES,
CCTV_SHARED_SOURCE_SLOTS,
} from '../../server/providers/cctv/constants.js';
import { CAMERA_CODE_MAX_CHARS } from '../../server/providers/cctv/normalize.js';
import { allocateSourceCap } from '../../server/providers/cctv/cap.js';
@@ -418,6 +418,6 @@ test('CCTV_TORONTO_ENABLED=0 keeps the lane from being loaded at all', async (t)
}
});
test('the shipped catalog ceiling is not raised to make room for this pack', () => {
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 4000);
test('the shared catalog slots are not raised to make room for this pack', () => {
assert.equal(CCTV_SHARED_SOURCE_SLOTS, 4000);
});
+316
View File
@@ -0,0 +1,316 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
DEFAULT_WORLD_CCTV_SOURCE_FILE,
WORLD_CCTV_SOURCE_COUNT,
} from '../../server/providers/cctv/constants.js';
import {
isMlitRiverAdjustmentImage,
loadWorldCameraSources,
} from '../../server/providers/cctv/worldCameras.js';
import {
loadQuebecSources,
QUEBEC_CAMERA_SOURCE_COUNT,
quebec511VideoUrl,
quebecBrowserVideoEnabled,
} from '../../server/providers/cctv/quebec.js';
import { cctvProxy } from '../../server/providers/cctv.js';
const REPO_ROOT = path.resolve(import.meta.dirname, '../..');
const JPEG = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0xff, 0xd9]);
/** Silence the loaders' progress and failure logging. */
function quiet(t) {
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
}
/** Set (or, for `undefined`, delete) environment variables for one test. */
function withEnv(t, env) {
for (const [name, value] of Object.entries(env)) {
const previous = process.env[name];
t.after(() => {
if (previous === undefined) delete process.env[name];
else process.env[name] = previous;
});
if (value === undefined) delete process.env[name];
else process.env[name] = value;
}
}
/** A temporary source root holding the given World and Québec catalogs. */
function sourceRoot(t, { world = [], quebec = null } = {}) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gev-cctv-world-'));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const worldFile = path.join(root, DEFAULT_WORLD_CCTV_SOURCE_FILE);
fs.mkdirSync(path.dirname(worldFile), { recursive: true });
fs.writeFileSync(worldFile, JSON.stringify(world));
if (quebec) {
fs.mkdirSync(path.join(root, 'public'), { recursive: true });
fs.writeFileSync(
path.join(root, 'public/quebec-cameras.json'),
JSON.stringify({ cameras: quebec }),
);
}
return root;
}
const oregonRow = (overrides = {}) => ({
id: 'world-or-764',
name: '3rd St at Harwood MP18.35',
provider: 'TripCheck (ODOT)',
lat: 44.3029,
lon: -120.8549,
sourceKind: 'tripcheck-odot',
url: 'https://www.tripcheck.com/roadcams/cams/Harwood_pid3527.jpg',
snapshotUrl: 'https://www.tripcheck.com/roadcams/cams/Harwood_pid3527.jpg',
...overrides,
});
test('the shipped World catalog loads every curated row', (t) => {
quiet(t);
const sources = loadWorldCameraSources({ sourceRoot: REPO_ROOT });
assert.equal(sources.length, WORLD_CCTV_SOURCE_COUNT);
const byKind = Object.groupBy(sources, (source) => source.sourceKind);
// Iceland comes from the live Iceland pack, never a second World copy.
assert.equal(byKind['vegagerdin-road'], undefined);
assert.equal(byKind['tripcheck-odot'].length, 1176);
assert.equal(byKind['mlit-river'].length, 1054);
for (const source of byKind['mlit-river'])
assert.equal(source.frameRefreshMs, 10 * 60 * 1000);
});
test('the World loader accepts a pinned row and rejects anything outside its policy', (t) => {
quiet(t);
const rejected = [
['unknown kind', { sourceKind: 'nzta-journeys' }],
[
'Iceland duplicates the Iceland pack',
{
sourceKind: 'vegagerdin-road',
lat: 64.291877,
lon: -15.028035,
url: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/almannaskard_1.jpg',
snapshotUrl: 'https://www.vegagerdin.is/vgdata/vefmyndavelar/almannaskard_1.jpg',
},
],
['wrong id prefix', { id: 'world-jp-river-7075' }],
['outside bounds', { lat: 40, lon: -15 }],
['other host', { url: 'https://evil.test/roadcams/cams/a.jpg' }],
['other directory', { url: 'https://www.tripcheck.com/other/a.jpg' }],
['query string', { url: oregonRow().url + '?x=1' }],
['not a jpg', { url: 'https://www.tripcheck.com/roadcams/cams/a.png' }],
['plain http', { url: oregonRow().url.replace('https:', 'http:') }],
[
'url and snapshot differ',
{ snapshotUrl: 'https://www.tripcheck.com/roadcams/cams/b.jpg' },
],
];
const root = sourceRoot(t, {
world: [
oregonRow(),
oregonRow(), // duplicate id
...rejected.map(([, overrides], i) =>
oregonRow({ id: `world-or-${9000 + i}`, ...overrides }),
),
],
});
const sources = loadWorldCameraSources({ sourceRoot: root });
assert.deepEqual(
sources.map((source) => source.id),
['world-or-764'],
);
assert.equal(sources[0].frameRefreshMs, 5 * 60 * 1000);
assert.equal(sources[0].feedType, 'image');
});
test('the World loader fails soft on a missing or malformed catalog', (t) => {
quiet(t);
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gev-cctv-world-'));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
assert.deepEqual(loadWorldCameraSources({ sourceRoot: root }), []);
const file = path.join(root, DEFAULT_WORLD_CCTV_SOURCE_FILE);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, '{not json');
assert.deepEqual(loadWorldCameraSources({ sourceRoot: root }), []);
});
test('only the exact MLIT adjustment image is recognised', () => {
assert.equal(isMlitRiverAdjustmentImage({ ok: true, body: JPEG }), false);
assert.equal(isMlitRiverAdjustmentImage(null), false);
assert.equal(isMlitRiverAdjustmentImage({ ok: false, body: JPEG }), false);
});
test('the shipped Québec catalog loads every location', (t) => {
quiet(t);
const sources = loadQuebecSources({ sourceRoot: REPO_ROOT });
assert.equal(sources.length, QUEBEC_CAMERA_SOURCE_COUNT);
for (const source of sources) {
assert.equal(source.url, '', 'the server never fetches Québec media');
assert.equal(source.snapshotUrl, '');
}
});
test('the Québec loader maps ids by kind, keeps https viewer links, and splits the MTQ credit', (t) => {
quiet(t);
const at = { lat: 45.51651, lon: -73.5502 };
const root = sourceRoot(t, {
quebec: [
{ id: '3735', kind: 'traffic', name: 'Route 136', viewerUrl: 'https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3735', ...at },
{ id: 'mtl-465', kind: 'montreal', name: 'Montréal 465', viewerUrl: 'http://ville.montreal.qc.ca/x', ...at },
{ id: 'web-1', kind: 'webcam', name: 'Tremblant', operator: 'Station Mont-Tremblant', viewerUrl: 'https://user:[email protected]/', ...at },
{ id: 'abc', kind: 'traffic', ...at }, // non-numeric traffic id
{ id: '3735', kind: 'traffic', ...at }, // duplicate
{ id: '9', kind: 'traffic', lat: 40, lon: -73.5 }, // outside Québec
],
});
const sources = loadQuebecSources({ sourceRoot: root });
const byId = new Map(sources.map((source) => [source.id, source]));
assert.deepEqual([...byId.keys()], ['qc-511-3735', 'qc-mtl-465', 'qc-web-1']);
const mtq = byId.get('qc-511-3735');
assert.equal(mtq.sourceKind, 'quebec-511-browser');
assert.match(mtq.credit, /Locations: MTQ · CC BY 4\.0/);
assert.match(mtq.credit, /Video: Québec 511/);
assert.doesNotMatch(mtq.license, /^Données Québec \/ MTQ, CC BY 4\.0$/);
assert.equal(byId.get('qc-mtl-465').sourceUrl, '', 'http viewer links are dropped');
assert.equal(byId.get('qc-web-1').sourceUrl, '', 'credentialed links are dropped');
assert.equal(
quebec511VideoUrl('qc-511-3735'),
'https://www.quebec511.info/Carte/Fenetres/camera.ashx?id=3735&format=mp4',
);
assert.equal(quebec511VideoUrl('qc-mtl-465'), '');
});
test('Québec 511 browser video is opt-in', () => {
assert.equal(quebecBrowserVideoEnabled({}), false);
assert.equal(quebecBrowserVideoEnabled({ CCTV_QUEBEC_VIDEO_ENABLED: '0' }), false);
assert.equal(quebecBrowserVideoEnabled({ CCTV_QUEBEC_VIDEO_ENABLED: ' 1 ' }), true);
});
/** Mount the real proxy with a fixed fetch, and drive requests through it. */
function mount(t, root, respond) {
withEnv(t, {
CCTV_SOURCES_FILE: 'absent-source-file.json',
CCTV_SOURCES_JSON: undefined,
CCTV_FORCE_AUSTIN: '0',
CCTV_MAX_SOURCES: undefined,
// A configured key makes any Street View fallback observable.
GOOGLE_MAPS_SERVER_API_KEY: 'test-google-key',
});
const requests = [];
t.mock.method(globalThis, 'fetch', async (url, init = {}) => {
requests.push(String(url));
return respond(String(url), init) || new Response('down', { status: 503 });
});
let handler;
cctvProxy({ sourceRoot: root }).configureServer({
middlewares: {
use(_route, fn) {
handler = fn;
},
},
});
const call = async (url) => {
const res = {
writeHead(status, headers) {
Object.assign(this, { status, headers });
},
end(body) {
this.body = body;
},
on() {},
once() {},
};
await handler({ url, method: 'GET', headers: {}, on() {} }, res);
return res;
};
return { call, requests };
}
test('/sources lists Québec browser media only when the operator opts in', async (t) => {
quiet(t);
const root = sourceRoot(t, {
quebec: [{ id: '3735', kind: 'traffic', name: 'Route 136', lat: 45.5, lon: -73.5 }],
});
withEnv(t, { CCTV_QUEBEC_VIDEO_ENABLED: undefined });
const app = mount(t, root, () => null);
const off = JSON.parse((await app.call('/sources')).body).sources;
assert.equal(off.find((s) => s.id === 'qc-511-3735').browserMediaUrl, '');
process.env.CCTV_QUEBEC_VIDEO_ENABLED = '1';
const on = JSON.parse((await app.call('/sources')).body).sources;
assert.match(on.find((s) => s.id === 'qc-511-3735').browserMediaUrl, /camera\.ashx\?id=3735&format=mp4$/);
});
test('a World frame is fetched once per refresh floor and the cooldown never spends Street View', async (t) => {
quiet(t);
const root = sourceRoot(t, { world: [oregonRow()] });
let answer = () => new Response(JPEG, { headers: { 'content-type': 'image/jpeg' } });
const app = mount(t, root, (url) =>
url === oregonRow().url ? answer() : null,
);
const frame = '/frame/world-or-764';
const upstreamHits = () =>
app.requests.filter((url) => url === oregonRow().url).length;
const first = await app.call(frame);
assert.equal(first.headers['X-CCTV-Source'], 'upstream-image');
const second = await app.call(frame);
assert.equal(second.headers['X-CCTV-Source'], 'upstream-image');
assert.equal(upstreamHits(), 1, 'the second view reuses the cached frame');
// A camera whose first fetch fails is inside its failure backoff: it gets
// the placeholder, not Google Street View, and no second upstream request.
const failRoot = sourceRoot(t, { world: [oregonRow()] });
answer = () => new Response('down', { status: 503 });
const failing = mount(t, failRoot, (url) =>
url === oregonRow().url ? answer() : null,
);
const failed = await failing.call(frame);
assert.notEqual(failed.headers['X-CCTV-Source'], 'upstream-image');
const retry = await failing.call(frame);
assert.equal(retry.headers['X-CCTV-Source'], 'synthetic');
assert.equal(
failing.requests.filter((url) => url === oregonRow().url).length,
1,
'the backoff holds the provider cadence',
);
assert.equal(
failing.requests.filter((url) => url.includes('maps.googleapis.com/maps/api/streetview')).length,
1,
'only the first miss, outside any cooldown, may try Street View',
);
});
test('Ottawa frames are signed with the certificate at fetch time only', async (t) => {
quiet(t);
const root = sourceRoot(t);
withEnv(t, { OTTAWA_CAMERA_CERTIFICATE: 'CERT123' });
const app = mount(t, root, (url) => {
if (url === 'https://traffic.ottawa.ca/map/service/camera')
return Response.json({
cameras: [
{ camera_number: 16, name: 'Bank & Hunt Club', latitude: 45.35, longitude: -75.65, cameraOwner: 'CITY' },
],
});
if (url.startsWith('https://traffic.ottawa.ca/opendata/camera'))
return new Response(JPEG, { headers: { 'content-type': 'image/jpeg' } });
return null;
});
const sources = JSON.parse((await app.call('/sources')).body).sources;
const ottawa = sources.find((source) => source.id === 'ottawa-16');
assert.ok(ottawa);
assert.doesNotMatch(JSON.stringify(sources), /CERT123/, 'no catalog field carries the certificate');
const frame = await app.call('/frame/ottawa-16');
assert.equal(frame.headers['X-CCTV-Source'], 'upstream-image');
await app.call('/frame/ottawa-16');
const hits = app.requests.filter((url) => url.includes('/opendata/camera'));
assert.deepEqual(hits, [
'https://traffic.ottawa.ca/opendata/camera?c=16&certificate=CERT123&id=gev',
], 'one signed request per camera per minute');
});
+49
View File
@@ -299,6 +299,38 @@ export const DATA_CREDITS = [
'CCTV static data (MOTC 2.0), released under the ' +
'<a href="https://data.gov.tw/license" target="_blank" rel="noopener">Open Government Data License, Taiwan (Version 1.0)</a>',
},
{
key: 'ottawa-cctv',
html:
'Traffic cameras (Ottawa, with an access certificate): ' +
'<a href="https://traffic.ottawa.ca/en/traffic-map-data-lists-and-resources/about-the-data" target="_blank" rel="noopener">City of Ottawa Traffic Camera Images API</a>',
},
{
key: 'quebec-cctv',
html:
'Camera locations (Québec): Ministère des Transports et de la Mobilité durable via ' +
'<a href="https://www.donneesquebec.ca/" target="_blank" rel="noopener">Données Québec</a> ' +
'(<a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noopener">CC BY 4.0</a>); ' +
'Montréal camera locations © OpenStreetMap contributors (ODbL). ' +
'Video (when enabled) and linked webcam pages stay with ' +
'<a href="https://www.quebec511.info/" target="_blank" rel="noopener">Québec 511</a> and each operator, under their own terms',
},
{
key: 'tripcheck-cctv',
html:
'Road camera stills (Oregon): camera courtesy of ODOT, via ' +
'<a href="https://www.tripcheck.com/" target="_blank" rel="noopener">TripCheck</a>; ' +
'partner cameras (counties, cities, Caltrans, WSDOT) are named in the camera label',
},
{
key: 'mlit-river-cctv',
html:
'River camera stills (Japan): 出典:国土交通省 ' +
'<a href="https://www.river.go.jp/" target="_blank" rel="noopener">川の防災情報</a> ' +
'(Ministry of Land, Infrastructure, Transport and Tourism; ' +
'<a href="https://www.river.go.jp/kawabou/kwb_apend/html/caution.html" target="_blank" rel="noopener">terms</a>), ' +
'fetched on view, at most every ten minutes per camera',
},
{
key: 'calgary-cctv',
html:
@@ -321,6 +353,15 @@ export const DATA_CREDITS = [
'(Llicència oberta d’ús d’informació - Catalunya); some cameras are supplied by partners ' +
'(Ajuntament de Barcelona, Ajuntament de Terrassa), each named in the CCTV panel',
},
{
key: 'iceland-cctv',
html:
'CCTV cameras &amp; frames (Iceland): road camera images, fetched live, from the ' +
'<a href="https://www.vegagerdin.is/" target="_blank" rel="noopener">Icelandic Road and Coastal Administration (Vegagerðin)</a> — ' +
'<a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noopener">CC BY 4.0</a> ' +
'(<a href="https://www.vegagerdin.is/vegagerdin/gagnasafn/vefthjonustur/terms-and-conditions" target="_blank" rel="noopener">terms</a>); ' +
'no endorsement by IRCA is implied',
},
{
key: 'kingcounty-cctv',
html:
@@ -345,6 +386,14 @@ export const DATA_CREDITS = [
'<a href="https://data.gov.hk/en-data/dataset/hk-td-tis_2-traffic-snapshot-images" target="_blank" rel="noopener">Transport Department / DATA.GOV.HK</a> ' +
'(<a href="https://data.gov.hk/en/terms-and-conditions" target="_blank" rel="noopener">Terms of Use</a> — attribution required)',
},
{
key: 'webcamromania-cctv',
html:
'Giroc camera (when configured): ' +
'<a href="https://webcamromania.ro/webcam-orase/webcam-giroc/" target="_blank" rel="noopener">Webcam Romania</a>. ' +
'Video rights remain with the camera operator; ' +
'<a href="https://webcamromania.ro/termeni-si-conditii/" target="_blank" rel="noopener">provider terms</a> apply.',
},
{
key: 'vegvesen-cctv',
html:
+14
View File
@@ -177,3 +177,17 @@ test('the News layer credits its built-in sources', () => {
);
assert.doesNotMatch(credit('news').html, /No single provider/);
});
test('the PR #1506 camera packs are credited with their sources and terms', () => {
const html = (key) => DATA_CREDITS.find((entry) => entry.key === key)?.html;
assert.match(html('ottawa-cctv') || '', /traffic\.ottawa\.ca/);
assert.match(html('quebec-cctv') || '', /CC BY 4\.0/);
assert.match(html('quebec-cctv') || '', /OpenStreetMap contributors/);
assert.match(html('tripcheck-cctv') || '', /courtesy of ODOT/);
// MLIT's terms ask for the source in this form, with the page link.
assert.match(html('mlit-river-cctv') || '', /出典:国土交通省/);
assert.match(
html('mlit-river-cctv') || '',
/href="https:\/\/www\.river\.go\.jp\//,
);
});
+3
View File
@@ -335,6 +335,9 @@ const OPTION_GROUPS = Object.freeze({
osm: 'o',
hybrid: 'h',
}),
// Street Traffic row toggles: flow-colored road lines, moving dots.
booleanOption('lines', 'l', false),
booleanOption('dots', 'd', true),
]),
'weather-lightning': Object.freeze([
enumOption('opacity', 'o', 'strong', ['light', 'strong'], {
+1 -1
View File
@@ -85,7 +85,7 @@ test('Warendorf webcam display introduces the shared OSM credit', (t) => {
const v = viewer(t), empty = new Proxy({}, { get: () => noop });
const lifecycle = createCctvLifecycle({ state: { _viewer: v, _records: [{ camera: { id: 'warendorf-marktplatz-rathaus', cityId: 'warendorf' } }], _recordById: new Map(), _cctvOverlayHost: overlayHost },
services: { credits: { showOsmCredit, hideOsmCredit }, sprites: empty, activation: empty, picking: empty, focus: empty, render: empty },
parts: { selection: empty, geometryQueue: empty, rendering: empty, projection: empty, cards: empty, presentation: empty }, source: {} });
parts: { selection: empty, geometryQueue: empty, rendering: empty, projection: empty, cards: empty, presentation: empty, navigation: empty }, source: {} });
lifecycle.methods.enable(); assertCredit(v); lifecycle.methods.disable();
assert.equal(v.credits.filter(c => c.showOnScreen).length, 0);
});
+12 -1
View File
@@ -400,6 +400,8 @@
"AVAILABLE MISSIONS": "المهام المتاحة",
"SELECT A MISSION TO INSPECT": "اختر مهمة لفحصها",
"LOADING 30-DAY MISSION INDEX": "جارٍ تحميل فهرس مهام 30 يومًا",
"Space Missions unavailable: the mission index could not load. Try again later.": "المهام الفضائية غير متاحة: تعذّر تحميل فهرس المهام. حاول مرة أخرى لاحقًا.",
"30-DAY MISSION INDEX UNAVAILABLE": "فهرس مهام 30 يومًا غير متاح",
"RADIO": "الراديو",
"ENABLE": "تفعيل",
"Previous filtered radio station": "محطة الراديو السابقة في القائمة المصفّاة",
@@ -1847,6 +1849,11 @@
"TomTom roads first; OpenStreetMap fills the rest, simulated": "طرق TomTom أولًا؛ ويكمل OpenStreetMap الباقي بتدفق محاكى",
"TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "طرق TomTom أولًا؛ ويكمل OpenStreetMap الباقي بتدفق محاكى (يحتاج إلى مفتاح TomTom)",
"Hybrid": "هجين",
"Lines": "خطوط",
"Dots": "نقاط",
"Road lines colored by live flow: green to red": "خطوط الطرق ملوّنة حسب التدفق المباشر: من الأخضر إلى الأحمر",
"Moving dots colored by live flow": "نقاط متحركة ملوّنة حسب التدفق المباشر",
"Moving dots": "نقاط متحركة",
"TomTom key unavailable": "مفتاح TomTom غير متاح",
"TomTom daily budget reached": "بلغت الحصة اليومية لـ TomTom",
"Monthly traffic allowance used": "تم استهلاك حصة حركة المرور الشهرية",
@@ -3588,5 +3595,9 @@
"Maximum event age: {days}d; undated markers are kept": "الحد الأقصى لعمر الحدث: {days} يوم؛ تُحفظ العلامات غير المؤرخة",
"event age filter|All": "الكل",
"Event age": "عمر الحدث",
"Event age: {days}d · undated kept": "عمر الحدث: {days} يوم · الاحتفاظ بغير المؤرخ"
"Event age: {days}d · undated kept": "عمر الحدث: {days} يوم · الاحتفاظ بغير المؤرخ",
"Close camera preview": "إغلاق معاينة الكاميرا",
"Refresh map still": "تحديث صورة الخريطة الثابتة",
"The map still uses a separate request and may show a different moment.": "تستخدم صورة الخريطة طلبًا منفصلًا وقد تعرض لحظة مختلفة.",
"Video plays from Québec 511, which sees your IP address.": "يُشغَّل الفيديو من Québec 511، الذي يرى عنوان IP الخاص بك."
}
+12 -1
View File
@@ -848,6 +848,8 @@
"AVAILABLE MISSIONS": "VERFÜGBARE MISSIONEN",
"SELECT A MISSION TO INSPECT": "MISSION ZUM ANSEHEN WÄHLEN",
"LOADING 30-DAY MISSION INDEX": "30-TAGE-MISSIONSINDEX WIRD GELADEN",
"Space Missions unavailable: the mission index could not load. Try again later.": "Raummissionen nicht verfügbar: Der Missionsindex konnte nicht geladen werden. Später erneut versuchen.",
"30-DAY MISSION INDEX UNAVAILABLE": "30-TAGE-MISSIONSINDEX NICHT VERFÜGBAR",
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB: VORSCHAU · ENTER / LEERTASTE: AUSWAHL",
"Internet radio and local SDR": "Internetradio und lokales SDR",
"STATION TAG": "KATEGORIE",
@@ -1753,6 +1755,11 @@
"TomTom roads first; OpenStreetMap fills the rest, simulated": "Zuerst TomTom-Straßen; OpenStreetMap füllt den Rest, simuliert",
"TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "Zuerst TomTom-Straßen; OpenStreetMap füllt den Rest, simuliert (braucht einen TomTom-Schlüssel)",
"Hybrid": "Hybrid",
"Lines": "Linien",
"Dots": "Punkte",
"Road lines colored by live flow: green to red": "Straßenlinien nach Live-Verkehrsfluss gefärbt: grün bis rot",
"Moving dots colored by live flow": "Bewegte Punkte nach Live-Verkehrsfluss gefärbt",
"Moving dots": "Bewegte Punkte",
"TomTom key unavailable": "TomTom-Schlüssel nicht verfügbar",
"TomTom daily budget reached": "TomTom-Tagesbudget erreicht",
"Monthly traffic allowance used": "Monatliches Verkehrskontingent aufgebraucht",
@@ -3276,5 +3283,9 @@
"Maximum event age: {days}d; undated markers are kept": "Maximales Ereignisalter: {days} Tage; undatierte Markierungen bleiben",
"event age filter|All": "Alle",
"Event age": "Ereignisalter",
"Event age: {days}d · undated kept": "Ereignisalter: {days} Tage · undatierte bleiben"
"Event age: {days}d · undated kept": "Ereignisalter: {days} Tage · undatierte bleiben",
"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.",
"Video plays from Québec 511, which sees your IP address.": "Das Video wird von Québec 511 abgespielt, das Ihre IP-Adresse sieht."
}
+12 -1
View File
@@ -282,6 +282,8 @@
"AVAILABLE MISSIONS": "MISIONES DISPONIBLES",
"SELECT A MISSION TO INSPECT": "SELECCIONA UNA MISIÓN PARA INSPECCIONAR",
"LOADING 30-DAY MISSION INDEX": "CARGANDO ÍNDICE DE MISIONES DE 30 DÍAS",
"Space Missions unavailable: the mission index could not load. Try again later.": "Misiones espaciales no disponibles: no se pudo cargar el índice de misiones. Inténtalo más tarde.",
"30-DAY MISSION INDEX UNAVAILABLE": "ÍNDICE DE MISIONES DE 30 DÍAS NO DISPONIBLE",
"RADIO": "RADIO",
"ENABLE": "ACTIVAR",
"Previous filtered radio station": "Emisora de radio filtrada anterior",
@@ -2788,6 +2790,11 @@
"Zoom in for vector tile coverage": "Acércate para cargar las teselas vectoriales",
"Camera coverage unavailable": "Cobertura de cámaras no disponible",
"Hybrid": "Híbrido",
"Lines": "Líneas",
"Dots": "Puntos",
"Road lines colored by live flow: green to red": "Líneas de vías coloreadas por el flujo en vivo: de verde a rojo",
"Moving dots colored by live flow": "Puntos en movimiento coloreados por el flujo en vivo",
"Moving dots": "Puntos en movimiento",
"TomTom roads only, each with its live flow": "Solo vías de TomTom, cada una con su flujo en vivo",
"TomTom roads only, each with its live flow (needs a TomTom key)": "Solo vías de TomTom, cada una con su flujo en vivo (necesita una clave de TomTom)",
"OpenStreetMap roads, with TomTom flow matched when available": "Vías de OpenStreetMap, con el flujo de TomTom emparejado cuando está disponible",
@@ -3354,5 +3361,9 @@
"Maximum event age: {days}d; undated markers are kept": "Antigüedad máxima: {days} días; se conservan los marcadores sin fecha",
"event age filter|All": "Todos",
"Event age": "Antigüedad",
"Event age: {days}d · undated kept": "Antigüedad: {days} días · se conservan los marcadores sin fecha"
"Event age: {days}d · undated kept": "Antigüedad: {days} días · se conservan los marcadores sin fecha",
"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.",
"Video plays from Québec 511, which sees your IP address.": "El vídeo se reproduce desde Québec 511, que ve tu dirección IP."
}
+12 -1
View File
@@ -1193,6 +1193,11 @@
"TomTom roads first; OpenStreetMap fills the rest, simulated": "Routes TomTom d'abord ; OpenStreetMap complète le reste, en simulation",
"TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "Routes TomTom d'abord ; OpenStreetMap complète le reste, en simulation (nécessite une clé TomTom)",
"Hybrid": "Hybride",
"Lines": "Lignes",
"Dots": "Points",
"Road lines colored by live flow: green to red": "Routes colorées selon le trafic en direct : du vert au rouge",
"Moving dots colored by live flow": "Points mobiles colorés selon le trafic en direct",
"Moving dots": "Points mobiles",
"TomTom key unavailable": "Clé TomTom indisponible",
"TomTom daily budget reached": "Quota quotidien TomTom atteint",
"Monthly traffic allowance used": "Quota mensuel de trafic épuisé",
@@ -2896,6 +2901,8 @@
"AVAILABLE MISSIONS": "MISSIONS DISPONIBLES",
"SELECT A MISSION TO INSPECT": "CHOISIR UNE MISSION À EXAMINER",
"LOADING 30-DAY MISSION INDEX": "CHARGEMENT DE L'INDEX DES MISSIONS À 30 JOURS",
"Space Missions unavailable: the mission index could not load. Try again later.": "Missions spatiales indisponibles : impossible de charger l’index des missions. Réessayez plus tard.",
"30-DAY MISSION INDEX UNAVAILABLE": "INDEX DES MISSIONS À 30 JOURS INDISPONIBLE",
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB APERÇU · ENTER / SPACE SÉLECTIONNE",
"Internet radio and local SDR": "Radio Internet et SDR local",
"STATION TAG": "TAG DE STATION",
@@ -3354,5 +3361,9 @@
"Maximum event age: {days}d; undated markers are kept": "Ancienneté maximale : {days} j ; marqueurs sans date conservés",
"event age filter|All": "Tous",
"Event age": "Ancienneté",
"Event age: {days}d · undated kept": "Ancienneté : {days} j · marqueurs sans date conservés"
"Event age: {days}d · undated kept": "Ancienneté : {days} j · marqueurs sans date conservés",
"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.",
"Video plays from Québec 511, which sees your IP address.": "La vidéo est lue depuis Québec 511, qui voit votre adresse IP."
}
+12 -1
View File
@@ -1193,6 +1193,11 @@
"TomTom roads first; OpenStreetMap fills the rest, simulated": "Prima le strade TomTom; OpenStreetMap completa il resto, simulato",
"TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "Prima le strade TomTom; OpenStreetMap completa il resto, simulato (richiede una chiave TomTom)",
"Hybrid": "Ibrido",
"Lines": "Linee",
"Dots": "Punti",
"Road lines colored by live flow: green to red": "Strade colorate in base al traffico in tempo reale: dal verde al rosso",
"Moving dots colored by live flow": "Punti in movimento colorati in base al traffico in tempo reale",
"Moving dots": "Punti in movimento",
"TomTom key unavailable": "Chiave TomTom non disponibile",
"TomTom daily budget reached": "Budget giornaliero TomTom esaurito",
"Monthly traffic allowance used": "Quota mensile del traffico esaurita",
@@ -2896,6 +2901,8 @@
"AVAILABLE MISSIONS": "MISSIONI DISPONIBILI",
"SELECT A MISSION TO INSPECT": "SELEZIONA UNA MISSIONE DA ESAMINARE",
"LOADING 30-DAY MISSION INDEX": "CARICAMENTO INDICE MISSIONI A 30 GIORNI",
"Space Missions unavailable: the mission index could not load. Try again later.": "Missioni spaziali non disponibili: impossibile caricare l’indice delle missioni. Riprova più tardi.",
"30-DAY MISSION INDEX UNAVAILABLE": "INDICE MISSIONI A 30 GIORNI NON DISPONIBILE",
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB: ANTEPRIMA · ENTER / SPACE: SELEZIONA",
"Internet radio and local SDR": "Radio internet e SDR locale",
"STATION TAG": "TAG STAZIONE",
@@ -3354,5 +3361,9 @@
"Maximum event age: {days}d; undated markers are kept": "Età massima degli eventi: {days} giorni; indicatori senza data mantenuti",
"event age filter|All": "Tutti",
"Event age": "Età degli eventi",
"Event age: {days}d · undated kept": "Età degli eventi: {days} giorni · senza data mantenuti"
"Event age: {days}d · undated kept": "Età degli eventi: {days} giorni · senza data mantenuti",
"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.",
"Video plays from Québec 511, which sees your IP address.": "Il video viene riprodotto da Québec 511, che vede il tuo indirizzo IP."
}
+12 -1
View File
@@ -1155,6 +1155,11 @@
"TomTom roads first; OpenStreetMap fills the rest, simulated": "TomTomの道路を優先し、残りはOpenStreetMapで補完(シミュレーション)",
"TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "TomTomの道路を優先し、残りはOpenStreetMapで補完(シミュレーション、TomTomキーが必要)",
"Hybrid": "ハイブリッド",
"Lines": "ライン",
"Dots": "ドット",
"Road lines colored by live flow: green to red": "ライブの交通流で色分けした道路ライン(緑から赤)",
"Moving dots colored by live flow": "ライブの交通流で色分けした移動ドット",
"Moving dots": "移動ドット",
"TomTom key unavailable": "TomTomキーを利用できません",
"TomTom daily budget reached": "TomTomの1日の上限に達しました",
"Monthly traffic allowance used": "今月の交通情報の上限に達しました",
@@ -2748,6 +2753,8 @@
"AVAILABLE MISSIONS": "利用可能なミッション",
"SELECT A MISSION TO INSPECT": "ミッションを選択して詳細を表示",
"LOADING 30-DAY MISSION INDEX": "30日間のミッション一覧を読込中",
"Space Missions unavailable: the mission index could not load. Try again later.": "宇宙ミッションを利用できません:ミッション一覧を読み込めませんでした。後でもう一度お試しください。",
"30-DAY MISSION INDEX UNAVAILABLE": "30日間のミッション一覧を取得できません",
"TAB PREVIEWS · ENTER / SPACE SELECTS": "Tabでプレビュー · Enter / Spaceで選択",
"Internet radio and local SDR": "インターネットラジオとローカルSDR",
"STATION TAG": "局タグ",
@@ -3198,5 +3205,9 @@
"Maximum event age: {days}d; undated markers are kept": "イベントの最大経過日数:{days}日。日付のないマーカーは保持",
"event age filter|All": "すべて",
"Event age": "経過日数",
"Event age: {days}d · undated kept": "経過日数:{days}日以内 · 日付なしは保持"
"Event age: {days}d · undated kept": "経過日数:{days}日以内 · 日付なしは保持",
"Close camera preview": "カメラのプレビューを閉じる",
"Refresh map still": "地図上の静止画を更新",
"The map still uses a separate request and may show a different moment.": "地図上の静止画は別のリクエストで取得されるため、異なる時点を表示する場合があります。",
"Video plays from Québec 511, which sees your IP address.": "動画は Québec 511 から再生され、Québec 511 にはあなたの IP アドレスが見えます。"
}
+12 -1
View File
@@ -1191,6 +1191,11 @@
"TomTom roads first; OpenStreetMap fills the rest, simulated": "Primeiro as vias do TomTom; o OpenStreetMap completa o resto com fluxo simulado",
"TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "Primeiro as vias do TomTom; o OpenStreetMap completa o resto com fluxo simulado (precisa de uma chave do TomTom)",
"Hybrid": "Híbrido",
"Lines": "Linhas",
"Dots": "Pontos",
"Road lines colored by live flow: green to red": "Vias coloridas pelo fluxo ao vivo: do verde ao vermelho",
"Moving dots colored by live flow": "Pontos em movimento coloridos pelo fluxo ao vivo",
"Moving dots": "Pontos em movimento",
"TomTom key unavailable": "Chave do TomTom indisponível",
"TomTom daily budget reached": "Limite diário do TomTom atingido",
"Monthly traffic allowance used": "Cota mensal de trânsito esgotada",
@@ -2898,6 +2903,8 @@
"AVAILABLE MISSIONS": "MISSÕES DISPONÍVEIS",
"SELECT A MISSION TO INSPECT": "SELECIONE UMA MISSÃO PARA INSPECIONAR",
"LOADING 30-DAY MISSION INDEX": "CARREGANDO ÍNDICE DE MISSÕES DE 30 DIAS",
"Space Missions unavailable: the mission index could not load. Try again later.": "Missões espaciais indisponíveis: não foi possível carregar o índice de missões. Tente novamente mais tarde.",
"30-DAY MISSION INDEX UNAVAILABLE": "ÍNDICE DE MISSÕES DE 30 DIAS INDISPONÍVEL",
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB MOSTRA PRÉVIA · ENTER / SPACE SELECIONA",
"Internet radio and local SDR": "Rádio pela internet e SDR local",
"STATION TAG": "TAG DA ESTAÇÃO",
@@ -3354,5 +3361,9 @@
"Maximum event age: {days}d; undated markers are kept": "Idade máxima do evento: {days} dias; marcadores sem data mantidos",
"event age filter|All": "Todos",
"Event age": "Idade do evento",
"Event age: {days}d · undated kept": "Idade do evento: {days} dias · sem data mantidos"
"Event age: {days}d · undated kept": "Idade do evento: {days} dias · sem data mantidos",
"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.",
"Video plays from Québec 511, which sees your IP address.": "O vídeo é reproduzido a partir do Québec 511, que vê o seu endereço IP."
}
+12 -1
View File
@@ -816,6 +816,8 @@
"Contact Context actions": "Действия контекста контакта",
"Available Space Missions": "Доступные космические миссии",
"LOADING 30-DAY MISSION INDEX": "ЗАГРУЗКА ИНДЕКСА МИССИЙ ЗА 30 ДНЕЙ",
"Space Missions unavailable: the mission index could not load. Try again later.": "Космические миссии недоступны: не удалось загрузить список миссий. Повторите попытку позже.",
"30-DAY MISSION INDEX UNAVAILABLE": "ИНДЕКС МИССИЙ ЗА 30 ДНЕЙ НЕДОСТУПЕН",
"Radio playback": "Воспроизведение радио",
"Radio volume": "Громкость радио",
"STATION TAG": "ТЕГ СТАНЦИИ",
@@ -2849,6 +2851,11 @@
"Zoom in for vector tile coverage": "Приблизьте карту, чтобы загрузить векторные тайлы",
"Camera coverage unavailable": "Покрытие камер недоступно",
"Hybrid": "Гибрид",
"Lines": "Линии",
"Dots": "Точки",
"Road lines colored by live flow: green to red": "Линии дорог по потоку онлайн: от зелёного к красному",
"Moving dots colored by live flow": "Движущиеся точки, окрашенные по потоку онлайн",
"Moving dots": "Движущиеся точки",
"TomTom roads only, each with its live flow": "Только дороги TomTom, у каждой — свой поток онлайн",
"TomTom roads only, each with its live flow (needs a TomTom key)": "Только дороги TomTom, у каждой — свой поток онлайн (нужен ключ TomTom)",
"OpenStreetMap roads, with TomTom flow matched when available": "Дороги OpenStreetMap; поток TomTom сопоставляется, где он есть",
@@ -3432,5 +3439,9 @@
"Maximum event age: {days}d; undated markers are kept": "Максимальный возраст события: {days} дн.; метки без даты сохранены",
"event age filter|All": "Все",
"Event age": "Возраст события",
"Event age: {days}d · undated kept": "Возраст события: {days} дн. · метки без даты сохранены"
"Event age: {days}d · undated kept": "Возраст события: {days} дн. · метки без даты сохранены",
"Close camera preview": "Закрыть просмотр камеры",
"Refresh map still": "Обновить кадр на карте",
"The map still uses a separate request and may show a different moment.": "Кадр на карте запрашивается отдельно и может показывать другой момент.",
"Video plays from Québec 511, which sees your IP address.": "Видео воспроизводится с Québec 511, который видит ваш IP-адрес."
}
+12 -1
View File
@@ -1211,6 +1211,11 @@
"TomTom roads first; OpenStreetMap fills the rest, simulated": "Спершу дороги TomTom; решту доповнює OpenStreetMap з імітацією потоку",
"TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "Спершу дороги TomTom; решту доповнює OpenStreetMap з імітацією потоку (потрібен ключ TomTom)",
"Hybrid": "Гібрид",
"Lines": "Лінії",
"Dots": "Точки",
"Road lines colored by live flow: green to red": "Лінії доріг за потоком онлайн: від зеленого до червоного",
"Moving dots colored by live flow": "Рухомі точки, забарвлені за потоком онлайн",
"Moving dots": "Рухомі точки",
"TomTom key unavailable": "Ключ TomTom недоступний",
"TomTom daily budget reached": "Вичерпано денний ліміт TomTom",
"Monthly traffic allowance used": "Місячний ліміт даних про трафік вичерпано",
@@ -2969,6 +2974,8 @@
"AVAILABLE MISSIONS": "ДОСТУПНІ МІСІЇ",
"SELECT A MISSION TO INSPECT": "ВИБЕРІТЬ МІСІЮ ДЛЯ ПЕРЕГЛЯДУ",
"LOADING 30-DAY MISSION INDEX": "ЗАВАНТАЖЕННЯ ІНДЕКСУ МІСІЙ ЗА 30 ДНІВ",
"Space Missions unavailable: the mission index could not load. Try again later.": "Космічні місії недоступні: не вдалося завантажити список місій. Спробуйте пізніше.",
"30-DAY MISSION INDEX UNAVAILABLE": "ІНДЕКС МІСІЙ ЗА 30 ДНІВ НЕДОСТУПНИЙ",
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB — ОГЛЯД · ENTER / SPACE — ВИБІР",
"Internet radio and local SDR": "Інтернет-радіо та локальний SDR",
"STATION TAG": "ТЕГ СТАНЦІЇ",
@@ -3432,5 +3439,9 @@
"Maximum event age: {days}d; undated markers are kept": "Максимальний вік події: {days} дн.; позначки без дати збережено",
"event age filter|All": "Усі",
"Event age": "Вік події",
"Event age: {days}d · undated kept": "Вік події: {days} дн. · позначки без дати збережено"
"Event age: {days}d · undated kept": "Вік події: {days} дн. · позначки без дати збережено",
"Close camera preview": "Закрити перегляд камери",
"Refresh map still": "Оновити кадр на карті",
"The map still uses a separate request and may show a different moment.": "Кадр на карті запитується окремо та може показувати інший момент.",
"Video plays from Québec 511, which sees your IP address.": "Відео відтворюється з Québec 511, який бачить вашу IP-адресу."
}
+12 -1
View File
@@ -1131,6 +1131,11 @@
"TomTom roads first; OpenStreetMap fills the rest, simulated": "优先使用 TomTom 道路及实时路况;缺失路段由 OpenStreetMap 补充,并模拟其路况",
"TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "优先使用 TomTom 道路及实时路况;缺失路段由 OpenStreetMap 补充,并模拟其路况(需要 TomTom 密钥)",
"Hybrid": "混合",
"Lines": "线条",
"Dots": "圆点",
"Road lines colored by live flow: green to red": "按实时路况着色的道路线条:从绿到红",
"Moving dots colored by live flow": "按实时路况着色的移动圆点",
"Moving dots": "移动圆点",
"TomTom key unavailable": "TomTom 密钥不可用",
"TomTom daily budget reached": "已达 TomTom 每日配额",
"Monthly traffic allowance used": "本月交通流量额度已用完",
@@ -2647,6 +2652,8 @@
"AVAILABLE MISSIONS": "可用任务",
"SELECT A MISSION TO INSPECT": "选择一项任务以查看",
"LOADING 30-DAY MISSION INDEX": "正在加载 30 天任务索引",
"Space Missions unavailable: the mission index could not load. Try again later.": "太空任务不可用:无法加载任务索引。请稍后重试。",
"30-DAY MISSION INDEX UNAVAILABLE": "30 天任务索引不可用",
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB 预览 · ENTER / 空格键选择",
"Internet radio and local SDR": "网络电台与本地 SDR",
"STATION TAG": "电台标签",
@@ -3198,5 +3205,9 @@
"Maximum event age: {days}d; undated markers are kept": "事件最长保留时间:{days}天;保留无日期标记",
"event age filter|All": "全部",
"Event age": "事件时间",
"Event age: {days}d · undated kept": "事件时间:{days}天内 · 保留无日期标记"
"Event age: {days}d · undated kept": "事件时间:{days}天内 · 保留无日期标记",
"Close camera preview": "关闭摄像头预览",
"Refresh map still": "刷新地图静止画面",
"The map still uses a separate request and may show a different moment.": "地图静止画面通过单独的请求获取,可能显示不同的时刻。",
"Video plays from Québec 511, which sees your IP address.": "视频从 Québec 511 播放,Québec 511 会看到你的 IP 地址。"
}
+12 -1
View File
@@ -1126,6 +1126,11 @@
"TomTom roads first; OpenStreetMap fills the rest, simulated": "優先使用 TomTom 道路及實時路況;缺失路段由 OpenStreetMap 補充,並模擬其路況",
"TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "優先使用 TomTom 道路及實時路況;缺失路段由 OpenStreetMap 補充,並模擬其路況(需要 TomTom 密鑰)",
"Hybrid": "混合",
"Lines": "線條",
"Dots": "圓點",
"Road lines colored by live flow: green to red": "依即時路況著色的道路線條:由綠到紅",
"Moving dots colored by live flow": "依即時路況著色的移動圓點",
"Moving dots": "移動圓點",
"TomTom key unavailable": "TomTom 密鑰不可用",
"TomTom daily budget reached": "已達 TomTom 每日配額",
"Monthly traffic allowance used": "本月交通流量額度已用完",
@@ -2635,6 +2640,8 @@
"AVAILABLE MISSIONS": "可用任務",
"SELECT A MISSION TO INSPECT": "選擇一項任務以查看",
"LOADING 30-DAY MISSION INDEX": "正在加載 30 天任務索引",
"Space Missions unavailable: the mission index could not load. Try again later.": "太空任務無法使用:無法載入任務索引。請稍後再試。",
"30-DAY MISSION INDEX UNAVAILABLE": "30 天任務索引不可用",
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB 預覽 · ENTER / 空格鍵選擇",
"Internet radio and local SDR": "網絡電臺與本地 SDR",
"STATION TAG": "電臺標籤",
@@ -3198,5 +3205,9 @@
"Maximum event age: {days}d; undated markers are kept": "事件最長保留時間:{days}天;保留無日期標記",
"event age filter|All": "全部",
"Event age": "事件時間",
"Event age: {days}d · undated kept": "事件時間:{days}天內 · 保留無日期標記"
"Event age: {days}d · undated kept": "事件時間:{days}天內 · 保留無日期標記",
"Close camera preview": "關閉攝影機預覽",
"Refresh map still": "重新整理地圖靜態畫面",
"The map still uses a separate request and may show a different moment.": "地圖靜態畫面使用獨立請求,顯示的時間可能不同。",
"Video plays from Québec 511, which sees your IP address.": "影片從 Québec 511 播放,Québec 511 會看到你的 IP 位址。"
}
+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;
+2
View File
@@ -112,6 +112,8 @@ export function createCctvLayer({ services, source }) {
_setCctvCoverageStateForTest: parts.testing._setCctvCoverageStateForTest,
focusCctvRecord: parts.navigation.focusCctvRecord,
maybeAutoHop: parts.navigation.maybeAutoHop,
openingCameraId: parts.navigation.openingCameraId,
reopenNearestCamera: parts.navigation.reopenNearestCamera,
cctvCycleIndex: parts.navigation.cctvCycleIndex,
},
);
+7 -6
View File
@@ -151,6 +151,7 @@ export function createLifecycle({
layerState._enabled = false;
layerState._activeCameraId = null;
layerState._autoHopSuspended = false;
layerState._openingCameraProvisional = false;
layerState._lastHopAt = 0;
layerState._lastViewContext = '';
layerState._calibrationById = parts.calibration.loadCalibrationStore();
@@ -299,11 +300,8 @@ export function createLifecycle({
}
layerState._count = layerState._records.length;
if (layerState._records.length > 0) {
// Projection runtime + first frame fetch are deferred to enable() so
// initializing the catalog stays render-cheap.
layerState._activeCameraId = layerState._records[0].camera.id;
}
// No camera is picked here: enable() opens on the one nearest the
// viewer, unless restored params selected one first.
// Task 5: if the prior batch lost init's bounded race, apply it post-hoc
// when it lands (pure recomputes — applyLateGroundPriors guards against
@@ -361,6 +359,7 @@ export function createLifecycle({
layerState._horizonCullListener = () => {
layerState._cameraMoving = false;
stopViewMotionTimer();
parts.navigation.reopenNearestCamera();
settleView();
};
layerState._viewer.camera.moveEnd.addEventListener(
@@ -508,8 +507,9 @@ export function createLifecycle({
return Boolean(coverage && layerState._recordById.has(coverage[1]));
});
if (!layerState._activeCameraId && layerState._records.length) {
layerState._activeCameraId = layerState._records[0].camera.id;
layerState._activeCameraId = parts.navigation.openingCameraId();
layerState._autoHopSuspended = false;
layerState._openingCameraProvisional = true;
}
const activeRecord = parts.selection.getActiveRecord();
if (activeRecord) {
@@ -629,6 +629,7 @@ export function createLifecycle({
layerState._enabled = false;
layerState._activeCameraId = null;
layerState._autoHopSuspended = false;
layerState._openingCameraProvisional = false;
// Clear existing subscribers rather than replacing the Set —
// replacing would silently orphan any unsubscribe() closures
layerState._listeners.clear();
+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,
+32
View File
@@ -33,6 +33,36 @@ export function createNavigation({
return best?.id || null;
}
/**
* The camera the layer opens on when none is selected: the one nearest the
* viewer, or the first record when the viewer has no position yet.
* @returns {string|null} Camera ID, or null when the catalog is empty.
*/
function openingCameraId() {
return (
nearestCameraIdToViewer() || layerState._records[0]?.camera?.id || null
);
}
/**
* After a settled move, moves the opening camera to the one nearest the
* view, until someone chooses a camera. A shared view's flight lands after
* enable() picked from the startup view.
* @returns {boolean} True when the active camera changed.
*/
function reopenNearestCamera() {
if (!layerState._openingCameraProvisional || !layerState._enabled)
return false;
const nearest = nearestCameraIdToViewer();
if (!nearest || nearest === layerState._activeCameraId) return false;
parts.selection.setActiveCamera(nearest);
// setActiveCamera ends the opening pick; this is still that pick.
layerState._openingCameraProvisional = true;
return true;
}
/**
* Flies the Cesium viewer camera to frame the specified CCTV camera,
* looking along its heading from above.
@@ -149,6 +179,8 @@ export function createNavigation({
}
return {
nearestCameraIdToViewer,
openingCameraId,
reopenNearestCamera,
focusCctvRecord,
focusCamera,
maybeAutoHop,
+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: {},
});
+8
View File
@@ -79,10 +79,17 @@ 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;
layerState._autoHopSuspended = false;
layerState._openingCameraProvisional = false;
// A real activation creates projection work — wake the self-stopping loop.
parts.projection.startProjectionLoop();
if (previousActiveRecord && previousActiveRecord !== record) {
@@ -169,6 +176,7 @@ export function createSelection({
if (!record) return false;
layerState._activeCameraId = null;
layerState._autoHopSuspended = true;
layerState._openingCameraProvisional = false;
record.activationDone = false;
parts.geometry.clearProbeClampOnDeactivation(record, (previous) => {
parts.geometry.applyFrustumGeometry(
+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);
});
+10
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)
@@ -36,6 +40,12 @@ export function createState({ services }) {
layerState._autoHop = false;
// True while the active camera is the one enable() opened on and nobody has
// chosen a camera since: each settled move then re-picks the one nearest
// the view, so a share flight that lands after enable still opens nearby.
layerState._openingCameraProvisional = false;
// An explicit empty-space deselect keeps AUTO HOP configured but prevents its
// timer from silently choosing a replacement. A later explicit activation or
// AUTO HOP toggle-on releases the hold.
+2
View File
@@ -91,6 +91,7 @@ export function createTesting({ state: layerState, services, parts, source }) {
coverageMode = 'on',
showProjection = false,
healthById = new Map(),
openingCameraProvisional = false,
} = {}) {
layerState._viewer = viewer;
layerState._records = Array.isArray(records) ? records : [];
@@ -104,6 +105,7 @@ export function createTesting({ state: layerState, services, parts, source }) {
layerState._billboards = null;
layerState._activeCameraId = activeCameraId;
layerState._autoHopSuspended = false;
layerState._openingCameraProvisional = !!openingCameraProvisional;
layerState._enabled = !!enabled;
layerState._coverageMode = parts.model.normalizeCoverageMode(
coverageMode,
+4
View File
@@ -12,6 +12,9 @@ export function createControls({ state: layerState, services, parts, source }) {
/** Release only Space Mission camera ownership, preserving layer and selection state. */
releaseCameraOwnership() {
// Navigation takes the camera; a failed entry must not restore the old view.
layerState._entryCameraView = null;
layerState._cameraReleased = true;
parts.panel.clearMissionRosterHover();
parts.replay.stopMissionReplay();
parts.selection.stopMissionZoomAnchor();
@@ -57,6 +60,7 @@ export function createControls({ state: layerState, services, parts, source }) {
orbitMatches: layerState._orbitMatches,
lastUpdate: layerState._lastUpdate,
error: layerState._lastError,
errorAt: layerState._lastErrorAt,
stale: layerState._stale,
};
},
+495
View File
@@ -0,0 +1,495 @@
// The real manager, Context coordinator and launches ingestion run against a
// stubbed /api/launches, so a failed first load takes the browser's path.
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { DataLayerManager } from '../../data/manager.js';
import { ContextControls } from '../../ui/contextControls.js';
import { spaceMissionsIndexFailure } from '../../ui/contextActions.js';
import { createLaunchSource } from './source.js';
import { createState } from './state.js';
import { createModel } from './model.js';
import { createPolicyHelpers } from './policyHelpers.js';
import { createIngestion } from './ingestion.js';
import { createPanel } from './panel.js';
import { createControls } from './controls.js';
import { createLifecycle } from './lifecycle.js';
import { createReplay } from './replay.js';
const noop = () => {};
const launchRecord = {
id: 'fixture-launch',
name: 'Fixture mission',
net: new Date(Date.now() - 3600000).toISOString(),
status: { name: 'Launch Successful' },
pad: { name: 'Fixture pad', latitude: 28.6, longitude: -80.6 },
rocket: { payloads: [] },
};
function plainLayer(id, name, params = {}) {
let current = { ...params };
return {
id,
name,
icon: '',
source: 'test',
updateInterval: -1,
init: noop,
enable: noop,
disable: noop,
update: noop,
getParams: () => ({ ...current }),
setParams(next) {
current = { ...current, ...next };
return true;
},
};
}
function fakeCamera() {
const camera = {
position: new Cesium.Cartesian3(1, 2, 3),
heading: 0.1,
pitch: -0.5,
roll: 0,
flights: 0,
cancelled: 0,
flight: null,
get positionWC() {
return camera.position;
},
// Like Cesium, a new flight cancels the one in progress.
flyTo({ destination, complete, cancel }) {
camera.flight?.cancel?.();
camera.flight = { complete, cancel };
camera.flights += 1;
camera.position = Cesium.Cartesian3.clone(destination);
camera.heading = 0;
camera.pitch = -Math.PI / 2;
},
land() {
const flight = camera.flight;
camera.flight = null;
flight?.complete?.();
},
cancelFlight() {
camera.cancelled += 1;
const flight = camera.flight;
camera.flight = null;
flight?.cancel?.();
},
lookAtTransform: noop,
setView({ destination, orientation }) {
camera.position = Cesium.Cartesian3.clone(destination);
camera.heading = orientation.heading;
camera.pitch = orientation.pitch;
camera.roll = orientation.roll;
},
};
return camera;
}
async function fixture(t) {
const upstream = { status: 429, results: [launchRecord] };
const source = createLaunchSource({
async fetchImpl(url) {
if (url === '/api/celestrak/active') return new Response('');
if (upstream.hold) await upstream.hold;
return Response.json(
upstream.status === 200 ? { results: upstream.results } : {},
{ status: upstream.status },
);
},
});
const services = {
overlays: {
setOverlayEntries: noop,
setOverlaySourceVisible: noop,
clearOverlaySource: noop,
},
render: { holdContinuousRender: noop, releaseContinuousRender: noop },
};
const state = createState({ services });
const camera = fakeCamera();
const canvas = Object.assign(new EventTarget(), {
disableRootEvents: true,
onwheel: null,
style: {},
clientWidth: 1200,
clientHeight: 800,
});
const viewer = {
camera,
dataSources: new Cesium.DataSourceCollection(),
scene: { canvas, preRender: new Cesium.Event(), drillPick: () => [] },
};
// Scene presentation is stubbed; ingestion, lifecycle and panel are real.
const parts = {
overlays: {
shortMissionLabel: (name) => name,
createReplayVehicleOverlay: noop,
clearMissionOverlaySources: noop,
hideReplayVehicleOverlay: noop,
destroyReplayVehicleOverlay: noop,
syncMissionOverlayEntries: noop,
},
selection: {
focusFullGlobe(target, flight) {
target.camera.flyTo({
destination: new Cesium.Cartesian3(9, 9, 9),
...flight,
});
return true;
},
setSelectedMission: noop,
focusMission: noop,
stopMissionZoomAnchor: noop,
entityLaunchId: () => null,
},
orbitRendering: {
removeMissionOrbitPrimitives: noop,
syncMissionOrbitPrimitiveVisibility: noop,
},
launchPad: {
initLaunchPadZonePrimitive: noop,
hideLaunchPadZone: noop,
destroyLaunchPadZonePrimitive: noop,
},
rendering: {
updateMissionFrame: noop,
addLaunchEntity(record) {
state._dataSource.entities.add({
id: record.id,
position: Cesium.Cartesian3.fromDegrees(record.lon, record.lat),
});
},
},
};
const context = { state, services, parts, source };
parts.policyHelpers = createPolicyHelpers(context);
parts.model = createModel(context);
parts.panel = createPanel(context);
parts.replay = createReplay(context);
parts.ingestion = createIngestion(context);
parts.controls = createControls(context);
parts.lifecycle = createLifecycle(context);
const missions = Object.assign(
{},
parts.controls.methods,
parts.lifecycle.methods,
parts.ingestion.methods,
);
const rosters = [];
parts.panel.subscribePanel((view) => rosters.push(view.roster));
const manager = new DataLayerManager(viewer);
manager.register(plainLayer('earthquakes', 'Earthquakes (24h)'));
manager.register(plainLayer('satellites', 'Satellites'));
manager.register(plainLayer('wind', 'Wind'));
manager.register(missions);
missions.attachDataManager(manager);
const toasts = [];
const controls = new ContextControls({
elements: {},
installations: {},
actions: {
getCockpit: () => null,
claimVisualAuthority: noop,
showToast: (message) => toasts.push(message),
setClearBusy: noop,
syncDetection: noop,
scheduleLayout: noop,
refreshRadio: noop,
setPanelCollapsed: noop,
},
});
controls.connect(manager);
t.after(async () => {
controls.stop();
controls.disconnect();
for (const id of ['rocket-launches', 'satellites', 'earthquakes', 'wind'])
await manager.setEnabled(id, false);
await missions.destroy(viewer);
});
const enabledIds = () => [...manager.getEnabledLayerIds()].sort();
const view = () => ({
x: camera.position.x,
y: camera.position.y,
z: camera.position.z,
heading: camera.heading,
pitch: camera.pitch,
});
const settle = async () => {
await controls._waitForContextLayerSettlement();
for (const id of ['rocket-launches', 'satellites', 'earthquakes'])
await manager.waitForLayerSettled(id);
await controls._waitForContextLayerSettlement();
};
return {
upstream,
manager,
controls,
missions,
toasts,
state,
camera,
rosters,
enabledIds,
view,
settle,
};
}
const entries = {
direct: (f) =>
f.manager.setEnabled('rocket-launches', true, { origin: 'user' }),
'Context-panel': async (f) =>
(
await f.controls.setContextMode('space-missions', {
claimVisualAuthority: false,
})
).ok,
};
for (const [path, enter] of Object.entries(entries))
test(`a ${path} Space Missions entry that gets HTTP 429 rolls back instead of isolating`, async (t) => {
const f = await fixture(t);
await f.manager.setEnabled('earthquakes', true, { origin: 'user' });
const before = f.view();
const entered = await enter(f);
await f.settle();
assert.equal(entered, false, 'a failed mission index fails the enable');
assert.deepEqual(f.enabledIds(), ['earthquakes']);
assert.equal(f.controls.getContextModeState().mode, null);
assert.equal(f.controls.getContextModeState().active, false);
assert.equal(f.missions.getStats().error, 'HTTP 429');
assert.deepEqual(f.view(), before, 'the pre-entry view comes back');
assert.ok(f.camera.cancelled > 0, 'the full-globe flight is cancelled');
assert.equal(
await f.manager.setEnabled('wind', true, { origin: 'user' }),
true,
'an unrelated layer is not blocked after the failed entry',
);
});
// Holds the /api/launches reply until the returned release is called.
function holdReply(f) {
let release;
f.upstream.hold = new Promise((resolve) => {
release = resolve;
});
return () => {
f.upstream.hold = null;
release();
};
}
async function untilFlown(f) {
for (let i = 0; i < 200 && f.camera.flights === 0; i++)
await new Promise((resolve) => setTimeout(resolve, 5));
assert.ok(f.camera.flights > 0, 'the entry started its full-globe flight');
}
test('a landed entry flight still gives the view back after a failed first load', async (t) => {
const f = await fixture(t);
await f.manager.setEnabled('earthquakes', true, { origin: 'user' });
const before = f.view();
const release = holdReply(f);
const entering = f.manager.setEnabled('rocket-launches', true, {
origin: 'user',
});
await untilFlown(f);
f.camera.land();
release();
assert.equal(await entering, false);
await f.settle();
assert.deepEqual(f.view(), before);
});
test('a camera move after the entry flight survives a failed first load', async (t) => {
const f = await fixture(t);
await f.manager.setEnabled('earthquakes', true, { origin: 'user' });
const release = holdReply(f);
const entering = f.manager.setEnabled('rocket-launches', true, {
origin: 'user',
});
await untilFlown(f);
f.camera.land();
// The user drags the globe while the mission index is still loading.
f.camera.setView({
destination: new Cesium.Cartesian3(7, 7, 7),
orientation: { heading: 0.3, pitch: -0.2, roll: 0 },
});
const moved = f.view();
release();
assert.equal(await entering, false);
await f.settle();
assert.deepEqual(f.view(), moved);
});
test('a navigation during a failed first load is not cancelled by the rollback', async (t) => {
const f = await fixture(t);
await f.manager.setEnabled('earthquakes', true, { origin: 'user' });
const release = holdReply(f);
const entering = f.manager.setEnabled('rocket-launches', true, {
origin: 'user',
});
await untilFlown(f);
// Search or Home: navigation releases mission camera ownership, then flies.
f.missions.releaseCameraOwnership();
let navigationCancelled = false;
f.camera.flyTo({
destination: new Cesium.Cartesian3(5, 5, 5),
cancel: () => {
navigationCancelled = true;
},
});
const cancelled = f.camera.cancelled;
const navigated = f.view();
release();
assert.equal(await entering, false);
await f.settle();
assert.equal(
navigationCancelled,
false,
'the rollback does not cancel the Search/Home flight',
);
assert.equal(f.camera.cancelled, cancelled);
assert.deepEqual(f.view(), navigated);
});
test('the roster reports a failed mission index instead of loading forever', async (t) => {
const f = await fixture(t);
await f.manager.setEnabled('rocket-launches', true, { origin: 'user' });
await f.settle();
const roster = f.rosters.at(-1);
assert.ok(roster, 'a failed load publishes a roster view');
assert.deepEqual(roster.entries, []);
assert.equal(roster.empty, '30-DAY MISSION INDEX UNAVAILABLE');
// A new attempt starts from the loading state, then shows the index.
f.upstream.status = 200;
const loadingFrom = f.rosters.length;
assert.equal(
await f.manager.setEnabled('rocket-launches', true, { origin: 'user' }),
true,
);
await f.settle();
assert.equal(f.rosters.slice(loadingFrom).includes(null), true);
assert.equal(f.rosters.at(-1).entries.length, 1);
assert.equal(f.controls.getContextModeState().mode, 'space-missions');
});
test('a valid empty mission index is a successful entry', async (t) => {
const f = await fixture(t);
f.upstream.status = 200;
f.upstream.results = [];
assert.equal(
await f.manager.setEnabled('rocket-launches', true, { origin: 'user' }),
true,
);
await f.settle();
assert.equal(f.controls.getContextModeState().mode, 'space-missions');
assert.equal(
f.rosters.at(-1).empty,
'NO MISSIONS AVAILABLE IN THE CURRENT 30-DAY WINDOW',
);
assert.ok(f.camera.flights > 0, 'a successful entry frames the globe');
});
test('a refresh failure after a loaded index keeps the mode and its records', async (t) => {
const f = await fixture(t);
f.upstream.status = 200;
await f.manager.setEnabled('rocket-launches', true, { origin: 'user' });
await f.settle();
const framed = f.view();
f.upstream.status = 429;
await f.missions.update();
await f.settle();
assert.equal(f.manager.isEnabled('rocket-launches'), true);
assert.equal(f.controls.getContextModeState().mode, 'space-missions');
assert.equal(f.missions.getStats().count, 1);
assert.equal(f.missions.getStats().error, 'HTTP 429');
assert.equal(f.rosters.at(-1).entries.length, 1);
// Leaving a loaded mode does not restore the pre-entry view.
await f.manager.setEnabled('rocket-launches', false, { origin: 'user' });
await f.settle();
assert.deepEqual(f.view(), framed);
});
test('exiting during a successful entry flight cancels the mission-owned flight', async (t) => {
const f = await fixture(t);
f.upstream.status = 200;
assert.equal(
await f.manager.setEnabled('rocket-launches', true, { origin: 'user' }),
true,
);
await f.settle();
assert.ok(f.camera.flight, 'the 2.4 s entry flight is still in progress');
await f.manager.setEnabled('rocket-launches', false, { origin: 'user' });
await f.settle();
assert.equal(
f.camera.flight,
null,
'leaving Space Missions stops the flight it started (main cancels it)',
);
});
// The rollback hides the roster, so the toast has to name the cause.
test('a failed first load tells the user the mission index is unavailable', async (t) => {
const f = await fixture(t);
await f.manager.setEnabled('earthquakes', true, { origin: 'user' });
await f.controls.setContextMode('space-missions', {
claimVisualAuthority: false,
});
await f.settle();
assert.equal(f.controls.getContextModeState().mode, null, 'rolled back');
assert.ok(
f.toasts.some((message) => /mission index/i.test(message)),
`visible feedback names the failure, got ${JSON.stringify(f.toasts)}`,
);
});
test('navigation taking the camera keeps a mission flight it asked to preserve', async (t) => {
const f = await fixture(t);
f.upstream.status = 200;
await f.manager.setEnabled('rocket-launches', true, { origin: 'user' });
await f.settle();
const flight = f.camera.flight;
assert.ok(flight, 'the entry flight is in progress');
f.missions.releaseCameraOwnership();
assert.equal(f.camera.flight, flight, 'navigation decides about the flight');
// After exit an inactive layer never cancels another owner's flight.
await f.manager.setEnabled('rocket-launches', false, { origin: 'user' });
await f.settle();
f.camera.flyTo({ destination: new Cesium.Cartesian3(1, 1, 1) });
const other = f.camera.flight;
f.missions.releaseCameraOwnership();
assert.equal(f.camera.flight, other);
});
test("an earlier attempt's index error is not blamed for a later failure", async (t) => {
const f = await fixture(t);
await f.manager.setEnabled('rocket-launches', true, { origin: 'user' });
await f.settle();
assert.match(spaceMissionsIndexFailure(f.manager), /mission index/);
assert.equal(spaceMissionsIndexFailure(f.manager, Date.now() + 1), null);
});
+14 -4
View File
@@ -50,8 +50,9 @@ export function createIngestion({
layerState._postTleRetryCount++;
layerState._retryTimer = setTimeout(() => {
layerState._retryTimer = null;
// A failed retry is already recorded in the layer stats.
if (layerState._enabled && token === layerState._lifecycleToken)
requestMissionUpdate();
requestMissionUpdate().catch(() => {});
}, POST_TLE_RETRY_DELAY_MS);
}
@@ -201,6 +202,10 @@ export function createIngestion({
if (layerState._renderedTleText === layerState._activeTleText)
clearPostTleRetry();
layerState._count = launches.length;
// The roster reads these to tell a loaded empty index from loading.
layerState._lastUpdate = Date.now();
layerState._lastError = null;
layerState._stale = stale === true;
parts.panel.renderMissionRoster();
if (
!layerState._selectedLaunchId ||
@@ -219,13 +224,18 @@ export function createIngestion({
}
layerState._focusAfterActiveLookup = false;
parts.panel.renderMissionPanel();
layerState._lastUpdate = Date.now();
layerState._lastError = null;
layerState._stale = stale === true;
layerState._entryCameraView = null;
} catch (error) {
if (layerState._enabled && token === layerState._lifecycleToken) {
layerState._lastError = error.message;
layerState._lastErrorAt = Date.now();
console.warn('[Data:RocketLaunches] Fetch error:', error);
// No index yet: fail the enable so Context rolls back.
// After a loaded index, a failed refresh keeps its records.
if (layerState._lastUpdate === null) {
parts.panel.renderMissionRoster();
throw error;
}
}
}
}
+68 -1
View File
@@ -111,6 +111,43 @@ export function createLaunchHover({
};
}
/** The camera pose to give back, in world coordinates. */
function captureCameraView(viewer) {
const camera = viewer?.camera;
if (!camera?.positionWC) return null;
return {
destination: Cesium.Cartesian3.clone(camera.positionWC),
orientation: {
heading: camera.heading,
pitch: camera.pitch,
roll: camera.roll,
},
};
}
function sameCameraView(camera, view) {
return (
Cesium.Cartesian3.equalsEpsilon(
camera.positionWC,
view.destination,
0,
1,
) &&
['heading', 'pitch', 'roll'].every((key) =>
Cesium.Math.equalsEpsilon(camera[key], view.orientation[key], 0, 1e-6),
)
);
}
/** Restore the pre-entry view if the entry flight still holds the camera. */
function restoreCameraView(viewer, view, landedView) {
const camera = viewer?.camera;
if (!camera) return;
if (!landedView) camera.cancelFlight?.();
else if (!sameCameraView(camera, landedView)) return;
camera.setView(view);
}
export function createLifecycle({
state: layerState,
services,
@@ -200,7 +237,27 @@ export function createLifecycle({
layerState._updateDirty = false;
if (layerState._dataSource) layerState._dataSource.show = true;
parts.orbitRendering.syncMissionOrbitPrimitiveVisibility();
parts.selection.focusFullGlobe(layerState._viewer);
if (layerState._lastUpdate === null) {
// A new attempt at an index that never loaded starts from loading.
layerState._lastError = null;
parts.panel.renderMissionRoster();
// Kept until the mission index loads, so an entry that never does
// gives the view back. A newer flight takes the camera and drops it.
layerState._entryCameraView = captureCameraView(layerState._viewer);
layerState._entryLandedView = null;
}
const token = layerState._lifecycleToken;
const flying = parts.selection.focusFullGlobe(layerState._viewer, {
complete: () => {
if (token === layerState._lifecycleToken)
layerState._entryLandedView = captureCameraView(layerState._viewer);
},
cancel: () => {
if (token === layerState._lifecycleToken)
layerState._entryCameraView = null;
},
});
if (!flying) layerState._entryCameraView = null;
if (layerState._selectedLaunchId)
parts.selection.setSelectedMission(
layerState._selectedLaunchId,
@@ -214,12 +271,21 @@ export function createLifecycle({
hover?.reset();
layerState._sourceController.abort();
layerState._enabled = false;
const entryView = layerState._entryCameraView;
layerState._entryCameraView = null;
if (entryView)
restoreCameraView(
layerState._viewer,
entryView,
layerState._entryLandedView,
);
releaseContinuousRender('rocket-launches');
layerState._lifecycleToken++;
layerState._updateDirty = false;
parts.ingestion.clearPostTleRetry();
parts.panel.clearMissionRosterHover();
parts.replay.stopMissionReplay();
layerState._cameraReleased = true;
parts.selection.stopMissionZoomAnchor();
if (layerState._dataSource) layerState._dataSource.show = false;
parts.orbitRendering.syncMissionOrbitPrimitiveVisibility();
@@ -244,6 +310,7 @@ export function createLifecycle({
await parts.ingestion.restoreSatelliteDependency();
parts.panel.clearMissionRosterHover();
parts.replay.stopMissionReplay();
layerState._cameraReleased = true;
parts.selection.stopMissionZoomAnchor();
parts.ingestion.clearPostTleRetry();
if (layerState._clickHandler) layerState._clickHandler.destroy();
+9 -1
View File
@@ -263,8 +263,16 @@ export function createPanel({ state: layerState, services, parts, source }) {
publishPanel();
}
/** The roster entries as the list shows them. */
/** The roster as listed; null until the first index loads or fails. */
function presentRoster() {
if (layerState._lastUpdate === null && !layerState._launches.length) {
if (!layerState._lastError) return null;
return {
count: '—',
entries: [],
empty: t('30-DAY MISSION INDEX UNAVAILABLE'),
};
}
const entries = parts.policyHelpers
.missionRosterEntries(layerState._launches)
.map(({ launch, index }) => {
+1
View File
@@ -229,6 +229,7 @@ export function createPolicyHelpers({
layerState._viewer.camera.positionCartographic?.height,
);
const position = Cesium.Cartesian3.fromDegrees(launch.lon, launch.lat);
layerState._cameraReleased = false;
layerState._viewer.camera.flyToBoundingSphere(
new Cesium.BoundingSphere(position, 0),
{
+8 -2
View File
@@ -314,9 +314,15 @@ export function createReplay({ state: layerState, services, parts, source }) {
layerState._replayCameraToken++;
if (layerState._replayCameraRemover) layerState._replayCameraRemover();
layerState._replayCameraRemover = null;
layerState._viewer?.camera?.cancelFlight();
if (layerState._viewer?.camera)
// Cancel the layer's own flights, never one navigation started after
// taking the camera.
if (
(stoppedLaunchId || !layerState._cameraReleased) &&
layerState._viewer?.camera
) {
layerState._viewer.camera.cancelFlight();
layerState._viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
}
layerState._replayCameraLaunchId = null;
layerState._replayPaused = false;
layerState._replayPausedAtMs = null;
+9 -2
View File
@@ -9,7 +9,8 @@ export function createSelection({
}) {
const { getKeyholeGeometry } = services.geometry;
function focusFullGlobe(viewer, duration = 2.4) {
/** @returns {boolean} Whether the flight started. */
function focusFullGlobe(viewer, { duration = 2.4, complete, cancel } = {}) {
const canvas = viewer?.scene?.canvas;
const height = canvas?.clientHeight || canvas?.height;
const width = canvas?.clientWidth || canvas?.width;
@@ -23,7 +24,7 @@ export function createSelection({
fovy <= 0 ||
fovy >= Math.PI
)
return;
return false;
const earthRadius = Cesium.Ellipsoid.WGS84.maximumRadius;
const keyholeRadius = getKeyholeGeometry(width, height).radius;
const targetScreenRadius = keyholeRadius * 0.61;
@@ -32,6 +33,7 @@ export function createSelection({
);
const distance = earthRadius / Math.max(Math.sin(angularRadius), 1e-4);
const altitude = Math.max(earthRadius * 1.55, distance - earthRadius);
layerState._cameraReleased = false;
viewer.camera.flyTo({
destination: Cesium.Cartesian3.fromRadians(
cartographic.longitude,
@@ -45,7 +47,10 @@ export function createSelection({
},
duration,
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
complete,
cancel,
});
return true;
}
function entityLaunchId(entity) {
@@ -187,6 +192,7 @@ export function createSelection({
Cesium.Cartesian3.cross(right, direction, new Cesium.Cartesian3()),
new Cesium.Cartesian3(),
);
layerState._cameraReleased = false;
layerState._viewer.camera.flyTo({
destination,
orientation: { direction, up },
@@ -209,6 +215,7 @@ export function createSelection({
layerState._viewer.selectedEntity = layerState._dataSource.entities.getById(
`rocket-launch:${launch.id}`,
);
layerState._cameraReleased = false;
layerState._viewer.camera.flyToBoundingSphere(
new Cesium.BoundingSphere(launchPosition, 0),
{
+9
View File
@@ -19,6 +19,15 @@ export function createState({ services }) {
state._lastError = null;
state._lastErrorAt = null;
state._entryCameraView = null;
state._entryLandedView = null;
/** Navigation took the camera; set until the layer flies again. */
state._cameraReleased = false;
state._stale = false;
state._orbitMatches = 0;
+16 -3
View File
@@ -21,9 +21,20 @@ export function createAnimation({
source,
}) {
function syncRenderDemand() {
// Hidden dots (`dots` off) need no frames. Lines need them only while
// their asynchronous geometry builds (or old lines wait to be retired),
// and the heat-line prototype's jam batch while it pulses.
const lines = [
layerState._heatJamPrim,
layerState._heatSlowPrim,
layerState._heatFreePrim,
].filter(Boolean);
const needed = Boolean(
layerState._enabled &&
(layerState._dots.length || layerState._heatJamPrim),
((layerState._showDots !== false && layerState._dots.length) ||
(layerState._heatJamPrim && !layerState._showLines) ||
layerState._heatRetired?.length ||
lines.some((primitive) => !primitive.ready)),
);
// Height easing only runs in a held frame. Settle it before releasing, so
// a population emptied mid-correction cannot report loading forever.
@@ -224,6 +235,7 @@ export function createAnimation({
*/
function animate() {
parts.rendering.retireHeatLines?.();
syncRenderDemand();
if (!layerState._renderHeld) return;
const now = Date.now();
@@ -317,8 +329,9 @@ export function createAnimation({
}
// Jam heat-lines throb (~1.6 s period) — one shared material uniform for
// the whole jam batch, no geometry rebuild. Null outside live heatline mode.
if (layerState._heatJamPrim?.appearance) {
// the whole jam batch, no geometry rebuild. Null outside live heatline
// mode; the steady flow lines (`lines` toggle) never pulse.
if (layerState._heatJamPrim?.appearance && !layerState._showLines) {
layerState._heatJamPrim.appearance.material.uniforms.color.alpha =
HEAT_JAM_BASE_ALPHA + HEAT_JAM_PULSE_ALPHA * Math.sin(now / 260);
}
+95 -20
View File
@@ -1,7 +1,7 @@
import {
normalizeRoadMode,
resolveRoadMode,
TRAFFIC_ROAD_MODES,
roadModesSimulate,
} from './roadModes.js';
import {
trafficBucketTier,
@@ -19,19 +19,25 @@ export function createControls({ state: layerState, services, parts, source }) {
const notes = [feed.loadingLabel];
if (!layerState._liveMode || feed.error)
notes.push(t('Roads: {source}', { source }));
// An explicit TomTom or Hybrid choice without a key draws OpenStreetMap.
// An explicit TomTom or Hybrid choice without a key draws OpenStreetMap;
// a host without OpenStreetMap draws nothing.
const requested = roadModesSimulate(layerState._roadModes)
? layerState._roadMode
: resolveRoadMode(layerState._roadMode, true, layerState._roadModes);
// A host without OpenStreetMap waits for the probe before naming a key.
if (
!layerState._liveMode &&
['tomtom', 'hybrid'].includes(layerState._roadMode)
['tomtom', 'hybrid'].includes(requested) &&
(roadModesSimulate(layerState._roadModes) || layerState._flowStatusKnown)
)
notes.push(
layerState._flowStatusUnavailable
? layerState._roadMode === 'tomtom'
? requested === 'tomtom'
? t(
'TomTom roads unavailable while the traffic service is unreachable',
)
: t('Hybrid unavailable while the traffic service is unreachable')
: layerState._roadMode === 'tomtom'
: requested === 'tomtom'
? t('TomTom roads need a TomTom key')
: t('Hybrid needs a TomTom key'),
);
@@ -62,10 +68,13 @@ export function createControls({ state: layerState, services, parts, source }) {
updateInterval: 0,
/**
* Update user-adjustable parameters (road source, density and speed scaling).
* Update user-adjustable parameters (road source, row toggles, density and
* speed scaling).
*
* @param {Object} [params]
* @param {'tomtom'|'osm'|'hybrid'|null} [params.roadMode] - Road source; null = default.
* @param {'tomtom'|'osm'|'hybrid'|null} [params.roadMode] - Road source; null = default; outside the host's modes = ignored.
* @param {boolean} [params.lines] - Draw flow-colored road lines (live mode).
* @param {boolean} [params.dots] - Draw the moving dots.
* @param {number} [params.densityScale] - Dot density multiplier (clamped 0.2–2.5).
* @param {number} [params.speedScale] - Dot speed multiplier (clamped 0.3–3.0).
* @param {{origin?:string}} [options] - Intent origin; restores never beat `?trafficRoads=`.
@@ -75,7 +84,8 @@ export function createControls({ state: layerState, services, parts, source }) {
const previousSpeed = layerState._speedScale;
if (
Object.hasOwn(params, 'roadMode') &&
(params.roadMode === null || normalizeRoadMode(params.roadMode))
(params.roadMode === null ||
normalizeRoadMode(params.roadMode, layerState._roadModes))
) {
const explicit = ['user', 'voice', 'tool'].includes(origin);
const mode =
@@ -98,6 +108,31 @@ export function createControls({ state: layerState, services, parts, source }) {
parts.viewport.onCameraChanged({ immediate: true });
}
}
if (
typeof params.lines === 'boolean' &&
params.lines !== layerState._showLines
) {
layerState._showLines = params.lines;
// Rebuild in place from the roads on screen: no refetch, no respawn.
if (layerState._enabled)
parts.rendering.rebuildHeatLines(
parts.rendering.visibleRoadsForAltitude(
layerState._roads,
layerState._lastRenderAltitude,
),
);
}
if (
typeof params.dots === 'boolean' &&
params.dots !== layerState._showDots
) {
layerState._showDots = params.dots;
// Hidden dots are kept, so showing them again is instant.
if (layerState._enabled && layerState._pointCollection)
layerState._pointCollection.show = params.dots;
parts.animation.syncRenderDemand();
layerState._viewer?.scene?.requestRender?.();
}
if (typeof params.densityScale === 'number') {
layerState._densityScale = Math.max(
0.2,
@@ -154,11 +189,14 @@ export function createControls({ state: layerState, services, parts, source }) {
/**
* Return the current user-adjustable parameters.
* @returns {{densityScale:number, speedScale:number}}
* @returns {{roadMode:string|null, lines:boolean, dots:boolean,
* densityScale:number, speedScale:number}}
*/
getParams() {
return {
roadMode: layerState._roadMode,
lines: layerState._showLines,
dots: layerState._showDots,
densityScale: layerState._densityScale,
speedScale: layerState._speedScale,
uncoveredRoads: layerState._uncoveredMode,
@@ -167,10 +205,15 @@ export function createControls({ state: layerState, services, parts, source }) {
};
},
/** Road-source chips on the Street Traffic row (TomTom / OSM / Hybrid). */
/**
* Street Traffic row chips: a road-source chip per offered mode (none for
* a single mode), then Lines and Dots toggles while live flow colors them.
*/
getRowControls() {
const modes = layerState._roadModes;
const selected =
layerState._roadMode || resolveRoadMode(null, layerState._liveMode);
layerState._roadMode ||
resolveRoadMode(null, layerState._liveMode, modes);
const live = layerState._liveMode;
const titles = {
tomtom: live
@@ -187,14 +230,36 @@ export function createControls({ state: layerState, services, parts, source }) {
};
// TomTom and OSM are provider names and stay as they are.
const labels = { tomtom: 'TomTom', osm: 'OSM', hybrid: t('Hybrid') };
const lines = {
id: 'lines',
label: t('Lines'),
title: t('Road lines colored by live flow: green to red'),
active: layerState._showLines,
params: { lines: !layerState._showLines },
};
const dots = {
id: 'dots',
label: t('Dots'),
title: live ? t('Moving dots colored by live flow') : t('Moving dots'),
active: layerState._showDots,
params: { dots: !layerState._showDots },
};
// Lines need live flow. Dots, once hidden (a live choice, a restore or
// a share link), stay offered so they can always be shown again.
const toggles = live ? [lines, dots] : layerState._showDots ? [] : [dots];
return {
chips: TRAFFIC_ROAD_MODES.map((mode) => ({
id: `roads-${mode}`,
label: labels[mode],
title: titles[mode],
active: mode === selected,
params: { roadMode: mode },
})),
chips: [
...(modes.length > 1
? modes.map((mode) => ({
id: `roads-${mode}`,
label: labels[mode],
title: titles[mode],
active: mode === selected,
params: { roadMode: mode },
}))
: []),
...toggles,
],
};
},
@@ -211,7 +276,13 @@ export function createControls({ state: layerState, services, parts, source }) {
* @returns {Array<{position:Cesium.Cartesian3, id:string, type:string}>}
*/
getDetectableObjects(options = {}) {
if (!layerState._enabled || layerState._dots.length === 0) return [];
// Hidden dots are not on screen to detect.
if (
!layerState._enabled ||
!layerState._showDots ||
layerState._dots.length === 0
)
return [];
const maxCount = Number.isFinite(options.maxCount)
? Math.max(1, Math.floor(options.maxCount))
: layerState._dots.length;
@@ -382,7 +453,11 @@ export function createControls({ state: layerState, services, parts, source }) {
// in one had better be the honest one. This is also where LIVE vs
// SIMULATED mode is surfaced, and it must never imply a live feed the
// layer does not have.
roadMode: resolveRoadMode(layerState._roadMode, layerState._liveMode),
roadMode: resolveRoadMode(
layerState._roadMode,
layerState._liveMode,
layerState._roadModes,
),
roadModeRequested: layerState._roadMode,
roadSource: layerState._roadSource,
source: layerState._roadSource,
+326
View File
@@ -0,0 +1,326 @@
// A host's road-source offer (createTrafficSource `roadModes`) and the
// Street Traffic Lines / Dots row toggles.
import test from 'node:test';
import assert from 'node:assert/strict';
import {
normalizeRoadModes,
resolveRoadMode,
roadModesSimulate,
TRAFFIC_ROAD_MODES,
} from './roadModes.js';
import { createTrafficSource } from './source.js';
import { createTrafficLayer } from './index.js';
import {
createDefaultLayerState,
decodeLayerStateParams,
encodeLayerStateParams,
parseStoredLayerState,
serializeStoredLayerState,
} from '../../data/layerState.js';
const box = { west: -97.75, east: -97.74, south: 30.26, north: 30.27 };
const flow = {
coords: [
[-97.744, 30.267],
[-97.742, 30.267],
],
trafficLevel: 0.3,
closure: false,
roadType: 'Major road',
};
function tomtomOnlySource() {
let requests = 0;
const source = createTrafficSource({
roadModes: ['tomtom'],
mapTiles: {
async fetchBounds() {
requests++;
return { tiles: [], partial: false };
},
async getMetadata() {
requests++;
},
clear() {},
},
});
return { source, requests: () => requests };
}
test('host road modes are a non-empty subset in canonical order', () => {
assert.equal(normalizeRoadModes(undefined), TRAFFIC_ROAD_MODES);
assert.deepEqual(normalizeRoadModes(['hybrid', 'tomtom']), [
'tomtom',
'hybrid',
]);
assert.ok(Object.isFrozen(normalizeRoadModes(['tomtom'])));
for (const bad of [[], ['bogus'], ['tomtom', 'tomtom'], 'tomtom', null])
assert.throws(() => normalizeRoadModes(bad), TypeError, String(bad));
assert.throws(() => createTrafficSource({ roadModes: [] }), TypeError);
assert.equal(roadModesSimulate(), true);
assert.equal(roadModesSimulate(['tomtom', 'hybrid']), false);
});
test('a TomTom-only host resolves every request to TomTom, keyed or not', () => {
for (const value of [null, 'osm', 'hybrid', 'tomtom'])
for (const hasKey of [true, false])
assert.equal(resolveRoadMode(value, hasKey, ['tomtom']), 'tomtom');
// Without Hybrid the default is the first offered mode.
assert.equal(resolveRoadMode(null, true, ['tomtom', 'osm']), 'tomtom');
assert.equal(resolveRoadMode(null, false, ['tomtom', 'osm']), 'osm');
});
test('a TomTom-only source draws TomTom roads and never requests OpenStreetMap', async () => {
const f = tomtomOnlySource();
assert.deepEqual(f.source.roadModes, ['tomtom']);
for (const roadMode of [null, 'osm', 'hybrid']) {
const data = await (
await f.source.requestRoads(box, {
roadMode,
flowSnapshot: Promise.resolve({ hasKey: true, segments: [flow] }),
})
).json();
assert.equal(data.roadSource, 'TomTom', String(roadMode));
assert.equal(data.roads.length, 1);
assert.equal(data.roads[0].directFlow, true);
}
assert.equal(f.requests(), 0);
await assert.rejects(f.source.requestRoads(box, {}), TypeError);
});
test('a TomTom-only source never simulates: keyless or unreachable loads fail with the reason', async () => {
const f = tomtomOnlySource();
const failure = (snapshot) =>
f.source
.requestRoads(box, { flowSnapshot: Promise.resolve(snapshot) })
.then(
() => assert.fail('a keyless load drew roads'),
(error) => error,
);
const keyless = await failure({ hasKey: false, segments: [] });
assert.equal(keyless.message, 'TomTom roads need a TomTom key');
assert.equal(keyless.retryable, false);
const unreachable = await failure({
hasKey: false,
segments: [],
statusUnavailable: true,
});
assert.equal(
unreachable.message,
'TomTom roads unavailable while the traffic service is unreachable',
);
assert.equal(unreachable.retryable, true);
const late = await failure({
hasKey: false,
segments: [],
error: 'TomTom flow timed out',
});
assert.equal(late.message, 'TomTom flow timed out');
assert.equal(f.requests(), 0);
});
test('a TomTom-only layer offers no road chips and ignores other road choices', () => {
const oldLocation = globalThis.location;
globalThis.location = { search: '?trafficRoads=osm' };
try {
const { source } = tomtomOnlySource();
const layer = createTrafficLayer({
services: { credits: {}, render: {} },
source,
});
assert.equal(layer.getParams().roadMode, null);
for (const origin of ['user', 'local-restore', 'share-restore'])
layer.setParams({ roadMode: 'osm' }, { origin });
assert.equal(layer.getParams().roadMode, null);
assert.deepEqual(layer.getRowControls().chips, []);
const stats = layer.getStats();
assert.equal(stats.roadMode, 'tomtom');
// No key verdict before the status probe settles.
assert.doesNotMatch(stats.loadingLabel, /need a TomTom key/);
} finally {
if (oldLocation === undefined) delete globalThis.location;
else globalThis.location = oldLocation;
}
});
test('Lines and Dots are boolean params that only appear as chips with live flow', () => {
const layer = createTrafficLayer({
services: { credits: {}, render: {} },
source: tomtomOnlySource().source,
});
assert.equal(layer.getParams().lines, false);
assert.equal(layer.getParams().dots, true);
// Keyless (the status probe has not reported a key): nothing to color.
assert.deepEqual(layer.getRowControls().chips, []);
layer.setParams({ lines: true });
assert.equal(layer.getParams().lines, true);
layer.setParams({ lines: 'no', dots: 0 });
assert.equal(layer.getParams().lines, true, 'non-boolean ignored');
assert.equal(layer.getParams().dots, true, 'non-boolean ignored');
layer.setParams({ lines: false, dots: false });
assert.deepEqual(
[layer.getParams().lines, layer.getParams().dots],
[false, false],
);
assert.deepEqual(layer.getDetectableObjects(), []);
// Hidden dots stay recoverable without live flow (restore, share link).
assert.deepEqual(
layer.getRowControls().chips.map((chip) => [chip.id, chip.title]),
[['dots', 'Moving dots']],
);
layer.setParams(layer.getRowControls().chips[0].params, { origin: 'user' });
assert.equal(layer.getParams().dots, true);
assert.deepEqual(layer.getRowControls().chips, []);
});
test('live chips toggle each control without touching the other', async () => {
const { createState } = await import('./state.js');
const { createControls } = await import('./controls.js');
const state = createState({
services: {},
source: { roadModes: ['tomtom'] },
});
state._liveMode = true;
const controls = createControls({
state,
services: {},
source: { getFlowSessionStats: () => ({}) },
parts: {
animation: { syncRenderDemand() {} },
rendering: {},
},
}).methods;
const chip = (id) =>
controls.getRowControls().chips.find((entry) => entry.id === id);
assert.deepEqual(
controls.getRowControls().chips.map((entry) => entry.id),
['lines', 'dots'],
);
assert.equal(chip('lines').active, false);
assert.equal(chip('dots').active, true);
controls.setParams(chip('lines').params, { origin: 'user' });
assert.equal(chip('lines').active, true);
assert.equal(chip('dots').active, true);
controls.setParams(chip('dots').params, { origin: 'user' });
assert.equal(chip('dots').active, false);
assert.equal(chip('lines').active, true);
// Every mode keeps its road chips beside the toggles.
const all = createState({ services: {}, source: {} });
all._liveMode = true;
assert.deepEqual(
createControls({
state: all,
services: {},
source: { getFlowSessionStats: () => ({}) },
parts: {},
})
.methods.getRowControls()
.chips.map((entry) => entry.id),
['roads-tomtom', 'roads-osm', 'roads-hybrid', 'lines', 'dots'],
);
});
test('hiding dots hides the point collection of an enabled layer', async () => {
const { createState } = await import('./state.js');
const { createControls } = await import('./controls.js');
const state = createState({ services: {}, source: {} });
let demand = 0;
state._enabled = true;
state._pointCollection = { show: true };
const controls = createControls({
state,
services: {},
source: { getFlowSessionStats: () => ({}) },
parts: { animation: { syncRenderDemand: () => demand++ } },
}).methods;
controls.setParams({ dots: false });
assert.equal(state._pointCollection.show, false);
controls.setParams({ dots: true });
assert.equal(state._pointCollection.show, true);
assert.equal(demand, 2);
});
test('Lines and Dots persist in layer state and local storage', () => {
const state = createDefaultLayerState();
assert.equal(state.options.traffic.lines, false);
assert.equal(state.options.traffic.dots, true);
state.enabledLayerIds = ['traffic'];
state.options.traffic.lines = true;
state.options.traffic.dots = false;
const params = new URLSearchParams('v=2');
encodeLayerStateParams(params, state);
const decoded = decodeLayerStateParams(params).options.traffic;
assert.equal(decoded.lines, true);
assert.equal(decoded.dots, false);
const stored = parseStoredLayerState(serializeStoredLayerState(state)).options
.traffic;
assert.equal(stored.lines, true);
assert.equal(stored.dots, false);
});
test('rebuilt lines keep the old ones drawn until the new geometry is ready', async () => {
const { createRendering } = await import('./rendering.js');
const removed = [];
const state = {
_viewer: {
scene: { groundPrimitives: { remove: (p) => removed.push(p) } },
},
_liveMode: false,
_heatFreePrim: { ready: true, id: 'old' },
_heatRetired: [],
};
const parts = {
style: { heatlineOn: () => true },
animation: { syncRenderDemand() {} },
};
const rendering = createRendering({ state, services: {}, parts, source: {} });
// Nothing new to draw (not live): the old line goes at once.
rendering.rebuildHeatLines([]);
assert.deepEqual(
removed.map((p) => p.id),
['old'],
);
// A pending successor holds the retired line until it is ready.
const next = { ready: false, id: 'new' };
state._heatRetired = [{ id: 'older' }];
state._heatJamPrim = next;
rendering.retireHeatLines();
assert.equal(state._heatRetired.length, 1);
next.ready = true;
rendering.retireHeatLines();
assert.deepEqual(state._heatRetired, []);
assert.deepEqual(
removed.map((p) => p.id),
['old', 'older'],
);
});
test('lines hold frames only while building; hidden dots hold none', async () => {
const { createAnimation } = await import('./animation.js');
const calls = [];
const state = {
_enabled: true,
_dots: [{}],
_showDots: false,
_showLines: true,
_motion: { rebuilds: 0 },
};
const animation = createAnimation({
state,
services: {
render: {
holdContinuousRender: () => calls.push('hold'),
releaseContinuousRender: () => calls.push('release'),
},
},
parts: {},
});
animation.syncRenderDemand();
assert.deepEqual(calls, []);
state._heatFreePrim = { ready: false };
animation.syncRenderDemand();
assert.deepEqual(calls, ['hold']);
state._heatFreePrim.ready = true;
animation.syncRenderDemand();
assert.deepEqual(calls, ['hold', 'release']);
});
+1 -1
View File
@@ -24,7 +24,7 @@ export function createTrafficLayer({ services, source }) {
].every((key) => typeof source?.[key] === 'function')
)
throw new TypeError('A traffic source is required');
const state = createState({ services });
const state = createState({ services, source });
const parts = {};
const context = { state, services, parts, source };
parts.style = createStyle(context);
+35 -7
View File
@@ -1,6 +1,11 @@
import * as Cesium from 'cesium';
import { requestWithDeadline } from '../../sources/requestDeadline.js';
import { resolveRoadMode, ROAD_SOURCE_LABELS } from './roadModes.js';
import {
normalizeRoadMode,
resolveRoadMode,
roadModesSimulate,
ROAD_SOURCE_LABELS,
} from './roadModes.js';
import { phaseTiming } from '../../sources/phaseTiming.js';
import { RoadRequestError, roadRequestError } from './source.js';
import {
@@ -294,10 +299,14 @@ export function createIngestion({
// settled; before that (a session's first load) the cache is skipped
// rather than waiting on the probe. Writes are decided by the snapshot.
const cacheMode =
layerState._roadMode === 'osm'
normalizeRoadMode(layerState._roadMode, layerState._roadModes) === 'osm'
? 'osm'
: layerState._flowStatusKnown
? resolveRoadMode(layerState._roadMode, layerState._liveMode)
? resolveRoadMode(
layerState._roadMode,
layerState._liveMode,
layerState._roadModes,
)
: null;
// Cache key: fixed-precision bounding-box string for deterministic lookups
const cacheKey =
@@ -378,6 +387,7 @@ export function createIngestion({
(segments) => ({
segments,
hasKey: layerState._liveMode,
statusUnavailable: layerState._flowStatusUnavailable,
partial: source.getFlowSessionStats?.().partial,
}),
(error) => {
@@ -388,6 +398,7 @@ export function createIngestion({
return {
segments: [],
hasKey: layerState._liveMode,
statusUnavailable: layerState._flowStatusUnavailable,
error: reason,
};
},
@@ -397,7 +408,9 @@ export function createIngestion({
// This uses the same shared XYZ cache and cap, with no extra ring.
if (
altitude <= FAST_FETCH_ALTITUDE &&
layerState._roadMode !== 'tomtom'
resolveRoadMode(layerState._roadMode, true, layerState._roadModes) !==
'tomtom' &&
(roadModesSimulate(layerState._roadModes) || layerState._liveMode)
) {
source
.requestOsmRoads?.(clamped, {
@@ -411,8 +424,19 @@ export function createIngestion({
.catch(() => {});
}
requestSignal.throwIfAborted();
// Until the probe settles only OpenStreetMap roads can be drawn.
layerState._roadSource = ROAD_SOURCE_LABELS[cacheMode || 'osm'];
// Until the probe settles only OpenStreetMap roads can be drawn (or,
// for a host without them, the TomTom roads still to come).
layerState._roadSource =
ROAD_SOURCE_LABELS[
cacheMode ||
(roadModesSimulate(layerState._roadModes)
? 'osm'
: resolveRoadMode(
layerState._roadMode,
true,
layerState._roadModes,
))
];
let cache =
cacheMode === 'osm' ? layerState._tileCache.get(cacheKey) : null;
if (cache) {
@@ -581,7 +605,11 @@ export function createIngestion({
if (generation === layerState._loadGeneration && !renderedSomething)
layerState._roadSource =
ROAD_SOURCE_LABELS[
resolveRoadMode(layerState._roadMode, layerState._liveMode)
resolveRoadMode(
layerState._roadMode,
layerState._liveMode,
layerState._roadModes,
)
];
retryable = !isUnavailableCapability(e);
layerState._roadRetryStopped = !retryable;
+8 -2
View File
@@ -1,6 +1,7 @@
import { observeTrafficSurface } from './surface.js';
import * as Cesium from 'cesium';
import { TRAFFIC_TIMING_ENABLED } from './policy.js';
import { resolveRoadMode } from './roadModes.js';
import {
claimCameraSensitivity,
releaseCameraSensitivity,
@@ -81,10 +82,15 @@ export function createLifecycle({
if (layerState._enabled) return;
layerState._enabled = true;
layerState._surfaceFrameRemover = observeTrafficSurface(viewer.scene);
if (layerState._roadMode !== 'tomtom') source.prefetch?.();
// OpenFreeMap metadata, unless only TomTom roads can be drawn.
if (
resolveRoadMode(layerState._roadMode, true, layerState._roadModes) !==
'tomtom'
)
source.prefetch?.();
parts.animation.syncRenderDemand();
layerState._lastAnimTime = 0;
layerState._pointCollection.show = true;
layerState._pointCollection.show = layerState._showDots;
layerState._preRenderRemover = viewer.scene.preRender.addEventListener(
parts.animation.animate,
+17
View File
@@ -117,6 +117,23 @@ export const FLOW_BUCKET_COLORS = {
jam: Cesium.Color.fromCssColorString('#e05252').withAlpha(0.9),
};
// ─── Flow road lines (`lines` row toggle, live mode only) ────────────────
/**
* @const {number} Max flow-colored road lines per render. Jam first, then
* slow, then free-flow, longest first within a bucket, so a dense city keeps
* its congestion when the cap trims the free roads.
*/
export const FLOW_LINE_CAP = 1500;
/** @const {number} Px — flow road-line width (every bucket). */
export const FLOW_LINE_WIDTH = 4;
/** @const {number} Flow road-line opacity: the bucket colors, steady. */
export const FLOW_LINE_ALPHA = 0.8;
// ─── Jam-viz prototype (live mode only — see 2026-07-21 design doc) ────────
/** @const {number} Max congestion heat-line polylines per render (jam first). */
+131 -60
View File
@@ -2,6 +2,10 @@ import * as Cesium from 'cesium';
import { flowBucket } from '../../data/trafficFlowStyle.js';
import { trafficStyleProfile } from '../../data/trafficPresetStyle.js';
import {
FLOW_BUCKET_COLORS,
FLOW_LINE_ALPHA,
FLOW_LINE_CAP,
FLOW_LINE_WIDTH,
HEAT_LINE_CAP,
HEAT_JAM_COLOR,
HEAT_SLOW_COLOR,
@@ -34,45 +38,81 @@ export function createRendering({
: roads;
}
/** Remove both heat-line ground primitives from the scene. */
const LINE_KEYS = ['_heatJamPrim', '_heatSlowPrim', '_heatFreePrim'];
function removeHeatLines() {
if (layerState._heatJamPrim) {
layerState._viewer?.scene.groundPrimitives.remove(
layerState._heatJamPrim,
);
layerState._heatJamPrim = null;
}
if (layerState._heatSlowPrim) {
layerState._viewer?.scene.groundPrimitives.remove(
layerState._heatSlowPrim,
);
layerState._heatSlowPrim = null;
/** Detach the current line primitives, leaving them in the scene. */
function takeHeatLines() {
const taken = [];
for (const key of LINE_KEYS) {
if (layerState[key]) taken.push(layerState[key]);
layerState[key] = null;
}
layerState._heatLineCount = 0;
return taken;
}
function removePrimitives(primitives) {
for (const primitive of primitives)
layerState._viewer?.scene.groundPrimitives.remove(primitive);
}
/** Remove the heat-line / flow-line ground primitives from the scene. */
function removeHeatLines() {
removePrimitives(takeHeatLines());
removePrimitives(layerState._heatRetired || []);
layerState._heatRetired = [];
parts.animation.syncRenderDemand?.();
}
/**
* Rebuild the congestion heat-line underlay (jam-viz heatline prototype):
* slow/jam roads drape a corridor line onto the rendered 3D tiles — glowing
* pulsing red for jam, faint flat amber for slow — with the dots animating
* on top. Two batched GroundPolylinePrimitives (one per bucket) so the jam
* batch pulses through one shared material. Capped at HEAT_LINE_CAP (jam
* first, longest first); overflow is logged. No-op in sim mode, when the
* heatline mode is off, or without ground-primitive support.
* Replaced lines stay drawn until their successors finish building
* (GroundPolylinePrimitive geometry is asynchronous), so a rebuild never
* blinks. Called every held frame.
*/
function retireHeatLines() {
if (!layerState._heatRetired?.length) return;
if (LINE_KEYS.some((key) => layerState[key] && !layerState[key].ready))
return;
removePrimitives(layerState._heatRetired);
layerState._heatRetired = [];
}
/**
* Rebuild the road-line underlay drawn from live flow, in one of two looks:
*
* - Flow lines (the `lines` row toggle): every road with flow drapes a
* steady line in its bucket color — green free-flow, amber slow, red jam.
* Capped at FLOW_LINE_CAP.
* - Heat-lines (jam-viz `heatline` prototype): only slow/jam roads, a
* glowing pulsing red for jam and a faint flat amber for slow. Capped at
* HEAT_LINE_CAP.
*
* One batched GroundPolylinePrimitive per bucket, so the heat-line jam batch
* pulses through one shared material. Kept roads are jam first, then slow,
* then free, longest first; overflow is logged. Lines drape on 3D tiles when
* the globe is hidden (photoreal) and on terrain or tiles otherwise. No-op in
* sim mode, when both looks are off, or without ground-primitive support.
*
* @param {Array} roads - Road objects visible in the current render.
*/
function rebuildHeatLines(roads) {
removeHeatLines();
const previous = takeHeatLines();
const finish = () => {
// Nothing new to wait for: the old lines go now.
if (LINE_KEYS.some((key) => layerState[key]))
(layerState._heatRetired ||= []).push(...previous);
else removePrimitives(previous);
parts.animation.syncRenderDemand?.();
};
if (
!layerState._viewer ||
!layerState._liveMode ||
!parts.style.heatlineOn()
)
return;
return finish();
if (layerState._heatSupported === null) {
layerState._heatSupported = Cesium.GroundPolylinePrimitive.isSupported(
layerState._viewer.scene,
@@ -82,22 +122,25 @@ export function createRendering({
'[Data:Traffic] GroundPolylinePrimitive unsupported — heat-lines disabled',
);
}
if (!layerState._heatSupported) return;
if (!layerState._heatSupported) return finish();
const flowLines = layerState._showLines;
const cap = flowLines ? FLOW_LINE_CAP : HEAT_LINE_CAP;
const rank = { jam: 0, slow: 1, free: 2 };
const candidates = [];
for (const road of roads) {
const flow = road.flow;
if (!flow || flow.closure) continue;
const bucket = flowBucket(flow.level);
if (bucket === 'free') continue;
if (bucket === 'free' && !flowLines) continue;
let len = 0;
for (const d of road.segmentDist) len += d;
candidates.push({ road, bucket, len });
}
candidates.sort((a, b) =>
a.bucket === b.bucket ? b.len - a.len : a.bucket === 'jam' ? -1 : 1,
a.bucket === b.bucket ? b.len - a.len : rank[a.bucket] - rank[b.bucket],
);
const kept = candidates.slice(0, HEAT_LINE_CAP);
const kept = candidates.slice(0, cap);
const instancesFor = (bucket, width) =>
kept
@@ -112,51 +155,72 @@ export function createRendering({
}),
);
// Mono presets (NVG/FLIR/noir) discard hue — heat-lines re-encode in
// luminance like the dots: jam = white glow, slow = faint gray.
const monoHeat =
// Mono presets (NVG/FLIR/noir) discard hue — lines re-encode in
// luminance like the dots: jam = white, slow = gray, free = dim gray.
const mono =
layerState._presetDots === 'on' &&
trafficStyleProfile(layerState._stylePreset) === 'mono';
const jamLineColor = monoHeat ? Cesium.Color.WHITE : HEAT_JAM_COLOR;
const slowLineColor = monoHeat
? new Cesium.Color(0.7, 0.7, 0.7, HEAT_SLOW_COLOR.alpha)
: HEAT_SLOW_COLOR;
const jamInstances = instancesFor('jam', HEAT_LINE_JAM_WIDTH);
if (jamInstances.length) {
layerState._heatJamPrim = layerState._viewer.scene.groundPrimitives.add(
new Cesium.GroundPolylinePrimitive({
geometryInstances: jamInstances,
classificationType: Cesium.ClassificationType.CESIUM_3D_TILE,
appearance: new Cesium.PolylineMaterialAppearance({
material: Cesium.Material.fromType('PolylineGlow', {
color: jamLineColor.withAlpha(HEAT_JAM_BASE_ALPHA),
glowPower: 0.25,
const gray = (level, alpha) => new Cesium.Color(level, level, level, alpha);
const classificationType =
layerState._viewer.scene.globe?.show === false
? Cesium.ClassificationType.CESIUM_3D_TILE
: flowLines
? Cesium.ClassificationType.BOTH
: Cesium.ClassificationType.CESIUM_3D_TILE;
const add = (instances, material) =>
instances.length
? layerState._viewer.scene.groundPrimitives.add(
new Cesium.GroundPolylinePrimitive({
geometryInstances: instances,
classificationType,
appearance: new Cesium.PolylineMaterialAppearance({ material }),
}),
)
: null;
// Lines take the dots' active (preset-aware) colors.
const dotColor = (bucket) =>
layerState._activeBucketColors?.[bucket] || FLOW_BUCKET_COLORS[bucket];
if (flowLines) {
const lineColor = {
jam: mono ? gray(1, FLOW_LINE_ALPHA) : dotColor('jam'),
slow: mono ? gray(0.7, FLOW_LINE_ALPHA) : dotColor('slow'),
free: mono ? gray(0.45, FLOW_LINE_ALPHA) : dotColor('free'),
};
for (const [bucket, key] of [
['free', '_heatFreePrim'],
['slow', '_heatSlowPrim'],
['jam', '_heatJamPrim'],
])
layerState[key] = add(
instancesFor(bucket, FLOW_LINE_WIDTH),
Cesium.Material.fromType('Color', {
color: lineColor[bucket].withAlpha(FLOW_LINE_ALPHA),
}),
);
} else {
const jamLineColor = mono ? Cesium.Color.WHITE : HEAT_JAM_COLOR;
const slowLineColor = mono
? gray(0.7, HEAT_SLOW_COLOR.alpha)
: HEAT_SLOW_COLOR;
layerState._heatJamPrim = add(
instancesFor('jam', HEAT_LINE_JAM_WIDTH),
Cesium.Material.fromType('PolylineGlow', {
color: jamLineColor.withAlpha(HEAT_JAM_BASE_ALPHA),
glowPower: 0.25,
}),
);
}
const slowInstances = instancesFor('slow', HEAT_LINE_SLOW_WIDTH);
if (slowInstances.length) {
layerState._heatSlowPrim = layerState._viewer.scene.groundPrimitives.add(
new Cesium.GroundPolylinePrimitive({
geometryInstances: slowInstances,
classificationType: Cesium.ClassificationType.CESIUM_3D_TILE,
appearance: new Cesium.PolylineMaterialAppearance({
material: Cesium.Material.fromType('Color', {
color: slowLineColor,
}),
}),
}),
layerState._heatSlowPrim = add(
instancesFor('slow', HEAT_LINE_SLOW_WIDTH),
Cesium.Material.fromType('Color', { color: slowLineColor }),
);
}
layerState._heatLineCount = kept.length;
parts.animation.syncRenderDemand?.();
finish();
if (candidates.length > kept.length) {
console.log(
`[Data:Traffic] Heat-lines capped at ${HEAT_LINE_CAP} (${candidates.length} congested roads in view)`,
`[Data:Traffic] ${flowLines ? 'Flow lines' : 'Heat-lines'} capped at ${cap} (${candidates.length} roads in view)`,
);
}
}
@@ -270,7 +334,13 @@ export function createRendering({
renderMetrics,
)
: null;
if (!label.includes('tile')) rebuildHeatLines(filteredRoads);
// The flow recolor above already rebuilt the lines from every retained
// road; without dots to recolor, rebuild them here.
if (
!label.includes('tile') &&
!(layerState._liveMode && layerState._dots.length)
)
rebuildHeatLines(visibleRoadsForAltitude(layerState._roads, altitude));
if (state) {
const heatEnd = parts.timing.trafficTimingMark(
state,
@@ -314,6 +384,7 @@ export function createRendering({
return {
visibleRoadsForAltitude,
removeHeatLines,
retireHeatLines,
rebuildHeatLines,
renderRoadsForAltitude,
};
+40 -5
View File
@@ -46,24 +46,59 @@ const PAIRED_SIDE_SAMPLES = [0.25, 0.5, 0.75];
const CONTROLLED_ACCESS_FLOW = new Set(['Motorway', 'International road']);
const CONTROLLED_ACCESS_OSM = new Set(['motorway', 'trunk']);
/**
* Validate a host's road-source list (`createTrafficSource({ roadModes })`):
* a non-empty subset of TRAFFIC_ROAD_MODES, kept in canonical order.
* @param {*} value - Candidate list; undefined means every mode.
* @returns {ReadonlyArray<'tomtom'|'osm'|'hybrid'>}
*/
export function normalizeRoadModes(value) {
if (value === undefined) return TRAFFIC_ROAD_MODES;
const modes = Array.isArray(value)
? TRAFFIC_ROAD_MODES.filter((mode) => value.includes(mode))
: [];
if (!modes.length || modes.length !== value.length)
throw new TypeError(
`Traffic road modes must be a non-empty subset of ${TRAFFIC_ROAD_MODES.join(', ')}`,
);
return Object.freeze(modes);
}
/**
* Whether a host's modes allow simulated roads. Without OpenStreetMap the
* layer never simulates: keyless, it draws no roads and says why.
* @param {ReadonlyArray<string>} [modes]
* @returns {boolean}
*/
export function roadModesSimulate(modes = TRAFFIC_ROAD_MODES) {
return modes.includes('osm');
}
/**
* Validate an explicit choice.
* @param {*} value - Candidate mode.
* @param {ReadonlyArray<string>} [modes] - The host's offered modes.
* @returns {'tomtom'|'osm'|'hybrid'|null} Null means "use the default".
*/
export function normalizeRoadMode(value) {
return TRAFFIC_ROAD_MODES.includes(value) ? value : null;
export function normalizeRoadMode(value, modes = TRAFFIC_ROAD_MODES) {
return modes.includes(value) ? value : null;
}
/**
* Resolve the mode actually drawn. Without a TomTom key every choice draws
* OpenStreetMap roads; with one, an explicit choice wins over Hybrid.
* OpenStreetMap roads when the host offers them; with one, an explicit
* choice wins over Hybrid. A host that offers no OpenStreetMap mode keeps
* its TomTom choice keyless (nothing is drawn; the status names the reason),
* and a host without Hybrid defaults to its first offered mode.
* @param {*} value - Requested mode (null for the default).
* @param {boolean} hasKey - The server reported a TomTom key.
* @param {ReadonlyArray<string>} [modes] - The host's offered modes.
* @returns {'tomtom'|'osm'|'hybrid'}
*/
export function resolveRoadMode(value, hasKey) {
return hasKey ? normalizeRoadMode(value) || 'hybrid' : 'osm';
export function resolveRoadMode(value, hasKey, modes = TRAFFIC_ROAD_MODES) {
const chosen = normalizeRoadMode(value, modes);
if (!hasKey && roadModesSimulate(modes)) return 'osm';
return chosen || (modes.includes('hybrid') ? 'hybrid' : modes[0]);
}
const FLOW_ROAD_TYPES = Object.freeze({
+42 -1
View File
@@ -74,7 +74,23 @@ function roadChips(liveMode) {
source: { getFlowSessionStats: () => ({}) },
parts: {},
});
return controls.methods.getRowControls().chips;
return controls.methods
.getRowControls()
.chips.filter((chip) => chip.id.startsWith('roads-'));
}
function toggleChips() {
const state = createState({ services: {} });
state._liveMode = true;
const controls = createControls({
state,
services: {},
source: { getFlowSessionStats: () => ({}) },
parts: {},
});
return controls.methods
.getRowControls()
.chips.filter((chip) => !chip.id.startsWith('roads-'));
}
test('en: road-source chips keep their English labels and titles', () => {
@@ -124,3 +140,28 @@ for (const [locale, hybrid] of [
}
});
}
test('en: the Lines and Dots toggles keep their English labels and titles', () => {
setLocale('en');
assert.deepEqual(
toggleChips().map((chip) => [chip.id, chip.label, chip.title]),
[
['lines', 'Lines', 'Road lines colored by live flow: green to red'],
['dots', 'Dots', 'Moving dots colored by live flow'],
],
);
});
for (const locale of ['ru', 'es', 'ja']) {
test(`${locale}: the Lines and Dots toggles are translated`, () => {
setLocale(locale);
try {
for (const chip of toggleChips()) {
assert.doesNotMatch(chip.label, /^(Lines|Dots)$/, locale);
assert.doesNotMatch(chip.title, /colored by live flow/, locale);
}
} finally {
setLocale('en');
}
});
}
+50 -7
View File
@@ -1,7 +1,10 @@
import {
createHybridFill,
flowSegmentsToRoads,
normalizeRoadMode,
normalizeRoadModes,
resolveRoadMode,
roadModesSimulate,
ROAD_SOURCE_LABELS,
} from './roadModes.js';
import { phaseTiming } from '../../sources/phaseTiming.js';
@@ -80,7 +83,15 @@ export function roadRequestError(status, cause) {
});
}
/** Supply tile-derived road geometry and flow availability without Overpass queries. */
/**
* Supply tile-derived road geometry and flow availability without Overpass queries.
*
* `roadModes` is the host's offer of road sources (default: all three). The
* layer shows a chip per offered mode (none for a single mode) and never
* draws one outside it. Without `osm` nothing is simulated: keyless, or with
* the traffic service unreachable, requests fail with the reason instead of
* drawing white OpenStreetMap dots.
*/
export function createTrafficSource({
fetchImpl = (...args) => globalThis.fetch(...args),
tileFetchImpl = (...args) => globalThis.fetch(...args),
@@ -88,10 +99,13 @@ export function createTrafficSource({
fetchImpl: tileFetchImpl,
selectDecoded: ({ roads }) => ({ roads }),
}),
roadModes,
} = {}) {
const modes = normalizeRoadModes(roadModes);
const flow = createFlowTileSource({ fetchImpl });
const api = {
...flow,
roadModes: modes,
prefetch: () => mapTiles.getMetadata().catch(() => {}),
resetFlowTileCache() {
flow.resetFlowTileCache();
@@ -247,8 +261,8 @@ export function createTrafficSource({
*
* @param {{south:number, west:number, north:number, east:number}} box
* @param {Object} [options]
* @param {'tomtom'|'osm'|'hybrid'|null} [options.roadMode] - Requested mode (null = default).
* @param {Promise<{segments:Array, hasKey:boolean, error?:string, partial?:boolean}>} [options.flowSnapshot]
* @param {'tomtom'|'osm'|'hybrid'|null} [options.roadMode] - Requested mode (null = default; outside `roadModes` = default).
* @param {Promise<{segments:Array, hasKey:boolean, error?:string, partial?:boolean, statusUnavailable?:boolean}>} [options.flowSnapshot]
* @param {() => boolean} [options.liveModeHint] - Key state if the snapshot misses the pass deadline.
* @param {number} [options.timeoutSec=20] - Pass deadline, also bounding the snapshot wait.
* @param {(data:Object) => void} [options.onTile] - Incremental snapshots; `replace` resets.
@@ -269,7 +283,16 @@ export function createTrafficSource({
box.east - box.west > 10
)
throw new TypeError('A bounded road viewport is required');
if (roadMode === 'osm' || !flowSnapshot)
roadMode = normalizeRoadMode(roadMode, modes);
if (!roadModesSimulate(modes) && !flowSnapshot)
throw new TypeError(
'Road selection requires a flow availability snapshot',
);
if (
roadMode === 'osm' ||
!flowSnapshot ||
(modes.length === 1 && modes[0] === 'osm')
)
return api.requestOsmRoads(box, { ...options, onTile });
const signal = options.signal;
const osm = [];
@@ -332,7 +355,13 @@ export function createTrafficSource({
osmError = error;
}
};
const osmJob = roadMode === 'tomtom' ? null : loadOsm();
// A host without OpenStreetMap waits for the key before fetching fill.
const osmJob =
roadMode === 'tomtom' ||
modes.every((entry) => entry === 'tomtom') ||
!roadModesSimulate(modes)
? null
: loadOsm();
osmJob?.catch(() => {});
// The snapshot waits on the status probe and flow tiles; never let it
// hold the road pass past its own deadline. A late snapshot counts as a
@@ -357,7 +386,21 @@ export function createTrafficSource({
throw new TypeError(
'Road selection requires a flow availability snapshot',
);
mode = resolveRoadMode(roadMode, live.hasKey);
mode = resolveRoadMode(roadMode, live.hasKey, modes);
// A host without OpenStreetMap never simulates: name why no roads draw.
if (!live.hasKey && !roadModesSimulate(modes))
throw Object.assign(
new RoadRequestError(
live.error ||
(live.statusUnavailable
? msg(
'TomTom roads unavailable while the traffic service is unreachable',
)
: msg('TomTom roads need a TomTom key')),
),
// A known missing key cannot recover this session; the rest can.
{ retryable: Boolean(live.error || live.statusUnavailable) },
);
if (mode === 'tomtom' && live.error) throw new RoadRequestError(live.error);
if (mode !== 'osm') {
tomtom = flowSegmentsToRoads(live.segments);
@@ -365,7 +408,7 @@ export function createTrafficSource({
publish(composed(), true);
}
if (osmJob) await osmJob;
else if (mode === 'osm') await loadOsm();
else if (mode !== 'tomtom') await loadOsm();
signal?.throwIfAborted();
if (osmError && (mode === 'osm' || !tomtom.length)) throw osmError;
const data = snapshot(composed(), true);
+21 -2
View File
@@ -1,14 +1,17 @@
import { normalizeRoadMode } from './roadModes.js';
import { normalizeRoadMode, normalizeRoadModes } from './roadModes.js';
import * as Cesium from 'cesium';
import { FLOW_BUCKET_COLORS } from './policy.js';
export function createState({ services }) {
export function createState({ services, source }) {
const layerState = {};
/** The host's offered road sources (createTrafficSource `roadModes`). */
layerState._roadModes = normalizeRoadModes(source?.roadModes);
layerState._roadMode =
typeof location === 'undefined'
? null
: normalizeRoadMode(
new URLSearchParams(location.search).get('trafficRoads'),
layerState._roadModes,
);
// The query override is consumed at construction and shields passive restoration.
layerState._roadModeUrlOverride = layerState._roadMode;
@@ -167,6 +170,14 @@ export function createState({ services }) {
layerState._jamViz = 'density';
/**
* Row toggles (`lines` / `dots` params): flow-colored road lines draped on
* the surface, and the moving dots. Live mode only for lines — the keyless
* simulation has no flow to color them with.
*/
layerState._showLines = false;
layerState._showDots = true;
/**
* Congestion heat-lines are GroundPolylinePrimitive batches draped onto the
* rendered 3D tiles (ClassificationType.CESIUM_3D_TILE). One
@@ -181,6 +192,14 @@ export function createState({ services }) {
layerState._heatSlowPrim = null;
/** @type {Cesium.GroundPolylinePrimitive|null} Free-flow road-line batch (`lines` only). */
layerState._heatFreePrim = null;
/** @type {Cesium.GroundPolylinePrimitive[]} Replaced lines kept until successors are ready. */
layerState._heatRetired = [];
/** @type {number} Heat-lines in the current render (stats). */
layerState._heatLineCount = 0;
+6 -2
View File
@@ -137,10 +137,14 @@ export function createStyle({ state: layerState, services, parts, source }) {
return layerState._jamViz === 'density' || layerState._jamViz === 'both';
}
/** @returns {boolean} Heat-line prototype active. */
/** @returns {boolean} Road lines drawn: the `lines` toggle or the heat-line prototype. */
function heatlineOn() {
return layerState._jamViz === 'heatline' || layerState._jamViz === 'both';
return (
layerState._showLines ||
layerState._jamViz === 'heatline' ||
layerState._jamViz === 'both'
);
}
return {
presetProfileActive,
+1 -1
View File
@@ -253,7 +253,7 @@ export const SHARE_VIEW_FLAGS = freezeAll([
*/
export const SHARE_VIEW_LAYER_OPTION_ABSENT = Object.freeze({
'local-firms': Object.freeze({ minFrp: 0, confidence: 'all' }),
traffic: Object.freeze({ roadMode: null }),
traffic: Object.freeze({ roadMode: null, lines: false, dots: true }),
'weather-lightning': Object.freeze({ opacity: 'strong' }),
'weather-radar': Object.freeze({ opacity: 'strong' }),
'weather-satellite': Object.freeze({
+31 -1
View File
@@ -3,7 +3,10 @@ import assert from 'node:assert/strict';
import http from 'node:http';
import { once } from 'node:events';
import { createGeminiChatHandler } from '../server/providers/gemini/chat.js';
import { createOpenAiChatHandler } from '../server/providers/openai/chat.js';
import {
chatSchema,
createOpenAiChatHandler,
} from '../server/providers/openai/chat.js';
import { GEV_REALTIME_TOOLS } from '../server/providers/openai/tools.js';
import {
realtimeDirectives,
@@ -534,6 +537,11 @@ for (const [name, provider] of Object.entries(providers)) {
assert.equal(offered.includes('osm_query'), overpass);
assert.equal(prompt.includes('osm_query'), overpass);
assert.ok(offered.includes('resolve_area'));
// Chat Completions answers 400 invalid_function_parameters for a
// pattern with Unicode property classes, as resolve_area's area ids
// use; OpenAI chat leaves those patterns out.
if (name === 'openai')
assert.doesNotMatch(JSON.stringify(body.tools), /\\\\[pP]\{/);
}
});
test(`${name} throttled replies say they are rate limited`, async (t) => {
@@ -697,3 +705,25 @@ test('OpenAI empty tool arrays are removed before history is returned', async (t
const body = await (await f.send()).json();
assert.equal(body.content.tool_calls, undefined);
});
test('OpenAI chat schemas drop only the patterns Chat Completions refuses', () => {
assert.match(JSON.stringify(GEV_REALTIME_TOOLS), /\\\\[pP]\{/);
assert.deepEqual(
chatSchema({
type: 'object',
properties: {
id: { type: 'string', pattern: '^ne:[\\p{L}]+$', maxLength: 80 },
code: { type: 'string', pattern: '^[A-Z]{2}$' },
list: { type: 'array', items: [{ pattern: '\\P{N}' }] },
},
}),
{
type: 'object',
properties: {
id: { type: 'string', maxLength: 80 },
code: { type: 'string', pattern: '^[A-Z]{2}$' },
list: { type: 'array', items: [{}] },
},
},
);
});
+1
View File
@@ -1154,6 +1154,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
+17 -1
View File
@@ -4,6 +4,19 @@ import {
contextModeWord,
} from '../contextModePolicy.js';
/** Why a Space Missions entry failed: its mission index never loaded, and
* the error is no older than `since` (an earlier attempt's is not the cause). */
export function spaceMissionsIndexFailure(dataManager, since = 0) {
const stats = dataManager?.layers
?.get('rocket-launches')
?.module?.getStats?.();
return stats?.error && stats.lastUpdate == null && stats.errorAt >= since
? t(
'Space Missions unavailable: the mission index could not load. Try again later.',
)
: null;
}
export async function _runUserFacingContextAction(
operation,
message = t('Context could not restore every layer; try again'),
@@ -19,7 +32,10 @@ export async function _runUserFacingContextAction(
onFailure: (error) => {
if (this.destroyed) return;
console.warn('[Context] user-facing transition failed', error);
this.showToast(localizedMessage || message);
this.showToast(
localizedMessage ||
(typeof message === 'function' ? message() : message),
);
},
});
} finally {
+7 -3
View File
@@ -1,4 +1,5 @@
import { t } from '../i18n/index.js';
import { spaceMissionsIndexFailure } from './contextActions.js';
import { shouldExpandGlobalContextPanel } from '../rightRailPolicy.js';
export function _initGlobalContextPanel() {
@@ -59,12 +60,15 @@ export function _initGlobalContextPanel() {
const nextMode =
this._contextMode === 'space-missions' ? null : 'space-missions';
this._claimContextVisualAuthority();
const startedAt = Date.now();
void this._runUserFacingContextAction(
(notificationToken) =>
this._selectContextMode(nextMode, { notificationToken }),
t(
'Space Missions could not complete the requested transition; try again',
),
() =>
(nextMode && spaceMissionsIndexFailure(this._dataManager, startedAt)) ||
t(
'Space Missions could not complete the requested transition; try again',
),
).then((succeeded) => {
if (
!this.destroyed &&
+9 -4
View File
@@ -9,6 +9,7 @@ import {
spaceMissionEntryCancellationDisposition,
} from '../contextModePolicy.js';
import { localizeLayerName } from './layerNames.js';
import { spaceMissionsIndexFailure } from './contextActions.js';
/**
* A blocked-visibility reason in the UI language. Reasons stay English (voice
@@ -120,10 +121,14 @@ export function _handleContextLayerChange(change) {
return;
}
if (change?.type === 'visibility-failed') {
const failureMessage = t('{layerId} could not {action} cleanly', {
layerId: change.layerId,
action: change.enabled ? t('start') : t('stop'),
});
const failureMessage =
(change.layerId === 'rocket-launches' &&
change.enabled &&
spaceMissionsIndexFailure(this._dataManager)) ||
t('{layerId} could not {action} cleanly', {
layerId: change.layerId,
action: change.enabled ? t('start') : t('stop'),
});
// A failed direct Context-shell START has already had its siblings
// cleared by the visibility guard. Wait outside the synchronous manager
// notification for this queue to settle, then reconcile the complete
+470
View File
@@ -0,0 +1,470 @@
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();
}
/** Treat an interrupted play() (a pause or a new source) as no failure. */
function onPlayFailure(mark) {
return (error) => {
if (error?.name !== 'AbortError') mark();
};
}
/**
* Québec 511 serves a new clip at the same URL every few minutes, so a refresh
* adds a timestamp to get past the browser's media cache.
*/
function mediaRefreshUrl(value) {
const url = new URL(value);
url.searchParams.set('_', String(Date.now()));
return url.href;
}
/**
* Attach the active Québec source to a floating browser player and map
* preview. The browser contacts Québec 511 only after the viewer presses Play
* for that camera; selecting it (by click, auto-hop, a scene or voice) only
* shows its details and the play control.
*/
export function createQuebecCctvOverlay(viewer) {
if (!viewer?.scene || typeof document === 'undefined') return null;
const controller = new AbortController();
const { signal } = controller;
const panel = document.createElement('section');
panel.className = 'qci-panel';
panel.hidden = true;
panel.setAttribute('role', 'region');
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 play = document.createElement('button');
play.type = 'button';
play.className = 'qci-play';
play.textContent = t('Play');
const refresh = document.createElement('button');
refresh.type = 'button';
refresh.textContent = t('Refresh map still');
actions.append(play, 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 = 'none';
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 = '';
/** Camera id the viewer pressed Play for; media loads only for this one. */
let playingCameraId = null;
let mediaRefreshTimer = 0;
let removeRender = null;
let canvasRect = null;
let liveUnavailable = false;
let stillUnavailable = false;
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 startRender() {
if (!removeRender)
removeRender = viewer.scene.postRender.addEventListener(updateMarker);
}
function stopRender() {
removeRender?.();
removeRender = null;
}
function stopMediaRefresh() {
window.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(onPlayFailure(markLiveUnavailable));
refreshStill();
},
2 * 60 * 1000,
);
}
function refreshStill() {
if (!selectedMediaUrl || playingCameraId !== selected?.id) return;
setMedia(still, '');
setMedia(still, mediaRefreshUrl(selectedMediaUrl));
void still.play().catch(onPlayFailure(markStillUnavailable));
}
/** Load and play the selected camera's media: only from a Play click. */
function startPlayback() {
if (!selected || !selectedMediaUrl) return;
playingCameraId = selected.id;
liveUnavailable = false;
stillUnavailable = false;
play.hidden = true;
refresh.hidden = false;
live.hidden = false;
still.hidden = false;
marker.classList.remove('qci-marker-link-only');
note.textContent = t(
'The map still uses a separate request and may show a different moment.',
);
note.hidden = false;
markerLabel.textContent = markerLabelText(selected, true);
setMedia(live, selectedMediaUrl);
setMedia(still, selectedMediaUrl);
void live.play().catch(onPlayFailure(markLiveUnavailable));
void still.play().catch(onPlayFailure(markStillUnavailable));
startMediaRefresh();
}
function setCamera(camera, { reselected = false } = {}) {
if (!isQuebecCamera(camera)) {
selected = null;
selectedPosition = null;
closedWindowCameraId = null;
selectedMediaUrl = '';
playingCameraId = null;
liveUnavailable = false;
stillUnavailable = false;
stopMediaRefresh();
stopRender();
panel.hidden = true;
marker.hidden = true;
setMedia(live, '');
setMedia(still, '');
return;
}
const changed = selected?.id !== camera.id;
if (changed || reselected) closedWindowCameraId = null;
selected = camera;
startRender();
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;
playingCameraId = null;
}
const playing = Boolean(mediaUrl) && playingCameraId === camera.id;
title.textContent = camera.name || t('CCTV camera');
coordinates.textContent = [
`${Number(camera.lat).toFixed(5)}, ${Number(camera.lon).toFixed(5)}`,
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 = !playing;
play.hidden = !mediaUrl || playing;
refresh.hidden = !playing;
actions.hidden = !mediaUrl;
note.hidden = !mediaUrl;
note.textContent = mediaUrl
? !playing
? t('Video plays from Québec 511, which sees your IP address.')
: liveUnavailable
? t('Source unavailable')
: t(
'The map still uses a separate request and may show a different moment.',
)
: sourceUrl
? ''
: t('Source unavailable');
note.hidden = !note.textContent;
sourceLink.href = sourceUrl || '#';
sourceLink.hidden = !sourceUrl;
markerLabel.textContent = stillUnavailable
? t('Source unavailable')
: markerLabelText(camera, playing);
marker.classList.toggle('qci-marker-link-only', !playing);
still.hidden = !playing;
panel.hidden = false;
if (changed || mediaUrl !== selectedMediaUrl) {
selectedMediaUrl = mediaUrl;
// A new camera never inherits playback: the browser stays off 511
// until the viewer presses Play for it.
stopMediaRefresh();
setMedia(live, '');
setMedia(still, '');
} else if (reselected && playing) {
if (live.error || live.networkState === 3 || live.readyState < 2) {
setMedia(live, mediaRefreshUrl(mediaUrl));
}
if (live.paused)
void live.play().catch(onPlayFailure(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;
// Measured once per resize or scroll, not every frame, so the per-frame
// style writes never force a layout.
canvasRect ||= canvas.getBoundingClientRect();
const rect = canvasRect;
marker.style.left = `${rect.left + point.x}px`;
marker.style.top = `${rect.top + point.y}px`;
const distance = Cesium.Cartesian3.distance(
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 });
play.addEventListener('click', startPlayback, { signal });
const resetCanvasRect = () => {
canvasRect = null;
};
window.addEventListener('resize', resetCanvasRect, { signal });
window.addEventListener('scroll', resetCanvasRect, {
signal,
capture: true,
passive: true,
});
panel.addEventListener(
'keydown',
(event) => {
if (event.key === 'Escape') close.click();
},
{ signal },
);
close.addEventListener(
'click',
() => {
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 || playingCameraId !== selected?.id) return;
if (document.hidden) {
live.pause();
still.pause();
} else if (closedWindowCameraId !== selected?.id) {
void live.play().catch(onPlayFailure(markLiveUnavailable));
refreshStill();
}
},
{ signal },
);
return {
setCamera,
dispose() {
controller.abort();
stopMediaRefresh();
stopRender();
setMedia(live, '');
setMedia(still, '');
panel.remove();
marker.remove();
selected = null;
selectedPosition = null;
},
};
}
+192
View File
@@ -0,0 +1,192 @@
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';
const MEDIA_URL =
'https://www.quebec511.info/Carte/Fenetres/camera.ashx?id=3735&format=mp4';
const CAMERA = Object.freeze({
id: 'qc-511-3735',
sourceKind: 'quebec-511-browser',
name: 'Route 136 at rue Panet',
lat: 45.51651,
lon: -73.5502,
positionSource: 'MTQ georeferenced point',
browserMediaUrl: MEDIA_URL,
sourceUrl:
'https://www.quebec511.info/Carte/Fenetres/FenetreVideo.html?id=3735',
});
/**
* Mount the overlay in a happy-dom window with recorded media loads, timers
* and postRender listeners. `play` decides what each play() returns.
*/
function mountOverlay(t, { play = () => Promise.resolve() } = {}) {
const window = new Window({ url: 'http://localhost:4173/' });
const previousWindow = globalThis.window;
const previousDocument = globalThis.document;
globalThis.window = window;
globalThis.document = window.document;
const loads = [];
window.HTMLMediaElement.prototype.load = function () {
if (this.getAttribute('src')) loads.push(this.getAttribute('src'));
};
window.HTMLMediaElement.prototype.play = function () {
return play(this);
};
window.HTMLMediaElement.prototype.pause = function () {};
const intervals = new Map();
let nextTimer = 1;
window.setInterval = (fn) => {
const id = nextTimer++;
intervals.set(id, fn);
return id;
};
window.clearInterval = (id) => intervals.delete(id);
const renderListeners = new Set();
const viewer = {
camera: {
positionWC: Cesium.Cartesian3.fromDegrees(-73.5502, 45.51651, 1000),
},
scene: {
canvas: { clientWidth: 800, clientHeight: 600 },
cartesianToCanvasCoordinates: () => null,
postRender: {
addEventListener(fn) {
renderListeners.add(fn);
return () => renderListeners.delete(fn);
},
},
},
};
const overlay = createQuebecCctvOverlay(viewer);
t.after(() => {
overlay?.dispose();
globalThis.window = previousWindow;
globalThis.document = previousDocument;
window.close();
});
const [live, still] = window.document.querySelectorAll('video');
return {
window,
overlay,
loads,
intervals,
renderListeners,
live,
still,
note: () => window.document.querySelector('.qci-note').textContent,
label: () => window.document.querySelector('.qci-marker-label').textContent,
playButton: () => window.document.querySelector('.qci-play'),
};
}
test('selecting a Québec camera contacts Québec 511 only after Play', (t) => {
const ui = mountOverlay(t);
assert.equal(ui.renderListeners.size, 0, 'no per-frame work before a camera');
ui.overlay.setCamera(CAMERA);
assert.deepEqual(ui.loads, [], 'selection alone loads no 511 media');
assert.equal(ui.intervals.size, 0, 'and starts no refresh timer');
assert.equal(ui.playButton().hidden, false);
assert.match(ui.note(), /sees your IP address/);
assert.equal(ui.renderListeners.size, 1);
ui.playButton().click();
assert.deepEqual(ui.loads, [MEDIA_URL, MEDIA_URL], 'live player and map still');
assert.equal(ui.playButton().hidden, true);
assert.equal(ui.intervals.size, 1);
// A different camera never inherits playback.
ui.loads.length = 0;
ui.overlay.setCamera({
...CAMERA,
id: 'qc-511-4103',
browserMediaUrl: MEDIA_URL.replace('3735', '4103'),
});
assert.deepEqual(ui.loads, []);
assert.equal(ui.intervals.size, 0);
assert.equal(ui.playButton().hidden, false);
ui.overlay.setCamera(null);
assert.equal(ui.renderListeners.size, 0, 'deselect drops the per-frame listener');
});
test('a camera without browser media shows only its provider link', (t) => {
const ui = mountOverlay(t);
ui.overlay.setCamera({ ...CAMERA, browserMediaUrl: '' });
assert.equal(ui.playButton().hidden, true);
assert.deepEqual(ui.loads, []);
const link = ui.window.document.querySelector('.qci-panel a');
assert.equal(link.hidden, false);
assert.equal(link.href, CAMERA.sourceUrl);
});
test('routine same-camera updates keep Québec feed errors visible until recovery', (t) => {
const ui = mountOverlay(t);
ui.overlay.setCamera(CAMERA);
ui.playButton().click();
ui.live.dispatchEvent(new ui.window.Event('error'));
ui.still.dispatchEvent(new ui.window.Event('error'));
ui.overlay.setCamera({ ...CAMERA });
assert.equal(ui.note(), 'Source unavailable');
assert.equal(ui.label(), 'Source unavailable');
ui.live.dispatchEvent(new ui.window.Event('loadeddata'));
ui.still.dispatchEvent(new ui.window.Event('loadeddata'));
assert.match(ui.note(), /separate request/);
assert.match(ui.label(), /^PREVIEW ·/);
});
test('an interrupted play() is not reported as an unavailable source', async (t) => {
const abort = () =>
Promise.reject(new DOMException('interrupted by pause()', 'AbortError'));
const ui = mountOverlay(t, { play: abort });
ui.overlay.setCamera(CAMERA);
ui.playButton().click();
await new Promise((resolve) => setImmediate(resolve));
assert.match(ui.note(), /separate request/);
assert.match(ui.label(), /^PREVIEW ·/);
});
test('a real play() failure is reported', async (t) => {
const fail = () =>
Promise.reject(new DOMException('no supported source', 'NotSupportedError'));
const ui = mountOverlay(t, { play: fail });
ui.overlay.setCamera(CAMERA);
ui.playButton().click();
await new Promise((resolve) => setImmediate(resolve));
assert.equal(ui.note(), 'Source unavailable');
});
test('the refresh timer reloads with a cache-buster, skips hidden tabs, and stops on dispose', (t) => {
const ui = mountOverlay(t);
ui.overlay.setCamera(CAMERA);
ui.playButton().click();
const [refresh] = ui.intervals.values();
ui.loads.length = 0;
Object.defineProperty(ui.window.document, 'hidden', {
configurable: true,
get: () => true,
});
refresh();
assert.deepEqual(ui.loads, [], 'a hidden tab does not refresh');
Object.defineProperty(ui.window.document, 'hidden', {
configurable: true,
get: () => false,
});
refresh();
assert.equal(ui.loads.length, 2);
for (const src of ui.loads) assert.match(src, /[?&]_=\d+$/);
ui.overlay.dispose();
assert.equal(ui.intervals.size, 0);
assert.equal(ui.renderListeners.size, 0);
assert.equal(ui.window.document.querySelector('.qci-panel'), null);
});
+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;
}