From 3cf9a8d65dace4704eacd58dc033074007ad2f87 Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Mon, 12 Oct 2026 19:18:26 +0000 Subject: [PATCH 1/9] feat(cctv): add NYC DOT Traffic Management Center camera pack Register the keyless webcams.nyctmc.org catalog as a live CCTV source, synthesize frame URLs from validated camera UUIDs, and keep the nearest cameras to the five borough cores under the existing catalog ceiling. --- .env.example | 3 + CHANGELOG.md | 9 + DATA_SOURCES.md | 2 + README.md | 2 +- docs/CURRENT-STATE.md | 17 +- scripts/format-scope.json | 1 + server/providers/cctv/catalog.js | 6 + server/providers/cctv/constants.js | 30 +++ server/providers/cctv/normalize.js | 11 + server/providers/cctv/sources.js | 153 ++++++++++++ src/data/cctvNyc.test.mjs | 385 +++++++++++++++++++++++++++++ src/data/dataCredits.js | 6 + 12 files changed, 623 insertions(+), 2 deletions(-) create mode 100644 src/data/cctvNyc.test.mjs diff --git a/.env.example b/.env.example index c89680b30..0b9a0037a 100644 --- a/.env.example +++ b/.env.example @@ -215,6 +215,9 @@ AISSTREAM_API_KEY= # CCTV_VEGVESEN_MAX_SOURCES=900 # cap cameras loaded from Statens vegvesen (default loads all ~850 live; max 1000) # CCTV_VEGVESEN_VIDEO=1 # set 0 to show stills instead of live HLS for Statens vegvesen video cameras # CCTV_VEGVESEN_URL= # override the Statens vegvesen catalog URL +# CCTV_NYC_ENABLED=1 # set 0 to disable the NYC DOT Traffic Management Center cameras +# CCTV_NYC_MAX_SOURCES=250 # cap cameras loaded from the NYC TMC catalog (nearest borough cores first) +# CCTV_NYC_CAMERAS_URL= # override the NYC TMC catalog URL # CCTV_MAX_SOURCES=4000 # catalog-wide ceiling, shared round-robin across packs # CCTV_PREFER_AUSTIN=1 # prioritize Austin cameras in the registry # CCTV_FORCE_AUSTIN= # force live default sources when a source-pack JSON is configured diff --git a/CHANGELOG.md b/CHANGELOG.md index b24ac95ed..0b12ee132 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +- Add New York City traffic cameras from the NYC DOT Traffic Management Center + as a CCTV pack. The camera list is one keyless JSON request to + `webcams.nyctmc.org/api/cameras`; frames are stills synthesized from each + camera UUID on that same host, so a catalog field cannot steer the proxy + off-host. Offline cameras are dropped. By default the 250 cameras nearest + the five borough cores load. `CCTV_NYC_ENABLED=0` turns the pack off and + `CCTV_NYC_MAX_SOURCES` changes the cap. The catalog-wide ceiling is + unchanged; extra cameras share it round-robin with the other packs. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 0b8eac358..dbb07f0bf 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -56,6 +56,7 @@ How to read this: | **DriveBC** (Government of British Columbia) | CCTV camera catalog + frames, British Columbia highways | [Open Government Licence – British Columbia](https://www2.gov.bc.ca/gov/content/data/open-data/open-government-licence-bc) — attribution REQUIRED | "DriveBC. Contains information licensed under the Open Government Licence – British Columbia" | | **Open Calgary** (`data.calgary.ca`) | CCTV camera catalog + frames, Calgary | [Open Government Licence – City of Calgary](https://data.calgary.ca/stories/s/Open-Calgary-Terms-of-Use/u45n-7awa) — attribution REQUIRED | "Contains information licensed under the Open Government Licence – City of Calgary" | | **Statens vegvesen** (catalog `ogckart-sn1.atlas.vegvesen.no`, frames `kamera.atlas.vegvesen.no`, HLS `kamera.vegvesen.no`) | CCTV camera catalog + frames + live HLS video, Norway road cameras | [NLOD 2.0](https://data.norge.no/nlod/en/2.0) — attribution REQUIRED | "Contains data under the Norwegian licence for Open Government data (NLOD) distributed by Statens vegvesen" | +| **NYC DOT Traffic Management Center** (`webcams.nyctmc.org`) | CCTV camera catalog + frames, New York City five boroughs | Public NYC DOT traffic camera stills | "NYC DOT Traffic Management Center" (courtesy) | | **GBFS (Lyft / BCycle)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle) + its `license_url` | | **MBTA / MassDOT** (cdn.mbta.com) | Transit layer live vehicles and up to 15 minutes of recently observed positions, Greater Boston | MBTA / MassDOT open realtime vehicle data, used for the live view and up to fifteen minutes of recently observed positions; published for developer use and not against their use. | Attribution to MBTA / MassDOT (courtesy text) | | **CapMetro** (data.texas.gov) | Transit layer live vehicles, Austin TX | CapMetro open realtime vehicle data, used for the live view; published for developer use and not against their use. | Attribution to CapMetro (courtesy text) | @@ -91,6 +92,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **Live Traffic NSW cameras.** The camera list is the keyless public `data.livetraffic.com/cameras/traffic-cam.json` feed (Transport for NSW open data, CC BY 4.0); frames are stills on `webcams.transport.nsw.gov.au`, and only that host is registered. That image host answers non-browser clients with an HTML placeholder, so the proxy identifies as a browser for that one host and as itself everywhere else. Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_NSW_ENABLED=0`. - **Open Calgary traffic cameras.** Keyless. The camera list is the public Socrata dataset `k7p9-kppz` on `data.calgary.ca`; frames are stills on the city's own `trafficcam.calgary.ca` host, and only that host is registered. Most rows publish an `http://` frame URL and the host redirects those to HTTPS, so URLs are upgraded and pinned to that origin before registration. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing: the `quadrant` field and the quadrant suffix on each camera name are Calgary's address grid, not a bearing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. The licence requires the attribution statement above. Disable with `CCTV_CALGARY_ENABLED=0`. - **Statens vegvesen road cameras.** Keyless. The camera list is one GeoJSON request to the OGC API Features view of the official DATEX 3.1 CCTV site table (`ogckart-sn1.atlas.vegvesen.no/ogc/features/v1/collections/datex_3_1:CctvSimple/items`), so no DATEX account is needed; frames are stills on `kamera.atlas.vegvesen.no/api/images/`, and only that exact path per camera is registered. Cameras the feed reports as faulty are dropped. About 135 working cameras also publish live HLS (`kamera.vegvesen.no/public//manifest.m3u8`, MPEG-TS); those play as live video through the bounded in-memory HLS relay, with the still as the snapshot fallback, and only that exact manifest path per camera is registered. `CCTV_VEGVESEN_VIDEO=0` keeps every camera on stills. `orientationDescription` names the place a camera faces, not a bearing, so it goes into the label and headings use the id-hash fallback at low confidence. By default every live camera loads (~850); `CCTV_VEGVESEN_MAX_SOURCES` (8–1000) lowers the cap, keeping the cameras nearest Oslo, Bergen, Stavanger, Trondheim, Kristiansand, Bodø and Tromsø and `CCTV_VEGVESEN_ENABLED=0` turns the pack off. Frames are fetched at request time and never stored or redistributed. The NLOD attribution is registered in the Data attribution popover. +- **NYC DOT Traffic Management Center cameras.** Keyless. The camera list is the public `webcams.nyctmc.org/api/cameras` catalog (~980 cameras across the five boroughs); frames are stills at `webcams.nyctmc.org/api/cameras//image`, synthesized from a validated UUID rather than taken from the payload, and only that host is registered. Cameras the feed reports as offline are dropped. The catalog publishes no camera facing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. By default the 250 cameras nearest the borough cores load; `CCTV_NYC_MAX_SOURCES` (8–400) changes the cap and `CCTV_NYC_ENABLED=0` turns the pack off. Frames are fetched at request time and never stored or redistributed. - **Camera frame content.** Public camera frames can contain people, vehicles and license plates, and the camera layer does nothing with them beyond putting them on the map. A successful upstream response is relayed as the provider served it: nothing in the camera pipeline blurs, redacts, enhances or restores it, and nothing there runs face, plate or object detection or otherwise recognises or classifies what is in the picture. (The ALPR layer described below maps where plate-reader cameras are, from locations contributors tagged in OpenStreetMap; it reads nothing from any camera frame.) When an upstream has no frame for a camera, what you see instead is a Google Street View still or the app's own placeholder card; the panel's source badge names which of them you are looking at, and a refresh that fails after a frame has arrived leaves the last good picture on screen rather than replacing it. The browser resamples each frame for display: onto a fixed 1920x1080 surface for the camera's projection plane in the scene, and onto a 192x108 canvas for the small ambient card. That scaling is the only change made to the picture, and the only pixels the camera layer reads back are a 64x36 downscale hashed to tell whether the picture has changed. Live streams are piped through; the still path reads the image into memory to pass it on — TxDOT wraps its JPEG in a JSON envelope, which is decoded there — and no frame is written to disk (the disk cache, `.gev-cache/`, holds feed JSON for other layers and never camera imagery). Outside that pipeline, the voice assistant is the one feature that sends imagery anywhere: when it answers a question about what is in view it may attach a downscaled screenshot of the rendered scene — including any camera plane on screen — to the request it sends to the voice model. Frames are fetched only from server-registered camera URLs — a client cannot hand the proxy a URL (see [SECURITY.md](SECURITY.md)). What the app does stays inside the line README draws: no named-person search, no face recognition, no tracking individuals. - **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. All seven registered regions are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth. - **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. diff --git a/README.md b/README.md index 5d97dfb2f..38f7a4913 100644 --- a/README.md +++ b/README.md @@ -300,7 +300,7 @@ Twenty layers and map sources. **Seventeen have a keyless path.** Some offer add | 🛰️ **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** | ~3,900 public cameras 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). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 | +| 📹 **CCTV Mesh** | ~4,100 public cameras 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) · New York City (NYC DOT TMC). 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 first; Panoramax, KartaView and Google Street View planned | Mapillary | 🟡 Mapillary client token | | 📻 **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 | 🟢 | diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 26d56e66b..38bfe3a20 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -914,6 +914,21 @@ owners. Tuner calculations have a pure entry, with existing layer exports preserved. Destruction removes listeners and state subscriptions before ending tuning; a delayed Enable result cannot reveal or refocus removed controls. +## NYC DOT TMC CCTV source pack + +The CCTV catalog adds New York City traffic cameras from the NYC DOT Traffic +Management Center alongside the other live packs. The server reads the keyless +camera list at `https://webcams.nyctmc.org/api/cameras` and keeps cameras the +feed marks online. Frame URLs are built from each camera UUID under +`https://webcams.nyctmc.org/api/cameras//image`; they are never taken +from the payload. There is no heading in the feed, so gizmos use the shared +id-hash fallback at low confidence. + +By default the 250 cameras nearest the five borough cores load. +`CCTV_NYC_MAX_SOURCES` sets the pack cap (8–400) and `CCTV_NYC_ENABLED=0` +turns the pack off. The catalog-wide ceiling stays at 4,000 and is shared +round-robin, so adding this pack does not silently drop another region. + ## DriveBC CCTV source pack The CCTV catalog adds DriveBC highway cameras for British Columbia alongside the @@ -3058,7 +3073,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 | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache | -| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) | +| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS) + NYC DOT TMC + 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 | | Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) | | Bikeshare 🚲 | GBFS (Lyft + BCycle) | `src/data/bikeshare.js` | `/api/gbfs` | 60s | diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 6b5c9b544..388028925 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -532,6 +532,7 @@ "src/data/cctvWarendorf.test.mjs", "src/data/cctvCalgary.test.mjs", "src/data/cctvVegvesen.test.mjs", + "src/data/cctvNyc.test.mjs", "src/data/cctvNswSource.test.mjs", "src/data/cctvTxdotSource.test.mjs", "src/layers/cctv/source.test.mjs", diff --git a/server/providers/cctv/catalog.js b/server/providers/cctv/catalog.js index 9bc3b70ec..0939a71da 100644 --- a/server/providers/cctv/catalog.js +++ b/server/providers/cctv/catalog.js @@ -19,6 +19,7 @@ import { loadCalgarySourcesFromOpenData, loadDelDOTSourcesFromOpenData, loadVegvesenSourcesFromOpenData, + loadNycSourcesFromOpenData, } from './sources.js'; /** Env kill switch: unset or anything but "0" means enabled. */ @@ -98,6 +99,11 @@ const LIVE_PACKS = [ enabled: () => envEnabled('CCTV_VEGVESEN_ENABLED'), load: loadVegvesenSourcesFromOpenData, }, + { + name: 'nyc', + enabled: () => envEnabled('CCTV_NYC_ENABLED'), + load: loadNycSourcesFromOpenData, + }, ]; /** * Load CCTV sources from a local JSON file (CCTV_SOURCES_FILE env or default). diff --git a/server/providers/cctv/constants.js b/server/providers/cctv/constants.js index 6298e86a0..ae80904a5 100644 --- a/server/providers/cctv/constants.js +++ b/server/providers/cctv/constants.js @@ -267,6 +267,36 @@ export const NORWAY_ANCHORS = [ { lat: 69.6492, lon: 18.9553 }, // Tromsø ]; +/** NYC DOT Traffic Management Center cameras: one keyless JSON list for the + * five boroughs (~980 cameras). Frames are stills synthesized from each + * camera UUID on the city's webcam host — never taken from the payload. */ +export const NYC_CAMERAS_URL = 'https://webcams.nyctmc.org/api/cameras'; +/** The only origin NYC camera frames may come from. The catalog's `imageUrl` + * is ignored; the registered path is always this origin plus a validated + * UUID plus `/image`. */ +export const NYC_IMAGE_ORIGIN = 'https://webcams.nyctmc.org/api/cameras/'; +export const NYC_IMAGE_URL = (id) => `${NYC_IMAGE_ORIGIN}${id}/image`; +export const NYC_CAMERA_ID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +export const DEFAULT_NYC_MAX_SOURCES = 250; +/** Hard ceiling on the NYC catalog body. The live list is ~980 rows / ~250 KB; + * this only exists so an upstream that streams an unbounded body cannot be + * buffered without limit. */ +export const NYC_MAX_CATALOG_BYTES = 4 * 1024 * 1024; +/** Ground-elevation prior in metres. Matches the New York CITY_POIS prior; + * the client's one-shot ground snap corrects it wherever 3D tiles load. */ +export const NYC_GROUND_ELEVATION_M = 10; +/** Prioritization anchors: one per borough so a cap keeps citywide coverage + * rather than Midtown alone. */ +export const NYC_ANCHORS = [ + { lat: 40.758, lon: -73.9855 }, // Manhattan (Times Square) + { lat: 40.7128, lon: -74.006 }, // Lower Manhattan + { lat: 40.6782, lon: -73.9442 }, // Brooklyn + { lat: 40.7282, lon: -73.7949 }, // Queens + { lat: 40.8448, lon: -73.8648 }, // Bronx + { lat: 40.5795, lon: -74.1502 }, // Staten Island +]; + /** DelDOT CCTV: one keyless statewide JSON catalog; live video via RTMP-over-HTTP (rtmpt:80). */ export const DELDOT_CCTV_URL = 'https://tmc.deldot.gov/json/videocamera.json'; export const DEFAULT_DELDOT_MAX_SOURCES = 300; diff --git a/server/providers/cctv/normalize.js b/server/providers/cctv/normalize.js index e62ba33f3..99b685f44 100644 --- a/server/providers/cctv/normalize.js +++ b/server/providers/cctv/normalize.js @@ -367,6 +367,17 @@ export function isLikelyCalgaryCoordinate(lat, lon) { ); } +/** New York City's five boroughs, with slack for the harbor and city line. */ +export function isLikelyNycCoordinate(lat, lon) { + return ( + isPlausibleLatLon(lat, lon) && + lat >= 40.45 && + lat <= 40.95 && + lon >= -74.3 && + lon <= -73.65 + ); +} + /** Mainland Norway, with slack for the coast and the Finnmark border. */ export function isLikelyNorwayCoordinate(lat, lon) { return ( diff --git a/server/providers/cctv/sources.js b/server/providers/cctv/sources.js index 7ecd3434d..43ba30994 100644 --- a/server/providers/cctv/sources.js +++ b/server/providers/cctv/sources.js @@ -65,6 +65,13 @@ import { DEFAULT_VEGVESEN_MAX_SOURCES, VEGVESEN_MAX_CATALOG_BYTES, NORWAY_ANCHORS, + NYC_CAMERAS_URL, + NYC_IMAGE_URL, + NYC_CAMERA_ID_RE, + DEFAULT_NYC_MAX_SOURCES, + NYC_MAX_CATALOG_BYTES, + NYC_GROUND_ELEVATION_M, + NYC_ANCHORS, } from './constants.js'; import { toFiniteNumber, @@ -82,6 +89,7 @@ import { isLikelyTexasCoordinate, isLikelyNswCoordinate, isLikelyCalgaryCoordinate, + isLikelyNycCoordinate, isLikelyNorwayCoordinate, cameraDisplayCode, rowArrayToObject, @@ -1863,3 +1871,148 @@ export async function loadVegvesenSourcesFromOpenData() { return []; } } + +const NYC_BOROUGHS = new Set([ + 'Manhattan', + 'Brooklyn', + 'Queens', + 'Bronx', + 'Staten Island', +]); + +/** NYC TMC publishes `isOnline` as the strings "true"/"false". */ +export function nycCameraIsOnline(value) { + return ( + value === true || + String(value ?? '') + .trim() + .toLowerCase() === 'true' + ); +} + +/** + * Label for one NYC camera: the intersection an operator recognises + * ("Central Park West @ 86 St"). The catalog's `area` is the borough, not + * a facing, and is not part of the name. + * + * @param {object} record - Raw TMC camera row. + * @param {string} cameraId - Derived stable id. + * @returns {string} + */ +export function nycCameraName(record, cameraId) { + const name = String(record?.name ?? '').trim(); + if (name) return name; + const short = String(cameraId || '') + .replace(/^nyc-/, '') + .slice(0, 8); + return short ? `NYC Camera ${short}` : 'NYC Camera'; +} + +/** + * One NYC DOT TMC camera row -> one catalog source, or null. + * + * Frame URLs are synthesized from a validated UUID on the pinned TMC image + * origin. The payload's `imageUrl` is ignored so a catalog edit cannot steer + * the frame proxy off-host. There is no heading in the feed; headings use + * the shared id-hash fallback at low confidence, like Calgary and Fintraffic. + * + * @param {object} record - Raw TMC camera row. + * @returns {?object} + */ +export function nycCameraToSource(record) { + if (!record || typeof record !== 'object') return null; + if (!nycCameraIsOnline(record.isOnline)) return null; + + const lat = toFiniteNumber(record.latitude); + const lon = toFiniteNumber(record.longitude); + if (!isLikelyNycCoordinate(lat, lon)) return null; + + const uuid = String(record.id ?? '').trim(); + if (!NYC_CAMERA_ID_RE.test(uuid)) return null; + const cameraId = `nyc-${uuid.toLowerCase()}`; + const imageUrl = NYC_IMAGE_URL(uuid); + const name = nycCameraName(record, cameraId); + const area = String(record.area ?? '').trim(); + const city = NYC_BOROUGHS.has(area) ? area : 'New York'; + + return { + id: cameraId, + name, + city, + cityId: 'nyc', + provider: 'NYC DOT Traffic Management Center', + lat, + lon, + headingDeg: fallbackHeadingFromId(cameraId), + headingConfidence: 'low', + pitchDeg: -18, + fovDeg: 44, + rangeM: 145, + mountHeightM: 8, + groundElevationM: NYC_GROUND_ELEVATION_M, + feedType: 'image', + url: imageUrl, + snapshotUrl: imageUrl, + sourceKind: 'nyc-dot-tmc', + license: 'Public NYC DOT Traffic Management Center traffic camera', + code: cameraDisplayCode(name.toUpperCase()), + }; +} + +/** + * Fetch NYC DOT Traffic Management Center cameras from the keyless + * `webcams.nyctmc.org` catalog. Frames are stills on that same host, + * registered from a validated UUID rather than from the payload URL. + * + * @returns {Promise>} Normalized camera source objects. + */ +export async function loadNycSourcesFromOpenData() { + try { + const endpoint = process.env.CCTV_NYC_CAMERAS_URL || NYC_CAMERAS_URL; + const resp = await fetch(endpoint, { + headers: { Accept: 'application/json' }, + redirect: 'manual', + signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS), + }); + const discard = async () => { + try { + await resp.body?.cancel(); + } catch { + /* no-op */ + } + return []; + }; + if (resp.status >= 300 && resp.status < 400) { + console.warn('[CCTV] NYC catalog redirected; redirects are not followed'); + return discard(); + } + if (!resp.ok) { + console.warn('[CCTV] NYC camera download failed:', resp.status); + return discard(); + } + const rows = await readResponseJsonCapped(resp, NYC_MAX_CATALOG_BYTES); + if (!Array.isArray(rows)) return []; + const cameras = []; + const seen = new Set(); + for (const record of rows) { + const camera = nycCameraToSource(record); + if (!camera || seen.has(camera.id)) continue; + seen.add(camera.id); + cameras.push(camera); + } + const maxRaw = Number( + process.env.CCTV_NYC_MAX_SOURCES || DEFAULT_NYC_MAX_SOURCES, + ); + const maxCount = Number.isFinite(maxRaw) + ? Math.max(8, Math.min(400, Math.floor(maxRaw))) + : DEFAULT_NYC_MAX_SOURCES; + const prioritized = prioritizeSources(cameras, maxCount, NYC_ANCHORS); + console.log( + `[CCTV] Loaded NYC camera sources: ${cameras.length} (using nearest ${prioritized.length})`, + ); + return prioritized; + } catch (error) { + console.warn('[CCTV] NYC camera download error:', error?.message || error); + return []; + } +} diff --git a/src/data/cctvNyc.test.mjs b/src/data/cctvNyc.test.mjs new file mode 100644 index 000000000..d67ffa002 --- /dev/null +++ b/src/data/cctvNyc.test.mjs @@ -0,0 +1,385 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + loadNycSourcesFromOpenData, + nycCameraIsOnline, + nycCameraName, + nycCameraToSource, +} from '../../server/providers/cctv/sources.js'; +import { + DEFAULT_CCTV_MAX_SOURCES, + DEFAULT_NYC_MAX_SOURCES, + NYC_CAMERAS_URL, + NYC_GROUND_ELEVATION_M, + NYC_IMAGE_ORIGIN, + NYC_IMAGE_URL, + NYC_MAX_CATALOG_BYTES, +} from '../../server/providers/cctv/constants.js'; +import { + CAMERA_CODE_MAX_CHARS, + isLikelyNycCoordinate, +} from '../../server/providers/cctv/normalize.js'; +import { createCctvCatalog } from '../../server/providers/cctv/catalog.js'; + +/** + * A response whose body is a live stream, plus a flag that flips when the + * stream is cancelled. A rejection path that returns without cancelling holds + * the transport open, so the flag is what the refusal tests actually assert. + */ +const streamingResponse = (init = {}) => { + const state = { cancelled: false }; + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('[')); + }, + cancel() { + state.cancelled = true; + }, + }); + return { response: new Response(body, init), state }; +}; + +const MIDTOWN_ID = '8a6bc417-4877-4ebe-8052-88c1b261baf1'; +const BROOKLYN_ID = '11111111-2222-3333-4444-555555555555'; + +/** One TMC camera row, shaped like the live `webcams.nyctmc.org` payload. */ +const row = (overrides = {}) => ({ + id: MIDTOWN_ID, + name: 'Central Park West @ 86 St', + latitude: 40.785302, + longitude: -73.969353, + area: 'Manhattan', + isOnline: 'true', + imageUrl: NYC_IMAGE_URL(MIDTOWN_ID), + ...overrides, +}); + +test('a NYC row maps to a source on the pinned HTTPS frame host', () => { + const source = nycCameraToSource(row()); + assert.equal(source.id, `nyc-${MIDTOWN_ID}`); + assert.equal(source.name, 'Central Park West @ 86 St'); + assert.equal(source.city, 'Manhattan'); + assert.equal(source.cityId, 'nyc'); + assert.equal(source.provider, 'NYC DOT Traffic Management Center'); + assert.equal(source.lat, 40.785302); + assert.equal(source.lon, -73.969353); + assert.equal(source.feedType, 'image'); + assert.equal(source.sourceKind, 'nyc-dot-tmc'); + assert.equal( + source.license, + 'Public NYC DOT Traffic Management Center traffic camera', + ); + assert.equal(source.groundElevationM, NYC_GROUND_ELEVATION_M); + assert.equal(source.url, NYC_IMAGE_URL(MIDTOWN_ID)); + assert.equal(source.url, source.snapshotUrl); + assert.ok(source.url.startsWith(NYC_IMAGE_ORIGIN)); + assert.equal(source.headingConfidence, 'low'); + assert.ok(Number.isFinite(source.headingDeg)); +}); + +test('frame URLs are synthesized from the UUID; the payload URL is ignored', () => { + const source = nycCameraToSource( + row({ imageUrl: 'https://evil.example/cameras/x/image' }), + ); + assert.equal(source.url, NYC_IMAGE_URL(MIDTOWN_ID)); + assert.equal(source.snapshotUrl, source.url); +}); + +test('offline cameras, off-city geometry and unusable ids are dropped', () => { + assert.equal(nycCameraToSource(row({ isOnline: 'false' })), null); + assert.equal(nycCameraToSource(row({ isOnline: false })), null); + assert.equal( + nycCameraToSource(row({ isOnline: 'TRUE' })).id, + `nyc-${MIDTOWN_ID}`, + ); + assert.equal( + nycCameraToSource(row({ isOnline: true })).id, + `nyc-${MIDTOWN_ID}`, + ); + // Austin: a plausible lat/lon, but not a New York camera. + assert.equal( + nycCameraToSource(row({ latitude: 30.2672, longitude: -97.7431 })), + null, + ); + assert.equal(nycCameraToSource(row({ latitude: 0, longitude: 0 })), null); + assert.equal(nycCameraToSource(row({ id: '../etc/passwd' })), null); + assert.equal(nycCameraToSource(row({ id: 'not-a-uuid' })), null); + assert.equal(nycCameraToSource(row({ id: `${MIDTOWN_ID}/../x` })), null); + assert.equal(nycCameraToSource(null), null); +}); + +test('ids lower-case the UUID and unknown boroughs fall back to New York', () => { + const mixed = nycCameraToSource( + row({ id: MIDTOWN_ID.toUpperCase(), area: 'Long Island' }), + ); + assert.equal(mixed.id, `nyc-${MIDTOWN_ID}`); + assert.equal(mixed.city, 'New York'); + assert.equal( + mixed.url, + NYC_IMAGE_URL(MIDTOWN_ID.toUpperCase()), + 'the registered frame path keeps the catalog UUID spelling', + ); +}); + +test('a nameless row still gets a label', () => { + assert.equal( + nycCameraName({ name: ' FDR Drive @ 23 St ' }, 'nyc-x'), + 'FDR Drive @ 23 St', + ); + assert.equal(nycCameraName({}, `nyc-${MIDTOWN_ID}`), 'NYC Camera 8a6bc417'); + assert.equal(nycCameraIsOnline('true'), true); + assert.equal(nycCameraIsOnline('false'), false); +}); + +test('the unselected label is the intersection, trimmed to the code width', () => { + assert.equal(nycCameraToSource(row()).code, 'CENTRAL PARK WEST @ 86 ST'); + const long = nycCameraToSource( + row({ + name: 'Brooklyn Queens Expressway @ Atlantic Avenue / Flatbush Avenue Extension', + }), + ); + assert.ok(long.code.length <= CAMERA_CODE_MAX_CHARS); + assert.ok(long.code.endsWith('…')); + assert.ok(long.code.startsWith('BROOKLYN')); +}); + +test('the loader reads the keyless catalog and collapses duplicate ids', async (t) => { + t.mock.method(console, 'log', () => {}); + const requested = []; + t.mock.method(globalThis, 'fetch', async (url, init) => { + requested.push([String(url), init.redirect]); + return Response.json([ + row({ latitude: 40.758, longitude: -73.9855 }), + row({ + name: 'Central Park West @ 86 St (duplicate)', + latitude: 40.758, + longitude: -73.9855, + }), + row({ + id: BROOKLYN_ID, + name: 'Atlantic Ave @ Flatbush Ave', + latitude: 40.65, + longitude: -73.96, + area: 'Brooklyn', + imageUrl: NYC_IMAGE_URL(BROOKLYN_ID), + }), + row({ isOnline: 'false', name: 'Offline camera' }), + row({ id: 'https://evil.example/x' }), + ]); + }); + const cameras = await loadNycSourcesFromOpenData(); + assert.deepEqual(requested, [[NYC_CAMERAS_URL, 'manual']]); + assert.deepEqual( + cameras.map((camera) => camera.id), + // Nearest to a borough anchor first: the Times Square camera before Brooklyn. + [`nyc-${MIDTOWN_ID}`, `nyc-${BROOKLYN_ID}`], + ); + assert.equal(cameras[0].name, 'Central Park West @ 86 St'); + assert.equal(cameras[1].city, 'Brooklyn'); +}); + +test('an upstream failure yields an empty pack and releases the response', async (t) => { + t.mock.method(console, 'warn', () => {}); + const failed = streamingResponse({ status: 503 }); + t.mock.method(globalThis, 'fetch', async () => failed.response); + assert.deepEqual(await loadNycSourcesFromOpenData(), []); + assert.equal(failed.state.cancelled, true, 'the failed body is cancelled'); + + t.mock.restoreAll(); + t.mock.method(console, 'warn', () => {}); + t.mock.method(globalThis, 'fetch', async () => { + throw new Error('network down'); + }); + assert.deepEqual(await loadNycSourcesFromOpenData(), []); +}); + +test('the catalog fetch refuses redirects and oversized bodies', async (t) => { + t.mock.method(console, 'warn', () => {}); + const seen = []; + const redirected = streamingResponse({ + status: 302, + headers: { location: 'https://evil.example/cameras.json' }, + }); + t.mock.method(globalThis, 'fetch', async (url, init) => { + seen.push([String(url), init.redirect]); + return redirected.response; + }); + assert.deepEqual(await loadNycSourcesFromOpenData(), []); + assert.deepEqual(seen, [[NYC_CAMERAS_URL, 'manual']]); + assert.equal( + redirected.state.cancelled, + true, + 'the redirect body is cancelled', + ); + + t.mock.restoreAll(); + t.mock.method(console, 'warn', () => {}); + t.mock.method(globalThis, 'fetch', async () => { + const body = JSON.stringify([row()]); + return new Response(body, { + headers: { + 'Content-Type': 'application/json', + 'content-length': String(NYC_MAX_CATALOG_BYTES + 1), + }, + }); + }); + assert.deepEqual(await loadNycSourcesFromOpenData(), []); + + t.mock.restoreAll(); + t.mock.method(console, 'warn', () => {}); + t.mock.method(globalThis, 'fetch', async () => { + const oversized = 'x'.repeat(NYC_MAX_CATALOG_BYTES + 1024); + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(oversized)); + controller.close(); + }, + }), + { headers: { 'Content-Type': 'application/json' } }, + ); + }); + assert.deepEqual(await loadNycSourcesFromOpenData(), []); +}); + +test('a reduced pack cap keeps cameras nearest the borough anchors', async (t) => { + t.mock.method(console, 'log', () => {}); + const saved = process.env.CCTV_NYC_MAX_SOURCES; + t.after(() => { + if (saved === undefined) delete process.env.CCTV_NYC_MAX_SOURCES; + else process.env.CCTV_NYC_MAX_SOURCES = saved; + }); + process.env.CCTV_NYC_MAX_SOURCES = '8'; + const rows = [ + // Far southern Staten Island, listed first so only distance can keep them. + ...[40.5, 40.505, 40.51, 40.515, 40.52, 40.525].map((lat, i) => + row({ + id: `aaaaaaaa-bbbb-cccc-dddd-${String(i).padStart(12, '0')}`, + latitude: lat, + longitude: -74.24, + area: 'Staten Island', + name: `SI ${i}`, + }), + ), + ...[40.758, 40.757, 40.756, 40.755, 40.754, 40.753].map((lat, i) => + row({ + id: `bbbbbbbb-cccc-dddd-eeee-${String(i).padStart(12, '0')}`, + latitude: lat, + longitude: -73.9855, + area: 'Manhattan', + name: `Midtown ${i}`, + }), + ), + ]; + t.mock.method(globalThis, 'fetch', async () => Response.json(rows)); + const cameras = await loadNycSourcesFromOpenData(); + assert.equal(cameras.length, 8); + assert.deepEqual( + cameras.map((camera) => camera.city), + [ + 'Manhattan', + 'Manhattan', + 'Manhattan', + 'Manhattan', + 'Manhattan', + 'Manhattan', + 'Staten Island', + 'Staten Island', + ], + 'Midtown fills first; the Staten Island anchor still keeps a share', + ); +}); + +/** + * Serve the NYC catalog to the NYC endpoint and an empty payload to every + * other pack, so one catalog refresh exercises the registration without + * reaching the network. + */ +const runCatalogWithMockedUpstreams = async (t) => { + const requested = []; + t.mock.method(console, 'log', () => {}); + t.mock.method(console, 'warn', () => {}); + t.mock.method(globalThis, 'fetch', async (url) => { + const href = String(url); + requested.push(href); + if (href === NYC_CAMERAS_URL) { + return Response.json([ + row({ latitude: 40.758, longitude: -73.9855 }), + row({ + id: BROOKLYN_ID, + name: 'Atlantic Ave @ Flatbush Ave', + latitude: 40.65, + longitude: -73.96, + area: 'Brooklyn', + }), + ]); + } + return Response.json([]); + }); + const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })(); + return { requested, sources }; +}; + +test('the NYC lane is wired into the catalog and its loader runs', async (t) => { + const saved = { ...process.env }; + try { + delete process.env.CCTV_SOURCES_FILE; + delete process.env.CCTV_SOURCES_JSON; + delete process.env.CCTV_NYC_ENABLED; + const { requested, sources } = await runCatalogWithMockedUpstreams(t); + assert.ok( + requested.includes(NYC_CAMERAS_URL), + 'the catalog refresh invokes the NYC loader', + ); + assert.deepEqual( + sources.filter((s) => s.cityId === 'nyc').map((s) => s.id), + [`nyc-${MIDTOWN_ID}`, `nyc-${BROOKLYN_ID}`], + 'NYC cameras reach the served catalog through the registered lane', + ); + } finally { + for (const key of Object.keys(process.env)) { + if (!(key in saved)) delete process.env[key]; + } + Object.assign(process.env, saved); + } +}); + +test('CCTV_NYC_ENABLED=0 keeps the lane from being loaded at all', async (t) => { + const saved = { ...process.env }; + try { + delete process.env.CCTV_SOURCES_FILE; + delete process.env.CCTV_SOURCES_JSON; + process.env.CCTV_NYC_ENABLED = '0'; + const { requested, sources } = await runCatalogWithMockedUpstreams(t); + assert.equal( + requested.includes(NYC_CAMERAS_URL), + false, + 'the disabled lane never reaches its upstream', + ); + assert.deepEqual( + sources.filter((s) => s.cityId === 'nyc'), + [], + ); + } finally { + for (const key of Object.keys(process.env)) { + if (!(key in saved)) delete process.env[key]; + } + Object.assign(process.env, saved); + } +}); + +test('the shipped catalog ceiling is not raised to make room for this pack', () => { + assert.equal(DEFAULT_CCTV_MAX_SOURCES, 4000); + assert.equal(DEFAULT_NYC_MAX_SOURCES, 250); +}); + +test('isLikelyNycCoordinate spans the five boroughs and rejects the rest', () => { + assert.equal(isLikelyNycCoordinate(40.758, -73.9855), true); // Times Square + assert.equal(isLikelyNycCoordinate(40.5795, -74.1502), true); // Staten Island + assert.equal(isLikelyNycCoordinate(40.8448, -73.8648), true); // Bronx + assert.equal(isLikelyNycCoordinate(40.5077, -74.2304), true); // southern SI slack + assert.equal(isLikelyNycCoordinate(30.2672, -97.7431), false); // Austin + assert.equal(isLikelyNycCoordinate(0, 0), false); + assert.equal(isLikelyNycCoordinate(NaN, -73.98), false); +}); diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index 2967f0a2b..891cd21e2 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -247,6 +247,12 @@ export const DATA_CREDITS = [ 'Norwegian licence for Open Government data (NLOD) ' + 'distributed by Statens vegvesen', }, + { + key: 'nyc-cctv', + html: + 'CCTV cameras & frames (New York City): ' + + 'NYC DOT Traffic Management Center', + }, { key: 'gbfs', html: 'Bikeshare availability: GBFS operator feeds (e.g. Austin BCycle)', From 16e0f35c74f2ff672eefac1eff05f1bd13d719fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krist=C3=B3f=20K=C3=A9kesi?= <64812024+KristofKekesi@users.noreply.github.com> Date: Wed, 26 Aug 2026 00:21:23 +0200 Subject: [PATCH 2/9] Feat: GBFS v3 support for station names GBFS v3 uses `LocalizedString` for `name` and `short_name` of the stations. This caused stations from v3 to have names like `[object Object]`. This commit makes the code handle v3 names. It default to locale 'en' then fallbacks to the first option in the array. Documentation for v3: https://gbfs.org/documentation/reference/#station_informationjson --- src/sources/gbfsStations.js | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/sources/gbfsStations.js b/src/sources/gbfsStations.js index 9c5b93528..cd90b12dd 100644 --- a/src/sources/gbfsStations.js +++ b/src/sources/gbfsStations.js @@ -63,15 +63,27 @@ export function parseStationInformation(payload) { const stationMap = new Map(); for (const raw of stations) { - // Accept both GBFS 2.x (station_id) and alternate (id) field names + // Accept both GBFS (station_id) and alternate (id) field names const stationId = String(raw?.station_id ?? raw?.id ?? '').trim(); const lat = Number(raw?.lat ?? raw?.latitude); const lon = Number(raw?.lon ?? raw?.longitude); if (!stationId || !Number.isFinite(lat) || !Number.isFinite(lon)) continue; + let name; + if (payload?.version >= 3) { + const handleLocalizedString = (array) => + array.find((localizedString) => + localizedString.language.toLowerCase() === 'en') || array[0]; + name = String( + handleLocalizedString(raw?.name).text || + handleLocalizedString(raw?.short_name).text || '').trim(); + } else { + name = String(raw?.name || raw?.short_name || '').trim(); + } + stationMap.set(stationId, { stationId, - name: String(raw?.name || raw?.short_name || '').trim(), + name, lat, lon, capacity: toNonNegativeInteger(raw?.capacity), From ddd932f0a7e287da7297fed3d02c03834d5039b0 Mon Sep 17 00:00:00 2001 From: Mazeyar Moeini Feizabadi Date: Tue, 13 Oct 2026 23:46:51 +0200 Subject: [PATCH 3/9] Harden shared GBFS station-name parsing and add regression coverage --- CHANGELOG.md | 6 + docs/CURRENT-STATE.md | 13 ++ scripts/format-scope.json | 4 +- scripts/qa-gbfs-names.mjs | 335 ++++++++++++++++++++++++++++ src/sources/gbfsStations.js | 34 +-- src/sources/gbfsStations.test.mjs | 199 +++++++++++++++++ src/tools/queries/mobility.test.mjs | 28 +++ 7 files changed, 605 insertions(+), 14 deletions(-) create mode 100644 scripts/qa-gbfs-names.mjs create mode 100644 src/sources/gbfsStations.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index c9d15d897..032c6fe2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +- GBFS v3 station names use English translations when available, then the first + usable translation or short name. Empty or malformed names no longer appear as + `[object Object]` or interrupt station loading; legacy string names remain + supported in the globe and bike-share tools. Thanks to + [Kristóf Kékesi](https://github.com/KristofKekesi) (#61). + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index a9aeeddbc..0d3ef31e0 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1092,6 +1092,19 @@ normal fallback chain. The existing declared media size ceiling remains 64 MiB. ## GBFS upstream bounds +Station information is normalized in `src/sources/gbfsStations.js` for both the +globe layer and `get_bike_share`. Names accept legacy strings or GBFS v3 Localized +String arrays regardless of the reported feed version. Nonempty English text +(including regional `en-*` tags, case-insensitive) takes precedence, then the first +usable translation. An unusable `name` falls back to `short_name` with the same +rules; if neither supplies text, the station remains available with an empty name +(the globe uses its station-ID label and the tool returns `null`). Malformed +translation entries do not interrupt loading of the other stations. This covers +station names, not complete GBFS v3 support. +`scripts/qa-gbfs-names.mjs` checks synthetic localized names through the real layer, +station selection, keyboard dismissal, mobile cards and bike-share tool against +either a dev or preview server (`--url` selects the target). + The station reader addresses the proxy as `/api/gbfs/`; the feed address is read from the path, and a query-string form is refused with a 400. GBFS refuses upstream redirects and enforces its 5 MiB response cap while streaming. The 12-second deadline includes reading the body, and rejected or diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 4973bd484..662d33a6b 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -979,5 +979,7 @@ "src/tools/views.test.mjs", "src/app/embed.test.mjs", "src/tools/globePanel.test.mjs", - "src/costEndpointThrottle.test.mjs" + "src/costEndpointThrottle.test.mjs", + "src/sources/gbfsStations.test.mjs", + "scripts/qa-gbfs-names.mjs" ] diff --git a/scripts/qa-gbfs-names.mjs b/scripts/qa-gbfs-names.mjs new file mode 100644 index 000000000..81dc5eb08 --- /dev/null +++ b/scripts/qa-gbfs-names.mjs @@ -0,0 +1,335 @@ +#!/usr/bin/env node +/** + * Keyless station-name regression against the real globe and bike-share tool. + * Run with a dev or preview server: node scripts/qa-gbfs-names.mjs --url http://localhost:4173 + * GBFS documents are synthetic; no operator feed or provider key is required. + */ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import puppeteer from 'puppeteer'; + +const urlIndex = process.argv.indexOf('--url'); +const appUrl = + urlIndex < 0 ? 'http://localhost:4173' : process.argv[urlIndex + 1]; +const shots = path.resolve('qa-shots/gbfs-names'); +fs.mkdirSync(shots, { recursive: true }); +const information = { + version: '3.0', + data: { + stations: [ + { + station_id: 'english', + name: [ + { language: 'hu', text: 'Magyar tér' }, + { language: 'en', text: 'QA English Square' }, + ], + }, + { + station_id: 'short', + name: [null, { language: 'en', text: ' ' }], + short_name: [{ language: 'en-GB', text: 'QA Short Name' }], + }, + { station_id: 'unnamed', name: [null, {}, { language: 42, text: [] }] }, + { station_id: 'legacy', name: ' QA Legacy Station ' }, + { + station_id: 'fallback', + name: [null, { language: 'hu', text: 'QA Magyar Station' }], + }, + ].map((station, index) => ({ + ...station, + lat: 30.2681 + index * 0.001, + lon: -97.7469 + index * 0.001, + capacity: 13, + })), + }, +}; +const status = { + data: { + stations: information.data.stations.map(({ station_id }) => ({ + station_id, + num_bikes_available: 4, + num_docks_available: 9, + is_renting: true, + is_returning: true, + })), + }, +}; +const browser = await puppeteer.launch({ + headless: 'new', + ...(process.env.PUPPETEER_EXECUTABLE_PATH + ? { executablePath: process.env.PUPPETEER_EXECUTABLE_PATH } + : {}), + args: + process.platform === 'darwin' + ? ['--use-angle=metal'] + : ['--use-gl=angle', '--use-angle=swiftshader'], +}); +let checks = 0; +const check = (name, actual, expected) => { + assert.deepEqual(actual, expected, name); + checks++; + console.log(`[PASS] ${name}`); +}; +async function open(mode = 'normal') { + const page = await browser.newPage(); + await page.bringToFront(); + const errors = []; + page.on('pageerror', (error) => errors.push(error.message)); + await page.setViewport({ width: 1440, height: 900 }); + await page.evaluateOnNewDocument( + ({ information, status, mode }) => { + localStorage.clear(); + localStorage.setItem('gev:first-run-mission:v1', 'suppressed'); + window.__qaPainted = []; + const paint = CanvasRenderingContext2D.prototype.fillText; + CanvasRenderingContext2D.prototype.fillText = function (text, ...args) { + if ( + String(text).startsWith('QA ') || + String(text) === 'Station unnamed' + ) { + window.__qaPainted.push(String(text)); + if (window.__qaPainted.length > 100) window.__qaPainted.shift(); + } + return paint.call(this, text, ...args); + }; + const original = window.fetch.bind(window); + window.fetch = async (input, init) => { + const url = String( + typeof input === 'string' || input instanceof URL ? input : input.url, + ); + if (url.includes('/api/gbfs/')) { + if (mode === 'failure') return new Response('{}', { status: 503 }); + const payload = + mode === 'empty' + ? { data: { stations: [] } } + : decodeURIComponent(url).includes('station_information') + ? information + : status; + return new Response(JSON.stringify(payload), { + headers: { 'Content-Type': 'application/json' }, + }); + } + if (url.includes('/api/flights')) return new Response('{"states":[]}'); + if (url.includes('/api/military')) return new Response('{"ac":[]}'); + if (url.includes('/api/vessels')) + return new Response('{"connected":false,"vessels":[]}'); + return original(input, init); + }; + }, + { information, status, mode }, + ); + await page.goto(appUrl, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction( + () => + window.__godsEyeView?.dataManager && window.__godsEyeView?.voiceCommands, + { timeout: 60000 }, + ); + await page.waitForFunction( + () => + !document.getElementById('loading-screen') || + (document.getElementById('loading-screen').classList.contains('hidden') && + getComputedStyle(document.getElementById('loading-screen')).opacity === + '0'), + { timeout: 60000 }, + ); + await page.evaluate(async () => { + const { dataManager, viewer } = window.__godsEyeView; + for (const [id, layer] of dataManager.layers) { + if (layer.enabled) + await dataManager.setEnabled(id, false, { origin: 'user' }); + } + const Cartesian3 = viewer.camera.position.constructor; + viewer.camera.cancelFlight(); + viewer.camera.setView({ + destination: Cartesian3.fromDegrees(-97.7449, 30.2701, 3000), + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + await dataManager.setEnabled('bikeshare', true, { origin: 'user' }); + window.__godsEyeView.requestRender('gbfs-name-qa'); + }); + return { page, errors }; +} +try { + const { page, errors } = await open(); + await page.waitForFunction( + () => + window.__godsEyeView.dataManager.layers.get('bikeshare').module.getStats() + .count === 5, + { timeout: 15000 }, + ); + const records = await page.evaluate(() => + window.__godsEyeView.dataManager.layers + .get('bikeshare') + .module.getAnalystRecords(), + ); + check( + 'the layer retains all five stations and normalized names', + records.map((r) => r.name), + [ + 'QA English Square', + 'QA Short Name', + null, + 'QA Legacy Station', + 'QA Magyar Station', + ], + ); + const tool = await page.evaluate(() => + window.__godsEyeView.voiceCommands.runner('get_bike_share', { + area: { lat: 30.2701, lon: -97.7449, radius_km: 2 }, + }), + ); + check('the real bike-share tool succeeds', tool.ok, true); + check( + 'the tool preserves localized, short, unnamed, legacy and non-English names', + tool.data.rows.map((r) => r.name).sort(), + [ + null, + 'QA English Square', + 'QA Short Name', + 'QA Legacy Station', + 'QA Magyar Station', + ].sort(), + ); + check( + 'availability remains attached to every station', + tool.data.rows.every( + (r) => r.bikes_available === 4 && r.docks_available === 9, + ), + true, + ); + const clickPoint = await page.evaluate(() => { + window.__qaPainted = []; + const { viewer } = window.__godsEyeView; + const Cartesian3 = viewer.camera.position.constructor; + const point = viewer.scene.cartesianToCanvasCoordinates( + Cartesian3.fromDegrees(-97.7469, 30.2681), + ); + const rect = viewer.scene.canvas.getBoundingClientRect(); + return { x: rect.left + point.x, y: rect.top + point.y }; + }); + await page.mouse.click(clickPoint.x, clickPoint.y); + await page.waitForFunction( + () => window.__qaPainted.includes('QA English Square'), + { timeout: 10000 }, + ); + check( + 'a real pointer click selects the station with its localized name', + await page.evaluate(() => window.__qaPainted.includes('QA English Square')), + true, + ); + await page.keyboard.press('Escape'); + for (const [id, title] of [ + ['english', 'QA English Square'], + ['short', 'QA Short Name'], + ['unnamed', 'Station unnamed'], + ]) { + await page.evaluate((id) => { + window.__qaPainted = []; + const app = window.__godsEyeView; + app.dataManager.layers + .get('bikeshare') + .module._selectBikeshareStationForTest(`austin-capmetro:${id}`); + app.requestRender('gbfs-name-selection-qa'); + }, id); + await page.waitForFunction( + (title) => window.__qaPainted.includes(title), + { timeout: 10000 }, + title, + ); + check( + `the selected ${id} card paints the expected title`, + await page.evaluate((title) => window.__qaPainted.includes(title), title), + true, + ); + await page.screenshot({ path: path.join(shots, `${id}.png`) }); + await page.keyboard.press('Escape'); + await page.waitForFunction(() => + window.__godsEyeView.dataManager.layers + .get('bikeshare') + .module.getDetectableObjects() + .every((record) => !record.skipLabel), + ); + check( + `Escape clears the ${id} card`, + await page.evaluate( + () => + window.__godsEyeView.dataManager.layers + .get('bikeshare') + .module.getDetectableObjects() + .filter((record) => record.skipLabel).length, + ), + 0, + ); + } + await page.setViewport({ width: 390, height: 844 }); + await page.evaluate(() => { + window.__qaPainted = []; + const app = window.__godsEyeView; + // Place the station card below the mobile panel headers. + const Cartesian3 = app.viewer.camera.position.constructor; + app.viewer.camera.cancelFlight(); + app.viewer.camera.setView({ + destination: Cartesian3.fromDegrees(-97.7449, 30.2771, 3000), + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + app.dataManager.layers + .get('bikeshare') + .module._selectBikeshareStationForTest('austin-capmetro:english'); + app.requestRender('gbfs-name-mobile-qa'); + }); + await page.waitForFunction( + () => window.__qaPainted.includes('QA English Square'), + { timeout: 10000 }, + ); + check( + 'the mobile selected card paints its normalized name', + await page.evaluate(() => window.__qaPainted.includes('QA English Square')), + true, + ); + await page.screenshot({ path: path.join(shots, 'mobile.png') }); + await page.evaluate(() => + window.__godsEyeView.dataManager.setEnabled('bikeshare', false, { + origin: 'user', + }), + ); + check( + 'disabling clears selected cards and analyst records', + await page.evaluate(() => ({ + count: window.__godsEyeView.dataManager.layers + .get('bikeshare') + .module.getAnalystRecords().length, + selected: window.__godsEyeView.dataManager.layers + .get('bikeshare') + .module.getDetectableObjects() + .filter((record) => record.skipLabel).length, + })), + { count: 0, selected: 0 }, + ); + check('normal fixtures produce no page exceptions', errors, []); + await page.close(); + for (const mode of ['empty', 'failure']) { + const { page, errors } = await open(mode); + await page.waitForFunction( + () => + !window.__godsEyeView.dataManager.layers + .get('bikeshare') + .module.getStats().loading, + ); + check( + `${mode} leaves no rendered stations`, + await page.evaluate( + () => + window.__godsEyeView.dataManager.layers + .get('bikeshare') + .module.getStats().count, + ), + 0, + ); + check(`${mode} produces no page exceptions`, errors, []); + await page.close(); + } + console.log(`GBFS names: ${checks}/${checks} checks passed; ${appUrl}`); +} finally { + await browser.close(); +} diff --git a/src/sources/gbfsStations.js b/src/sources/gbfsStations.js index cd90b12dd..efa1e6169 100644 --- a/src/sources/gbfsStations.js +++ b/src/sources/gbfsStations.js @@ -50,6 +50,26 @@ export function extractStationsArray(payload) { return []; } +/** Read legacy text or a GBFS v3 Localized String, preferring usable English text. */ +function stationName(value) { + if (typeof value === 'string') return value.trim(); + if (!Array.isArray(value)) return ''; + + let fallback = ''; + for (const translation of value) { + if (typeof translation?.text !== 'string') continue; + const text = translation.text.trim(); + if (!text) continue; + if (!fallback) fallback = text; + if ( + typeof translation.language === 'string' && + /^en(?:-|$)/i.test(translation.language.trim()) + ) + return text; + } + return fallback; +} + /** * Parse a GBFS station_information payload into a Map of station metadata. * Handles field-name variations across different GBFS providers @@ -69,21 +89,9 @@ export function parseStationInformation(payload) { const lon = Number(raw?.lon ?? raw?.longitude); if (!stationId || !Number.isFinite(lat) || !Number.isFinite(lon)) continue; - let name; - if (payload?.version >= 3) { - const handleLocalizedString = (array) => - array.find((localizedString) => - localizedString.language.toLowerCase() === 'en') || array[0]; - name = String( - handleLocalizedString(raw?.name).text || - handleLocalizedString(raw?.short_name).text || '').trim(); - } else { - name = String(raw?.name || raw?.short_name || '').trim(); - } - stationMap.set(stationId, { stationId, - name, + name: stationName(raw?.name) || stationName(raw?.short_name), lat, lon, capacity: toNonNegativeInteger(raw?.capacity), diff --git a/src/sources/gbfsStations.test.mjs b/src/sources/gbfsStations.test.mjs new file mode 100644 index 000000000..8c60da90c --- /dev/null +++ b/src/sources/gbfsStations.test.mjs @@ -0,0 +1,199 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { parseStationInformation } from './gbfsStations.js'; + +const station = (fields = {}) => ({ + station_id: 's1', + lat: 30.2681, + lon: -97.7469, + capacity: 13, + ...fields, +}); +const parseName = (fields, version) => + parseStationInformation({ + version, + data: { stations: [station(fields)] }, + }).get('s1').name; + +test('GBFS v2 station names remain trimmed strings', () => { + assert.equal( + parseName({ name: ' Republic Square ', short_name: 'Short' }, '2.3'), + 'Republic Square', + ); +}); + +test('GBFS v3 station names prefer English over the first translation', () => { + assert.equal( + parseName( + { + name: [ + { language: 'hu', text: 'Köztársaság tér' }, + { language: 'en', text: ' Republic Square ' }, + ], + }, + '3.0', + ), + 'Republic Square', + ); +}); + +test('English preference accepts case differences and regional language tags', () => { + for (const language of ['EN', 'en-GB', 'En-US']) { + assert.equal( + parseName({ + name: [ + { language: 'hu', text: 'Hungarian' }, + { language, text: 'English' }, + ], + }), + 'English', + ); + } +}); + +test('without usable English text the first usable translation wins', () => { + assert.equal( + parseName({ + name: [ + null, + { language: 'en', text: ' ' }, + { language: 'en', text: 42 }, + { language: 'hu', text: ' Első ' }, + { language: 'fr', text: 'Deuxième' }, + ], + }), + 'Első', + ); +}); + +test('malformed language fields cannot abort translation selection', () => { + const name = [ + null, + false, + 'bad', + {}, + { language: 42, text: 'Fallback' }, + { language: { unexpected: true }, text: 'Other' }, + { language: 'en', text: 'English' }, + ]; + assert.equal(parseName({ name }), 'English'); + assert.equal( + parseName({ name: [{ text: ' Usable text without a locale ' }] }), + 'Usable text without a locale', + ); +}); + +test('a usable name takes precedence over an English short name', () => { + assert.equal( + parseName({ + name: [{ language: 'hu', text: 'Full name' }], + short_name: [{ language: 'en', text: 'Short name' }], + }), + 'Full name', + ); +}); + +test('missing, empty and malformed names fall back to localized short_name', () => { + for (const name of [ + undefined, + null, + '', + ' ', + [], + {}, + 0, + 42, + false, + [null, {}, { language: 'en', text: [] }, { language: 'hu', text: ' ' }], + ]) { + assert.equal( + parseName({ + name, + short_name: [ + { language: 'hu', text: 'Rövid' }, + { language: 'en', text: ' Short ' }, + ], + }), + 'Short', + ); + } +}); + +test('localized names can fall back to legacy short_name strings', () => { + assert.equal(parseName({ name: [], short_name: ' Short ' }), 'Short'); +}); + +test('no usable name leaves the station available with an empty name', () => { + for (const value of [ + undefined, + null, + '', + [], + {}, + 42, + true, + [null, { text: false }, { language: 'en', text: ' ' }], + ]) { + assert.equal(parseName({ name: value, short_name: value }), ''); + } +}); + +test('name shape is normalized even when a feed omits or misstates its version', () => { + const name = [{ language: 'en', text: 'Republic Square' }]; + for (const version of [undefined, '2.3', '3.0', '3.1', 'unknown']) { + assert.equal(parseName({ name }, version), 'Republic Square'); + assert.equal(parseName({ name: 'Legacy name' }, version), 'Legacy name'); + } +}); + +test('bad names do not lose valid stations or their metadata', () => { + const information = parseStationInformation({ + version: '3.0', + data: { + stations: [ + station({ station_id: 'bad', name: [null, {}] }), + station({ + station_id: 'short', + name: [], + short_name: [{ language: 'en', text: 'Short' }], + }), + station({ + station_id: 'good', + name: [{ language: 'en', text: 'Republic Square' }], + }), + ], + }, + }); + assert.equal(information.size, 3); + assert.equal(information.get('bad').name, ''); + assert.equal(information.get('short').name, 'Short'); + assert.deepEqual(information.get('good'), { + stationId: 'good', + name: 'Republic Square', + lat: 30.2681, + lon: -97.7469, + capacity: 13, + isInstalled: true, + isRenting: true, + isReturning: true, + }); +}); + +test('normalizing a localized name leaves the feed document unchanged', () => { + const payload = { + version: '3.0', + data: { + stations: [ + station({ + name: [ + { language: 'hu', text: 'Magyar' }, + { language: 'en', text: ' English ' }, + ], + }), + ], + }, + }; + const original = structuredClone(payload); + parseStationInformation(payload); + assert.deepEqual(payload, original); +}); diff --git a/src/tools/queries/mobility.test.mjs b/src/tools/queries/mobility.test.mjs index af8142305..7c7050e88 100644 --- a/src/tools/queries/mobility.test.mjs +++ b/src/tools/queries/mobility.test.mjs @@ -106,6 +106,34 @@ test('bike share joins station information with live status inside the area', as assert.equal(result.data.rows[1].capacity, 10); }); +test('bike share returns localized names without losing stations with malformed names', async () => { + const informationUrl = 'https://gbfs.example/austin/station_information.json'; + const information = structuredClone(documents[informationUrl]); + information.version = '3.0'; + information.data.stations[0].name = [ + { language: 'hu', text: 'Magyar név' }, + { language: 'en', text: 'Congress & 2nd' }, + ]; + information.data.stations[2].name = [null, { language: 42, text: [] }]; + const source = bikeshare([system('austin', 'Austin, TX', 30.27, -97.74)]); + const catalog = composeCatalog({ + tools: coreTools, + services: { + bikeshare: { + ...source, + getStations: (url) => + url === informationUrl ? information : source.getStations(url), + }, + }, + }); + const result = await catalog.call('get_bike_share', { area: downtown }); + assert.equal(result.data.total, 2); + assert.equal(result.data.rows[0].name, 'Congress & 2nd'); + assert.equal(result.data.rows[0].bikes_available, 4); + assert.equal(result.data.rows[1].name, null); + assert.doesNotMatch(result.summary, /unavailable/); +}); + test('bike share reports uncovered areas and failed systems honestly', async () => { const none = composeCatalog({ tools: coreTools, From 82b8c0c850665245c5149279387a6dac2632656e Mon Sep 17 00:00:00 2001 From: Mazeyar Moeini Feizabadi Date: Tue, 13 Oct 2026 23:47:48 +0200 Subject: [PATCH 4/9] Frame mobile GBFS QA in supported clean view --- scripts/qa-gbfs-names.mjs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/qa-gbfs-names.mjs b/scripts/qa-gbfs-names.mjs index 81dc5eb08..3a367abe4 100644 --- a/scripts/qa-gbfs-names.mjs +++ b/scripts/qa-gbfs-names.mjs @@ -266,11 +266,12 @@ try { await page.evaluate(() => { window.__qaPainted = []; const app = window.__godsEyeView; - // Place the station card below the mobile panel headers. + // Use the supported clean view to inspect the mobile card clear of panels. + app.styleManager.toggleCleanView(true); const Cartesian3 = app.viewer.camera.position.constructor; app.viewer.camera.cancelFlight(); app.viewer.camera.setView({ - destination: Cartesian3.fromDegrees(-97.7449, 30.2771, 3000), + destination: Cartesian3.fromDegrees(-97.7449, 30.2701, 3000), orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, }); app.dataManager.layers From 128a689a5699efcc4ceaf0fd24868368b2b4eced Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 14 Oct 2026 11:44:25 +0100 Subject: [PATCH 5/9] fix(cctv): make the NYC DOT pack opt-in and carry DOT's disclaimer NYC DOT asks developers who use its camera feed to sign a data-sharing agreement, and this project does not hold one that covers downstream installs. The pack now loads only when CCTV_NYC_ENABLED=1; unset, empty or any other value makes no request to webcams.nyctmc.org (tested). The NYC credit carries NYC DOT's requested application disclaimer verbatim, and DATA_SOURCES, README, CHANGELOG, CURRENT-STATE and .env.example describe the prerequisite. Co-Authored-By: Claude Opus 5.5 --- .env.example | 2 +- CHANGELOG.md | 10 +++--- DATA_SOURCES.md | 4 +-- README.md | 2 +- docs/CURRENT-STATE.md | 15 ++++---- server/providers/cctv/catalog.js | 9 +++-- src/data/cctvNyc.test.mjs | 62 +++++++++++++++++--------------- src/data/dataCredits.js | 12 +++++-- src/data/dataCredits.test.mjs | 10 +++++- 9 files changed, 79 insertions(+), 47 deletions(-) diff --git a/.env.example b/.env.example index 0b9a0037a..be746e3e4 100644 --- a/.env.example +++ b/.env.example @@ -215,7 +215,7 @@ AISSTREAM_API_KEY= # CCTV_VEGVESEN_MAX_SOURCES=900 # cap cameras loaded from Statens vegvesen (default loads all ~850 live; max 1000) # CCTV_VEGVESEN_VIDEO=1 # set 0 to show stills instead of live HLS for Statens vegvesen video cameras # CCTV_VEGVESEN_URL= # override the Statens vegvesen catalog URL -# CCTV_NYC_ENABLED=1 # set 0 to disable the NYC DOT Traffic Management Center cameras +# CCTV_NYC_ENABLED= # off by default; set 1 only if you hold a NYC DOT data-sharing agreement (tmcdot@dot.nyc.gov) for the camera feed # CCTV_NYC_MAX_SOURCES=250 # cap cameras loaded from the NYC TMC catalog (nearest borough cores first) # CCTV_NYC_CAMERAS_URL= # override the NYC TMC catalog URL # CCTV_MAX_SOURCES=4000 # catalog-wide ceiling, shared round-robin across packs diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b12ee132..8053a997e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,14 @@ # Changelog - Add New York City traffic cameras from the NYC DOT Traffic Management Center - as a CCTV pack. The camera list is one keyless JSON request to + as an opt-in CCTV pack. NYC DOT requires a data-sharing agreement for the + camera feed, so the pack is off unless `CCTV_NYC_ENABLED=1` is set by an + operator who holds one; otherwise no request reaches NYC DOT. NYC DOT's + requested disclaimer is in the Data attribution popover. The camera list is one keyless JSON request to `webcams.nyctmc.org/api/cameras`; frames are stills synthesized from each camera UUID on that same host, so a catalog field cannot steer the proxy - off-host. Offline cameras are dropped. By default the 250 cameras nearest - the five borough cores load. `CCTV_NYC_ENABLED=0` turns the pack off and - `CCTV_NYC_MAX_SOURCES` changes the cap. The catalog-wide ceiling is + off-host. Offline cameras are dropped. Once enabled, the 250 cameras nearest + the five borough cores load and `CCTV_NYC_MAX_SOURCES` changes the cap. The catalog-wide ceiling is unchanged; extra cameras share it round-robin with the other packs. - Treat explicitly current, complete empty vessel coverage as a successful update, diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index dbb07f0bf..0e2595237 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -56,7 +56,7 @@ How to read this: | **DriveBC** (Government of British Columbia) | CCTV camera catalog + frames, British Columbia highways | [Open Government Licence – British Columbia](https://www2.gov.bc.ca/gov/content/data/open-data/open-government-licence-bc) — attribution REQUIRED | "DriveBC. Contains information licensed under the Open Government Licence – British Columbia" | | **Open Calgary** (`data.calgary.ca`) | CCTV camera catalog + frames, Calgary | [Open Government Licence – City of Calgary](https://data.calgary.ca/stories/s/Open-Calgary-Terms-of-Use/u45n-7awa) — attribution REQUIRED | "Contains information licensed under the Open Government Licence – City of Calgary" | | **Statens vegvesen** (catalog `ogckart-sn1.atlas.vegvesen.no`, frames `kamera.atlas.vegvesen.no`, HLS `kamera.vegvesen.no`) | CCTV camera catalog + frames + live HLS video, Norway road cameras | [NLOD 2.0](https://data.norge.no/nlod/en/2.0) — attribution REQUIRED | "Contains data under the Norwegian licence for Open Government data (NLOD) distributed by Statens vegvesen" | -| **NYC DOT Traffic Management Center** (`webcams.nyctmc.org`) | CCTV camera catalog + frames, New York City five boroughs | Public NYC DOT traffic camera stills | "NYC DOT Traffic Management Center" (courtesy) | +| **NYC DOT Traffic Management Center** (`webcams.nyctmc.org`) | CCTV camera catalog + frames, New York City five boroughs | NYC DOT requires developers to sign a [data-sharing agreement](https://www.nyc.gov/html/dot/html/about/datafeeds.shtml) for the camera feed. **Off by default**; enable only under your own agreement | "NYC DOT Traffic Management Center" + NYC DOT's required application disclaimer (in the Data attribution popover) | | **GBFS (Lyft / BCycle)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle) + its `license_url` | | **MBTA / MassDOT** (cdn.mbta.com) | Transit layer live vehicles and up to 15 minutes of recently observed positions, Greater Boston | MBTA / MassDOT open realtime vehicle data, used for the live view and up to fifteen minutes of recently observed positions; published for developer use and not against their use. | Attribution to MBTA / MassDOT (courtesy text) | | **CapMetro** (data.texas.gov) | Transit layer live vehicles, Austin TX | CapMetro open realtime vehicle data, used for the live view; published for developer use and not against their use. | Attribution to CapMetro (courtesy text) | @@ -92,7 +92,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei - **Live Traffic NSW cameras.** The camera list is the keyless public `data.livetraffic.com/cameras/traffic-cam.json` feed (Transport for NSW open data, CC BY 4.0); frames are stills on `webcams.transport.nsw.gov.au`, and only that host is registered. That image host answers non-browser clients with an HTML placeholder, so the proxy identifies as a browser for that one host and as itself everywhere else. Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_NSW_ENABLED=0`. - **Open Calgary traffic cameras.** Keyless. The camera list is the public Socrata dataset `k7p9-kppz` on `data.calgary.ca`; frames are stills on the city's own `trafficcam.calgary.ca` host, and only that host is registered. Most rows publish an `http://` frame URL and the host redirects those to HTTPS, so URLs are upgraded and pinned to that origin before registration. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing: the `quadrant` field and the quadrant suffix on each camera name are Calgary's address grid, not a bearing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. The licence requires the attribution statement above. Disable with `CCTV_CALGARY_ENABLED=0`. - **Statens vegvesen road cameras.** Keyless. The camera list is one GeoJSON request to the OGC API Features view of the official DATEX 3.1 CCTV site table (`ogckart-sn1.atlas.vegvesen.no/ogc/features/v1/collections/datex_3_1:CctvSimple/items`), so no DATEX account is needed; frames are stills on `kamera.atlas.vegvesen.no/api/images/`, and only that exact path per camera is registered. Cameras the feed reports as faulty are dropped. About 135 working cameras also publish live HLS (`kamera.vegvesen.no/public//manifest.m3u8`, MPEG-TS); those play as live video through the bounded in-memory HLS relay, with the still as the snapshot fallback, and only that exact manifest path per camera is registered. `CCTV_VEGVESEN_VIDEO=0` keeps every camera on stills. `orientationDescription` names the place a camera faces, not a bearing, so it goes into the label and headings use the id-hash fallback at low confidence. By default every live camera loads (~850); `CCTV_VEGVESEN_MAX_SOURCES` (8–1000) lowers the cap, keeping the cameras nearest Oslo, Bergen, Stavanger, Trondheim, Kristiansand, Bodø and Tromsø and `CCTV_VEGVESEN_ENABLED=0` turns the pack off. Frames are fetched at request time and never stored or redistributed. The NLOD attribution is registered in the Data attribution popover. -- **NYC DOT Traffic Management Center cameras.** Keyless. The camera list is the public `webcams.nyctmc.org/api/cameras` catalog (~980 cameras across the five boroughs); frames are stills at `webcams.nyctmc.org/api/cameras//image`, synthesized from a validated UUID rather than taken from the payload, and only that host is registered. Cameras the feed reports as offline are dropped. The catalog publishes no camera facing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. By default the 250 cameras nearest the borough cores load; `CCTV_NYC_MAX_SOURCES` (8–400) changes the cap and `CCTV_NYC_ENABLED=0` turns the pack off. Frames are fetched at request time and never stored or redistributed. +- **NYC DOT Traffic Management Center cameras.** Off by default. NYC DOT's [developer data feeds page](https://www.nyc.gov/html/dot/html/about/datafeeds.shtml) asks developers who want the camera feed to contact `tmcdot@dot.nyc.gov` and sign a data-sharing agreement. This project does not hold one that covers downstream installations, so the pack loads only when an operator who holds their own agreement sets `CCTV_NYC_ENABLED=1`; with the flag unset, empty or any other value, the server makes no request to NYC DOT. The endpoint needs no key. The camera list is the public `webcams.nyctmc.org/api/cameras` catalog (~980 cameras across the five boroughs); frames are stills at `webcams.nyctmc.org/api/cameras//image`, synthesized from a validated UUID rather than taken from the payload, and only that host is registered. Cameras the feed reports as offline are dropped. The catalog publishes no camera facing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. Once enabled, the 250 cameras nearest the borough cores load; `CCTV_NYC_MAX_SOURCES` (8–400) changes the cap. Frames are fetched at request time and never stored or redistributed. NYC DOT asks every application that uses its data to carry its disclaimer; it is registered verbatim in the Data attribution popover. - **Camera frame content.** Public camera frames can contain people, vehicles and license plates, and the camera layer does nothing with them beyond putting them on the map. A successful upstream response is relayed as the provider served it: nothing in the camera pipeline blurs, redacts, enhances or restores it, and nothing there runs face, plate or object detection or otherwise recognises or classifies what is in the picture. (The ALPR layer described below maps where plate-reader cameras are, from locations contributors tagged in OpenStreetMap; it reads nothing from any camera frame.) When an upstream has no frame for a camera, what you see instead is a Google Street View still or the app's own placeholder card; the panel's source badge names which of them you are looking at, and a refresh that fails after a frame has arrived leaves the last good picture on screen rather than replacing it. The browser resamples each frame for display: onto a fixed 1920x1080 surface for the camera's projection plane in the scene, and onto a 192x108 canvas for the small ambient card. That scaling is the only change made to the picture, and the only pixels the camera layer reads back are a 64x36 downscale hashed to tell whether the picture has changed. Live streams are piped through; the still path reads the image into memory to pass it on — TxDOT wraps its JPEG in a JSON envelope, which is decoded there — and no frame is written to disk (the disk cache, `.gev-cache/`, holds feed JSON for other layers and never camera imagery). Outside that pipeline, the voice assistant is the one feature that sends imagery anywhere: when it answers a question about what is in view it may attach a downscaled screenshot of the rendered scene — including any camera plane on screen — to the request it sends to the voice model. Frames are fetched only from server-registered camera URLs — a client cannot hand the proxy a URL (see [SECURITY.md](SECURITY.md)). What the app does stays inside the line README draws: no named-person search, no face recognition, no tracking individuals. - **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. All seven registered regions are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth. - **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. diff --git a/README.md b/README.md index 38f7a4913..70c4edf28 100644 --- a/README.md +++ b/README.md @@ -300,7 +300,7 @@ Twenty layers and map sources. **Seventeen have a keyless path.** Some offer add | 🛰️ **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** | ~4,100 public cameras 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) · New York City (NYC DOT TMC). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 | +| 📹 **CCTV Mesh** | ~3,900 public cameras 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), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement). 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 first; Panoramax, KartaView and Google Street View planned | Mapillary | 🟡 Mapillary client token | | 📻 **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 | 🟢 | diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 38bfe3a20..7711e1e9e 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -916,17 +916,20 @@ tuning; a delayed Enable result cannot reveal or refocus removed controls. ## NYC DOT TMC CCTV source pack -The CCTV catalog adds New York City traffic cameras from the NYC DOT Traffic -Management Center alongside the other live packs. The server reads the keyless +The CCTV catalog can add New York City traffic cameras from the NYC DOT +Traffic Management Center. NYC DOT requires developers to sign a data-sharing +agreement for the camera feed, so this is the one opt-in pack: it loads only +when `CCTV_NYC_ENABLED=1`, and with the flag unset the server makes no NYC +request. NYC DOT's requested disclaimer is in the Data attribution popover. +When enabled, the server reads the keyless camera list at `https://webcams.nyctmc.org/api/cameras` and keeps cameras the feed marks online. Frame URLs are built from each camera UUID under `https://webcams.nyctmc.org/api/cameras//image`; they are never taken from the payload. There is no heading in the feed, so gizmos use the shared id-hash fallback at low confidence. -By default the 250 cameras nearest the five borough cores load. -`CCTV_NYC_MAX_SOURCES` sets the pack cap (8–400) and `CCTV_NYC_ENABLED=0` -turns the pack off. The catalog-wide ceiling stays at 4,000 and is shared +Once enabled, the 250 cameras nearest the five borough cores load. +`CCTV_NYC_MAX_SOURCES` sets the pack cap (8–400). The catalog-wide ceiling stays at 4,000 and is shared round-robin, so adding this pack does not silently drop another region. ## DriveBC CCTV source pack @@ -3073,7 +3076,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 | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache | -| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS) + NYC DOT TMC + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) | +| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS) + NYC DOT TMC (opt-in) + 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 | | Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) | | Bikeshare 🚲 | GBFS (Lyft + BCycle) | `src/data/bikeshare.js` | `/api/gbfs` | 60s | diff --git a/server/providers/cctv/catalog.js b/server/providers/cctv/catalog.js index 0939a71da..99e627310 100644 --- a/server/providers/cctv/catalog.js +++ b/server/providers/cctv/catalog.js @@ -24,13 +24,16 @@ import { /** Env kill switch: unset or anything but "0" means enabled. */ const envEnabled = (name) => String(process.env[name] || '1').trim() !== '0'; +/** Env opt-in: only "1" enables. For packs whose feed access needs an + * operator agreement, so an unset flag never reaches the upstream. */ +const envOptIn = (name) => String(process.env[name] || '').trim() === '1'; /** * Live open-data packs, in merge order. Adding a region is one entry here * plus its loader in sources.js; the catalog cap is shared across entries * round-robin (cap.js), so a new pack never silently evicts an older one. * Each pack fails independently (allSettled) and is gated by its own env - * kill switch. + * kill switch, or by an opt-in flag where access needs an agreement. */ const LIVE_PACKS = [ { name: 'austin', enabled: () => true, load: loadAustinSourcesFromOpenData }, @@ -100,8 +103,10 @@ const LIVE_PACKS = [ load: loadVegvesenSourcesFromOpenData, }, { + // NYC DOT requires a data-sharing agreement for this feed, so the pack is + // off unless the operator opts in (see DATA_SOURCES.md). name: 'nyc', - enabled: () => envEnabled('CCTV_NYC_ENABLED'), + enabled: () => envOptIn('CCTV_NYC_ENABLED'), load: loadNycSourcesFromOpenData, }, ]; diff --git a/src/data/cctvNyc.test.mjs b/src/data/cctvNyc.test.mjs index d67ffa002..e96755f5a 100644 --- a/src/data/cctvNyc.test.mjs +++ b/src/data/cctvNyc.test.mjs @@ -321,53 +321,59 @@ const runCatalogWithMockedUpstreams = async (t) => { return { requested, sources }; }; -test('the NYC lane is wired into the catalog and its loader runs', async (t) => { +/** Run one catalog refresh with CCTV_NYC_ENABLED set to `flag` (undefined + * leaves it unset), restoring the environment afterwards. */ +const runCatalogWithNycFlag = async (t, flag) => { const saved = { ...process.env }; try { delete process.env.CCTV_SOURCES_FILE; delete process.env.CCTV_SOURCES_JSON; - delete process.env.CCTV_NYC_ENABLED; - const { requested, sources } = await runCatalogWithMockedUpstreams(t); - assert.ok( - requested.includes(NYC_CAMERAS_URL), - 'the catalog refresh invokes the NYC loader', - ); - assert.deepEqual( - sources.filter((s) => s.cityId === 'nyc').map((s) => s.id), - [`nyc-${MIDTOWN_ID}`, `nyc-${BROOKLYN_ID}`], - 'NYC cameras reach the served catalog through the registered lane', - ); + if (flag === undefined) delete process.env.CCTV_NYC_ENABLED; + else process.env.CCTV_NYC_ENABLED = flag; + return await runCatalogWithMockedUpstreams(t); } finally { for (const key of Object.keys(process.env)) { if (!(key in saved)) delete process.env[key]; } Object.assign(process.env, saved); } +}; + +test('CCTV_NYC_ENABLED=1 wires the NYC lane into the catalog', async (t) => { + const { requested, sources } = await runCatalogWithNycFlag(t, '1'); + assert.ok( + requested.includes(NYC_CAMERAS_URL), + 'the catalog refresh invokes the NYC loader', + ); + assert.deepEqual( + sources.filter((s) => s.cityId === 'nyc').map((s) => s.id), + [`nyc-${MIDTOWN_ID}`, `nyc-${BROOKLYN_ID}`], + 'NYC cameras reach the served catalog through the registered lane', + ); }); -test('CCTV_NYC_ENABLED=0 keeps the lane from being loaded at all', async (t) => { - const saved = { ...process.env }; - try { - delete process.env.CCTV_SOURCES_FILE; - delete process.env.CCTV_SOURCES_JSON; - process.env.CCTV_NYC_ENABLED = '0'; - const { requested, sources } = await runCatalogWithMockedUpstreams(t); +// NYC DOT requires a data-sharing agreement for this feed, so an install that +// has not opted in must never contact it. +for (const [label, flag] of [ + ['an unset CCTV_NYC_ENABLED', undefined], + ['an empty CCTV_NYC_ENABLED', ''], + ['CCTV_NYC_ENABLED=0', '0'], + ['CCTV_NYC_ENABLED=true', 'true'], +]) { + test(`${label} makes no NYC request`, async (t) => { + const { requested, sources } = await runCatalogWithNycFlag(t, flag); + assert.ok(requested.length > 0, 'the other packs still refreshed'); assert.equal( - requested.includes(NYC_CAMERAS_URL), + requested.some((href) => href.includes('nyctmc.org')), false, - 'the disabled lane never reaches its upstream', + 'the lane never reaches its upstream', ); assert.deepEqual( sources.filter((s) => s.cityId === 'nyc'), [], ); - } finally { - for (const key of Object.keys(process.env)) { - if (!(key in saved)) delete process.env[key]; - } - Object.assign(process.env, saved); - } -}); + }); +} test('the shipped catalog ceiling is not raised to make room for this pack', () => { assert.equal(DEFAULT_CCTV_MAX_SOURCES, 4000); diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index 891cd21e2..e68caddb4 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -91,6 +91,13 @@ export function hideOsmCredit(viewer, owner) { return true; } +/** NYC DOT asks every application that uses its data to carry this + * disclaimer verbatim (nyc.gov/html/dot/html/about/datafeeds.shtml). */ +export const NYC_DOT_DISCLAIMER = + 'This application may contain and/or utilize information which was originally compiled by the New York City Department of Transportation (NYC DOT) for governmental purposes; the information may subsequently been modified by entity/entities other than NYC DOT. ' + + 'NYC DOT and the City of New York make no representation as to the accuracy or usefulness of the information provided by this application or the information's suitability for any purpose and disclaim any liability for omissions or errors that may be contained therein. ' + + 'The public is advised to observe posted signage for compliance with applicable laws and regulations.'; + export const DATA_CREDITS = [ // ── Live sources ──────────────────────────────────────────────── { @@ -250,8 +257,9 @@ export const DATA_CREDITS = [ { key: 'nyc-cctv', html: - 'CCTV cameras & frames (New York City): ' + - 'NYC DOT Traffic Management Center', + 'CCTV cameras & frames (New York City, when enabled): ' + + 'NYC DOT Traffic Management Center. ' + + NYC_DOT_DISCLAIMER, }, { key: 'gbfs', diff --git a/src/data/dataCredits.test.mjs b/src/data/dataCredits.test.mjs index 46de48972..30a19b8fe 100644 --- a/src/data/dataCredits.test.mjs +++ b/src/data/dataCredits.test.mjs @@ -1,6 +1,6 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { DATA_CREDITS } from './dataCredits.js'; +import { DATA_CREDITS, NYC_DOT_DISCLAIMER } from './dataCredits.js'; test('every credit carries a unique key and some markup to render', () => { const keys = DATA_CREDITS.map((entry) => entry.key); @@ -120,3 +120,11 @@ test('tile attribution follows source changes and viewers have independent owner assert.equal(a.credits.size, 0); assert.equal(b.credits.size, 1); }); + +test('the NYC camera credit carries NYC DOT’s requested disclaimer', () => { + const credit = DATA_CREDITS.find((entry) => entry.key === 'nyc-cctv'); + assert.ok(credit, 'NYC DOT camera frames must be credited'); + assert.ok(credit.html.includes(NYC_DOT_DISCLAIMER)); + assert.match(credit.html, /make no representation as to the accuracy/); + assert.match(credit.html, /observe posted signage/); +}); From e9a4dbb0625dec71d3c0ae209e124a09079bf810 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 14 Oct 2026 12:17:33 +0100 Subject: [PATCH 6/9] fix(street-level): KartaView marker and camera direction; remaining review P4s MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Marker and direction (reported on the PR): - KartaView has its own marker, a purple view cone (Street View's is blue), not GEV's default pin that Mapillary uses. - The camera direction (marker, cones, FOLLOW, a panorama's centre) is the direction of travel to the next photo a few metres on. KartaView's heading is the device compass: whole drives read 0 (no compass) and others sit a steady ~110° off. Until the drive loads the compass stands in (0 = unknown), and the pose is sent again once the course is known. Remaining P4s from the review of #1121: - Segment chaining is linear (a per-point cursor) and capped at 20,000 segments a tile and 5,000 points a line: a hostile tile of 60k segments sharing one point took 1.5 s, now tens of ms. - Decoded tiles are dropped when KartaView is switched off. - No network / a refused request says so and is not retried as a timeout; an unreadable answer says so. - A docked flat photo skips the 2,560 px download until the viewer grows. - The panel counts the drive's photos (countActivePhotos), not the thinned page of cones (2,250, not 217). - After a GPU reset the viewer redraws the photo itself, so a lookup the user started is not cancelled; a failure is still reported. - The gate's keyless page lights KartaView and checks it draws with no key; the fixture answers the sequence count; the line-click step checks the camera looks along the drive when the compass says 200°. - README/CHANGELOG show the share-link form the app writes. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 +- README.md | 2 +- docs/CURRENT-STATE.md | 24 +++- scripts/fixtures/street-level/kartaView.mjs | 16 ++- scripts/qa-street-level.mjs | 51 +++++++- .../streetLevel/providers/kartaview/decode.js | 24 +++- .../providers/kartaview/decode.test.mjs | 43 ++++++- .../streetLevel/providers/kartaview/index.js | 77 +++++++++--- .../streetLevel/providers/kartaview/policy.js | 53 +++++++- .../providers/kartaview/policy.test.mjs | 45 ++++++- .../providers/kartaview/provider.test.mjs | 119 +++++++++++++++++- .../streetLevel/providers/kartaview/source.js | 56 +++++++-- .../providers/kartaview/source.test.mjs | 79 +++++++++--- .../streetLevel/providers/kartaview/viewer.js | 36 +++++- .../providers/kartaview/viewer.test.mjs | 19 +++ 15 files changed, 588 insertions(+), 63 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0df3bc64..8a3261ba0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -103,7 +103,12 @@ look, wheel to zoom), with cones along its drive and ‹ › / ← → to step through it. The caption links to the full-size photo (KartaView's photo pages fail for signed-out visitors), and the globe credits "© KartaView - contributors, CC BY-SA 4.0". Share links carry it as `0.m.0_0.r.kartaview-1`. Archived photos (KartaView answers 409) are skipped by the click and by ‹ ›; a keyboard and pinch look around a 360° photo. + contributors, CC BY-SA 4.0". Share links carry it in the provider + switches (`0.m.0_0.r.google-0*kartaview-1`). Archived photos (KartaView + answers 409) are skipped by the click and by ‹ ›. Shift+← →, ↑ ↓, + − and + pinch look around a 360° photo. The open photo's marker is a purple view + cone pointing along the drive (KartaView's own compass headings are often + 0 or far off). Mapillary's line coverage and sequence cones are now shared by both (`lineCoverage.js`, `sequenceCones.js`). diff --git a/README.md b/README.md index b58a2eaec..e55c94a77 100644 --- a/README.md +++ b/README.md @@ -346,7 +346,7 @@ _The Space Missions layer replaying a Falcon 9 ascent — labeled `RECONSTRUCTED **Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Each provider has a chip in its own colour (Mapillary green), and one is lit at a time: lighting one switches the others off. Mapillary, which is free, is lit by default. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary; Street View panoramas are all 360°, so with Street View lit the filters and legend are hidden. Click a coverage line for its image cones and a cone to open the photo. Light **KARTAVIEW** (purple) and zoom in below about 6 km: its lines are the streets KartaView has photos of, and a click on one opens the photo nearest the click, flat or 360° (drag to look around), with cones along its drive; **‹ ›** or ← → step through the drive (Shift+← →, ↑ ↓ and + − look around a 360° photo; pinch zooms on a phone). Photos KartaView has archived are skipped, and where all are archived the panel says so. KartaView photos are undated on its map, so the filters do not apply to it. On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND**, **FIT / FILL** (Mapillary, and flat KartaView photos), and **FOLLOW** (the globe camera follows the photo's view; Google 3D map only). The caption links to the image on the provider's site, and the globe credits each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google", "© KartaView contributors, CC BY-SA 4.0"). KartaView's own photo pages fail for signed-out visitors, so its link opens the full-size photo. -Drag the panel header to float the panel, and resize it from any edge. Double-click the header or collapse it to put it back in the rail. Share links carry the provider switches and filters (`0.m.0` Mapillary off, `0.m.0_0.r.google-1` Street View on, `0.m.0_0.r.kartaview-1` KartaView on (a link that leaves out `m` keeps Mapillary, the default, lit), `0.p.p` panoramas only, `0.s.365` the last year). To add a provider, implement the contract in `src/layers/streetLevel/registry.js` and register it in `src/app/layers/streetLevel.js`. +Drag the panel header to float the panel, and resize it from any edge. Double-click the header or collapse it to put it back in the rail. Share links carry the provider switches and filters (`0.m.0` Mapillary off, `0.m.0_0.r.google-1` Street View on, `0.m.0_0.r.google-0*kartaview-1` KartaView on, as the app writes it (`0.m.0_0.r.kartaview-1` works too; a link that leaves out `m` keeps Mapillary, the default, lit), `0.p.p` panoramas only, `0.s.365` the last year). To add a provider, implement the contract in `src/layers/streetLevel/registry.js` and register it in `src/app/layers/streetLevel.js`. ## 🎖️ Field Missions diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index f595ce519..21b894fb3 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3195,14 +3195,16 @@ ground and z13 up to 6 km (lower zooms take seconds per tile; z10 does not answer), so higher up the hint says to zoom in, and an empty view says there are no KartaView photos in it. A tile's lines are OSM way segments with photos and carry no sequence, date or projection, so `decode.js` chains -segments that meet end to start (about four times fewer lines), they are +segments that meet end to start (about four times fewer lines; linear time, +at most 20,000 segments a tile and 5,000 points a line), they are drawn as `kv:line:`, up to 1,600 per tile, and never highlighted (`highlightsSequence: false`, so the legend has no "Selected"), and the provider declares `filters: []` and `coverageLabel` ("N streets in view · click a line for its nearest photo"). Tiles come uncompressed and -uncacheable, so the last 24 decoded tiles are kept for 10 minutes -(`createTileCache`); one that does not answer in 20 s is asked once more, -and one that is not a line list is not cached. A click on a line reaches +uncacheable, so the last 24 decoded tiles are kept for 10 minutes while +KartaView is lit (`createTileCache`, dropped when it is switched off); one +that does not answer in 20 s is asked once more, and one that is not a line +list is not cached. A click on a line reaches `handlePick(id, {groundPoint})`, and the provider asks the core (`actions.openNearest`) for its nearest showable photo within 50 m, without framing: many photos are archived (their file answers 409, while a HEAD says @@ -3211,7 +3213,15 @@ off at the headers, and where all are gone the panel says KartaView's photos there are archived. Only a definite answer (403/404/409/410, or 200) is remembered; a storage timeout or 5xx is asked again. The open photo's sequence draws as cones from the 250-photo page holding it -(`/2.0/sequence/{id}/photos`). Pages follow photo ids, which only roughly +(`/2.0/sequence/{id}/photos`); the panel counts the drive's own photos +(`countActivePhotos`, asked once per sequence). The camera direction (the +marker, cones, FOLLOW, and a panorama's centre) is the direction of travel +to the next photo a few metres on (`assignCourses`): KartaView is dashcam +imagery, while its `heading` is the device compass, which reads 0 for whole +drives without one and can sit a steady 110° off inside a car. Until the +drive's page arrives the compass stands in (0 counts as unknown), and the +pose is sent again once the course is known. The open photo's marker is a +purple view cone (`marker: {kind: 'view-cone'}`), as Street View's is blue. Pages follow photo ids, which only roughly follow the index, so the page is found by search (both ways, halving past the end when many photos were deleted), a neighbour page joins within 12 photos of an edge, and the cones re-centre whenever a photo of the selected sequence @@ -3222,7 +3232,9 @@ browser's six connections to it tiles take three, lookups two and pages one (newest first), and a lookup never waits behind tiles; the photo API answers 429 to more than about three requests at once, so a 429 is retried with backoff (at most 10 s), and a lookup gets 8 s, then 15 s once more (KartaView -sometimes sits on a lookup for 11 s and answers the same one in 3 s). Answers +sometimes sits on a lookup for 11 s and answers the same one in 3 s); a +request that could not be made at all (no network, CORS) says so and is not +repeated. Answers are read with a running byte count and cut off past 16 MB (KartaView sends them chunked, without a length). Photo URLs are built from `storage` and `filepath*`: the API's `fileurl*` always name storage1, where diff --git a/scripts/fixtures/street-level/kartaView.mjs b/scripts/fixtures/street-level/kartaView.mjs index 06d48b020..1c85b0ec0 100644 --- a/scripts/fixtures/street-level/kartaView.mjs +++ b/scripts/fixtures/street-level/kartaView.mjs @@ -66,7 +66,8 @@ function record(photo) { lng: String(photo.lon), matchLat: String(photo.lat), matchLng: String(photo.lon), - heading: '0', + // A dashcam phone's compass, disturbed by the car: the drive runs north. + heading: '200', fieldOfView: photo.isPano ? '360' : '0', projection: photo.isPano ? 'SPHERE' : 'PLANE', shotDate: SHOT, @@ -117,6 +118,7 @@ export function describeKartaViewCall(method, url) { .replace(/\/tiles\/\d+\/\d+\/\d+\.json$/, '/tiles/{x}/{y}/{z}.json') .replace(/\/photo\/\d+$/, '/photo/{id}') .replace(/\/sequence\/\d+\/photos$/, '/sequence/{id}/photos') + .replace(/\/sequence\/\d+$/, '/sequence/{id}') .replace(/^\/files\/photo\/.*$/, '/files/photo/{path}') .replace( /^\/pr:sharp\/rs:fit:\d+:\d+\/.*$/, @@ -202,6 +204,18 @@ export function answerKartaViewRequest({ method, url }) { ? { ...ok({ data: record(photo) }), known: true } : { ...raw(200, { status: { apiCode: 601 }, result: {} }), known: true }; } + const sequence = path.match(/^\/2\.0\/sequence\/(\d+)$/); + if (sequence) + return { + ...ok({ + data: { + id: sequence[1], + countActivePhotos: + sequence[1] === KV_SEQUENCE_ID ? String(photos.length) : '0', + }, + }), + known: true, + }; const page = path.match(/^\/2\.0\/sequence\/(\d+)\/photos$/); if (page) { const q = address.searchParams; diff --git a/scripts/qa-street-level.mjs b/scripts/qa-street-level.mjs index 153d7514c..1355cb765 100644 --- a/scripts/qa-street-level.mjs +++ b/scripts/qa-street-level.mjs @@ -464,6 +464,45 @@ async function assertKeylessGate(page) { assert.match(chip.title, /MAPILLARY_CLIENT_TOKEN/); } +/** + * With no Mapillary key the panel points at KartaView, which needs none: + * lit, it draws coverage and the layer is not key-gated. + */ +async function assertKeylessKartaView(page) { + assert.match( + await page.$eval('#sl-coverage-meta', (node) => node.textContent), + /light KARTAVIEW/, + 'KEY REQUIRED points at KartaView', + ); + for (let attempt = 0; attempt < 4; attempt++) { + await page.evaluate((at) => { + const v = window.__godsEyeView.viewer; + v.camera.cancelFlight?.(); + const C = v.camera.positionCartographic.constructor; + v.camera.setView({ + destination: v.scene.globe.ellipsoid.cartographicToCartesian( + C.fromDegrees(at.lon, at.lat, at.height), + ), + orientation: { heading: 0, pitch: at.pitch, roll: 0 }, + }); + }, PARK); + await sleep(1000); + } + await clickPanelControl( + page, + '#sl-provider-chips [data-chip-id="kartaview"]', + ); + await waitForCoverage(page); + const state = await page.evaluate(() => ({ + status: document.getElementById('sl-status').textContent, + keyRequired: window.__godsEyeView.dataManager.layers + .get('street-level') + .module.getUIState().keyRequired, + })); + assert.equal(state.keyRequired, false); + assert.doesNotMatch(state.status, /KEY/); +} + /** * Runs in the page. Find a point where a real click picks `prefix` + `id` (any * id when null), searched around `points` ([lon, lat]), the aimed billboards, @@ -942,6 +981,15 @@ async function main() { ), `cones for the whole drive: ${JSON.stringify((await ui()).sequence)}`, ); + // The drive runs north; the photos' compass says 200°. + assert.ok( + await uiUntil( + (u) => + Number.isFinite(u.street.bearing) && + Math.min(u.street.bearing, 360 - u.street.bearing) < 2, + ), + `the camera looks along the drive, not where the compass points: ${(await ui()).street.bearing}`, + ); } const before = street.imageId; await clickPanelControl(page, '.sl-kv-step-next'); @@ -2444,7 +2492,7 @@ async function main() { ); if (fixtures) await step( - 'keyless install (a second page, no key on the server) gates the controls and reports KEY REQUIRED', + 'keyless install (a second page, no key on the server) gates the controls and reports KEY REQUIRED, and KartaView draws there', async () => { const keyless = await browser.newPage(); await keyless.setViewport(VIEWPORTS[0]); @@ -2453,6 +2501,7 @@ async function main() { await boot(keyless, url); await expandStrip(keyless); await assertKeylessGate(keyless); + await assertKeylessKartaView(keyless); renderErrorsSeen.push( ...(await readRenderErrors(keyless)).map( (text) => `[keyless] scene.renderError: ${text}`, diff --git a/src/layers/streetLevel/providers/kartaview/decode.js b/src/layers/streetLevel/providers/kartaview/decode.js index d670f4ca2..b5488af00 100644 --- a/src/layers/streetLevel/providers/kartaview/decode.js +++ b/src/layers/streetLevel/providers/kartaview/decode.js @@ -1,3 +1,8 @@ +/** More than any real tile holds (a dense z13 tile has ~4,700 segments). */ +export const MAX_TILE_SEGMENTS = 20_000; +/** A chained line stops growing here and the next one starts. */ +export const MAX_LINE_POINTS = 5_000; + /** * Decode a KartaView coverage tile (`{lines: [{track: [{lat, lng}]}]}`) into * line records for the shared coverage. The tile's lines are OSM way @@ -14,6 +19,7 @@ export function decodeCoverageTile(payload, { x, y, z }) { throw new Error('KartaView sent an unreadable coverage tile'); const segments = []; for (const line of payload.lines) { + if (segments.length >= MAX_TILE_SEGMENTS) break; const points = []; for (const point of Array.isArray(line?.track) ? line.track : []) { const lon = Number(point?.lng); @@ -34,16 +40,28 @@ export function decodeCoverageTile(payload, { x, y, z }) { else startingAt.set(at, [index]); }); const used = new Uint8Array(segments.length); + // Per start point, how far its list has been used: each segment is looked + // at once, so a tile of segments sharing one point stays linear. + const cursor = new Map(); + const nextFrom = (point) => { + const at = key(point); + const list = startingAt.get(at); + if (!list) return undefined; + let i = cursor.get(at) ?? 0; + while (i < list.length && used[list[i]]) i++; + cursor.set(at, i); + return list[i]; + }; const sequences = []; segments.forEach((segment, index) => { if (used[index]) return; used[index] = 1; const chain = [...segment]; for (;;) { - const next = startingAt - .get(key(chain[chain.length - 1])) - ?.find((candidate) => !used[candidate]); + const next = nextFrom(chain[chain.length - 1]); if (next === undefined) break; + // Full: the segment starts the next line instead. + if (chain.length + segments[next].length - 1 > MAX_LINE_POINTS) break; used[next] = 1; chain.push(...segments[next].slice(1)); } diff --git a/src/layers/streetLevel/providers/kartaview/decode.test.mjs b/src/layers/streetLevel/providers/kartaview/decode.test.mjs index c84855e06..e6cc498d6 100644 --- a/src/layers/streetLevel/providers/kartaview/decode.test.mjs +++ b/src/layers/streetLevel/providers/kartaview/decode.test.mjs @@ -1,6 +1,10 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { decodeCoverageTile } from './decode.js'; +import { + MAX_LINE_POINTS, + MAX_TILE_SEGMENTS, + decodeCoverageTile, +} from './decode.js'; const at = (lng, lat) => ({ lat: String(lat), lng: String(lng) }); const TILE = { x: 4412, y: 6061, z: 14 }; @@ -85,3 +89,40 @@ test('a tile that is not a line list fails, so it is asked for again rather than /unreadable coverage tile/, ); }); + +test('a tile whose segments all share one point decodes in linear time', () => { + // [P, Q_i, P]: every chain step looks at the same start point's list. + // 60k: the old quadratic chaining took over a second; capped and linear + // decoding takes tens of milliseconds. + const lines = Array.from({ length: 60_000 }, (_, i) => ({ + track: [at(0, 0), at(1 + i * 1e-6, 1), at(0, 0)], + })); + const started = performance.now(); + const { sequences } = decodeCoverageTile({ lines }, TILE); + const ms = performance.now() - started; + assert.ok( + ms < 1_000, + `${Math.round(ms)} ms (quadratic chaining took ~200 ms per 20k and grows fourfold per doubling)`, + ); + const points = sequences.reduce((sum, s) => sum + s.parts[0].length, 0); + assert.ok(sequences.every((s) => s.parts[0].length <= MAX_LINE_POINTS)); + assert.equal( + points, + MAX_TILE_SEGMENTS * 2 + sequences.length, + 'every segment drawn once', + ); +}); + +test('a tile is capped in segments and in points per line', () => { + const lines = Array.from({ length: MAX_TILE_SEGMENTS + 500 }, (_, i) => ({ + track: [at(i, 0), at(i + 1, 0)], + })); + const { sequences } = decodeCoverageTile({ lines }, TILE); + const points = sequences.reduce((sum, s) => sum + s.parts[0].length, 0); + // One long chain, cut into lines of at most MAX_LINE_POINTS. + assert.ok(sequences.every((s) => s.parts[0].length <= MAX_LINE_POINTS)); + assert.ok(points <= MAX_TILE_SEGMENTS + sequences.length); + assert.ok( + sequences.length >= Math.floor(MAX_TILE_SEGMENTS / MAX_LINE_POINTS), + ); +}); diff --git a/src/layers/streetLevel/providers/kartaview/index.js b/src/layers/streetLevel/providers/kartaview/index.js index 07e9672cf..0435b28d8 100644 --- a/src/layers/streetLevel/providers/kartaview/index.js +++ b/src/layers/streetLevel/providers/kartaview/index.js @@ -24,6 +24,8 @@ import { SEQUENCE_PAGE_EDGE, SEQUENCE_PAGE_SIZE, TOO_HIGH_HINT, + assignCourses, + cameraBearing, normalizePhoto, } from './policy.js'; @@ -86,6 +88,8 @@ export function createTileCache({ kept.set(key, { at: now(), tile: loaded }); return loaded; }, + /** Drop every tile (the provider was switched off). */ + clear: () => kept.clear(), }; } @@ -112,6 +116,8 @@ export function createKartaViewProvider({ requiresKeyId: null, pickPrefix: PICK_PREFIX.root, colors: COLORS, + // Its own marker, as Street View has: a purple cone that turns with the view. + marker: Object.freeze({ kind: 'view-cone', color: COLORS.coverage }), credit: Object.freeze({ html: KARTAVIEW_CREDIT_HTML }), defaultOn: false, // Coverage lines carry no date or projection: no filter could thin them. @@ -162,9 +168,13 @@ export function createKartaViewProvider({ /** The sequence page the cones were drawn from. */ let drawnPage = null; + /** A record as a photo; the one already known keeps its identity (and course). */ function remember(record) { - const photo = normalizePhoto(record); - if (photo) photos.set(photo.id, photo); + const fresh = normalizePhoto(record); + if (!fresh) return null; + const known = photos.get(fresh.id); + const photo = known ? Object.assign(known, fresh) : fresh; + photos.set(photo.id, photo); return photo; } @@ -186,16 +196,20 @@ export function createKartaViewProvider({ page, itemsPerPage: SEQUENCE_PAGE_SIZE, }) - .then(({ photos: records, hasMore }) => ({ - page, - hasMore, - photos: records - .map(remember) - .filter(Boolean) - .sort( - (a, b) => (a.sequenceIndex ?? 0) - (b.sequenceIndex ?? 0), - ), - })); + .then(({ photos: records, hasMore }) => { + const list = assignCourses( + records + .map(remember) + .filter(Boolean) + .sort( + (a, b) => (a.sequenceIndex ?? 0) - (b.sequenceIndex ?? 0), + ), + metresBetween, + ); + // The open photo may have just learnt which way it looked. + viewer.refreshPose?.(); + return { page, hasMore, photos: list }; + }); pages.set(key, entry); entry.catch(() => pages.delete(key)); } @@ -353,12 +367,13 @@ export function createKartaViewProvider({ const window = await windowOf(anchor); signal?.throwIfAborted(); drawnPage = { sequenceId, page: window.home }; + countPhotos(sequenceId); return thinImages( window.photos.map((photo) => ({ id: photo.id, lon: photo.lon, lat: photo.lat, - compassAngle: photo.heading ?? 0, + compassAngle: cameraBearing(photo) ?? 0, capturedAt: photo.capturedAt ?? 0, isPano: photo.isPano, })), @@ -372,6 +387,23 @@ export function createKartaViewProvider({ passes: () => true, }); + /** Sequence id → its photo count from KartaView (asked once). */ + const counts = new Map(); + function countPhotos(sequenceId) { + if (counts.has(sequenceId) || !source.getSequenceCount) return; + counts.set(sequenceId, undefined); + source.getSequenceCount(sequenceId).then( + (count) => { + if (count === null) counts.delete(sequenceId); + else { + counts.set(sequenceId, count); + context.notify(); + } + }, + () => counts.delete(sequenceId), + ); + } + /** ‹ › open through the core (loading, errors, cones), camera still. */ function openStepped(photoId) { context.actions.openImage(photoId, { frame: false }); @@ -406,6 +438,17 @@ export function createKartaViewProvider({ step, onStep: openStepped, onShown: recentreCones, + // After a GPU reset the same photo is drawn again by the viewer + // itself: going through the core would cancel a lookup the user + // started. A failure still shows. + onReopen: (photoId) => + viewer + .open(photoId) + .catch((error) => + context.actions.reportError( + error?.message || 'The KartaView photo could not be loaded', + ), + ), render: context.services?.render, ...viewerOptions, }); @@ -434,6 +477,8 @@ export function createKartaViewProvider({ cones.clearSelection(); cones.setVisible(false); drawnPage = null; + // Tens of MB of decoded lines are not kept for a source left off. + tiles.clear(); }, destroy(cesiumViewer) { @@ -441,6 +486,7 @@ export function createKartaViewProvider({ coverage.clear(); coverage.resetErrors(); cones.destroy(cesiumViewer); + tiles.clear(); }, refreshCoverage: () => coverage.refresh(), @@ -495,9 +541,12 @@ export function createKartaViewProvider({ cones.clearSelection(); drawnPage = null; }, + // The drive's own count, not the thinned page of it the cones show. sequenceStats: () => ({ selectedId: state.sequence.selectedId, - images: state.sequence.images.length, + images: + counts.get(state.sequence.selectedId) ?? + state.sequence.images.length, loading: state.sequence.loading, }), diff --git a/src/layers/streetLevel/providers/kartaview/policy.js b/src/layers/streetLevel/providers/kartaview/policy.js index 200033449..9930f2c5e 100644 --- a/src/layers/streetLevel/providers/kartaview/policy.js +++ b/src/layers/streetLevel/providers/kartaview/policy.js @@ -145,6 +145,56 @@ export function kartaViewTimeMs(value) { return Number.isFinite(ms) ? ms : null; } +/** Initial great-circle bearing from `a` to `b`, degrees clockwise from north. */ +export function bearingBetween(a, b) { + const rad = Math.PI / 180; + const dLon = (b.lon - a.lon) * rad; + const y = Math.sin(dLon) * Math.cos(b.lat * rad); + const x = + Math.cos(a.lat * rad) * Math.sin(b.lat * rad) - + Math.sin(a.lat * rad) * Math.cos(b.lat * rad) * Math.cos(dLon); + return (Math.atan2(y, x) / rad + 360) % 360; +} + +/** Photos closer than this say nothing about the direction of travel. */ +const COURSE_MIN_STEP_M = 2; +/** How many photos along to look for one far enough away. */ +const COURSE_SEARCH = 5; + +/** + * Give each photo of a drive (in index order) its `course`: the direction + * of travel from it to the next photo a few metres on (from the previous one + * at the end). KartaView is dashcam imagery, so this is where the camera + * looks; its `heading` is the device compass, which reads 0 without one and + * can sit 110° off inside a car. + * @param {Array<{lon: number, lat: number, course?: number|null}>} photos + * @param {(a: object, b: object) => number} metres + */ +export function assignCourses(photos, metres) { + photos.forEach((photo, i) => { + let course = null; + for (let j = i + 1; j <= i + COURSE_SEARCH && j < photos.length; j++) + if (metres(photo, photos[j]) >= COURSE_MIN_STEP_M) { + course = bearingBetween(photo, photos[j]); + break; + } + for ( + let k = i - 1; + course === null && k >= i - COURSE_SEARCH && k >= 0; + k-- + ) + if (metres(photos[k], photo) >= COURSE_MIN_STEP_M) + course = bearingBetween(photos[k], photo); + photo.course = course; + }); + return photos; +} + +/** Where the camera looked: the direction of travel, else the compass, else unknown. */ +export function cameraBearing(photo) { + return photo?.course ?? photo?.heading ?? null; +} + /** A coordinate pair as numbers; the road-matched one when KartaView has it. */ function positionOf(record) { const matchLat = Number(record.matchLat); @@ -214,10 +264,11 @@ export function normalizePhoto(record) { // 0,0 is a missing fix, not a photo in the Gulf of Guinea. if (lat === 0 && lon === 0) return null; if (Math.abs(lat) > 90 || Math.abs(lon) > 180) return null; + // An exact 0 is a device without a compass (whole drives read 0), not north. const heading = record.heading === '' || record.heading == null ? NaN - : Number(record.heading); + : Number(record.heading) || NaN; const isPano = record.projection === 'SPHERE' || Number(record.fieldOfView) >= 360; const full = diff --git a/src/layers/streetLevel/providers/kartaview/policy.test.mjs b/src/layers/streetLevel/providers/kartaview/policy.test.mjs index c9ab2ca4b..0698a9e8a 100644 --- a/src/layers/streetLevel/providers/kartaview/policy.test.mjs +++ b/src/layers/streetLevel/providers/kartaview/policy.test.mjs @@ -1,6 +1,9 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { + assignCourses, + bearingBetween, + cameraBearing, kartaViewPhotoUrl, kartaViewTimeMs, normalizePhoto, @@ -140,7 +143,9 @@ test('records without an id or a position are dropped; a missing heading is null null, ); assert.equal(normalizePhoto(record({ heading: '' })).heading, null); - assert.equal(normalizePhoto(record({ heading: '0' })).heading, 0); + // A device without a compass reads 0 for a whole drive: unknown, not north. + assert.equal(normalizePhoto(record({ heading: '0' })).heading, null); + assert.equal(normalizePhoto(record({ heading: '0.5' })).heading, 0.5); assert.equal(normalizePhoto(record({ heading: 'n/a' })).heading, null); }); @@ -237,3 +242,41 @@ test('with no processed-photo path the size prefix gives the full photo', () => /^https:\/\/storage3\.openstreetcam\.org\/.*\/lth\//, ); }); + +test('the camera looks along the drive: each photo’s course points to the next one a few metres on', () => { + // North along a meridian, 5 m apart, then a repeat of the last position. + const step = 5 / 110_540; + const photos = [0, 1, 2, 3].map((i) => ({ lon: 10, lat: 50 + i * step })); + photos.push({ lon: 10, lat: 50 + 3 * step }); // a stop: same place + const metres = (a, b) => + Math.hypot( + (b.lon - a.lon) * 111_320 * Math.cos((a.lat * Math.PI) / 180), + (b.lat - a.lat) * 110_540, + ); + assignCourses(photos, metres); + for (const photo of photos) + assert.ok( + Math.abs(photo.course) < 0.5 || Math.abs(photo.course - 360) < 0.5, + `${photo.course}`, + ); + // Eastward: 90°. + const east = [0, 1].map((i) => ({ lon: 10 + (i * 5) / 71_600, lat: 50 })); + assignCourses(east, metres); + assert.ok( + Math.abs(east[0].course - 90) < 0.5 && Math.abs(east[1].course - 90) < 0.5, + ); + // A lone photo has no course; the compass is the fallback, then unknown. + const [lone] = assignCourses([{ lon: 1, lat: 1 }], metres); + assert.equal(lone.course, null); + assert.equal(cameraBearing({ ...lone, heading: 42 }), 42); + assert.equal(cameraBearing({ ...lone, heading: null }), null); + assert.equal( + cameraBearing({ course: 90, heading: 200 }), + 90, + 'the drive beats the compass', + ); + assert.ok( + Math.abs(bearingBetween({ lon: 0, lat: 0 }, { lon: 0, lat: -1 }) - 180) < + 1e-9, + ); +}); diff --git a/src/layers/streetLevel/providers/kartaview/provider.test.mjs b/src/layers/streetLevel/providers/kartaview/provider.test.mjs index 9c63e9c18..055c3b1a6 100644 --- a/src/layers/streetLevel/providers/kartaview/provider.test.mjs +++ b/src/layers/streetLevel/providers/kartaview/provider.test.mjs @@ -714,7 +714,7 @@ test('coverage hints: zoom in from too high, “no photos” only once an empty assert.equal(env.instance.coverageStats().hint, NO_COVERAGE_HINT); }); -test('a coverage tile loaded a moment ago is not asked for again', async (t) => { +test('coverage tiles are kept while KartaView is on and dropped once it is switched off', async (t) => { const env = coverageSetup(t, { lines: [ [ @@ -725,11 +725,14 @@ test('a coverage tile loaded a moment ago is not asked for again', async (t) => }); env.instance.activate(env.viewer); assert.ok(await until(() => env.instance.coverageStats().count === 1)); - // Off and on again (or a pan away and back): drawn from the kept tile. + env.instance.refreshCoverage(); + assert.ok(await until(() => env.instance.coverageStats().count === 1)); + assert.equal(env.tiles.length, 1, 'kept while on'); + // Switched off: the decoded lines are not kept, so on again asks again. env.instance.deactivate(); env.instance.activate(env.viewer); assert.ok(await until(() => env.instance.coverageStats().count === 1)); - assert.equal(env.tiles.length, 1); + assert.equal(env.tiles.length, 2); }); test('a photo no page lists steps to the photos either side of its index', async () => { @@ -746,3 +749,113 @@ test('a photo no page lists steps to the photos either side of its index', async await press(env.prev, env.poses); assert.equal(env.poses.at(-1), 's-4'); }); + +test('the panel counts the drive’s photos, not the thinned page of them the cones show', async (t) => { + const env = await mounted({ + sequences: { s: Array.from({ length: 40 }, (_, i) => i) }, + }); + const asked = []; + env.source.getSequenceCount = async (id) => { + asked.push(id); + return 2385; + }; + await withCones(t, env); + await env.instance.viewer.open('s-3'); + await env.instance.selectSequence('s'); + assert.ok( + await until(() => env.instance.sequenceStats().images === 2385), + `images: ${env.instance.sequenceStats().images}`, + ); + // Asked once per drive. + env.instance.clearSequence(); + await env.instance.viewer.open('s-30'); + await env.instance.selectSequence('s'); + assert.ok(await until(() => env.instance.sequenceStats().images === 2385)); + assert.deepEqual(asked, ['s']); +}); + +test('after a GPU reset the photo is drawn again by the viewer, not through the core; a failure is reported', async () => { + const source = fakeSource(); + const record = { + ...photoRecord('pano', { seq: 'p', index: 0 }), + projection: 'SPHERE', + fieldOfView: '360', + filepathProc: '2022/10/6/wrapped_proc/pano.jpg', + }; + source.getPhoto = async () => record; + const view = { yaw: 0, pitch: 0, fov: 80 }; + let failLoads = false; + const definition = createKartaViewProvider({ + source, + viewerOptions: { + load: async (url) => { + if (failLoads) + throw new Error('The KartaView photo could not be loaded'); + return { src: url }; + }, + createRenderer: () => ({ + setImage() {}, + setView: (next) => Object.assign(view, next), + getView: () => ({ ...view }), + resize() {}, + clear() {}, + destroy() {}, + }), + }, + }); + const { context, actions } = fakeContext(); + const errors = []; + context.actions.reportError = (message) => errors.push(message); + const instance = definition.create(context); + actions.instance = instance; + const host = fakeHost(); + await instance.viewer.mount(host); + const poses = []; + instance.viewer.onPose((pose) => poses.push(pose.imageId)); + await instance.viewer.open('pano'); + const canvas = () => host.children[0].children[1]; + const kind = () => host.children[0].dataset.kind; + canvas().fire('webglcontextlost'); + assert.equal(kind(), '', 'cleared while it is drawn again'); + assert.ok(await until(() => kind() === 'pano'), 'drawn again'); + assert.ok(poses.length >= 2); + assert.deepEqual(actions.opened, [], 'the core (and its lookup) left alone'); + failLoads = true; + canvas().fire('webglcontextlost'); + assert.ok(await until(() => errors.length === 1)); + assert.match(errors[0], /could not be loaded/); +}); + +test('the marker is KartaView’s own cone, and it points along the drive, not at a wrong compass', async () => { + const env = setup({ + sequences: { s: Array.from({ length: 10 }, (_, i) => i) }, + }); + assert.deepEqual(env.definition.marker, { + kind: 'view-cone', + color: '#635bff', + }); + // Photos run east (photoRecord puts index i at 5·i m east); the compass says 200°. + const getPhoto = env.source.getPhoto; + env.source.getPhoto = async (id) => ({ + ...(await getPhoto(id)), + heading: '200', + }); + const getPage = env.source.getSequencePhotos; + env.source.getSequencePhotos = async (...args) => { + const page = await getPage(...args); + return { + ...page, + photos: page.photos.map((record) => ({ ...record, heading: '200' })), + }; + }; + const host = fakeHost(); + await env.instance.viewer.mount(host); + const bearings = []; + env.instance.viewer.onPose((pose) => bearings.push(pose.bearing)); + await env.instance.viewer.open('s-4'); + assert.equal(bearings[0], 200, 'the compass until the drive is known'); + assert.ok( + await until(() => Math.abs(bearings.at(-1) - 90) < 1), + `then along the drive: ${bearings}`, + ); +}); diff --git a/src/layers/streetLevel/providers/kartaview/source.js b/src/layers/streetLevel/providers/kartaview/source.js index 9e926a9b0..4c1b47ff8 100644 --- a/src/layers/streetLevel/providers/kartaview/source.js +++ b/src/layers/streetLevel/providers/kartaview/source.js @@ -16,17 +16,27 @@ import { TILE_TIMEOUT_MS, } from './policy.js'; -/** An HTTP or API failure; `retryAfterSec` is set while KartaView rate-limits. */ +/** + * An HTTP or API failure; `retryAfterSec` is set while KartaView rate-limits, + * `timedOut` when it did not answer in time (the one failure worth asking + * again at once). + */ export class KartaViewSourceError extends Error { - constructor(message, { status = 0, retryAfterSec = null } = {}) { + constructor( + message, + { status = 0, retryAfterSec = null, timedOut = false } = {}, + ) { super(message); this.name = 'KartaViewSourceError'; this.status = status; this.retryAfterSec = retryAfterSec; + this.timedOut = timedOut; } } const NO_ANSWER = 'KartaView did not answer in time — try again'; +const UNREACHABLE = 'KartaView could not be reached — check the network'; +const UNREADABLE = 'KartaView sent an answer that could not be read'; /** Statuses that mean a photo file is gone, not that storage hiccupped. */ const GONE_STATUSES = Object.freeze([403, 404, 409, 410]); @@ -129,7 +139,10 @@ export function createKartaViewSource({ if (signal?.aborted) throw signal.reason; signal?.addEventListener('abort', onAbort, { once: true }); const timer = setTimeout( - () => controller.abort(new KartaViewSourceError(NO_ANSWER)), + () => + controller.abort( + new KartaViewSourceError(NO_ANSWER, { timedOut: true }), + ), timeoutMs, ); try { @@ -152,7 +165,16 @@ export function createKartaViewSource({ if (length > MAX_RESPONSE_BYTES) throw tooMuch(); // KartaView answers chunked, without a length: count while reading. const reader = response.body?.getReader?.(); - if (!reader) return await response.json(); + if (!reader) { + try { + return await response.json(); + } catch (error) { + if (controller.signal.aborted) throw error; + throw new KartaViewSourceError(UNREADABLE, { + status: response.status, + }); + } + } const chunks = []; let bytes = 0; for (;;) { @@ -171,12 +193,19 @@ export function createKartaViewSource({ body.set(chunk, offset); offset += chunk.byteLength; } - return JSON.parse(new TextDecoder().decode(body)); + try { + return JSON.parse(new TextDecoder().decode(body)); + } catch { + throw new KartaViewSourceError(UNREADABLE, { + status: response.status, + }); + } } catch (error) { if (signal?.aborted) throw signal.reason; if (controller.signal.aborted) throw controller.signal.reason; if (error instanceof KartaViewSourceError) throw error; - throw new KartaViewSourceError(NO_ANSWER); + // fetch rejects on no network, a blocked request or a refused CORS check. + throw new KartaViewSourceError(UNREACHABLE); } finally { clearTimeout(timer); signal?.removeEventListener('abort', onAbort); @@ -221,7 +250,7 @@ export function createKartaViewSource({ return payload?.result ?? {}; } catch (error) { if (signal?.aborted) throw error; - const timedOut = error?.message === NO_ANSWER && error.status === 0; + const timedOut = error?.timedOut === true; if (timedOut && timeouts++ < 1) continue; if (error?.status !== 429 || attempt >= PHOTO_API_RETRIES) { if (error?.status === 429) @@ -259,7 +288,7 @@ export function createKartaViewSource({ } catch (error) { // A cold dense tile can outlast the timeout; KartaView has it ready // by the second ask. - if (signal?.aborted || error?.message !== NO_ANSWER) throw error; + if (signal?.aborted || error?.timedOut !== true) throw error; return once(); } }, @@ -322,6 +351,17 @@ export function createKartaViewSource({ return result.data; }, + /** How many photos a sequence has, or null if KartaView does not say. */ + async getSequenceCount(sequenceId, { signal } = {}) { + const result = await photoApi( + `/2.0/sequence/${encodeURIComponent(sequenceId)}`, + null, + { signal }, + ); + const count = Number(result.data?.countActivePhotos); + return Number.isFinite(count) && count > 0 ? count : null; + }, + /** A page of a sequence's photos in capture order (1-based `page`). */ async getSequencePhotos( sequenceId, diff --git a/src/layers/streetLevel/providers/kartaview/source.test.mjs b/src/layers/streetLevel/providers/kartaview/source.test.mjs index cd96bb5e4..5ad3f6cb8 100644 --- a/src/layers/streetLevel/providers/kartaview/source.test.mjs +++ b/src/layers/streetLevel/providers/kartaview/source.test.mjs @@ -151,12 +151,19 @@ test('an API refusal inside a 200 is a readable error with its code; 601 is just test('HTTP errors, network failures and non-JSON become readable errors', async () => { const http = createKartaViewSource({ fetchImpl: async () => status(500) }); await assert.rejects(http.getTile({ x: 0, y: 0, z: 14 }), /HTTP 500/); + let asked = 0; const offline = createKartaViewSource({ fetchImpl: async () => { + asked++; throw new TypeError('Failed to fetch'); }, }); - await assert.rejects(offline.getPhoto('1'), /did not answer/); + // Not a timeout: no network is said as much, and not asked again at once. + await assert.rejects( + offline.getPhoto('1'), + /could not be reached — check the network/, + ); + assert.equal(asked, 1); const garbled = createKartaViewSource({ fetchImpl: async () => ({ ok: true, @@ -169,7 +176,19 @@ test('HTTP errors, network failures and non-JSON become readable errors', async }); await assert.rejects( garbled.getTile({ x: 0, y: 0, z: 14 }), - /did not answer/, + /could not be read/, + ); + const torn = createKartaViewSource({ + fetchImpl: async () => ({ + ok: true, + status: 200, + headers: new Map(), + body: streamed('{"lines": [', 4), + }), + }); + await assert.rejects( + torn.getTile({ x: 0, y: 0, z: 14 }), + /could not be read/, ); }); @@ -265,18 +284,24 @@ test('tiles, lookups and sequence pages queue separately, so a lookup never wait assert.equal(peak.page, 1); }); -test('a lookup that times out is asked once more; a second timeout says to try again', async () => { +test('a lookup that times out is asked once more, then reported as no answer in time', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); let calls = 0; - const source = createKartaViewSource({ - fetchImpl: async () => { - calls++; - throw new TypeError('Failed to fetch'); - }, - }); - await assert.rejects( - source.getPhoto('1'), + const outcome = await onMockClock(t, () => + createKartaViewSource({ + fetchImpl: (url, { signal }) => { + calls++; + return new Promise((resolve, reject) => + signal.addEventListener('abort', () => reject(signal.reason)), + ); + }, + }).getPhoto('1'), + ); + assert.match( + String(outcome?.error?.message), /did not answer in time — try again/, ); + assert.equal(outcome.error.timedOut, true); assert.equal(calls, 2); }); @@ -323,17 +348,35 @@ test('an image probe reads only the status: an archived photo (409) is not showa ); }); -test('a cold tile that times out is asked once more', async () => { +test('a cold tile that times out is asked once more; an unreachable one is not', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); let calls = 0; - const source = createKartaViewSource({ + const outcome = await onMockClock(t, () => + createKartaViewSource({ + fetchImpl: (url, { signal }) => { + calls++; + if (calls === 1) + return new Promise((resolve, reject) => + signal.addEventListener('abort', () => reject(signal.reason)), + ); + return Promise.resolve(ok({ lines: [] })); + }, + }).getTile({ x: 1, y: 2, z: 13 }), + ); + assert.deepEqual(outcome.value, { lines: [] }); + assert.equal(calls, 2); + let asked = 0; + const offline = createKartaViewSource({ fetchImpl: async () => { - calls++; - if (calls === 1) throw new TypeError('Failed to fetch'); - return ok({ lines: [] }); + asked++; + throw new TypeError('Failed to fetch'); }, }); - assert.deepEqual(await source.getTile({ x: 1, y: 2, z: 13 }), { lines: [] }); - assert.equal(calls, 2); + await assert.rejects( + offline.getTile({ x: 1, y: 2, z: 13 }), + /could not be reached/, + ); + assert.equal(asked, 1); }); test('an absurdly large answer is refused before it is parsed', async () => { diff --git a/src/layers/streetLevel/providers/kartaview/viewer.js b/src/layers/streetLevel/providers/kartaview/viewer.js index ae04b0f5a..a9beb52c5 100644 --- a/src/layers/streetLevel/providers/kartaview/viewer.js +++ b/src/layers/streetLevel/providers/kartaview/viewer.js @@ -11,6 +11,7 @@ import { PANO_FULL_SIZE, PANO_MID_SIZE, PANO_PREVIEW_SIZE, + cameraBearing, kartaViewPhotoUrl, resizedImageUrl, } from './policy.js'; @@ -88,6 +89,7 @@ export function panoSizeFor(canvasPixels, fovDeg) { * @param {(photo: object, delta: -1|1) => Promise} options.step The photo `delta` along the sequence, or null at its end. * @param {(imageId: string) => void} options.onStep Open the photo ‹ › chose (through the core, so loading and errors show). * @param {(photo: object) => void} [options.onShown] A photo is on screen. + * @param {(imageId: string) => void} [options.onReopen] Draw the same photo again after a GPU reset (default: `onStep`). * @param {object} [options.render] Render governor. * @param {(url: string, options?: object) => Promise} [options.load] * @param {(canvas: HTMLCanvasElement) => object} [options.createRenderer] @@ -98,6 +100,7 @@ export function createKartaViewViewer({ step, onStep, onShown = () => {}, + onReopen = onStep, render, load = loadImage, createRenderer = createPanoramaRenderer, @@ -125,6 +128,8 @@ export function createKartaViewViewer({ let restoreView = null; /** Width of the panorama texture on screen (0: the preview). */ let panoWidth = 0; + /** The flat photo on screen is the 2,560 px version. */ + let flatSharp = false; /** The photo an open is fetching: ‹ › step on from it, not the old one. */ let openingId = null; /** ‹ › chose this photo: keep the panorama's look direction for it. */ @@ -139,7 +144,7 @@ export function createKartaViewViewer({ function emit() { if (!photo || !showing) return; const view = showing === 'pano' ? renderer.getView() : null; - const heading = photo.heading; + const heading = cameraBearing(photo); const pose = { providerId: KARTAVIEW_PROVIDER_ID, imageId: photo.id, @@ -257,7 +262,7 @@ export function createKartaViewViewer({ const again = photo.id; show(null); openingId = again; - onStep(again); + onReopen(again); } } @@ -471,6 +476,10 @@ export function createKartaViewViewer({ if (kind === 'flat' && target.isPano) return; const size = kind === 'pano' ? panoSizeNow() : FLAT_FULL_SIZE; if (kind === 'pano' && size[0] <= panoWidth) return; + // A docked viewer shows the 1,280 px version in full: the 2,560 px one + // (~0.6 MB a photo) waits until the viewer is bigger than that. + if (kind === 'flat' && (flatSharp || flatPixels() <= FLAT_VIEW_SIZE[0])) + return; const url = kind === 'pano' ? resizedImageUrl(target.urls.pano, size) @@ -483,13 +492,24 @@ export function createKartaViewViewer({ if (!renderer || size[0] <= panoWidth) return; renderer.setImage(image); panoWidth = size[0]; - } else picture.src = image.src; + } else { + picture.src = image.src; + flatSharp = true; + } requestRender(); } catch { /* the first version stays */ } } + /** Device pixels across the flat photo's frame. */ + function flatPixels() { + const ratio = globalThis.devicePixelRatio || 1; + return ( + Math.max(picture?.clientWidth || 0, picture?.clientHeight || 0) * ratio + ); + } + /** The sharp panorama this viewer needs now: 2,048 docked, 4,096 expanded. */ function panoSizeNow() { const ratio = Math.min(globalThis.devicePixelRatio || 1, 2); @@ -543,6 +563,7 @@ export function createKartaViewViewer({ if (!image) throw failure || new Error(LOAD_FAILED); if (seq !== openSeq) return false; picture.src = image.src; + flatSharp = false; photo = target; show('flat'); return true; @@ -610,9 +631,11 @@ export function createKartaViewViewer({ resize() { renderer?.resize(); - // Expanded: a docked viewer's 2,048 px panorama is now too soft. + // Expanded: a docked viewer's first version is now too soft. if (showing === 'pano' && photo && panoSizeNow()[0] > panoWidth) upgrade(photo, openSeq, 'pano'); + if (showing === 'flat' && photo && !flatSharp) + upgrade(photo, openSeq, 'flat'); }, /** FIT / FILL frame a flat photo; a panorama has no frame to fit. */ @@ -623,6 +646,11 @@ export function createKartaViewViewer({ applyRenderMode(); }, + /** The photo's direction became known (its drive loaded): pose again. */ + refreshPose() { + emit(); + }, + onPose(listener) { listeners.add(listener); return () => listeners.delete(listener); diff --git a/src/layers/streetLevel/providers/kartaview/viewer.test.mjs b/src/layers/streetLevel/providers/kartaview/viewer.test.mjs index 603a02512..519e76615 100644 --- a/src/layers/streetLevel/providers/kartaview/viewer.test.mjs +++ b/src/layers/streetLevel/providers/kartaview/viewer.test.mjs @@ -168,6 +168,7 @@ test('a flat photo shows its 1280 px version, then the sharp one, with a pose fa }); await viewer.mount(host); const { picture, canvas, prev, next } = parts(host); + picture.clientWidth = 1600; // expanded: the sharp version is wanted assert.equal(prev.disabled, true, 'nothing to step through yet'); await viewer.open('flat1'); assert.equal(picture.hidden, false); @@ -621,6 +622,8 @@ test('quick presses while the next photo is still loading each step on from the test('a slow sharp version of an older photo never replaces the newer photo', async () => { const { viewer, host, shown, poses, release } = heldSetup(); await viewer.mount(host); + // An expanded viewer, so the sharp version is wanted. + parts(host).picture.clientWidth = 1600; const first = viewer.open('a'); await release('a@1280'); await first; @@ -904,3 +907,19 @@ test('a CORS image is decoded off the main thread into an ImageBitmap where the /could not be loaded/, ); }); + +test('a docked viewer keeps the 1,280 px photo; expanding it brings the 2,560 px one', async () => { + const { viewer, host, loads } = setup(); + await viewer.mount(host); + await viewer.open('flat1'); + for (let i = 0; i < 4; i++) await settle(); + assert.equal(loads.length, 1, 'no 2,560 px download for a 400 px frame'); + parts(host).picture.clientWidth = 1600; + viewer.resize(); + assert.ok(await until(() => loads.length === 2)); + assert.match(loads[1].url, /rs:fit:2560:2560/); + assert.equal(parts(host).picture.src, loads[1].url); + viewer.resize(); + for (let i = 0; i < 4; i++) await settle(); + assert.equal(loads.length, 2, 'once only'); +}); From 04b3231534c6b05cea50aa79b0a13c8f83f0e001 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 14 Oct 2026 13:09:40 +0100 Subject: [PATCH 7/9] fix(street-level): KartaView review P2s; budget the gate's pick search - Viewer: resizes while a sharper photo loads start no second load (the last size is looked at once it lands); decoded bitmaps nobody will draw (an overtaken preview or upgrade) are closed. - Cones: a photo opened while the drive's first page loads is the one they centre on. - A sequence page that lands while a lost WebGL context recovers keeps its cones: no pose is emitted without a renderer, and a viewer error never rejects page data. - Source: an answer turned away unread (HTTP error, 429, length past the cap) aborts its request and cancels its body, freeing the connection. - Gate: findPickPoint no longer picks each centre five times at radius 0, aims step 13's cone pick at the drawn billboards, and caps each evaluation at 4 s, resuming from a cursor on the next poll, so a slow machine cannot spend a whole wait in one evaluation. Co-Authored-By: Claude Opus 5.5 --- scripts/qa-street-level.mjs | 82 ++++++++++---- .../streetLevel/providers/kartaview/index.js | 20 +++- .../providers/kartaview/provider.test.mjs | 101 ++++++++++++++++++ .../streetLevel/providers/kartaview/source.js | 13 ++- .../providers/kartaview/source.test.mjs | 59 ++++++++++ .../streetLevel/providers/kartaview/viewer.js | 44 +++++++- .../providers/kartaview/viewer.test.mjs | 61 ++++++++++- 7 files changed, 350 insertions(+), 30 deletions(-) diff --git a/scripts/qa-street-level.mjs b/scripts/qa-street-level.mjs index 1355cb765..4b60a0695 100644 --- a/scripts/qa-street-level.mjs +++ b/scripts/qa-street-level.mjs @@ -507,6 +507,7 @@ async function assertKeylessKartaView(page) { * Runs in the page. Find a point where a real click picks `prefix` + `id` (any * id when null), searched around `points` ([lon, lat]), the aimed billboards, * or else a grid mid-screen. Only points where the canvas is topmost count. + * Each call picks for at most `budgetMs`; the next call resumes the search. * @returns {{x: number, y: number, id: string}|null} client coordinates */ export function findPickPoint({ @@ -515,6 +516,7 @@ export function findPickPoint({ points, billboardIds, billboardPrefix = null, + budgetMs = 4000, }) { const viewer = window.__godsEyeView.viewer; const scene = viewer.scene; @@ -569,24 +571,54 @@ export function findPickPoint({ centres.push([at.x, at.y]); } } - for (const [cx, cy] of centres) - for (let r = 0; r <= 16; r += 2) + // A pick is a render of the scene (0.5–2 s each under SwiftShader): try + // the centres the canvas is on top of first, nearest the screen centre, + // so the first pick usually hits. + const uncovered = ([x, y]) => + document.elementFromPoint(rect.left + x, rect.top + y) === canvas; + const fromMiddle = ([x, y]) => + (x - rect.width / 2) ** 2 + (y - rect.height / 2) ** 2; + centres.sort( + (a, b) => + Number(uncovered(b)) - Number(uncovered(a)) || + fromMiddle(a) - fromMiddle(b), + ); + const candidates = []; + for (const [cx, cy] of centres) { + candidates.push([cx, cy]); + for (let r = 2; r <= 16; r += 2) for (const [dx, dy] of [ - [0, 0], [r, 0], [-r, 0], [0, r], [0, -r], - ]) { - const hit = tryAt(cx + dx, cy + dy); - if (hit) return hit; - } - if (points || billboardIds) return null; - for (let y = rect.height * 0.25; y < rect.height * 0.8; y += 18) - for (let x = rect.width * 0.25; x < rect.width * 0.7; x += 6) { - const hit = tryAt(x, y); - if (hit) return hit; + ]) + candidates.push([cx + dx, cy + dy]); + } + // Aimed at known places: never fall back to a scan of the whole screen. + if (!points && !billboardIds && billboardPrefix === null) + for (let y = rect.height * 0.25; y < rect.height * 0.8; y += 18) + for (let x = rect.width * 0.25; x < rect.width * 0.7; x += 6) + candidates.push([x, y]); + // One evaluation stays well inside the caller's wait: past the budget it + // returns null, and the next poll carries on from where this one stopped. + const key = JSON.stringify([prefix, id, billboardPrefix]); + const cursors = (window.__qaPickCursors ??= {}); + const deadline = performance.now() + budgetMs; + let at = cursors[key] ?? 0; + if (at >= candidates.length) at = 0; + for (; at < candidates.length; at++) { + const hit = tryAt(...candidates[at]); + if (hit) { + delete cursors[key]; + return hit; } + if (performance.now() > deadline) { + cursors[key] = at + 1; + return null; + } + } + delete cursors[key]; return null; } @@ -1542,22 +1574,28 @@ async function main() { ); if (fixtures) assert.equal(sequence.images, fixture.images.length, 'every photo'); - // The cones are on the globe: one of them picks as an image. + // The cones are on the globe: one of them picks as an image. Aim at + // where each cone billboard is drawn (its ground position at + // height 0 can sit a few pixels off, and every missed pick costs a + // render); the same 30 s as the line pick above. const cone = await page.waitForFunction( findPickPoint, - { timeout: 15_000, polling: 250 }, + { timeout: 30_000, polling: 250 }, { prefix: 'mly:img:', id: null, - points: fixtures - ? fixture.images.map((image) => [image.lon, image.lat]) - : null, + points: null, billboardIds: null, - // Live: the cones' positions are only known from the globe. - billboardPrefix: fixtures ? null : 'mly:img:', + billboardPrefix: 'mly:img:', }, ); - assert.ok(await cone.jsonValue(), 'an image cone is pickable'); + const picked = await cone.jsonValue(); + assert.ok(picked, 'an image cone is pickable'); + if (fixtures) + assert.ok( + fixture.images.some((image) => image.id === picked.id), + `the cone is a photo of the drive: ${picked.id}`, + ); await page.evaluate(() => window.__godsEyeView.dataManager.layers .get('street-level') @@ -1685,7 +1723,9 @@ async function main() { assert.ok(neighbour, 'a neighbour of the other camera type'); const handle = await page.waitForFunction( findPickPoint, - { timeout: 15_000, polling: 250 }, + // A pick is a render; under SwiftShader on a busy machine one + // evaluation can take seconds. + { timeout: 30_000, polling: 250 }, { prefix: 'mly:img:', id: neighbour.id, diff --git a/src/layers/streetLevel/providers/kartaview/index.js b/src/layers/streetLevel/providers/kartaview/index.js index 0435b28d8..da093112b 100644 --- a/src/layers/streetLevel/providers/kartaview/index.js +++ b/src/layers/streetLevel/providers/kartaview/index.js @@ -206,8 +206,13 @@ export function createKartaViewProvider({ ), metresBetween, ); - // The open photo may have just learnt which way it looked. - viewer.refreshPose?.(); + // The open photo may have just learnt which way it looked. The + // page is good whatever the viewer is doing. + try { + viewer.refreshPose?.(); + } catch { + /* the next pose carries the direction */ + } return { page, hasMore, photos: list }; }); pages.set(key, entry); @@ -362,10 +367,17 @@ export function createKartaViewProvider({ const cones = createSequenceCones({ state, async loadImages(sequenceId, { signal }) { - const anchor = anchors.get(sequenceId); + let anchor = anchors.get(sequenceId); if (!anchor) return []; - const window = await windowOf(anchor); + let window = await windowOf(anchor); signal?.throwIfAborted(); + // Another photo of the drive opened while that page loaded (its + // re-centre found nothing drawn yet): centre on the newest one. + while (anchors.get(sequenceId) !== anchor) { + anchor = anchors.get(sequenceId); + window = await windowOf(anchor); + signal?.throwIfAborted(); + } drawnPage = { sequenceId, page: window.home }; countPhotos(sequenceId); return thinImages( diff --git a/src/layers/streetLevel/providers/kartaview/provider.test.mjs b/src/layers/streetLevel/providers/kartaview/provider.test.mjs index 055c3b1a6..ea82db277 100644 --- a/src/layers/streetLevel/providers/kartaview/provider.test.mjs +++ b/src/layers/streetLevel/providers/kartaview/provider.test.mjs @@ -859,3 +859,104 @@ test('the marker is KartaView’s own cone, and it points along the drive, not a `then along the drive: ${bearings}`, ); }); + +/** Hold `seq`'s page `page` until the returned function is called. */ +function holdPage(source, seq, page) { + const getPage = source.getSequencePhotos; + let release; + const gate = new Promise((resolve) => (release = resolve)); + source.getSequencePhotos = async (id, options) => { + if (id === seq && options.page === page) await gate; + return getPage(id, options); + }; + return () => release(); +} + +test('a photo opened while the cones first load is the one they centre on', async (t) => { + const size = SEQUENCE_PAGE_SIZE; + const env = await mounted({ + sequences: { s: Array.from({ length: size * 5 }, (_, i) => i) }, + }); + const cones = await withCones(t, env); + const release = holdPage(env.source, 's', 1); + await env.instance.viewer.open('s-10'); + const selected = env.instance.selectSequence('s'); + // A line click further along the drive while page 1 is still loading: + // nothing is drawn yet for its re-centre to move. + await env.instance.viewer.open(`s-${size * 4 + 10}`); + release(); + await selected; + assert.ok( + await until( + () => !env.instance.sequenceStats().loading && cones.count() > 0, + ), + ); + assert.ok( + cones()[0] <= size * 4 + 10 && cones()[1] >= size * 4 + 10, + `around the newer photo, not the first: ${cones()}`, + ); +}); + +test('a sequence page that lands while a lost GPU context recovers still draws its cones', async (t) => { + const env = setup({ sequences: { p: [0, 1, 2, 3] } }); + const sphere = (record) => ({ + ...record, + projection: 'SPHERE', + fieldOfView: '360', + filepathProc: `2022/10/6/wrapped_proc/${record.id}.jpg`, + }); + const getPhoto = env.source.getPhoto; + env.source.getPhoto = async (id) => sphere(await getPhoto(id)); + const getPage = env.source.getSequencePhotos; + env.source.getSequencePhotos = async (...args) => { + const page = await getPage(...args); + return { ...page, photos: page.photos.map(sphere) }; + }; + const release = holdPage(env.source, 'p', 1); + let holdImages = false; + let releaseImage = null; + const view = { yaw: 0, pitch: 0, fov: 80 }; + const definition = createKartaViewProvider({ + source: env.source, + viewerOptions: { + load: async (url) => { + if (holdImages) await new Promise((r) => (releaseImage = r)); + return { src: url }; + }, + createRenderer: () => ({ + setImage() {}, + setView: (next) => Object.assign(view, next), + getView: () => ({ ...view }), + resize() {}, + clear() {}, + destroy() {}, + }), + }, + }); + const { context, actions } = fakeContext(); + const errors = []; + context.actions.reportError = (message) => errors.push(message); + const instance = definition.create(context); + actions.instance = instance; + const cones = await withCones(t, { instance }); + const host = fakeHost(); + await instance.viewer.mount(host); + await instance.viewer.open('p-0'); + // › to the next panorama; the GPU resets while it loads. + holdImages = true; + const opening = instance.viewer.open('p-1'); + assert.ok(await until(() => releaseImage !== null)); + host.children[0].children[1].fire('webglcontextlost'); + const selected = instance.selectSequence('p'); + release(); + await selected; + assert.ok( + await until(() => !instance.sequenceStats().loading && cones.count() > 0), + 'the page is drawn, not dropped', + ); + releaseImage(); + await opening; + assert.equal(host.children[0].dataset.kind, 'pano', 'p-1 shows'); + // reportError(null) clears; nothing was reported. + assert.deepEqual(errors.filter(Boolean), []); +}); diff --git a/src/layers/streetLevel/providers/kartaview/source.js b/src/layers/streetLevel/providers/kartaview/source.js index 4c1b47ff8..4283cf859 100644 --- a/src/layers/streetLevel/providers/kartaview/source.js +++ b/src/layers/streetLevel/providers/kartaview/source.js @@ -145,8 +145,10 @@ export function createKartaViewSource({ ), timeoutMs, ); + let response = null; + let read = false; try { - const response = await fetchImpl(url, { signal: controller.signal }); + response = await fetchImpl(url, { signal: controller.signal }); if (response.status === 429) throw new KartaViewSourceError('KartaView is rate-limiting requests', { status: 429, @@ -166,6 +168,7 @@ export function createKartaViewSource({ // KartaView answers chunked, without a length: count while reading. const reader = response.body?.getReader?.(); if (!reader) { + read = true; try { return await response.json(); } catch (error) { @@ -175,6 +178,7 @@ export function createKartaViewSource({ }); } } + read = true; const chunks = []; let bytes = 0; for (;;) { @@ -209,6 +213,13 @@ export function createKartaViewSource({ } finally { clearTimeout(timer); signal?.removeEventListener('abort', onAbort); + // An answer turned away unread (429, an error, too long) still holds + // its connection, one of the six the browser has: let it go. + if (response && !read) { + controller.abort(); + if (response.body && !response.body.locked) + response.body.cancel?.().catch?.(() => {}); + } } } diff --git a/src/layers/streetLevel/providers/kartaview/source.test.mjs b/src/layers/streetLevel/providers/kartaview/source.test.mjs index 5ad3f6cb8..4aed435c7 100644 --- a/src/layers/streetLevel/providers/kartaview/source.test.mjs +++ b/src/layers/streetLevel/providers/kartaview/source.test.mjs @@ -595,3 +595,62 @@ test('a sequence page gets the full 30 s on its first try, then 30 s once more', assert.match(String(outcome?.error?.message), /did not answer in time/); assert.equal(outcome.at, 2 * PAGE_TIMEOUT_MS); }); + +test('an answer turned away unread (an error, a 429, a length past the cap) lets its connection go', async () => { + /** A response whose body is never read: records its cancel and the abort. */ + const unread = (answer) => { + const seen = { cancelled: false, signal: null }; + const fetchImpl = async (url, { signal }) => { + seen.signal = signal; + return { + ...answer, + body: { + locked: false, + getReader: () => { + throw new Error('not read'); + }, + cancel: async () => { + seen.cancelled = true; + }, + }, + }; + }; + return { seen, fetchImpl }; + }; + const cases = [ + [{ ok: false, status: 503, headers: new Map() }, /HTTP 503/], + [ + { + ok: true, + status: 200, + headers: new Map([['content-length', String(64 * 1024 * 1024)]]), + }, + /too much data/, + ], + ]; + for (const [answer, message] of cases) { + const { seen, fetchImpl } = unread(answer); + const source = createKartaViewSource({ fetchImpl }); + await assert.rejects(source.getTile({ x: 1, y: 2, z: 13 }), message); + assert.equal(seen.signal.aborted, true, `${message}: request aborted`); + assert.equal(seen.cancelled, true, `${message}: body cancelled`); + } + // A 429 is let go before its retry is asked. + const { seen, fetchImpl } = unread({ + ok: false, + status: 429, + headers: new Map([['retry-after', '0']]), + }); + const signals = []; + const source = createKartaViewSource({ + fetchImpl: async (url, init) => { + signals.push(init.signal); + if (signals.length === 1) return fetchImpl(url, init); + return ok(photoPayload([])); + }, + }); + await source.getPhoto('1'); + assert.equal(signals[0].aborted, true); + assert.equal(seen.cancelled, true); + assert.equal(signals[1].aborted, false, 'a read answer is left alone'); +}); diff --git a/src/layers/streetLevel/providers/kartaview/viewer.js b/src/layers/streetLevel/providers/kartaview/viewer.js index a9beb52c5..9aa4639c5 100644 --- a/src/layers/streetLevel/providers/kartaview/viewer.js +++ b/src/layers/streetLevel/providers/kartaview/viewer.js @@ -73,6 +73,11 @@ async function loadBitmap(url, timeoutMs) { } } +/** Free a decoded bitmap nobody will draw (an has nothing to free). */ +function release(image) { + image?.close?.(); +} + /** The panorama size a canvas this wide can show sharply at this field of view. */ export function panoSizeFor(canvasPixels, fovDeg) { const needed = (Math.max(1, canvasPixels) * 360) / Math.max(1, fovDeg); @@ -130,6 +135,10 @@ export function createKartaViewViewer({ let panoWidth = 0; /** The flat photo on screen is the 2,560 px version. */ let flatSharp = false; + /** The sharper version loading ({seq, kind}): a resize starts no second one. */ + let upgrading = null; + /** A resize came while it loaded: look again once it lands. */ + let upgradeAgain = false; /** The photo an open is fetching: ‹ › step on from it, not the old one. */ let openingId = null; /** ‹ › chose this photo: keep the panorama's look direction for it. */ @@ -143,6 +152,9 @@ export function createKartaViewViewer({ function emit() { if (!photo || !showing) return; + // A lost GPU context, still being recovered: the open that redraws the + // panorama poses once it is on screen. + if (showing === 'pano' && !renderer) return; const view = showing === 'pano' ? renderer.getView() : null; const heading = cameraBearing(photo); const pose = { @@ -485,20 +497,40 @@ export function createKartaViewViewer({ ? resizedImageUrl(target.urls.pano, size) : resizedImageUrl(target.urls.full, size); if (!url) return; + // Resizes come in bursts while the panel animates: one load at a time + // for this photo, and one more look once it lands. + if (upgrading?.seq === seq && upgrading.kind === kind) { + upgradeAgain = true; + return; + } + const pending = (upgrading = { seq, kind }); + upgradeAgain = false; + let image = null; try { - const image = await load(url, { crossOrigin: kind === 'pano' }); + image = await load(url, { crossOrigin: kind === 'pano' }); if (seq !== openSeq || photo !== target || showing !== kind) return; if (kind === 'pano') { if (!renderer || size[0] <= panoWidth) return; renderer.setImage(image); + image = null; panoWidth = size[0]; } else { picture.src = image.src; + image = null; flatSharp = true; } requestRender(); } catch { /* the first version stays */ + } finally { + release(image); + if (upgrading === pending) { + upgrading = null; + if (upgradeAgain && seq === openSeq && photo === target) { + upgradeAgain = false; + upgrade(target, seq, showing); + } + } } } @@ -524,10 +556,16 @@ export function createKartaViewViewer({ resizedImageUrl(target.urls.pano, PANO_PREVIEW_SIZE), { crossOrigin: true }, ); - if (seq !== openSeq) return false; + if (seq !== openSeq) { + release(image); + return false; + } // The context may have been lost while this loaded: build on the new // canvas, or let the caller show the front view. - if (!ensureRenderer()) throw new Error(LOAD_FAILED); + if (!ensureRenderer()) { + release(image); + throw new Error(LOAD_FAILED); + } const restore = restoreView?.id === target.id ? restoreView.view : null; restoreView = null; if (restore) renderer.setView(restore); diff --git a/src/layers/streetLevel/providers/kartaview/viewer.test.mjs b/src/layers/streetLevel/providers/kartaview/viewer.test.mjs index 519e76615..1391b727c 100644 --- a/src/layers/streetLevel/providers/kartaview/viewer.test.mjs +++ b/src/layers/streetLevel/providers/kartaview/viewer.test.mjs @@ -105,8 +105,11 @@ function setup({ rendererThrows = false, steps = {}, failSteps = false, + hold = () => false, } = {}) { const loads = []; + const held = []; + const closed = []; const renderers = []; const stepped = []; const asked = []; @@ -130,7 +133,10 @@ function setup({ loads.push({ url, crossOrigin: options?.crossOrigin }); if (failLoads.some((part) => url.includes(part))) throw new Error('The KartaView photo could not be loaded'); - return { src: url }; + // A decoded bitmap: close() frees it. + const image = { src: url, close: () => closed.push(url) }; + if (!hold(url)) return image; + return new Promise((resolve) => held.push(() => resolve(image))); }, createRenderer: (canvas) => { attempts.renderer++; @@ -152,6 +158,8 @@ function setup({ stepped, asked, attempts, + held, + closed, }; } @@ -923,3 +931,54 @@ test('a docked viewer keeps the 1,280 px photo; expanding it brings the 2,560 px for (let i = 0; i < 4; i++) await settle(); assert.equal(loads.length, 2, 'once only'); }); + +test('resizes while a sharper panorama loads start no second load; the last size wins once it lands', async () => { + let first = true; + const { viewer, host, loads, renderers, held } = setup({ + hold: (url) => { + const holdIt = first && url.includes('rs:fit:2048'); + if (holdIt) first = false; + return holdIt; + }, + }); + await viewer.mount(host); + await viewer.open('pano1'); + assert.ok(await until(() => held.length === 1)); + // The panel animates open: a burst of resizes while 2,048 px loads. + parts(host).canvas.clientWidth = 1400; + for (let i = 0; i < 5; i++) viewer.resize(); + for (let i = 0; i < 4; i++) await settle(); + assert.equal(loads.length, 2, 'the preview and the one 2,048 px load'); + held[0](); + assert.ok(await until(() => renderers[0].images.length === 3)); + assert.match(renderers[0].images[1], /rs:fit:2048:1024/); + assert.match(renderers[0].images[2], /rs:fit:4096:2048/); + for (let i = 0; i < 4; i++) await settle(); + assert.equal(loads.length, 3, 'one more load, for the size asked last'); +}); + +test('a preview or sharper version nobody will draw is freed, not left decoded', async () => { + const { viewer, host, renderers, held, closed } = setup({ + hold: (url) => /rs:fit:(1024|2048):/.test(url), + }); + await viewer.mount(host); + // pano1's preview is still loading when flat1 opens over it. + const overtaken = viewer.open('pano1'); + assert.ok(await until(() => held.length === 1)); + await viewer.open('flat1'); + held[0](); + await overtaken; + assert.deepEqual(closed.length, 1); + assert.match(closed[0], /rs:fit:1024/, 'the overtaken preview'); + // pano1 shows; its sharp version lands after flat1 replaced it. + const shown = viewer.open('pano1'); + assert.ok(await until(() => held.length === 2)); + held[1](); + await shown; + assert.ok(await until(() => held.length === 3)); + await viewer.open('flat1'); + held[2](); + assert.ok(await until(() => closed.length === 2)); + assert.match(closed[1], /rs:fit:2048/, 'the overtaken sharp version'); + assert.equal(renderers[0].images.length, 1, 'only the preview was drawn'); +}); From b42ab9bb02b7f7f1adf1e517e1caecfda10f0e31 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 14 Oct 2026 14:54:12 +0100 Subject: [PATCH 8/9] feat(street-level): IMAGERY and SINCE filters for KartaView KartaView filters coverage on its server only in its PNG tiles (its JSON tiles come back empty with a filter), so a filtered tile's PNG is a mask over the cached JSON segments: a segment stays when at least half the points along it lie on drawn pixels. A filter change reloads the lines (lineCoverage.reload keeps the drawn ones until the new ones are in); masks are cached per filter and tile, and a filtered tile KartaView is still drawing (504 or a timeout) is asked again, four tries in all. The nearest lookup asks the photo API for the projection and date, and the cones follow the filter like Mapillary's. The gate fixture draws filtered PNG tiles and adds an older flat-only street; a new gate step checks 360 and LAST YEAR thin KartaView's lines and ALL brings them back. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 +- DATA_SOURCES.md | 2 +- README.md | 2 +- docs/CURRENT-STATE.md | 22 +- scripts/fixtures/street-level/kartaView.mjs | 163 +++++++++++++-- scripts/qa-street-level.mjs | 70 ++++++- src/layers/streetLevel/lineCoverage.js | 10 + .../streetLevel/providers/kartaview/decode.js | 116 ++++++++++- .../providers/kartaview/decode.test.mjs | 59 ++++++ .../streetLevel/providers/kartaview/index.js | 80 ++++++-- .../streetLevel/providers/kartaview/policy.js | 65 ++++++ .../providers/kartaview/policy.test.mjs | 27 +++ .../providers/kartaview/provider.test.mjs | 190 +++++++++++++++--- .../streetLevel/providers/kartaview/source.js | 73 ++++++- .../providers/kartaview/source.test.mjs | 72 +++++++ src/tooling/streetLevelQa.test.mjs | 82 ++++++++ 16 files changed, 961 insertions(+), 78 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index abe3f8201..cde9b6883 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -114,7 +114,11 @@ answers 409) are skipped by the click and by ‹ ›. Shift+← →, ↑ ↓, + − and pinch look around a 360° photo. The open photo's marker is a purple view cone pointing along the drive (KartaView's own compass headings are often - 0 or far off). + 0 or far off). The IMAGERY (ALL / 360° / FLAT) and SINCE filters apply to + KartaView as to Mapillary: its lines are filtered by KartaView's own + server (its filtered PNG tiles, as a mask over the JSON lines; SINCE goes + by upload date there), and the photo a click opens, and the cones, by + each photo's projection and capture date. Mapillary's line coverage and sequence cones are now shared by both (`lineCoverage.js`, `sequenceCones.js`). diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 23d9cfc82..1319c1541 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -22,7 +22,7 @@ How to read this: | **NOAA NHC / CPHC advisories** (`www.nhc.noaa.gov/CurrentStorms.json` + the `mapservices.weather.noaa.gov` tropical weather summary MapServer) | Cyclone advisories: storm positions, forecast tracks, points and cones for the Atlantic and eastern/central North Pacific. Keyless through `/api/cyclones`; cached for 5 minutes | [NWS public-data terms](https://www.weather.gov/disclaimer); no endorsement implied | "NOAA/NWS NHC / CPHC" | | **Mapillary** (vector tiles, Graph API, MapillaryJS) | Street Level: coverage lines, image cones and the embedded viewer. Coverage tiles cached for 24 h | Imagery [CC BY-SA 4.0](https://www.mapillary.com/terms); API under the Mapillary terms with a client token | "© Mapillary contributors, CC BY-SA 4.0", shown on the globe while the provider is on | | **Google Street View** (Maps JavaScript API) | Street Level, on the Google 3D map only (Google's terms): Google's panorama viewer and nearest-panorama lookups; panorama dates and addresses kept for the session only | Google Maps Platform ToS (your own key + billing; every panorama load is billed) | "Street View imagery © Google" on the globe while the provider is on, and Google's logo and terms in the viewer | -| **KartaView** (`api.kartaview.org` coverage tiles and photo API, `cdn.kartaview.org` image proxy, `storage*.openstreetcam.org` photos) | Street Level: coverage lines (z13–14 JSON tiles), nearest-photo lookups, the photo's sequence and the photo itself, fetched by the browser. Keyless; nothing cached beyond the session | Imagery [CC BY-SA 4.0](https://kartaview.org/terms) (KartaView terms §4); the public API needs no token | "© KartaView contributors, CC BY-SA 4.0", shown on the globe while the provider is on | +| **KartaView** (`api.kartaview.org` coverage tiles and photo API, `cdn.kartaview.org` image proxy, `storage*.openstreetcam.org` photos) | Street Level: coverage lines (z13–14 JSON tiles; with the imagery filter on, KartaView's filtered PNG tiles as a mask), nearest-photo lookups, the photo's sequence and the photo itself, fetched by the browser. Keyless; nothing cached beyond the session | Imagery [CC BY-SA 4.0](https://kartaview.org/terms) (KartaView terms §4); the public API needs no token | "© KartaView contributors, CC BY-SA 4.0", shown on the globe while the provider is on | | **OpenStreetMap** | Map and place data (roads, military areas and names, ALPR, place search, outlines); via vector tiles, hourly ALPR extract, bundled data, Nominatim and optional self-configured Overpass | [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/) | © OpenStreetMap contributors | | **Google Map Tiles API** (Photorealistic 3D Tiles) + Places/Geocoding | The 3D globe, voice scene context, and on-demand nearby installation search | Google Maps Platform ToS (proprietary, your own key + billing) | "Google" / "Google Maps" logo — **shown in-app**, required | | **OpenSky Network** | Primary worldwide live-flight snapshot | Non-commercial research/education license | Schäfer et al., _"Bringing Up OpenSky"_, IPSN 2014 + opensky-network.org | diff --git a/README.md b/README.md index e55c94a77..9e914764b 100644 --- a/README.md +++ b/README.md @@ -344,7 +344,7 @@ _The Space Missions layer replaying a Falcon 9 ascent — labeled `RECONSTRUCTED **Setup.** At [mapillary.com/dashboard/developers](https://www.mapillary.com/dashboard/developers), register an application with **Read** access and copy its **Client Token** (`MLY|…`). Paste it into **POWER UP → Mapillary**, or set `MAPILLARY_CLIENT_TOKEN` in `.env` and restart. Without it the panel reads **KEY REQUIRED**. **Google Street View** uses `GOOGLE_MAPS_API_KEY` with the **Maps JavaScript API** enabled for it in Google Cloud; its chip starts off, since every panorama is billed to the key. **KartaView** needs nothing: its public API is keyless. -**Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Each provider has a chip in its own colour (Mapillary green), and one is lit at a time: lighting one switches the others off. Mapillary, which is free, is lit by default. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary; Street View panoramas are all 360°, so with Street View lit the filters and legend are hidden. Click a coverage line for its image cones and a cone to open the photo. Light **KARTAVIEW** (purple) and zoom in below about 6 km: its lines are the streets KartaView has photos of, and a click on one opens the photo nearest the click, flat or 360° (drag to look around), with cones along its drive; **‹ ›** or ← → step through the drive (Shift+← →, ↑ ↓ and + − look around a 360° photo; pinch zooms on a phone). Photos KartaView has archived are skipped, and where all are archived the panel says so. KartaView photos are undated on its map, so the filters do not apply to it. On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND**, **FIT / FILL** (Mapillary, and flat KartaView photos), and **FOLLOW** (the globe camera follows the photo's view; Google 3D map only). The caption links to the image on the provider's site, and the globe credits each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google", "© KartaView contributors, CC BY-SA 4.0"). KartaView's own photo pages fail for signed-out visitors, so its link opens the full-size photo. +**Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Each provider has a chip in its own colour (Mapillary green), and one is lit at a time: lighting one switches the others off. Mapillary, which is free, is lit by default. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary and KartaView; Street View panoramas are all 360°, so with Street View lit the filters and legend are hidden. Click a coverage line for its image cones and a cone to open the photo. Light **KARTAVIEW** (purple) and zoom in below about 6 km: its lines are the streets KartaView has photos of, and a click on one opens the photo nearest the click, flat or 360° (drag to look around), with cones along its drive; **‹ ›** or ← → step through the drive (Shift+← →, ↑ ↓ and + − look around a 360° photo; pinch zooms on a phone). Photos KartaView has archived are skipped, and where all are archived the panel says so. KartaView filters its lines on its own server, by upload date for **SINCE** (never before the capture date, so a line may hold only older captures: the click then says none of its photos pass), and a photo opened is held to its capture date; a filtered area KartaView has not drawn before can take several seconds, and the unfiltered lines show meanwhile. On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND**, **FIT / FILL** (Mapillary, and flat KartaView photos), and **FOLLOW** (the globe camera follows the photo's view; Google 3D map only). The caption links to the image on the provider's site, and the globe credits each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google", "© KartaView contributors, CC BY-SA 4.0"). KartaView's own photo pages fail for signed-out visitors, so its link opens the full-size photo. Drag the panel header to float the panel, and resize it from any edge. Double-click the header or collapse it to put it back in the rail. Share links carry the provider switches and filters (`0.m.0` Mapillary off, `0.m.0_0.r.google-1` Street View on, `0.m.0_0.r.google-0*kartaview-1` KartaView on, as the app writes it (`0.m.0_0.r.kartaview-1` works too; a link that leaves out `m` keeps Mapillary, the default, lit), `0.p.p` panoramas only, `0.s.365` the last year). To add a provider, implement the contract in `src/layers/streetLevel/registry.js` and register it in `src/app/layers/streetLevel.js`. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 7c98f62fb..89644fae5 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3212,8 +3212,26 @@ segments that meet end to start (about four times fewer lines; linear time, at most 20,000 segments a tile and 5,000 points a line), they are drawn as `kv:line:`, up to 1,600 per tile, and never highlighted (`highlightsSequence: false`, so the legend has no "Selected"), and the -provider declares `filters: []` and `coverageLabel` ("N streets in view · -click a line for its nearest photo"). Tiles come uncompressed and +provider declares `filters: ['pano', 'since']` and `coverageLabel` ("N +streets in view · click a line for its nearest photo"). KartaView filters +coverage only in its PNG tiles (`/sequence/tiles/{x}/{y}/{z}.png?fieldOfView=360|plane&startDate=YYYY-MM-DD`; +its JSON, GeoJSON and jsoncanvas tiles come back empty once a filter is +asked for), so a filtered tile is drawn from the cached JSON segments that +lie on the filtered PNG's drawn pixels (`maskedSegments`: at least half the +points sampled every 2 px have a drawn pixel within one; on Jakarta's 360° +filter, 332 of 1,041 segments lie on it, 676 miss it, 33 fall between). +A filter change reloads the lines (`lineCoverage.reload`: the drawn ones show +until the new ones are in), keeps the JSON segments, and caches masks per +filter and tile. A dense filtered PNG can take KartaView over a minute the +first time (it answers 504 at 60 s and keeps drawing), so a timeout or 5xx is +asked again 4 s later, four tries in all; then the panel says it did not +filter the area in time. KartaView's `startDate` is the upload date, never +before the capture date, so the lines are a superset of what passes; the +nearest lookup asks the photo API for the projection (`SPHERE`/`PLANE`; it +ignores `fieldOfView`) and upload date, then holds each photo to its capture +date (when photos are near but none passes, the panel says so), and the +cones pass the filter on each photo's projection and capture date, as +Mapillary's do. Tiles come uncompressed and uncacheable, so the last 24 decoded tiles are kept for 10 minutes while KartaView is lit (`createTileCache`, dropped when it is switched off); one that does not answer in 20 s is asked once more, and one that is not a line diff --git a/scripts/fixtures/street-level/kartaView.mjs b/scripts/fixtures/street-level/kartaView.mjs index 1c85b0ec0..91caa8004 100644 --- a/scripts/fixtures/street-level/kartaView.mjs +++ b/scripts/fixtures/street-level/kartaView.mjs @@ -1,3 +1,4 @@ +import { deflateSync } from 'node:zlib'; import { tileBounds } from '../../../src/layers/streetLevel/tileMath.js'; import { metresApart, photoJpeg } from './mapillaryGraph.mjs'; @@ -11,6 +12,10 @@ import { metresApart, photoJpeg } from './mapillaryGraph.mjs'; * so the nearest photo opened is even and › must skip the one after it. The * first nearest-photo lookup is rate-limited (429, Retry-After: 0), as * KartaView does under load. + * + * Beside the drive runs KV_OLD_STREET, photographed flat in 2019 only: the + * imagery filter drops it. KartaView filters coverage only in its PNG + * tiles, so filtered PNG tiles are drawn here for the streets that pass. */ export const KV_SEQUENCE_ID = '900001'; @@ -19,9 +24,20 @@ export const KV_LINE = Object.freeze({ south: 38.5795, north: 38.5875, }); +/** A street ~130 m east of the drive with only flat 2019 photos. */ +export const KV_OLD_STREET = Object.freeze({ + lon: -121.4916, + south: 38.58, + north: 38.587, +}); +const OLD_STREET_UPLOADED = '2019-06-01'; const KV_SPACING_M = 25; const METRES_PER_DEG_LAT = 110_540; -const SHOT = '2024-05-02 16:20:00.000'; +/** KartaView's time format for `ms`. */ +const kartaViewTime = (ms) => + new Date(ms).toISOString().replace('T', ' ').slice(0, 23); +/** The drive is a month old, whenever the gate runs: LAST YEAR keeps it. */ +const SHOT = kartaViewTime(Date.now() - 30 * 86_400_000); const CORS = Object.freeze({ 'Access-Control-Allow-Origin': '*' }); @@ -95,27 +111,123 @@ const raw = (status, body) => ({ body: JSON.stringify(body), }); -/** The drive's track inside tile `z/x/y` (with a small margin), as KartaView's JSON tile. */ -function tileLines(z, x, y) { - const { west, east, south, north } = tileBounds(x, y, z); - if (KV_LINE.lon < west || KV_LINE.lon > east) return []; - const from = Math.max(KV_LINE.south, south); - const to = Math.min(KV_LINE.north, north); - if (from >= to) return []; - // Two segments meeting end to start, as KartaView splits ways. - const mid = (from + to) / 2; - const at = (lat) => ({ lat: String(lat), lng: String(KV_LINE.lon) }); +/** The streets with photos: the drive, and the old street beside it. */ +function streets() { return [ - { track: [at(from), at(mid)], matched: '1' }, - { track: [at(mid), at(to)], matched: '1' }, + { line: KV_LINE, uploaded: SHOT.slice(0, 10), views: ['360', 'plane'] }, + { line: KV_OLD_STREET, uploaded: OLD_STREET_UPLOADED, views: ['plane'] }, ]; } +/** A street's stretch inside tile `z/x/y`, as [from, to] latitudes, or null. */ +function stretch(line, z, x, y) { + const { west, east, south, north } = tileBounds(x, y, z); + if (line.lon < west || line.lon > east) return null; + const from = Math.max(line.south, south); + const to = Math.min(line.north, north); + return from < to ? [from, to] : null; +} + +/** The streets' tracks inside tile `z/x/y`, as KartaView's JSON tile. */ +function tileLines(z, x, y) { + const lines = []; + for (const { line } of streets()) { + const inside = stretch(line, z, x, y); + if (!inside) continue; + const [from, to] = inside; + // Two segments meeting end to start, as KartaView splits ways. + const mid = (from + to) / 2; + const at = (lat) => ({ lat: String(lat), lng: String(line.lon) }); + lines.push( + { track: [at(from), at(mid)], matched: '1' }, + { track: [at(mid), at(to)], matched: '1' }, + ); + } + return lines; +} + +/** Whether a street has photos passing a filtered tile's query. */ +function passesTileQuery(street, query) { + const view = query.get('fieldOfView'); + if (view && !street.views.includes(view)) return false; + const since = query.get('startDate'); + return !since || street.uploaded >= since; +} + +const CRC_TABLE = Array.from({ length: 256 }, (_, n) => { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + return c >>> 0; +}); +function crc32(bytes) { + let c = 0xffffffff; + for (const byte of bytes) c = CRC_TABLE[(c ^ byte) & 0xff] ^ (c >>> 8); + return (c ^ 0xffffffff) >>> 0; +} +function pngChunk(type, data) { + const head = Buffer.alloc(8); + head.writeUInt32BE(data.length, 0); + head.write(type, 4, 'ascii'); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(crc32(Buffer.concat([head.subarray(4), data])), 0); + return Buffer.concat([head, data, crc]); +} + +/** + * A filtered coverage tile as KartaView draws it: 512 px, transparent, the + * passing streets three pixels wide. + */ +export function maskTilePng(z, x, y, query) { + const size = 512; + const rgba = Buffer.alloc(size * size * 4); + const { west, east } = tileBounds(x, y, z); + const n = 2 ** z; + const row = (lat) => { + const sin = Math.sin((lat * Math.PI) / 180); + const my = 0.5 - Math.log((1 + sin) / (1 - sin)) / (4 * Math.PI); + return Math.round((my * n - y) * size); + }; + for (const street of streets()) { + if (!passesTileQuery(street, query)) continue; + const inside = stretch(street.line, z, x, y); + if (!inside) continue; + const column = Math.round( + ((street.line.lon - west) / (east - west)) * size, + ); + const top = Math.max(0, row(inside[1])); + const bottom = Math.min(size - 1, row(inside[0])); + for (let py = top; py <= bottom; py++) + for (let px = column - 1; px <= column + 1; px++) { + if (px < 0 || px >= size) continue; + rgba.set([99, 91, 255, 255], (py * size + px) * 4); + } + } + const rows = Buffer.alloc(size * (size * 4 + 1)); + for (let py = 0; py < size; py++) + rgba.copy( + rows, + py * (size * 4 + 1) + 1, + py * size * 4, + (py + 1) * size * 4, + ); + const header = Buffer.alloc(13); + header.writeUInt32BE(size, 0); + header.writeUInt32BE(size, 4); + header.set([8, 6, 0, 0, 0], 8); + return Buffer.concat([ + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), + pngChunk('IHDR', header), + pngChunk('IDAT', deflateSync(rows)), + pngChunk('IEND', Buffer.alloc(0)), + ]); +} + /** A call named by host, path shape and parameter names. */ export function describeKartaViewCall(method, url) { const keys = [...url.searchParams.keys()].sort(); const path = url.pathname .replace(/\/tiles\/\d+\/\d+\/\d+\.json$/, '/tiles/{x}/{y}/{z}.json') + .replace(/\/tiles\/\d+\/\d+\/\d+\.png$/, '/tiles/{x}/{y}/{z}.png') .replace(/\/photo\/\d+$/, '/photo/{id}') .replace(/\/sequence\/\d+\/photos$/, '/sequence/{id}/photos') .replace(/\/sequence\/\d+$/, '/sequence/{id}') @@ -173,6 +285,23 @@ export function answerKartaViewRequest({ method, url }) { }), known: true, }; + // Filtered coverage: KartaView filters only its PNG tiles. + const mask = path.match( + /^\/2\.0\/sequence\/tiles\/(\d+)\/(\d+)\/(\d+)\.png$/, + ); + if (mask) + return { + status: 200, + contentType: 'image/png', + headers: CORS, + body: maskTilePng( + Number(mask[3]), + Number(mask[1]), + Number(mask[2]), + address.searchParams, + ), + known: true, + }; if (path === '/2.0/photo/') { if (nearestLookups++ === 0) return { @@ -186,7 +315,15 @@ export function answerKartaViewRequest({ method, url }) { }; const q = address.searchParams; const at = { lat: Number(q.get('lat')), lon: Number(q.get('lng')) }; + // Its own filter, as KartaView's: projection, and upload date. + const projection = q.get('projection'); + const since = q.get('startDate'); const near = photos + .filter( + (photo) => + (!projection || projection === (photo.isPano ? 'SPHERE' : 'PLANE')) && + (!since || SHOT.slice(0, 10) >= since), + ) .map((photo) => ({ photo, metres: metresApart(at, photo) })) .filter(({ metres }) => metres < 200) .sort((a, b) => a.metres - b.metres) diff --git a/scripts/qa-street-level.mjs b/scripts/qa-street-level.mjs index 4b60a0695..e5f76fe8c 100644 --- a/scripts/qa-street-level.mjs +++ b/scripts/qa-street-level.mjs @@ -888,14 +888,14 @@ async function main() { await sleep(1000); } await step( - 'KartaView: its chip draws keyless coverage, credits KartaView and hides the filters', + 'KartaView: its chip draws keyless coverage, credits KartaView and offers Mapillary’s filters', async () => { await clickPanelControl(page, kvChip); assert.ok(await lit('kartaview'), 'KartaView alone is lit'); await waitForCoverage(page); const u = await ui(); assert.equal(u.keyRequired, false); - assert.deepEqual(u.filterControls, { pano: false, since: false }); + assert.deepEqual(u.filterControls, { pano: true, since: true }); assert.equal( await page.$eval(kvChip, (chip) => chip.style.getPropertyValue('--chip-color'), @@ -924,6 +924,72 @@ async function main() { ); }, ); + await step( + 'KartaView: 360° and SINCE thin its lines through KartaView’s filtered tiles, and ALL / ANY DATE bring them back', + async () => { + const before = (await ui()).coverage.count; + const setStop = (index) => + page.$eval( + '#sl-since', + (input, value) => { + input.value = String(value); + input.dispatchEvent(new Event('input', { bubbles: true })); + input.dispatchEvent(new Event('change', { bubbles: true })); + }, + index, + ); + // Filtered tiles are drawn by KartaView on demand: give them time. + const settled = async (test, arg, what) => + assert.ok( + await uiUntil(test, arg, { timeout: 60_000 }), + `${what}: ${JSON.stringify((await ui()).coverage)}`, + ); + await clickPanelControl(page, '[data-sl-pano="pano"]'); + await settled( + (u) => u.filter.pano === 'pano' && !u.coverage.loading, + null, + '360° drawn', + ); + const panos = (await ui()).coverage.count; + assert.ok(panos <= before, `${panos} ≤ ${before}`); + // Fixtures: the old street has only flat photos, the drive 360° too. + if (fixtures) + assert.ok(panos > 0 && panos < before, `0 < ${panos} < ${before}`); + await clickPanelControl(page, '[data-sl-pano="all"]'); + await settled( + (u, n) => + u.filter.pano === 'all' && + !u.coverage.loading && + u.coverage.count === n, + before, + 'every line again', + ); + await setStop(5); + await settled( + (u) => u.filter.sinceDays === 365 && !u.coverage.loading, + null, + 'LAST YEAR drawn', + ); + const lastYear = (await ui()).coverage.count; + assert.ok(lastYear <= before, `${lastYear} ≤ ${before}`); + // Fixtures: the drive is a month old, the old street from 2019. + if (fixtures) + assert.ok( + lastYear > 0 && lastYear < before, + `0 < ${lastYear} < ${before}`, + ); + await setStop(0); + await settled( + (u, n) => + u.filter.sinceDays === 0 && + !u.coverage.loading && + u.coverage.count === n, + before, + 'ANY DATE: every line again', + ); + assert.equal((await ui()).coverage.error, null); + }, + ); await step( 'KartaView: a click on a line opens the nearest photo where the camera is, cones follow its drive, and › steps along it, past an archived photo', async ({ skip }) => { diff --git a/src/layers/streetLevel/lineCoverage.js b/src/layers/streetLevel/lineCoverage.js index 85edd97c1..71b98ed6a 100644 --- a/src/layers/streetLevel/lineCoverage.js +++ b/src/layers/streetLevel/lineCoverage.js @@ -798,6 +798,15 @@ export function createLineCoverage({ notify(); } + /** + * Load every tile again (a source that filters on its server had its + * filter changed); the lines drawn now show until the new ones are in. + */ + function reload() { + retire(); + refresh(); + } + /** * Redraw tiles draped or cast for the new surface mode, then re-pick the * zoom, since the ground height under the camera may have changed. @@ -919,6 +928,7 @@ export function createLineCoverage({ clear, resetErrors, rebuild, + reload, setSurface, recolorSequence, sequenceCount, diff --git a/src/layers/streetLevel/providers/kartaview/decode.js b/src/layers/streetLevel/providers/kartaview/decode.js index b5488af00..bee93d76c 100644 --- a/src/layers/streetLevel/providers/kartaview/decode.js +++ b/src/layers/streetLevel/providers/kartaview/decode.js @@ -1,19 +1,19 @@ +import { MASK_KEEP_SHARE, MASK_SAMPLE_PX } from './policy.js'; + /** More than any real tile holds (a dense z13 tile has ~4,700 segments). */ export const MAX_TILE_SEGMENTS = 20_000; /** A chained line stops growing here and the next one starts. */ export const MAX_LINE_POINTS = 5_000; /** - * Decode a KartaView coverage tile (`{lines: [{track: [{lat, lng}]}]}`) into - * line records for the shared coverage. The tile's lines are OSM way - * segments with photos; segments that meet end to start are chained, which - * cuts the line count about fourfold. Lines carry no sequence, date or - * projection, so nothing can filter them. + * The way segments of a KartaView coverage tile + * (`{lines: [{track: [{lat, lng}]}]}`), as [lon, lat] point lists. Lines + * carry no sequence, date or projection: a filter thins them through a mask + * (see `maskedSegments`). * @param {object} payload - * @param {{x: number, y: number, z: number}} address - * @returns {{sequences: Array<{id: string, parts: Array>, capturedAt: number, isPano: boolean}>, overview: Array}} + * @returns {Array>} */ -export function decodeCoverageTile(payload, { x, y, z }) { +export function tileSegments(payload) { // Not cached as empty: an unreadable tile is asked for again. if (!Array.isArray(payload?.lines)) throw new Error('KartaView sent an unreadable coverage tile'); @@ -31,6 +31,18 @@ export function decodeCoverageTile(payload, { x, y, z }) { } if (points.length >= 2) segments.push(points); } + return segments; +} + +/** + * Segments chained into line records for the shared coverage: segments + * that meet end to start are joined, which cuts the line count about + * fourfold. + * @param {Array>} segments + * @param {{x: number, y: number, z: number}} address + * @returns {{sequences: Array<{id: string, parts: Array>, capturedAt: number, isPano: boolean}>, overview: Array}} + */ +export function chainSegments(segments, { x, y, z }) { const key = ([lon, lat]) => `${lon},${lat}`; const startingAt = new Map(); segments.forEach((segment, index) => { @@ -74,3 +86,91 @@ export function decodeCoverageTile(payload, { x, y, z }) { }); return { sequences, overview: [] }; } + +/** Decode a KartaView coverage tile into the shared coverage's line records. */ +export function decodeCoverageTile(payload, address) { + return chainSegments(tileSegments(payload), address); +} + +/** + * The segments a filtered PNG tile of the same address draws over. The PNG + * draws the same ways a few pixels wide (on a transparent ground): a + * segment is kept when at least half the points sampled along it have a + * drawn pixel within one pixel. Segments either lie on it or miss it + * almost entirely (Jakarta, 360° only: 332 of 1,041 kept, 676 missed, 33 + * between). A segment with no point on the tile is not this tile's to keep. + * @param {Array>} segments + * @param {{width: number, height: number, alpha: ArrayLike}} mask one alpha byte per pixel + * @param {{x: number, y: number, z: number}} address + */ +export function maskedSegments(segments, mask, { x, y, z }) { + const { width, height, alpha } = mask; + const n = 2 ** z; + const toPixel = ([lon, lat]) => { + const sin = Math.sin((lat * Math.PI) / 180); + const my = 0.5 - Math.log((1 + sin) / (1 - sin)) / (4 * Math.PI); + return [(((lon + 180) / 360) * n - x) * width, (my * n - y) * height]; + }; + const drawnNear = (px, py) => { + const cx = Math.floor(px); + const cy = Math.floor(py); + for (let dy = -1; dy <= 1; dy++) + for (let dx = -1; dx <= 1; dx++) { + const X = cx + dx; + const Y = cy + dy; + if (X < 0 || Y < 0 || X >= width || Y >= height) continue; + if (alpha[Y * width + X] > 0) return true; + } + return false; + }; + return segments.filter((segment) => { + let on = 0; + let tested = 0; + let [ax, ay] = toPixel(segment[0]); + for (let i = 1; i < segment.length; i++) { + const [bx, by] = toPixel(segment[i]); + const steps = Math.max( + 1, + Math.ceil(Math.hypot(bx - ax, by - ay) / MASK_SAMPLE_PX), + ); + // Each point once: a segment's next stretch starts where this one ends. + for (let j = i === 1 ? 0 : 1; j <= steps; j++) { + const px = ax + ((bx - ax) * j) / steps; + const py = ay + ((by - ay) * j) / steps; + if (px < 0 || py < 0 || px >= width || py >= height) continue; + tested++; + if (drawnNear(px, py)) on++; + } + ax = bx; + ay = by; + } + return tested > 0 && on / tested >= MASK_KEEP_SHARE; + }); +} + +/** + * A PNG mask's alpha, one byte per pixel, decoded off the main thread + * where the browser can. + * @param {Uint8Array} bytes the PNG + * @returns {Promise<{width: number, height: number, alpha: Uint8Array}>} + */ +export async function readMaskImage(bytes) { + const bitmap = await createImageBitmap( + new Blob([bytes], { type: 'image/png' }), + ); + try { + const { width, height } = bitmap; + const canvas = + typeof OffscreenCanvas === 'function' + ? new OffscreenCanvas(width, height) + : Object.assign(document.createElement('canvas'), { width, height }); + const context = canvas.getContext('2d', { willReadFrequently: true }); + context.drawImage(bitmap, 0, 0); + const rgba = context.getImageData(0, 0, width, height).data; + const alpha = new Uint8Array(width * height); + for (let i = 0; i < alpha.length; i++) alpha[i] = rgba[i * 4 + 3]; + return { width, height, alpha }; + } finally { + bitmap.close?.(); + } +} diff --git a/src/layers/streetLevel/providers/kartaview/decode.test.mjs b/src/layers/streetLevel/providers/kartaview/decode.test.mjs index e6cc498d6..619719008 100644 --- a/src/layers/streetLevel/providers/kartaview/decode.test.mjs +++ b/src/layers/streetLevel/providers/kartaview/decode.test.mjs @@ -3,7 +3,10 @@ import test from 'node:test'; import { MAX_LINE_POINTS, MAX_TILE_SEGMENTS, + chainSegments, decodeCoverageTile, + maskedSegments, + tileSegments, } from './decode.js'; const at = (lng, lat) => ({ lat: String(lat), lng: String(lng) }); @@ -126,3 +129,59 @@ test('a tile is capped in segments and in points per line', () => { sequences.length >= Math.floor(MAX_TILE_SEGMENTS / MAX_LINE_POINTS), ); }); + +/** [lon, lat] at pixel (px, py) of a 512 px mask of `tile` (Web Mercator). */ +function lonLatAt(px, py, { x, y, z } = TILE, size = 512) { + const n = 2 ** z; + const lon = ((x + px / size) / n) * 360 - 180; + const my = Math.PI * (1 - (2 * (y + py / size)) / n); + const lat = (Math.atan(Math.sinh(my)) * 180) / Math.PI; + return [lon, lat]; +} + +/** A 512 px mask drawn on the given rows. */ +function rowsMask(rows) { + const alpha = new Uint8Array(512 * 512); + for (const [from, to] of rows) alpha.fill(255, from * 512, (to + 1) * 512); + return { width: 512, height: 512, alpha }; +} + +test('a mask keeps the segments it draws over, chained as before', () => { + const along = (row, from = 40, to = 470) => [ + lonLatAt(from, row), + lonLatAt(to, row), + ]; + const segments = [ + along(100), // drawn at row 100 + along(101.6), // a pixel off: still drawn within one pixel + along(300), // not drawn + // Two thirds along the drawn row, then off it: kept (at least half). + [lonLatAt(40, 100), lonLatAt(400, 100), lonLatAt(400, 280)], + // A third along it, then off: dropped. + [lonLatAt(300, 100), lonLatAt(400, 100), lonLatAt(400, 300)], + // Off the tile entirely: not this tile's to keep. + [lonLatAt(-200, 100), lonLatAt(-20, 100)], + ]; + const kept = maskedSegments(segments, rowsMask([[99, 100]]), TILE); + assert.deepEqual(kept, [segments[0], segments[1], segments[3]]); + assert.deepEqual(maskedSegments(segments, rowsMask([]), TILE), []); + // A full mask keeps every segment that is on the tile. + assert.equal(maskedSegments(segments, rowsMask([[0, 511]]), TILE).length, 5); +}); + +test('decoding is segments then chaining: the same lines either way', () => { + const payload = { + lines: [ + { track: [at(1, 1), at(2, 1)] }, + { track: [at(2, 1), at(3, 1)] }, + { track: [at(5, 5), at(6, 6)] }, + ], + }; + const segments = tileSegments(payload); + assert.equal(segments.length, 3); + assert.deepEqual( + chainSegments(segments, TILE), + decodeCoverageTile(payload, TILE), + ); + assert.throws(() => tileSegments({}), /unreadable coverage tile/); +}); diff --git a/src/layers/streetLevel/providers/kartaview/index.js b/src/layers/streetLevel/providers/kartaview/index.js index da093112b..cf932462b 100644 --- a/src/layers/streetLevel/providers/kartaview/index.js +++ b/src/layers/streetLevel/providers/kartaview/index.js @@ -1,7 +1,13 @@ +import { passesImageryFilter } from '../../filter.js'; import { createLineCoverage } from '../../lineCoverage.js'; import { createSequenceCones, thinImages } from '../../sequenceCones.js'; import { metresBetween } from '../../view.js'; -import { decodeCoverageTile } from './decode.js'; +import { + chainSegments, + maskedSegments, + readMaskImage, + tileSegments, +} from './decode.js'; import { createKartaViewSource } from './source.js'; import { createKartaViewViewer } from './viewer.js'; import { @@ -11,6 +17,7 @@ import { COVERAGE_NOUN, COVERAGE_STREET_ZOOM_BELOW_M, COVERAGE_TUNING, + FILTERED_OUT_MESSAGE, KARTAVIEW_CREDIT_HTML, KARTAVIEW_LABEL, KARTAVIEW_NAME, @@ -27,13 +34,14 @@ import { assignCourses, cameraBearing, normalizePhoto, + tileFilterQuery, } from './policy.js'; /** Photos kept in memory by id (sequence pages fill it). */ const PHOTO_CACHE_SIZE = 4000; /** Sequence pages kept in memory. */ const PAGE_CACHE_SIZE = 12; -/** Decoded coverage tiles kept, and for how long. */ +/** Decoded coverage tiles kept, and for how long (masks too). */ const TILE_CACHE_SIZE = 24; const TILE_CACHE_TTL_MS = 10 * 60_000; @@ -77,11 +85,12 @@ export function createTileCache({ size = TILE_CACHE_SIZE, ttlMs = TILE_CACHE_TTL_MS, now = Date.now, + keyOf = (tile) => `${tile.z}/${tile.x}/${tile.y}`, }) { const kept = createLru(size); return { async get(tile, options) { - const key = `${tile.z}/${tile.x}/${tile.y}`; + const key = keyOf(tile); const entry = kept.get(key); if (entry && now() - entry.at < ttlMs) return entry.tile; const loaded = await load(tile, options); @@ -102,12 +111,13 @@ function pageOf(index) { * KartaView Street Level provider: keyless coverage lines from KartaView's * tile API, the photos nearest a clicked line, cones along the photo's * sequence, and a flat or 360° viewer with ‹ › along the sequence. - * @param {{source?: ReturnType, viewerOptions?: object}} [options] + * @param {{source?: ReturnType, viewerOptions?: object, readMask?: typeof readMaskImage}} [options] * @returns {import('../../registry.js').StreetLevelProvider} */ export function createKartaViewProvider({ source = createKartaViewSource(), viewerOptions = {}, + readMask = readMaskImage, } = {}) { return Object.freeze({ id: KARTAVIEW_PROVIDER_ID, @@ -120,8 +130,9 @@ export function createKartaViewProvider({ marker: Object.freeze({ kind: 'view-cone', color: COLORS.coverage }), credit: Object.freeze({ html: KARTAVIEW_CREDIT_HTML }), defaultOn: false, - // Coverage lines carry no date or projection: no filter could thin them. - filters: [], + // 360° / flat and SINCE, as Mapillary: KartaView filters the lines on + // its server (see tileFilterQuery), the photos by their own record. + filters: Object.freeze(['pano', 'since']), // Its lines are streets, not sequences: a selected drive shows as cones. highlightsSequence: false, coverageLabel: Object.freeze({ @@ -315,12 +326,30 @@ export function createKartaViewProvider({ return probe; } - // Decoded coverage tiles, kept a while: panning back asks nothing. + // A tile's way segments, kept a while: panning back, or a filter + // change, asks KartaView nothing more for them. const tiles = createTileCache({ load: async (tile, { signal }) => - decodeCoverageTile(await source.getTile(tile, { signal }), tile), + tileSegments(await source.getTile(tile, { signal })), }); - const fetchTile = (tile, options) => tiles.get(tile, options); + // A filtered tile's mask, by filter and tile. + const masks = createTileCache({ + load: async ({ query, ...tile }, { signal }) => + readMask(await source.getTileMask(tile, query, { signal })), + keyOf: ({ query, z, x, y }) => `${query}|${z}/${x}/${y}`, + }); + /** The tile's lines under the filter now: all, or the masked ones. */ + async function fetchTile(tile, options) { + const query = tileFilterQuery(context.getFilter()); + if (!query) return chainSegments(await tiles.get(tile, options), tile); + const [segments, mask] = await Promise.all([ + tiles.get(tile, options), + masks.get({ ...tile, query }, options), + ]); + return chainSegments(maskedSegments(segments, mask, tile), tile); + } + /** The query the drawn lines were loaded under. */ + let drawnQuery = tileFilterQuery(context.getFilter()); /** * The next showable photo `delta` along the sequence, or null at its @@ -361,6 +390,7 @@ export function createKartaViewProvider({ renderKey: 'kartaview-coverage', tuning: COVERAGE_TUNING, messages: { rateLimited: RATE_LIMITED_MESSAGE, tooHigh: TOO_HIGH_HINT }, + // Filtered on the way in (fetchTile): a line has no date to test. passes: () => true, }); @@ -395,8 +425,7 @@ export function createKartaViewProvider({ pickPrefix: PICK_PREFIX.image, spriteId: 'street-level:kartaview-cones', renderKey: 'kartaview-sequence', - // The filters are hidden for KartaView: one left set must not hide cones. - passes: () => true, + // The imagery filter applies to cones as to Mapillary's (capture date). }); /** Sequence id → its photo count from KartaView (asked once). */ @@ -491,6 +520,7 @@ export function createKartaViewProvider({ drawnPage = null; // Tens of MB of decoded lines are not kept for a source left off. tiles.clear(); + masks.clear(); }, destroy(cesiumViewer) { @@ -499,12 +529,23 @@ export function createKartaViewProvider({ coverage.resetErrors(); cones.destroy(cesiumViewer); tiles.clear(); + masks.clear(); }, refreshCoverage: () => coverage.refresh(), - /** No filter applies (see `filters`). */ - setFilter() {}, + /** + * The filter changed: the lines come from KartaView again under it + * (the old ones show meanwhile), the cones are drawn again. + */ + setFilter() { + const query = tileFilterQuery(context.getFilter()); + if (query !== drawnQuery) { + drawnQuery = query; + coverage.reload(); + } + cones.rerender(); + }, setSurface() { coverage.setSurface(); @@ -568,13 +609,21 @@ export function createKartaViewProvider({ * file at all, so the nearest few are tried in turn. */ async nearestImage({ lat, lon }, { signal } = {}) { - const records = await source.nearestPhotos({ lat, lon }, { signal }); + const filter = context.getFilter(); + const records = await source.nearestPhotos( + { lat, lon, filter }, + { signal }, + ); const near = []; + let filteredOut = 0; for (const record of records) { const photo = remember(record); if (!photo?.urls.view) continue; const metres = metresBetween({ lat, lon }, photo); - if (metres <= NEAREST_RADIUS_M) near.push({ photo, metres }); + if (metres > NEAREST_RADIUS_M) continue; + // KartaView filtered by upload date: hold it to the capture date. + if (!passesImageryFilter(photo, filter)) filteredOut++; + else near.push({ photo, metres }); } near.sort((a, b) => a.metres - b.metres); // Probed side by side (photo storage is not the API host): the @@ -586,6 +635,7 @@ export function createKartaViewProvider({ const hit = candidates.find((_, i) => answers[i]); if (hit) return hit.photo.id; if (near.length) throw new Error(ARCHIVED_MESSAGE); + if (filteredOut) throw new Error(FILTERED_OUT_MESSAGE); return null; }, diff --git a/src/layers/streetLevel/providers/kartaview/policy.js b/src/layers/streetLevel/providers/kartaview/policy.js index 9930f2c5e..d77353d6c 100644 --- a/src/layers/streetLevel/providers/kartaview/policy.js +++ b/src/layers/streetLevel/providers/kartaview/policy.js @@ -111,6 +111,71 @@ export const FLAT_FULL_SIZE = Object.freeze([2560, 2560]); export const KARTAVIEW_CREDIT_HTML = 'Street Level: imagery © KartaView contributors, CC BY-SA 4.0'; +/** + * The imagery filter (360° or flat, SINCE). KartaView filters coverage on + * its server only in its PNG tiles: its JSON tiles come back empty once a + * filter is asked for. So a filtered PNG tile is a mask, and a JSON line is + * kept where it lies on the mask's drawn pixels. + * + * KartaView's date filter is the upload date, never before the capture + * date: the lines are the streets with photos uploaded since the cut-off, + * and a photo opened from them is held to its capture date. + */ +export const MASK_TIMEOUT_MS = 20_000; +/** + * A filtered PNG tile is drawn on demand and a dense one can outlast + * KartaView's own 60 s (504); it keeps drawing, and has the tile a few + * seconds later. A timeout or a 5xx is asked again this many times in all. + */ +export const MASK_ATTEMPTS = 4; +export const MASK_RETRY_WAIT_MS = 4_000; +/** Real masks are tens of kilobytes. */ +export const MASK_MAX_BYTES = 2 * 1024 * 1024; +/** A segment is kept when this share of the points along it lies on the mask. */ +export const MASK_KEEP_SHARE = 0.5; +/** Points along a segment are tested this many mask pixels apart. */ +export const MASK_SAMPLE_PX = 2; +export const MASK_SLOW_MESSAGE = + 'KartaView did not filter this area in time — move the map to ask again'; +export const FILTERED_OUT_MESSAGE = + 'None of KartaView’s photos here pass the imagery filter'; + +/** The UTC calendar day of `ms`, as KartaView's startDate reads it. */ +function utcDay(ms) { + return new Date(ms).toISOString().slice(0, 10); +} + +const filtersSince = (filter) => + Number.isFinite(filter?.sinceMs) && filter.sinceMs > 0; + +/** + * The query of a filtered PNG coverage tile, or '' when nothing is + * filtered. The day, not the instant, so it is stable for a day (and keys + * the mask cache). + * @param {{pano: string, sinceMs: number|null}|null} filter resolved + */ +export function tileFilterQuery(filter) { + const params = new URLSearchParams(); + if (filter?.pano === 'pano') params.set('fieldOfView', '360'); + else if (filter?.pano === 'flat') params.set('fieldOfView', 'plane'); + if (filtersSince(filter)) params.set('startDate', utcDay(filter.sinceMs)); + return params.toString(); +} + +/** + * The photo search's own filter: projection and upload date (its + * `fieldOfView` is ignored). The capture date is checked on the answers. + * @param {{pano: string, sinceMs: number|null}|null} filter resolved + * @returns {Record} + */ +export function photoFilterParams(filter) { + const params = {}; + if (filter?.pano === 'pano') params.projection = 'SPHERE'; + else if (filter?.pano === 'flat') params.projection = 'PLANE'; + if (filtersSince(filter)) params.startDate = utcDay(filter.sinceMs); + return params; +} + /** The cone and coverage noun: a line is a street with photos, not a sequence. */ export const COVERAGE_NOUN = 'streets'; export const LINE_CLICK_HINT = 'click a line for its nearest photo'; diff --git a/src/layers/streetLevel/providers/kartaview/policy.test.mjs b/src/layers/streetLevel/providers/kartaview/policy.test.mjs index 0698a9e8a..fe23b8f48 100644 --- a/src/layers/streetLevel/providers/kartaview/policy.test.mjs +++ b/src/layers/streetLevel/providers/kartaview/policy.test.mjs @@ -7,7 +7,9 @@ import { kartaViewPhotoUrl, kartaViewTimeMs, normalizePhoto, + photoFilterParams, resizedImageUrl, + tileFilterQuery, } from './policy.js'; /** A photo record as /2.0/photo returns it (trimmed to what is read). */ @@ -280,3 +282,28 @@ test('the camera looks along the drive: each photo’s course points to the next 1e-9, ); }); + +test('the imagery filter becomes KartaView’s tile query and photo-search params', () => { + // 2026-03-05 23:30 UTC: the cut-off day is the UTC one. + const sinceMs = Date.UTC(2026, 2, 5, 23, 30); + assert.equal(tileFilterQuery(null), ''); + assert.equal(tileFilterQuery({ pano: 'all', sinceMs: null }), ''); + assert.equal( + tileFilterQuery({ pano: 'pano', sinceMs: null }), + 'fieldOfView=360', + ); + assert.equal( + tileFilterQuery({ pano: 'flat', sinceMs }), + 'fieldOfView=plane&startDate=2026-03-05', + ); + assert.equal(tileFilterQuery({ pano: 'all', sinceMs: 0 }), ''); + // The search ignores fieldOfView; its projection filters instead. + assert.deepEqual(photoFilterParams({ pano: 'all', sinceMs: null }), {}); + assert.deepEqual(photoFilterParams({ pano: 'pano', sinceMs }), { + projection: 'SPHERE', + startDate: '2026-03-05', + }); + assert.deepEqual(photoFilterParams({ pano: 'flat', sinceMs: null }), { + projection: 'PLANE', + }); +}); diff --git a/src/layers/streetLevel/providers/kartaview/provider.test.mjs b/src/layers/streetLevel/providers/kartaview/provider.test.mjs index ea82db277..013a510f7 100644 --- a/src/layers/streetLevel/providers/kartaview/provider.test.mjs +++ b/src/layers/streetLevel/providers/kartaview/provider.test.mjs @@ -6,6 +6,7 @@ import { kartaViewZoomFor, } from './index.js'; import { + FILTERED_OUT_MESSAGE, NO_COVERAGE_HINT, SEQUENCE_PAGE_SIZE, TOO_HIGH_HINT, @@ -36,13 +37,23 @@ function photoRecord(id, { metres = 0, seq = '14010', index = 0 } = {}) { }; } +/** KartaView's shotDate for `ms` ago. */ +const shotAgo = (ms) => + new Date(Date.now() - ms).toISOString().replace('T', ' ').slice(0, 23); + /** * A source over `sequences` (id → indexes present, in page order: KartaView * pages follow photo ids, which only roughly follow the index), recording * calls. Photos whose id is in `archived` answer 409. */ function fakeSource({ nearest = [], sequences = {}, archived = [] } = {}) { - const calls = { nearest: 0, photo: [], pages: [], probes: [] }; + const calls = { + nearest: 0, + nearestFilters: [], + photo: [], + pages: [], + probes: [], + }; return { calls, probeImage: async (url) => { @@ -50,8 +61,9 @@ function fakeSource({ nearest = [], sequences = {}, archived = [] } = {}) { return !archived.some((id) => url.includes(`/${id}.jpg`)); }, getTile: async () => ({ lines: [] }), - nearestPhotos: async () => { + nearestPhotos: async ({ filter }) => { calls.nearest++; + calls.nearestFilters.push(filter); return nearest; }, getPhoto: async (id) => { @@ -80,12 +92,13 @@ function fakeSource({ nearest = [], sequences = {}, archived = [] } = {}) { } function fakeContext({ filter = { pano: 'all', sinceMs: null } } = {}) { - const actions = { opened: [], nearest: [], instance: null }; + // `actions.filter` is the resolved filter now; a test may change it. + const actions = { opened: [], nearest: [], instance: null, filter }; return { actions, context: { services: {}, - getFilter: () => filter, + getFilter: () => actions.filter, isActive: () => true, groundCaster: null, meshSampler: null, @@ -105,11 +118,12 @@ function fakeContext({ filter = { pano: 'all', sinceMs: null } } = {}) { }; } -function setup(sourceOptions, contextOptions) { +function setup(sourceOptions, contextOptions, { readMask } = {}) { const source = fakeSource(sourceOptions); const loads = []; const definition = createKartaViewProvider({ source, + readMask, viewerOptions: { load: async (url) => { loads.push(url); @@ -128,12 +142,12 @@ function setup(sourceOptions, contextOptions) { const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); -test('KartaView registers keyless, off by default, with no filters and its own wording', () => { +test('KartaView registers keyless, off by default, with Mapillary’s filters and its own wording', () => { const { definition } = setup(); assert.equal(definition.id, 'kartaview'); assert.equal(definition.requiresKeyId, null); assert.equal(definition.defaultOn, false); - assert.deepEqual(definition.filters, []); + assert.deepEqual(definition.filters, ['pano', 'since']); assert.equal( definition.highlightsSequence, false, @@ -195,6 +209,38 @@ test('the nearest photo is the closest within 50 m, and opening it asks nothing ); }); +test('the nearest photo passes the imagery filter by its capture date; none passing says so', async () => { + const since = Date.now() - 86_400_000; + const filter = { pano: 'pano', sinceMs: since }; + // KartaView filters the search by upload date: an old capture uploaded + // today still comes back, and is held to its capture date here. + const pano = (id, metres, shot) => ({ + ...photoRecord(id, { metres }), + projection: 'SPHERE', + fieldOfView: '360', + shotDate: shot, + }); + const { instance, source } = setup( + { + nearest: [ + pano('old', 5, '2016-08-23 20:17:47.000'), + pano('new', 20, shotAgo(3_600_000)), + ], + }, + { filter }, + ); + const at = { lat: 42.3314, lon: -83.0458 }; + assert.equal(await instance.nearestImage(at), 'new'); + assert.deepEqual(source.calls.nearestFilters, [filter]); + const old = setup( + { nearest: [pano('old', 5, '2016-08-23 20:17:47.000')] }, + { filter }, + ); + await assert.rejects(old.instance.nearestImage(at), { + message: FILTERED_OUT_MESSAGE, + }); +}); + test('a cone opens its photo; a line opens the photo nearest the click', () => { const { instance, actions } = setup(); actions.instance = null; @@ -617,34 +663,72 @@ test('the cones re-centre when ‹ › walk off the page they were drawn from', ); }); -test('a leftover imagery filter hides no cones: flat, undated photos still draw', async (t) => { +test('the imagery filter thins the cones by each photo’s capture date and projection', async (t) => { const env = await mounted( { sequences: { s: [0, 1, 2, 3] } }, { filter: { pano: 'pano', sinceMs: Date.now() - 86_400_000 } }, ); - // Undated records: no capture time to pass a "since" filter with. - const undated = (record) => ({ ...record, shotDate: '', dateAdded: '' }); + // 0 and 1: 360° photos taken an hour ago; 2 and 3: flat ones from 2016. + const shaped = (record) => + Number(record.sequenceIndex) < 2 + ? { + ...record, + projection: 'SPHERE', + fieldOfView: '360', + shotDate: shotAgo(3_600_000), + } + : record; const getPhoto = env.source.getPhoto; - env.source.getPhoto = async (id) => undated(await getPhoto(id)); + env.source.getPhoto = async (id) => shaped(await getPhoto(id)); const getPage = env.source.getSequencePhotos; env.source.getSequencePhotos = async (...args) => { const page = await getPage(...args); - return { ...page, photos: page.photos.map(undated) }; + return { ...page, photos: page.photos.map(shaped) }; }; const cones = await withCones(t, env); await env.instance.viewer.open('s-1'); await env.instance.selectSequence('s'); assert.ok(await until(() => !env.instance.sequenceStats().loading)); - assert.equal(env.instance.sequenceStats().images, 4); - assert.equal(cones.count(), 4, 'every cone drawn'); + assert.equal(env.instance.sequenceStats().images, 4, 'the drive is whole'); + assert.equal(cones.count(), 2, 'the recent 360° photos'); + assert.deepEqual(cones(), [0, 1]); + env.actions.filter = { pano: 'flat', sinceMs: null }; + env.instance.setFilter(); + assert.deepEqual(cones(), [2, 3], 'the flat ones'); + env.actions.filter = { pano: 'all', sinceMs: null }; + env.instance.setFilter(); + assert.equal(cones.count(), 4, 'every cone, unfiltered'); }); /** * The provider drawing coverage on a stand-in viewer above one z14 tile. * `lines` are the tile's tracks as [lon, lat] pairs; `getTile` may be held. */ -function coverageSetup(t, { lines = [], filter, height = 900 } = {}) { - const env = setup({}, { filter }); +function coverageSetup( + t, + { lines = [], filter, height = 900, drawnRows = () => [] } = {}, +) { + // A filtered tile's mask: 512 px square, drawn on the rows + // `drawnRows(query)` lists as [from, to]. + const env = setup( + {}, + { filter }, + { + readMask: async ({ query }) => { + const alpha = new Uint8Array(512 * 512); + for (const [from, to] of drawnRows(query)) + alpha.fill(255, from * 512, (to + 1) * 512); + return { width: 512, height: 512, alpha }; + }, + }, + ); + const masks = []; + let maskHold = null; + env.source.getTileMask = async (tile, query) => { + masks.push(query); + await maskHold; + return { query }; + }; const centre = tileCentre(-83.0458, 42.3314); const viewer = coverageViewer( { lon: centre.lon, lat: centre.lat, height }, @@ -671,6 +755,15 @@ function coverageSetup(t, { lines = [], filter, height = 900 } = {}) { viewer, centre, tiles, + masks, + holdMasks() { + let release; + maskHold = new Promise((resolve) => (release = resolve)); + return () => { + maskHold = null; + release(); + }; + }, holdTiles() { let release; hold = new Promise((resolve) => (release = resolve)); @@ -682,20 +775,59 @@ function coverageSetup(t, { lines = [], filter, height = 900 } = {}) { }; } -test('a leftover imagery filter hides no KartaView line', async (t) => { - const { instance, viewer, centre } = coverageSetup(t, { - filter: { pano: 'pano', sinceMs: Date.now() - 86_400_000 }, - lines: [ - [ - [-83.0458 - 0.001, 42.3314], - [-83.0458 + 0.001, 42.3314], - ], - ], +test('a filter thins the lines to those its PNG mask draws; without one they all show', async (t) => { + const { lon, lat } = tileCentre(-83.0458, 42.3314); + // Across the tile's middle row (mask row ~256), and a quarter up (~130). + const across = (at) => [ + [lon - 0.001, at], + [lon + 0.001, at], + ]; + const env = coverageSetup(t, { + filter: { pano: 'pano', sinceMs: null }, + lines: [across(lat), across(lat + 0.004)], + // 360° photos lie on the middle line only. + drawnRows: (query) => (query === 'fieldOfView=360' ? [[254, 258]] : []), }); - assert.ok(centre); - instance.activate(viewer); - assert.ok(await until(() => instance.coverageStats().count === 1)); - assert.equal(instance.coverageStats().hint, ''); + env.instance.activate(env.viewer); + assert.ok(await until(() => env.instance.coverageStats().count === 1)); + assert.deepEqual(env.masks, ['fieldOfView=360']); + // Unfiltered: every line, and the tile's lines are not asked for again. + env.actions.filter = { pano: 'all', sinceMs: null }; + env.instance.setFilter(); + assert.ok(await until(() => env.instance.coverageStats().count === 2)); + assert.equal(env.tiles.length, 1, 'the segments are kept'); + // SINCE a day: uploaded since yesterday (UTC) — none here. The lines + // drawn now stay on the globe while KartaView draws the filtered tile. + const since = Date.now() - 86_400_000; + const release = env.holdMasks(); + env.actions.filter = { pano: 'all', sinceMs: since }; + env.instance.setFilter(); + assert.ok(await until(() => env.instance.coverageStats().loading)); + assert.ok( + env.viewer.scene.groundPrimitives.items.size > 0, + 'the unfiltered lines show meanwhile', + ); + release(); + assert.ok( + await until( + () => + !env.instance.coverageStats().loading && + env.instance.coverageStats().count === 0, + ), + ); + assert.equal( + env.masks.at(-1), + `startDate=${new Date(since).toISOString().slice(0, 10)}`, + ); + assert.equal(env.instance.coverageStats().hint, NO_COVERAGE_HINT); + assert.ok( + await until(() => env.viewer.scene.groundPrimitives.items.size === 0), + 'and go once it is in', + ); + // The same filter again asks KartaView for nothing. + env.instance.setFilter(); + for (let i = 0; i < 5; i++) await settle(); + assert.equal(env.masks.length, 2); }); test('coverage hints: zoom in from too high, “no photos” only once an empty view has loaded', async (t) => { diff --git a/src/layers/streetLevel/providers/kartaview/source.js b/src/layers/streetLevel/providers/kartaview/source.js index 4283cf859..ddf21817f 100644 --- a/src/layers/streetLevel/providers/kartaview/source.js +++ b/src/layers/streetLevel/providers/kartaview/source.js @@ -2,6 +2,11 @@ import { IMAGE_PROBE_TIMEOUT_MS, MAX_RESPONSE_BYTES, KARTAVIEW_API_HOST, + MASK_ATTEMPTS, + MASK_MAX_BYTES, + MASK_RETRY_WAIT_MS, + MASK_SLOW_MESSAGE, + MASK_TIMEOUT_MS, NEAREST_LIMIT, NEAREST_SEARCH_ZOOM, PAGE_CONCURRENCY, @@ -14,6 +19,7 @@ import { PHOTO_API_TIMEOUT_MS, TILE_CONCURRENCY, TILE_TIMEOUT_MS, + photoFilterParams, } from './policy.js'; /** @@ -121,19 +127,32 @@ export function createLimiter(limit, { lifo = false } = {}) { * waits behind slow tiles or pages. The photo API answers 429 to more than * about three requests at once; a 429 (and a lookup that timed out) is * retried. - * @param {{fetchImpl?: typeof fetch, apiHost?: string, retryBaseMs?: number}} [options] + * @param {{fetchImpl?: typeof fetch, apiHost?: string, retryBaseMs?: number, maskRetryMs?: number}} [options] */ export function createKartaViewSource({ fetchImpl = (...args) => globalThis.fetch(...args), apiHost = KARTAVIEW_API_HOST, retryBaseMs = PHOTO_API_RETRY_BASE_MS, + maskRetryMs = MASK_RETRY_WAIT_MS, } = {}) { const limiter = createLimiter(PHOTO_API_CONCURRENCY); const tileLimiter = createLimiter(TILE_CONCURRENCY); const pageLimiter = createLimiter(PAGE_CONCURRENCY, { lifo: true }); /** GET JSON within `timeoutMs`; the caller's abort wins over the timeout. */ - async function getJson(url, { signal, timeoutMs }) { + function getJson(url, { signal, timeoutMs }) { + return fetchBody(url, { signal, timeoutMs, as: 'json' }); + } + + /** + * GET a body within `timeoutMs`, at most `maxBytes` of it, as parsed JSON + * or (`as: 'bytes'`) a Uint8Array; the caller's abort wins over the + * timeout. + */ + async function fetchBody( + url, + { signal, timeoutMs, as = 'json', maxBytes = MAX_RESPONSE_BYTES }, + ) { const controller = new AbortController(); const onAbort = () => controller.abort(signal.reason); if (signal?.aborted) throw signal.reason; @@ -164,12 +183,14 @@ export function createKartaViewSource({ new KartaViewSourceError('KartaView sent too much data', { status: response.status, }); - if (length > MAX_RESPONSE_BYTES) throw tooMuch(); + if (length > maxBytes) throw tooMuch(); // KartaView answers chunked, without a length: count while reading. const reader = response.body?.getReader?.(); if (!reader) { read = true; try { + if (as === 'bytes') + return new Uint8Array(await response.arrayBuffer()); return await response.json(); } catch (error) { if (controller.signal.aborted) throw error; @@ -185,7 +206,7 @@ export function createKartaViewSource({ const { done, value } = await reader.read(); if (done) break; bytes += value.byteLength; - if (bytes > MAX_RESPONSE_BYTES) { + if (bytes > maxBytes) { reader.cancel().catch(() => {}); throw tooMuch(); } @@ -197,6 +218,7 @@ export function createKartaViewSource({ body.set(chunk, offset); offset += chunk.byteLength; } + if (as === 'bytes') return body; try { return JSON.parse(new TextDecoder().decode(body)); } catch { @@ -304,6 +326,41 @@ export function createKartaViewSource({ } }, + /** + * A filtered coverage tile as PNG bytes, `query` from tileFilterQuery + * (KartaView filters only its PNG tiles). It draws a filtered tile on + * demand; a dense one can outlast its own 60 s (504) and still be drawn, + * so a timeout or a 5xx is asked again a few seconds later. + */ + async getTileMask({ x, y, z }, query, { signal } = {}) { + const url = `${apiHost}/2.0/sequence/tiles/${x}/${y}/${z}.png?${query}`; + for (let attempt = 1; ; attempt++) { + try { + // Outside the gate between tries: a wait holds no connection. + return await tileLimiter.run( + () => + fetchBody(url, { + signal, + timeoutMs: MASK_TIMEOUT_MS, + as: 'bytes', + maxBytes: MASK_MAX_BYTES, + }), + { signal }, + ); + } catch (error) { + if (signal?.aborted) throw error; + const drawing = + error?.timedOut === true || [502, 503, 504].includes(error?.status); + if (!drawing) throw error; + if (attempt >= MASK_ATTEMPTS) + throw new KartaViewSourceError(MASK_SLOW_MESSAGE, { + timedOut: true, + }); + await wait(maskRetryMs, signal); + } + } + }, + /** * Whether a photo file is there: true, false when it is gone (archived * photos answer 409, and a HEAD says 200 regardless), or null when the @@ -335,8 +392,11 @@ export function createKartaViewSource({ } }, - /** Photo records around a point, nearest first. */ - async nearestPhotos({ lat, lon }, { signal } = {}) { + /** + * Photo records around a point, nearest first; with a resolved imagery + * `filter`, only those of its projection uploaded since its cut-off. + */ + async nearestPhotos({ lat, lon, filter = null }, { signal } = {}) { const result = await photoApi( '/2.0/photo/', { @@ -344,6 +404,7 @@ export function createKartaViewSource({ lng: lon.toFixed(7), zoomLevel: String(NEAREST_SEARCH_ZOOM), itemsPerPage: String(NEAREST_LIMIT), + ...photoFilterParams(filter), }, { signal }, ); diff --git a/src/layers/streetLevel/providers/kartaview/source.test.mjs b/src/layers/streetLevel/providers/kartaview/source.test.mjs index 4aed435c7..6dcd393a4 100644 --- a/src/layers/streetLevel/providers/kartaview/source.test.mjs +++ b/src/layers/streetLevel/providers/kartaview/source.test.mjs @@ -6,6 +6,8 @@ import { createLimiter, } from './source.js'; import { + MASK_ATTEMPTS, + MASK_SLOW_MESSAGE, NEAREST_LIMIT, NEAREST_SEARCH_ZOOM, PHOTO_API_CONCURRENCY, @@ -654,3 +656,73 @@ test('an answer turned away unread (an error, a 429, a length past the cap) lets assert.equal(seen.cancelled, true); assert.equal(signals[1].aborted, false, 'a read answer is left alone'); }); + +test('a filtered tile comes as PNG bytes; KartaView still drawing it (504) is asked again, then given up', async () => { + const png = new Uint8Array([137, 80, 78, 71, 1, 2, 3]); + const bytesOf = () => ({ + ok: true, + status: 200, + headers: new Map(), + body: { + getReader: () => { + let given = false; + return { + read: async () => + given + ? { done: true } + : ((given = true), { done: false, value: png }), + cancel: async () => {}, + }; + }, + }, + }); + const urls = []; + let answers = [status(504), status(503), bytesOf()]; + const source = createKartaViewSource({ + maskRetryMs: 0, + fetchImpl: async (url) => { + urls.push(url); + return answers.shift(); + }, + }); + const tile = { x: 13054, y: 8474, z: 14 }; + assert.deepEqual(await source.getTileMask(tile, 'fieldOfView=360'), png); + assert.deepEqual(urls, [ + 'https://api.kartaview.org/2.0/sequence/tiles/13054/8474/14.png?fieldOfView=360', + 'https://api.kartaview.org/2.0/sequence/tiles/13054/8474/14.png?fieldOfView=360', + 'https://api.kartaview.org/2.0/sequence/tiles/13054/8474/14.png?fieldOfView=360', + ]); + // Still drawing after every try: given up, saying so. + urls.length = 0; + answers = Array.from({ length: 10 }, () => status(504)); + await assert.rejects(source.getTileMask(tile, 'fieldOfView=360'), { + message: MASK_SLOW_MESSAGE, + timedOut: true, + }); + assert.equal(urls.length, MASK_ATTEMPTS); + // A refusal is not asked again. + urls.length = 0; + answers = [status(400)]; + await assert.rejects(source.getTileMask(tile, 'fieldOfView=360'), /HTTP 400/); + assert.equal(urls.length, 1); +}); + +test('a filtered nearest search asks KartaView for the projection and upload date', async () => { + const urls = []; + const source = createKartaViewSource({ + fetchImpl: async (url) => { + urls.push(new URL(url)); + return ok(photoPayload([])); + }, + }); + await source.nearestPhotos({ + lat: 42.3314, + lon: -83.0458, + filter: { pano: 'pano', sinceMs: Date.UTC(2026, 0, 2, 12) }, + }); + await source.nearestPhotos({ lat: 42.3314, lon: -83.0458 }); + assert.equal(urls[0].searchParams.get('projection'), 'SPHERE'); + assert.equal(urls[0].searchParams.get('startDate'), '2026-01-02'); + assert.equal(urls[1].searchParams.has('projection'), false); + assert.equal(urls[1].searchParams.has('startDate'), false); +}); diff --git a/src/tooling/streetLevelQa.test.mjs b/src/tooling/streetLevelQa.test.mjs index 11680c5d3..6a29b4f69 100644 --- a/src/tooling/streetLevelQa.test.mjs +++ b/src/tooling/streetLevelQa.test.mjs @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import test from 'node:test'; +import { inflateSync } from 'node:zlib'; import { createApplicationStreetLevel } from '../app/layers/streetLevel.js'; import { fixtureTile, @@ -24,10 +25,15 @@ import { answerKartaViewRequest, describeKartaViewCall, KV_LINE, + KV_OLD_STREET, KV_SEQUENCE_ID, kartaViewPhotos, resetKartaViewFixture, } from '../../scripts/fixtures/street-level/kartaView.mjs'; +import { + maskedSegments, + tileSegments, +} from '../layers/streetLevel/providers/kartaview/decode.js'; import { lonToTileX, latToTileY } from '../layers/streetLevel/tileMath.js'; import { normalizePhoto, @@ -743,3 +749,79 @@ test('KartaView fixtures draw the drive, answer nearest first, and refuse the st 'GET api.kartaview.org/2.0/photo/{id}', ); }); + +/** The alpha of a fixture PNG (8-bit RGBA, unfiltered rows, as it writes them). */ +function fixturePngAlpha(bytes) { + const width = bytes.readUInt32BE(16); + const height = bytes.readUInt32BE(20); + const idat = []; + for (let at = 8; at < bytes.length;) { + const length = bytes.readUInt32BE(at); + const type = bytes.toString('ascii', at + 4, at + 8); + if (type === 'IDAT') idat.push(bytes.subarray(at + 8, at + 8 + length)); + at += 12 + length; + } + const rows = inflateSync(Buffer.concat(idat)); + const alpha = new Uint8Array(width * height); + for (let y = 0; y < height; y++) + for (let x = 0; x < width; x++) + alpha[y * width + x] = rows[y * (width * 4 + 1) + 1 + x * 4 + 3]; + return { width, height, alpha }; +} + +test('KartaView fixtures filter like KartaView: PNG tiles by view and upload date, the photo search by projection', () => { + const body = (answer) => JSON.parse(answer.body); + const z = 14; + const x = lonToTileX(KV_LINE.lon, z); + const y = latToTileY((KV_LINE.south + KV_LINE.north) / 2, z); + assert.equal(x, lonToTileX(KV_OLD_STREET.lon, z), 'both streets in a tile'); + const base = `https://api.kartaview.org/2.0/sequence/tiles/${x}/${y}/${z}`; + const segments = tileSegments( + body(answerKartaViewRequest({ method: 'GET', url: `${base}.json` })), + ); + const lons = (kept) => [...new Set(kept.map((segment) => segment[0][0]))]; + const keptFor = (query) => { + const answer = answerKartaViewRequest({ + method: 'GET', + url: `${base}.png?${query}`, + }); + assert.equal(answer.contentType, 'image/png'); + assert.equal(answer.known, true); + return lons( + maskedSegments(segments, fixturePngAlpha(answer.body), { x, y, z }), + ); + }; + assert.deepEqual( + lons(segments).sort(), + [KV_LINE.lon, KV_OLD_STREET.lon].sort(), + ); + assert.deepEqual(keptFor('fieldOfView=360'), [KV_LINE.lon]); + assert.deepEqual(keptFor('fieldOfView=plane').sort(), lons(segments).sort()); + const lastYear = new Date(Date.now() - 365 * 86_400_000) + .toISOString() + .slice(0, 10); + assert.deepEqual(keptFor(`startDate=${lastYear}`), [KV_LINE.lon]); + assert.deepEqual(keptFor('startDate=2099-01-01'), []); + assert.equal( + describeKartaViewCall('GET', new URL(`${base}.png?fieldOfView=360`)), + 'GET api.kartaview.org/2.0/sequence/tiles/{x}/{y}/{z}.png?fieldOfView', + ); + // The photo search: 360° only, and nothing uploaded after the drive. + resetKartaViewFixture(); + const search = (extra) => { + answerKartaViewRequest({ + method: 'GET', + url: 'https://api.kartaview.org/2.0/photo/?lat=0&lng=0', + }); + return body( + answerKartaViewRequest({ + method: 'GET', + url: `https://api.kartaview.org/2.0/photo/?lat=${KV_LINE.south + 0.002}&lng=${KV_LINE.lon}&zoomLevel=18&itemsPerPage=10${extra}`, + }), + ).result.data; + }; + const panos = search('&projection=SPHERE'); + assert.ok(panos.length > 0); + assert.ok(panos.every((record) => record.projection === 'SPHERE')); + assert.deepEqual(search('&startDate=2099-01-01'), []); +}); From c4ecb11f75e3638e11efa6b776761b5e10e7391b Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 14 Oct 2026 15:02:00 +0100 Subject: [PATCH 9/9] feat(street-level): KartaView's SINCE is its upload date, noted in the panel KartaView's server filters by upload date only, so SINCE goes by it throughout for KartaView: the lines (as before), the photo a click opens and the cones, the cut-off taken as its UTC day as KartaView's startDate (passesKartaViewFilter). Providers may declare sinceBasis: 'upload' (validated); while one is lit the panel shows "KartaView: by upload date" under the SINCE slider, read out with it. The gate checks the note, and step 13's picks get 60 s on a loaded machine. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 8 ++-- README.md | 2 +- docs/CURRENT-STATE.md | 16 ++++--- scripts/qa-street-level.mjs | 17 +++++-- src/layers/streetLevel/index.js | 1 + .../streetLevel/providers/kartaview/index.js | 11 +++-- .../streetLevel/providers/kartaview/policy.js | 29 ++++++++++-- .../providers/kartaview/policy.test.mjs | 39 ++++++++++++++++ .../providers/kartaview/provider.test.mjs | 46 ++++++++++++------- src/layers/streetLevel/registry.js | 9 ++++ src/layers/streetLevel/registry.test.mjs | 5 ++ src/layers/streetLevel/uiState.js | 14 ++++++ src/layers/streetLevel/uiState.test.mjs | 12 +++++ src/ui/streetLevelControls.js | 4 ++ src/ui/streetLevelControls.test.mjs | 22 +++++++++ src/ui/streetLevelPresentation.js | 1 + src/ui/styles/street-level.css | 11 +++++ src/ui/templates/layer-panels.html | 3 +- 18 files changed, 210 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cde9b6883..36c0f2bc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -116,9 +116,11 @@ cone pointing along the drive (KartaView's own compass headings are often 0 or far off). The IMAGERY (ALL / 360° / FLAT) and SINCE filters apply to KartaView as to Mapillary: its lines are filtered by KartaView's own - server (its filtered PNG tiles, as a mask over the JSON lines; SINCE goes - by upload date there), and the photo a click opens, and the cones, by - each photo's projection and capture date. + server (its filtered PNG tiles, as a mask over the JSON lines), and the + photo a click opens, and the cones, by each photo's projection and upload + date. KartaView's SINCE is the upload date (all its server can filter by); + a note under the slider says so while KartaView is lit (`sinceBasis: + 'upload'` in the provider contract). Mapillary's line coverage and sequence cones are now shared by both (`lineCoverage.js`, `sequenceCones.js`). diff --git a/README.md b/README.md index 9e914764b..f65d5e5d4 100644 --- a/README.md +++ b/README.md @@ -344,7 +344,7 @@ _The Space Missions layer replaying a Falcon 9 ascent — labeled `RECONSTRUCTED **Setup.** At [mapillary.com/dashboard/developers](https://www.mapillary.com/dashboard/developers), register an application with **Read** access and copy its **Client Token** (`MLY|…`). Paste it into **POWER UP → Mapillary**, or set `MAPILLARY_CLIENT_TOKEN` in `.env` and restart. Without it the panel reads **KEY REQUIRED**. **Google Street View** uses `GOOGLE_MAPS_API_KEY` with the **Maps JavaScript API** enabled for it in Google Cloud; its chip starts off, since every panorama is billed to the key. **KartaView** needs nothing: its public API is keyless. -**Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Each provider has a chip in its own colour (Mapillary green), and one is lit at a time: lighting one switches the others off. Mapillary, which is free, is lit by default. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary and KartaView; Street View panoramas are all 360°, so with Street View lit the filters and legend are hidden. Click a coverage line for its image cones and a cone to open the photo. Light **KARTAVIEW** (purple) and zoom in below about 6 km: its lines are the streets KartaView has photos of, and a click on one opens the photo nearest the click, flat or 360° (drag to look around), with cones along its drive; **‹ ›** or ← → step through the drive (Shift+← →, ↑ ↓ and + − look around a 360° photo; pinch zooms on a phone). Photos KartaView has archived are skipped, and where all are archived the panel says so. KartaView filters its lines on its own server, by upload date for **SINCE** (never before the capture date, so a line may hold only older captures: the click then says none of its photos pass), and a photo opened is held to its capture date; a filtered area KartaView has not drawn before can take several seconds, and the unfiltered lines show meanwhile. On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND**, **FIT / FILL** (Mapillary, and flat KartaView photos), and **FOLLOW** (the globe camera follows the photo's view; Google 3D map only). The caption links to the image on the provider's site, and the globe credits each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google", "© KartaView contributors, CC BY-SA 4.0"). KartaView's own photo pages fail for signed-out visitors, so its link opens the full-size photo. +**Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Each provider has a chip in its own colour (Mapillary green), and one is lit at a time: lighting one switches the others off. Mapillary, which is free, is lit by default. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary and KartaView; Street View panoramas are all 360°, so with Street View lit the filters and legend are hidden. Click a coverage line for its image cones and a cone to open the photo. Light **KARTAVIEW** (purple) and zoom in below about 6 km: its lines are the streets KartaView has photos of, and a click on one opens the photo nearest the click, flat or 360° (drag to look around), with cones along its drive; **‹ ›** or ← → step through the drive (Shift+← →, ↑ ↓ and + − look around a 360° photo; pinch zooms on a phone). Photos KartaView has archived are skipped, and where all are archived the panel says so. For KartaView, **SINCE** goes by upload date, as KartaView's own map does (it can filter by nothing else), and the panel notes it under the slider; KartaView filters its lines on its own server, and a filtered area KartaView has not drawn before can take several seconds, and the unfiltered lines show meanwhile. On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND**, **FIT / FILL** (Mapillary, and flat KartaView photos), and **FOLLOW** (the globe camera follows the photo's view; Google 3D map only). The caption links to the image on the provider's site, and the globe credits each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google", "© KartaView contributors, CC BY-SA 4.0"). KartaView's own photo pages fail for signed-out visitors, so its link opens the full-size photo. Drag the panel header to float the panel, and resize it from any edge. Double-click the header or collapse it to put it back in the rail. Share links carry the provider switches and filters (`0.m.0` Mapillary off, `0.m.0_0.r.google-1` Street View on, `0.m.0_0.r.google-0*kartaview-1` KartaView on, as the app writes it (`0.m.0_0.r.kartaview-1` works too; a link that leaves out `m` keeps Mapillary, the default, lit), `0.p.p` panoramas only, `0.s.365` the last year). To add a provider, implement the contract in `src/layers/streetLevel/registry.js` and register it in `src/app/layers/streetLevel.js`. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 89644fae5..cf29a4bc3 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3225,13 +3225,15 @@ until the new ones are in), keeps the JSON segments, and caches masks per filter and tile. A dense filtered PNG can take KartaView over a minute the first time (it answers 504 at 60 s and keeps drawing), so a timeout or 5xx is asked again 4 s later, four tries in all; then the panel says it did not -filter the area in time. KartaView's `startDate` is the upload date, never -before the capture date, so the lines are a superset of what passes; the -nearest lookup asks the photo API for the projection (`SPHERE`/`PLANE`; it -ignores `fieldOfView`) and upload date, then holds each photo to its capture -date (when photos are near but none passes, the panel says so), and the -cones pass the filter on each photo's projection and capture date, as -Mapillary's do. Tiles come uncompressed and +filter the area in time. KartaView's `startDate` is the upload date (its +server filters by nothing else), so SINCE goes by upload date throughout, +the cut-off taken as its UTC day (`passesKartaViewFilter`): the nearest +lookup asks the photo API for the projection (`SPHERE`/`PLANE`; it ignores +`fieldOfView`) and upload date and checks each answer the same way (when +photos are near but none passes, the panel says so), and the cones pass on +each photo's projection and upload date. The provider declares +`sinceBasis: 'upload'`, so while it is lit the panel shows "KartaView: by +upload date" under the SINCE slider (`sinceNote` in the UI state). Tiles come uncompressed and uncacheable, so the last 24 decoded tiles are kept for 10 minutes while KartaView is lit (`createTileCache`, dropped when it is switched off); one that does not answer in 20 s is asked once more, and one that is not a line diff --git a/scripts/qa-street-level.mjs b/scripts/qa-street-level.mjs index e5f76fe8c..c47a3f9f8 100644 --- a/scripts/qa-street-level.mjs +++ b/scripts/qa-street-level.mjs @@ -888,7 +888,7 @@ async function main() { await sleep(1000); } await step( - 'KartaView: its chip draws keyless coverage, credits KartaView and offers Mapillary’s filters', + 'KartaView: its chip draws keyless coverage, credits KartaView and offers Mapillary’s filters, SINCE noted as its upload date', async () => { await clickPanelControl(page, kvChip); assert.ok(await lit('kartaview'), 'KartaView alone is lit'); @@ -896,6 +896,14 @@ async function main() { const u = await ui(); assert.equal(u.keyRequired, false); assert.deepEqual(u.filterControls, { pano: true, since: true }); + // KartaView's SINCE is its upload date: the panel says so. + assert.equal(u.sinceNote, 'KartaView: by upload date'); + assert.equal( + await page.$eval('#sl-since-note', (node) => + node.hidden ? null : node.textContent, + ), + 'KartaView: by upload date', + ); assert.equal( await page.$eval(kvChip, (chip) => chip.style.getPropertyValue('--chip-color'), @@ -1613,9 +1621,10 @@ async function main() { ((i + 2) / 13) * (PHOTO_LINE.north - PHOTO_LINE.south), ]); // Ground primitives are built over a few frames: poll for a pick. + // A loaded machine renders a frame in seconds: 60 s, not 30. const handle = await page.waitForFunction( findPickPoint, - { timeout: 30_000, polling: 250 }, + { timeout: 60_000, polling: 250 }, { prefix: 'mly:seq:', id: target, @@ -1643,10 +1652,10 @@ async function main() { // The cones are on the globe: one of them picks as an image. Aim at // where each cone billboard is drawn (its ground position at // height 0 can sit a few pixels off, and every missed pick costs a - // render); the same 30 s as the line pick above. + // render); the same 60 s as the line pick above. const cone = await page.waitForFunction( findPickPoint, - { timeout: 30_000, polling: 250 }, + { timeout: 60_000, polling: 250 }, { prefix: 'mly:img:', id: null, diff --git a/src/layers/streetLevel/index.js b/src/layers/streetLevel/index.js index 54076cc90..97b864d40 100644 --- a/src/layers/streetLevel/index.js +++ b/src/layers/streetLevel/index.js @@ -438,6 +438,7 @@ export function createStreetLevelLayer({ color: entry.def.colors.coverage, coverageLabel: entry.def.coverageLabel || null, highlightsSequence: entry.def.highlightsSequence !== false, + sinceBasis: entry.def.sinceBasis === 'upload' ? 'upload' : 'capture', groundClick: entry.def.groundClick === true, filters: [...filtersOf(entry.def)], }; diff --git a/src/layers/streetLevel/providers/kartaview/index.js b/src/layers/streetLevel/providers/kartaview/index.js index cf932462b..ae05983a4 100644 --- a/src/layers/streetLevel/providers/kartaview/index.js +++ b/src/layers/streetLevel/providers/kartaview/index.js @@ -1,4 +1,3 @@ -import { passesImageryFilter } from '../../filter.js'; import { createLineCoverage } from '../../lineCoverage.js'; import { createSequenceCones, thinImages } from '../../sequenceCones.js'; import { metresBetween } from '../../view.js'; @@ -34,6 +33,7 @@ import { assignCourses, cameraBearing, normalizePhoto, + passesKartaViewFilter, tileFilterQuery, } from './policy.js'; @@ -133,6 +133,8 @@ export function createKartaViewProvider({ // 360° / flat and SINCE, as Mapillary: KartaView filters the lines on // its server (see tileFilterQuery), the photos by their own record. filters: Object.freeze(['pano', 'since']), + // SINCE is the upload date for KartaView: the panel notes it. + sinceBasis: 'upload', // Its lines are streets, not sequences: a selected drive shows as cones. highlightsSequence: false, coverageLabel: Object.freeze({ @@ -417,6 +419,7 @@ export function createKartaViewProvider({ lat: photo.lat, compassAngle: cameraBearing(photo) ?? 0, capturedAt: photo.capturedAt ?? 0, + uploadedAt: photo.uploadedAt, isPano: photo.isPano, })), ); @@ -425,7 +428,8 @@ export function createKartaViewProvider({ pickPrefix: PICK_PREFIX.image, spriteId: 'street-level:kartaview-cones', renderKey: 'kartaview-sequence', - // The imagery filter applies to cones as to Mapillary's (capture date). + // By projection and upload date, as the lines. + passes: passesKartaViewFilter, }); /** Sequence id → its photo count from KartaView (asked once). */ @@ -621,8 +625,7 @@ export function createKartaViewProvider({ if (!photo?.urls.view) continue; const metres = metresBetween({ lat, lon }, photo); if (metres > NEAREST_RADIUS_M) continue; - // KartaView filtered by upload date: hold it to the capture date. - if (!passesImageryFilter(photo, filter)) filteredOut++; + if (!passesKartaViewFilter(photo, filter)) filteredOut++; else near.push({ photo, metres }); } near.sort((a, b) => a.metres - b.metres); diff --git a/src/layers/streetLevel/providers/kartaview/policy.js b/src/layers/streetLevel/providers/kartaview/policy.js index d77353d6c..8d2c955ae 100644 --- a/src/layers/streetLevel/providers/kartaview/policy.js +++ b/src/layers/streetLevel/providers/kartaview/policy.js @@ -117,9 +117,9 @@ export const KARTAVIEW_CREDIT_HTML = * filter is asked for. So a filtered PNG tile is a mask, and a JSON line is * kept where it lies on the mask's drawn pixels. * - * KartaView's date filter is the upload date, never before the capture - * date: the lines are the streets with photos uploaded since the cut-off, - * and a photo opened from them is held to its capture date. + * KartaView's date filter is the upload date, and SINCE goes by it + * throughout (lines, the photo a click opens, the cones), as KartaView's own + * map does; the panel says so under the slider. */ export const MASK_TIMEOUT_MS = 20_000; /** @@ -139,6 +139,8 @@ export const MASK_SLOW_MESSAGE = 'KartaView did not filter this area in time — move the map to ask again'; export const FILTERED_OUT_MESSAGE = 'None of KartaView’s photos here pass the imagery filter'; +/** Under the SINCE slider while KartaView is lit. */ +export const SINCE_NOTE = 'KartaView: by upload date'; /** The UTC calendar day of `ms`, as KartaView's startDate reads it. */ function utcDay(ms) { @@ -148,6 +150,23 @@ function utcDay(ms) { const filtersSince = (filter) => Number.isFinite(filter?.sinceMs) && filter.sinceMs > 0; +const DAY_MS = 86_400_000; + +/** + * Whether a photo passes the resolved imagery filter by its projection and + * upload date, the cut-off taken as its UTC day, as KartaView's startDate. + * @param {{isPano: boolean, uploadedAt: number|null}} photo + * @param {{pano: string, sinceMs: number|null}|null} filter + */ +export function passesKartaViewFilter(photo, filter) { + if (!filter) return true; + if (filter.pano === 'pano' && !photo.isPano) return false; + if (filter.pano === 'flat' && photo.isPano) return false; + if (!filtersSince(filter)) return true; + const day = Math.floor(filter.sinceMs / DAY_MS) * DAY_MS; + return (photo.uploadedAt ?? 0) >= day; +} + /** * The query of a filtered PNG coverage tile, or '' when nothing is * filtered. The day, not the instant, so it is stable for a day (and keys @@ -164,7 +183,7 @@ export function tileFilterQuery(filter) { /** * The photo search's own filter: projection and upload date (its - * `fieldOfView` is ignored). The capture date is checked on the answers. + * `fieldOfView` is ignored). * @param {{pano: string, sinceMs: number|null}|null} filter resolved * @returns {Record} */ @@ -349,6 +368,8 @@ export function normalizePhoto(record) { isPano, capturedAt: kartaViewTimeMs(record.shotDate) ?? kartaViewTimeMs(record.dateAdded), + // KartaView's SINCE goes by this (its server filters by upload date). + uploadedAt: kartaViewTimeMs(record.dateAdded), urls: { // A 1280 px rendition; for a 360° photo it is the front view only. view: diff --git a/src/layers/streetLevel/providers/kartaview/policy.test.mjs b/src/layers/streetLevel/providers/kartaview/policy.test.mjs index fe23b8f48..82a68555e 100644 --- a/src/layers/streetLevel/providers/kartaview/policy.test.mjs +++ b/src/layers/streetLevel/providers/kartaview/policy.test.mjs @@ -7,6 +7,7 @@ import { kartaViewPhotoUrl, kartaViewTimeMs, normalizePhoto, + passesKartaViewFilter, photoFilterParams, resizedImageUrl, tileFilterQuery, @@ -307,3 +308,41 @@ test('the imagery filter becomes KartaView’s tile query and photo-search param projection: 'PLANE', }); }); + +test('KartaView’s SINCE is the upload date, from the start of the cut-off’s UTC day', () => { + const photo = normalizePhoto( + record({ + shotDate: '2016-08-23 20:17:47.000', + dateAdded: '2026-03-05 00:10:00', + }), + ); + assert.equal(photo.uploadedAt, Date.UTC(2026, 2, 5, 0, 10)); + const since = (iso) => ({ pano: 'all', sinceMs: Date.parse(iso) }); + // Taken in 2016, uploaded on the cut-off day: it passes, as KartaView's + // startDate=2026-03-05 keeps it. + assert.equal( + passesKartaViewFilter(photo, since('2026-03-05T23:00:00Z')), + true, + ); + assert.equal( + passesKartaViewFilter(photo, since('2026-03-06T00:00:00Z')), + false, + ); + assert.equal( + passesKartaViewFilter(photo, { pano: 'pano', sinceMs: null }), + false, + ); + assert.equal( + passesKartaViewFilter(photo, { pano: 'flat', sinceMs: null }), + true, + ); + assert.equal(passesKartaViewFilter(photo, null), true); + assert.equal( + passesKartaViewFilter( + { ...photo, uploadedAt: null }, + since('2020-01-01T00:00:00Z'), + ), + false, + 'no upload date passes no SINCE', + ); +}); diff --git a/src/layers/streetLevel/providers/kartaview/provider.test.mjs b/src/layers/streetLevel/providers/kartaview/provider.test.mjs index 013a510f7..706988904 100644 --- a/src/layers/streetLevel/providers/kartaview/provider.test.mjs +++ b/src/layers/streetLevel/providers/kartaview/provider.test.mjs @@ -148,6 +148,11 @@ test('KartaView registers keyless, off by default, with Mapillary’s filters an assert.equal(definition.requiresKeyId, null); assert.equal(definition.defaultOn, false); assert.deepEqual(definition.filters, ['pano', 'since']); + assert.equal( + definition.sinceBasis, + 'upload', + 'SINCE is KartaView’s upload date', + ); assert.equal( definition.highlightsSequence, false, @@ -209,36 +214,44 @@ test('the nearest photo is the closest within 50 m, and opening it asks nothing ); }); -test('the nearest photo passes the imagery filter by its capture date; none passing says so', async () => { +test('the nearest photo passes the imagery filter by its upload date, as KartaView filters; none passing says so', async () => { const since = Date.now() - 86_400_000; const filter = { pano: 'pano', sinceMs: since }; - // KartaView filters the search by upload date: an old capture uploaded - // today still comes back, and is held to its capture date here. - const pano = (id, metres, shot) => ({ + const pano = (id, metres, uploaded) => ({ ...photoRecord(id, { metres }), projection: 'SPHERE', fieldOfView: '360', - shotDate: shot, + shotDate: '2016-08-23 20:17:47.000', + dateAdded: uploaded, }); + // Both taken in 2016; only the second was uploaded since yesterday. const { instance, source } = setup( { nearest: [ - pano('old', 5, '2016-08-23 20:17:47.000'), - pano('new', 20, shotAgo(3_600_000)), + pano('stale', 5, '2016-08-24 04:01:07'), + pano('fresh', 20, shotAgo(3_600_000).slice(0, 19)), ], }, { filter }, ); const at = { lat: 42.3314, lon: -83.0458 }; - assert.equal(await instance.nearestImage(at), 'new'); + assert.equal(await instance.nearestImage(at), 'fresh'); assert.deepEqual(source.calls.nearestFilters, [filter]); - const old = setup( - { nearest: [pano('old', 5, '2016-08-23 20:17:47.000')] }, + const stale = setup( + { nearest: [pano('stale', 5, '2016-08-24 04:01:07')] }, { filter }, ); - await assert.rejects(old.instance.nearestImage(at), { + await assert.rejects(stale.instance.nearestImage(at), { message: FILTERED_OUT_MESSAGE, }); + // Uploaded earlier on the cut-off's UTC day: KartaView's startDate keeps + // it, and so does the check. + const day = new Date(since).toISOString().slice(0, 10); + const sameDay = setup( + { nearest: [pano('early', 5, `${day} 00:00:01`)] }, + { filter }, + ); + assert.equal(await sameDay.instance.nearestImage(at), 'early'); }); test('a cone opens its photo; a line opens the photo nearest the click', () => { @@ -663,21 +676,22 @@ test('the cones re-centre when ‹ › walk off the page they were drawn from', ); }); -test('the imagery filter thins the cones by each photo’s capture date and projection', async (t) => { +test('the imagery filter thins the cones by each photo’s upload date and projection', async (t) => { const env = await mounted( { sequences: { s: [0, 1, 2, 3] } }, { filter: { pano: 'pano', sinceMs: Date.now() - 86_400_000 } }, ); - // 0 and 1: 360° photos taken an hour ago; 2 and 3: flat ones from 2016. + // 0 and 1: 360° photos uploaded an hour ago (taken in 2016, as all); + // 2 and 3: flat ones uploaded in 2016. const shaped = (record) => Number(record.sequenceIndex) < 2 ? { ...record, projection: 'SPHERE', fieldOfView: '360', - shotDate: shotAgo(3_600_000), + dateAdded: shotAgo(3_600_000).slice(0, 19), } - : record; + : { ...record, dateAdded: '2016-08-24 04:01:07' }; const getPhoto = env.source.getPhoto; env.source.getPhoto = async (id) => shaped(await getPhoto(id)); const getPage = env.source.getSequencePhotos; @@ -690,7 +704,7 @@ test('the imagery filter thins the cones by each photo’s capture date and proj await env.instance.selectSequence('s'); assert.ok(await until(() => !env.instance.sequenceStats().loading)); assert.equal(env.instance.sequenceStats().images, 4, 'the drive is whole'); - assert.equal(cones.count(), 2, 'the recent 360° photos'); + assert.equal(cones.count(), 2, 'the 360° photos uploaded since yesterday'); assert.deepEqual(cones(), [0, 1]); env.actions.filter = { pano: 'flat', sinceMs: null }; env.instance.setFilter(); diff --git a/src/layers/streetLevel/registry.js b/src/layers/streetLevel/registry.js index 68fff8444..3451447e4 100644 --- a/src/layers/streetLevel/registry.js +++ b/src/layers/streetLevel/registry.js @@ -24,6 +24,7 @@ export function filtersOf(provider) { * @property {Array<'pano'|'since'>} [filters] Filters it honours (default both). * @property {{noun: string, action: string}} [coverageLabel] How the panel counts its lines and says what a click does (default: sequences, "click a line for its photos"). * @property {boolean} [highlightsSequence] Its lines show the selected sequence (default true; the legend's "Selected" needs one). + * @property {'capture'|'upload'} [sinceBasis] What SINCE compares (default 'capture'); 'upload' puts a note under the slider while the provider is lit. * @property {(context: ProviderContext) => ProviderInstance} create * * @typedef {object} ProviderContext Handed to `create()` once by the core. @@ -160,6 +161,14 @@ export function validateProviders(providers) { throw new TypeError( `Street Level provider ${label}: filters must list ${IMAGERY_FILTERS.join(' or ')}`, ); + if ( + provider.sinceBasis !== undefined && + provider.sinceBasis !== 'capture' && + provider.sinceBasis !== 'upload' + ) + throw new TypeError( + `Street Level provider ${label}: sinceBasis must be 'capture' or 'upload'`, + ); ids.add(provider.id); prefixes.push(provider.pickPrefix); } diff --git a/src/layers/streetLevel/registry.test.mjs b/src/layers/streetLevel/registry.test.mjs index 7981d9acf..1783d7f42 100644 --- a/src/layers/streetLevel/registry.test.mjs +++ b/src/layers/streetLevel/registry.test.mjs @@ -117,4 +117,9 @@ test('a provider may narrow the filters it honours and pick a view-cone marker', () => validateProviders([def({ marker: { kind: 'pegman' } })]), /marker must be/, ); + validateProviders([def({ sinceBasis: 'upload' })]); + assert.throws( + () => validateProviders([def({ sinceBasis: 'shot' })]), + /sinceBasis must be 'capture' or 'upload'/, + ); }); diff --git a/src/layers/streetLevel/uiState.js b/src/layers/streetLevel/uiState.js index cab0d12b1..4c4590b3e 100644 --- a/src/layers/streetLevel/uiState.js +++ b/src/layers/streetLevel/uiState.js @@ -19,6 +19,7 @@ import { COLORS } from './policy.js'; * @property {Array<'pano'|'since'>} [filters] * @property {{noun: string, action: string}|null} [coverageLabel] * @property {boolean} [highlightsSequence] Its lines turn cyan when their sequence is selected. + * @property {'capture'|'upload'} [sinceBasis] What its SINCE compares. */ /** @@ -72,6 +73,18 @@ export function filterControls(providers) { return { pano: honoured('pano'), since: honoured('since') }; } +/** + * The note under SINCE when a lit provider dates its imagery by upload, + * e.g. "KartaView: by upload date", or null. + * @param {Array} providers + */ +export function sinceNote(providers) { + const names = providers + .filter((p) => p.on && p.sinceBasis === 'upload') + .map((p) => p.name); + return names.length ? `${names.join(', ')}: by upload date` : null; +} + /** * The panel snapshot from core state and provider snapshots. The legend has * one swatch per active source that draws coverage lines, then Selected. @@ -112,6 +125,7 @@ export function composeUIState({ keyRejected, filter: { ...filter }, filterControls: filterControls(providers), + sinceNote: sinceNote(providers), providers: providers.map((p) => ({ ...p })), coverage, legend, diff --git a/src/layers/streetLevel/uiState.test.mjs b/src/layers/streetLevel/uiState.test.mjs index 813213c4a..21b5eefe0 100644 --- a/src/layers/streetLevel/uiState.test.mjs +++ b/src/layers/streetLevel/uiState.test.mjs @@ -128,6 +128,18 @@ test('the filter controls show only for a lit provider that honours them', () => ); }); +test('a lit provider that dates by upload puts a note under SINCE', () => { + const kartaView = provider({ + id: 'kartaview', + name: 'KartaView', + sinceBasis: 'upload', + }); + const note = (providers) => composeUIState({ ...base, providers }).sinceNote; + assert.equal(note([provider()]), null, 'Mapillary: capture date'); + assert.equal(note([kartaView]), 'KartaView: by upload date'); + assert.equal(note([{ ...kartaView, on: false }]), null, 'only while lit'); +}); + test('hint and error come from the first active provider that has one', () => { const ui = composeUIState({ ...base, diff --git a/src/ui/streetLevelControls.js b/src/ui/streetLevelControls.js index 7e6759199..db1a626f5 100644 --- a/src/ui/streetLevelControls.js +++ b/src/ui/streetLevelControls.js @@ -73,6 +73,7 @@ export class StreetLevelControls { sinceRow: byId('sl-since-row'), sinceRange: byId('sl-since'), sinceLabel: byId('sl-since-label'), + sinceNote: byId('sl-since-note'), legend: byId('sl-legend'), followBtn: byId('sl-follow-btn'), viewerWrap: byId('sl-viewer-wrap'), @@ -527,6 +528,9 @@ export class StreetLevelControls { const shows = view.filterControls || { pano: true, since: true }; setProp(el.panoRow, 'hidden', !shows.pano); setProp(el.sinceRow, 'hidden', !shows.since); + // A lit source that dates by upload says so under the slider. + setProp(el.sinceNote, 'textContent', view.sinceNote || ''); + setProp(el.sinceNote, 'hidden', !view.sinceNote); setProp(el.controls, 'hidden', !shows.pano && !shows.since); setProp(el.legend, 'hidden', view.legend.length === 0); this._renderRadios( diff --git a/src/ui/streetLevelControls.test.mjs b/src/ui/streetLevelControls.test.mjs index a27afd468..eb572781b 100644 --- a/src/ui/streetLevelControls.test.mjs +++ b/src/ui/streetLevelControls.test.mjs @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import fs from 'node:fs'; import test from 'node:test'; import { StreetLevelControls } from './streetLevelControls.js'; import { createStreetLevelLayer } from '../layers/streetLevel/index.js'; @@ -313,6 +314,7 @@ function panelDom() { const sinceRow = add(controls, 'div', { id: 'sl-since-row' }); add(sinceRow, 'input', { id: 'sl-since' }); add(sinceRow, 'output', { id: 'sl-since-label' }); + add(sinceRow, 'small', { id: 'sl-since-note' }); add(settings, 'ul', { id: 'sl-legend' }); const metaRow = add(settings, 'div', { classes: ['sl-meta-row'] }); add(metaRow, 'div', { id: 'sl-coverage-meta' }); @@ -1224,6 +1226,26 @@ test('a swap names every registered provider, also those hidden on this map', () ]); })); +test('a note under SINCE says what date it compares, only while one is given', () => + withDom(async (dom) => { + const state = uiState(); + const { layer } = stubPanel(dom, state); + const note = () => dom.root.querySelector('#sl-since-note'); + assert.equal(note().hidden, true, 'none for Mapillary'); + layer.publish({ ...state, sinceNote: 'KartaView: by upload date' }); + assert.equal(note().hidden, false); + assert.equal(note().textContent, 'KartaView: by upload date'); + layer.publish(state); + assert.equal(note().hidden, true); + // The panel's own markup has it, read out with the slider. + const template = fs.readFileSync( + new URL('./templates/layer-panels.html', import.meta.url), + 'utf8', + ); + assert.match(template, /]* hidden>/); + assert.match(template, /aria-describedby="sl-since-label sl-since-note"/); + })); + test('Street View lit hides the imagery filters and the legend; Mapillary lit shows them', () => withDom(async (dom) => { const state = uiState(); diff --git a/src/ui/streetLevelPresentation.js b/src/ui/streetLevelPresentation.js index 9ecfd077e..616829095 100644 --- a/src/ui/streetLevelPresentation.js +++ b/src/ui/streetLevelPresentation.js @@ -224,6 +224,7 @@ export function presentStreetLevelPanel(state, { now = Date.now() } = {}) { since: state.filterControls?.since !== false, }, since: presentSince(filter.sinceDays, now), + sinceNote: state.sinceNote || null, legend: state.legend || [], viewer: presentViewer(state), meta: presentMeta(state), diff --git a/src/ui/styles/street-level.css b/src/ui/styles/street-level.css index 0e7258978..324f28bb6 100644 --- a/src/ui/styles/street-level.css +++ b/src/ui/styles/street-level.css @@ -299,6 +299,17 @@ button.sl-status:focus-visible { outline-offset: 3px; } +.sl-since-note { + grid-column: 2; + font-family: var(--font-mono); + font-size: var(--sl-text-small); + color: var(--text-secondary); +} + +.sl-since-note[hidden] { + display: none; +} + .sl-since-label { grid-column: 2; font-family: var(--font-mono); diff --git a/src/ui/templates/layer-panels.html b/src/ui/templates/layer-panels.html index 4a44c03eb..d079ebd11 100644 --- a/src/ui/templates/layer-panels.html +++ b/src/ui/templates/layer-panels.html @@ -128,8 +128,9 @@
- + ANY DATE +