mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +00:00
Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
664c18d97a | ||
|
|
fb3506cc20 | ||
|
|
f927ef288a | ||
|
|
d90bae49b6 | ||
|
|
ea09a59877 | ||
|
|
5b4f5ed4a0 | ||
|
|
1bccc295f0 | ||
|
|
4416a491d2 | ||
|
|
4171ebb6a8 | ||
|
|
0561277dcb | ||
|
|
e9a234094b | ||
|
|
7cdaf40fd0 | ||
|
|
0a4d520e79 | ||
|
|
695cce967d | ||
|
|
42c95a8a24 | ||
|
|
29d8fb4101 | ||
|
|
7a31ef5e8a | ||
|
|
7dfecd7d98 | ||
|
|
ea06c9fc64 | ||
|
|
83b9940733 | ||
|
|
91461381ee | ||
|
|
cd0956eef7 | ||
|
|
fa085cbd64 | ||
|
|
48c8a64c97 | ||
|
|
77dfa718a9 | ||
|
|
436ffbb21c | ||
|
|
1a0b9c8b0f |
No files matched your search
@@ -207,6 +207,7 @@ AISSTREAM_API_KEY=
|
||||
# CCTV_TFL_ENABLED=1 # set 0 to disable Transport for London JamCams
|
||||
# CCTV_TFL_MAX_SOURCES=250 # cap cameras loaded from TfL
|
||||
# CCTV_ONTARIO_ENABLED=1 # set 0 to disable Ontario 511 cameras
|
||||
# ONTARIO_511_API_KEY= # free developer key: sign up at 511on.ca/my511/register, then request it at 511on.ca/developers/doc
|
||||
# CCTV_ONTARIO_MAX_SOURCES=1000 # cap cameras loaded from Ontario 511
|
||||
# CCTV_FINTRAFFIC_ENABLED=1 # set 0 to drop the Fintraffic (Finland) pack
|
||||
# CCTV_FINTRAFFIC_MAX_SOURCES=300 # cap cameras loaded from the Fintraffic catalog
|
||||
|
||||
+1
-1
@@ -154,7 +154,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
|
||||
- **adsbdb enrichment.** `/api/adsbdb` resolves an ICAO hex to aircraft type, model name and registration, and the tracked flight's callsign to an airline and an origin/destination pair. Fan-out is deliberately small because adsbdb is a free community API that publishes no quota: at most 4 requests in flight, dispatches dripped to roughly 5/s, and each key asked for at most once per session, with route lookups reserved for the tracked aircraft. The dev-server proxy caches each key for 24 hours in `.gev-cache/adsbdb.json`, negative answers included, so fresh entries survive server restarts. That gitignored cache remains local to the deployment and is not bundled with this repo. adsbdb publishes no general service license; its README says the route data may not be copied, published, or incorporated into other databases without the explicit permission of David J Taylor, Edinburgh. Deployers are responsible for obtaining any permission their use requires. Enrichment is best-effort and fail-silent — a flight that gets no answer keeps what the flight feed already gave it.
|
||||
- **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.
|
||||
- **Ontario 511 CCTV.** The camera list comes from the keyless `511on.ca/api/v2/get/cameras` endpoint; frames are server-registered still images under `511on.ca/map/Cctv/`. The source is live-fetched at runtime and attributed under the Open Government Licence - Ontario.
|
||||
- **Ontario 511 CCTV.** The camera list comes from the `511on.ca/api/v2/get/cameras` endpoint, which now requires a free developer key (`ONTARIO_511_API_KEY`: sign up at 511on.ca/my511/register, then request it while logged in at 511on.ca/developers/doc; throttled to 10 calls a minute). Without the key the pack is skipped. Frames are server-registered still images under `511on.ca/map/Cctv/`. The source is live-fetched at runtime and attributed under the Open Government Licence - Ontario. Access is governed by Ontario 511's Developer's Access Agreement (511on.ca/developers/resources): the data stays under the Open Government Licence - Ontario unless otherwise specified; key requests are reviewed case by case and ask for the intended use and any third parties that will receive the data; attribution by link to 511on.ca is encouraged, and the Ontario 511 logo may not be altered or used to imply endorsement (this app links and shows no logo); and the data is provided "as is", a disclaimer distributors should pass on, which the credit does.
|
||||
- **DelDOT live video cameras.** The keyless `tmc.deldot.gov/json/videocamera.json` catalog supplies active cameras and official `https://video.deldot.gov/live/…/playlist.m3u8` links. The local server transiently buffers bounded MPEG-TS segments in memory and deletes them on session release or idle expiry. No recordings are retained. `CCTV_DELDOT_ENABLED=0` disables the pack. Public availability is not a blanket redistribution license; deployment beyond personal local viewing needs confirmation of applicable DelDOT terms.
|
||||
- **Fintraffic weathercams.** The camera list is one keyless GeoJSON request to `tie.digitraffic.fi/api/weathercam/v1/stations` (809 stations / 2,275 presets), sent with the `Digitraffic-User: gods-eye-view` header the service asks clients to identify themselves with and with gzip; frames are stills on `weathercam.digitraffic.fi`, refreshed on each station's 600 s collection interval, which is also the client's ambient-still refresh cadence for this pack (the active camera keeps the usual 10-second refresh). One preset (a fixed camera view) is one camera; stations not `GATHERING` and presets not `inCollection` are dropped. Attribution is required by the CC BY 4.0 licence and is registered in the Data attribution popover. `CCTV_FINTRAFFIC_ENABLED=0` is the kill switch.
|
||||
- **DriveBC highway cameras.** The camera list comes from the keyless `https://www.drivebc.ca/api/webcams/` endpoint served by the DriveBC.ca site ([bcgov/DriveBC.ca](https://github.com/bcgov/DriveBC.ca)); frames come from `https://www.drivebc.ca/images/<id>.jpg`, built from the numeric camera id. DataBC's [DriveBC HighwayCams](https://catalogue.data.gov.bc.ca/dataset/bc-highwaycams) catalogue entry publishes the same cameras under the Open Government Licence – British Columbia, but its CSV still lists the retired `images.drivebc.ca` frame URLs, which now return a placeholder. The licence's attribution statement is required and is registered in the Data attribution popover. By default the 250 cameras nearest Vancouver and Victoria load; `CCTV_DRIVEBC_MAX_SOURCES` changes the cap and `CCTV_DRIVEBC_ENABLED=0` turns the pack off. Some cameras in the feed are supplied by partners (TransLink, the City of Vancouver, the City of Surrey, Parks Canada / Alberta Motor Association and others); the feed's per-camera credit is carried onto the camera record and shown beside the provider in the CCTV panel.
|
||||
|
||||
@@ -381,7 +381,7 @@ Twenty layers and map sources. **Eighteen have a keyless path.** Some offer addi
|
||||
| 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 |
|
||||
| ◍ **Air quality** | Live AQHI on the published 1–10+ health-risk scale, with a Weather-panel card showing the band legend and the highest readings in view. **Canada only** — ECCC is the first registered provider | ECCC MSC GeoMet | 🟢 |
|
||||
| 🚗 **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** | ~4,000 public cameras (shared cap) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen) · Toronto · King County (WA) · Washington (WSDOT) · Catalonia (Servei Català de Trànsit), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement), opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed) and opt-in Florida (FL511, individual personal use only; other use needs FDOT's written consent). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
|
||||
| 📹 **CCTV Mesh** | ~4,000 public cameras (shared cap) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511, free developer key) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen) · Toronto · King County (WA) · Washington (WSDOT) · Catalonia (Servei Català de Trànsit), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement), opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed) and opt-in Florida (FL511, individual personal use only; other use needs FDOT's written consent). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
|
||||
| 📷 **Mapped ALPR Cameras** | License-plate-reader camera locations tagged by OpenStreetMap contributors, loaded one city-sized view at a time, with **SHOW NEAREST**. Locations and tags only: no plate data, no video | OpenStreetMap (incl. DeFlock mapping) | 🟢 |
|
||||
| 📷 **Street Level** | Street-level photos behind one panel, one chip per provider: coverage on the streets, image cones, an embedded viewer and **FOLLOW**. Mapillary, Google Street View, KartaView and Panoramax | Mapillary · Google · KartaView · Panoramax | 🟢 KartaView, Panoramax · 🟡 Mapillary token · 🔴 Google Maps key |
|
||||
| 📻 **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 | 🟢 |
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
- Make CCTV QA ground-sampling checks respect the active terrain regime and
|
||||
frame the actual calibration gizmo for the drag regression, preserving the
|
||||
photoreal completion floor and all drag invariants.
|
||||
- Measure the tracking arrival regression on the first visible frame within
|
||||
250 ms, accounting for the 80 ms fleet cadence while rejecting a stale
|
||||
first-visible orientation instead of retrying until it is correct.
|
||||
@@ -0,0 +1,6 @@
|
||||
- Load Ontario 511 cameras again. 511on.ca's camera endpoint now requires a
|
||||
free developer key; set `ONTARIO_511_API_KEY` to enable the pack, which is
|
||||
otherwise skipped with one log line saying where to get a key
|
||||
(quanticsoul4772, #1228).
|
||||
- Refuse Ontario catalog redirects and keep credential-bearing URLs and provider
|
||||
exception text out of failure logs; retain the Ontario licence attribution.
|
||||
@@ -1,4 +1,5 @@
|
||||
- Harden the Tarktee (Estonia) DATEX2 camera catalog fetches: refuse redirects,
|
||||
cancel both response streams when either response fails or overflows the 4 MB
|
||||
byte cap, and deduplicate camera IDs before per-pack prioritization
|
||||
(yaka0007, #1233).
|
||||
abort pending siblings and cancel received or late response streams when either
|
||||
request fails before headers or overflows the 4 MB
|
||||
byte cap, retain the shared 30 s dual-XML fetch deadline, and deduplicate camera
|
||||
IDs before per-pack prioritization (yaka0007, #1233).
|
||||
+10
-3
@@ -4441,10 +4441,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. Ontario 511 (2026-09-12) adds keyless highway cameras including Kitchener-area routes
|
||||
(cap 1,000, all enabled rows from the current ~944-camera catalog). All sources are RAW PRIOR
|
||||
sources. Ontario 511 (2026-09-12) adds highway cameras including Kitchener-area routes
|
||||
(cap 1,000, all enabled rows from the current ~944-camera catalog). The 511on.ca API now
|
||||
requires a developer key, so the pack needs `ONTARIO_511_API_KEY` and is skipped without one
|
||||
(#1228). All sources are RAW PRIOR
|
||||
poses, and the layer is stills-first.
|
||||
sources. Fintraffic Finland road weather cameras (2026-09-13; cap 300, `CCTV_FINTRAFFIC_MAX_SOURCES`,
|
||||
Fintraffic Finland road weather cameras (2026-09-13; cap 300, `CCTV_FINTRAFFIC_MAX_SOURCES`,
|
||||
kill switch `CCTV_FINTRAFFIC_ENABLED=0`) are the fourth pack: one keyless GeoJSON station list
|
||||
covering the whole country, where one _preset_ (a station's fixed view) is one camera — 806
|
||||
GATHERING stations carry 2,256 in-collection presets, prioritized to 300 against seven anchors
|
||||
@@ -4457,6 +4459,11 @@ silently demoting every later lookup for the session.
|
||||
empty keeps the old catalog and is retried after another 15 minutes, not on the next
|
||||
request. Estonia's Tarktee DATEX feeds, which answer in 2 to 16.6 s, get 30 s
|
||||
(`TARKTEE_FETCH_TIMEOUT_MS`) where other packs get 15 s.
|
||||
Ontario catalog requests refuse redirects and log only a fixed diagnostic for
|
||||
transport/JSON failures, so reflected request URLs or keys cannot reach logs.
|
||||
Tarktee owns both XML requests with one 30-second deadline and shared abort;
|
||||
a failed header or body read cancels its sibling, including a response arriving
|
||||
after cleanup.
|
||||
Both Tarktee XML catalogs refuse redirects and have a 4 MiB per-document
|
||||
limit, enforced against declared length and streamed bytes. A rejected fetch,
|
||||
unsuccessful response, or body-read failure releases both available response
|
||||
|
||||
@@ -1797,6 +1797,34 @@ async function main() {
|
||||
gizmoStates.map((s, i) => `${GIZMO_PARTS[i]}=${s}`).join(' '),
|
||||
);
|
||||
|
||||
// Frame the actual gizmo for the drag fixture. Default autofocus can leave
|
||||
// its shaft below the 10px interaction threshold; that distant view is not
|
||||
// the drag-usability scenario this regression exercises.
|
||||
const dragFraming = await page.evaluate(async () => {
|
||||
const viewer = window.__godsEyeView.viewer;
|
||||
const e = viewer.entities.getById('cctv-gizmo-move-east');
|
||||
if (!e) return null;
|
||||
const pts = e.polyline.positions.getValue(viewer.clock.currentTime);
|
||||
const target = pts?.[0];
|
||||
if (!target) return null;
|
||||
const measure = () => {
|
||||
const a = viewer.scene.cartesianToCanvasCoordinates(pts[0]);
|
||||
const b = viewer.scene.cartesianToCanvasCoordinates(pts[pts.length - 1]);
|
||||
return {
|
||||
shaftPx: a && b ? Math.hypot(b.x - a.x, b.y - a.y) : null,
|
||||
rangeM: target.constructor.distance(viewer.camera.positionWC, target),
|
||||
};
|
||||
};
|
||||
const before = measure();
|
||||
viewer.camera.cancelFlight();
|
||||
viewer.camera.lookAt(target, new target.constructor(0, -200, 180));
|
||||
viewer.camera.lookAtTransform(viewer.camera.transform.constructor.IDENTITY);
|
||||
viewer.scene.requestRender();
|
||||
const frames = await window.__qaRenderFrames(3, 10000);
|
||||
return { before, after: measure(), frames };
|
||||
});
|
||||
console.log('Drag fixture framing:', JSON.stringify(dragFraming));
|
||||
|
||||
// Synthetic CDP drag on the EAST arrow — the owner's field-test
|
||||
// regression. Sample effective elevation after every mouse move: it must
|
||||
// stay frozen until release, with no transient sampleHeight calls.
|
||||
|
||||
@@ -2696,9 +2696,9 @@ async function main() {
|
||||
// Staged deterministically: park the camera nadir over the synthetic
|
||||
// cluster, let rotations settle, TAMPER a billboard's rotation, then
|
||||
// trigger each path with the camera idle. The idle-drift catch-up
|
||||
// (ROTATION_REFRESH_MS = 1 s) can coincidentally mask a regression in a
|
||||
// ~300 ms SwiftShader frame window, so a regression shows as an
|
||||
// intermittent (not guaranteed) failure — never a false failure.
|
||||
// (ROTATION_REFRESH_MS = 1 s) must not repair a stale first-visible nose
|
||||
// before we inspect it: capture that first postRender, with a 250 ms
|
||||
// deadline that admits the 80 ms fleet cadence without waiting a second.
|
||||
// ============================================================
|
||||
console.log('\nChange 4 — arrival rotation: moveEnd settle pass + horizon-reveal pass');
|
||||
const arrival = await evalPage(async () => {
|
||||
@@ -2762,6 +2762,43 @@ async function main() {
|
||||
});
|
||||
await nextFrames(3);
|
||||
const angDiff = (a, b) => Math.abs(Math.atan2(Math.sin(a - b), Math.cos(a - b)));
|
||||
// Render frames are not fleet ticks: at 60/120 Hz four frames can
|
||||
// precede the next 80 ms fleet pass. Observe elapsed time instead, and
|
||||
// freeze the angle on the FIRST visible frame, even when it is wrong.
|
||||
const observeFirstReveal = (bb, course, budgetMs = 250) => new Promise((resolve) => {
|
||||
const startedAt = performance.now();
|
||||
let frames = 0;
|
||||
let settled = false;
|
||||
const finish = (shown, elapsedMs) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
remove();
|
||||
const expected = shown
|
||||
? screenProjectedRotation(v.scene, bb.position, course, null)
|
||||
: null;
|
||||
resolve({
|
||||
shown,
|
||||
flipFrame: shown ? frames : -1,
|
||||
revealElapsedMs: elapsedMs,
|
||||
revealWithinBudget: shown && elapsedMs <= budgetMs,
|
||||
dReveal: shown && Number.isFinite(expected) ? angDiff(bb.rotation, expected) : null,
|
||||
});
|
||||
};
|
||||
const remove = v.scene.postRender.addEventListener(() => {
|
||||
frames += 1;
|
||||
const elapsedMs = performance.now() - startedAt;
|
||||
if (bb.show) {
|
||||
finish(true, elapsedMs);
|
||||
} else if (elapsedMs >= budgetMs) {
|
||||
finish(false, elapsedMs);
|
||||
} else {
|
||||
v.scene.requestRender();
|
||||
}
|
||||
});
|
||||
const timer = setTimeout(() => finish(false, performance.now() - startedAt), budgetMs);
|
||||
v.scene.requestRender();
|
||||
});
|
||||
const probe = async (id, course) => {
|
||||
const bb = findBB(id);
|
||||
if (!bb || !bb.show) return { error: `${id} missing/hidden` };
|
||||
@@ -2779,25 +2816,17 @@ async function main() {
|
||||
const expectedMoveEnd = screenProjectedRotation(v.scene, bb.position, course, null);
|
||||
// (b) reveal pass: tamper + hide — the next fleet tick must flip it
|
||||
// visible AND correct the nose in that same tick (camera still idle,
|
||||
// no moveEnd raised). Diagnostic fields (round 5): record WHICH frame
|
||||
// the flip landed on (flipFrame; -1 = never within the extended
|
||||
// window) and whether a 3D model owns this id — distinguishes "tick
|
||||
// slow under load" from "occluder says hidden" from "model handoff".
|
||||
// no moveEnd raised). Record frame count as diagnostics only: the
|
||||
// first visible postRender must already have the corrected rotation.
|
||||
bb.rotation = r0 + 1.5;
|
||||
bb.show = false;
|
||||
let flipFrame = -1;
|
||||
for (let f = 1; f <= 6; f++) {
|
||||
await nextFrames(1);
|
||||
if (bb.show && flipFrame === -1) flipFrame = f;
|
||||
if (f >= 2 && flipFrame !== -1) break;
|
||||
}
|
||||
const expectedReveal = screenProjectedRotation(v.scene, bb.position, course, null);
|
||||
const reveal = await observeFirstReveal(bb, course);
|
||||
const hasModel = !!(window.__g3dFindModel && window.__g3dFindModel(id));
|
||||
return {
|
||||
r0,
|
||||
dMoveEnd: angDiff(afterMoveEnd, expectedMoveEnd),
|
||||
dReveal: angDiff(bb.rotation, expectedReveal),
|
||||
shown: bb.show, flipFrame, hasModel,
|
||||
...reveal,
|
||||
hasModel,
|
||||
};
|
||||
};
|
||||
const flightCourse = window.__SYNTH.flights.find((f) => f.icao === 'aaa002')?.track ?? 0;
|
||||
@@ -2808,20 +2837,18 @@ async function main() {
|
||||
};
|
||||
});
|
||||
const fmtArr = (a) => (!a ? `no result (${arrival?.error || 'phase error'})` : a.error ? a.error
|
||||
: `settle-err=${(a.dMoveEnd * 180 / Math.PI).toFixed(1)}° reveal-err=${(a.dReveal * 180 / Math.PI).toFixed(1)}° shown=${a.shown} flipFrame=${a.flipFrame} hasModel=${a.hasModel}`);
|
||||
: `settle-err=${(a.dMoveEnd * 180 / Math.PI).toFixed(1)}° reveal-err=${a.dReveal === null ? 'unobserved' : (a.dReveal * 180 / Math.PI).toFixed(1) + '°'} shown=${a.shown} flipFrame=${a.flipFrame} revealMs=${a.revealElapsedMs.toFixed(1)} hasModel=${a.hasModel}`);
|
||||
const TAMPER_DEG = 1.5 * 180 / Math.PI; // ≈ 86° — an uncorrected nose stays this far off
|
||||
// Reveal budget re-pinned 2 → 4 frames (round 5): the diagnostic probe
|
||||
// shows the military flip consistently lands on frame 2 — the exact edge
|
||||
// of the old budget — so any load hiccup flaked it (flights lands frame
|
||||
// 1). The regression this protects against is a reveal that waits for
|
||||
// the 1 s idle catch-up (~30-60 frames) or never re-aims; 4 frames still
|
||||
// catches that with a wide margin, with zero-headroom flakes gone.
|
||||
// 250 ms spans multiple 80 ms fleet opportunities while remaining well
|
||||
// below the 1 s idle catch-up. Never retry until the angle becomes correct:
|
||||
// dReveal is the immutable first-visible-frame observation above.
|
||||
const arrOk = (a) => !!a && !a.error && a.shown
|
||||
&& (a.flipFrame === -1 ? false : a.flipFrame <= 4)
|
||||
&& a.revealWithinBudget && a.revealElapsedMs <= 250
|
||||
&& a.dReveal !== null
|
||||
&& a.dMoveEnd * 180 / Math.PI < 10 && a.dReveal * 180 / Math.PI < 10;
|
||||
record(`arrival: moveEnd + reveal both re-aim within 4 frames, flights (tamper was ${TAMPER_DEG.toFixed(0)}°)`,
|
||||
record(`arrival: moveEnd re-aims and first reveal is correct within 250 ms, flights (tamper was ${TAMPER_DEG.toFixed(0)}°)`,
|
||||
arrOk(arrival.flights), fmtArr(arrival.flights));
|
||||
record(`arrival: moveEnd + reveal both re-aim within 4 frames, military (tamper was ${TAMPER_DEG.toFixed(0)}°)`,
|
||||
record(`arrival: moveEnd re-aims and first reveal is correct within 250 ms, military (tamper was ${TAMPER_DEG.toFixed(0)}°)`,
|
||||
arrOk(arrival.military), fmtArr(arrival.military));
|
||||
|
||||
// ============================================================
|
||||
|
||||
@@ -140,7 +140,7 @@ export const TFL_IMAGE_ORIGIN =
|
||||
'https://s3-eu-west-1.amazonaws.com/jamcams.tfl.gov.uk/';
|
||||
export const DEFAULT_TFL_MAX_SOURCES = 250;
|
||||
export const LONDON_CENTER = { lat: 51.5074, lon: -0.1278 };
|
||||
/** Ontario 511: keyless CARS/511 camera catalog; frame URLs are still images. */
|
||||
/** Ontario 511: CARS/511 camera catalog, which needs a developer key (ONTARIO_511_API_KEY); frame URLs are still images. */
|
||||
export const ONTARIO_511_CAMERAS_URL =
|
||||
'https://511on.ca/api/v2/get/cameras?format=json&lang=en';
|
||||
export const ONTARIO_511_IMAGE_ORIGIN = 'https://511on.ca/map/Cctv/';
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
_resetOntarioKeyWarningForTest,
|
||||
loadOntarioSourcesFromOpenData,
|
||||
} from './sources.js';
|
||||
|
||||
const ROW = {
|
||||
Id: '42',
|
||||
Latitude: 43.6532,
|
||||
Longitude: -79.3832,
|
||||
Location: 'Hwy 401 near Yonge St',
|
||||
Direction: 'Eastbound',
|
||||
Views: [
|
||||
{
|
||||
Status: 'Enabled',
|
||||
Url: 'https://511on.ca/map/Cctv/4201',
|
||||
Description: 'Eastbound',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function captureConsole(t) {
|
||||
const lines = [];
|
||||
const capture = (...args) => lines.push(args.join(' '));
|
||||
t.mock.method(console, 'warn', capture);
|
||||
t.mock.method(console, 'log', capture);
|
||||
return lines;
|
||||
}
|
||||
|
||||
test.beforeEach(() => _resetOntarioKeyWarningForTest());
|
||||
|
||||
test('without ONTARIO_511_API_KEY the Ontario pack is skipped, logged once', async (t) => {
|
||||
const lines = captureConsole(t);
|
||||
let fetches = 0;
|
||||
t.mock.method(globalThis, 'fetch', async () => {
|
||||
fetches += 1;
|
||||
return Response.json([ROW]);
|
||||
});
|
||||
assert.deepEqual(await loadOntarioSourcesFromOpenData({}, {}), []);
|
||||
assert.deepEqual(
|
||||
await loadOntarioSourcesFromOpenData({}, { ONTARIO_511_API_KEY: ' ' }),
|
||||
[],
|
||||
);
|
||||
assert.equal(fetches, 0);
|
||||
assert.equal(lines.length, 1);
|
||||
assert.match(lines[0], /ONTARIO_511_API_KEY/);
|
||||
});
|
||||
|
||||
test('with a key the camera list is requested with it and parsed', async (t) => {
|
||||
const lines = captureConsole(t);
|
||||
const requested = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
requested.push(String(url));
|
||||
return Response.json([ROW]);
|
||||
});
|
||||
const cameras = await loadOntarioSourcesFromOpenData(
|
||||
{ sourceRoot: '/ignored' },
|
||||
{ ONTARIO_511_API_KEY: ' test-key ' },
|
||||
);
|
||||
assert.equal(requested.length, 1);
|
||||
const url = new URL(requested[0]);
|
||||
assert.equal(url.host, '511on.ca');
|
||||
assert.equal(url.searchParams.get('key'), 'test-key');
|
||||
assert.equal(url.searchParams.get('format'), 'json');
|
||||
assert.equal(cameras.length, 1);
|
||||
assert.equal(cameras[0].id, 'on-42');
|
||||
assert.equal(cameras[0].url, 'https://511on.ca/map/Cctv/4201');
|
||||
// The records go to the browser through /api/cctv; the key must stay on
|
||||
// the server-side catalog request.
|
||||
assert.doesNotMatch(JSON.stringify(cameras), /test-key/);
|
||||
assert.doesNotMatch(lines.join('\n'), /test-key/);
|
||||
});
|
||||
|
||||
test('the source cap is read from the same environment as the key', async (t) => {
|
||||
captureConsole(t);
|
||||
const rows = Array.from({ length: 12 }, (_, i) => ({
|
||||
...ROW,
|
||||
Id: String(100 + i),
|
||||
Latitude: ROW.Latitude + i * 0.01,
|
||||
Views: [{ ...ROW.Views[0], Url: `https://511on.ca/map/Cctv/${100 + i}` }],
|
||||
}));
|
||||
t.mock.method(globalThis, 'fetch', async () => Response.json(rows));
|
||||
const cameras = await loadOntarioSourcesFromOpenData(
|
||||
{},
|
||||
{ ONTARIO_511_API_KEY: 'test-key', CCTV_ONTARIO_MAX_SOURCES: '8' },
|
||||
);
|
||||
assert.equal(cameras.length, 8);
|
||||
});
|
||||
|
||||
for (const status of [400, 401, 403]) {
|
||||
test(`a ${status} points at the key by name, never by value`, async (t) => {
|
||||
const lines = captureConsole(t);
|
||||
t.mock.method(globalThis, 'fetch', async () =>
|
||||
Response.json({ Message: 'Invalid Key' }, { status }),
|
||||
);
|
||||
const cameras = await loadOntarioSourcesFromOpenData(
|
||||
{},
|
||||
{ ONTARIO_511_API_KEY: 'secret-key-value' },
|
||||
);
|
||||
assert.deepEqual(cameras, []);
|
||||
assert.match(
|
||||
lines.join('\n'),
|
||||
new RegExp(`failed: ${status} \\(check ONTARIO_511_API_KEY\\)`),
|
||||
);
|
||||
assert.doesNotMatch(lines.join('\n'), /secret-key-value/);
|
||||
});
|
||||
}
|
||||
|
||||
for (const status of [429, 500, 503]) {
|
||||
test(`a ${status} is reported without blaming the key`, async (t) => {
|
||||
const lines = captureConsole(t);
|
||||
t.mock.method(globalThis, 'fetch', async () =>
|
||||
Response.json({}, { status }),
|
||||
);
|
||||
const cameras = await loadOntarioSourcesFromOpenData(
|
||||
{},
|
||||
{ ONTARIO_511_API_KEY: 'secret-key-value' },
|
||||
);
|
||||
assert.deepEqual(cameras, []);
|
||||
assert.equal(lines.length, 1);
|
||||
assert.equal(
|
||||
lines[0],
|
||||
`[CCTV] Ontario 511 camera download failed: ${status}`,
|
||||
);
|
||||
assert.doesNotMatch(lines[0], /secret-key-value/);
|
||||
});
|
||||
}
|
||||
|
||||
for (const failure of ['transport', 'invalid-json']) {
|
||||
test(`Ontario ${failure} failures cannot log reflected keys or request URLs`, async (t) => {
|
||||
const lines = captureConsole(t);
|
||||
const key = 'synthetic secret&/=%value';
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
if (failure === 'transport')
|
||||
throw new Error(`request failed: ${url} ${key}`);
|
||||
return new Response(`not JSON: ${url} ${key}`);
|
||||
});
|
||||
assert.deepEqual(
|
||||
await loadOntarioSourcesFromOpenData({}, { ONTARIO_511_API_KEY: key }),
|
||||
[],
|
||||
);
|
||||
assert.equal(lines.length, 1);
|
||||
assert.match(lines[0], /Ontario 511 camera download error/);
|
||||
assert.equal(lines[0].includes(key), false);
|
||||
assert.equal(lines[0].includes('synthetic'), false);
|
||||
assert.equal(lines[0].includes('https://'), false);
|
||||
});
|
||||
}
|
||||
|
||||
test('Ontario encodes reserved key characters and refuses credential-bearing redirects', async (t) => {
|
||||
const lines = captureConsole(t);
|
||||
const key = 'synthetic &/=% value';
|
||||
let request;
|
||||
t.mock.method(globalThis, 'fetch', async (url, init) => {
|
||||
request = { url: new URL(url), init };
|
||||
return Response.json([ROW]);
|
||||
});
|
||||
const cameras = await loadOntarioSourcesFromOpenData(
|
||||
{},
|
||||
{ ONTARIO_511_API_KEY: key },
|
||||
);
|
||||
assert.equal(request.url.searchParams.get('key'), key);
|
||||
assert.equal(request.init.redirect, 'error');
|
||||
assert.equal(cameras.length, 1);
|
||||
assert.equal(JSON.stringify(cameras).includes('synthetic'), false);
|
||||
assert.equal(lines.join(' ').includes('synthetic'), false);
|
||||
});
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
TARKTEE_IMAGE_ORIGIN,
|
||||
DEFAULT_TARKTEE_MAX_SOURCES,
|
||||
TARKTEE_MAX_CATALOG_BYTES,
|
||||
TARKTEE_FETCH_TIMEOUT_MS,
|
||||
TARKTEE_ANCHORS,
|
||||
DEFAULT_WARENDORF_SOURCE_FILE,
|
||||
WARENDORF_IMAGE_ORIGINS,
|
||||
@@ -77,7 +78,6 @@ import {
|
||||
TORONTO_GROUND_ELEVATION_M,
|
||||
TORONTO_MAX_CATALOG_BYTES,
|
||||
CCTV_SOURCE_FETCH_TIMEOUT_MS,
|
||||
TARKTEE_FETCH_TIMEOUT_MS,
|
||||
DELDOT_CCTV_URL,
|
||||
DEFAULT_DELDOT_MAX_SOURCES,
|
||||
DELDOT_ANCHORS,
|
||||
@@ -524,22 +524,57 @@ function pickOntarioCctvView(views) {
|
||||
);
|
||||
}
|
||||
|
||||
let ontarioKeyMissingLogged = false;
|
||||
|
||||
/** Test seam: let the next keyless call log the missing-key line again. */
|
||||
export function _resetOntarioKeyWarningForTest() {
|
||||
ontarioKeyMissingLogged = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Ontario 511 CCTV cameras. Keyless: the catalog is exposed by the
|
||||
* public 511 API, while frame URLs are stable still-image endpoints under
|
||||
* 511on.ca/map/Cctv/. Only rows with finite Ontario coords and at least one
|
||||
* enabled official still view are kept.
|
||||
* Fetch Ontario 511 CCTV cameras. The camera list comes from the 511 API,
|
||||
* which now requires a free developer key (511on.ca/developers/doc, "Requires a
|
||||
* developer key"; unkeyed calls answer 400 "Invalid Key"), read from
|
||||
* ONTARIO_511_API_KEY. Without one the pack is skipped with a single log line.
|
||||
* Frame URLs are stable still-image endpoints under 511on.ca/map/Cctv/. Only
|
||||
* rows with finite Ontario coords and at least one enabled official still
|
||||
* view are kept.
|
||||
*
|
||||
* @param {object} [_options] The catalog's loader options (unused).
|
||||
* @param {object} [env] Environment to read the key and the source cap from.
|
||||
* @returns {Promise<Array<object>>} Normalized camera source objects.
|
||||
*/
|
||||
export async function loadOntarioSourcesFromOpenData() {
|
||||
export async function loadOntarioSourcesFromOpenData(
|
||||
_options = {},
|
||||
env = process.env,
|
||||
) {
|
||||
const key = String(env.ONTARIO_511_API_KEY ?? '').trim();
|
||||
if (!key) {
|
||||
if (!ontarioKeyMissingLogged) {
|
||||
ontarioKeyMissingLogged = true;
|
||||
console.warn(
|
||||
'[CCTV] Ontario 511 cameras skipped: the 511on.ca API needs a free developer key (set ONTARIO_511_API_KEY)',
|
||||
);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const resp = await fetch(ONTARIO_511_CAMERAS_URL, {
|
||||
const url = new URL(ONTARIO_511_CAMERAS_URL);
|
||||
url.searchParams.set('key', key);
|
||||
const resp = await fetch(url, {
|
||||
headers: { Accept: 'application/json' },
|
||||
redirect: 'error',
|
||||
signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
console.warn('[CCTV] Ontario 511 camera download failed:', resp.status);
|
||||
// The API answers 400 "Invalid Key" to a wrong or revoked key.
|
||||
const keyHint =
|
||||
resp.status === 400 || resp.status === 401 || resp.status === 403
|
||||
? ' (check ONTARIO_511_API_KEY)'
|
||||
: '';
|
||||
console.warn(
|
||||
`[CCTV] Ontario 511 camera download failed: ${resp.status}${keyHint}`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
const rows = await resp.json();
|
||||
@@ -602,7 +637,7 @@ export async function loadOntarioSourcesFromOpenData() {
|
||||
new Map(cameras.map((camera) => [camera.id, camera])).values(),
|
||||
);
|
||||
const maxRaw = Number(
|
||||
process.env.CCTV_ONTARIO_MAX_SOURCES || DEFAULT_ONTARIO_MAX_SOURCES,
|
||||
env.CCTV_ONTARIO_MAX_SOURCES || DEFAULT_ONTARIO_MAX_SOURCES,
|
||||
);
|
||||
const maxCount = Number.isFinite(maxRaw)
|
||||
? Math.max(8, Math.min(1000, Math.floor(maxRaw)))
|
||||
@@ -612,10 +647,11 @@ export async function loadOntarioSourcesFromOpenData() {
|
||||
`[CCTV] Loaded Ontario 511 camera sources: ${unique.length} enabled (using nearest ${prioritized.length})`,
|
||||
);
|
||||
return prioritized;
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// Transport and JSON errors may reflect the credential-bearing request.
|
||||
// Do not log upstream exception text, response bodies or URLs.
|
||||
console.warn(
|
||||
'[CCTV] Ontario 511 camera download error:',
|
||||
error?.message || error,
|
||||
'[CCTV] Ontario 511 camera download error (network or invalid response)',
|
||||
);
|
||||
return [];
|
||||
}
|
||||
@@ -1212,74 +1248,50 @@ export function parseTarkteeDatexImages(xml) {
|
||||
* @returns {Promise<Array<object>>} Normalized camera source objects.
|
||||
*/
|
||||
export async function loadTarkteeSourcesFromDatex() {
|
||||
try {
|
||||
const responses = await Promise.allSettled([
|
||||
fetch(TARKTEE_LOCATIONS_URL, {
|
||||
headers: { Accept: 'application/xml,text/xml,*/*' },
|
||||
redirect: 'manual',
|
||||
signal: AbortSignal.timeout(TARKTEE_FETCH_TIMEOUT_MS),
|
||||
}),
|
||||
fetch(TARKTEE_IMAGES_URL, {
|
||||
headers: { Accept: 'application/xml,text/xml,*/*' },
|
||||
redirect: 'manual',
|
||||
signal: AbortSignal.timeout(TARKTEE_FETCH_TIMEOUT_MS),
|
||||
}),
|
||||
]);
|
||||
const [locResp, imgResp] = responses.map((result) =>
|
||||
result.status === 'fulfilled' ? result.value : undefined,
|
||||
// Own both transports before either can reject. A response that arrives
|
||||
// after cleanup must also be discarded instead of escaping Promise.all.
|
||||
const abort = new AbortController();
|
||||
const signal = AbortSignal.any([
|
||||
abort.signal,
|
||||
AbortSignal.timeout(TARKTEE_FETCH_TIMEOUT_MS),
|
||||
]);
|
||||
const responses = [];
|
||||
const discardBoth = async () => {
|
||||
abort.abort();
|
||||
await Promise.allSettled(
|
||||
responses.map((response) => response.body?.cancel()),
|
||||
);
|
||||
// Both responses are fetched in parallel; if either is rejected or overflows
|
||||
// its byte cap, both bodies must be released so neither transport leaks.
|
||||
const discardBoth = async () => {
|
||||
await Promise.allSettled([
|
||||
locResp?.body?.cancel(),
|
||||
imgResp?.body?.cancel(),
|
||||
]);
|
||||
return [];
|
||||
};
|
||||
const rejected = responses.find((result) => result.status === 'rejected');
|
||||
if (rejected) {
|
||||
await discardBoth();
|
||||
throw rejected.reason;
|
||||
return [];
|
||||
};
|
||||
const fetchXml = async (url) => {
|
||||
const response = await fetch(url, {
|
||||
headers: { Accept: 'application/xml,text/xml,*/*' },
|
||||
redirect: 'manual',
|
||||
signal,
|
||||
});
|
||||
if (signal.aborted) {
|
||||
await Promise.allSettled([response.body?.cancel()]);
|
||||
signal.throwIfAborted();
|
||||
}
|
||||
if (
|
||||
(locResp.status >= 300 && locResp.status < 400) ||
|
||||
(imgResp.status >= 300 && imgResp.status < 400)
|
||||
) {
|
||||
console.warn(
|
||||
'[CCTV] Tarktee DATEX catalog redirected; redirects are not followed',
|
||||
responses.push(response);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
response.status >= 300 && response.status < 400
|
||||
? 'catalog redirected; redirects are not followed'
|
||||
: `catalog HTTP ${response.status}`,
|
||||
);
|
||||
return discardBoth();
|
||||
}
|
||||
if (!locResp.ok) {
|
||||
console.warn('[CCTV] Tarktee locations download failed:', locResp.status);
|
||||
return discardBoth();
|
||||
}
|
||||
if (!imgResp.ok) {
|
||||
console.warn('[CCTV] Tarktee images download failed:', imgResp.status);
|
||||
return discardBoth();
|
||||
}
|
||||
let locXml;
|
||||
let imgXml;
|
||||
const readAbort = new AbortController();
|
||||
try {
|
||||
[locXml, imgXml] = await Promise.all([
|
||||
readResponseTextCapped(
|
||||
locResp,
|
||||
TARKTEE_MAX_CATALOG_BYTES,
|
||||
readAbort.signal,
|
||||
),
|
||||
readResponseTextCapped(
|
||||
imgResp,
|
||||
TARKTEE_MAX_CATALOG_BYTES,
|
||||
readAbort.signal,
|
||||
),
|
||||
]);
|
||||
} catch (error) {
|
||||
readAbort.abort();
|
||||
await discardBoth();
|
||||
throw error;
|
||||
}
|
||||
return response;
|
||||
};
|
||||
try {
|
||||
const [locResp, imgResp] = await Promise.all([
|
||||
fetchXml(TARKTEE_LOCATIONS_URL),
|
||||
fetchXml(TARKTEE_IMAGES_URL),
|
||||
]);
|
||||
const [locXml, imgXml] = await Promise.all([
|
||||
readResponseTextCapped(locResp, TARKTEE_MAX_CATALOG_BYTES, signal),
|
||||
readResponseTextCapped(imgResp, TARKTEE_MAX_CATALOG_BYTES, signal),
|
||||
]);
|
||||
const locations = parseTarkteeDatexLocations(locXml);
|
||||
const images = parseTarkteeDatexImages(imgXml);
|
||||
if (!locations.size || !images.size) {
|
||||
@@ -1340,6 +1352,7 @@ export async function loadTarkteeSourcesFromDatex() {
|
||||
);
|
||||
return prioritized;
|
||||
} catch (error) {
|
||||
await discardBoth();
|
||||
console.warn(
|
||||
'[CCTV] Tarktee DATEX download error:',
|
||||
error?.message || error,
|
||||
|
||||
+126
-13
@@ -4,6 +4,7 @@ import {
|
||||
TARKTEE_IMAGES_URL,
|
||||
TARKTEE_LOCATIONS_URL,
|
||||
TARKTEE_MAX_CATALOG_BYTES,
|
||||
TARKTEE_FETCH_TIMEOUT_MS,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import {
|
||||
loadTallinnSourcesFromCatalog,
|
||||
@@ -97,6 +98,29 @@ test('Tarktee DATEX image parser rejects non-official hosts', () => {
|
||||
assert.equal(images.size, 0);
|
||||
});
|
||||
|
||||
test('Tarktee DATEX requests wait 30 s, past the slow answers measured live', async (t) => {
|
||||
const timeouts = [];
|
||||
const timeout = AbortSignal.timeout;
|
||||
t.mock.method(AbortSignal, 'timeout', (ms) => {
|
||||
timeouts.push(ms);
|
||||
return timeout.call(AbortSignal, ms);
|
||||
});
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(
|
||||
globalThis,
|
||||
'fetch',
|
||||
async (url) =>
|
||||
new Response(
|
||||
String(url).includes('roadCameraLocations')
|
||||
? SAMPLE_LOCATIONS_XML
|
||||
: SAMPLE_IMAGES_XML,
|
||||
),
|
||||
);
|
||||
const cameras = await loadTarkteeSourcesFromDatex();
|
||||
assert.deepEqual(timeouts, [30_000]);
|
||||
assert.equal(cameras.length, 1);
|
||||
});
|
||||
|
||||
test('Tarktee loader deduplicates cameras sharing an image number before the cap', async (t) => {
|
||||
quiet(t);
|
||||
const dupLocationsXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
@@ -194,6 +218,8 @@ test('Tarktee loader cancels fulfilled bodies when either fetch rejects', async
|
||||
|
||||
try {
|
||||
assert.deepEqual(await loadTarkteeSourcesFromDatex(), []);
|
||||
// An aborted mock transport may still deliver its queued response.
|
||||
if (delayedSibling) await new Promise(setImmediate);
|
||||
assert.equal(
|
||||
cancelled,
|
||||
true,
|
||||
@@ -264,25 +290,112 @@ test('Tarktee loader cancels the sibling body when one feed fails or overflows t
|
||||
);
|
||||
});
|
||||
|
||||
test('Tarktee DATEX requests wait 30 s, past the slow answers measured live', async (t) => {
|
||||
const timeouts = [];
|
||||
const timeout = AbortSignal.timeout;
|
||||
t.mock.method(AbortSignal, 'timeout', (ms) => {
|
||||
timeouts.push(ms);
|
||||
return timeout.call(AbortSignal, ms);
|
||||
for (const failingUrl of [TARKTEE_LOCATIONS_URL, TARKTEE_IMAGES_URL]) {
|
||||
for (const lateSibling of [false, true]) {
|
||||
test(`Tarktee cancels a ${lateSibling ? 'late' : 'received'} sibling when ${failingUrl} rejects before headers`, async (t) => {
|
||||
quiet(t);
|
||||
let cancelled = false;
|
||||
let siblingSignal;
|
||||
let release;
|
||||
const gate = new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
t.mock.method(globalThis, 'fetch', async (url, init) => {
|
||||
if (String(url) === failingUrl) throw new Error('headers unavailable');
|
||||
siblingSignal = init.signal;
|
||||
if (lateSibling) await gate;
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode('<xml/>'));
|
||||
},
|
||||
cancel() {
|
||||
cancelled = true;
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
assert.deepEqual(await loadTarkteeSourcesFromDatex(), []);
|
||||
// Also exercise a response arriving after abort, as a queued transport
|
||||
// completion can race the rejection owner even when fetch honors abort.
|
||||
if (lateSibling) {
|
||||
release();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
assert.equal(cancelled, true, 'every obtained body must be cancelled');
|
||||
assert.equal(
|
||||
siblingSignal.aborted,
|
||||
true,
|
||||
'a pending transport is aborted immediately',
|
||||
);
|
||||
});
|
||||
}
|
||||
test(`Tarktee aborts a pending sibling when ${failingUrl} rejects`, async (t) => {
|
||||
quiet(t);
|
||||
let aborted = false;
|
||||
t.mock.method(globalThis, 'fetch', async (url, init) => {
|
||||
if (String(url) === failingUrl) throw new Error('headers unavailable');
|
||||
return new Promise((resolve, reject) => {
|
||||
init.signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
aborted = true;
|
||||
reject(init.signal.reason);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
});
|
||||
assert.deepEqual(await loadTarkteeSourcesFromDatex(), []);
|
||||
assert.equal(aborted, true, 'do not wait for the 30-second deadline');
|
||||
});
|
||||
t.mock.method(console, 'log', () => {});
|
||||
}
|
||||
|
||||
test('Tarktee cancels both lengthless streams when one exceeds the byte cap', async (t) => {
|
||||
quiet(t);
|
||||
const cancelled = [];
|
||||
t.mock.method(
|
||||
globalThis,
|
||||
'fetch',
|
||||
async (url) =>
|
||||
new Response(
|
||||
String(url).includes('roadCameraLocations')
|
||||
? SAMPLE_LOCATIONS_XML
|
||||
: SAMPLE_IMAGES_XML,
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
const oversized = String(url) === TARKTEE_LOCATIONS_URL;
|
||||
controller.enqueue(
|
||||
new Uint8Array(oversized ? TARKTEE_MAX_CATALOG_BYTES + 1 : 1),
|
||||
);
|
||||
},
|
||||
cancel() {
|
||||
cancelled.push(String(url));
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
const cameras = await loadTarkteeSourcesFromDatex();
|
||||
assert.deepEqual(timeouts, [30_000, 30_000]);
|
||||
assert.equal(cameras.length, 1);
|
||||
assert.deepEqual(await loadTarkteeSourcesFromDatex(), []);
|
||||
assert.deepEqual(
|
||||
cancelled.sort(),
|
||||
[TARKTEE_LOCATIONS_URL, TARKTEE_IMAGES_URL].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
test('Tarktee aborts pending headers as soon as its sibling returns an HTTP error', async (t) => {
|
||||
quiet(t);
|
||||
let aborted = false;
|
||||
t.mock.method(globalThis, 'fetch', async (url, init) => {
|
||||
if (String(url) === TARKTEE_LOCATIONS_URL)
|
||||
return new Response('unavailable', { status: 503 });
|
||||
return new Promise((resolve, reject) => {
|
||||
init.signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
aborted = true;
|
||||
reject(init.signal.reason);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
});
|
||||
assert.deepEqual(await loadTarkteeSourcesFromDatex(), []);
|
||||
assert.equal(aborted, true);
|
||||
});
|
||||
@@ -238,8 +238,9 @@ export const DATA_CREDITS = [
|
||||
key: 'ontario-511-cctv',
|
||||
html:
|
||||
'CCTV cameras & frames (Ontario): ' +
|
||||
'<a href="https://511on.ca/" target="_blank" rel="noopener">Ontario 511</a> ' +
|
||||
'(<a href="https://www.ontario.ca/page/open-government-licence-ontario" target="_blank" rel="noopener">Open Government Licence - Ontario</a>)',
|
||||
'<a href="https://511on.ca/" target="_blank" rel="noopener">Ontario 511</a>. ' +
|
||||
'Contains information licensed under the <a href="https://www.ontario.ca/page/open-government-licence-ontario" target="_blank" rel="noopener">Open Government Licence – Ontario</a>. ' +
|
||||
'Provided as is, without warranty; actual road conditions may differ.',
|
||||
},
|
||||
{
|
||||
key: 'fintraffic-cctv',
|
||||
|
||||
@@ -139,3 +139,34 @@ test('same pixels at a new aspect redraw at the new fit', () => {
|
||||
]);
|
||||
assert.equal(runtime.canvasStamp, 2);
|
||||
});
|
||||
|
||||
test('same pixels at a new resolution with the same fit skip the redraw', () => {
|
||||
const { runtime, load } = stillHarness();
|
||||
load(640, 480);
|
||||
assert.deepEqual(load(1280, 960), []);
|
||||
assert.equal(runtime.canvasStamp, 1);
|
||||
assert.equal(runtime.drawnImageStamp, 2);
|
||||
});
|
||||
|
||||
test('changed pixels redraw even when the fitted rectangle is unchanged', () => {
|
||||
const { runtime, load } = stillHarness();
|
||||
load(640, 480);
|
||||
const changedPixels = new Uint8ClampedArray(
|
||||
FRAME_SIGNATURE_W * FRAME_SIGNATURE_H * 4,
|
||||
).fill(90);
|
||||
runtime.signatureCtx.getImageData = () => ({ data: changedPixels });
|
||||
const calls = load(640, 480);
|
||||
assert.equal(calls[0][0], 'fillRect');
|
||||
assert.deepEqual(calls[1], ['drawImage', 240, 0, 1440, 1080]);
|
||||
assert.equal(runtime.canvasStamp, 2);
|
||||
});
|
||||
|
||||
test('a failed pixel signature redraws rather than assuming equal content', () => {
|
||||
const { runtime, load } = stillHarness();
|
||||
load(640, 480);
|
||||
runtime.signatureCtx.getImageData = () => {
|
||||
throw new Error('tainted canvas');
|
||||
};
|
||||
assert.deepEqual(load(640, 480).at(-1), ['drawImage', 240, 0, 1440, 1080]);
|
||||
assert.equal(runtime.canvasStamp, 2);
|
||||
});
|
||||
@@ -71,3 +71,56 @@ test('focus de-emphasis fades each icon from its own color', () => {
|
||||
);
|
||||
assert.equal(state._activeFocusStyleCount, 3);
|
||||
});
|
||||
|
||||
test('focus release restores video hue and alpha after the style throttle', () => {
|
||||
let target = {};
|
||||
const records = ['mp4', 'webm'].map((feedType) => ({
|
||||
camera: { id: feedType, feedType },
|
||||
billboard: { show: true, color: LIVE_CAMERA_COLOR },
|
||||
}));
|
||||
const state = {
|
||||
_enabled: true,
|
||||
_viewer: { scene: {}, camera: {} },
|
||||
_records: records,
|
||||
_activeCameraId: null,
|
||||
_activeFocusStyleCount: 0,
|
||||
_lastFocusStyleAt: -Infinity,
|
||||
};
|
||||
const focus = {
|
||||
focusNowMs: (nowMs) => nowMs,
|
||||
getFocusTarget: () => target,
|
||||
focusPassIsNeeded: (value, count) => !!value || count > 0,
|
||||
advanceSpriteFocus: () => ({
|
||||
factor: target ? FOCUS_FACTOR : 1,
|
||||
active: !!target,
|
||||
transitioning: false,
|
||||
}),
|
||||
focusAlphaNeedsWrite: (before, after) => before !== after,
|
||||
};
|
||||
const { refreshCctvFocusStyles } = createRendering({
|
||||
state,
|
||||
services: { focus },
|
||||
parts: { model: { isVideoFeedType } },
|
||||
source: {},
|
||||
});
|
||||
|
||||
refreshCctvFocusStyles(1000);
|
||||
for (const record of records) {
|
||||
assertFadedFrom(
|
||||
record.billboard.color,
|
||||
LIVE_CAMERA_COLOR,
|
||||
record.camera.id,
|
||||
);
|
||||
}
|
||||
const dimmed = records.map((record) => record.billboard.color);
|
||||
target = null;
|
||||
refreshCctvFocusStyles(1050);
|
||||
for (let i = 0; i < records.length; i += 1) {
|
||||
assert.equal(records[i].billboard.color, dimmed[i]);
|
||||
}
|
||||
refreshCctvFocusStyles(1100);
|
||||
for (const record of records) {
|
||||
assert.deepEqual(record.billboard.color, LIVE_CAMERA_COLOR);
|
||||
}
|
||||
assert.equal(state._activeFocusStyleCount, 0);
|
||||
});
|
||||
Reference in new issue
Block a user