Merge branch 'main' into feat/typed-ai-chat

# Conflicts:
#	src/i18n/locales/ar.json
This commit is contained in:
Sameh Khamis committed 2026-11-03 15:45:43 -08:00
commit b6967a3dd9
69 files changed
+5296 -125

No files matched your search

+6
View File
@@ -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'),
+31
View File
@@ -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
+12
View File
@@ -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 (
+167
View File
@@ -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 [];
}
}
+157
View File
@@ -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);
},
};
}
+2
View File
@@ -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(),
];
}