Merge a879211898 into 759652207f
This commit is contained in:
commit
ef175f104d
|
|
@ -100,6 +100,9 @@ represent previously published GitHub Releases.
|
|||
|
||||
### Added
|
||||
|
||||
- Added Estonia CCTV source packs: Tallinn intersection stills (`ristmikud.tallinn.ee`,
|
||||
curated catalog) and nationwide Transpordiamet / Tarktee road-weather cameras
|
||||
(DATEX2 locations + rotating JPEG URLs), with Tallinn city POIs and attribution.
|
||||
- Added honest aircraft identity narration: callsign, operator, registration,
|
||||
type, and route come only from selected-contact context, and missing operator,
|
||||
route, or type enrichment is named explicitly.
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@ How to read this:
|
|||
| **City of Austin Open Data** | CCTV camera catalog + frames | City of Austin Open Data Terms of Use | "City of Austin, TX — data.austintexas.gov" |
|
||||
| **Caltrans (cwwp2.dot.ca.gov)** | CCTV camera catalogs + frames, California districts | Public Caltrans traffic camera data | "Caltrans — cwwp2.dot.ca.gov" (courtesy) |
|
||||
| **TfL Open Data (JamCams)** | CCTV camera catalog + frames, London | [TfL Open Data terms](https://tfl.gov.uk/info-for/open-data-users/) — attribution REQUIRED | "Powered by TfL Open Data. Contains OS data © Crown copyright and database rights" |
|
||||
| **City of Tallinn (ristmikud.tallinn.ee)** | CCTV camera catalog + frames, Tallinn intersections | Public city traffic camera stills | "City of Tallinn — ristmikud.tallinn.ee" |
|
||||
| **Transpordiamet / Tarktee** | CCTV camera catalog + frames, Estonia road-weather cameras | Public Transpordiamet Tarktee / DATEX2 feeds | "Transpordiamet / Tarktee — tarktee.transpordiamet.ee" |
|
||||
| **GBFS (Lyft / BCycle)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle) + its `license_url` |
|
||||
| **Radio Browser** | Geolocated internet-radio station directory and station-level tags | Public-domain directory data under PDDL 1.0; individual broadcaster stream terms apply | "Radio Browser" plus a link to the selected broadcaster |
|
||||
| **Re:Earth Terrain** (Mapterhorn) | Terrain (keyless globe stacks — OSM etc. — + `/api/terrain/heights` ellipsoidal-height lookups) | Terrain mesh: CC BY 4.0; geoid: EGM2008 (NGA, public domain) | "Terrain (keyless globe stacks): Re:Earth Terrain / Mapterhorn (CC BY 4.0) / EGM2008 (NGA)" |
|
||||
|
|
@ -45,6 +47,8 @@ How to read this:
|
|||
- **adsb.lol flight fallback.** When OpenSky is unavailable and no last-good OpenSky response exists, the server requests a cached, capped 250 nm adsb.lol point snapshot around the current camera subpoint. This is regional observed context, not worldwide completeness; provenance is exposed in the Flights stats/context row. Military ICAOs remain reconciled through the existing dedicated military registry rather than duplicated.
|
||||
- **Launch Library 2.** `/api/launches` makes a server-side rolling-30-day query against the supported v2.3 detailed launch endpoint, caches successful responses for 15 minutes in memory and on disk, and serves the last successful response during a throttle or transient outage. Anonymous access is limited to 15 calls/hour; deployments can provide `LL2_API_TOKEN` for authenticated access. The Space Devs' published terms permit using and sharing the API data in any form, ask users not to forward it without adding value, disclaim complete accuracy, and encourage—but do not require—attribution. This app keeps a courtesy credit. Payload and stage/recovery records are shown only when supplied. Failed launches expose their source status and never receive fallback orbit geometry or a live/estimated marker. LL2 supplies launch context and event timing, not continuous ascent telemetry or live orbital state.
|
||||
- **TfL JamCams.** The camera list comes from the keyless `api.tfl.gov.uk` endpoint (an optional `TFL_APP_KEY` raises its rate limit); frames come from TfL's public S3 bucket. The "Powered by TfL Open Data" attribution is required by TfL's terms and is registered in the Data attribution popover.
|
||||
- **Tallinn ristmikud.** Intersection camera stills are fetched live from `ristmikud.tallinn.ee` (`/last/camNNN.jpg`). The curated catalog (`config/cctv_sources.tallinn.json`) supplies coordinates and heading priors; only server-registered ristmikud HTTPS URLs are proxied. Disable with `CCTV_TALLINN_ENABLED=0`.
|
||||
- **Tarktee / Transpordiamet.** Estonia road-weather camera locations and current JPEG URLs come from keyless DATEX2 endpoints on `tarktee.transpordiamet.ee` (`/api/v1/datex/roadCameraLocations` + `roadCameraImages`). Frame URLs rotate with each capture; the 15-minute CCTV source cache refreshes them. Only `https://tarktee.transpordiamet.ee/images/…` URLs are registered. Disable with `CCTV_TARKTEE_ENABLED=0`.
|
||||
- **Radio Browser.** `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; schema-valid responses whose rows all fail the product's health policy do not count as successful queries. A usable partial cold catalog is explicitly `DEGRADED`, and malformed or empty successful payloads are rejected atomically. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply.
|
||||
- **TomTom Traffic.** Optional and BYOK: without `TOMTOM_API_KEY` the traffic layer runs its built-in simulation and no TomTom data (or attribution) appears. With a key, flow vector tiles are fetched through the server-side `/api/tomtom` proxy (120 s cache + a daily tile-budget governor — `TOMTOM_DAILY_TILE_BUDGET`, default 40,000, a configurable application safety ceiling, not a guarantee of staying within TomTom's monthly free allowance; TomTom's [current pricing](https://docs.tomtom.com/pricing/) lists 200K free tile requests per month) and the "Traffic flow data © TomTom" credit is registered in the Data attribution popover the moment live mode activates. TomTom data is served live and cached only transiently (≤120 s TTL under `.gev-cache/`, gitignored) — it is not bundled or redistributed. One 23 KB point-in-time tile snapshot is committed as a decode-test fixture (`src/data/fixtures/`, © TomTom, never served to the app).
|
||||
- **Re:Earth Terrain.** Keyless (no API key). Used two ways: (1) `src/mapStackController.js` swaps in a `Cesium.CesiumTerrainProvider` pointed at Re:Earth's `cesium-mesh/ellipsoid` quantized-mesh endpoint for globe stacks without a Cesium ion token (e.g. OSM), replacing a flat `EllipsoidTerrainProvider`; falls back to the flat provider if the endpoint can't be reached. (2) The server-side `/api/terrain/heights` proxy (disk-cached, serve-stale) resolves per-point ellipsoidal ground height for entity placement. Both are best-effort with a keyless-safe fallback (bundled EGM96 geoid math) if Re:Earth is unreachable.
|
||||
|
|
@ -129,4 +133,4 @@ Douglas-Peucker simplification, 6-decimal rounding).
|
|||
|
||||
## In-app attribution
|
||||
|
||||
The required Google Maps / Cesium credit renders on the on-globe credit line (`#cesium-credits`, bottom-left) and must stay visible — including in clean-view and recording modes (the whole line, logo + "Google Maps" + the "Data attribution" link, stays on screen; only the GEV panels/HUD fade). The layer-specific credits (adsb.lol, TeleGeography, OSM datacenters/dams/roads, NASA FIRMS, CelesTrak, USGS, City of Austin, GBFS, Radio Browser, OpenSky, AISStream) are registered into the expandable **"Data attribution"** popover on that credit line via `viewer.creditDisplay.addStaticCredit(new Cesium.Credit(html, /* showOnScreen */ false))` — see `src/data/dataCredits.js`. When you add a new data source, add its license and attribution to this file **and** append an entry to `DATA_CREDITS` in `src/data/dataCredits.js` so it surfaces in the app.
|
||||
The required Google Maps / Cesium credit renders on the on-globe credit line (`#cesium-credits`, bottom-left) and must stay visible — including in clean-view and recording modes (the whole line, logo + "Google Maps" + the "Data attribution" link, stays on screen; only the GEV panels/HUD fade). The layer-specific credits (adsb.lol, TeleGeography, OSM datacenters/dams/roads, NASA FIRMS, CelesTrak, USGS, City of Austin, Tallinn ristmikud, Transpordiamet / Tarktee, GBFS, Radio Browser, OpenSky, AISStream) are registered into the expandable **"Data attribution"** popover on that credit line via `viewer.creditDisplay.addStaticCredit(new Cesium.Credit(html, /* showOnScreen */ false))` — see `src/data/dataCredits.js`. When you add a new data source, add its license and attribution to this file **and** append an entry to `DATA_CREDITS` in `src/data/dataCredits.js` so it surfaces in the app.
|
||||
|
|
|
|||
|
|
@ -186,7 +186,7 @@ Choose a first-run mission, or try these in order. The GIFs show Google Photorea
|
|||
|
||||

|
||||
|
||||
4. **Look through a public camera.** Turn on **CCTV** over Austin, London, or California. The feeds aren't webcam embeds — they project *into* the 3D city. Cycle coverage to **VIEWSHED** and every camera draws its estimated coverage volume — where it reaches, and where it goes blind.
|
||||
4. **Look through a public camera.** Turn on **CCTV** over Austin, London, California, or Estonia (Tallinn + Tarktee). The feeds aren't webcam embeds — they project *into* the 3D city. Cycle coverage to **VIEWSHED** and every camera draws its estimated coverage volume — where it reaches, and where it goes blind.
|
||||
|
||||

|
||||
|
||||
|
|
@ -273,7 +273,7 @@ Thirteen layers and map sources. **Eleven have a keyless path.** Some offer addi
|
|||
| 🛰️ **Satellites** | 838-object catalog, color-coded by class with a live legend — the **DENSE** chip drops in the whole Starlink shell | CelesTrak | 🟢 |
|
||||
| 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 |
|
||||
| 🚗 **Traffic** | Simulated vehicles on OSM roads. With TomTom, live flow speeds drive the simulation and congestion colors below ~8 km; individual vehicle positions are not live observations | TomTom + OSM | 🟢 simulation · 🟡 live flow speeds |
|
||||
| 📹 **CCTV Mesh** | ~800 public cameras projected *into* the 3D space — Austin · California (Caltrans) · London (TfL). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
|
||||
| 📹 **CCTV Mesh** | ~1,200 public cameras projected *into* the 3D space — Austin · California (Caltrans) · London (TfL) · Tallinn · Estonia (Tarktee). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
|
||||
| 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 |
|
||||
| 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 |
|
||||
| 🔥 **Active Fires** | Live NASA FIRMS detections, trailing 24h | NASA FIRMS | 🟡 |
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1618,7 +1618,7 @@ its criteria cannot be silently ignored.
|
|||
| Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s |
|
||||
| Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min |
|
||||
| Traffic | OSM Overpass (+ optional TomTom live flow) | `src/data/traffic.js` | `/api/overpass` + `/api/tomtom` | viewport-driven |
|
||||
| CCTV | Austin + Caltrans (CA) + TfL London Open Data + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) |
|
||||
| CCTV | Austin + Caltrans (CA) + TfL London + Tallinn + Tarktee (Estonia) Open Data + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) |
|
||||
| Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh |
|
||||
| Bikeshare 🚲 | GBFS (Lyft + BCycle) | `src/data/bikeshare.js` | `/api/gbfs` | 60s |
|
||||
| Datacenters ▣ | OSM extract (bundled) | `src/data/localLayers.js` | — | static |
|
||||
|
|
@ -1967,7 +1967,12 @@ silently demoting every later lookup for the session.
|
|||
default 36 → 250, hard bound 300), filtered to `camera_status === TURNED_ON` (~815 live of
|
||||
1,003 rows). City packs (2026-07-04): Caltrans (districts 4/7/11/3 — SF, LA, San Diego,
|
||||
Sacramento; cap 300) and TfL London JamCams (cap 250) join Austin (cap 250) as keyless default
|
||||
sources — ~800 cameras total, all RAW PRIOR poses, stills-first.
|
||||
sources — ~800 cameras total, all RAW PRIOR poses, stills-first. Estonia packs (2026-08):
|
||||
Tallinn ristmikud curated catalog (cap 255, stable `/last/camNNN.jpg` stills) and
|
||||
Transpordiamet / Tarktee DATEX2 road-weather cameras (cap 179; image URLs refresh with the
|
||||
15-minute source cache) raise the default multi-city mesh to ~1,200 cameras
|
||||
(`CCTV_MAX_SOURCES` default 1300). Disable with `CCTV_TALLINN_ENABLED=0` /
|
||||
`CCTV_TARKTEE_ENABLED=0`.
|
||||
- **CCTV v3 UX — viewshed + calibration gizmo** (built 2026-07-05 and field
|
||||
validated 2026-07-21): the COVERAGE toggle is a
|
||||
tri-state cycle `OFF → ON → VIEWSHED`; viewshed mode renders each visible camera's frustum
|
||||
|
|
|
|||
|
|
@ -0,0 +1,83 @@
|
|||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
loadTallinnSourcesFromCatalog,
|
||||
parseTarkteeDatexImages,
|
||||
parseTarkteeDatexLocations,
|
||||
} from '../../vite.config.js';
|
||||
|
||||
const SAMPLE_LOCATIONS_XML = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<d2LogicalModel xmlns="http://datex2.eu/schema/2/2_0">
|
||||
<payloadPublication>
|
||||
<predefinedLocationContainer id="TRAFFIC_CAMERAS" version="0">
|
||||
<predefinedLocation id="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" version="0">
|
||||
<predefinedLocationName>
|
||||
<values><value lang="et">Test Site</value></values>
|
||||
</predefinedLocationName>
|
||||
<location>
|
||||
<pointByCoordinates>
|
||||
<pointCoordinates>
|
||||
<latitude>59.4370</latitude>
|
||||
<longitude>24.7530</longitude>
|
||||
</pointCoordinates>
|
||||
</pointByCoordinates>
|
||||
</location>
|
||||
</predefinedLocation>
|
||||
</predefinedLocationContainer>
|
||||
</payloadPublication>
|
||||
</d2LogicalModel>`;
|
||||
|
||||
const SAMPLE_IMAGES_XML = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<d2LogicalModel xmlns="http://datex2.eu/schema/2/2_0">
|
||||
<payloadPublication>
|
||||
<trafficView id="1">
|
||||
<linearTrafficView id="1">
|
||||
<linearPredefinedLocationReference targetClass="PredefinedLocation" id="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" version="0"/>
|
||||
<trafficViewRecord id="1-1">
|
||||
<urlLink>
|
||||
<urlLinkAddress>https://tarktee.transpordiamet.ee/images/42/42_202608251542.jpg</urlLinkAddress>
|
||||
<urlLinkType>image</urlLinkType>
|
||||
</urlLink>
|
||||
</trafficViewRecord>
|
||||
</linearTrafficView>
|
||||
</trafficView>
|
||||
</payloadPublication>
|
||||
</d2LogicalModel>`;
|
||||
|
||||
test('Tallinn catalog loads curated ristmikud stills only', () => {
|
||||
const cameras = loadTallinnSourcesFromCatalog();
|
||||
assert.ok(cameras.length >= 200, `expected a full Tallinn pack, got ${cameras.length}`);
|
||||
for (const camera of cameras.slice(0, 25)) {
|
||||
assert.match(camera.id, /^tln-/);
|
||||
assert.equal(camera.cityId, 'tallinn');
|
||||
assert.equal(camera.feedType, 'image');
|
||||
assert.equal(camera.sourceKind, 'tallinn-ristmikud');
|
||||
assert.ok(camera.url.startsWith('https://ristmikud.tallinn.ee/'));
|
||||
assert.equal(camera.url, camera.snapshotUrl);
|
||||
assert.ok(Number.isFinite(camera.lat) && Number.isFinite(camera.lon));
|
||||
}
|
||||
});
|
||||
|
||||
test('Tarktee DATEX parsers join location UUIDs to image URLs', () => {
|
||||
const locations = parseTarkteeDatexLocations(SAMPLE_LOCATIONS_XML);
|
||||
const images = parseTarkteeDatexImages(SAMPLE_IMAGES_XML);
|
||||
assert.equal(locations.size, 1);
|
||||
assert.equal(images.size, 1);
|
||||
const id = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee';
|
||||
assert.equal(locations.get(id)?.name, 'Test Site');
|
||||
assert.equal(locations.get(id)?.lat, 59.437);
|
||||
assert.equal(locations.get(id)?.lon, 24.753);
|
||||
assert.equal(
|
||||
images.get(id),
|
||||
'https://tarktee.transpordiamet.ee/images/42/42_202608251542.jpg',
|
||||
);
|
||||
});
|
||||
|
||||
test('Tarktee DATEX image parser rejects non-official hosts', () => {
|
||||
const xml = SAMPLE_IMAGES_XML.replace(
|
||||
'https://tarktee.transpordiamet.ee/images/42/42_202608251542.jpg',
|
||||
'https://evil.example/images/42/42.jpg',
|
||||
);
|
||||
const images = parseTarkteeDatexImages(xml);
|
||||
assert.equal(images.size, 0);
|
||||
});
|
||||
|
|
@ -127,6 +127,18 @@ export const DATA_CREDITS = [
|
|||
'<a href="https://tfl.gov.uk/info-for/open-data-users/" target="_blank" rel="noopener">Powered by TfL Open Data</a>. ' +
|
||||
'Contains OS data © Crown copyright and database rights.',
|
||||
},
|
||||
{
|
||||
key: 'tallinn-cctv',
|
||||
html:
|
||||
'CCTV cameras & frames (Tallinn): City of Tallinn — ' +
|
||||
'<a href="https://ristmikud.tallinn.ee/" target="_blank" rel="noopener">ristmikud.tallinn.ee</a>',
|
||||
},
|
||||
{
|
||||
key: 'tarktee-cctv',
|
||||
html:
|
||||
'CCTV cameras & frames (Estonia road weather): Transpordiamet / Tarktee — ' +
|
||||
'<a href="https://tarktee.transpordiamet.ee/" target="_blank" rel="noopener">tarktee.transpordiamet.ee</a>',
|
||||
},
|
||||
{
|
||||
key: 'gbfs',
|
||||
html: 'Bikeshare availability: GBFS operator feeds (e.g. Austin BCycle)',
|
||||
|
|
|
|||
|
|
@ -81,6 +81,18 @@ export const CITY_POIS = {
|
|||
{ name: 'The Gherkin', lat: 51.5145, lon: -0.0803, alt: 350, pitch: -20, heading: 30, buildingHeight: 60 },
|
||||
],
|
||||
},
|
||||
tallinn: {
|
||||
name: 'Tallinn',
|
||||
groundElevation: 15,
|
||||
viewBounds: { southwest: { lat: 59.36, lng: 24.60 }, northeast: { lat: 59.52, lng: 24.92 } },
|
||||
pois: [
|
||||
{ name: 'Viru Square', lat: 59.4366, lon: 24.7527, alt: 450, pitch: -28, heading: 60, buildingHeight: 25 },
|
||||
{ name: 'Old Town / Raekoja plats', lat: 59.4372, lon: 24.7452, alt: 400, pitch: -30, heading: 180, buildingHeight: 20 },
|
||||
{ name: 'Teatri väljak', lat: 59.4344, lon: 24.7514, alt: 400, pitch: -25, heading: 220, buildingHeight: 25 },
|
||||
{ name: 'Port of Tallinn', lat: 59.4445, lon: 24.7675, alt: 700, pitch: -30, heading: 90, buildingHeight: 20 },
|
||||
{ name: 'Ülemiste', lat: 59.4210, lon: 24.7920, alt: 600, pitch: -28, heading: 45, buildingHeight: 30 },
|
||||
],
|
||||
},
|
||||
paris: {
|
||||
name: 'Paris',
|
||||
groundElevation: 35,
|
||||
|
|
|
|||
255
vite.config.js
255
vite.config.js
|
|
@ -3522,7 +3522,7 @@ const DEFAULT_AUSTIN_ROWS_URL = 'https://data.austintexas.gov/api/views/b4k4-adk
|
|||
/** Default cap on Austin cameras after distance-based prioritization. */
|
||||
const DEFAULT_AUSTIN_MAX_SOURCES = 250;
|
||||
/** Global cap on total CCTV sources served by the proxy. */
|
||||
const DEFAULT_CCTV_MAX_SOURCES = 900;
|
||||
const DEFAULT_CCTV_MAX_SOURCES = 1300;
|
||||
/** Reference point for Austin camera prioritization (Congress & 6th). */
|
||||
const AUSTIN_DOWNTOWN = { lat: 30.2672, lon: -97.7431 };
|
||||
/** Caltrans CCTV: one JSON feed per district, identical schema statewide. */
|
||||
|
|
@ -3543,7 +3543,24 @@ const TFL_JAMCAM_URL = 'https://api.tfl.gov.uk/Place/Type/JamCam';
|
|||
const TFL_IMAGE_ORIGIN = 'https://s3-eu-west-1.amazonaws.com/jamcams.tfl.gov.uk/';
|
||||
const DEFAULT_TFL_MAX_SOURCES = 250;
|
||||
const LONDON_CENTER = { lat: 51.5074, lon: -0.1278 };
|
||||
/** Camera CATALOGS change rarely; 15 min keeps multi-megabyte upstream list refetches (Austin rows.json + 4 Caltrans districts + TfL) infrequent. Frames are fetched per-request and are unaffected. */
|
||||
/** Tallinn intersection cameras: curated catalog + public stills on ristmikud.tallinn.ee. */
|
||||
const DEFAULT_TALLINN_SOURCE_FILE = 'config/cctv_sources.tallinn.json';
|
||||
const DEFAULT_TALLINN_MAX_SOURCES = 255;
|
||||
const TALLINN_IMAGE_ORIGIN = 'https://ristmikud.tallinn.ee/';
|
||||
const TALLINN_CENTER = { lat: 59.437, lon: 24.753 };
|
||||
/** Estonian Transpordiamet / Tarktee road-weather cameras (DATEX2 locations + image URLs). */
|
||||
const TARKTEE_LOCATIONS_URL = 'https://tarktee.transpordiamet.ee/api/v1/datex/roadCameraLocations';
|
||||
const TARKTEE_IMAGES_URL = 'https://tarktee.transpordiamet.ee/api/v1/datex/roadCameraImages';
|
||||
const TARKTEE_IMAGE_ORIGIN = 'https://tarktee.transpordiamet.ee/images/';
|
||||
const DEFAULT_TARKTEE_MAX_SOURCES = 179;
|
||||
/** Estonia anchors: Tallinn, Tartu, Pärnu, Narva — keep highway + metro cores under the pack cap. */
|
||||
const TARKTEE_ANCHORS = [
|
||||
{ lat: 59.4370, lon: 24.7530 }, // Tallinn
|
||||
{ lat: 58.3780, lon: 26.7290 }, // Tartu
|
||||
{ lat: 58.3859, lon: 24.4971 }, // Pärnu
|
||||
{ lat: 59.3797, lon: 28.1791 }, // Narva
|
||||
];
|
||||
/** Camera CATALOGS change rarely; 15 min keeps multi-megabyte upstream list refetches (Austin rows.json + 4 Caltrans districts + TfL + Tarktee DATEX) infrequent. Frames are fetched per-request and are unaffected. */
|
||||
const CCTV_SOURCE_CACHE_MS = 15 * 60 * 1000;
|
||||
/** Per-provider catalog-fetch timeout. Bounds the worst-case refresh so one
|
||||
* stalled upstream can't leave getCctvSources (and thus every CCTV route)
|
||||
|
|
@ -4168,6 +4185,214 @@ async function loadTflSourcesFromOpenData() {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load Tallinn intersection cameras from the curated catalog file.
|
||||
*
|
||||
* Frames are public JPEG stills on ristmikud.tallinn.ee (stable /last/camNNN.jpg
|
||||
* URLs). The catalog ships coordinates + curated heading priors; only official
|
||||
* ristmikud HTTPS URLs are kept (proxy fetches registered URLs only).
|
||||
*
|
||||
* @returns {Array<object>} Normalized camera source objects.
|
||||
*/
|
||||
export function loadTallinnSourcesFromCatalog() {
|
||||
const sourceFile = process.env.CCTV_TALLINN_SOURCES_FILE || DEFAULT_TALLINN_SOURCE_FILE;
|
||||
const resolved = path.isAbsolute(sourceFile)
|
||||
? sourceFile
|
||||
: path.resolve(__dirname, sourceFile);
|
||||
let rows = [];
|
||||
try {
|
||||
if (!fs.existsSync(resolved)) {
|
||||
console.warn('[CCTV] Tallinn source file missing:', resolved);
|
||||
return [];
|
||||
}
|
||||
const parsed = JSON.parse(fs.readFileSync(resolved, 'utf8'));
|
||||
rows = Array.isArray(parsed) ? parsed : [];
|
||||
} catch (error) {
|
||||
console.warn('[CCTV] Tallinn source file read error:', error?.message || error);
|
||||
return [];
|
||||
}
|
||||
|
||||
const cameras = [];
|
||||
for (const item of rows) {
|
||||
if (!item || typeof item !== 'object') continue;
|
||||
const cameraId = String(item.id || '').trim();
|
||||
if (!cameraId) continue;
|
||||
const lat = toFiniteNumber(item.lat);
|
||||
const lon = toFiniteNumber(item.lon);
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
|
||||
// Rough Estonia/Tallinn metro sanity (allows nearby suburbs already in the pack).
|
||||
if (lat < 59.20 || lat > 59.70 || lon < 24.30 || lon > 25.40) continue;
|
||||
|
||||
const imageUrl = String(item.url || item.snapshotUrl || '').trim();
|
||||
if (!imageUrl.startsWith(TALLINN_IMAGE_ORIGIN)) continue;
|
||||
|
||||
const extractedHeading = toFiniteNumber(item.headingDeg, NaN);
|
||||
const hasHeading = Number.isFinite(extractedHeading);
|
||||
const headingDeg = hasHeading ? ((extractedHeading % 360) + 360) % 360 : fallbackHeadingFromId(cameraId);
|
||||
const headingConfidence = hasHeading
|
||||
? (String(item.headingConfidence || '').toLowerCase() === 'low' ? 'low' : 'high')
|
||||
: 'low';
|
||||
cameras.push({
|
||||
id: cameraId,
|
||||
name: String(item.name || cameraId).trim(),
|
||||
city: 'Tallinn',
|
||||
cityId: 'tallinn',
|
||||
provider: 'City of Tallinn',
|
||||
lat,
|
||||
lon,
|
||||
headingDeg,
|
||||
headingConfidence,
|
||||
pitchDeg: headingConfidence === 'high' ? -24 : -18,
|
||||
fovDeg: headingConfidence === 'high' ? 56 : 44,
|
||||
rangeM: headingConfidence === 'high' ? 210 : 145,
|
||||
mountHeightM: headingConfidence === 'high' ? 10 : 8,
|
||||
groundElevationM: toFiniteNumber(item.groundElevationM, 15),
|
||||
feedType: 'image',
|
||||
url: imageUrl,
|
||||
snapshotUrl: imageUrl,
|
||||
sourceKind: 'tallinn-ristmikud',
|
||||
license: String(item.license || 'Public City of Tallinn traffic camera frame'),
|
||||
poseSource: hasHeading ? 'curated' : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const unique = Array.from(new Map(cameras.map((camera) => [camera.id, camera])).values());
|
||||
const maxRaw = Number(process.env.CCTV_TALLINN_MAX_SOURCES || DEFAULT_TALLINN_MAX_SOURCES);
|
||||
const maxCount = Number.isFinite(maxRaw) ? Math.max(8, Math.min(300, Math.floor(maxRaw))) : DEFAULT_TALLINN_MAX_SOURCES;
|
||||
const prioritized = prioritizeSources(unique, maxCount, [TALLINN_CENTER]);
|
||||
console.log(`[CCTV] Loaded Tallinn camera sources: ${unique.length} (using nearest ${prioritized.length})`);
|
||||
return prioritized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract DATEX2 predefined-location id → {name, lat, lon} from Tarktee XML.
|
||||
*
|
||||
* @param {string} xml
|
||||
* @returns {Map<string,{name:string,lat:number,lon:number}>}
|
||||
*/
|
||||
export function parseTarkteeDatexLocations(xml) {
|
||||
const out = new Map();
|
||||
const blockRe = /<predefinedLocation\s+id="([^"]+)"[^>]*>([\s\S]*?)<\/predefinedLocation>/g;
|
||||
let match;
|
||||
while ((match = blockRe.exec(String(xml || ''))) !== null) {
|
||||
const id = match[1];
|
||||
const body = match[2];
|
||||
// Skip the group container (no coordinates of its own).
|
||||
const latMatch = /<latitude>\s*(-?\d+(?:\.\d+)?)\s*<\/latitude>/i.exec(body);
|
||||
const lonMatch = /<longitude>\s*(-?\d+(?:\.\d+)?)\s*<\/longitude>/i.exec(body);
|
||||
if (!latMatch || !lonMatch) continue;
|
||||
const lat = toFiniteNumber(latMatch[1]);
|
||||
const lon = toFiniteNumber(lonMatch[1]);
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
|
||||
const nameMatch = /<value\b[^>]*>\s*([^<]+?)\s*<\/value>/i.exec(body);
|
||||
const name = nameMatch ? nameMatch[1].trim() : id;
|
||||
out.set(id, { name, lat, lon });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract DATEX2 traffic-view location id → HTTPS image URL from Tarktee XML.
|
||||
*
|
||||
* @param {string} xml
|
||||
* @returns {Map<string,string>}
|
||||
*/
|
||||
export function parseTarkteeDatexImages(xml) {
|
||||
const out = new Map();
|
||||
const blockRe = /<trafficView\b[^>]*>([\s\S]*?)<\/trafficView>/g;
|
||||
let match;
|
||||
while ((match = blockRe.exec(String(xml || ''))) !== null) {
|
||||
const body = match[1];
|
||||
const refMatch = /<linearPredefinedLocationReference\b[^>]*\bid="([^"]+)"/i.exec(body);
|
||||
const urlMatch = /<urlLinkAddress>\s*([^<\s]+)\s*<\/urlLinkAddress>/i.exec(body);
|
||||
if (!refMatch || !urlMatch) continue;
|
||||
const url = urlMatch[1].trim();
|
||||
if (!url.startsWith(TARKTEE_IMAGE_ORIGIN)) continue;
|
||||
out.set(refMatch[1], url);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Estonian Transpordiamet / Tarktee road-weather cameras via DATEX2.
|
||||
*
|
||||
* Locations and current still URLs are keyless public feeds. Image paths on the
|
||||
* ArcGIS MapServer layer go stale; DATEX always carries the current JPEG URL.
|
||||
* Only https://tarktee.transpordiamet.ee/images/… URLs are registered.
|
||||
*
|
||||
* @returns {Promise<Array<object>>} Normalized camera source objects.
|
||||
*/
|
||||
async function loadTarkteeSourcesFromDatex() {
|
||||
try {
|
||||
const [locResp, imgResp] = await Promise.all([
|
||||
fetch(TARKTEE_LOCATIONS_URL, {
|
||||
headers: { Accept: 'application/xml,text/xml,*/*' },
|
||||
signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
|
||||
}),
|
||||
fetch(TARKTEE_IMAGES_URL, {
|
||||
headers: { Accept: 'application/xml,text/xml,*/*' },
|
||||
signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
|
||||
}),
|
||||
]);
|
||||
if (!locResp.ok) {
|
||||
console.warn('[CCTV] Tarktee locations download failed:', locResp.status);
|
||||
return [];
|
||||
}
|
||||
if (!imgResp.ok) {
|
||||
console.warn('[CCTV] Tarktee images download failed:', imgResp.status);
|
||||
return [];
|
||||
}
|
||||
const [locXml, imgXml] = await Promise.all([locResp.text(), imgResp.text()]);
|
||||
const locations = parseTarkteeDatexLocations(locXml);
|
||||
const images = parseTarkteeDatexImages(imgXml);
|
||||
if (!locations.size || !images.size) {
|
||||
console.warn('[CCTV] Tarktee DATEX parse empty:', { locations: locations.size, images: images.size });
|
||||
return [];
|
||||
}
|
||||
|
||||
const cameras = [];
|
||||
for (const [locationId, loc] of locations.entries()) {
|
||||
const imageUrl = images.get(locationId);
|
||||
if (!imageUrl) continue;
|
||||
// Estonia bounding box (mainland + nearby islands).
|
||||
if (loc.lat < 57.4 || loc.lat > 59.9 || loc.lon < 21.5 || loc.lon > 28.4) continue;
|
||||
|
||||
const numMatch = /\/images\/(\d+)\//.exec(imageUrl);
|
||||
const cameraId = numMatch ? `ee-tarktee-${numMatch[1]}` : `ee-tarktee-${locationId}`;
|
||||
cameras.push({
|
||||
id: cameraId,
|
||||
name: loc.name,
|
||||
city: loc.name,
|
||||
cityId: 'estonia',
|
||||
provider: 'Transpordiamet (Tarktee)',
|
||||
lat: loc.lat,
|
||||
lon: loc.lon,
|
||||
headingDeg: fallbackHeadingFromId(cameraId),
|
||||
headingConfidence: 'low',
|
||||
pitchDeg: -18,
|
||||
fovDeg: 44,
|
||||
rangeM: 145,
|
||||
mountHeightM: 8,
|
||||
groundElevationM: 40,
|
||||
feedType: 'image',
|
||||
url: imageUrl,
|
||||
snapshotUrl: imageUrl,
|
||||
sourceKind: 'tarktee-datex',
|
||||
license: 'Public Transpordiamet / Tarktee road weather camera frame',
|
||||
});
|
||||
}
|
||||
|
||||
const maxRaw = Number(process.env.CCTV_TARKTEE_MAX_SOURCES || DEFAULT_TARKTEE_MAX_SOURCES);
|
||||
const maxCount = Number.isFinite(maxRaw) ? Math.max(8, Math.min(300, Math.floor(maxRaw))) : DEFAULT_TARKTEE_MAX_SOURCES;
|
||||
const prioritized = prioritizeSources(cameras, maxCount, TARKTEE_ANCHORS);
|
||||
console.log(`[CCTV] Loaded Tarktee camera sources: ${cameras.length} with images (using nearest ${prioritized.length})`);
|
||||
return prioritized;
|
||||
} catch (error) {
|
||||
console.warn('[CCTV] Tarktee DATEX download error:', error?.message || error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a raw CCTV source item into a canonical shape with safe defaults.
|
||||
*
|
||||
|
|
@ -4239,27 +4464,35 @@ async function refreshCctvSources() {
|
|||
|
||||
const forceAustin = String(process.env.CCTV_FORCE_AUSTIN || '').trim() === '1';
|
||||
const preferAustin = String(process.env.CCTV_PREFER_AUSTIN || '1').trim() !== '0';
|
||||
// Live open-data packs (Austin + Caltrans + TfL) load unless a file/env pack
|
||||
// is configured and live packs aren't forced — same gate that governed the
|
||||
// Austin-only fetch, now governing all three. Each pack fails independently.
|
||||
// Live open-data packs (Austin + Caltrans + TfL + Tallinn + Tarktee) load unless a
|
||||
// file/env pack is configured and live packs aren't forced — same gate that
|
||||
// governed the Austin-only fetch, now governing all five. Each pack fails independently.
|
||||
const needsLiveSources = forceAustin || ((fromFile.length + fromEnv.length) === 0 && preferAustin);
|
||||
const tflEnabled = String(process.env.CCTV_TFL_ENABLED || '1').trim() !== '0';
|
||||
const tallinnEnabled = String(process.env.CCTV_TALLINN_ENABLED || '1').trim() !== '0';
|
||||
const tarkteeEnabled = String(process.env.CCTV_TARKTEE_ENABLED || '1').trim() !== '0';
|
||||
|
||||
let fromAustin = [];
|
||||
let fromCaltrans = [];
|
||||
let fromTfl = [];
|
||||
let fromTallinn = [];
|
||||
let fromTarktee = [];
|
||||
if (needsLiveSources) {
|
||||
const [austinResult, caltransResult, tflResult] = await Promise.allSettled([
|
||||
const [austinResult, caltransResult, tflResult, tallinnResult, tarkteeResult] = await Promise.allSettled([
|
||||
loadAustinSourcesFromOpenData(),
|
||||
loadCaltransSourcesFromOpenData(),
|
||||
tflEnabled ? loadTflSourcesFromOpenData() : Promise.resolve([]),
|
||||
tallinnEnabled ? Promise.resolve(loadTallinnSourcesFromCatalog()) : Promise.resolve([]),
|
||||
tarkteeEnabled ? loadTarkteeSourcesFromDatex() : Promise.resolve([]),
|
||||
]);
|
||||
fromAustin = austinResult.status === 'fulfilled' ? austinResult.value : [];
|
||||
fromCaltrans = caltransResult.status === 'fulfilled' ? caltransResult.value : [];
|
||||
fromTfl = tflResult.status === 'fulfilled' ? tflResult.value : [];
|
||||
fromTallinn = tallinnResult.status === 'fulfilled' ? tallinnResult.value : [];
|
||||
fromTarktee = tarkteeResult.status === 'fulfilled' ? tarkteeResult.value : [];
|
||||
}
|
||||
// Live sources first so file/env overrides win on duplicate IDs (Map last-write).
|
||||
const merged = [...fromAustin, ...fromCaltrans, ...fromTfl, ...fromFile, ...fromEnv];
|
||||
const merged = [...fromAustin, ...fromCaltrans, ...fromTfl, ...fromTallinn, ...fromTarktee, ...fromFile, ...fromEnv];
|
||||
|
||||
// Deduplicate by camera ID (last-write wins because of Map.set)
|
||||
const byId = new Map();
|
||||
|
|
@ -4272,7 +4505,7 @@ async function refreshCctvSources() {
|
|||
|
||||
const mergedSources = Array.from(byId.values());
|
||||
const maxRaw = Number(process.env.CCTV_MAX_SOURCES || DEFAULT_CCTV_MAX_SOURCES);
|
||||
const maxCount = Number.isFinite(maxRaw) ? Math.max(8, Math.min(1200, Math.floor(maxRaw))) : DEFAULT_CCTV_MAX_SOURCES;
|
||||
const maxCount = Number.isFinite(maxRaw) ? Math.max(8, Math.min(1600, Math.floor(maxRaw))) : DEFAULT_CCTV_MAX_SOURCES;
|
||||
if (mergedSources.length > maxCount) {
|
||||
console.warn(`[CCTV] source catalog ${mergedSources.length} exceeds cap ${maxCount}; keeping the first ${maxCount} (raise CCTV_MAX_SOURCES or lower a per-pack cap to change which).`);
|
||||
}
|
||||
|
|
@ -4519,9 +4752,9 @@ function cctvProxy() {
|
|||
/** @type {Map<string,{id:string,status:string,sourceKind:string,label:string,message:string,updatedAt:number}>} */
|
||||
const health = new Map();
|
||||
/** Cap on health map entries to prevent unbounded growth. Sized to cover the
|
||||
* full served catalog (CCTV_MAX_SOURCES hard-bounds at 1200) so health/status
|
||||
* observability isn't silently evicted for a default 800-camera catalog. */
|
||||
const HEALTH_MAX_ENTRIES = 1200;
|
||||
* full served catalog (CCTV_MAX_SOURCES hard-bounds at 1600) so health/status
|
||||
* observability isn't silently evicted for a default ~1200-camera catalog. */
|
||||
const HEALTH_MAX_ENTRIES = 1600;
|
||||
|
||||
/** Update the health entry for a camera, evicting the oldest entry if at capacity. */
|
||||
const setHealth = (cameraId, patch) => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue