mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
feat(cctv): WSDOT highway camera source pack (Washington State)
Adds Washington State's highway cameras as a keyless source pack, from the Esri JSON layer WSDOT's own Travel Center map consumes (data.wsdot.wa.gov/travelcenter/Cameras.json, ~1,700 point features). Closes #624. - Geometry arrives in EPSG:3857; converted to WGS84 on load and bounded to a Washington bounding box. - Only frames on WSDOT's own images.wsdot.wa.gov host are registered — the catalog also lists ~70 partner cameras (Oregon DOT, City of Seattle, lodges, airports) whose own terms apply; those are skipped rather than proxied. 1,630 cameras survive the filters against the live feed. - CompassDirection letters (N/E/S/W) give a high-confidence heading; the majority `B` ("both directions") rows carry no single facing and use the shared id-hash fallback at low confidence, with the usual two pose personalities. - The feed body carries Windows-1252 bytes (en-dashes in titles) with no charset declaration, so it is decoded explicitly before parsing. - WSDOT's layer metadata marks the feed "low volume": the 15-minute catalog cache and per-request frame fetches respect that, frames are never stored or redistributed, and the WSDOT credit is registered in the attribution popover. A keyed WSDOT Traveler Information API serves the same population for higher-volume use and is deliberately not required. - Defaults: 250 cameras nearest Seattle and Spokane; CCTV_WSDOT_MAX_SOURCES changes the cap, CCTV_WSDOT_ENABLED=0 turns the pack off. - Tests: Mercator conversion against a real layer feature, official-host and malformed-row filtering, compass mapping and both pose personalities, cp1252 decoding, env cap, upstream-failure fallback.
This commit is contained in:
1 parent
b210ab0fe4
commit
ee3e3c7a41
6 files changed
+337
No files matched your search
@@ -18,6 +18,7 @@ import {
|
||||
loadNswSourcesFromOpenData,
|
||||
loadCalgarySourcesFromOpenData,
|
||||
loadDelDOTSourcesFromOpenData,
|
||||
loadWsdotSourcesFromOpenData,
|
||||
} 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: 'wsdot',
|
||||
enabled: () => envEnabled('CCTV_WSDOT_ENABLED'),
|
||||
load: loadWsdotSourcesFromOpenData,
|
||||
},
|
||||
];
|
||||
/**
|
||||
* Load CCTV sources from a local JSON file (CCTV_SOURCES_FILE env or default).
|
||||
|
||||
@@ -244,6 +244,24 @@ export const DELDOT_ANCHORS = [
|
||||
{ lat: 39.1582, lon: -75.5244 }, // Dover (Kent)
|
||||
{ lat: 38.6906, lon: -75.3877 }, // Georgetown (Sussex)
|
||||
];
|
||||
|
||||
/** WSDOT Travel Center cameras (Washington State): keyless Esri JSON on the
|
||||
* official open-data host — ~1,700 point features in EPSG:3857 (Web Mercator),
|
||||
* ETag/Last-Modified served. WSDOT's metadata marks the feed "low volume" use,
|
||||
* which the 15-minute catalog cache respects. */
|
||||
export const WSDOT_CAMERAS_URL =
|
||||
'https://data.wsdot.wa.gov/travelcenter/Cameras.json';
|
||||
/** Only frames on WSDOT's own image host are registered; the catalog also
|
||||
* lists ~70 partner cameras (Oregon DOT, City of Seattle, lodges, airports)
|
||||
* whose own terms apply. */
|
||||
export const WSDOT_IMAGE_ORIGIN = 'https://images.wsdot.wa.gov/';
|
||||
export const DEFAULT_WSDOT_MAX_SOURCES = 250;
|
||||
/** Prioritization anchors: downtown Seattle and Spokane. */
|
||||
export const WSDOT_ANCHORS = [
|
||||
{ lat: 47.6062, lon: -122.3321 }, // Seattle
|
||||
{ lat: 47.6588, lon: -117.426 }, // Spokane
|
||||
];
|
||||
|
||||
/** Camera CATALOGS change rarely; 15 min keeps multi-megabyte upstream list refetches (Austin rows.json + 4 Caltrans districts + TfL + Ontario 511) infrequent. Frames are fetched per-request and are unaffected. */
|
||||
export const CCTV_SOURCE_CACHE_MS = 15 * 60 * 1000;
|
||||
/** Per-provider catalog-fetch timeout. Bounds the worst-case refresh so one
|
||||
|
||||
@@ -59,6 +59,10 @@ import {
|
||||
DELDOT_CCTV_URL,
|
||||
DEFAULT_DELDOT_MAX_SOURCES,
|
||||
DELDOT_ANCHORS,
|
||||
WSDOT_CAMERAS_URL,
|
||||
WSDOT_IMAGE_ORIGIN,
|
||||
DEFAULT_WSDOT_MAX_SOURCES,
|
||||
WSDOT_ANCHORS,
|
||||
} from './constants.js';
|
||||
import {
|
||||
toFiniteNumber,
|
||||
@@ -1701,3 +1705,133 @@ export async function loadDelDOTSourcesFromOpenData() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** WSDOT compass letters as headings in degrees. `B` (both directions), `O`
|
||||
* (other) and null carry no single facing and fall through to the id hash. */
|
||||
const WSDOT_COMPASS_HEADINGS = Object.freeze({
|
||||
N: 0,
|
||||
E: 90,
|
||||
S: 180,
|
||||
W: 270,
|
||||
});
|
||||
|
||||
/**
|
||||
* Converts an EPSG:3857 (Web Mercator) coordinate pair to WGS84 degrees.
|
||||
* The WSDOT camera layer publishes point geometry in wkid 102100.
|
||||
* @param {number} x - Web Mercator metres east.
|
||||
* @param {number} y - Web Mercator metres north.
|
||||
* @returns {{lat: number, lon: number}} WGS84 degrees.
|
||||
*/
|
||||
export function webMercatorToWgs84(x, y) {
|
||||
const R = 6378137;
|
||||
const lon = (x / R) * (180 / Math.PI);
|
||||
const lat = (2 * Math.atan(Math.exp(y / R)) - Math.PI / 2) * (180 / Math.PI);
|
||||
return { lat, lon };
|
||||
}
|
||||
|
||||
/** Rough Washington State bounding box (with the Columbia River border cams). */
|
||||
function isLikelyWashingtonCoordinate(lat, lon) {
|
||||
return (
|
||||
Number.isFinite(lat) &&
|
||||
Number.isFinite(lon) &&
|
||||
lat >= 45.3 &&
|
||||
lat <= 49.3 &&
|
||||
lon >= -125 &&
|
||||
lon <= -116.5
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch WSDOT highway cameras (Washington State). Keyless: one Esri JSON
|
||||
* layer on WSDOT's official open-data host (the same layer the WSDOT Travel
|
||||
* Center map consumes; a keyed Traveler Information API serves the identical
|
||||
* population for higher-volume use). Only cameras with a positive integer id,
|
||||
* finite in-state coordinates, and a frame on WSDOT's own image host are
|
||||
* kept — the catalog's ~70 partner cameras (Oregon DOT, City of Seattle,
|
||||
* lodges, airports) are skipped rather than proxied under someone else's
|
||||
* terms. `CompassDirection` letters give a high-confidence heading; `B`
|
||||
* (both directions, the majority) and `O`/null fall back to the id hash.
|
||||
* The feed carries Windows-1252 bytes (en-dashes in titles) with no charset
|
||||
* declaration, so the body is decoded explicitly before parsing.
|
||||
*
|
||||
* @returns {Promise<Array<object>>} Normalized camera source objects.
|
||||
*/
|
||||
export async function loadWsdotSourcesFromOpenData() {
|
||||
try {
|
||||
const resp = await fetch(WSDOT_CAMERAS_URL, {
|
||||
headers: { Accept: 'application/json' },
|
||||
signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
console.warn('[CCTV] WSDOT camera download failed:', resp.status);
|
||||
return [];
|
||||
}
|
||||
const payload = JSON.parse(
|
||||
new TextDecoder('windows-1252').decode(await resp.arrayBuffer()),
|
||||
);
|
||||
const rows = Array.isArray(payload?.features) ? payload.features : [];
|
||||
|
||||
const cameras = [];
|
||||
for (const row of rows) {
|
||||
const attrs = row?.attributes;
|
||||
const rawId = attrs?.CameraID;
|
||||
if (!Number.isSafeInteger(rawId) || rawId <= 0) continue;
|
||||
const imageUrl = String(attrs.ImageURL || '').trim();
|
||||
if (!imageUrl.startsWith(WSDOT_IMAGE_ORIGIN)) continue;
|
||||
const x = row?.geometry?.x;
|
||||
const y = row?.geometry?.y;
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
|
||||
const { lat, lon } = webMercatorToWgs84(x, y);
|
||||
if (!isLikelyWashingtonCoordinate(lat, lon)) continue;
|
||||
|
||||
const cameraId = `wsdot-${rawId}`;
|
||||
const heading =
|
||||
WSDOT_COMPASS_HEADINGS[
|
||||
String(attrs.CompassDirection || '')
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
];
|
||||
const hasHeading = Number.isFinite(heading);
|
||||
cameras.push({
|
||||
id: cameraId,
|
||||
name: String(attrs.CameraTitle || '').trim() || `WSDOT camera ${rawId}`,
|
||||
city: 'Washington',
|
||||
cityId: 'washington-state',
|
||||
provider: 'WSDOT',
|
||||
lat,
|
||||
lon,
|
||||
headingDeg: hasHeading ? heading : fallbackHeadingFromId(cameraId),
|
||||
headingConfidence: hasHeading ? 'high' : 'low',
|
||||
// Same two pose personalities as the other packs: raw priors that the
|
||||
// client's ground snap and manual calibration refine.
|
||||
pitchDeg: hasHeading ? -24 : -18,
|
||||
fovDeg: hasHeading ? 56 : 44,
|
||||
rangeM: hasHeading ? 210 : 145,
|
||||
mountHeightM: hasHeading ? 10 : 8,
|
||||
feedType: 'image',
|
||||
url: imageUrl,
|
||||
snapshotUrl: imageUrl,
|
||||
sourceKind: 'wsdot-open-data',
|
||||
license: 'WSDOT Travel Center camera data (public low-volume feed)',
|
||||
});
|
||||
}
|
||||
|
||||
const maxRaw = Number(
|
||||
process.env.CCTV_WSDOT_MAX_SOURCES || DEFAULT_WSDOT_MAX_SOURCES,
|
||||
);
|
||||
const maxCount = Number.isFinite(maxRaw)
|
||||
? Math.max(8, Math.min(1200, Math.floor(maxRaw)))
|
||||
: DEFAULT_WSDOT_MAX_SOURCES;
|
||||
const prioritized = prioritizeSources(cameras, maxCount, WSDOT_ANCHORS);
|
||||
console.log(
|
||||
`[CCTV] Loaded WSDOT camera sources: ${cameras.length} published (using nearest ${prioritized.length})`,
|
||||
);
|
||||
return prioritized;
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[CCTV] WSDOT camera download error:',
|
||||
error?.message || error,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user