Merge remote-tracking branch 'upstream/main' into fix-1237

# Conflicts:
#	README.md
#	docs/CURRENT-STATE.md
This commit is contained in:
Said Turksever committed 2026-10-20 14:54:57 +01:00
commit 1ec21c59ce
12 files changed
+759 -2

No files matched your search

+6
View File
@@ -20,6 +20,7 @@ import {
loadNswSourcesFromOpenData,
loadCalgarySourcesFromOpenData,
loadCataloniaSourcesFromOpenData,
loadHkSourcesFromOpenData,
loadDelDOTSourcesFromOpenData,
loadKingCountySourcesFromOpenData,
loadWsdotSourcesFromOpenData,
@@ -107,6 +108,11 @@ const LIVE_PACKS = [
enabled: () => envEnabled('CCTV_CATALONIA_ENABLED'),
load: loadCataloniaSourcesFromOpenData,
},
{
name: 'hk',
enabled: () => envEnabled('CCTV_HK_ENABLED'),
load: loadHkSourcesFromOpenData,
},
{
name: 'deldot',
enabled: () => envEnabled('CCTV_DELDOT_ENABLED'),
+30
View File
@@ -309,6 +309,36 @@ export const CATALONIA_ANCHORS = [
{ lat: 41.5636, lon: 2.0111 }, // Terrassa
];
/** Hong Kong Transport Department traffic snapshot cameras: keyless English
* locations XML on DATA.GOV.HK; frames are stills on the TD CCTV host. */
export const DEFAULT_HK_CAMERAS_URL =
'https://static.data.gov.hk/td/traffic-snapshot-images/code/Traffic_Camera_Locations_En.xml';
/** The only origin HK camera frames may come from. Verified 2026-10-11: still
* URLs answer 200 on this host with no redirect hop. */
export const HK_IMAGE_ORIGIN = 'https://tdcctv.data.one.gov.hk/';
/** Above the full TD English catalog (~1,013 cameras) so a default install
* keeps nearly all of them. Catalog-wide round-robin still applies when the
* sum of packs exceeds DEFAULT_CCTV_MAX_SOURCES (Norway-style). */
export const DEFAULT_HK_MAX_SOURCES = 1100;
/** Prioritization anchors across Hong Kong Island, Kowloon and the New
* Territories so a lowered cap keeps territory-wide coverage rather than
* only the harbour corridor. */
export const HK_ANCHORS = [
{ lat: 22.2819, lon: 114.158 }, // Central
{ lat: 22.2976, lon: 114.1722 }, // Tsim Sha Tsui
{ lat: 22.3827, lon: 114.19 }, // Sha Tin
{ lat: 22.3701, lon: 114.114 }, // Tsuen Wan
{ lat: 22.4503, lon: 114.1688 }, // Tai Po
{ lat: 22.493, lon: 114.139 }, // Fanling
{ lat: 22.391, lon: 113.977 }, // Tuen Mun
{ lat: 22.4445, lon: 114.022 }, // Yuen Long
{ lat: 22.307, lon: 114.26 }, // Tseung Kwan O
];
/** Hard ceiling on the locations XML body. The live English list is ~400 KB
* for ~1,000 cameras; this only exists so an unbounded upstream cannot be
* buffered without limit. */
export const HK_MAX_CATALOG_BYTES = 4 * 1024 * 1024;
/** Statens vegvesen (Norway) road cameras: the keyless OGC API Features view
* of the official DATEX 3.1 CCTV site table, one GeoJSON request for the whole
* country (~900 cameras). The raw DATEX node needs an account; this view does
+11
View File
@@ -399,6 +399,17 @@ export function isLikelyNycCoordinate(lat, lon) {
);
}
/** Hong Kong SAR extent, with slack for the outlying islands. */
export function isLikelyHongKongCoordinate(lat, lon) {
return (
isPlausibleLatLon(lat, lon) &&
lat >= 22.15 &&
lat <= 22.6 &&
lon >= 113.8 &&
lon <= 114.45
);
}
/** Mainland Norway, with slack for the coast and the Finnmark border. */
export function isLikelyNorwayCoordinate(lat, lon) {
return (
+226
View File
@@ -76,6 +76,11 @@ import {
TORONTO_DOWNTOWN,
TORONTO_GROUND_ELEVATION_M,
TORONTO_MAX_CATALOG_BYTES,
DEFAULT_HK_CAMERAS_URL,
HK_IMAGE_ORIGIN,
DEFAULT_HK_MAX_SOURCES,
HK_ANCHORS,
HK_MAX_CATALOG_BYTES,
CCTV_SOURCE_FETCH_TIMEOUT_MS,
TARKTEE_FETCH_TIMEOUT_MS,
DELDOT_CCTV_URL,
@@ -138,6 +143,7 @@ import {
isLikelyCataloniaCoordinate,
decodeNumericEntities,
isLikelyNycCoordinate,
isLikelyHongKongCoordinate,
isLikelyNorwayCoordinate,
isLikelyTorontoCoordinate,
cameraDisplayCode,
@@ -2003,6 +2009,226 @@ export async function loadCataloniaSourcesFromOpenData() {
}
}
/**
* Read one XML child tag's text from an `<image>` block.
*
* @param {string} body
* @param {string} tag
* @returns {string}
*/
function hkXmlTag(body, tag) {
const match = new RegExp(`<${tag}>\\s*([^<]*?)\\s*</${tag}>`, 'i').exec(
String(body || ''),
);
return match ? match[1].trim() : '';
}
/**
* Parse the Transport Department English camera-locations XML into rows.
*
* Schema-specific: each `<image>` carries key, description, lat/lon and a
* still URL. No XML dependency — same regex style as the Tarktee DATEX parser.
*
* @param {string} xml
* @returns {Array<object>}
*/
export function parseHkCameraLocationsXml(xml) {
const rows = [];
const blockRe = /<image>([\s\S]*?)<\/image>/gi;
let match;
while ((match = blockRe.exec(String(xml || ''))) !== null) {
const body = match[1];
rows.push({
key: hkXmlTag(body, 'key'),
region: hkXmlTag(body, 'region'),
district: hkXmlTag(body, 'district'),
description: hkXmlTag(body, 'description'),
latitude: hkXmlTag(body, 'latitude'),
longitude: hkXmlTag(body, 'longitude'),
url: hkXmlTag(body, 'url'),
});
}
return rows;
}
/**
* Build a host-pinned still URL from a Transport Department camera key.
*
* The catalog also publishes a URL; the registered frame is always rebuilt
* from the key so a catalog edit cannot steer the proxy off-origin.
*
* @param {string|null|undefined} key
* @returns {?string}
*/
export function hkImageUrlFromKey(key) {
const id = String(key ?? '')
.trim()
.toUpperCase();
if (!/^[A-Z0-9]{1,32}$/.test(id)) return null;
return `${HK_IMAGE_ORIGIN}${id}.JPG`;
}
/**
* Pin a catalog frame URL to the Transport Department still host.
*
* @param {string|null|undefined} raw
* @returns {?string}
*/
export function normalizeHkImageUrl(raw) {
const text = String(raw ?? '').trim();
if (!text) return null;
let parsed;
try {
parsed = new URL(text);
} catch {
return null;
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null;
parsed.protocol = 'https:';
const upgraded = parsed.toString();
return upgraded.startsWith(HK_IMAGE_ORIGIN) ? upgraded : null;
}
/**
* Label for one HK camera: the published English description, else the key.
*
* @param {object} record
* @param {string} cameraId
* @returns {string}
*/
export function hkCameraName(record, cameraId) {
const description = String(record?.description ?? '').trim();
if (description) return description;
const key = String(record?.key ?? '').trim();
if (key) return key;
return `HK Camera ${String(cameraId).replace(/^hk-/, '')}`;
}
/**
* One Transport Department locations row -> one catalog source, or null.
*
* The dataset publishes no camera facing, so headings use the shared id-hash
* fallback at low confidence and are corrected with the calibration gizmo.
*
* @param {object} record
* @returns {?object}
*/
export function hkCameraToSource(record) {
if (!record || typeof record !== 'object') return null;
const key = String(record.key ?? '')
.trim()
.toUpperCase();
if (!key) return null;
const lat = toFiniteNumber(record.latitude);
const lon = toFiniteNumber(record.longitude);
if (!isLikelyHongKongCoordinate(lat, lon)) return null;
const imageUrl = hkImageUrlFromKey(key);
if (!imageUrl) return null;
// If the catalog carries a URL, it must already be on the pinned host.
const catalogUrl = String(record.url ?? '').trim();
if (catalogUrl) {
const pinnedCatalog = normalizeHkImageUrl(catalogUrl);
if (!pinnedCatalog) return null;
}
const cameraId = `hk-${key.toLowerCase()}`;
const name = hkCameraName(record, cameraId);
const region = String(record.region ?? '').trim();
return {
id: cameraId,
name,
city: region || 'Hong Kong',
cityId: 'hk',
provider: 'Transport Department',
lat,
lon,
headingDeg: fallbackHeadingFromId(cameraId),
headingConfidence: 'low',
pitchDeg: -18,
fovDeg: 44,
rangeM: 145,
mountHeightM: 8,
// Coastal / harbour prior; the client's one-shot ground snap corrects.
groundElevationM: 15,
feedType: 'image',
url: imageUrl,
snapshotUrl: imageUrl,
sourceKind: 'hk-td-open-data',
license:
'Hong Kong Transport Department / DATA.GOV.HK — attribution required',
code: cameraDisplayCode(name.toUpperCase()),
};
}
/**
* Fetch Hong Kong Transport Department traffic snapshot cameras from the
* keyless DATA.GOV.HK English locations XML. Frames are stills on
* tdcctv.data.one.gov.hk.
*
* @returns {Promise<Array<object>>} Normalized camera source objects.
*/
export async function loadHkSourcesFromOpenData() {
try {
const endpoint = process.env.CCTV_HK_CAMERAS_URL || DEFAULT_HK_CAMERAS_URL;
const resp = await fetch(endpoint, {
headers: { Accept: 'application/xml,text/xml,*/*' },
redirect: 'manual',
signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
});
const discard = async () => {
try {
await resp.body?.cancel();
} catch {
/* no-op */
}
return [];
};
if (resp.status >= 300 && resp.status < 400) {
console.warn('[CCTV] HK catalog redirected; redirects are not followed');
return discard();
}
if (!resp.ok) {
console.warn('[CCTV] HK camera download failed:', resp.status);
return discard();
}
let xml;
try {
xml = await readResponseTextCapped(resp, HK_MAX_CATALOG_BYTES);
} catch (error) {
if (error?.code === 'RESPONSE_TOO_LARGE') {
console.warn('[CCTV] HK catalog body exceeded byte ceiling');
return [];
}
throw error;
}
const rows = parseHkCameraLocationsXml(xml);
const cameras = [];
const seen = new Set();
for (const record of rows) {
const camera = hkCameraToSource(record);
if (!camera || seen.has(camera.id)) continue;
seen.add(camera.id);
cameras.push(camera);
}
const maxRaw = Number(
process.env.CCTV_HK_MAX_SOURCES || DEFAULT_HK_MAX_SOURCES,
);
const maxCount = Number.isFinite(maxRaw)
? Math.max(8, Math.min(1200, Math.floor(maxRaw)))
: DEFAULT_HK_MAX_SOURCES;
const prioritized = prioritizeSources(cameras, maxCount, HK_ANCHORS);
console.log(
`[CCTV] Loaded HK camera sources: ${cameras.length} (using nearest ${prioritized.length})`,
);
return prioritized;
} catch (error) {
console.warn('[CCTV] HK camera download error:', error?.message || error);
return [];
}
}
export async function loadDelDOTSourcesFromOpenData() {
try {
const resp = await fetch(DELDOT_CCTV_URL, {