mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
feat: add Hong Kong Transport Department CCTV pack
Fetch keyless DATA.GOV.HK camera locations at runtime and pin stills to tdcctv.data.one.gov.hk with required attribution. Default cap 1,100 covers the ~1,013-camera TD catalog, prioritized across Hong Kong Island, Kowloon and the New Territories. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
1 parent
f882626612
commit
22c22d1876
10 files changed
+749
-2
No files matched your search
@@ -19,6 +19,7 @@ import {
|
||||
loadNswSourcesFromOpenData,
|
||||
loadCalgarySourcesFromOpenData,
|
||||
loadCataloniaSourcesFromOpenData,
|
||||
loadHkSourcesFromOpenData,
|
||||
loadDelDOTSourcesFromOpenData,
|
||||
loadKingCountySourcesFromOpenData,
|
||||
loadWsdotSourcesFromOpenData,
|
||||
@@ -106,6 +107,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'),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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, {
|
||||
|
||||
Reference in new issue
Block a user