mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +00:00
feat: add Statens vegvesen (Norway) road cameras as a CCTV pack
Norway's ~850 working road cameras load from one keyless GeoJSON request to the agency's OGC API view of its DATEX 3.1 CCTV table, so no DATEX account is needed. Faulty cameras are dropped, every camera sits under a "Norway" category, and the ~135 that publish HLS play as live video through the existing in-memory relay with the still as fallback. Frames and manifests are pinned to each camera's own path on the agency hosts. CCTV_VEGVESEN_ENABLED=0 disables the pack, CCTV_VEGVESEN_MAX_SOURCES caps it (nearest the largest cities first) and CCTV_VEGVESEN_VIDEO=0 keeps stills only. Attributed under NLOD 2.0. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
e1cc7afacf
commit
d43478a547
12 files changed
+430
-2
No files matched your search
@@ -59,6 +59,12 @@ import {
|
||||
DELDOT_CCTV_URL,
|
||||
DEFAULT_DELDOT_MAX_SOURCES,
|
||||
DELDOT_ANCHORS,
|
||||
DEFAULT_VEGVESEN_CCTV_URL,
|
||||
VEGVESEN_IMAGE_ORIGIN,
|
||||
VEGVESEN_VIDEO_URL,
|
||||
DEFAULT_VEGVESEN_MAX_SOURCES,
|
||||
VEGVESEN_MAX_CATALOG_BYTES,
|
||||
NORWAY_ANCHORS,
|
||||
} from './constants.js';
|
||||
import {
|
||||
toFiniteNumber,
|
||||
@@ -76,6 +82,7 @@ import {
|
||||
isLikelyTexasCoordinate,
|
||||
isLikelyNswCoordinate,
|
||||
isLikelyCalgaryCoordinate,
|
||||
isLikelyNorwayCoordinate,
|
||||
cameraDisplayCode,
|
||||
rowArrayToObject,
|
||||
prioritizeSources,
|
||||
@@ -1701,3 +1708,152 @@ export async function loadDelDOTSourcesFromOpenData() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One Statens vegvesen `CctvSimple` feature -> one catalog source, or null.
|
||||
*
|
||||
* `orientationDescription` names the place the camera looks towards
|
||||
* ("Svelgen"), not a bearing, so it goes into the label and the heading uses
|
||||
* the shared id-hash fallback at low confidence, like Calgary and Fintraffic.
|
||||
* Cameras the feed reports as faulty are dropped. Cameras that publish HLS
|
||||
* get live video, with the still as the snapshot fallback.
|
||||
*
|
||||
* @param {object} feature - GeoJSON feature from the OGC items response.
|
||||
* @returns {?object}
|
||||
*/
|
||||
export function vegvesenCameraToSource(feature) {
|
||||
if (!feature || typeof feature !== 'object') return null;
|
||||
const props = feature.properties;
|
||||
if (!props || typeof props !== 'object') return null;
|
||||
const availability = String(
|
||||
props['status.stillImageAvailability'] ?? '',
|
||||
).trim();
|
||||
if (availability && availability !== 'videoOrImagesAvailable') return null;
|
||||
|
||||
const coordinates = feature?.geometry?.coordinates;
|
||||
if (!Array.isArray(coordinates) || coordinates.length < 2) return null;
|
||||
const lon = toFiniteNumber(coordinates[0]);
|
||||
const lat = toFiniteNumber(coordinates[1]);
|
||||
if (!isLikelyNorwayCoordinate(lat, lon)) return null;
|
||||
|
||||
const rawId = String(props.cameraId ?? '').trim();
|
||||
if (!/^[A-Za-z0-9_-]{1,40}$/.test(rawId)) return null;
|
||||
let image;
|
||||
try {
|
||||
image = new URL(String(props.stillImageUrl ?? ''));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (image.username || image.password) return null;
|
||||
const imageUrl = image.href;
|
||||
if (imageUrl !== `${VEGVESEN_IMAGE_ORIGIN}${rawId}`) return null;
|
||||
|
||||
const cameraId = `no-vegvesen-${rawId.toLowerCase()}`;
|
||||
const place = String(props.description ?? '').trim() || `Kamera ${rawId}`;
|
||||
const towards = String(props.orientationDescription ?? '').trim();
|
||||
const road = String(props.roadNumber ?? '').trim();
|
||||
const name = towards && towards !== place ? `${place} → ${towards}` : place;
|
||||
// Live video when the camera publishes HLS on the agency's own host; the
|
||||
// still stays the snapshot fallback. CCTV_VEGVESEN_VIDEO=0 keeps stills only.
|
||||
const videoUrl =
|
||||
String(process.env.CCTV_VEGVESEN_VIDEO || '1').trim() !== '0' &&
|
||||
Number(props.videoServiceLevel) > 0 &&
|
||||
String(props.videoEncodingStandard ?? '').toLowerCase() === 'hls' &&
|
||||
String(props.videoUrl ?? '').trim() === VEGVESEN_VIDEO_URL(rawId)
|
||||
? VEGVESEN_VIDEO_URL(rawId)
|
||||
: '';
|
||||
|
||||
return {
|
||||
id: cameraId,
|
||||
name: road ? `${road} ${name}` : name,
|
||||
// One country-wide category in the camera picker, like Finland; the
|
||||
// place itself is already in the name.
|
||||
city: 'Norway',
|
||||
cityId: 'norway',
|
||||
provider: 'Statens vegvesen',
|
||||
lat,
|
||||
lon,
|
||||
headingDeg: fallbackHeadingFromId(cameraId),
|
||||
headingConfidence: 'low',
|
||||
pitchDeg: -18,
|
||||
fovDeg: 44,
|
||||
rangeM: 145,
|
||||
mountHeightM: 8,
|
||||
// Norwegian road cameras run from sea level to mountain passes; the
|
||||
// client's one-shot ground snap corrects this prior where tiles load.
|
||||
groundElevationM: 150,
|
||||
feedType: videoUrl ? 'hls' : 'image',
|
||||
url: videoUrl || imageUrl,
|
||||
snapshotUrl: imageUrl,
|
||||
sourceKind: 'vegvesen-datex',
|
||||
license:
|
||||
'Contains data under the Norwegian licence for Open Government data (NLOD) distributed by Statens vegvesen',
|
||||
code: cameraDisplayCode(place.toUpperCase()),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Statens vegvesen (Norway) road cameras from the keyless OGC API
|
||||
* Features view of the DATEX 3.1 CCTV table. Frames are stills on
|
||||
* kamera.atlas.vegvesen.no.
|
||||
*
|
||||
* @returns {Promise<Array<object>>} Normalized camera source objects.
|
||||
*/
|
||||
export async function loadVegvesenSourcesFromOpenData() {
|
||||
try {
|
||||
const endpoint = process.env.CCTV_VEGVESEN_URL || DEFAULT_VEGVESEN_CCTV_URL;
|
||||
const resp = await fetch(endpoint, {
|
||||
headers: { Accept: 'application/geo+json,application/json' },
|
||||
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] Vegvesen catalog redirected; redirects are not followed',
|
||||
);
|
||||
return discard();
|
||||
}
|
||||
if (!resp.ok) {
|
||||
console.warn('[CCTV] Vegvesen camera download failed:', resp.status);
|
||||
return discard();
|
||||
}
|
||||
const payload = await readResponseJsonCapped(
|
||||
resp,
|
||||
VEGVESEN_MAX_CATALOG_BYTES,
|
||||
);
|
||||
const features = Array.isArray(payload?.features) ? payload.features : [];
|
||||
const cameras = [];
|
||||
const seen = new Set();
|
||||
for (const feature of features) {
|
||||
const camera = vegvesenCameraToSource(feature);
|
||||
if (!camera || seen.has(camera.id)) continue;
|
||||
seen.add(camera.id);
|
||||
cameras.push(camera);
|
||||
}
|
||||
const maxRaw = Number(
|
||||
process.env.CCTV_VEGVESEN_MAX_SOURCES || DEFAULT_VEGVESEN_MAX_SOURCES,
|
||||
);
|
||||
const maxCount = Number.isFinite(maxRaw)
|
||||
? Math.max(8, Math.min(1000, Math.floor(maxRaw)))
|
||||
: DEFAULT_VEGVESEN_MAX_SOURCES;
|
||||
const prioritized = prioritizeSources(cameras, maxCount, NORWAY_ANCHORS);
|
||||
console.log(
|
||||
`[CCTV] Loaded Vegvesen camera sources: ${cameras.length} (using nearest ${prioritized.length})`,
|
||||
);
|
||||
return prioritized;
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[CCTV] Vegvesen camera download error:',
|
||||
error?.message || error,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user