feat: Kartverket ground heights for Statens vegvesen cameras

The Vegvesen camera feed is 2D, so every camera shared one flat 150 m
ground prior and mountain-pass cameras could sit hundreds of metres below
the terrain where neither the point-height prior nor the ground snap
resolves. Each camera now gets its terrain height from Kartverket's
keyless point-height API (CC BY 4.0): ~840 of ~845 cameras.

- 50 points per request, six in flight; points a full batch answers null
  for are retried once in batches of 10 (the API drops points from large
  batches).
- Sea-floor depths (dybdekurver / Havflate) under bridges and quays
  count as the water surface, 0 m.
- Heights are cached by position, so the 15-minute refresh only asks
  about new or moved cameras. A catalog load waits at most 12 s; a slower
  lookup keeps filling the cache for the next refresh.
- CCTV_VEGVESEN_HEIGHTS=0 turns it off; Kartverket is credited beside
  Statens vegvesen in the attribution popover.

Follow-up to #929.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Trygve HemminghyttandClaude Opus 5.5 committed 2026-10-05 21:46:20 +02:00
1 parent a08a53cf94
commit c43f58dc3f
8 files changed
+479 -11

No files matched your search

+24
View File
@@ -267,6 +267,30 @@ export const NORWAY_ANCHORS = [
{ lat: 69.6492, lon: 18.9553 }, // Tromsø
];
/** Kartverket's keyless point-height API (CC BY 4.0): terrain height above
* sea level (NN2000) for up to 50 points per request, used as the Vegvesen
* cameras' ground prior because the camera feed's points are 2D. */
export const KARTVERKET_HEIGHT_URL =
'https://ws.geonorge.no/hoydedata/v1/punkt';
export const KARTVERKET_MAX_POINTS = 50;
/** Batch size for the one retry of points a full batch answered null for. */
export const KARTVERKET_RETRY_POINTS = 10;
/** Batches in flight at once. The whole country is ~17 batches of ~2 s each
* (spread-out points are slow to resolve), so six keep a cold lookup ~6 s. */
export const KARTVERKET_CONCURRENCY = 6;
/** Deadline for one complete lookup, shared by every batch. */
export const KARTVERKET_LOOKUP_TIMEOUT_MS = 30 * 1000;
/** How long a catalog load waits for heights. Kept under the per-provider
* fetch timeout so this pack is never the catalog's slowest lane; a lookup
* still running after this keeps filling the cache, and the next refresh
* applies the rest. At app start, with every pack loading at once, a cold
* lookup takes ~10 s. */
export const KARTVERKET_CATALOG_WAIT_MS = 12 * 1000;
export const KARTVERKET_MAX_RESPONSE_BYTES = 256 * 1024;
/** Plausible Norwegian terrain: below sea level only at the shore, and no road
* camera sits above ~1,500 m. Anything outside is treated as no answer. */
export const KARTVERKET_HEIGHT_RANGE_M = Object.freeze({ min: -20, max: 2500 });
/** DelDOT CCTV: one keyless statewide JSON catalog; live video via RTMP-over-HTTP (rtmpt:80). */
export const DELDOT_CCTV_URL = 'https://tmc.deldot.gov/json/videocamera.json';
export const DEFAULT_DELDOT_MAX_SOURCES = 300;
+217 -8
View File
@@ -65,6 +65,14 @@ import {
DEFAULT_VEGVESEN_MAX_SOURCES,
VEGVESEN_MAX_CATALOG_BYTES,
NORWAY_ANCHORS,
KARTVERKET_HEIGHT_URL,
KARTVERKET_MAX_POINTS,
KARTVERKET_RETRY_POINTS,
KARTVERKET_CONCURRENCY,
KARTVERKET_LOOKUP_TIMEOUT_MS,
KARTVERKET_CATALOG_WAIT_MS,
KARTVERKET_MAX_RESPONSE_BYTES,
KARTVERKET_HEIGHT_RANGE_M,
} from './constants.js';
import {
toFiniteNumber,
@@ -1779,14 +1787,11 @@ export function vegvesenCameraToSource(feature) {
fovDeg: 44,
rangeM: 145,
mountHeightM: 8,
// KNOWN LIMITATION: the feed's coordinates are 2D, so this is one flat
// prior for the whole country, while cameras run from sea level to
// mountain passes near 1,000 m (Haukelifjell, Sjonfjellet, Rugeldalen).
// The point-height prior and the client's one-shot ground snap correct it
// where they resolve; on a stack where neither does (no 3D tiles), a pass
// camera stays hundreds of metres below the terrain, the same risk the
// Caltrans pack documents. A per-camera height (e.g. Kartverket's keyless
// point-height API) would remove it.
// The feed's coordinates are 2D. The loader replaces this flat prior with
// Kartverket's terrain height for each camera (applyVegvesenGroundHeights);
// it only survives when that lookup fails, and then a mountain-pass camera
// can sit below the terrain on a stack without 3D tiles, the same risk the
// Caltrans pack documents.
groundElevationM: 150,
feedType: videoUrl ? 'hls' : 'image',
url: videoUrl || imageUrl,
@@ -1851,6 +1856,7 @@ export async function loadVegvesenSourcesFromOpenData() {
? Math.max(8, Math.min(1000, Math.floor(maxRaw)))
: DEFAULT_VEGVESEN_MAX_SOURCES;
const prioritized = prioritizeSources(cameras, maxCount, NORWAY_ANCHORS);
await applyVegvesenGroundHeights(prioritized);
console.log(
`[CCTV] Loaded Vegvesen camera sources: ${cameras.length} (using nearest ${prioritized.length})`,
);
@@ -1863,3 +1869,206 @@ export async function loadVegvesenSourcesFromOpenData() {
return [];
}
}
/**
* Terrain heights already resolved, keyed by rounded position. Camera
* positions rarely change, so after the first catalog load a 15-minute refresh
* asks Kartverket only for new or moved cameras. Bounded well above the ~900
* cameras the pack can load.
*/
const kartverketHeightCache = new Map();
const KARTVERKET_CACHE_LIMIT = 5000;
/** The lookup in flight, so overlapping catalog loads never start a second. */
let kartverketPending = null;
/** Cache key for one position, at the ~0.1 m precision the request sends. */
const kartverketKey = (lon, lat) => `${lon.toFixed(6)},${lat.toFixed(6)}`;
/** Forget cached heights and any lookup in flight. Tests only. */
export function clearKartverketHeightCache() {
kartverketHeightCache.clear();
kartverketPending = null;
}
/**
* Terrain heights above sea level (NN2000) from Kartverket's keyless
* point-height API, in batches of up to 50 points. Answers are matched back by
* the coordinates the API echoes, not by position in the list. Each answer is
* written to `into` as its batch lands, so a caller that stops waiting still
* keeps what has arrived. One deadline covers every request.
*
* Two quirks of the live API are handled here:
* - A large batch sometimes answers `z: null` for a point it resolves when
* asked again (Valdresflye: null in a 50-point batch, 1,390.7 m alone), so
* every point still missing after the first pass is retried once in batches
* of KARTVERKET_RETRY_POINTS. A point still null then has no data.
* - Over water `/punkt` returns the depth of the sea floor (`dybdekurver`,
* terrain `Havflate`; −547 m under Nordhordlandsbrua). A camera on a bridge
* or quay stands over the water surface, so those answers count as 0 m.
*
* @param {Array<{lon:number, lat:number}>} points
* @param {{into?: Map<string, number>, timeoutMs?: number}} [options]
* @returns {Promise<Map<string, number>>} kartverketKey(lon, lat) → metres.
*/
export async function fetchKartverketHeights(
points,
{ into = new Map(), timeoutMs = KARTVERKET_LOOKUP_TIMEOUT_MS } = {},
) {
const unique = Array.from(
new Map(
(points || [])
.filter((p) => isLikelyNorwayCoordinate(p?.lat, p?.lon))
.map((p) => [kartverketKey(p.lon, p.lat), p]),
).values(),
);
if (!unique.length) return into;
const signal = AbortSignal.timeout(timeoutMs);
const fetchBatch = async (batch) => {
const url = new URL(KARTVERKET_HEIGHT_URL);
url.searchParams.set(
'punkter',
JSON.stringify(
batch.map((p) => [Number(p.lon.toFixed(6)), Number(p.lat.toFixed(6))]),
),
);
url.searchParams.set('koordsys', '4258');
const resp = await fetch(url, {
headers: { Accept: 'application/json' },
redirect: 'error',
signal,
});
if (!resp.ok) {
try {
await resp.body?.cancel();
} catch {
/* no-op */
}
throw new Error(`HTTP ${resp.status}`);
}
const payload = await readResponseJsonCapped(
resp,
KARTVERKET_MAX_RESPONSE_BYTES,
);
for (const point of Array.isArray(payload?.punkter)
? payload.punkter
: []) {
// `z: null` means no answer for that point; Number(null) would read 0.
if (point?.z === null || point?.z === undefined || point?.z === '')
continue;
const x = toFiniteNumber(point?.x);
const y = toFiniteNumber(point?.y);
let z = toFiniteNumber(point?.z);
if (![x, y, z].every(Number.isFinite)) continue;
if (point?.datakilde === 'dybdekurver' || point?.terreng === 'Havflate')
z = 0;
if (
z < KARTVERKET_HEIGHT_RANGE_M.min ||
z > KARTVERKET_HEIGHT_RANGE_M.max
)
continue;
into.set(kartverketKey(x, y), z);
}
};
let failed = 0;
const runPass = async (list, size) => {
const batches = [];
for (let i = 0; i < list.length; i += size) {
batches.push(list.slice(i, i + size));
}
let next = 0;
const worker = async () => {
while (next < batches.length && !signal.aborted) {
const batch = batches[next++];
try {
await fetchBatch(batch);
} catch (error) {
failed += 1;
if (failed === 1) {
console.warn(
'[CCTV] Kartverket height lookup failed:',
error?.message || error,
);
}
}
}
};
await Promise.all(
Array.from(
{ length: Math.min(KARTVERKET_CONCURRENCY, batches.length) },
worker,
),
);
};
await runPass(unique, KARTVERKET_MAX_POINTS);
const missing = unique.filter((p) => !into.has(kartverketKey(p.lon, p.lat)));
if (missing.length && !signal.aborted) {
await runPass(missing, KARTVERKET_RETRY_POINTS);
}
return into;
}
/**
* Give each Vegvesen camera its own terrain height as the ground prior, in
* place. Waits at most `waitMs` for heights it does not have cached; a lookup
* still running then keeps filling the cache for the next catalog refresh.
* Cameras without a height keep the flat prior. Never throws: a failed lookup
* only means the old behavior. CCTV_VEGVESEN_HEIGHTS=0 turns it off.
*
* @param {Array<object>} cameras - Sources from vegvesenCameraToSource().
* @param {{waitMs?: number}} [options]
* @returns {Promise<number>} How many cameras got a terrain height.
*/
export async function applyVegvesenGroundHeights(
cameras,
{ waitMs = KARTVERKET_CATALOG_WAIT_MS } = {},
) {
if (String(process.env.CCTV_VEGVESEN_HEIGHTS || '1').trim() === '0') return 0;
const list = Array.isArray(cameras) ? cameras : [];
const missing = list.filter(
(c) => !kartverketHeightCache.has(kartverketKey(c.lon, c.lat)),
);
if (missing.length) {
if (!kartverketPending) {
if (
kartverketHeightCache.size + missing.length >
KARTVERKET_CACHE_LIMIT
) {
kartverketHeightCache.clear();
}
kartverketPending = fetchKartverketHeights(missing, {
into: kartverketHeightCache,
})
.catch((error) => {
console.warn(
'[CCTV] Kartverket height lookup error:',
error?.message || error,
);
})
.finally(() => {
kartverketPending = null;
});
}
let timer;
await Promise.race([
kartverketPending,
new Promise((resolve) => {
timer = setTimeout(resolve, waitMs);
timer.unref?.();
}),
]);
clearTimeout(timer);
}
let applied = 0;
for (const camera of list) {
const z = kartverketHeightCache.get(kartverketKey(camera.lon, camera.lat));
if (!Number.isFinite(z)) continue;
camera.groundElevationM = Math.round(z * 10) / 10;
applied += 1;
}
if (list.length) {
console.log(
`[CCTV] Kartverket ground heights: ${applied}/${list.length} Vegvesen cameras`,
);
}
return applied;
}