mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Merge branch 'main' into feat/typed-ai-chat
# Conflicts: # src/i18n/locales/ar.json
This commit is contained in:
commit
b6967a3dd9
69 files changed
+5296
-125
No files matched your search
@@ -28,6 +28,7 @@ import {
|
||||
loadNycSourcesFromOpenData,
|
||||
loadTorontoSourcesFromOpenData,
|
||||
loadFloridaSourcesFromOpenData,
|
||||
loadHoustonSourcesFromOpenData,
|
||||
loadA22SourcesFromOpenDataHub,
|
||||
} from './sources.js';
|
||||
|
||||
@@ -166,6 +167,11 @@ const LIVE_PACKS = [
|
||||
enabled: () => envEnabled('CCTV_TAIWAN_ENABLED'),
|
||||
load: loadTaiwanSources,
|
||||
},
|
||||
{
|
||||
name: 'houston',
|
||||
enabled: () => envOptIn('CCTV_HOUSTON_ENABLED'),
|
||||
load: loadHoustonSourcesFromOpenData,
|
||||
},
|
||||
{
|
||||
name: 'a22',
|
||||
enabled: () => envEnabled('CCTV_A22_ENABLED'),
|
||||
|
||||
@@ -443,6 +443,37 @@ export const NYC_ANCHORS = [
|
||||
{ lat: 40.5795, lon: -74.1502 }, // Staten Island
|
||||
];
|
||||
|
||||
/** Houston TranStar cameras: one keyless JSON list for the metro (~1,000
|
||||
* cameras with a numeric id). Frames are stills synthesized from that id on
|
||||
* the city's snapshot host — never taken from the payload. The catalog is
|
||||
* served as `application/javascript`; asking for `application/json` gets HTTP
|
||||
* 406, so the loader accepts any media type. */
|
||||
export const HOUSTON_CAMERAS_URL =
|
||||
'https://traffic.houstontranstar.org/data/layers/cctvSnapshots_json.js';
|
||||
/** The only origin Houston camera frames may come from. The catalog's `url`
|
||||
* is a `showWin(...)` viewer helper and is ignored; the registered path is
|
||||
* always this origin plus `/snapshots/cctv/<id>.jpg`. */
|
||||
export const HOUSTON_IMAGE_ORIGIN = 'https://www.houstontranstar.org/';
|
||||
export const HOUSTON_IMAGE_URL = (id) =>
|
||||
`${HOUSTON_IMAGE_ORIGIN}snapshots/cctv/${id}.jpg`;
|
||||
/** Numeric TranStar snapshot ids: 3–5 digits, no leading zero. Id `0` is a
|
||||
* different (non-snapshot) camera class in the same list. */
|
||||
export const HOUSTON_CAMERA_ID_RE = /^[1-9]\d{2,4}$/;
|
||||
export const DEFAULT_HOUSTON_MAX_SOURCES = 250;
|
||||
export const HOUSTON_MAX_CATALOG_BYTES = 4 * 1024 * 1024;
|
||||
/** Ground-elevation prior in metres. The TranStar payload carries none, and
|
||||
* Houston sits a few metres above sea level. */
|
||||
export const HOUSTON_GROUND_ELEVATION_M = 15;
|
||||
/** Downtown Houston (Main @ Texas): the primary prioritization anchor. */
|
||||
export const HOUSTON_DOWNTOWN = { lat: 29.7604, lon: -95.3698 };
|
||||
export const HOUSTON_ANCHORS = [
|
||||
{ lat: 29.7604, lon: -95.3698 }, // Downtown
|
||||
{ lat: 29.739, lon: -95.461 }, // Uptown / Galleria
|
||||
{ lat: 29.71, lon: -95.398 }, // Texas Medical Center
|
||||
{ lat: 29.9902, lon: -95.3368 }, // IAH
|
||||
{ lat: 29.3013, lon: -94.7977 }, // Galveston
|
||||
];
|
||||
|
||||
/** Autostrada del Brennero (A22, Brenner–Modena) webcams: the keyless Open
|
||||
* Data Hub (NOI Techpark, South Tyrol) tourism API republishes Autobrennero's
|
||||
* own camera list with coordinates, one request for the whole motorway
|
||||
|
||||
@@ -410,6 +410,18 @@ export function isLikelyHongKongCoordinate(lat, lon) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Greater Houston (the Woodlands to Galveston, Katy to Baytown). Farther
|
||||
* TranStar partners (Dallas, Columbus) are out of this city pack. */
|
||||
export function isLikelyHoustonCoordinate(lat, lon) {
|
||||
return (
|
||||
isPlausibleLatLon(lat, lon) &&
|
||||
lat >= 29.2 &&
|
||||
lat <= 30.35 &&
|
||||
lon >= -96.05 &&
|
||||
lon <= -94.7
|
||||
);
|
||||
}
|
||||
|
||||
/** Mainland Norway, with slack for the coast and the Finnmark border. */
|
||||
export function isLikelyNorwayCoordinate(lat, lon) {
|
||||
return (
|
||||
|
||||
@@ -127,6 +127,13 @@ import {
|
||||
A22_MAX_CATALOG_BYTES,
|
||||
A22_GROUND_ELEVATION_BY_KM,
|
||||
A22_DEFAULT_GROUND_ELEVATION_M,
|
||||
HOUSTON_CAMERAS_URL,
|
||||
HOUSTON_IMAGE_URL,
|
||||
HOUSTON_CAMERA_ID_RE,
|
||||
DEFAULT_HOUSTON_MAX_SOURCES,
|
||||
HOUSTON_MAX_CATALOG_BYTES,
|
||||
HOUSTON_GROUND_ELEVATION_M,
|
||||
HOUSTON_ANCHORS,
|
||||
} from './constants.js';
|
||||
import {
|
||||
toFiniteNumber,
|
||||
@@ -147,6 +154,7 @@ import {
|
||||
isLikelyCataloniaCoordinate,
|
||||
decodeNumericEntities,
|
||||
isLikelyNycCoordinate,
|
||||
isLikelyHoustonCoordinate,
|
||||
isLikelyHongKongCoordinate,
|
||||
isLikelyNorwayCoordinate,
|
||||
isLikelyTorontoCoordinate,
|
||||
@@ -3771,3 +3779,162 @@ export async function loadA22SourcesFromOpenDataHub() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Label for one Houston TranStar camera: the interchange an operator
|
||||
* recognises ("10 EAST @ SAN JACINTO").
|
||||
*
|
||||
* @param {object} record - Raw TranStar camera row.
|
||||
* @param {string} cameraId - Derived stable id.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function houstonCameraName(record, cameraId) {
|
||||
const name = String(record?.location ?? '').trim();
|
||||
if (name) return name;
|
||||
const short = String(cameraId || '').replace(/^houston-/, '');
|
||||
return short ? `Houston Camera ${short}` : 'Houston Camera';
|
||||
}
|
||||
|
||||
/**
|
||||
* TranStar `dir` (the camera's side of the freeway) -> the travel direction
|
||||
* of the nearest lanes, as TranStar's map script `translateCameraDirection`
|
||||
* reads it. Only these four values are mapped; anything else is unknown.
|
||||
*/
|
||||
const HOUSTON_TRAVEL_HEADING_BY_SIDE = Object.freeze({
|
||||
south: 90,
|
||||
north: 270,
|
||||
west: 180,
|
||||
east: 0,
|
||||
});
|
||||
|
||||
/**
|
||||
* One Houston TranStar camera row -> one catalog source, or null.
|
||||
*
|
||||
* Frame URLs are synthesized from a validated numeric id on the pinned
|
||||
* snapshot origin. The payload's `url` is a `showWin(...)` viewer helper
|
||||
* and is ignored so a catalog edit cannot steer the frame proxy off-host.
|
||||
* TranStar's `dir` is the side of the freeway the camera sits on, not the
|
||||
* way it faces: TranStar's own map labels it "Traffic closest to the camera
|
||||
* is traveling …" (South→Eastbound, North→Westbound, West→Southbound,
|
||||
* East→Northbound). That travel direction is only a low-confidence prior
|
||||
* for the view, so the heading stays estimated with the low-confidence
|
||||
* pose; `None`, blank or unknown values fall back to the shared id-hash
|
||||
* heading.
|
||||
*
|
||||
* @param {object} record - Raw TranStar camera row.
|
||||
* @returns {?object}
|
||||
*/
|
||||
export function houstonCameraToSource(record) {
|
||||
if (!record || typeof record !== 'object') return null;
|
||||
|
||||
const lat = toFiniteNumber(record.lat);
|
||||
const lon = toFiniteNumber(record.lng);
|
||||
if (!isLikelyHoustonCoordinate(lat, lon)) return null;
|
||||
|
||||
const rawId = String(record.id ?? '').trim();
|
||||
if (!HOUSTON_CAMERA_ID_RE.test(rawId)) return null;
|
||||
const cameraId = `houston-${rawId}`;
|
||||
const imageUrl = HOUSTON_IMAGE_URL(rawId);
|
||||
const name = houstonCameraName(record, cameraId);
|
||||
const travelHeading =
|
||||
HOUSTON_TRAVEL_HEADING_BY_SIDE[
|
||||
String(record.dir ?? '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
];
|
||||
|
||||
return {
|
||||
id: cameraId,
|
||||
name,
|
||||
city: 'Houston',
|
||||
cityId: 'houston',
|
||||
provider: 'Houston TranStar',
|
||||
lat,
|
||||
lon,
|
||||
headingDeg: Number.isFinite(travelHeading)
|
||||
? travelHeading
|
||||
: fallbackHeadingFromId(cameraId),
|
||||
headingConfidence: 'low',
|
||||
pitchDeg: -18,
|
||||
fovDeg: 44,
|
||||
rangeM: 145,
|
||||
mountHeightM: 8,
|
||||
groundElevationM: HOUSTON_GROUND_ELEVATION_M,
|
||||
feedType: 'image',
|
||||
url: imageUrl,
|
||||
snapshotUrl: imageUrl,
|
||||
sourceKind: 'houston-transtar',
|
||||
license:
|
||||
'TranStar disclaimer — redistribution and external framing need TxDOT written permission',
|
||||
code: cameraDisplayCode(name.toUpperCase()),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Houston TranStar cameras from the keyless `cctvSnapshots_json.js`
|
||||
* catalog. Frames are stills on `www.houstontranstar.org`, registered from
|
||||
* a validated numeric id rather than from the payload URL. The catalog host
|
||||
* answers `Accept: application/json` with HTTP 406, so this request accepts
|
||||
* any media type.
|
||||
*
|
||||
* @returns {Promise<Array<object>>} Normalized camera source objects.
|
||||
*/
|
||||
export async function loadHoustonSourcesFromOpenData() {
|
||||
try {
|
||||
const endpoint =
|
||||
process.env.CCTV_HOUSTON_CAMERAS_URL || HOUSTON_CAMERAS_URL;
|
||||
const resp = await fetch(endpoint, {
|
||||
headers: { Accept: '*/*' },
|
||||
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] Houston catalog redirected; redirects are not followed',
|
||||
);
|
||||
return discard();
|
||||
}
|
||||
if (!resp.ok) {
|
||||
console.warn('[CCTV] Houston camera download failed:', resp.status);
|
||||
return discard();
|
||||
}
|
||||
const payload = await readResponseJsonCapped(
|
||||
resp,
|
||||
HOUSTON_MAX_CATALOG_BYTES,
|
||||
);
|
||||
const rows = Array.isArray(payload?.cameras) ? payload.cameras : [];
|
||||
const cameras = [];
|
||||
const seen = new Set();
|
||||
for (const record of rows) {
|
||||
const camera = houstonCameraToSource(record);
|
||||
if (!camera || seen.has(camera.id)) continue;
|
||||
seen.add(camera.id);
|
||||
cameras.push(camera);
|
||||
}
|
||||
const maxRaw = Number(
|
||||
process.env.CCTV_HOUSTON_MAX_SOURCES || DEFAULT_HOUSTON_MAX_SOURCES,
|
||||
);
|
||||
const maxCount = Number.isFinite(maxRaw)
|
||||
? Math.max(8, Math.min(400, Math.floor(maxRaw)))
|
||||
: DEFAULT_HOUSTON_MAX_SOURCES;
|
||||
const prioritized = prioritizeSources(cameras, maxCount, HOUSTON_ANCHORS);
|
||||
console.log(
|
||||
`[CCTV] Loaded Houston camera sources: ${cameras.length} (using nearest ${prioritized.length})`,
|
||||
);
|
||||
return prioritized;
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[CCTV] Houston camera download error:',
|
||||
error?.message || error,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { normalizeDroneAirspaceSnapshot } from '../../src/layers/droneAirspace/records.js';
|
||||
import { readResponseJsonCapped, coalesceProxyRequest } from './common/http.js';
|
||||
import { makeRateLimiter, clientKey } from './common/rate-limit.js';
|
||||
import { admitSameSiteKeyless } from './common/same-site.js';
|
||||
|
||||
// FAA Special Use Airspace (public, keyless). TYPE_CODE P (prohibited) and
|
||||
// R (restricted) only — the small, always-in-force slice of the national
|
||||
// SUA table. maxAllowableOffset trades ~100 m of boundary fidelity for a
|
||||
// payload small enough to refresh (~330 KB for the current P+R set).
|
||||
const API_URL =
|
||||
'https://services6.arcgis.com/ssFJjBXIUyZDrSYZ/arcgis/rest/services/' +
|
||||
'Special_Use_Airspace/FeatureServer/0/query?' +
|
||||
new URLSearchParams({
|
||||
where: "TYPE_CODE IN ('P','R')",
|
||||
outFields: [
|
||||
'NAME',
|
||||
'TYPE_CODE',
|
||||
'UPPER_VAL',
|
||||
'UPPER_UOM',
|
||||
'UPPER_CODE',
|
||||
'LOWER_VAL',
|
||||
'LOWER_UOM',
|
||||
'LOWER_CODE',
|
||||
'TIMESOFUSE',
|
||||
'CONT_AGENT',
|
||||
'CITY',
|
||||
'STATE',
|
||||
'OBJECTID',
|
||||
].join(','),
|
||||
maxAllowableOffset: '0.001',
|
||||
outSR: '4326',
|
||||
f: 'geojson',
|
||||
}).toString();
|
||||
|
||||
const MAX_PAGES = 5;
|
||||
const MIB = 1024 * 1024;
|
||||
const SNAPSHOT_TTL_MS = 3_600_000;
|
||||
/** Longest a failed refresh keeps serving the last snapshot, marked stale. */
|
||||
const STALE_MAX_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
const exceededTransferLimit = (payload) =>
|
||||
payload?.exceededTransferLimit === true ||
|
||||
payload?.properties?.exceededTransferLimit === true;
|
||||
|
||||
/** Fixed-origin, bounded FAA SUA routes for dev and preview. */
|
||||
export function droneAirspaceProxy({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
now = () => Date.now(),
|
||||
} = {}) {
|
||||
const cache = new Map();
|
||||
const inFlight = new Map();
|
||||
const allow = makeRateLimiter({ windowMs: 60_000, max: 60, globalMax: 1200 });
|
||||
|
||||
async function upstream(url, cap, timeout) {
|
||||
const signal = AbortSignal.timeout(timeout);
|
||||
const response = await fetchImpl(url, {
|
||||
signal,
|
||||
redirect: 'error',
|
||||
});
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel();
|
||||
throw new Error('upstream_unavailable');
|
||||
}
|
||||
return readResponseJsonCapped(response, cap, signal);
|
||||
}
|
||||
|
||||
// A snapshot is accepted only once the service says it is complete. A page
|
||||
// limit reached while still flagged, a flagged page that adds nothing, or
|
||||
// no valid zone at all is a failed refresh, so the last good snapshot stays.
|
||||
async function fetchZones() {
|
||||
const features = [];
|
||||
let complete = false;
|
||||
for (let page = 0; page < MAX_PAGES; page++) {
|
||||
const url =
|
||||
page === 0 ? API_URL : `${API_URL}&resultOffset=${features.length}`;
|
||||
const payload = await upstream(url, 8 * MIB, 30_000);
|
||||
if (!Array.isArray(payload?.features))
|
||||
throw new Error('invalid_snapshot');
|
||||
features.push(...payload.features);
|
||||
if (!exceededTransferLimit(payload)) {
|
||||
complete = true;
|
||||
break;
|
||||
}
|
||||
if (!payload.features.length) break;
|
||||
}
|
||||
if (!complete) throw new Error('incomplete_snapshot');
|
||||
const rows = normalizeDroneAirspaceSnapshot({ features });
|
||||
if (!rows?.length) throw new Error('invalid_snapshot');
|
||||
return { fetchedAt: now(), rows };
|
||||
}
|
||||
|
||||
async function acquire(key, ttl, load) {
|
||||
const previous = cache.get(key);
|
||||
if (previous && now() - previous.savedAt < ttl)
|
||||
return { value: previous.value, stale: false };
|
||||
if (!inFlight.has(key) && inFlight.size >= 256)
|
||||
throw Object.assign(new Error('busy'), { status: 429 });
|
||||
try {
|
||||
const { promise } = coalesceProxyRequest(inFlight, key, async () => {
|
||||
const value = await load();
|
||||
cache.set(key, { value, savedAt: now() });
|
||||
return value;
|
||||
});
|
||||
return { value: await promise, stale: false };
|
||||
} catch (error) {
|
||||
// A failed refresh serves the last snapshot, marked stale, for a day.
|
||||
if (previous && now() - previous.savedAt <= STALE_MAX_MS)
|
||||
return { value: previous.value, stale: true };
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function handler(req, res) {
|
||||
const json = (status, value, stale = false) => {
|
||||
if (res.destroyed) return;
|
||||
res.writeHead(status, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
...(status === 405 ? { Allow: 'GET' } : {}),
|
||||
...(status === 429 ? { 'Retry-After': '60' } : {}),
|
||||
...(stale ? { 'X-Data-Stale': 'true' } : {}),
|
||||
});
|
||||
res.end(JSON.stringify(value));
|
||||
};
|
||||
// Keyless and needed to render the layer: refuse cross-site browsers
|
||||
// before they spend a client's quota or start a refresh, but keep the
|
||||
// documented reverse-proxy deployment working.
|
||||
if (admitSameSiteKeyless(req, res)) return;
|
||||
if (req.method !== 'GET') return json(405, { error: 'method_not_allowed' });
|
||||
const path = (req.url || '/').split('?')[0];
|
||||
if (path !== '/' && path !== '')
|
||||
return json(404, { error: 'unknown_route' });
|
||||
if (!allow(clientKey(req))) return json(429, { error: 'rate_limited' });
|
||||
try {
|
||||
const { value, stale } = await acquire(
|
||||
'zones',
|
||||
SNAPSHOT_TTL_MS,
|
||||
fetchZones,
|
||||
);
|
||||
json(200, stale ? { ...value, stale: true } : value, stale);
|
||||
} catch (error) {
|
||||
json(error.status === 429 ? 429 : 502, {
|
||||
error: 'drone_airspace_unavailable',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'drone-airspace',
|
||||
configureServer({ middlewares }) {
|
||||
middlewares.use('/api/drone-airspace', handler);
|
||||
},
|
||||
configurePreviewServer({ middlewares }) {
|
||||
middlewares.use('/api/drone-airspace', handler);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import { weatherProxy } from './weather.js';
|
||||
import { firePerimetersProxy } from './firePerimeters.js';
|
||||
import { gnssIntegrityProxy } from './gnssIntegrity.js';
|
||||
import { ghostMapsProxy } from './ghostMaps.js';
|
||||
import { droneAirspaceProxy } from './droneAirspace.js';
|
||||
import { cycloneProxy } from './cyclones.js';
|
||||
import { windProxy } from './wind.js';
|
||||
|
||||
@@ -79,6 +80,7 @@ function localProviderPlugins({
|
||||
firePerimetersProxy(),
|
||||
gnssIntegrityProxy(),
|
||||
ghostMapsProxy(),
|
||||
droneAirspaceProxy(),
|
||||
keySetupEndpoint(),
|
||||
];
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user