mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Merge branch 'main' of https://github.com/bilawalsidhu/gods-eye-view into pr/mapillary-layer
# Conflicts: # CHANGELOG.md # README.md
This commit is contained in:
commit
91d8bba4a3
75 files changed
+1599
-381
No files matched your search
@@ -7,8 +7,10 @@ import {
|
||||
coreTools,
|
||||
catalogForSurface,
|
||||
} from '../../src/tools/index.js';
|
||||
import { panelRuntime } from '../../src/app/globePanelRuntime.js';
|
||||
import { createGlobePanelResource } from '../../src/tools/globePanel.js';
|
||||
import {
|
||||
createGlobePanelResource,
|
||||
panelRuntime,
|
||||
} from '../../src/tools/panel.js';
|
||||
import { createMcpServer } from '../../src/tools/mcp/index.js';
|
||||
import { DEFAULT_API_BASE, createLocalToolServices } from './services.js';
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Vite plugin: adsb.lol military aircraft proxy with 12 s response cache.
|
||||
*
|
||||
* Proxies GET /api/adsblol/mil to https://api.adsb.lol/v2/mil. When upstream
|
||||
* Serves GET /api/military from https://api.adsb.lol/v2/mil. When upstream
|
||||
* fails — a thrown fetch OR a non-OK status such as 429 — the proxy serves its
|
||||
* cached body as STALE instead of relaying the failure, and it backs off from
|
||||
* upstream for the Retry-After period (bounded) so a rate limit is never
|
||||
@@ -55,10 +55,11 @@ export function adsbLolProxy() {
|
||||
res.writeHead(status, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
'X-ADS-B-Cache': cacheStatus,
|
||||
'X-Feed-Source': 'adsb.lol',
|
||||
'X-Feed-Cache': cacheStatus,
|
||||
...(['HIT', 'STALE'].includes(cacheStatus)
|
||||
? {
|
||||
'X-ADS-B-Cache-Age-Ms': String(Math.max(0, Date.now() - _cacheAt)),
|
||||
'X-Feed-Age-Ms': String(Math.max(0, Date.now() - _cacheAt)),
|
||||
}
|
||||
: {}),
|
||||
...extra,
|
||||
@@ -67,8 +68,11 @@ export function adsbLolProxy() {
|
||||
}
|
||||
|
||||
const installMiddleware = (server) => {
|
||||
server.middlewares.use('/api/adsblol/mil', async (req, res) => {
|
||||
server.middlewares.use('/api/military', async (req, res, next) => {
|
||||
try {
|
||||
// This mount also matches its /track route, served by its own handler.
|
||||
if (new URL(req.url || '/', 'http://localhost').pathname !== '/')
|
||||
return next();
|
||||
const now = Date.now();
|
||||
if (_cache && now - _cacheAt < CACHE_MS) {
|
||||
serve(res, 200, _cache, 'HIT');
|
||||
|
||||
@@ -380,8 +380,12 @@ function openSkySourceIsStale(sourceEpochMs, now = Date.now()) {
|
||||
*/
|
||||
export function openSkyProxy() {
|
||||
const installMiddleware = (server) => {
|
||||
server.middlewares.use('/api/opensky', async (req, res) => {
|
||||
server.middlewares.use('/api/flights', async (req, res, next) => {
|
||||
try {
|
||||
// This mount also matches its /track route, served by its own handler.
|
||||
if (new URL(req.url || '/', 'http://localhost').pathname !== '/')
|
||||
return next();
|
||||
res.setHeader('X-Feed-Source', 'OpenSky Network');
|
||||
const requestedMode = normalizeOpenSkyAuthMode(
|
||||
process.env.OPENSKY_AUTH_MODE,
|
||||
);
|
||||
|
||||
@@ -3,11 +3,11 @@ import { readCappedResponseText } from '../common/http.js';
|
||||
/**
|
||||
* Vite plugin: aircraft track-history backfill proxies (PRD WS-F F1/F2).
|
||||
*
|
||||
* /api/opensky-track?icao24=<hex6> — OpenSky GET /tracks/all (experimental;
|
||||
* /api/flights/track?icao24=<hex6> — OpenSky GET /tracks/all (experimental;
|
||||
* own credit bucket, 4 credits per call on the free tier). OAuth via the
|
||||
* shared coalesced token. 60s per-icao cache; 404/429 forwarded so the
|
||||
* client can fall back to its accumulated trail silently.
|
||||
* /api/adsblol/trace?hex=<hex> — adsb.lol tar1090 readsb trace
|
||||
* /api/military/track?hex=<hex> — adsb.lol tar1090 readsb trace
|
||||
* (undocumented but live; no browser CORS, hence this proxy). Up to ~24h
|
||||
* of real history per aircraft. Treat as best-effort; data is ODbL —
|
||||
* credit "adsb.lol (ODbL)" in the UI.
|
||||
@@ -65,7 +65,7 @@ export function trackBackfillProxies() {
|
||||
}
|
||||
|
||||
function install(middlewares) {
|
||||
middlewares.use('/api/opensky-track', async (req, res) => {
|
||||
middlewares.use('/api/flights/track', async (req, res) => {
|
||||
try {
|
||||
const incoming = new URL(req.url || '', 'http://localhost');
|
||||
const icao24 = String(incoming.searchParams.get('icao24') || '')
|
||||
@@ -93,7 +93,7 @@ export function trackBackfillProxies() {
|
||||
}
|
||||
});
|
||||
|
||||
middlewares.use('/api/adsblol/trace', async (req, res) => {
|
||||
middlewares.use('/api/military/track', async (req, res) => {
|
||||
try {
|
||||
const incoming = new URL(req.url || '', 'http://localhost');
|
||||
const hex = String(incoming.searchParams.get('hex') || '')
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
loadNswSourcesFromOpenData,
|
||||
loadCalgarySourcesFromOpenData,
|
||||
loadDelDOTSourcesFromOpenData,
|
||||
loadVegvesenSourcesFromOpenData,
|
||||
} from './sources.js';
|
||||
|
||||
/** Env kill switch: unset or anything but "0" means enabled. */
|
||||
@@ -92,6 +93,11 @@ const LIVE_PACKS = [
|
||||
enabled: () => envEnabled('CCTV_DELDOT_ENABLED'),
|
||||
load: loadDelDOTSourcesFromOpenData,
|
||||
},
|
||||
{
|
||||
name: 'vegvesen',
|
||||
enabled: () => envEnabled('CCTV_VEGVESEN_ENABLED'),
|
||||
load: loadVegvesenSourcesFromOpenData,
|
||||
},
|
||||
];
|
||||
/**
|
||||
* Load CCTV sources from a local JSON file (CCTV_SOURCES_FILE env or default).
|
||||
|
||||
@@ -236,6 +236,37 @@ 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;
|
||||
|
||||
/** 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
|
||||
* not. Frames are stills on the agency's camera host. */
|
||||
export const DEFAULT_VEGVESEN_CCTV_URL =
|
||||
'https://ogckart-sn1.atlas.vegvesen.no/ogc/features/v1/collections/datex_3_1:CctvSimple/items?f=application/geo%2Bjson&limit=5000';
|
||||
/** The only origin Vegvesen camera frames may come from. */
|
||||
export const VEGVESEN_IMAGE_ORIGIN =
|
||||
'https://kamera.atlas.vegvesen.no/api/images/';
|
||||
/** Live HLS for the ~140 cameras that publish video; only this exact
|
||||
* per-camera manifest path is registered. */
|
||||
export const VEGVESEN_VIDEO_URL = (cameraId) =>
|
||||
`https://kamera.vegvesen.no/public/${cameraId}/manifest.m3u8`;
|
||||
/** Above the ~850 working cameras so a default install keeps them all, while
|
||||
* staying close to them: the per-pack defaults are meant to sum to no more
|
||||
* than DEFAULT_CCTV_MAX_SOURCES. */
|
||||
export const DEFAULT_VEGVESEN_MAX_SOURCES = 900;
|
||||
/** Hard ceiling on the catalog body. The whole country is ~800 KB. */
|
||||
export const VEGVESEN_MAX_CATALOG_BYTES = 8 * 1024 * 1024;
|
||||
/** Prioritization anchors: the largest cities from south to north, so a cap
|
||||
* keeps national coverage rather than just the capital region. */
|
||||
export const NORWAY_ANCHORS = [
|
||||
{ lat: 59.9139, lon: 10.7522 }, // Oslo
|
||||
{ lat: 60.3913, lon: 5.3221 }, // Bergen
|
||||
{ lat: 58.97, lon: 5.7331 }, // Stavanger
|
||||
{ lat: 63.4305, lon: 10.3951 }, // Trondheim
|
||||
{ lat: 58.1467, lon: 7.9956 }, // Kristiansand
|
||||
{ lat: 67.2804, lon: 14.4049 }, // Bodø
|
||||
{ lat: 69.6492, lon: 18.9553 }, // Tromsø
|
||||
];
|
||||
|
||||
/** 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;
|
||||
|
||||
@@ -367,6 +367,17 @@ export function isLikelyCalgaryCoordinate(lat, lon) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Mainland Norway, with slack for the coast and the Finnmark border. */
|
||||
export function isLikelyNorwayCoordinate(lat, lon) {
|
||||
return (
|
||||
isPlausibleLatLon(lat, lon) &&
|
||||
lat >= 57.9 &&
|
||||
lat <= 71.3 &&
|
||||
lon >= 4.4 &&
|
||||
lon <= 31.3
|
||||
);
|
||||
}
|
||||
|
||||
export function isLikelyFinlandCoordinate(lat, lon) {
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return false;
|
||||
return lat >= 59.5 && lat <= 70.5 && lon >= 19 && lon <= 32;
|
||||
|
||||
@@ -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,158 @@ 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,
|
||||
// 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.
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
@@ -71,14 +71,15 @@ let _aisWebSocketImpl;
|
||||
*/
|
||||
export function aisLiveProxy() {
|
||||
function install(middlewares) {
|
||||
middlewares.use('/api/ais-live', async (req, res) => {
|
||||
middlewares.use('/api/vessels', async (req, res) => {
|
||||
try {
|
||||
res.setHeader('X-Feed-Source', 'AISStream');
|
||||
ensureAisStreamConnection();
|
||||
const incoming = new URL(req.url || '', 'http://localhost');
|
||||
|
||||
// Track sub-route MUST be handled before the rows snapshot — this
|
||||
// mount prefix-matches every subpath, so without this branch
|
||||
// /api/ais-live/track would be silently answered with vessel rows.
|
||||
// /api/vessels/track would be silently answered with vessel rows.
|
||||
if (
|
||||
incoming.pathname === '/track' ||
|
||||
incoming.pathname.startsWith('/track/')
|
||||
@@ -288,7 +289,7 @@ function aisKeyFingerprint() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive the watchdog once. Called on every /api/ais-live request and on the
|
||||
* Drive the watchdog once. Called on every /api/vessels request and on the
|
||||
* background interval, so recovery does not depend on browser traffic.
|
||||
*/
|
||||
function ensureAisStreamConnection() {
|
||||
@@ -307,7 +308,7 @@ function ensureAisStreamConnection() {
|
||||
keyFingerprint: aisKeyFingerprint(),
|
||||
});
|
||||
}
|
||||
/** Status metadata for /api/ais-live, safe to call before the first connect. */
|
||||
/** Status metadata for /api/vessels, safe to call before the first connect. */
|
||||
function aisStreamStatusSnapshot() {
|
||||
const snapshot = _aisAdapter ? _aisAdapter.snapshot() : null;
|
||||
if (snapshot) return snapshot;
|
||||
|
||||
Reference in new issue
Block a user