feat(cctv): add Catalonia (Servei Català de Trànsit) as a keyless CCTV source pack

One WFS/GML XML feed (~135 cameras) aggregating three publishers under a
`font` field: SCT's own highway cameras plus hotlinked Barcelona (IMI) and
Terrassa municipal cameras. Frame URLs are checked against a small host
allowlist rather than one pinned origin, and every non-SCT camera carries
its real owner as a `credit` shown beside the shared provider. The feed
carries no compass heading, so every camera loads at low heading confidence
(same as TfL and Fintraffic), and ground elevation is primed per publisher
rather than one flat prior.

SCT's own frame link needed a workaround: RenderService always 302s to a
second endpoint (TransitCamera) on a Location the upstream hardcodes as
plain http even when requested over https — refused by fetchWithinHost's
same-origin (scheme-included) redirect policy, by design (stops an upstream
steering a host-pinned request into a plaintext downgrade). Separately,
mct.gencat.cat's TLS setup fails the handshake with Node's fetch outright
(ERR_SSL_WRONG_SIGNATURE_TYPE) even though other HTTP clients complete it.
The loader builds the TransitCamera URL itself, over plain http, from
RenderService's sctidcam id, instead of registering the broken upstream
link — the only transport this host actually serves to this runtime.

Verified against the live feed and the production fetch path
(fetchCctvImageFromUpstream): all 135 cameras return real image bytes, 0
false positives. Ambient-card refresh cadence added to src/data/cctvLod.js
(3 minutes), matching the upstream update rate measured empirically (no
change inside 64s, changed within ~90-150s) — the same cadence already used
for Caltrans/TfL/Ontario/TxDOT.

CCTV_CATALONIA_MAX_SOURCES sets the cap and CCTV_CATALONIA_ENABLED=0 turns
the pack off. No overlap with any existing pack (checked catalog.js and the
git history for Spain/Catalonia/Barcelona coverage: none exists).

Verified before this commit: format, check:boundaries, unit suite (3692
tests), and build all green.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
ulihhhhandClaude Sonnet 5 committed 2026-09-15 18:07:40 +02:00
1 parent 2746ec753c
commit ae661db7ab
13 files changed
+627 -1

No files matched your search

+6
View File
@@ -17,6 +17,7 @@ import {
loadWarendorfSourcesFromCatalog,
loadNswSourcesFromOpenData,
loadCalgarySourcesFromOpenData,
loadCataloniaSourcesFromOpenData,
} from './sources.js';
/** Env kill switch: unset or anything but "0" means enabled. */
@@ -86,6 +87,11 @@ const LIVE_PACKS = [
enabled: () => envEnabled('CCTV_CALGARY_ENABLED'),
load: loadCalgarySourcesFromOpenData,
},
{
name: 'catalonia',
enabled: () => envEnabled('CCTV_CATALONIA_ENABLED'),
load: loadCataloniaSourcesFromOpenData,
},
];
/**
* Load CCTV sources from a local JSON file (CCTV_SOURCES_FILE env or default).
+41
View File
@@ -236,6 +236,47 @@ export const CALGARY_DOWNTOWN = { lat: 51.0461, lon: -114.0626 };
* body cannot be buffered without limit. */
export const CALGARY_MAX_CATALOG_BYTES = 4 * 1024 * 1024;
/**
* Catalonia traffic cameras (Servei Català de Trànsit): one keyless WFS/GML
* XML list (verified 2026-09-15, hourly catalog refresh). The feed carries a
* `font` field naming the actual publisher per camera — SCT's own highway
* cameras plus hotlinked Barcelona (IMI) and Terrassa municipal cameras are
* kept, each confirmed to return a real frame. Frame URLs are checked
* against a host allowlist rather than one pinned origin, since each
* publisher serves from its own host.
*/
export const CATALONIA_CAMERAS_URL =
'http://www.gencat.cat/transit/opendata/cameres.xml';
/** Allowed image hosts, one per kept `font` publisher in the feed. */
export const CATALONIA_IMAGE_HOSTS = Object.freeze(
new Set([
'mct.gencat.cat', // SCT
'www.bcn.cat', // IMI (Ajuntament de Barcelona)
'emap.terrassa.cat', // Ajuntament de Terrassa
]),
);
/** Per-`font` partner credit, shown beside the provider in the CCTV panel
* (same pattern as DriveBC's partner-supplied cameras). SCT is the feed's
* own publisher and carries no separate credit. */
export const CATALONIA_FONT_CREDIT = Object.freeze({
IMI: 'Ajuntament de Barcelona',
Terrassa: 'Ajuntament de Terrassa',
});
/** Ground-elevation priors in metres, by `font`: the feed carries no
* elevation, and this pack spans sea level (Barcelona) to Terrassa's plateau
* (~270 m). The client's ground snap corrects these on 3D-tile stacks. */
export const CATALONIA_FONT_ELEVATION_M = Object.freeze({
SCT: 80,
IMI: 20,
Terrassa: 270,
});
export const DEFAULT_CATALONIA_MAX_SOURCES = 150;
/** Prioritization anchors: Barcelona and Terrassa. */
export const CATALONIA_ANCHORS = [
{ lat: 41.3874, lon: 2.1686 }, // Barcelona
{ lat: 41.5636, lon: 2.0111 }, // Terrassa
];
/** Camera CATALOGS change rarely; 15 min keeps multi-megabyte upstream list refetches (Austin rows.json + 4 Caltrans districts + TfL + Ontario 511) infrequent. Frames are fetched per-request and are unaffected. */
export const CCTV_SOURCE_CACHE_MS = 15 * 60 * 1000;
/** Per-provider catalog-fetch timeout. Bounds the worst-case refresh so one
+30
View File
@@ -372,6 +372,36 @@ export function isLikelyFinlandCoordinate(lat, lon) {
return lat >= 59.5 && lat <= 70.5 && lon >= 19 && lon <= 32;
}
/** Catalonia bounding box (mainland is roughly 40.5..42.9 N, 0.15..3.4 E). */
export function isLikelyCataloniaCoordinate(lat, lon) {
return (
isPlausibleLatLon(lat, lon) &&
lat >= 40.3 &&
lat <= 42.9 &&
lon >= 0.0 &&
lon <= 3.4
);
}
/**
* Decode decimal numeric HTML character references ("&#237;" -> "í"), the
* entity form the Catalonia feed uses for accented place names, plus the
* handful of named entities XML/HTML text commonly carries.
*
* @param {string} text
* @returns {string}
*/
export function decodeNumericEntities(text) {
return String(text || '')
.replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code)))
.replace(/&amp;/g, '&')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.trim();
}
/**
* Human label for one Fintraffic preset (camera view).
*
+203
View File
@@ -55,6 +55,12 @@ import {
DEFAULT_CALGARY_MAX_SOURCES,
CALGARY_DOWNTOWN,
CALGARY_MAX_CATALOG_BYTES,
CATALONIA_CAMERAS_URL,
CATALONIA_IMAGE_HOSTS,
CATALONIA_FONT_CREDIT,
CATALONIA_FONT_ELEVATION_M,
DEFAULT_CATALONIA_MAX_SOURCES,
CATALONIA_ANCHORS,
CCTV_SOURCE_FETCH_TIMEOUT_MS,
} from './constants.js';
import {
@@ -73,6 +79,8 @@ import {
isLikelyTexasCoordinate,
isLikelyNswCoordinate,
isLikelyCalgaryCoordinate,
isLikelyCataloniaCoordinate,
decodeNumericEntities,
cameraDisplayCode,
rowArrayToObject,
prioritizeSources,
@@ -1594,3 +1602,198 @@ export async function loadCalgarySourcesFromOpenData() {
return [];
}
}
/**
* Parse the Catalonia (`cameres.xml`) WFS/GML feed into raw feature records.
* Regex-based rather than a full XML parser — the feed is small (~160
* features) and this avoids a new dependency for one field set per feature.
*
* @param {string} xml
* @returns {Array<{fid:string, lat:number, lon:number, carretera:string, municipi:string, pk:string, link:string, font:string}>}
*/
export function parseCataloniaXml(xml) {
const out = [];
const blockRe = /<gml:featureMember>([\s\S]*?)<\/gml:featureMember>/g;
const tag = (block, name) => {
const match = new RegExp(`<cite:${name}>([^<]*)<\\/cite:${name}>`).exec(
block,
);
return match ? decodeNumericEntities(match[1]) : '';
};
let match;
while ((match = blockRe.exec(String(xml || ''))) !== null) {
const block = match[1];
const fidMatch = /<cite:cameres\s+fid="([^"]+)"/.exec(block);
const coordsMatch = /<gml:coordinates[^>]*>([^<]+)<\/gml:coordinates>/.exec(
block,
);
if (!fidMatch || !coordsMatch) continue;
const [lonRaw, latRaw] = coordsMatch[1].split(',');
out.push({
fid: fidMatch[1],
lon: toFiniteNumber(lonRaw),
lat: toFiniteNumber(latRaw),
carretera: tag(block, 'carretera'),
municipi: tag(block, 'municipi'),
pk: tag(block, 'pk'),
link: tag(block, 'link'),
font: tag(block, 'font'),
});
}
return out;
}
/**
* Validate and normalize one Catalonia feed `link` against the publisher
* host allowlist (CATALONIA_IMAGE_HOSTS), upgrading to https. Rejects
* anything else, including a host that merely resembles an allowed one.
*
* SCT's own `RenderService` frame link is a special case, for two stacked
* reasons (both verified 2026-09-15):
* 1. It always 302s to `TransitCamera`, on a Location the upstream
* hardcodes as plain http even when RenderService itself was requested
* over https. fetchWithinHost (media.js) only follows same-origin
* redirects — same scheme included, by design, to stop an upstream
* steering a host-pinned request into a plaintext downgrade — so that
* hop is refused and the camera would render as a placeholder.
* 2. mct.gencat.cat's TLS setup itself does not complete a handshake with
* Node's fetch/OpenSSL (`ERR_SSL_WRONG_SIGNATURE_TYPE`) even though it
* is reachable over https from other clients (e.g. curl) — an upstream
* server misconfiguration this proxy cannot route around.
* TransitCamera answers the frame directly over **http**, no redirect, and
* that is the only transport this host actually serves to this runtime — so
* this builds that URL itself from RenderService's `sctidcam` id instead of
* registering the (https, then-redirecting) RenderService link.
*
* @param {string} rawUrl
* @returns {string} Normalized URL, or '' if not accepted.
*/
export function normalizeCataloniaImageUrl(rawUrl) {
let parsed;
try {
parsed = new URL(String(rawUrl || '').trim());
} catch {
return '';
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return '';
if (!CATALONIA_IMAGE_HOSTS.has(parsed.hostname)) return '';
if (parsed.hostname === 'mct.gencat.cat') {
const camId = parsed.searchParams.get('sctidcam');
if (!camId) return '';
const direct = new URL('http://mct.gencat.cat/mct2bo/TransitCamera');
direct.searchParams.set('nom', camId);
direct.searchParams.set('visualitzacio', 'imatge');
return direct.toString();
}
parsed.protocol = 'https:';
return parsed.toString();
}
/**
* Human label for one Catalonia camera: "<road/place> (<municipality>)",
* skipping the parenthetical when the municipality is already named in the
* road/place text or when one half is missing.
*
* @param {string} carretera
* @param {string} municipi
* @returns {string}
*/
export function cataloniaCameraName(carretera, municipi) {
const road = String(carretera || '').trim();
const city = String(municipi || '').trim();
if (road && city) {
return road.toLowerCase().includes(city.toLowerCase())
? road
: `${road} (${city})`;
}
return road || city || 'Càmera de trànsit';
}
/**
* Fetch Catalonia traffic cameras (Servei Català de Trànsit), keyless: one
* WFS/GML XML list. The feed carries a `font` field naming the real
* publisher per camera; only SCT's own highway cameras and the hotlinked
* Barcelona (IMI) and Terrassa municipal cameras are kept, each confirmed to
* return a real frame — frame URLs are checked against a host allowlist
* (CATALONIA_IMAGE_HOSTS), so a row from any other publisher is dropped.
* Each non-SCT camera carries its publisher as a `credit` alongside the
* shared `provider`. The feed carries no heading, so every camera takes the
* id-hash fallback, low-confidence pose (same as TfL and Fintraffic).
*
* @returns {Promise<Array<object>>} Normalized camera source objects.
*/
export async function loadCataloniaSourcesFromOpenData() {
try {
const resp = await fetch(CATALONIA_CAMERAS_URL, {
headers: { Accept: 'application/xml,text/xml,*/*' },
signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
});
if (!resp.ok) {
console.warn('[CCTV] Catalonia camera download failed:', resp.status);
return [];
}
const xml = await resp.text();
const rows = parseCataloniaXml(xml);
if (!rows.length) return [];
const cameras = [];
for (const row of rows) {
if (!row.fid) continue;
if (!isLikelyCataloniaCoordinate(row.lat, row.lon)) continue;
const imageUrl = normalizeCataloniaImageUrl(row.link);
if (!imageUrl) continue;
const font = row.font.trim();
const cameraId = `cat-${row.fid
.replace(/^cameres\.fid-/, '')
.replace(/[^a-zA-Z0-9]/g, '')}`;
cameras.push({
id: cameraId,
name: cataloniaCameraName(row.carretera, row.municipi),
city: row.municipi.trim() || 'Catalunya',
cityId: 'catalunya',
provider: 'Servei Català de Trànsit',
lat: row.lat,
lon: row.lon,
headingDeg: fallbackHeadingFromId(cameraId),
headingConfidence: 'low',
pitchDeg: -18,
fovDeg: 44,
rangeM: 145,
mountHeightM: 8,
groundElevationM: CATALONIA_FONT_ELEVATION_M[font] ?? 80,
feedType: 'image',
url: imageUrl,
snapshotUrl: imageUrl,
sourceKind: 'catalonia-open-data',
license:
"Generalitat de Catalunya — Llicència oberta d'ús d'informació",
credit: CATALONIA_FONT_CREDIT[font] || '',
});
}
const unique = Array.from(
new Map(cameras.map((camera) => [camera.id, camera])).values(),
);
const maxRaw = Number(
process.env.CCTV_CATALONIA_MAX_SOURCES || DEFAULT_CATALONIA_MAX_SOURCES,
);
const maxCount = Number.isFinite(maxRaw)
? Math.max(8, Math.min(600, Math.floor(maxRaw)))
: DEFAULT_CATALONIA_MAX_SOURCES;
const prioritized = prioritizeSources(unique, maxCount, CATALONIA_ANCHORS);
console.log(
`[CCTV] Loaded Catalonia camera sources: ${unique.length} (using nearest ${prioritized.length})`,
);
return prioritized;
} catch (error) {
console.warn(
'[CCTV] Catalonia camera download error:',
error?.message || error,
);
return [];
}
}