mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-12 06:12:18 +00:00
feat(cctv): add Iceland (Vegagerðin / IRCA) as a keyless CCTV source pack
One JSON list from gagnaveita.vegagerdin.is (about 500 camera views at roughly
170 road stations). Frames are JPEG stills in a single directory on
www.vegagerdin.is; only links inside that directory are registered, and the
media proxy pins the same directory.
The list decides ids, coordinates and which frame path is proxied, so it is
fetched over https with redirects refused and the body read capped at 2 MiB;
every rejection path releases the response body. Camera ids come from the frame
file name, which is unique per view in the live feed, and repeated frames
collapse before the cap.
The feed's view description (`Skyring`) is the label. An explicit Icelandic
facing ("til vesturs", "í suður", "séð austur") sets a high-confidence heading
(264 of 499 views); a view that states none, or two different ones, keeps the
id-hash low-confidence pose. A bare road direction ("Miklabraut austur") is not
read as a facing.
IRCA publishes road camera images under CC BY 4.0 and asks that the licensor is
acknowledged without implying endorsement; the attribution is in the Data
attribution popover and DATA_SOURCES.md, and every camera carries the licence
string. Default cap is 250 with anchors at Reykjavík and four regional centres so
a cap keeps every region. CCTV_ICELAND_ENABLED=0 turns the pack off.
Verified against the live feed through fetchCctvImageFromUpstream: 499 of 499
frames returned a JPEG, all distinct, all under 20 minutes old.
This commit is contained in:
1 parent
6e4da23cf7
commit
021e887f94
11 files changed
+554
-2
No files matched your search
@@ -20,6 +20,7 @@ import {
|
||||
loadNswSourcesFromOpenData,
|
||||
loadCalgarySourcesFromOpenData,
|
||||
loadCataloniaSourcesFromOpenData,
|
||||
loadIcelandSourcesFromOpenData,
|
||||
loadHkSourcesFromOpenData,
|
||||
loadDelDOTSourcesFromOpenData,
|
||||
loadKingCountySourcesFromOpenData,
|
||||
@@ -110,6 +111,11 @@ const LIVE_PACKS = [
|
||||
enabled: () => envEnabled('CCTV_CATALONIA_ENABLED'),
|
||||
load: loadCataloniaSourcesFromOpenData,
|
||||
},
|
||||
{
|
||||
name: 'iceland',
|
||||
enabled: () => envEnabled('CCTV_ICELAND_ENABLED'),
|
||||
load: loadIcelandSourcesFromOpenData,
|
||||
},
|
||||
{
|
||||
name: 'hk',
|
||||
enabled: () => envEnabled('CCTV_HK_ENABLED'),
|
||||
|
||||
@@ -309,6 +309,37 @@ export const CATALONIA_ANCHORS = [
|
||||
{ lat: 41.5636, lon: 2.0111 }, // Terrassa
|
||||
];
|
||||
|
||||
/**
|
||||
* Icelandic Road and Coastal Administration (Vegagerðin, IRCA) road cameras:
|
||||
* one keyless JSON list, one row per camera view (about 500 views at roughly
|
||||
* 170 stations, verified 2026-11-06). Frames are stills on the IRCA host, each
|
||||
* rewritten by the publisher every few minutes. Camera images are published
|
||||
* under CC BY 4.0 (IRCA terms of use, "Open data" section).
|
||||
*/
|
||||
export const ICELAND_CAMERAS_URL =
|
||||
'https://gagnaveita.vegagerdin.is/api/vefmyndavelar2014_1';
|
||||
/** The only directory Iceland camera frames may come from. */
|
||||
export const ICELAND_IMAGE_ORIGIN =
|
||||
'https://www.vegagerdin.is/vgdata/vefmyndavelar/';
|
||||
/** Byte ceiling for the Iceland catalog read (~150 KB observed 2026-11-06). */
|
||||
export const ICELAND_MAX_CATALOG_BYTES = 2 * 1024 * 1024;
|
||||
/** Ground-elevation prior in metres: the feed carries none, and the country
|
||||
* runs from sea level to highland passes. The client's ground snap corrects
|
||||
* this on 3D-tile stacks. */
|
||||
export const ICELAND_GROUND_ELEVATION_M = 60;
|
||||
/** The feed lists about 500 views; the default keeps half so the shared
|
||||
* catalog cap still leaves room for denser regions. */
|
||||
export const DEFAULT_ICELAND_MAX_SOURCES = 250;
|
||||
/** Prioritization anchors: Reykjavík and the regional centres, so a cap keeps
|
||||
* every region's nearest cameras instead of only the capital's. */
|
||||
export const ICELAND_ANCHORS = [
|
||||
{ lat: 64.1466, lon: -21.9426 }, // Reykjavík
|
||||
{ lat: 65.6885, lon: -18.1262 }, // Akureyri
|
||||
{ lat: 65.2639, lon: -14.3948 }, // Egilsstaðir
|
||||
{ lat: 66.0749, lon: -23.1351 }, // Ísafjörður
|
||||
{ lat: 63.9331, lon: -20.9971 }, // Selfoss
|
||||
];
|
||||
|
||||
/** 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 =
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
NSW_IMAGE_USER_AGENT,
|
||||
TALLINN_IMAGE_ORIGIN,
|
||||
TARKTEE_IMAGE_ORIGIN,
|
||||
ICELAND_IMAGE_ORIGIN,
|
||||
TFL_IMAGE_ORIGIN,
|
||||
WARENDORF_IMAGE_ORIGINS,
|
||||
WSDOT_IMAGE_ORIGIN,
|
||||
@@ -523,6 +524,7 @@ for (const prefix of [
|
||||
TFL_IMAGE_ORIGIN,
|
||||
TALLINN_IMAGE_ORIGIN,
|
||||
TARKTEE_IMAGE_ORIGIN,
|
||||
ICELAND_IMAGE_ORIGIN,
|
||||
...WARENDORF_IMAGE_ORIGINS,
|
||||
NSW_IMAGE_ORIGIN,
|
||||
CALGARY_IMAGE_ORIGIN,
|
||||
|
||||
@@ -460,6 +460,17 @@ export function isLikelyCataloniaCoordinate(lat, lon) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Iceland bounding box (mainland and nearby islands, with a margin). */
|
||||
export function isLikelyIcelandCoordinate(lat, lon) {
|
||||
return (
|
||||
isPlausibleLatLon(lat, lon) &&
|
||||
lat >= 63.0 &&
|
||||
lat <= 66.7 &&
|
||||
lon >= -25.0 &&
|
||||
lon <= -13.0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode numeric HTML character references, decimal ("í") and hex
|
||||
* ("í") alike, the entity form the Catalonia feed uses for accented
|
||||
|
||||
@@ -134,6 +134,12 @@ import {
|
||||
HOUSTON_MAX_CATALOG_BYTES,
|
||||
HOUSTON_GROUND_ELEVATION_M,
|
||||
HOUSTON_ANCHORS,
|
||||
ICELAND_CAMERAS_URL,
|
||||
ICELAND_IMAGE_ORIGIN,
|
||||
ICELAND_MAX_CATALOG_BYTES,
|
||||
ICELAND_GROUND_ELEVATION_M,
|
||||
DEFAULT_ICELAND_MAX_SOURCES,
|
||||
ICELAND_ANCHORS,
|
||||
} from './constants.js';
|
||||
import {
|
||||
toFiniteNumber,
|
||||
@@ -152,6 +158,7 @@ import {
|
||||
isLikelyCalgaryCoordinate,
|
||||
isLikelyFloridaCoordinate,
|
||||
isLikelyCataloniaCoordinate,
|
||||
isLikelyIcelandCoordinate,
|
||||
decodeNumericEntities,
|
||||
isLikelyNycCoordinate,
|
||||
isLikelyHoustonCoordinate,
|
||||
@@ -2029,6 +2036,163 @@ export async function loadCataloniaSourcesFromOpenData() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compass headings for Icelandic direction words, as they appear after "til"
|
||||
* (genitive: "til vesturs"), "í" (accusative: "í vestur") or "séð" ("seen",
|
||||
* as in "séð austur").
|
||||
*/
|
||||
const ICELAND_DIRECTION_HEADINGS = {
|
||||
norður: 0,
|
||||
norðaustur: 45,
|
||||
austur: 90,
|
||||
suðaustur: 135,
|
||||
suður: 180,
|
||||
suðvestur: 225,
|
||||
vestur: 270,
|
||||
norðvestur: 315,
|
||||
};
|
||||
const ICELAND_DIRECTION_RE =
|
||||
/(?<![\p{L}])(?:til|í|séð)\s+(norðaustur|norðvestur|suðaustur|suðvestur|norður|suður|austur|vestur)s?(?![\p{L}])/gu;
|
||||
|
||||
/**
|
||||
* The compass heading an Icelandic view description states, or NaN. Only an
|
||||
* explicit "til <átt>", "í <átt>" or "séð <átt>" counts: a bare place name or
|
||||
* a road direction ("Miklabraut austur") is not a facing. A description naming two
|
||||
* different directions is ambiguous and yields NaN, and "niður á veg" (down
|
||||
* onto the road) states none.
|
||||
*
|
||||
* @param {string} text - The feed's `Skyring` field.
|
||||
* @returns {number} Heading in degrees [0..360), or NaN.
|
||||
*/
|
||||
export function icelandHeadingFromDescription(text) {
|
||||
const headings = new Set();
|
||||
for (const match of String(text || '')
|
||||
.toLowerCase()
|
||||
.matchAll(ICELAND_DIRECTION_RE)) {
|
||||
headings.add(ICELAND_DIRECTION_HEADINGS[match[1]]);
|
||||
}
|
||||
return headings.size === 1 ? headings.values().next().value : NaN;
|
||||
}
|
||||
|
||||
/**
|
||||
* One Vegagerðin camera row -> one catalog source, or null. A row is one camera
|
||||
* view; a station (`Maelist_nr`) carries several. The id comes from the frame
|
||||
* file name, which is unique per view in the live feed.
|
||||
*
|
||||
* @param {object} row - Element of the `vefmyndavelar2014_1` array.
|
||||
* @returns {?object}
|
||||
*/
|
||||
export function icelandCameraToSource(row) {
|
||||
const url = pinnedCameraUrl(row?.Slod, ICELAND_IMAGE_ORIGIN);
|
||||
if (!url || !/\.jpe?g$/i.test(new URL(url).pathname)) return null;
|
||||
// Numbers only: Number(null) and Number('') are 0.
|
||||
const lat = typeof row.Breidd === 'number' ? row.Breidd : NaN;
|
||||
const lon = typeof row.Lengd === 'number' ? row.Lengd : NaN;
|
||||
if (!isLikelyIcelandCoordinate(lat, lon)) return null;
|
||||
const stem = new URL(url).pathname
|
||||
.slice(new URL(ICELAND_IMAGE_ORIGIN).pathname.length)
|
||||
.replace(/\.jpe?g$/i, '')
|
||||
.toLowerCase();
|
||||
if (!/^[a-z0-9_-]+$/.test(stem)) return null;
|
||||
const cameraId = `is-vegagerdin-${stem}`;
|
||||
const description = String(row.Skyring || '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/\b(\p{L}+) \1\b/giu, '$1')
|
||||
.trim();
|
||||
const station = String(row.Myndavel || '').trim();
|
||||
const heading = icelandHeadingFromDescription(description);
|
||||
const hasHeading = Number.isFinite(heading);
|
||||
return {
|
||||
id: cameraId,
|
||||
name: description || station || `Vegagerðin ${row.Maelist_nr}`,
|
||||
city: 'Iceland',
|
||||
cityId: 'iceland',
|
||||
provider: 'Vegagerðin (IRCA)',
|
||||
lat,
|
||||
lon,
|
||||
headingDeg: hasHeading ? heading : fallbackHeadingFromId(cameraId),
|
||||
headingConfidence: hasHeading ? 'high' : 'low',
|
||||
pitchDeg: hasHeading ? -24 : -18,
|
||||
fovDeg: hasHeading ? 56 : 44,
|
||||
rangeM: hasHeading ? 210 : 145,
|
||||
mountHeightM: hasHeading ? 10 : 8,
|
||||
groundElevationM: ICELAND_GROUND_ELEVATION_M,
|
||||
feedType: 'image',
|
||||
url,
|
||||
snapshotUrl: url,
|
||||
sourceKind: 'iceland-vegagerdin',
|
||||
license:
|
||||
'Icelandic Road and Coastal Administration (IRCA) — road camera images, CC BY 4.0',
|
||||
code: cameraDisplayCode(station.toUpperCase() || description),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Icelandic road cameras (Vegagerðin / IRCA), keyless: one JSON list.
|
||||
* Frames are stills on the IRCA host and the loader registers only links
|
||||
* inside that host's camera directory (ICELAND_IMAGE_ORIGIN). The list decides
|
||||
* ids, coordinates, labels and which frame path is proxied, so it is fetched
|
||||
* over https, redirects are refused and the body read is capped at
|
||||
* ICELAND_MAX_CATALOG_BYTES.
|
||||
*
|
||||
* @returns {Promise<Array<object>>} Normalized camera source objects.
|
||||
*/
|
||||
export async function loadIcelandSourcesFromOpenData() {
|
||||
try {
|
||||
const resp = await fetch(ICELAND_CAMERAS_URL, {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'User-Agent': 'gods-eye-view-cctv-proxy/1.0',
|
||||
},
|
||||
redirect: 'manual',
|
||||
signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
|
||||
});
|
||||
// A response this loader will not read still owns its transport until the
|
||||
// body is released, so every rejection path cancels before returning.
|
||||
const discard = async () => {
|
||||
try {
|
||||
await resp.body?.cancel();
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
return [];
|
||||
};
|
||||
if (resp.status >= 300 && resp.status < 400) {
|
||||
console.warn(
|
||||
'[CCTV] Iceland catalog redirected; redirects are not followed',
|
||||
);
|
||||
return discard();
|
||||
}
|
||||
if (!resp.ok) {
|
||||
console.warn('[CCTV] Iceland camera download failed:', resp.status);
|
||||
return discard();
|
||||
}
|
||||
const body = await readResponseJsonCapped(resp, ICELAND_MAX_CATALOG_BYTES);
|
||||
const rows = Array.isArray(body) ? body : [];
|
||||
const cameras = rows.map(icelandCameraToSource).filter(Boolean);
|
||||
const unique = Array.from(
|
||||
new Map(cameras.map((camera) => [camera.id, camera])).values(),
|
||||
);
|
||||
const maxRaw = Number(
|
||||
process.env.CCTV_ICELAND_MAX_SOURCES || DEFAULT_ICELAND_MAX_SOURCES,
|
||||
);
|
||||
const maxCount = Number.isFinite(maxRaw)
|
||||
? Math.max(8, Math.min(600, Math.floor(maxRaw)))
|
||||
: DEFAULT_ICELAND_MAX_SOURCES;
|
||||
const prioritized = prioritizeSources(unique, maxCount, ICELAND_ANCHORS);
|
||||
console.log(
|
||||
`[CCTV] Loaded Iceland camera sources: ${unique.length} (using nearest ${prioritized.length})`,
|
||||
);
|
||||
return prioritized;
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[CCTV] Iceland camera download error:',
|
||||
error?.message || error,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one XML child tag's text from an `<image>` block.
|
||||
*
|
||||
|
||||
Reference in new issue
Block a user