From 2dd96e1f25f37a1464cf84ddbd162c677e546f87 Mon Sep 17 00:00:00 2001 From: MiguelGFerreira Date: Sat, 12 Sep 2026 14:24:33 -0300 Subject: [PATCH 001/320] =?UTF-8?q?feat(data):=20add=20seven=20Brazilian?= =?UTF-8?q?=20Bike=20Ita=C3=BA=20cities=20to=20the=20GBFS=20registry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit São Paulo, Rio de Janeiro, Salvador, Recife, Porto Alegre, Brasília and Curitiba run on the same PBSC platform as the existing Austin, Honolulu and Chattanooga entries, so they need no new feed handling and no proxy allowlist change — isAllowedGbfsHost already accepts *.publicbikesystem.net. These are the layer's first stations outside the United States: 1,017 stations across the seven systems. --- CHANGELOG.md | 4 ++ DATA_SOURCES.md | 2 +- src/data/dataCredits.js | 2 +- src/layers/bikeshare/registry.js | 84 ++++++++++++++++++++++++++++++++ 4 files changed, 90 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 32f8245b4..3c451b4f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,10 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ## [Unreleased] +- Add seven Brazilian Bike Itaú (Tembici) cities to the GBFS bikeshare registry: + São Paulo, Rio de Janeiro, Salvador, Recife, Porto Alegre, Brasília and + Curitiba. These are the layer's first stations outside the United States. + - Separate radio directory loading, station selection, globe presentation and playback into composed components with an explicit metadata source. - Separate submarine cable sources and rendering components, and export bundled geography lookup modules. diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 650d3fe6b..2dc8ba0d7 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -35,7 +35,7 @@ How to read this: | **City of Austin Open Data** | CCTV camera catalog + frames | City of Austin Open Data Terms of Use | "City of Austin, TX — data.austintexas.gov" | | **Caltrans (cwwp2.dot.ca.gov)** | CCTV camera catalogs + frames, California districts | Public Caltrans traffic camera data | "Caltrans — cwwp2.dot.ca.gov" (courtesy) | | **TfL Open Data (JamCams)** | CCTV camera catalog + frames, London | [TfL Open Data terms](https://tfl.gov.uk/info-for/open-data-users/) — attribution REQUIRED | "Powered by TfL Open Data. Contains OS data © Crown copyright and database rights" | -| **GBFS (Lyft / BCycle)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle) + its `license_url` | +| **GBFS (Lyft / BCycle / PBSC)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle, Bike Itaú/Tembici) + its `license_url` | | **Radio Browser** | Geolocated internet-radio station directory and station-level tags | Public-domain directory data under PDDL 1.0; individual broadcaster stream terms apply | "Radio Browser" plus a link to the selected broadcaster | | **Re:Earth Terrain** (Mapterhorn) | Terrain (keyless globe stacks — OSM etc. — + `/api/terrain/heights` ellipsoidal-height lookups) | Terrain mesh: CC BY 4.0; geoid: EGM2008 (NGA, public domain) | "Terrain (keyless globe stacks): Re:Earth Terrain / Mapterhorn (CC BY 4.0) / EGM2008 (NGA)" | diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index 009ff6d01..817261608 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -137,7 +137,7 @@ export const DATA_CREDITS = [ }, { key: 'gbfs', - html: 'Bikeshare availability: GBFS operator feeds (e.g. Austin BCycle)', + html: 'Bikeshare availability: GBFS operator feeds (e.g. Austin BCycle, Bike Itaú/Tembici)', }, { key: 'radio-browser', diff --git a/src/layers/bikeshare/registry.js b/src/layers/bikeshare/registry.js index ff752f664..a5a1ee32c 100644 --- a/src/layers/bikeshare/registry.js +++ b/src/layers/bikeshare/registry.js @@ -167,6 +167,90 @@ const RAW_GBFS_CITY_REGISTRY = [ 'https://chat.publicbikesystem.net/customer/gbfs/v2/en/station_status.json', provider: 'Bike Chattanooga', }, + { + id: 'sao-paulo-bike-itau', + city: 'São Paulo, SP', + centerLat: -23.5505, + centerLon: -46.6333, + loadRadiusKm: 100, + stationInformationUrl: + 'https://saopaulo.publicbikesystem.net/customer/gbfs/v2/en/station_information.json', + stationStatusUrl: + 'https://saopaulo.publicbikesystem.net/customer/gbfs/v2/en/station_status.json', + provider: 'Bike Itaú', + }, + { + id: 'rio-de-janeiro-bike-itau', + city: 'Rio de Janeiro, RJ', + centerLat: -22.9068, + centerLon: -43.1729, + loadRadiusKm: 100, + stationInformationUrl: + 'https://riodejaneiro.publicbikesystem.net/customer/gbfs/v2/en/station_information.json', + stationStatusUrl: + 'https://riodejaneiro.publicbikesystem.net/customer/gbfs/v2/en/station_status.json', + provider: 'Bike Itaú', + }, + { + id: 'salvador-bike-itau', + city: 'Salvador, BA', + centerLat: -12.9777, + centerLon: -38.5016, + loadRadiusKm: 90, + stationInformationUrl: + 'https://salvador.publicbikesystem.net/customer/gbfs/v2/en/station_information.json', + stationStatusUrl: + 'https://salvador.publicbikesystem.net/customer/gbfs/v2/en/station_status.json', + provider: 'Bike Itaú', + }, + { + id: 'recife-bike-itau', + city: 'Recife, PE', + centerLat: -8.0476, + centerLon: -34.877, + loadRadiusKm: 90, + stationInformationUrl: + 'https://recife.publicbikesystem.net/customer/gbfs/v2/en/station_information.json', + stationStatusUrl: + 'https://recife.publicbikesystem.net/customer/gbfs/v2/en/station_status.json', + provider: 'Bike Itaú', + }, + { + id: 'porto-alegre-bike-itau', + city: 'Porto Alegre, RS', + centerLat: -30.0346, + centerLon: -51.2177, + loadRadiusKm: 90, + stationInformationUrl: + 'https://portoalegre.publicbikesystem.net/customer/gbfs/v2/en/station_information.json', + stationStatusUrl: + 'https://portoalegre.publicbikesystem.net/customer/gbfs/v2/en/station_status.json', + provider: 'Bike Itaú', + }, + { + id: 'brasilia-bike-itau', + city: 'Brasília, DF', + centerLat: -15.7939, + centerLon: -47.8828, + loadRadiusKm: 90, + stationInformationUrl: + 'https://brasilia.publicbikesystem.net/customer/gbfs/v2/en/station_information.json', + stationStatusUrl: + 'https://brasilia.publicbikesystem.net/customer/gbfs/v2/en/station_status.json', + provider: 'Bike Itaú', + }, + { + id: 'curitiba-bike-itau', + city: 'Curitiba, PR', + centerLat: -25.4284, + centerLon: -49.2733, + loadRadiusKm: 90, + stationInformationUrl: + 'https://curitiba.publicbikesystem.net/customer/gbfs/v2/en/station_information.json', + stationStatusUrl: + 'https://curitiba.publicbikesystem.net/customer/gbfs/v2/en/station_status.json', + provider: 'Bike Itaú', + }, bcycleEntry({ id: 'boulder-bcycle', city: 'Boulder, CO', From ae661db7ab61a7ab913856c654a3731ee354d9e3 Mon Sep 17 00:00:00 2001 From: ulihhhh <54112476+ulihhhh@users.noreply.github.com> Date: Tue, 15 Sep 2026 12:38:00 +0200 Subject: [PATCH 002/320] =?UTF-8?q?feat(cctv):=20add=20Catalonia=20(Servei?= =?UTF-8?q?=20Catal=C3=A0=20de=20Tr=C3=A0nsit)=20as=20a=20keyless=20CCTV?= =?UTF-8?q?=20source=20pack?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One WFS/GML XML feed (~135 cameras) aggregating three publishers under a `font` field: SCT's own highway cameras plus hotlinked Barcelona (IMI) and Terrassa municipal cameras. Frame URLs are checked against a small host allowlist rather than one pinned origin, and every non-SCT camera carries its real owner as a `credit` shown beside the shared provider. The feed carries no compass heading, so every camera loads at low heading confidence (same as TfL and Fintraffic), and ground elevation is primed per publisher rather than one flat prior. SCT's own frame link needed a workaround: RenderService always 302s to a second endpoint (TransitCamera) on a Location the upstream hardcodes as plain http even when requested over https — refused by fetchWithinHost's same-origin (scheme-included) redirect policy, by design (stops an upstream steering a host-pinned request into a plaintext downgrade). Separately, mct.gencat.cat's TLS setup fails the handshake with Node's fetch outright (ERR_SSL_WRONG_SIGNATURE_TYPE) even though other HTTP clients complete it. The loader builds the TransitCamera URL itself, over plain http, from RenderService's sctidcam id, instead of registering the broken upstream link — the only transport this host actually serves to this runtime. Verified against the live feed and the production fetch path (fetchCctvImageFromUpstream): all 135 cameras return real image bytes, 0 false positives. Ambient-card refresh cadence added to src/data/cctvLod.js (3 minutes), matching the upstream update rate measured empirically (no change inside 64s, changed within ~90-150s) — the same cadence already used for Caltrans/TfL/Ontario/TxDOT. CCTV_CATALONIA_MAX_SOURCES sets the cap and CCTV_CATALONIA_ENABLED=0 turns the pack off. No overlap with any existing pack (checked catalog.js and the git history for Spain/Catalonia/Barcelona coverage: none exists). Verified before this commit: format, check:boundaries, unit suite (3692 tests), and build all green. Co-Authored-By: Claude Sonnet 5 --- .env.example | 2 + CHANGELOG.md | 7 + DATA_SOURCES.md | 2 + docs/CURRENT-STATE.md | 2 +- server/providers/cctv/catalog.js | 6 + server/providers/cctv/constants.js | 41 ++++ server/providers/cctv/normalize.js | 30 +++ server/providers/cctv/sources.js | 203 +++++++++++++++++ src/data/cctvCataloniaSource.test.mjs | 317 ++++++++++++++++++++++++++ src/data/cctvDriveBcSource.test.mjs | 2 + src/data/cctvLod.js | 4 + src/data/cctvLod.test.mjs | 4 + src/data/dataCredits.js | 8 + 13 files changed, 627 insertions(+), 1 deletion(-) create mode 100644 src/data/cctvCataloniaSource.test.mjs diff --git a/.env.example b/.env.example index 5bc02df9c..5d2a268df 100644 --- a/.env.example +++ b/.env.example @@ -171,6 +171,8 @@ AISSTREAM_API_KEY= # CCTV_CALGARY_ENABLED=1 # set 0 to disable the Open Calgary (Alberta) cameras # CCTV_CALGARY_MAX_SOURCES=220 # cap cameras loaded from the Open Calgary catalog # CCTV_CALGARY_ROWS_URL= # override the Open Calgary catalog URL +# CCTV_CATALONIA_ENABLED=1 # set 0 to disable the Servei Català de Trànsit cameras +# CCTV_CATALONIA_MAX_SOURCES=150 # cap cameras loaded from Catalonia (~135 exist today) # 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 1b3d01413..0cfff159d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +- Add Catalonia (Servei Català de Trànsit) as a keyless CCTV source pack: ~135 + cameras from a WFS/GML feed that aggregates SCT's own highway cameras with + hotlinked Barcelona and Terrassa municipal cameras, each partner named as a + credit beside the shared provider. Ambient-card refresh paced to 3 minutes, + matching the empirically observed upstream cadence. `CCTV_CATALONIA_MAX_SOURCES` + sets the cap and `CCTV_CATALONIA_ENABLED=0` turns the pack off. + - Separate map-feature acquisition from annotation/search selection. Move road and mapped-installation decoding into source adapters while preserving geometry policy, cancellation, retry outcomes and compatibility exports. - Cancel hidden Nepal provider preloads on Stop, event disable and replacement, and respect drawing-tool pointer ownership for fallback evidence cards. Preserve @manjunath22466’s Nepal scene contribution and source attribution. diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 850d27e39..02e7e4272 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -45,6 +45,7 @@ How to read this: | **Fintraffic / Digitraffic (weathercams)** | CCTV camera catalog + frames, Finland | [CC BY 4.0](https://www.digitraffic.fi/en/terms-of-service/) — attribution REQUIRED | "Fintraffic / digitraffic.fi, license CC BY 4.0" | | **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" | +| **Servei Català de Trànsit** (+ Ajuntament de Barcelona, Ajuntament de Terrassa) | CCTV camera catalog + frames, Catalonia | [Llicència oberta d'ús d'informació - Catalunya](https://web.gencat.cat/ca/generalitat/dades-indicadors/dades-obertes/llicencies) — attribution REQUIRED | "Servei Català de Trànsit"; partner-supplied cameras also name their owner | | **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) | @@ -73,6 +74,7 @@ How to read this: - **Warendorf webcam.** The Stadt Warendorf Marktplatz webcam, registered from the curated `config/cctv_sources.warendorf.json` (the Kreis Warendorf registration-office cameras from the contributing PR are indoor waiting rooms and were left out); only the municipal image host is proxied, frames are fetched at request time and never stored or redistributed. Disable with `CCTV_WARENDORF_ENABLED=0`. - **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`. +- **Catalonia (Servei Català de Trànsit) cameras.** The camera list is the keyless `gencat.cat/transit/opendata/cameres.xml` WFS/GML feed. It carries a `font` field naming the real publisher per camera; this pack keeps SCT's own highway cameras plus hotlinked Barcelona (IMI) and Terrassa municipal cameras (~135 total), each confirmed to return a real frame, so frame URLs are checked against a small host allowlist (`mct.gencat.cat`, `www.bcn.cat`, `emap.terrassa.cat`) rather than one pinned origin, and every non-SCT camera carries its real owner as a `credit` shown beside the shared `provider`. The feed carries no compass heading, so every camera loads at low heading confidence (same as TfL and Fintraffic). SCT's own feed link (`RenderService`) needed a workaround: it always 302s to a second endpoint (`TransitCamera`) on a Location the upstream hardcodes as plain http even when requested over https — refused by `fetchWithinHost`'s same-origin (scheme-included) redirect policy — and separately, `mct.gencat.cat`'s TLS setup fails the handshake with Node's fetch outright (`ERR_SSL_WRONG_SIGNATURE_TYPE`) even though other HTTP clients complete it. The loader therefore builds the `TransitCamera` URL itself, over plain http, from `RenderService`'s `sctidcam` id, rather than registering the (broken) upstream link — the only transport this one host actually serves to this runtime. Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_CATALONIA_ENABLED=0`. - **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/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 40e441b25..42ab3f07a 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2433,7 +2433,7 @@ its criteria cannot be silently ignored. | Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s | | Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min | | Traffic | OSM Overpass (+ optional TomTom live flow) | `src/data/traffic.js` | `/api/overpass` + `/api/tomtom` | viewport-driven | -| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + 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 + Catalonia (Servei Català de Trànsit) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) | | Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh | | Bikeshare 🚲 | GBFS (Lyft + BCycle) | `src/data/bikeshare.js` | `/api/gbfs` | 60s | | Directions 🧭 | OSRM on FOSSGIS servers (OpenStreetMap) | `src/data/directions.js` | `/api/route` (`steps=1`) | on placement / mode change | diff --git a/server/providers/cctv/catalog.js b/server/providers/cctv/catalog.js index a8519c85c..c681d321c 100644 --- a/server/providers/cctv/catalog.js +++ b/server/providers/cctv/catalog.js @@ -17,6 +17,7 @@ import { loadWarendorfSourcesFromCatalog, loadNswSourcesFromOpenData, loadCalgarySourcesFromOpenData, + loadCataloniaSourcesFromOpenData, } from './sources.js'; /** Env kill switch: unset or anything but "0" means enabled. */ @@ -86,6 +87,11 @@ const LIVE_PACKS = [ enabled: () => envEnabled('CCTV_CALGARY_ENABLED'), load: loadCalgarySourcesFromOpenData, }, + { + name: 'catalonia', + enabled: () => envEnabled('CCTV_CATALONIA_ENABLED'), + load: loadCataloniaSourcesFromOpenData, + }, ]; /** * 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 d66806e45..4dfcbbd15 100644 --- a/server/providers/cctv/constants.js +++ b/server/providers/cctv/constants.js @@ -236,6 +236,47 @@ export const CALGARY_DOWNTOWN = { lat: 51.0461, lon: -114.0626 }; * body cannot be buffered without limit. */ export const CALGARY_MAX_CATALOG_BYTES = 4 * 1024 * 1024; +/** + * Catalonia traffic cameras (Servei Català de Trànsit): one keyless WFS/GML + * XML list (verified 2026-09-15, hourly catalog refresh). The feed carries a + * `font` field naming the actual publisher per camera — SCT's own highway + * cameras plus hotlinked Barcelona (IMI) and Terrassa municipal cameras are + * kept, each confirmed to return a real frame. Frame URLs are checked + * against a host allowlist rather than one pinned origin, since each + * publisher serves from its own host. + */ +export const CATALONIA_CAMERAS_URL = + 'http://www.gencat.cat/transit/opendata/cameres.xml'; +/** Allowed image hosts, one per kept `font` publisher in the feed. */ +export const CATALONIA_IMAGE_HOSTS = Object.freeze( + new Set([ + 'mct.gencat.cat', // SCT + 'www.bcn.cat', // IMI (Ajuntament de Barcelona) + 'emap.terrassa.cat', // Ajuntament de Terrassa + ]), +); +/** Per-`font` partner credit, shown beside the provider in the CCTV panel + * (same pattern as DriveBC's partner-supplied cameras). SCT is the feed's + * own publisher and carries no separate credit. */ +export const CATALONIA_FONT_CREDIT = Object.freeze({ + IMI: 'Ajuntament de Barcelona', + Terrassa: 'Ajuntament de Terrassa', +}); +/** Ground-elevation priors in metres, by `font`: the feed carries no + * elevation, and this pack spans sea level (Barcelona) to Terrassa's plateau + * (~270 m). The client's ground snap corrects these on 3D-tile stacks. */ +export const CATALONIA_FONT_ELEVATION_M = Object.freeze({ + SCT: 80, + IMI: 20, + Terrassa: 270, +}); +export const DEFAULT_CATALONIA_MAX_SOURCES = 150; +/** Prioritization anchors: Barcelona and Terrassa. */ +export const CATALONIA_ANCHORS = [ + { lat: 41.3874, lon: 2.1686 }, // Barcelona + { lat: 41.5636, lon: 2.0111 }, // Terrassa +]; + /** Camera CATALOGS change rarely; 15 min keeps multi-megabyte upstream list refetches (Austin rows.json + 4 Caltrans districts + TfL + Ontario 511) infrequent. Frames are fetched per-request and are unaffected. */ export const CCTV_SOURCE_CACHE_MS = 15 * 60 * 1000; /** Per-provider catalog-fetch timeout. Bounds the worst-case refresh so one diff --git a/server/providers/cctv/normalize.js b/server/providers/cctv/normalize.js index b564ef440..2469bee76 100644 --- a/server/providers/cctv/normalize.js +++ b/server/providers/cctv/normalize.js @@ -372,6 +372,36 @@ export function isLikelyFinlandCoordinate(lat, lon) { return lat >= 59.5 && lat <= 70.5 && lon >= 19 && lon <= 32; } +/** Catalonia bounding box (mainland is roughly 40.5..42.9 N, 0.15..3.4 E). */ +export function isLikelyCataloniaCoordinate(lat, lon) { + return ( + isPlausibleLatLon(lat, lon) && + lat >= 40.3 && + lat <= 42.9 && + lon >= 0.0 && + lon <= 3.4 + ); +} + +/** + * Decode decimal numeric HTML character references ("í" -> "í"), the + * entity form the Catalonia feed uses for accented place names, plus the + * handful of named entities XML/HTML text commonly carries. + * + * @param {string} text + * @returns {string} + */ +export function decodeNumericEntities(text) { + return String(text || '') + .replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code))) + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/</g, '<') + .replace(/>/g, '>') + .trim(); +} + /** * Human label for one Fintraffic preset (camera view). * diff --git a/server/providers/cctv/sources.js b/server/providers/cctv/sources.js index 41471f5ba..3f73e70bc 100644 --- a/server/providers/cctv/sources.js +++ b/server/providers/cctv/sources.js @@ -55,6 +55,12 @@ import { DEFAULT_CALGARY_MAX_SOURCES, CALGARY_DOWNTOWN, CALGARY_MAX_CATALOG_BYTES, + CATALONIA_CAMERAS_URL, + CATALONIA_IMAGE_HOSTS, + CATALONIA_FONT_CREDIT, + CATALONIA_FONT_ELEVATION_M, + DEFAULT_CATALONIA_MAX_SOURCES, + CATALONIA_ANCHORS, CCTV_SOURCE_FETCH_TIMEOUT_MS, } from './constants.js'; import { @@ -73,6 +79,8 @@ import { isLikelyTexasCoordinate, isLikelyNswCoordinate, isLikelyCalgaryCoordinate, + isLikelyCataloniaCoordinate, + decodeNumericEntities, cameraDisplayCode, rowArrayToObject, prioritizeSources, @@ -1594,3 +1602,198 @@ export async function loadCalgarySourcesFromOpenData() { return []; } } + +/** + * Parse the Catalonia (`cameres.xml`) WFS/GML feed into raw feature records. + * Regex-based rather than a full XML parser — the feed is small (~160 + * features) and this avoids a new dependency for one field set per feature. + * + * @param {string} xml + * @returns {Array<{fid:string, lat:number, lon:number, carretera:string, municipi:string, pk:string, link:string, font:string}>} + */ +export function parseCataloniaXml(xml) { + const out = []; + const blockRe = /([\s\S]*?)<\/gml:featureMember>/g; + const tag = (block, name) => { + const match = new RegExp(`([^<]*)<\\/cite:${name}>`).exec( + block, + ); + return match ? decodeNumericEntities(match[1]) : ''; + }; + let match; + while ((match = blockRe.exec(String(xml || ''))) !== null) { + const block = match[1]; + const fidMatch = /]*>([^<]+)<\/gml:coordinates>/.exec( + block, + ); + if (!fidMatch || !coordsMatch) continue; + const [lonRaw, latRaw] = coordsMatch[1].split(','); + out.push({ + fid: fidMatch[1], + lon: toFiniteNumber(lonRaw), + lat: toFiniteNumber(latRaw), + carretera: tag(block, 'carretera'), + municipi: tag(block, 'municipi'), + pk: tag(block, 'pk'), + link: tag(block, 'link'), + font: tag(block, 'font'), + }); + } + return out; +} + +/** + * Validate and normalize one Catalonia feed `link` against the publisher + * host allowlist (CATALONIA_IMAGE_HOSTS), upgrading to https. Rejects + * anything else, including a host that merely resembles an allowed one. + * + * SCT's own `RenderService` frame link is a special case, for two stacked + * reasons (both verified 2026-09-15): + * 1. It always 302s to `TransitCamera`, on a Location the upstream + * hardcodes as plain http even when RenderService itself was requested + * over https. fetchWithinHost (media.js) only follows same-origin + * redirects — same scheme included, by design, to stop an upstream + * steering a host-pinned request into a plaintext downgrade — so that + * hop is refused and the camera would render as a placeholder. + * 2. mct.gencat.cat's TLS setup itself does not complete a handshake with + * Node's fetch/OpenSSL (`ERR_SSL_WRONG_SIGNATURE_TYPE`) even though it + * is reachable over https from other clients (e.g. curl) — an upstream + * server misconfiguration this proxy cannot route around. + * TransitCamera answers the frame directly over **http**, no redirect, and + * that is the only transport this host actually serves to this runtime — so + * this builds that URL itself from RenderService's `sctidcam` id instead of + * registering the (https, then-redirecting) RenderService link. + * + * @param {string} rawUrl + * @returns {string} Normalized URL, or '' if not accepted. + */ +export function normalizeCataloniaImageUrl(rawUrl) { + let parsed; + try { + parsed = new URL(String(rawUrl || '').trim()); + } catch { + return ''; + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return ''; + if (!CATALONIA_IMAGE_HOSTS.has(parsed.hostname)) return ''; + + if (parsed.hostname === 'mct.gencat.cat') { + const camId = parsed.searchParams.get('sctidcam'); + if (!camId) return ''; + const direct = new URL('http://mct.gencat.cat/mct2bo/TransitCamera'); + direct.searchParams.set('nom', camId); + direct.searchParams.set('visualitzacio', 'imatge'); + return direct.toString(); + } + + parsed.protocol = 'https:'; + return parsed.toString(); +} + +/** + * Human label for one Catalonia camera: " ()", + * skipping the parenthetical when the municipality is already named in the + * road/place text or when one half is missing. + * + * @param {string} carretera + * @param {string} municipi + * @returns {string} + */ +export function cataloniaCameraName(carretera, municipi) { + const road = String(carretera || '').trim(); + const city = String(municipi || '').trim(); + if (road && city) { + return road.toLowerCase().includes(city.toLowerCase()) + ? road + : `${road} (${city})`; + } + return road || city || 'Càmera de trànsit'; +} + +/** + * Fetch Catalonia traffic cameras (Servei Català de Trànsit), keyless: one + * WFS/GML XML list. The feed carries a `font` field naming the real + * publisher per camera; only SCT's own highway cameras and the hotlinked + * Barcelona (IMI) and Terrassa municipal cameras are kept, each confirmed to + * return a real frame — frame URLs are checked against a host allowlist + * (CATALONIA_IMAGE_HOSTS), so a row from any other publisher is dropped. + * Each non-SCT camera carries its publisher as a `credit` alongside the + * shared `provider`. The feed carries no heading, so every camera takes the + * id-hash fallback, low-confidence pose (same as TfL and Fintraffic). + * + * @returns {Promise>} Normalized camera source objects. + */ +export async function loadCataloniaSourcesFromOpenData() { + try { + const resp = await fetch(CATALONIA_CAMERAS_URL, { + headers: { Accept: 'application/xml,text/xml,*/*' }, + signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS), + }); + if (!resp.ok) { + console.warn('[CCTV] Catalonia camera download failed:', resp.status); + return []; + } + const xml = await resp.text(); + const rows = parseCataloniaXml(xml); + if (!rows.length) return []; + + const cameras = []; + for (const row of rows) { + if (!row.fid) continue; + if (!isLikelyCataloniaCoordinate(row.lat, row.lon)) continue; + const imageUrl = normalizeCataloniaImageUrl(row.link); + if (!imageUrl) continue; + + const font = row.font.trim(); + const cameraId = `cat-${row.fid + .replace(/^cameres\.fid-/, '') + .replace(/[^a-zA-Z0-9]/g, '')}`; + + cameras.push({ + id: cameraId, + name: cataloniaCameraName(row.carretera, row.municipi), + city: row.municipi.trim() || 'Catalunya', + cityId: 'catalunya', + provider: 'Servei Català de Trànsit', + lat: row.lat, + lon: row.lon, + headingDeg: fallbackHeadingFromId(cameraId), + headingConfidence: 'low', + pitchDeg: -18, + fovDeg: 44, + rangeM: 145, + mountHeightM: 8, + groundElevationM: CATALONIA_FONT_ELEVATION_M[font] ?? 80, + feedType: 'image', + url: imageUrl, + snapshotUrl: imageUrl, + sourceKind: 'catalonia-open-data', + license: + "Generalitat de Catalunya — Llicència oberta d'ús d'informació", + credit: CATALONIA_FONT_CREDIT[font] || '', + }); + } + + const unique = Array.from( + new Map(cameras.map((camera) => [camera.id, camera])).values(), + ); + const maxRaw = Number( + process.env.CCTV_CATALONIA_MAX_SOURCES || DEFAULT_CATALONIA_MAX_SOURCES, + ); + const maxCount = Number.isFinite(maxRaw) + ? Math.max(8, Math.min(600, Math.floor(maxRaw))) + : DEFAULT_CATALONIA_MAX_SOURCES; + const prioritized = prioritizeSources(unique, maxCount, CATALONIA_ANCHORS); + console.log( + `[CCTV] Loaded Catalonia camera sources: ${unique.length} (using nearest ${prioritized.length})`, + ); + return prioritized; + } catch (error) { + console.warn( + '[CCTV] Catalonia camera download error:', + error?.message || error, + ); + return []; + } +} diff --git a/src/data/cctvCataloniaSource.test.mjs b/src/data/cctvCataloniaSource.test.mjs new file mode 100644 index 000000000..d9b9f3456 --- /dev/null +++ b/src/data/cctvCataloniaSource.test.mjs @@ -0,0 +1,317 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { createCctvCatalog } from '../../server/providers/cctv/catalog.js'; +import { CATALONIA_CAMERAS_URL } from '../../server/providers/cctv/constants.js'; +import { + parseCataloniaXml, + normalizeCataloniaImageUrl, + cataloniaCameraName, + loadCataloniaSourcesFromOpenData, +} from '../../server/providers/cctv/sources.js'; + +/** Set (or, for `undefined`, delete) environment variables for one test. */ +function withEnv(t, env) { + for (const [name, value] of Object.entries(env)) { + const previous = process.env[name]; + t.after(() => { + if (previous === undefined) delete process.env[name]; + else process.env[name] = previous; + }); + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } +} + +/** Silence the loaders' progress and failure logging. */ +function quiet(t) { + t.mock.method(console, 'log', () => {}); + t.mock.method(console, 'warn', () => {}); +} + +/** One `` block matching the real feed's shape. */ +function cataloniaFeature({ + fid, + lon, + lat, + carretera = '', + municipi = '', + pk = '', + link = '', + font = '', +}) { + const pkTag = pk ? `${pk}` : ''; + return ( + `` + + `` + + `${lon},${lat}` + + `` + + `${carretera}` + + `${municipi}${pkTag}` + + `${link}` + + `${font}` + + `` + ); +} + +function cataloniaFeed(features) { + return ( + '' + + features.join('') + + '' + ); +} + +test('parseCataloniaXml extracts every field from a feature block', () => { + const xml = cataloniaFeed([ + cataloniaFeature({ + fid: 'cameres.fid-52bf6eca_1a0a06bea1c_386f', + lon: '2.1849528', + lat: '41.45989301', + carretera: 'C-58', + municipi: 'Nus Trinitat', + pk: '0.50', + link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif', + font: 'SCT', + }), + ]); + const rows = parseCataloniaXml(xml); + assert.equal(rows.length, 1); + assert.deepEqual(rows[0], { + fid: 'cameres.fid-52bf6eca_1a0a06bea1c_386f', + lon: 2.1849528, + lat: 41.45989301, + carretera: 'C-58', + municipi: 'Nus Trinitat', + pk: '0.50', + link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif', + font: 'SCT', + }); +}); + +test('normalizeCataloniaImageUrl rewrites SCT RenderService links to the direct http TransitCamera URL', () => { + // RenderService always 302s to a hardcoded http:// Location (which + // fetchWithinHost refuses as a scheme downgrade), and mct.gencat.cat's TLS + // setup fails the handshake with Node's fetch regardless — TransitCamera + // over plain http is the only transport that actually works here, so we + // build that URL from RenderService's sctidcam id instead of registering + // the redirector. + assert.equal( + normalizeCataloniaImageUrl( + 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif', + ), + 'http://mct.gencat.cat/mct2bo/TransitCamera?nom=nc87.gif&visualitzacio=imatge', + ); + assert.equal( + normalizeCataloniaImageUrl( + 'https://mct.gencat.cat/mct2bo/RenderService?sctidcam=sc64.gif', + ), + 'http://mct.gencat.cat/mct2bo/TransitCamera?nom=sc64.gif&visualitzacio=imatge', + 'an already-https RenderService link is rewritten the same way', + ); + assert.equal( + normalizeCataloniaImageUrl('https://mct.gencat.cat/mct2bo/RenderService'), + '', + 'a RenderService link with no sctidcam id is rejected', + ); +}); + +test('normalizeCataloniaImageUrl accepts the other registered hosts, upgraded to https', () => { + assert.equal( + normalizeCataloniaImageUrl('http://www.bcn.cat/transit/imatges/a.gif'), + 'https://www.bcn.cat/transit/imatges/a.gif', + ); + assert.equal( + normalizeCataloniaImageUrl( + 'https://emap.terrassa.cat/it_terrassa/cam02.jpeg', + ), + 'https://emap.terrassa.cat/it_terrassa/cam02.jpeg', + ); + assert.equal( + normalizeCataloniaImageUrl('https://evil.example.com/mct.gencat.cat.gif'), + '', + 'a look-alike host is rejected', + ); + assert.equal( + normalizeCataloniaImageUrl('https://unlisted-publisher.example/frame.gif'), + '', + 'any host outside the allowlist is rejected', + ); + assert.equal(normalizeCataloniaImageUrl(''), ''); + assert.equal(normalizeCataloniaImageUrl('not a url'), ''); +}); + +test('cataloniaCameraName joins road and municipality, skipping a redundant municipality', () => { + assert.equal( + cataloniaCameraName('C-58', 'Nus Trinitat'), + 'C-58 (Nus Trinitat)', + ); + assert.equal( + cataloniaCameraName('Plaça Urquinaona', 'Barcelona'), + 'Plaça Urquinaona (Barcelona)', + ); + assert.equal( + cataloniaCameraName('Barcelona Ronda Litoral', 'Barcelona'), + 'Barcelona Ronda Litoral', + ); + assert.equal(cataloniaCameraName('', ''), 'Càmera de trànsit'); + assert.equal(cataloniaCameraName('C-58', ''), 'C-58'); +}); + +test('Catalonia loader keeps SCT, Barcelona and Terrassa cameras with per-publisher credit', async (t) => { + quiet(t); + withEnv(t, { CCTV_CATALONIA_MAX_SOURCES: undefined }); + const requested = []; + t.mock.method(globalThis, 'fetch', async (url) => { + requested.push(String(url)); + const xml = cataloniaFeed([ + cataloniaFeature({ + fid: 'cameres.fid-1', + lon: '2.1849528', + lat: '41.45989301', + carretera: 'C-58', + municipi: 'Nus Trinitat', + pk: '0.50', + link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif', + font: 'SCT', + }), + cataloniaFeature({ + fid: 'cameres.fid-2', + lon: '2.17228212', + lat: '41.38890733', + carretera: 'Plaça Urquinaona', + municipi: 'Barcelona', + link: 'http://www.bcn.cat/transit/imatges/PlUrquinaona.gif?a=1', + font: 'IMI', + }), + cataloniaFeature({ + fid: 'cameres.fid-3', + lon: '2.0111', + lat: '41.5636', + carretera: 'Cam02', + municipi: 'Terrassa', + link: 'https://emap.terrassa.cat/it_terrassa/cam02.jpeg?a=1', + font: 'Terrassa', + }), + // Rejected: link host not in the allowlist. + cataloniaFeature({ + fid: 'cameres.fid-4', + lon: '2.0', + lat: '41.5', + link: 'https://example.com/fake.gif', + font: 'SCT', + }), + // Rejected: outside the Catalonia bounding box. + cataloniaFeature({ + fid: 'cameres.fid-5', + lon: '-3.7', + lat: '40.4', + link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=x.gif', + font: 'SCT', + }), + ]); + return new Response(xml, { + status: 200, + headers: { 'Content-Type': 'application/xml' }, + }); + }); + + const cameras = await loadCataloniaSourcesFromOpenData(); + + assert.deepEqual(requested, [CATALONIA_CAMERAS_URL]); + assert.deepEqual( + cameras.map((camera) => camera.id).sort(), + ['cat-1', 'cat-2', 'cat-3'], + 'the off-allowlist link (fid-4) and the out-of-bbox row (fid-5) are excluded', + ); + + const sct = cameras.find((c) => c.id === 'cat-1'); + assert.equal(sct.name, 'C-58 (Nus Trinitat)'); + assert.equal(sct.provider, 'Servei Català de Trànsit'); + assert.equal(sct.credit, '', 'SCT is the publisher, no separate credit'); + assert.equal( + sct.url, + 'http://mct.gencat.cat/mct2bo/TransitCamera?nom=nc87.gif&visualitzacio=imatge', + 'registers the direct, non-redirecting http frame URL, not the RenderService redirector', + ); + assert.equal(sct.groundElevationM, 80); + assert.equal(sct.headingConfidence, 'low', 'the feed carries no heading'); + + const bcn = cameras.find((c) => c.id === 'cat-2'); + assert.equal(bcn.credit, 'Ajuntament de Barcelona'); + assert.equal(bcn.city, 'Barcelona'); + assert.equal(bcn.groundElevationM, 20); + + const terrassa = cameras.find((c) => c.id === 'cat-3'); + assert.equal(terrassa.credit, 'Ajuntament de Terrassa'); + assert.equal(terrassa.groundElevationM, 270); +}); + +test('Catalonia loader fails soft on HTTP errors, empty payloads and network errors', async (t) => { + quiet(t); + let respond; + t.mock.method(globalThis, 'fetch', (...args) => respond(...args)); + for (respond of [ + async () => new Response('unavailable', { status: 503 }), + async () => new Response(''), + async () => { + throw new TypeError('fetch failed'); + }, + ]) { + assert.deepEqual(await loadCataloniaSourcesFromOpenData(), []); + } +}); + +test('CCTV catalog merges Catalonia cameras and CCTV_CATALONIA_ENABLED=0 skips the request', async (t) => { + quiet(t); + const sourceRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'gev-cctv-catalog-catalonia-'), + ); + t.after(() => fs.rmSync(sourceRoot, { recursive: true, force: true })); + withEnv(t, { + CCTV_SOURCES_FILE: undefined, + CCTV_SOURCES_JSON: undefined, + CCTV_FORCE_AUSTIN: undefined, + CCTV_PREFER_AUSTIN: undefined, + CCTV_MAX_SOURCES: undefined, + CCTV_CALTRANS_DISTRICTS: undefined, + CCTV_TFL_ENABLED: undefined, + CCTV_CATALONIA_ENABLED: undefined, + CCTV_CATALONIA_MAX_SOURCES: undefined, + }); + const requested = []; + t.mock.method(globalThis, 'fetch', async (url) => { + requested.push(String(url)); + if (String(url) !== CATALONIA_CAMERAS_URL) { + return new Response('unavailable', { status: 503 }); + } + const xml = cataloniaFeed([ + cataloniaFeature({ + fid: 'cameres.fid-1', + lon: '2.1849528', + lat: '41.45989301', + link: 'http://mct.gencat.cat/mct2bo/RenderService?sctidcam=nc87.gif', + font: 'SCT', + }), + ]); + return new Response(xml, { status: 200 }); + }); + + const sources = await createCctvCatalog({ sourceRoot })(); + assert.deepEqual( + sources.map((source) => source.id), + ['cat-1'], + ); + + process.env.CCTV_CATALONIA_ENABLED = '0'; + requested.length = 0; + assert.deepEqual(await createCctvCatalog({ sourceRoot })(), []); + assert.ok(requested.length > 0, 'the other live packs still load'); + assert.equal( + requested.includes(CATALONIA_CAMERAS_URL), + false, + 'a disabled pack makes no request', + ); +}); diff --git a/src/data/cctvDriveBcSource.test.mjs b/src/data/cctvDriveBcSource.test.mjs index 617bb1350..a4934af3c 100644 --- a/src/data/cctvDriveBcSource.test.mjs +++ b/src/data/cctvDriveBcSource.test.mjs @@ -17,6 +17,7 @@ import { DEFAULT_TALLINN_MAX_SOURCES, DEFAULT_TARKTEE_MAX_SOURCES, DEFAULT_NSW_MAX_SOURCES, + DEFAULT_CATALONIA_MAX_SOURCES, DRIVEBC_WEBCAMS_URL, } from '../../server/providers/cctv/constants.js'; import { @@ -234,6 +235,7 @@ test('default per-pack camera caps fit inside the default catalog cap', () => { DEFAULT_TALLINN_MAX_SOURCES + DEFAULT_TARKTEE_MAX_SOURCES + DEFAULT_NSW_MAX_SOURCES + + DEFAULT_CATALONIA_MAX_SOURCES + 1; assert.ok( packs <= DEFAULT_CCTV_MAX_SOURCES, diff --git a/src/data/cctvLod.js b/src/data/cctvLod.js index 65ddc2e12..44b2e8ba3 100644 --- a/src/data/cctvLod.js +++ b/src/data/cctvLod.js @@ -38,6 +38,10 @@ const PROVIDER_STATIC_REFRESH_MS = Object.freeze({ // collectionInterval, 600 s for every station sampled; polling faster only // re-fetches the same JPEG. fintraffic: 10 * 60 * 1000, + // Measured empirically (2026-09-15): polling the same SCT/Terrassa frame + // every 20s showed no change inside 64s but a new frame by ~90-150s later — + // matches the other highway packs' 3-minute cadence. + 'servei català de trànsit': 3 * 60 * 1000, }); /** diff --git a/src/data/cctvLod.test.mjs b/src/data/cctvLod.test.mjs index c6f6e4923..4f5982496 100644 --- a/src/data/cctvLod.test.mjs +++ b/src/data/cctvLod.test.mjs @@ -641,6 +641,10 @@ test('staticFrameRefreshMs follows known pack cadences and bounds explicit value assert.equal(staticFrameRefreshMs({ provider: 'TxDOT' }), 180_000); // Digitraffic publishes one weathercam frame per 600 s collection interval. assert.equal(staticFrameRefreshMs({ provider: 'Fintraffic' }), 600_000); + assert.equal( + staticFrameRefreshMs({ provider: 'Servei Català de Trànsit' }), + 180_000, + ); assert.equal(staticFrameRefreshMs({ frameRefreshMs: 5_000 }), 60_000); assert.equal(staticFrameRefreshMs({ frameRefreshMs: 3_000_000 }), 1_200_000); assert.equal(staticFrameRefreshMs({ provider: 'Unknown Provider' }), 300_000); diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index 76029ace8..c44ac3ca6 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -169,6 +169,14 @@ export const DATA_CREDITS = [ 'Traffic cameras (Calgary): contains information licensed under the ' + 'Open Government Licence – City of Calgary', }, + { + key: 'catalonia-cctv', + html: + 'CCTV cameras & frames (Catalonia): ' + + 'Servei Català de Trànsit ' + + '(Llicència oberta d’ús d’informació - Catalunya); some cameras are supplied by partners ' + + '(Ajuntament de Barcelona, Ajuntament de Terrassa), each named in the CCTV panel', + }, { key: 'gbfs', html: 'Bikeshare availability: GBFS operator feeds (e.g. Austin BCycle)', From ffbe98b4ac9e269fea098f26eadd01d28a0b4333 Mon Sep 17 00:00:00 2001 From: ulihhhh <54112476+ulihhhh@users.noreply.github.com> Date: Tue, 15 Sep 2026 12:38:04 +0200 Subject: [PATCH 003/320] test(format-scope): register cctvCataloniaSource.test.mjs for format:check Mechanical addition per CONTRIBUTING.md, following the existing per-pack test-file entries (cctvCalgary.test.mjs, cctvNswSource.test.mjs, etc.). Co-Authored-By: Claude Sonnet 5 --- scripts/format-scope.json | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/format-scope.json b/scripts/format-scope.json index b86632108..05fe8327a 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -489,6 +489,7 @@ "src/data/cctvEstonia.test.mjs", "src/data/cctvWarendorf.test.mjs", "src/data/cctvCalgary.test.mjs", + "src/data/cctvCataloniaSource.test.mjs", "src/data/cctvNswSource.test.mjs", "src/data/cctvTxdotSource.test.mjs", "src/layers/cctv/source.test.mjs", From 976efed6dc2fa207524def3c90e5cf9683a2323b Mon Sep 17 00:00:00 2001 From: Reloded <37674025+Reloded@users.noreply.github.com> Date: Wed, 16 Sep 2026 00:14:46 +0300 Subject: [PATCH 004/320] feat(cctv): add Lithuania Eismoinfo road cameras --- .env.example | 2 + CHANGELOG.md | 2 + DATA_SOURCES.md | 2 + docs/CURRENT-STATE.md | 14 +++ scripts/format-scope.json | 2 + scripts/package-boundaries.json | 1 + server/providers/cctv/catalog.js | 6 ++ server/providers/cctv/lithuania.js | 135 ++++++++++++++++++++++++++++ src/data/cctvLithuania.test.mjs | 138 +++++++++++++++++++++++++++++ src/data/dataCredits.js | 6 ++ 10 files changed, 308 insertions(+) create mode 100644 server/providers/cctv/lithuania.js create mode 100644 src/data/cctvLithuania.test.mjs diff --git a/.env.example b/.env.example index 5bc02df9c..02fdd78b4 100644 --- a/.env.example +++ b/.env.example @@ -171,6 +171,8 @@ AISSTREAM_API_KEY= # CCTV_CALGARY_ENABLED=1 # set 0 to disable the Open Calgary (Alberta) cameras # CCTV_CALGARY_MAX_SOURCES=220 # cap cameras loaded from the Open Calgary catalog # CCTV_CALGARY_ROWS_URL= # override the Open Calgary catalog URL +# CCTV_LITHUANIA_ENABLED=1 # set 0 to disable Via Lietuva / Eismoinfo road cameras +# CCTV_LITHUANIA_MAX_SOURCES=400 # cap Lithuanian cameras; no API key required # 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 d3ab7807e..a9a9e8687 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +- Add Lithuania's public Via Lietuva / Eismoinfo road cameras to CCTV, with published coordinates, on-demand snapshots, attribution and bounded catalog requests. No API key is required. + - Keep parked transit vehicles aligned to their world course during camera orbits, fall back to reported bearing, and keep vehicles with no course consistently screen-up. - Separate Realtime connection, response/tool, Radio, input/audio, cost, viewport diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 850d27e39..a2610887b 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -59,6 +59,8 @@ How to read this: ### Notes on the live sources +- **Via Lietuva / Eismoinfo (Lithuania).** Public road-camera catalogs and periodically updated still images from [Eismoinfo](https://eismoinfo.lt/), credited to Via Lietuva / eismoinfo.lt in the app. The provider's [open-data statement](https://eismoinfo.lt/eismoinfo-backend/content?id=44) describes reuse of its public data; no specific Creative Commons licence is asserted here. `eismoinfo-backend/camera-info-table` is joined by ID to `layer-static-features/VKR`. Without `lks=true`, the latter returns WGS84 `[latitude, longitude]`; the table's `x/y` instead use LKS-94 metres. Only the official HTTPS `/eismoinfo-backend/image-provider/camera/last?id=…` image endpoint is registered. Catalogs use the shared 15-minute cache; frames are fetched on demand and never bundled or archived. These are periodic snapshots, not continuous video. Camera headings are low-confidence estimates. No API key is required. Disable with `CCTV_LITHUANIA_ENABLED=0`; `CCTV_LITHUANIA_MAX_SOURCES` defaults to 400. + - **Google Maps Platform.** You supply your own API key and are bound by [Google's ToS](https://cloud.google.com/maps-platform/terms). Google Maps Content (tiles, geocodes, places) **may not be cached, stored, rehosted, or committed** — this app only ever uses it live, which is the compliant pattern. The "Google" attribution is displayed on the globe and must stay visible. Restrict your key (see [SECURITY.md](SECURITY.md)). - **OpenSky Network.** Its license is **non-commercial**, and operational use of the REST API in a live product can require a prior written agreement with OpenSky — even for non-profit/government use. If you deploy this commercially, contact OpenSky for your own terms. The flights layer is a toggle and runs anonymously by default. - **adsb.lol flight fallback.** When OpenSky is unavailable and no last-good OpenSky response exists, the server requests a cached, capped 250 nm adsb.lol point snapshot around the current camera subpoint. This is regional observed context, not worldwide completeness; provenance is exposed in the Flights stats/context row. Military ICAOs remain reconciled through the existing dedicated military registry rather than duplicated. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1b524a961..9d66da384 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -391,6 +391,20 @@ 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. +## Lithuania CCTV source pack + +The CCTV catalog includes Via Lietuva / Eismoinfo road cameras by default. +Two keyless public endpoints provide current snapshot URLs and WGS84 camera +locations; records are joined by ID, with invalid coordinates and off-origin +image URLs rejected. Catalog requests refuse redirects, have a 15-second +deadline and a 4 MiB response cap, and use the existing shared 15-minute cache +and fair catalog allocation. An unavailable source contributes an empty pack. +Frames use the registered-image proxy and retain Via Lietuva attribution. +The images are periodically updated road-condition photographs, not continuous +video; camera poses use the existing low-confidence heading prior. +`CCTV_LITHUANIA_ENABLED=0` disables the pack, and +`CCTV_LITHUANIA_MAX_SOURCES` sets its cap (default 400, bounded to 1–1000). + ## DriveBC CCTV source pack The CCTV catalog adds DriveBC highway cameras for British Columbia alongside the diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 7f26e1e71..1bdb1c157 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -85,6 +85,8 @@ "server/providers/cctv/cap.js", "server/providers/cctv/groundHeights.js", "server/providers/cctv/catalog.js", + "server/providers/cctv/lithuania.js", + "src/data/cctvLithuania.test.mjs", "server/providers/cctv/constants.js", "server/providers/cctv/media.js", "server/providers/cctv/normalize.js", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index a5806db67..f40976374 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -156,6 +156,7 @@ "server/providers/cctv/cap.js", "server/providers/cctv/groundHeights.js", "server/providers/cctv/catalog.js", + "server/providers/cctv/lithuania.js", "server/providers/cctv/constants.js", "server/providers/cctv/media.js", "server/providers/cctv/normalize.js", diff --git a/server/providers/cctv/catalog.js b/server/providers/cctv/catalog.js index a8519c85c..ca8a849ab 100644 --- a/server/providers/cctv/catalog.js +++ b/server/providers/cctv/catalog.js @@ -4,6 +4,7 @@ import { DEFAULT_CCTV_SOURCE_FILE, CCTV_SOURCE_CACHE_MS } from './constants.js'; import { allocateSourceCap, resolveCatalogCap } from './cap.js'; import { loadGroundHeights, joinGroundHeights } from './groundHeights.js'; import { normalizeSourceItem } from './normalize.js'; +import { loadLithuaniaSources } from './lithuania.js'; import { loadAustinSourcesFromOpenData, loadCaltransSourcesFromOpenData, @@ -30,6 +31,11 @@ const envEnabled = (name) => String(process.env[name] || '1').trim() !== '0'; * kill switch. */ const LIVE_PACKS = [ + { + name: 'lithuania', + enabled: () => envEnabled('CCTV_LITHUANIA_ENABLED'), + load: loadLithuaniaSources, + }, { name: 'austin', enabled: () => true, load: loadAustinSourcesFromOpenData }, { name: 'caltrans', diff --git a/server/providers/cctv/lithuania.js b/server/providers/cctv/lithuania.js new file mode 100644 index 000000000..df4ee154d --- /dev/null +++ b/server/providers/cctv/lithuania.js @@ -0,0 +1,135 @@ +import { CCTV_SOURCE_FETCH_TIMEOUT_MS } from './constants.js'; +import { + cameraDisplayCode, + fallbackHeadingFromId, + prioritizeSources, +} from './normalize.js'; +import { readResponseJsonCapped } from '../common/http.js'; + +const BASE = 'https://eismoinfo.lt/eismoinfo-backend'; +export const LITHUANIA_CAMERAS_URL = `${BASE}/camera-info-table`; +// Without lks=true this endpoint returns [latitude, longitude]. The camera +// table's x/y fields use LKS-94 metres and must never be treated as degrees. +export const LITHUANIA_LOCATIONS_URL = `${BASE}/layer-static-features/VKR`; +export const LITHUANIA_MAX_CATALOG_BYTES = 4 * 1024 * 1024; +const FRAME_PATH = '/eismoinfo-backend/image-provider/camera/last'; +const ANCHORS = [ + { lat: 54.6872, lon: 25.2797 }, // Vilnius + { lat: 54.8985, lon: 23.9036 }, // Kaunas + { lat: 55.7033, lon: 21.1443 }, // Klaipeda + { lat: 55.9349, lon: 23.3137 }, // Siauliai + { lat: 55.7348, lon: 24.3575 }, // Panevezys +]; + +/** Join public catalogs by ID; register only the official still endpoint. */ +export function lithuaniaCameraToSource(row, feature) { + const id = String(row?.id ?? ''); + if (!/^[1-9]\d{0,8}$/.test(id) || String(feature?.id) !== id) return null; + const point = feature?.points?.[0]?.point; + if (!Array.isArray(point) || point.length !== 2) return null; + const [lat, lon] = point; + if ( + !Number.isFinite(lat) || + !Number.isFinite(lon) || + lat < 53.8 || + lat > 56.5 || + lon < 20.8 || + lon > 26.9 + ) + return null; + try { + const url = new URL(row.image); + if ( + url.origin !== 'https://eismoinfo.lt' || + url.username || + url.password || + url.pathname !== FRAME_PATH || + url.searchParams.get('id') !== id + ) + return null; + } catch { + return null; + } + // Reconstruct so extra query parameters cannot alter the request. + const imageUrl = `https://eismoinfo.lt${FRAME_PATH}?id=${id}`; + const cameraId = `lithuania-${id}`; + const name = String(row.name || feature.name || `Lietuva ${id}`) + .trim() + .slice(0, 160); + return { + id: cameraId, + name, + city: 'Lithuania / Lietuva', + cityId: 'lithuania', + provider: 'Via Lietuva / Eismoinfo', + lat, + lon, + // No calibrated pose is published; use the app's low-confidence prior. + headingDeg: fallbackHeadingFromId(cameraId), + headingConfidence: 'low', + pitchDeg: -18, + fovDeg: 44, + rangeM: 145, + mountHeightM: 8, + feedType: 'image', + url: imageUrl, + snapshotUrl: imageUrl, + sourceKind: 'eismoinfo-public', + credit: + 'Via Lietuva / eismoinfo.lt — periodically updated road-camera stills', + code: cameraDisplayCode(name.toUpperCase()), + }; +} + +async function readCatalog(url) { + const signal = AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS); + const response = await fetch(url, { + headers: { Accept: 'application/json' }, + redirect: 'manual', + signal, + }); + if (!response.ok) { + await response.body?.cancel(); + throw new Error(`Catalog HTTP ${response.status}`); + } + return readResponseJsonCapped(response, LITHUANIA_MAX_CATALOG_BYTES, signal); +} + +/** Two bounded requests per shared catalog refresh, no API key required. */ +export async function loadLithuaniaSources() { + try { + const [rows, layers] = await Promise.all([ + readCatalog(LITHUANIA_CAMERAS_URL), + readCatalog(LITHUANIA_LOCATIONS_URL), + ]); + if (!Array.isArray(rows) || !Array.isArray(layers)) return []; + const features = layers.find((layer) => layer?.layer === 'VKR')?.features; + if (!Array.isArray(features)) return []; + const locations = new Map( + features.filter(Boolean).map((feature) => [String(feature.id), feature]), + ); + const cameras = new Map(); + for (const row of rows) { + const camera = lithuaniaCameraToSource( + row, + locations.get(String(row?.id)), + ); + if (camera && !cameras.has(camera.id)) cameras.set(camera.id, camera); + } + const rawCap = Number(process.env.CCTV_LITHUANIA_MAX_SOURCES || 400); + const cap = Number.isFinite(rawCap) + ? Math.max(1, Math.min(1000, Math.floor(rawCap))) + : 400; + const sources = prioritizeSources([...cameras.values()], cap, ANCHORS); + console.log( + `[CCTV] Loaded Lithuania camera sources: ${cameras.size} (using ${sources.length})`, + ); + return sources; + } catch (error) { + console.warn( + '[CCTV] Lithuania camera download error:', + error?.message || error, + ); + return []; + } +} diff --git a/src/data/cctvLithuania.test.mjs b/src/data/cctvLithuania.test.mjs new file mode 100644 index 000000000..675e8d251 --- /dev/null +++ b/src/data/cctvLithuania.test.mjs @@ -0,0 +1,138 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + lithuaniaCameraToSource, + loadLithuaniaSources, + LITHUANIA_CAMERAS_URL, + LITHUANIA_LOCATIONS_URL, + LITHUANIA_MAX_CATALOG_BYTES, +} from '../../server/providers/cctv/lithuania.js'; +import { createCctvCatalog } from '../../server/providers/cctv/catalog.js'; + +const row = { + id: 72, + name: 'Vilnius A1 10,04', + x: 576154, + y: 6056867, + image: + 'https://eismoinfo.lt/eismoinfo-backend/image-provider/camera/last?id=72', +}; +const feature = { + id: '72', + points: [{ point: [54.642550940833566, 25.17981587877114] }], +}; +const layers = [{ layer: 'VKR', features: [feature] }]; + +test('Lithuania joins by ID and uses WGS84 latitude/longitude, never LKS-94 metres', () => { + const camera = lithuaniaCameraToSource(row, feature); + assert.equal(camera.id, 'lithuania-72'); + assert.equal(camera.lat, 54.642550940833566); + assert.equal(camera.lon, 25.17981587877114); + assert.equal(camera.name, 'Vilnius A1 10,04'); + assert.equal(camera.feedType, 'image'); + assert.equal(camera.headingConfidence, 'low'); + assert.equal(camera.url, row.image); + assert.match(camera.credit, /Via Lietuva/); +}); + +test('invalid joins, coordinates and image destinations cannot enter the proxy registry', () => { + for (const invalid of [ + null, + { ...feature, id: '8' }, + { ...feature, points: [] }, + { ...feature, points: [{ point: [25.18, 54.64] }] }, + { ...feature, points: [{ point: [576154, 6056867] }] }, + { ...feature, points: [{ point: [null, 25] }] }, + ]) { + assert.equal(lithuaniaCameraToSource(row, invalid), null); + } + for (const image of [ + 'http://eismoinfo.lt/eismoinfo-backend/image-provider/camera/last?id=72', + row.image.replace('eismoinfo.lt', 'eismoinfo.lt.evil.test'), + row.image.replace('https://', 'https://user:pass@'), + row.image.replace('id=72', 'id=8'), + 'http://127.0.0.1/private', + '', + null, + ]) { + assert.equal(lithuaniaCameraToSource({ ...row, image }, feature), null); + } + assert.equal( + lithuaniaCameraToSource( + { ...row, image: row.image + '&extra=ignored' }, + feature, + ).url, + row.image, + ); + assert.equal(lithuaniaCameraToSource(null, feature), null); +}); + +function mockCatalogs(t) { + const requested = []; + t.mock.method(console, 'log', () => {}); + t.mock.method(console, 'warn', () => {}); + t.mock.method(globalThis, 'fetch', async (url, options) => { + requested.push(String(url)); + if (url === LITHUANIA_CAMERAS_URL) { + assert.equal(options.redirect, 'manual'); + return Response.json([row, row, { ...row, id: 999 }]); + } + if (url === LITHUANIA_LOCATIONS_URL) return Response.json(layers); + return Response.json([]); + }); + return requested; +} + +test('loader deduplicates and omits cameras without matching published coordinates', async (t) => { + const requested = mockCatalogs(t); + assert.deepEqual( + (await loadLithuaniaSources()).map((c) => c.id), + ['lithuania-72'], + ); + assert.deepEqual( + requested.sort(), + [LITHUANIA_CAMERAS_URL, LITHUANIA_LOCATIONS_URL].sort(), + ); +}); + +test('catalog includes Lithuania by default and respects the disable switch', async (t) => { + const requested = mockCatalogs(t); + const saved = process.env.CCTV_LITHUANIA_ENABLED; + try { + delete process.env.CCTV_LITHUANIA_ENABLED; + const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })(); + assert.equal( + sources.find((c) => c.id === 'lithuania-72')?.cityId, + 'lithuania', + ); + requested.length = 0; + process.env.CCTV_LITHUANIA_ENABLED = '0'; + const disabled = await createCctvCatalog({ sourceRoot: '/nonexistent' })(); + assert.ok(!disabled.some((c) => c.cityId === 'lithuania')); + assert.ok(!requested.includes(LITHUANIA_CAMERAS_URL)); + } finally { + if (saved === undefined) delete process.env.CCTV_LITHUANIA_ENABLED; + else process.env.CCTV_LITHUANIA_ENABLED = saved; + } +}); + +test('failed, redirected, malformed and oversized catalogs degrade to an empty pack', async (t) => { + t.mock.method(console, 'warn', () => {}); + for (const makeResponse of [ + () => new Response('', { status: 503 }), + () => + new Response('', { + status: 302, + headers: { Location: 'http://127.0.0.1/' }, + }), + () => new Response('invalid json'), + () => Response.json({ unexpected: true }), + () => + new Response('[]', { + headers: { 'Content-Length': String(LITHUANIA_MAX_CATALOG_BYTES + 1) }, + }), + ]) { + t.mock.method(globalThis, 'fetch', async () => makeResponse()); + assert.deepEqual(await loadLithuaniaSources(), []); + } +}); diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index 76029ace8..d5ee02c54 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -163,6 +163,12 @@ export const DATA_CREDITS = [ 'digitraffic.fi, ' + 'license CC BY 4.0', }, + { + key: 'lithuania-cctv', + html: + 'Road-camera stills (Lithuania): ' + + 'Via Lietuva / Eismoinfo; periodically updated images', + }, { key: 'calgary-cctv', html: From c5b1d41cbade33efd598286a104e49b9cd1d2952 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niccol=C3=B2=20Saggese?= Date: Mon, 21 Sep 2026 11:30:49 +0200 Subject: [PATCH 005/320] fix(search): geocode through the server, not the browser - Google's Geocoding web service refuses referrer-restricted keys, so the browser call forced GOOGLE_MAPS_API_KEY to ship unrestricted - add /api/google/geocode and /api/google/reverse-geocode, using the server key like Places and Street View already do - the browser key now needs the Map Tiles API only --- CHANGELOG.md | 9 ++ SECURITY.md | 6 +- docs/CURRENT-STATE.md | 19 ++- server/providers/local.js | 3 + server/providers/places.js | 7 +- server/providers/places/google-key.js | 33 +++- server/providers/places/google.js | 155 ++++++++++++++++++ src/annotations/annotationEngine.test.mjs | 2 +- src/annotations/annotationResolver.test.mjs | 20 +-- src/googleGeocodeProxy.test.mjs | 167 ++++++++++++++++++++ src/googleServerKey.test.mjs | 16 +- src/search/defaults.js | 23 +-- src/search/http.js | 15 +- src/search/placeSearch.test.mjs | 2 +- src/voice/gevActions.test.mjs | 6 +- 15 files changed, 436 insertions(+), 47 deletions(-) create mode 100644 src/googleGeocodeProxy.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 744f4be0a..526a89bc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +- Move Google geocoding behind the local server. Forward and reverse lookups now + go through `/api/google/geocode` and `/api/google/reverse-geocode` with the + server key, joining Places and Street View. Google's Geocoding web service + refuses referrer-restricted keys, so the previous browser call left + `SECURITY.md`'s advice and working search mutually exclusive: the bundled + browser key had to be unrestricted for search to work at all. That key now + needs the Map Tiles API only. Keyless sessions and the Photon/Nominatim + fallback order are unchanged. + - Enable responsive trackpad pinch zoom on the globe. Browser pixel-mode `Ctrl+wheel` pinch gestures now reach Cesium with bounded amplification, while ordinary wheel, line-mode and touch-pinch inputs retain their existing diff --git a/SECURITY.md b/SECURITY.md index a21ea99a4..661e8764f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -20,18 +20,18 @@ The golden rule: **secret-bearing API keys stay on the server side.** The dev/pr | `OPENAI_API_KEY` | Server only | Browser fetches a short-lived **ephemeral** Realtime session token from `/api/realtime/token`; the real key never ships | | `AISSTREAM_API_KEY` | Server only | Server holds the AISStream websocket; browser polls the same-origin `/api/ais-live` cache | | OpenSky OAuth (`OPENSKY_CLIENT_ID/SECRET`) | Server only | Server mints + refreshes the token behind `/api/opensky` | -| `GOOGLE_MAPS_SERVER_API_KEY` (optional, #33) | Server only | Server calls Places (`/api/google/nearby-places`, `/api/google/text-search`) and the Street View fallback with this key; falls back to `GOOGLE_MAPS_API_KEY` when unset | +| `GOOGLE_MAPS_SERVER_API_KEY` (optional, #33) | Server only | Server calls Places (`/api/google/nearby-places`, `/api/google/text-search`), Geocoding (`/api/google/geocode`, `/api/google/reverse-geocode`) and the Street View fallback with this key; falls back to `GOOGLE_MAPS_API_KEY` when unset | ### Two deliberately client-side keys — restrict them These are designed to be used directly in the browser (like a Mapbox public token). They are injected into the client bundle via Vite's `define`, so they **will** be visible in browser devtools. Scope and restrict them rather than trying to hide them: -1. **Google Maps API key** — loads Photorealistic 3D Tiles directly and powers GEV place search. **Restrict it** (HTTP referrer + API restriction to the required Google APIs) in the Google Cloud Console. An unrestricted key in a public deployment can be abused and billed to you. +1. **Google Maps API key** — loads Photorealistic 3D Tiles directly. **Restrict it** (HTTP referrer + API restriction to the required Google APIs) in the Google Cloud Console. An unrestricted key in a public deployment can be abused and billed to you. Restrict it to the **Map Tiles API only**: a referrer restriction applies to the Maps JavaScript API and the client SDKs, not to the web services, so a key carrying Geocoding under one is refused with `API keys with referer restrictions cannot be used with this API`. Search does not need it — geocoding goes through the server proxies above (#363). 2. **Cesium ion token** (`CESIUM_ION_TOKEN`, optional — for ion-hosted Google Photorealistic 3D Tiles, Bing world imagery, and world terrain) — used as `Cesium.Ion.defaultAccessToken` client-side. Use a public **`assets:read`** token with **URL restrictions** for any hosted deployment. The Community plan has eligibility and usage limits; a public token is not a secret, but it can still consume the account's quota. > The explicit browser `define` block in `build/vite.js` controls exactly what reaches the client: only these two keys. Everything else stays server-side. -**Places and Street View never needed to be on that list** (#33): they're called from the server-side proxies in the table above, which use `GOOGLE_MAPS_SERVER_API_KEY` when it's set. Splitting it from the browser-exposed key lets each key's Google Cloud restriction actually match what it does — the browser key referrer-restricted to the APIs the client loads, the server key IP-restricted (never a referrer, since it never leaves your server) to Places + Street View Static — instead of one key that has to be either over-permissioned or broken for one of its two jobs. A single shared `GOOGLE_MAPS_API_KEY` still works if you don't split them; it just has to cover every API both sides use. +**Places, Geocoding and Street View never needed to be on that list** (#33, #363): they're called from the server-side proxies in the table above, which use `GOOGLE_MAPS_SERVER_API_KEY` when it's set. Splitting it from the browser-exposed key lets each key's Google Cloud restriction actually match what it does — the browser key referrer-restricted to Map Tiles, the server key IP-restricted (never a referrer, since it never leaves your server) to Places + Geocoding + Street View Static — instead of one key that has to be either over-permissioned or broken for one of its jobs. A single shared `GOOGLE_MAPS_API_KEY` still works if you don't split them, but then it cannot carry a referrer restriction at all: the web services refuse one, and search would be the half that breaks. Never commit real keys. `.env` is gitignored; only `.env.example` (placeholder names) is tracked. On macOS `dev-fresh.sh` can read keys from the Keychain; plain Vite uses env vars or a local `.env`, and Pinokio uses its ignored app `ENVIRONMENT` file. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 9c381ac0c..e038bfe20 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3445,7 +3445,24 @@ Providers normalize coordinates, canonical name, label, place types and optional bounds. Camera framing, nearby landmark recovery and footprint selection remain in their consumers, including the Capitol identity/containment safeguards. Radio keeps localized country names in labels rather than station filters. -Reverse geocoding and nearby/text-search endpoints retain their existing behavior. +Nearby/text-search endpoints retain their existing behavior. + +**Google geocoding is a server route, not a browser call** (#363). Forward and +reverse lookups go to `/api/google/geocode` and `/api/google/reverse-geocode`, +which carry `GOOGLE_MAPS_SERVER_API_KEY` (falling back to `GOOGLE_MAPS_API_KEY`) +and share the Places routes' opt-in `GEV_RATELIMIT_GOOGLE_PER_MIN` throttle. +Google's Geocoding **web service** refuses referrer-restricted keys, so the +previous browser call forced the bundled browser key — visible in devtools by +design — to be left unrestricted; it now needs the Map Tiles API only. Both +routes answer in Google's own `status`/`results` shape rather than the Places +routes' `places: []` contract, because the browser adapters normalize that shape +directly. The browser key still gates the provider client-side, so a keyless +session spends no request to learn it has no Google: the route's own keyless +answer reads as an unanswered provider, which would stop the chain caching the +negative Photon or Nominatim then produces. Its query is capped at 200 +characters, its reverse coordinates are validated against WGS84 bounds before +the request is paid for, and a malformed viewport bias is dropped rather than +refused, since a bias only ranks results. Only valid answers and definitive misses enter bounded caches; malformed replies, HTTP refusals and outages remain retryable. Searches share a 12-second total diff --git a/server/providers/local.js b/server/providers/local.js index 68f61a3cb..07e1a5bb8 100644 --- a/server/providers/local.js +++ b/server/providers/local.js @@ -66,6 +66,9 @@ export { LL2_CACHE_TTL_MS, launchLibraryRequestHeaders } from './space.js'; export { googlePlacesContextProxy } from './places.js'; export { googleServerApiKey } from './places.js'; export { keylessGooglePlacesResponse } from './places.js'; +export { keylessGoogleGeocodeResponse } from './places.js'; +export { validateGeocodeAddress } from './places.js'; +export { geocodeBounds } from './places.js'; export { adsbLolFallbackAnchor } from './aircraft/opensky.js'; export { readResponseTextCapped } from './common/http.js'; export { readResponseJsonCapped } from './common/http.js'; diff --git a/server/providers/places.js b/server/providers/places.js index 648be2aa8..6c36627e0 100644 --- a/server/providers/places.js +++ b/server/providers/places.js @@ -1,8 +1,13 @@ /** Place-search and route middleware for Node development servers. */ -export { googlePlacesContextProxy } from './places/google.js'; +export { + googlePlacesContextProxy, + validateGeocodeAddress, + geocodeBounds, +} from './places/google.js'; export { googleServerApiKey, keylessGooglePlacesResponse, + keylessGoogleGeocodeResponse, } from './places/google-key.js'; export { installRouteMiddleware } from './places/routes.js'; export { diff --git a/server/providers/places/google-key.js b/server/providers/places/google-key.js index 353bc9e24..a5c54b302 100644 --- a/server/providers/places/google-key.js +++ b/server/providers/places/google-key.js @@ -14,12 +14,33 @@ export function keylessGooglePlacesResponse(apiKey) { } /** - * Google API key for the SERVER-SIDE calls (Places nearby/text search, the - * CCTV Street View fallback). These never reach the browser, so this key can - * be restricted by server IP and scoped to Places API + Street View Static - * API — while GOOGLE_MAPS_API_KEY stays referrer-restricted to Map Tiles + - * Geocoding for the browser (#33). Splitting them is opt-in: unset, this - * falls back to the shared browser key and nothing changes. + * Geocoding speaks Google's own response shape rather than the `places: []` + * contract its two siblings use, because the browser adapters read `status` + * and `results` directly. The keyless capability reads the same way: a status + * that is not `OK` and no results, which the adapters already treat as "this + * provider did not answer" and pass to the next geocoder in the chain. + */ +export function keylessGoogleGeocodeResponse(apiKey) { + if (String(apiKey ?? '').trim()) return null; + return { + statusCode: 200, + payload: { + configured: false, + status: 'REQUEST_DENIED', + results: [], + error: null, + }, + }; +} + +/** + * Google API key for the SERVER-SIDE calls (Places nearby/text search, + * geocoding, the CCTV Street View fallback). These never reach the browser, so + * this key can be restricted by server IP and scoped to Places API + Geocoding + * API + Street View Static API — while GOOGLE_MAPS_API_KEY stays + * referrer-restricted to Map Tiles for the browser (#33, #363). Splitting them + * is opt-in: unset, this falls back to the shared browser key, which then has + * to carry every API both sides use and cannot take a referrer restriction. */ export function googleServerApiKey() { return resolveGoogleServerKey(process.env); diff --git a/server/providers/places/google.js b/server/providers/places/google.js index 1ee83a892..8895bc19d 100644 --- a/server/providers/places/google.js +++ b/server/providers/places/google.js @@ -1,6 +1,7 @@ import { googleServerApiKey, keylessGooglePlacesResponse, + keylessGoogleGeocodeResponse, } from './google-key.js'; import { makeOptInRateLimiter, clientKey } from '../common/rate-limit.js'; import { @@ -42,6 +43,37 @@ export function validatePlacesCoordinates(searchParams) { return { ok: true, latitude, longitude }; } +/** Longest accepted geocoder query. Real place names are far shorter. */ +const GEOCODE_MAX_QUERY = 200; + +/** Validate a free-text geocoder query before consuming request quota. */ +export function validateGeocodeAddress(searchParams) { + const address = String(searchParams.get('address') || '').trim(); + if (!address) return { ok: false, error: 'address is required' }; + if (address.length > GEOCODE_MAX_QUERY) + return { ok: false, error: 'address is too long' }; + return { ok: true, address }; +} + +/** + * Validate the optional viewport bias, which Google reads as + * `sw_lat,sw_lng|ne_lat,ne_lng`. An unparsed value is dropped rather than + * refused: the bias only ranks results, so a malformed one costs relevance, + * not an answer. + */ +export function geocodeBounds(searchParams) { + const raw = String(searchParams.get('bounds') || '').trim(); + if (!raw) return null; + const corners = raw.split('|'); + if (corners.length !== 2) return null; + const numbers = corners.flatMap((corner) => corner.split(',').map(Number)); + if (numbers.length !== 4 || !numbers.every(Number.isFinite)) return null; + const [swLat, swLon, neLat, neLon] = numbers; + if (Math.abs(swLat) > 90 || Math.abs(neLat) > 90) return null; + if (Math.abs(swLon) > 180 || Math.abs(neLon) > 180) return null; + return raw; +} + /** Nearby place labels and view-biased text search, with request-time key resolution. */ export function googlePlacesContextProxy({ resolveApiKey = googleServerApiKey, @@ -274,6 +306,129 @@ export function googlePlacesContextProxy({ ); } }); + + /** + * Geocoding belongs on the server, not in the page. Google's Geocoding web + * service refuses referrer-restricted keys, so calling it from the browser + * forces GOOGLE_MAPS_API_KEY — which ships in the bundle by design — to be + * left unrestricted (#363). Here the request carries the server key, and + * the browser key no longer needs the Geocoding API at all. + * + * Unlike the two Places routes above, these answer in Google's own shape + * (`status` + `results`) rather than the `places: []` contract: the browser + * normalizers read that shape directly, and keeping it means the proxy adds + * a hop without adding a translation nobody asked for. + */ + function installGeocodeRoute(path, readQuery) { + middlewares.use(path, async (req, res) => { + const refuse = (statusCode, error) => { + res.statusCode = statusCode; + res.setHeader('Content-Type', 'application/json'); + res.end( + JSON.stringify({ status: 'REQUEST_DENIED', results: [], error }), + ); + }; + + if (req.method !== 'GET') { + refuse(405, 'Method not allowed'); + return; + } + + // A keyless geocode has no provider cost, so it resolves before the + // paid-endpoint limiter can consume or exhaust quota (mirrors the + // Places routes above). + const apiKey = resolveApiKey(); + const keyless = keylessGoogleGeocodeResponse(apiKey); + if (keyless) { + res.statusCode = keyless.statusCode; + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Cache-Control', 'no-store'); + res.end(JSON.stringify(keyless.payload)); + return; + } + + const requestUrl = new URL(req.url || '', 'http://localhost'); + const query = readQuery(requestUrl.searchParams); + if (!query.ok) { + refuse(400, query.error); + return; + } + + // Opt-in per-IP throttle (GEV_RATELIMIT_GOOGLE_PER_MIN), shared with + // the Places routes because both spend the same Google budget. + const _grl = googleRateLimiter(); + if (_grl && !_grl(clientKey(req))) { + res.statusCode = 429; + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Retry-After', '5'); + res.end( + JSON.stringify({ + status: 'OVER_QUERY_LIMIT', + results: [], + error: 'Rate limit exceeded', + }), + ); + return; + } + + try { + const upstream = new URL( + endpoints.geocode || + 'https://maps.googleapis.com/maps/api/geocode/json', + ); + for (const [name, value] of Object.entries(query.params)) + upstream.searchParams.set(name, value); + upstream.searchParams.set('key', apiKey); + const response = await fetchImpl(upstream.toString(), { + redirect: 'error', + }); + const data = await response.json().catch(() => null); + + res.statusCode = response.ok ? 200 : response.status; + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.setHeader('Cache-Control', 'private, max-age=300'); + res.end( + JSON.stringify( + data ?? { + status: 'UNKNOWN_ERROR', + results: [], + error: 'Google Geocoding request failed', + }, + ), + ); + } catch (error) { + res.statusCode = 502; + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.end( + JSON.stringify({ + status: 'UNKNOWN_ERROR', + results: [], + error: error?.message || 'Google Geocoding request failed', + }), + ); + } + }); + } + + installGeocodeRoute('/api/google/geocode', (searchParams) => { + const address = validateGeocodeAddress(searchParams); + if (!address.ok) return address; + const bounds = geocodeBounds(searchParams); + return { + ok: true, + params: { + address: address.address, + ...(bounds ? { bounds } : {}), + }, + }; + }); + + installGeocodeRoute('/api/google/reverse-geocode', (searchParams) => { + const coordinates = validatePlacesCoordinates(searchParams); + if (!coordinates.ok) return coordinates; + const { latitude, longitude } = coordinates; + return { ok: true, params: { latlng: `${latitude},${longitude}` } }; + }); } return { diff --git a/src/annotations/annotationEngine.test.mjs b/src/annotations/annotationEngine.test.mjs index 0d26974ae..cc43b612a 100644 --- a/src/annotations/annotationEngine.test.mjs +++ b/src/annotations/annotationEngine.test.mjs @@ -400,7 +400,7 @@ test('outline upgrade updates the rendered element in place without remove/add', }; let overpassCall = 0; globalThis.fetch = async (url) => { - if (String(url).startsWith('https://maps.googleapis.com/')) { + if (String(url).startsWith('/api/google/geocode')) { return { json: async () => ({ status: 'OK', results: [{ diff --git a/src/annotations/annotationResolver.test.mjs b/src/annotations/annotationResolver.test.mjs index 452d6997c..2c51213c1 100644 --- a/src/annotations/annotationResolver.test.mjs +++ b/src/annotations/annotationResolver.test.mjs @@ -200,7 +200,7 @@ test('ask-side admin bypass: "the Texas Capitol" recovers near-view despite a fa const calls = []; installGoogleMocks(t, async (url) => { calls.push(String(url)); - if (String(url).startsWith('https://maps.googleapis.com/')) { + if (String(url).startsWith('/api/google/geocode')) { return { json: async () => geocodePayload({ lat: 31.0000, lon: -99.0000, @@ -235,7 +235,7 @@ test('ask-side admin bypass: explicit "state of Texas" skips recovery and proxim const calls = []; installGoogleMocks(t, async (url) => { calls.push(String(url)); - assert.match(String(url), /^https:\/\/maps\.googleapis\.com\/maps\/api\/geocode/); + assert.match(String(url), /^\/api\/google\/geocode\?/); return { json: async () => geocodePayload({ lat: 31.0000, lon: -99.0000, @@ -273,7 +273,7 @@ for (const fixture of [ const calls = []; installGoogleMocks(t, async (url) => { calls.push(String(url)); - if (String(url).startsWith('https://maps.googleapis.com/')) { + if (String(url).startsWith('/api/google/geocode')) { return { json: async () => geocodePayload({ lat: fixture.lat, lon: fixture.lon, @@ -299,7 +299,7 @@ test('ask-side admin bypass: bare "Texas" remains on the guarded recovery path', const calls = []; installGoogleMocks(t, async (url) => { calls.push(String(url)); - if (String(url).startsWith('https://maps.googleapis.com/')) { + if (String(url).startsWith('/api/google/geocode')) { return { json: async () => geocodePayload({ lat: 31.0000, lon: -99.0000, @@ -328,7 +328,7 @@ test('ask-side admin bypass: admin level 2/3 result types never grant a township const calls = []; installGoogleMocks(t, async (url) => { calls.push(String(url)); - if (String(url).startsWith('https://maps.googleapis.com/')) { + if (String(url).startsWith('/api/google/geocode')) { const query = new URL(String(url)).searchParams.get('address'); return { json: async () => geocodePayload({ lat: 39.7817, @@ -380,7 +380,7 @@ function installCapitolMocks(t, elements, components = [ { long_name: 'Capitol Hill', types: ['neighborhood', 'political'] }, ]) { installGoogleMocks(t, async (url) => { - if (String(url).startsWith('https://maps.googleapis.com/')) { + if (String(url).startsWith('/api/google/geocode')) { return { json: async () => ({ status: 'OK', results: [{ formatted_address: 'Washington, DC 20004, USA', types: ['establishment', 'landmark', 'point_of_interest', 'tourist_attraction'], @@ -458,7 +458,7 @@ test('keyless: a key that geocodes to nothing still anchors the annotation', asy const requests = []; installGoogleMocks(t, async (url) => { requests.push(String(url)); - if (String(url).startsWith('https://maps.googleapis.com/')) return GOOGLE_FOUND_NOTHING; + if (String(url).startsWith('/api/google/geocode')) return GOOGLE_FOUND_NOTHING; return { ok: true, json: async () => ({ @@ -495,7 +495,7 @@ test('keyless: a key that geocodes to nothing still anchors the annotation', asy high: { latitude: 30.28, longitude: -97.68 }, }); // Google is asked first and exactly once; one Photon call answers it. - assert.equal(requests.filter((url) => url.includes('maps.googleapis.com')).length, 1); + assert.equal(requests.filter((url) => url.includes('/api/google/geocode')).length, 1); assert.equal(requests.filter((url) => url.includes('photon.komoot.io')).length, 1); }); @@ -512,7 +512,7 @@ test('keyless: OSM matching uses the feature\'s canonical name, not the user\'s installGoogleMocks(t, async (url, init) => { const href = String(url); - if (href.startsWith('https://maps.googleapis.com/')) return GOOGLE_FOUND_NOTHING; + if (href.startsWith('/api/google/geocode')) return GOOGLE_FOUND_NOTHING; if (href.startsWith('/api/google/text-search')) return { ok: true, json: async () => ({ places: [] }) }; if (href.startsWith('https://photon.komoot.io/')) { return { @@ -575,7 +575,7 @@ test('keyless: a Photon outage is not remembered as "no such place"', async (t) const requests = []; installGoogleMocks(t, async (url) => { requests.push(String(url)); - if (String(url).startsWith('https://maps.googleapis.com/')) return GOOGLE_FOUND_NOTHING; + if (String(url).startsWith('/api/google/geocode')) return GOOGLE_FOUND_NOTHING; if (!photonReachable) throw new Error('network down'); return photonUp; }); diff --git a/src/googleGeocodeProxy.test.mjs b/src/googleGeocodeProxy.test.mjs new file mode 100644 index 000000000..2518d02cb --- /dev/null +++ b/src/googleGeocodeProxy.test.mjs @@ -0,0 +1,167 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + googlePlacesContextProxy, + validateGeocodeAddress, + geocodeBounds, +} from '../vite.config.js'; + +const UPSTREAM = 'https://geocode.test/json'; + +function installRoutes({ apiKey = 'server-secret', fetchImpl } = {}) { + const routes = new Map(); + googlePlacesContextProxy({ + resolveApiKey: () => apiKey, + fetchImpl, + endpoints: { geocode: UPSTREAM }, + }).configureServer({ + middlewares: { use: (name, handler) => routes.set(name, handler) }, + }); + return routes; +} + +function invokeRoute(handler, { method = 'GET', url = '/' } = {}) { + return new Promise((resolve, reject) => { + const headers = new Map(); + const res = { + statusCode: 200, + setHeader(name, value) { + headers.set(String(name).toLowerCase(), String(value)); + }, + end(body = '') { + resolve({ + statusCode: this.statusCode, + headers: Object.fromEntries(headers), + body: body ? JSON.parse(String(body)) : null, + }); + }, + }; + Promise.resolve( + handler( + { method, url, headers: {}, socket: { remoteAddress: '127.0.0.1' } }, + res, + ), + ).catch(reject); + }); +} + +test('geocoder query and viewport bias are validated before Google is paid', () => { + assert.deepEqual(validateGeocodeAddress(new URLSearchParams()), { + ok: false, + error: 'address is required', + }); + assert.deepEqual(validateGeocodeAddress(new URLSearchParams({ address: ' ' })), { + ok: false, + error: 'address is required', + }); + assert.deepEqual( + validateGeocodeAddress(new URLSearchParams({ address: 'x'.repeat(201) })), + { ok: false, error: 'address is too long' }, + ); + assert.deepEqual( + validateGeocodeAddress(new URLSearchParams({ address: ' Austin ' })), + { ok: true, address: 'Austin' }, + ); + + // A bias only ranks results, so an unusable one is dropped, not refused. + const bias = '30.1,-97.9|30.4,-97.6'; + assert.equal(geocodeBounds(new URLSearchParams({ bounds: bias })), bias); + for (const bounds of [ + '', + '30.1,-97.9', + '30.1,-97.9|30.4', + 'north,-97.9|30.4,-97.6', + '91,-97.9|30.4,-97.6', + '30.1,-181|30.4,-97.6', + ]) + assert.equal(geocodeBounds(new URLSearchParams({ bounds })), null); +}); + +test('the geocode routes keep the key server-side and answer in Google shape', async () => { + const upstream = []; + const routes = installRoutes({ + fetchImpl: async (url) => { + upstream.push(new URL(String(url))); + return { + ok: true, + json: async () => ({ status: 'OK', results: [{ place_id: 'fixture' }] }), + }; + }, + }); + const forward = routes.get('/api/google/geocode'); + const reverse = routes.get('/api/google/reverse-geocode'); + assert.equal(typeof forward, 'function'); + assert.equal(typeof reverse, 'function'); + + const forwarded = await invokeRoute(forward, { + url: '/?address=Austin&bounds=30.1,-97.9|30.4,-97.6', + }); + assert.equal(forwarded.statusCode, 200); + assert.deepEqual(forwarded.body, { + status: 'OK', + results: [{ place_id: 'fixture' }], + }); + assert.equal(upstream.at(-1).searchParams.get('address'), 'Austin'); + assert.equal(upstream.at(-1).searchParams.get('bounds'), '30.1,-97.9|30.4,-97.6'); + assert.equal(upstream.at(-1).searchParams.get('key'), 'server-secret'); + + const reversed = await invokeRoute(reverse, { url: '/?lat=30.27&lon=-97.74' }); + assert.equal(reversed.statusCode, 200); + assert.equal(upstream.at(-1).searchParams.get('latlng'), '30.27,-97.74'); + assert.equal(upstream.at(-1).searchParams.get('key'), 'server-secret'); + + // The key travels to Google, never back to the page that asked. + assert.ok(!JSON.stringify(forwarded.body).includes('server-secret')); + assert.ok(!JSON.stringify(reversed.body).includes('server-secret')); + + // A malformed bias is dropped rather than forwarded. + await invokeRoute(forward, { url: '/?address=Austin&bounds=north' }); + assert.equal(upstream.at(-1).searchParams.has('bounds'), false); +}); + +test('a bad or keyless geocode request never reaches Google', async () => { + const upstream = []; + const fetchImpl = async (url) => { + upstream.push(String(url)); + return { ok: true, json: async () => ({ status: 'OK', results: [] }) }; + }; + + const routes = installRoutes({ fetchImpl }); + for (const [path, url] of [ + ['/api/google/geocode', '/?address='], + ['/api/google/reverse-geocode', '/?lat=91&lon=-97.74'], + ]) { + const response = await invokeRoute(routes.get(path), { url }); + assert.equal(response.statusCode, 400); + assert.deepEqual(response.body.results, []); + assert.equal(response.body.status, 'REQUEST_DENIED'); + } + + for (const path of ['/api/google/geocode', '/api/google/reverse-geocode']) { + const rejected = await invokeRoute(routes.get(path), { + method: 'POST', + url: '/?address=Austin&lat=30&lon=-97', + }); + assert.equal(rejected.statusCode, 405); + } + + // Keyless is an empty capability, not an outage: a 200 the browser adapters + // read as "this provider did not answer", so the chain moves on. It resolves + // ahead of validation, like the Places routes: with nothing configured there + // is no request to validate and no quota to protect. + const keyless = installRoutes({ apiKey: ' ', fetchImpl }); + for (const path of ['/api/google/geocode', '/api/google/reverse-geocode']) + for (const url of ['/?address=Austin&lat=30&lon=-97', '/?address=&lat=91']) { + const response = await invokeRoute(keyless.get(path), { url }); + assert.equal(response.statusCode, 200); + assert.equal(response.headers['cache-control'], 'no-store'); + assert.deepEqual(response.body, { + configured: false, + status: 'REQUEST_DENIED', + results: [], + error: null, + }); + } + + assert.deepEqual(upstream, []); +}); diff --git a/src/googleServerKey.test.mjs b/src/googleServerKey.test.mjs index 0851afcd5..2789219f5 100644 --- a/src/googleServerKey.test.mjs +++ b/src/googleServerKey.test.mjs @@ -67,11 +67,19 @@ test('the Street View tool resolves per-variable overrides before preferring the } }); -test('both Places routes select the intended key and keep it out of responses', async (t) => { +test('every Google route selects the intended key and keeps it out of responses', async (t) => { const original = { server: process.env.GOOGLE_MAPS_SERVER_API_KEY, browser: process.env.GOOGLE_MAPS_API_KEY, limit: process.env.GEV_RATELIMIT_GOOGLE_PER_MIN }; const calls = []; t.mock.method(globalThis, 'fetch', async (url, options) => { - calls.push({ url, key: options.headers['X-Goog-Api-Key'] }); + // Places sends the key as a header; Geocoding's web service takes it as a + // query parameter. Either way it must be the server key, and never the + // browser's copy of it. + calls.push({ + url, + key: + options?.headers?.['X-Goog-Api-Key'] ?? + new URL(String(url)).searchParams.get('key'), + }); return Response.json({ places: [] }); }); try { @@ -88,12 +96,12 @@ test('both Places routes select the intended key and keep it out of responses', for (const install of ['configureServer', 'configurePreviewServer']) { const routes = new Map(); googlePlacesContextProxy()[install]({ middlewares: { use: (name, handler) => routes.set(name, handler) } }); - assert.equal(routes.size, 2); + assert.equal(routes.size, 4); for (const handler of routes.values()) { const before = calls.length; let body; const res = { setHeader() {}, end(value) { body = value; } }; - await handler({ method: 'GET', url: '/?lat=30&lon=-97&q=capitol', headers: {}, socket: { remoteAddress: '127.0.0.1' } }, res); + await handler({ method: 'GET', url: '/?lat=30&lon=-97&q=capitol&address=capitol', headers: {}, socket: { remoteAddress: '127.0.0.1' } }, res); assert.equal(res.statusCode, 200); assert.ok(!body.includes('server-secret')); if (expected) { diff --git a/src/search/defaults.js b/src/search/defaults.js index 2b0428025..247512405 100644 --- a/src/search/defaults.js +++ b/src/search/defaults.js @@ -40,18 +40,23 @@ export function createDefaultPlaceSearch({ ...(selected ? [selected] : [ + // Google answers through the local proxy, which holds the server + // key: its Geocoding web service refuses referrer-restricted keys, + // so calling it from here would force the bundled browser key to be + // left unrestricted (#363). That key still gates the provider, + // because a keyless session must not spend a request to learn it + // has no Google: the proxy's "not configured" reads as an + // unanswered provider, which stops the chain caching the negative + // Photon or Nominatim then produces. createGoogleGeocoder({ request(query, { bias, signal }) { - const key = resolveApiKey?.(); - if (!key) return null; - const url = new URL( - endpoints.geocode || - 'https://maps.googleapis.com/maps/api/geocode/json', + if (!resolveApiKey?.()) return null; + const params = new URLSearchParams({ address: query }); + if (bias) params.set('bounds', bias); + return fetchImpl( + `${endpoints.geocode || '/api/google/geocode'}?${params}`, + { signal }, ); - url.searchParams.set('address', query); - url.searchParams.set('key', key); - if (bias) url.searchParams.set('bounds', bias); - return fetchImpl(url.toString(), { signal }); }, }), createPhotonGeocoder({ fetchImpl, endpoint: endpoints.photon }), diff --git a/src/search/http.js b/src/search/http.js index 5b1642c67..5afa35b75 100644 --- a/src/search/http.js +++ b/src/search/http.js @@ -53,7 +53,9 @@ export function createHttpGeospatialProvider({ endpoints = {}, } = {}) { const urls = { - reverse: 'https://maps.googleapis.com/maps/api/geocode/json', + // Same-origin like its siblings below: Google's Geocoding web service + // refuses referrer-restricted keys, so the key stays on the server (#363). + reverse: '/api/google/reverse-geocode', textSearch: '/api/google/text-search', nearby: '/api/google/nearby-places', route: '/api/route', @@ -88,14 +90,11 @@ export function createHttpGeospatialProvider({ }, routeProfiles: ['foot', 'car', 'bike'], async reverseGeocode(latitude, longitude, options) { - const key = resolveApiKey?.(); - if (resolveApiKey && !key) return null; + // The browser key gates the provider without travelling with the + // request: the proxy supplies the key Google actually sees. + if (resolveApiKey && !resolveApiKey()) return null; return normalizeGoogleReverse( - await json( - urls.reverse, - { latlng: `${latitude},${longitude}`, ...(key ? { key } : {}) }, - options, - ), + await json(urls.reverse, { lat: latitude, lon: longitude }, options), ); }, async textSearch(query, { latitude, longitude, radiusM = 6000 }, options) { diff --git a/src/search/placeSearch.test.mjs b/src/search/placeSearch.test.mjs index e609f9da9..05e1aaea0 100644 --- a/src/search/placeSearch.test.mjs +++ b/src/search/placeSearch.test.mjs @@ -20,7 +20,7 @@ test('standalone geocoding falls back after Google connection, JSON and refusal for (const fail of [() => { throw new Error('offline'); }, () => new Response('invalid json'), () => Response.json({ status: 'REQUEST_DENIED' })]) { const urls = []; const service = createStandalonePlaceSearch({ resolveApiKey: () => 'fixture', fetchImpl: async (url) => { - urls.push(url); return url.includes('maps.googleapis.com') ? fail() : hit(); + urls.push(url); return url.includes('/api/google/geocode') ? fail() : hit(); } }); const result = await service.geocode('Hanoi'); assert.equal(result.place.name, 'Hà Nội'); diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index ba0ed9cf7..1d8d28c97 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -3082,7 +3082,7 @@ test('voice Radio: a key that geocodes to nothing still places the station, keyl const requests = []; installKeyedFetch(t, async (url) => { requests.push(String(url)); - if (String(url).startsWith('https://maps.googleapis.com/')) { + if (String(url).startsWith('/api/google/geocode')) { return { ok: true, json: async () => ({ status: 'ZERO_RESULTS', results: [] }) }; } assert.match(String(url), /^https:\/\/photon\.komoot\.io\/api\/\?/); @@ -3107,7 +3107,7 @@ test('voice Radio: a key that geocodes to nothing still places the station, keyl assert.ok(Math.abs(calls[0].criteria.anchor.lat - 20.9101) < 1e-9); assert.ok(Math.abs(calls[0].criteria.anchor.lon - 107.1839) < 1e-9); // Google is asked first and exactly once; Photon answers unbiased, in one call. - assert.equal(requests.filter((url) => url.includes('maps.googleapis.com')).length, 1); + assert.equal(requests.filter((url) => url.includes('/api/google/geocode')).length, 1); assert.equal(requests.filter((url) => url.includes('photon.komoot.io')).length, 1); assert.match(requests.at(-1), /[?&]q=H%E1%BA%A1\+Long\+Bay/); assert.doesNotMatch(requests.at(-1), /[?&](lat|lon|bbox)=/, 'a named radio location is not viewport-biased'); @@ -3120,7 +3120,7 @@ test('voice Radio: the keyless path applies no country filter the keyed path wou // "No Radio station matched" for exactly the places it just resolved, while a // keyed install placed a station. The label may carry it; the filter may not. const { calls, dataManager } = radioSelectionHarness(); - installKeyedFetch(t, async (url) => (String(url).startsWith('https://maps.googleapis.com/') + installKeyedFetch(t, async (url) => (String(url).startsWith('/api/google/geocode') ? { ok: true, json: async () => ({ status: 'ZERO_RESULTS', results: [] }) } : { ok: true, From 771c9e5632ae6f0177adc09bc4ab4c34b4698765 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niccol=C3=B2=20Saggese?= Date: Mon, 21 Sep 2026 11:56:33 +0200 Subject: [PATCH 006/320] fix(search): bound and sanitize the geocode proxy - reject blank viewport-bias components before Number() reads them as 0 - give the upstream call a 10s deadline and a 256 KiB capped body read - return fixed public text on failure instead of the upstream message --- server/providers/places/google.js | 60 +++++++++++++++++++++++-------- src/googleGeocodeProxy.test.mjs | 47 +++++++++++++++++++++--- 2 files changed, 88 insertions(+), 19 deletions(-) diff --git a/server/providers/places/google.js b/server/providers/places/google.js index 8895bc19d..a4aa48a78 100644 --- a/server/providers/places/google.js +++ b/server/providers/places/google.js @@ -4,6 +4,7 @@ import { keylessGoogleGeocodeResponse, } from './google-key.js'; import { makeOptInRateLimiter, clientKey } from '../common/rate-limit.js'; +import { readResponseJsonCapped } from '../common/http.js'; import { projectNearbyPlaces, projectTextSearchPlaces, @@ -46,6 +47,16 @@ export function validatePlacesCoordinates(searchParams) { /** Longest accepted geocoder query. Real place names are far shorter. */ const GEOCODE_MAX_QUERY = 200; +/** + * Upstream deadline and body ceiling for the geocode routes. The deadline stays + * armed through body consumption, so a stalled upstream cannot hold a handler + * open, and the cap bounds a chunked or length-omitted reply. A geocode answer + * is a handful of results; 256 KiB is far above any honest one. + */ +const GEOCODE_UPSTREAM_TIMEOUT_MS = 10_000; + +const GEOCODE_MAX_RESPONSE_BYTES = 256 * 1024; + /** Validate a free-text geocoder query before consuming request quota. */ export function validateGeocodeAddress(searchParams) { const address = String(searchParams.get('address') || '').trim(); @@ -66,8 +77,12 @@ export function geocodeBounds(searchParams) { if (!raw) return null; const corners = raw.split('|'); if (corners.length !== 2) return null; - const numbers = corners.flatMap((corner) => corner.split(',').map(Number)); - if (numbers.length !== 4 || !numbers.every(Number.isFinite)) return null; + const parts = corners.flatMap((corner) => corner.split(',')); + // A blank component must be refused before Number() turns it into 0 and a + // half-written corner like `30.4,` reads as the equator. + if (parts.length !== 4 || parts.some((part) => !part.trim())) return null; + const numbers = parts.map(Number); + if (!numbers.every(Number.isFinite)) return null; const [swLat, swLon, neLat, neLon] = numbers; if (Math.abs(swLat) > 90 || Math.abs(neLat) > 90) return null; if (Math.abs(swLon) > 180 || Math.abs(neLon) > 180) return null; @@ -379,31 +394,48 @@ export function googlePlacesContextProxy({ for (const [name, value] of Object.entries(query.params)) upstream.searchParams.set(name, value); upstream.searchParams.set('key', apiKey); + const deadline = AbortSignal.timeout(GEOCODE_UPSTREAM_TIMEOUT_MS); const response = await fetchImpl(upstream.toString(), { redirect: 'error', + signal: deadline, }); - const data = await response.json().catch(() => null); + // An unreadable, oversized or timed-out body is not an answer: it + // falls through to the same shaped failure as a refusal below. + const data = await readResponseJsonCapped( + response, + GEOCODE_MAX_RESPONSE_BYTES, + deadline, + ).catch(() => null); + + // A body we could not read is an upstream failure, not an answer with + // a status of its own, so it does not borrow the upstream's code. + if (!data) { + res.statusCode = 502; + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.end( + JSON.stringify({ + status: 'UNKNOWN_ERROR', + results: [], + error: 'Google Geocoding request failed', + }), + ); + return; + } res.statusCode = response.ok ? 200 : response.status; res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.setHeader('Cache-Control', 'private, max-age=300'); - res.end( - JSON.stringify( - data ?? { - status: 'UNKNOWN_ERROR', - results: [], - error: 'Google Geocoding request failed', - }, - ), - ); - } catch (error) { + res.end(JSON.stringify(data)); + } catch { + // Fixed public text: a transport, URL or runtime message would + // describe the server to the browser (SECURITY.md, sanitized errors). res.statusCode = 502; res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.end( JSON.stringify({ status: 'UNKNOWN_ERROR', results: [], - error: error?.message || 'Google Geocoding request failed', + error: 'Google Geocoding request failed', }), ); } diff --git a/src/googleGeocodeProxy.test.mjs b/src/googleGeocodeProxy.test.mjs index 2518d02cb..98ffd876e 100644 --- a/src/googleGeocodeProxy.test.mjs +++ b/src/googleGeocodeProxy.test.mjs @@ -73,6 +73,10 @@ test('geocoder query and viewport bias are validated before Google is paid', () 'north,-97.9|30.4,-97.6', '91,-97.9|30.4,-97.6', '30.1,-181|30.4,-97.6', + // A blank component must not read as 0 — that is the equator, not a corner. + '30.1,-97.9|30.4,', + '30.1,|30.4,-97.6', + '30.1,-97.9| ,-97.6', ]) assert.equal(geocodeBounds(new URLSearchParams({ bounds })), null); }); @@ -82,10 +86,7 @@ test('the geocode routes keep the key server-side and answer in Google shape', a const routes = installRoutes({ fetchImpl: async (url) => { upstream.push(new URL(String(url))); - return { - ok: true, - json: async () => ({ status: 'OK', results: [{ place_id: 'fixture' }] }), - }; + return Response.json({ status: 'OK', results: [{ place_id: 'fixture' }] }); }, }); const forward = routes.get('/api/google/geocode'); @@ -123,7 +124,7 @@ test('a bad or keyless geocode request never reaches Google', async () => { const upstream = []; const fetchImpl = async (url) => { upstream.push(String(url)); - return { ok: true, json: async () => ({ status: 'OK', results: [] }) }; + return Response.json({ status: 'OK', results: [] }); }; const routes = installRoutes({ fetchImpl }); @@ -165,3 +166,39 @@ test('a bad or keyless geocode request never reaches Google', async () => { assert.deepEqual(upstream, []); }); + +test('an upstream failure reaches the browser as fixed public text', async () => { + const routes = installRoutes({ + fetchImpl: async () => { + throw new Error('connect ECONNREFUSED 10.0.0.7:443'); + }, + }); + for (const path of ['/api/google/geocode', '/api/google/reverse-geocode']) { + const response = await invokeRoute(routes.get(path), { + url: '/?address=Austin&lat=30&lon=-97', + }); + assert.equal(response.statusCode, 502); + assert.deepEqual(response.body, { + status: 'UNKNOWN_ERROR', + results: [], + error: 'Google Geocoding request failed', + }); + } +}); + +test('an oversized upstream body is dropped rather than buffered', async () => { + const routes = installRoutes({ + // Two hundred times the 256 KiB ceiling, declared honestly. + fetchImpl: async () => + Response.json({ status: 'OK', results: [{ pad: 'x'.repeat(52_428_800) }] }), + }); + const response = await invokeRoute(routes.get('/api/google/geocode'), { + url: '/?address=Austin', + }); + assert.equal(response.statusCode, 502); + assert.deepEqual(response.body, { + status: 'UNKNOWN_ERROR', + results: [], + error: 'Google Geocoding request failed', + }); +}); From 317c0a7e0c63acf32c6f6717bd9afd05e752cb03 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 23 Sep 2026 14:45:54 +0100 Subject: [PATCH 007/320] feat(ui): add panelResize geometry for edge and corner resizing Pure box arithmetic for resizing a floating panel from any of the eight compass handles: the named edges follow the pointer, the opposite edge stays pinned, and minimum size plus the viewport margin win. Owned by the same boundary groups as PanelPositionControls, which wires it next. Co-Authored-By: Claude Fable 5.1 --- scripts/format-scope.json | 1 + scripts/package-boundaries.json | 3 ++ src/ui/panelResize.js | 65 +++++++++++++++++++++++++++++++++ src/ui/panelResize.test.mjs | 64 ++++++++++++++++++++++++++++++++ 4 files changed, 133 insertions(+) create mode 100644 src/ui/panelResize.js create mode 100644 src/ui/panelResize.test.mjs diff --git a/scripts/format-scope.json b/scripts/format-scope.json index caffd41d2..cc248f38d 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -147,6 +147,7 @@ "src/ui/panelRails.js", "src/ui/leftPanelRail.js", "src/ui/rightPanelRail.js", + "src/ui/panelResize.test.mjs", "src/ui/panelMeasurement.js", "src/ui/panelRailGeometry.js", "src/ui/panelRails.test.mjs", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 433f5f36a..8e6cda2cd 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -553,6 +553,7 @@ "src/ui/panelLayoutController.js", "src/ui/panelMeasurement.js", "src/ui/panelPositionControls.js", + "src/ui/panelResize.js", "src/ui/panelRailGeometry.js", "src/ui/panelRails.js", "src/ui/radio.js", @@ -1670,6 +1671,7 @@ "src/ui/panelLayoutController.js", "src/ui/panelMeasurement.js", "src/ui/panelPositionControls.js", + "src/ui/panelResize.js", "src/ui/panelRailGeometry.js", "src/ui/panelRails.js", "src/ui/radio.js", @@ -2327,6 +2329,7 @@ "src/ui/panelLayoutController.js", "src/ui/panelMeasurement.js", "src/ui/panelPositionControls.js", + "src/ui/panelResize.js", "src/ui/panelRailGeometry.js", "src/ui/panelRails.js", "src/ui/rightPanelRail.js", diff --git a/src/ui/panelResize.js b/src/ui/panelResize.js new file mode 100644 index 000000000..4472e7a01 --- /dev/null +++ b/src/ui/panelResize.js @@ -0,0 +1,65 @@ +/** + * Pure geometry for resizing a floating panel from any edge or corner. The + * DOM wiring lives in PanelPositionControls; this module only decides boxes. + */ + +/** Viewport margin a resized panel keeps clear on every side. */ +const EDGE_MARGIN_PX = 6; +/** Resize directions: compass edges first, then corners. */ +export const RESIZE_DIRECTIONS = ['n', 's', 'e', 'w', 'ne', 'nw', 'sw', 'se']; + +function clamp(value, min, max) { + return Math.max(min, Math.min(max, value)); +} + +/** + * Work out the box a resize gesture produces. Edges named in `dir` move with + * the pointer; the opposite edge stays put, so growing from the left keeps + * the right side pinned. Minimum size and the viewport margin win over the + * pointer. + * @param {{left: number, top: number, width: number, height: number}} box Box at gesture start. + * @param {string} dir One of RESIZE_DIRECTIONS. + * @param {number} dx Pointer travel since gesture start. + * @param {number} dy Pointer travel since gesture start. + * @param {object} [limits] + * @param {number} limits.minWidth + * @param {number} limits.minHeight + * @param {number} [limits.viewportWidth=Infinity] + * @param {number} [limits.viewportHeight=Infinity] + * @param {number} [limits.margin=6] + * @returns {{left: number, top: number, width: number, height: number}} + */ +export function resizeBox( + box, + dir, + dx, + dy, + { + minWidth, + minHeight, + viewportWidth = Infinity, + viewportHeight = Infinity, + margin = EDGE_MARGIN_PX, + } = {}, +) { + let { left, top, width, height } = box; + const right = box.left + box.width; + const bottom = box.top + box.height; + if (dir.includes('e')) + width = clamp(box.width + dx, minWidth, viewportWidth - margin - box.left); + if (dir.includes('s')) + height = clamp( + box.height + dy, + minHeight, + viewportHeight - margin - box.top, + ); + if (dir.includes('w')) { + width = clamp(box.width - dx, minWidth, right - margin); + left = right - width; + } + if (dir.includes('n')) { + height = clamp(box.height - dy, minHeight, bottom - margin); + top = bottom - height; + } + return { left, top, width, height }; +} diff --git a/src/ui/panelResize.test.mjs b/src/ui/panelResize.test.mjs new file mode 100644 index 000000000..3e1577665 --- /dev/null +++ b/src/ui/panelResize.test.mjs @@ -0,0 +1,64 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { resizeBox } from './panelResize.js'; + +const box = { left: 100, top: 100, width: 400, height: 300 }; +const limits = { + minWidth: 200, + minHeight: 150, + viewportWidth: 1000, + viewportHeight: 800, +}; + +test('east and south edges grow away from the anchored corner', () => { + assert.deepEqual(resizeBox(box, 'se', 50, 20, limits), { + left: 100, + top: 100, + width: 450, + height: 320, + }); + assert.deepEqual(resizeBox(box, 'e', -30, 999, limits), { + left: 100, + top: 100, + width: 370, + height: 300, + }); +}); + +test('west and north edges keep the opposite edge pinned', () => { + assert.deepEqual(resizeBox(box, 'w', -40, 0, limits), { + left: 60, + top: 100, + width: 440, + height: 300, + }); + assert.deepEqual(resizeBox(box, 'nw', 40, 30, limits), { + left: 140, + top: 130, + width: 360, + height: 270, + }); + assert.deepEqual( + resizeBox(box, 'n', -50, 0, limits).top, + 100, + 'horizontal motion does not move a north edge', + ); +}); + +test('minimum size stops the moving edge, not the anchored one', () => { + const small = resizeBox(box, 'w', 350, 0, limits); + assert.deepEqual(small, { left: 300, top: 100, width: 200, height: 300 }); + assert.equal(resizeBox(box, 'ne', 0, 400, limits).height, 150); + assert.equal(resizeBox(box, 'ne', 0, 400, limits).top, 250); +}); + +test('the viewport margin caps growth in every direction', () => { + assert.equal(resizeBox(box, 'e', 2000, 0, limits).width, 1000 - 6 - 100); + assert.equal(resizeBox(box, 's', 0, 2000, limits).height, 800 - 6 - 100); + const west = resizeBox(box, 'w', -2000, 0, limits); + assert.equal(west.left, 6); + assert.equal(west.left + west.width, 500); + const north = resizeBox(box, 'n', 0, -2000, limits); + assert.equal(north.top, 6); + assert.equal(north.top + north.height, 400); +}); From 96a1e710eab7a7bfa6ba26589740cca8c6b6c592 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 23 Sep 2026 15:06:29 +0100 Subject: [PATCH 008/320] feat(ui): portable, resizable rail panels for CCTV A drag spec can now be `portable`: dragging the panel by its header lifts it out of the right rail into a fixed window, resizable from all four edges and corners, that remembers its place and size under the existing godsEyeView.v8.panelPos. key ({ left, top, width?, height?, floating }) and snaps back on a header double-click. Only CCTV opts in here; the rail skips .panel-floating panels when measuring and allocating, and its placement rules in layers.css are guarded so no !important is needed. PanelPositionControls gains an optional onPanelResized hook, threaded through PanelChrome to a no-op on the application shell. Co-Authored-By: Claude Fable 5.1 --- src/rightRailPolicy.test.mjs | 6 +- src/ui/applicationShell.js | 10 + src/ui/panelChrome.js | 2 + src/ui/panelPositionControls.js | 390 ++++++++++++++++++++++++++++++-- src/ui/panelRails.test.mjs | 5 +- src/ui/rightPanelRail.js | 4 +- src/ui/styles/controls.css | 110 +++++++++ src/ui/styles/layers.css | 20 +- src/ui/styles/responsive.css | 4 +- 9 files changed, 525 insertions(+), 26 deletions(-) diff --git a/src/rightRailPolicy.test.mjs b/src/rightRailPolicy.test.mjs index 2254ea24e..b727e6d12 100644 --- a/src/rightRailPolicy.test.mjs +++ b/src/rightRailPolicy.test.mjs @@ -45,7 +45,11 @@ test('desktop Display participates in Tactical exclusivity without changing mobi /panel\.id !== 'pp-toggles' && !panel\.classList\.contains\('collapsed'\)/, ); assert.match(ui, /if \(exclusive && panel\.classList\.contains\('collapsed'\)\)\s*panel\.setAttribute\('aria-hidden', 'true'\)/); - assert.match(css, /#right-context-rail\.layout-exclusive > \[data-panel-id\]\.collapsed \{/); + // A panel floating out of the rail (portable mode) is exempt from exclusivity. + assert.match( + css, + /#right-context-rail\.layout-exclusive\s*> \[data-panel-id\]\.collapsed:not\(\.panel-floating\) \{/, + ); }); test('explicit Contacts, Space Missions, and Cockpit actions expand Global Context after success', () => { diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index b22c21c3f..3a01f97fe 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -96,6 +96,7 @@ export class StyleManager extends ShellFacade { _setRadioDisclosure: (...args) => this._setRadioDisclosure(...args), _syncCctvPanelViewport: (...args) => this._syncCctvPanelViewport(...args), + _onPanelResized: (...args) => this._onPanelResized(...args), _syncContextRadioLauncherState: (...args) => this._syncContextRadioLauncherState(...args), _showToast: (...args) => this._showToast(...args), @@ -1409,6 +1410,15 @@ export class StyleManager extends ShellFacade { this._initCockpitDisplayPortal(); } + /** + * A portable panel finished a resize or snapped back to its rail. Nothing + * in the shell depends on it yet; a panel with a viewport of its own can + * refit here. + * @param {string} _panelId + * @returns {void} + */ + _onPanelResized(_panelId) {} + /** * Recalculates the CCTV panel max-height based on its current top position * and the window height, enabling internal scroll without viewport overflow. diff --git a/src/ui/panelChrome.js b/src/ui/panelChrome.js index 2562f137b..66e469a45 100644 --- a/src/ui/panelChrome.js +++ b/src/ui/panelChrome.js @@ -56,6 +56,7 @@ export class PanelChrome { layoutRightPanels: () => this._layoutRightPanels(), syncCctvPanelViewport: () => this._syncCctvPanelViewport(), showToast: (message) => this._showToast(message), + onPanelResized: (panelId) => this._onPanelResized(panelId), }); this._panelLayout = new PanelLayoutController({ readHud: () => ({ @@ -139,6 +140,7 @@ export class PanelChrome { this._initCommandDockPins(); this._initCommandDockTrayMetrics(); this._maybeNotifyLayoutReset(); + this._panelPosition._initPanelDrag(); } _collapsePanelOnEscape(event, panelId) { diff --git a/src/ui/panelPositionControls.js b/src/ui/panelPositionControls.js index 40d1c319f..ca85593a5 100644 --- a/src/ui/panelPositionControls.js +++ b/src/ui/panelPositionControls.js @@ -1,4 +1,6 @@ /** Own panel position preferences, viewport clamping and drag listeners. */ +import { RESIZE_DIRECTIONS, resizeBox } from './panelResize.js'; + /** Versioned localStorage namespace prefix to invalidate stale panel layouts. */ const PANEL_LAYOUT_STORAGE_VERSION = 'v6'; /** @@ -10,40 +12,93 @@ const PANEL_POSITION_STORAGE_VERSION = 'v8'; /** Z ladder: panels promote within [100, 139]; voice pill 150, toast 200, clean-view-exit 300. */ const PANEL_Z_BASE = 100; const PANEL_Z_MAX = 139; +/** Pointer travel before a header press becomes a drag that lifts a portable panel out. */ +const DRAG_THRESHOLD_PX = 4; +/** Viewport margin every positioned panel keeps clear. */ +const VIEWPORT_MARGIN_PX = 6; +/** One-time hint shown the first time a portable panel leaves its rail. */ +const PANEL_FLOAT_HINT_STORAGE_KEY = `godsEyeView.${PANEL_POSITION_STORAGE_VERSION}.panelFloatHintShown`; +const PANEL_FLOAT_HINT = 'Double-click the header to snap the panel back'; +/** Header children whose own interaction wins over a drag or a snap-back. */ +const INTERACTIVE_SELECTOR = + 'input, select, option, textarea, button, a, [role="button"]'; +/** Inline geometry a snap-back clears so the rail owns placement again. */ +const FLOATING_STYLE_PROPERTIES = [ + 'left', + 'top', + 'right', + 'bottom', + 'width', + 'height', +]; + +function clamp(value, min, max) { + return Math.max(min, Math.min(max, value)); +} + export class PanelPositionControls { + /** + * @param {object} options + * @param {Function} options.syncPanelCollapseButton + * @param {Function} options.layoutRightPanels + * @param {Function} options.syncCctvPanelViewport + * @param {Function} options.showToast + * @param {(panelId: string) => void} [options.onPanelResized] Fires after a + * portable panel's resize gesture ends or the panel snaps back to its rail. + */ constructor({ syncPanelCollapseButton, layoutRightPanels, syncCctvPanelViewport, showToast, + onPanelResized = null, }) { this._syncPanelCollapseButton = syncPanelCollapseButton; this._layoutRightPanels = layoutRightPanels; this._syncCctvPanelViewport = syncCctvPanelViewport; this._showToast = showToast; + this._onPanelResized = onPanelResized; this._ppToggles = document.getElementById('pp-toggles'); this._panelZCounter = PANEL_Z_BASE + 10; this._draggableResizeObserver = null; this._cancelDrag = null; + /** Portable panels by id: `{ panel, min: { width, height } }`. */ + this._portablePanels = new Map(); + this._resizeHandles = []; + this._dragInitialized = false; this.removers = []; this.destroyed = false; } - listen(target, type, callback) { + listen(target, type, callback, options) { if (this.destroyed || !target) return; const listener = (event) => { if (!this.destroyed) callback(event); }; - target.addEventListener(type, listener); - this.removers.push(() => target.removeEventListener(type, listener)); + target.addEventListener(type, listener, options); + this.removers.push(() => + target.removeEventListener(type, listener, options), + ); } _reclampDraggablePanels() { if (this.destroyed) return; const el = this._ppToggles; - if (!el || !el.style.top || el.style.top === 'auto') return; - const top = parseInt(el.style.top, 10); - if (!Number.isFinite(top)) return; - el.style.top = `${this._clampToViewport(0, top, el).top}px`; - this._pinPanelToRight(el); + if (el && el.style.top && el.style.top !== 'auto') { + const top = parseInt(el.style.top, 10); + if (Number.isFinite(top)) { + el.style.top = `${this._clampToViewport(0, top, el).top}px`; + this._pinPanelToRight(el); + } + } + // Floating panels only move back on-screen; their size is the user's. + for (const { panel } of this._portablePanels.values()) { + if (!panel.classList.contains('panel-floating')) continue; + const left = parseInt(panel.style.left, 10); + const top = parseInt(panel.style.top, 10); + if (!Number.isFinite(left) || !Number.isFinite(top)) continue; + const next = this._clampToViewport(left, top, panel); + panel.style.left = `${next.left}px`; + panel.style.top = `${next.top}px`; + } } _maybeNotifyLayoutReset() { @@ -65,19 +120,41 @@ export class PanelPositionControls { } _initPanelDrag() { - if (this.destroyed) return; + if (this.destroyed || this._dragInitialized) return; + this._dragInitialized = true; + const cctvPanel = document.getElementById('cctv-panel'); + // A `portable` spec lifts out of its rail on a header drag, resizes from + // every edge and remembers its window; the others only reposition in + // place. Another panel opts in by adding a spec here. const dragSpecs = [ { id: 'pp-toggles', panel: this._ppToggles, handle: this._ppToggles?.querySelector('.panel-drag-handle.compact'), }, + { + id: 'cctv-panel', + panel: cctvPanel, + handle: cctvPanel?.querySelector('.panel-header'), + portable: true, + min: { width: 300, height: 160 }, + }, ].filter(Boolean); for (const spec of dragSpecs) { if (!spec.panel || !spec.handle) continue; + if (spec.portable) { + this._portablePanels.set(spec.id, { + panel: spec.panel, + min: { width: 300, height: 160, ...spec.min }, + }); + spec.panel.classList.add('panel-portable'); + } this._restorePanelPosition(spec.id, spec.panel); this._makePanelDraggable(spec.id, spec.panel, spec.handle); + if (spec.portable) { + this._makePanelResizable(spec.id, spec.panel, spec.min); + } } // Keep a positioned panel on-screen when its HEIGHT changes after restore — it expands to its // full row set a frame or two later, so the restore-time clamp used a stale (shorter) height and @@ -88,6 +165,9 @@ export class PanelPositionControls { ); this._draggableResizeObserver.observe(this._ppToggles); } + if (this._portablePanels.size) { + this.listen(window, 'resize', () => this._reclampDraggablePanels()); + } } _panelStorageKey(panelId) { @@ -151,6 +231,27 @@ export class PanelPositionControls { const pos = JSON.parse(raw); if (!pos || typeof pos.left !== 'number' || typeof pos.top !== 'number') return; + // A floating record only means something to a panel that can float; a + // portable panel in turn only ever stores floating records. + const portable = this._portablePanels.get(panelId); + if (Boolean(pos.floating) !== Boolean(portable)) return; + if (portable) { + panelEl.classList.add('panel-floating', 'panel-draggable'); + if (Number.isFinite(pos.width)) { + panelEl.style.width = `${clamp( + Math.round(pos.width), + portable.min.width, + Math.max(portable.min.width, window.innerWidth - 12), + )}px`; + } + if (Number.isFinite(pos.height)) { + panelEl.style.height = `${clamp( + Math.round(pos.height), + portable.min.height, + Math.max(portable.min.height, window.innerHeight - 12), + )}px`; + } + } // Clamp to the viewport: a position saved at one window size would otherwise land off-screen at // another (audit U2 — observed a panel at x:-192). The drag handler clamps; restore must too. const { left, top } = this._clampToViewport( @@ -182,13 +283,21 @@ export class PanelPositionControls { _savePanelPosition(panelId, panelEl) { const rect = panelEl.getBoundingClientRect(); + const record = { + left: Math.round(rect.left), + top: Math.round(rect.top), + }; + if (this._portablePanels.has(panelId)) { + // Only an explicitly set size is remembered: freezing a collapsed + // panel's measured height would stop it from ever expanding again. + if (panelEl.style.width) record.width = Math.round(rect.width); + if (panelEl.style.height) record.height = Math.round(rect.height); + record.floating = true; + } try { localStorage.setItem( this._panelStorageKey(panelId), - JSON.stringify({ - left: Math.round(rect.left), - top: Math.round(rect.top), - }), + JSON.stringify(record), ); } catch { // storage unavailable @@ -211,11 +320,78 @@ export class PanelPositionControls { panelEl.style.zIndex = String(this._panelZCounter); } + _maybeShowFloatHint() { + try { + if (localStorage.getItem(PANEL_FLOAT_HINT_STORAGE_KEY)) return; + localStorage.setItem(PANEL_FLOAT_HINT_STORAGE_KEY, '1'); + } catch { + // storage unavailable: still worth saying once this session + if (this._floatHintShown) return; + this._floatHintShown = true; + } + this._showToast(PANEL_FLOAT_HINT); + } + + /** + * Switch a portable panel from rail flow to a fixed window at the place it + * currently occupies. Width is frozen so a `width: 100%` rail panel keeps + * its size once fixed-positioned; height stays natural until a resize. + */ + _liftPanelOut(panelId, panelEl, rect = panelEl.getBoundingClientRect()) { + if (panelEl.classList.contains('panel-floating')) return false; + panelEl.style.left = `${Math.round(rect.left)}px`; + panelEl.style.top = `${Math.round(rect.top)}px`; + panelEl.style.right = 'auto'; + panelEl.style.bottom = 'auto'; + if (!panelEl.classList.contains('collapsed')) { + panelEl.style.width = `${Math.round(rect.width)}px`; + } + // Rail bookkeeping no longer applies to a window the rail does not allocate. + panelEl.style.removeProperty('--right-panel-allocated-height'); + panelEl.removeAttribute('aria-hidden'); + panelEl.classList.add('panel-floating', 'panel-draggable'); + this._promotePanelZ(panelEl); + this._layoutRightPanels(); + this._maybeShowFloatHint(); + return true; + } + + /** Snap a floating panel back into its rail and forget its window. */ + _resetPanelPosition(panelId) { + const portable = this._portablePanels.get(panelId); + if (!portable) return; + const panelEl = portable.panel; + this._cancelDrag?.(); + panelEl.classList.remove( + 'panel-floating', + 'panel-draggable', + 'panel-dragging', + 'panel-resizing', + ); + for (const property of FLOATING_STYLE_PROPERTIES) { + panelEl.style.removeProperty(property); + } + try { + localStorage.removeItem(this._panelStorageKey(panelId)); + } catch { + // storage unavailable + } + this._layoutRightPanels(); + if (panelId === 'cctv-panel') { + this._syncCctvPanelViewport(); + } + this._onPanelResized?.(panelId); + } + _makePanelDraggable(panelId, panelEl, handleEl) { // Z-order promotion: bring clicked panel to front of the stacking context this.listen(panelEl, 'pointerdown', () => { this._promotePanelZ(panelEl); }); + if (this._portablePanels.has(panelId)) { + this._makePortablePanelDraggable(panelId, panelEl, handleEl); + return; + } this.listen(handleEl, 'pointerdown', (event) => { if (event.button !== 0) return; @@ -283,11 +459,199 @@ export class PanelPositionControls { window.addEventListener('pointercancel', onUp); }); } + + /** + * Portable drag: a press on the header only becomes a drag after a few + * pixels of travel, at which point the panel lifts out of its rail. The + * collapse button still toggles on a plain click, and the click that ends + * a real drag is swallowed so it cannot toggle by accident. Double-clicking + * the header snaps the panel back. + */ + _makePortablePanelDraggable(panelId, panelEl, handleEl) { + let swallowNextClick = false; + this.listen( + handleEl, + 'click', + (event) => { + if (!swallowNextClick) return; + swallowNextClick = false; + event.stopImmediatePropagation(); + event.preventDefault(); + }, + { capture: true }, + ); + this.listen(handleEl, 'dblclick', (event) => { + if (event.target.closest?.(INTERACTIVE_SELECTOR)) return; + if (!panelEl.classList.contains('panel-floating')) return; + event.preventDefault(); + this._resetPanelPosition(panelId); + }); + this.listen(handleEl, 'pointerdown', (event) => { + if (event.button !== 0) return; + const interactive = event.target.closest?.(INTERACTIVE_SELECTOR); + if (interactive && !interactive.matches('.panel-collapse-btn')) return; + + this._cancelDrag?.(); + // A header press must not start a text selection across the page. + event.preventDefault(); + const startX = event.clientX; + const startY = event.clientY; + let dragging = false; + let offsetX = 0; + let offsetY = 0; + let width = 0; + let height = 0; + + const onMove = (moveEvent) => { + if (!dragging) { + if ( + Math.hypot(moveEvent.clientX - startX, moveEvent.clientY - startY) < + DRAG_THRESHOLD_PX + ) + return; + dragging = true; + const rect = panelEl.getBoundingClientRect(); + offsetX = startX - rect.left; + offsetY = startY - rect.top; + width = rect.width; + height = rect.height; + this._liftPanelOut(panelId, panelEl, rect); + panelEl.classList.add('panel-dragging'); + this._promotePanelZ(panelEl); + } + moveEvent.preventDefault(); + const maxLeft = Math.max( + VIEWPORT_MARGIN_PX, + window.innerWidth - width - VIEWPORT_MARGIN_PX, + ); + const maxTop = Math.max( + VIEWPORT_MARGIN_PX, + window.innerHeight - height - VIEWPORT_MARGIN_PX, + ); + panelEl.style.left = `${clamp(moveEvent.clientX - offsetX, VIEWPORT_MARGIN_PX, maxLeft)}px`; + panelEl.style.top = `${clamp(moveEvent.clientY - offsetY, VIEWPORT_MARGIN_PX, maxTop)}px`; + }; + + const cancel = () => { + panelEl.classList.remove('panel-dragging'); + window.removeEventListener('pointermove', onMove); + window.removeEventListener('pointerup', onUp); + window.removeEventListener('pointercancel', onUp); + this._cancelDrag = null; + }; + const onUp = () => { + cancel(); + if (!dragging) return; + swallowNextClick = true; + setTimeout(() => { + swallowNextClick = false; + }, 0); + this._savePanelPosition(panelId, panelEl); + if (panelId === 'cctv-panel') { + this._syncCctvPanelViewport(); + } + }; + + this._cancelDrag = cancel; + window.addEventListener('pointermove', onMove); + window.addEventListener('pointerup', onUp); + window.addEventListener('pointercancel', onUp); + }); + } + + /** + * Append a resize handle for every edge and corner. Seven are invisible + * strips; the bottom-right corner shows a grip. Resizing a docked panel + * lifts it out first, so the handles work in both states. + */ + _makePanelResizable(panelId, panelEl, min = {}) { + const limits = { width: 300, height: 160, ...min }; + for (const dir of RESIZE_DIRECTIONS) { + const handle = document.createElement('div'); + handle.className = + dir === 'se' ? 'panel-resize-grip' : 'panel-resize-edge'; + handle.dataset.dir = dir; + handle.setAttribute('aria-hidden', 'true'); + if (dir === 'se') { + handle.title = 'Drag to resize · double-click the header to snap back'; + } + panelEl.appendChild(handle); + this._resizeHandles.push(handle); + this.listen(handle, 'pointerdown', (event) => + this._startPanelResize(event, panelId, panelEl, dir, limits), + ); + } + } + + _startPanelResize(event, panelId, panelEl, dir, limits) { + if (event.button !== 0) return; + event.preventDefault(); + this._cancelDrag?.(); + const rect = panelEl.getBoundingClientRect(); + this._liftPanelOut(panelId, panelEl, rect); + const startBox = { + left: Math.round(rect.left), + top: Math.round(rect.top), + width: Math.round(rect.width), + height: Math.round(rect.height), + }; + const startX = event.clientX; + const startY = event.clientY; + const apply = ({ left, top, width, height }) => { + panelEl.style.left = `${left}px`; + panelEl.style.top = `${top}px`; + panelEl.style.width = `${width}px`; + panelEl.style.height = `${height}px`; + }; + panelEl.classList.add('panel-resizing'); + this._promotePanelZ(panelEl); + apply(startBox); + + const onMove = (moveEvent) => { + moveEvent.preventDefault(); + apply( + resizeBox( + startBox, + dir, + moveEvent.clientX - startX, + moveEvent.clientY - startY, + { + minWidth: limits.width, + minHeight: limits.height, + viewportWidth: window.innerWidth, + viewportHeight: window.innerHeight, + }, + ), + ); + }; + const cancel = () => { + panelEl.classList.remove('panel-resizing'); + window.removeEventListener('pointermove', onMove); + window.removeEventListener('pointerup', onUp); + window.removeEventListener('pointercancel', onUp); + this._cancelDrag = null; + }; + const onUp = () => { + cancel(); + this._savePanelPosition(panelId, panelEl); + if (panelId === 'cctv-panel') { + this._syncCctvPanelViewport(); + } + this._onPanelResized?.(panelId); + }; + + this._cancelDrag = cancel; + window.addEventListener('pointermove', onMove); + window.addEventListener('pointerup', onUp); + window.addEventListener('pointercancel', onUp); + } + destroy() { if (this.destroyed) return; this.destroyed = true; this._cancelDrag?.(); for (const remove of this.removers.splice(0)) remove(); + for (const handle of this._resizeHandles.splice(0)) handle.remove(); this._draggableResizeObserver?.disconnect(); this._draggableResizeObserver = null; } diff --git a/src/ui/panelRails.test.mjs b/src/ui/panelRails.test.mjs index 46fbebc5f..2b03c1406 100644 --- a/src/ui/panelRails.test.mjs +++ b/src/ui/panelRails.test.mjs @@ -59,7 +59,10 @@ function element( getBoundingClientRect() { return this.rect; }, - matches: (selector) => selector === '[data-panel-id]', + matches: (selector) => + selector === '[data-panel-id]' || + (selector === '[data-panel-id]:not(.panel-floating)' && + !classes.has('panel-floating')), contains(target) { return ( target === this || this.children.some((child) => child.contains(target)) diff --git a/src/ui/rightPanelRail.js b/src/ui/rightPanelRail.js index 3dd513d9f..1c324a523 100644 --- a/src/ui/rightPanelRail.js +++ b/src/ui/rightPanelRail.js @@ -41,8 +41,10 @@ export function layoutRightPanelRail({ }) { if (!stack) return; + // A panel lifted out of the rail (PanelPositionControls' portable mode) + // is a fixed window the rail neither measures nor allocates. const panels = [...stack.children].filter((panel) => - panel.matches('[data-panel-id]'), + panel.matches('[data-panel-id]:not(.panel-floating)'), ); if (!hud.visible || hud.variant !== 'tactical') { for (const panel of panels.filter((item) => diff --git a/src/ui/styles/controls.css b/src/ui/styles/controls.css index c86be98f1..d2df76850 100644 --- a/src/ui/styles/controls.css +++ b/src/ui/styles/controls.css @@ -103,6 +103,116 @@ opacity: 0.96; } +/* ── Portable rail panels ──────────────────── + A header drag lifts a portable panel (PanelPositionControls) out of its + rail into a fixed window that remembers its place and size. The rail's + own placement rules in layers.css are guarded with :not(.panel-floating), + so these only have to beat a panel's base placement (one id). */ +.panel-portable .panel-header { + cursor: grab; +} +.panel-portable.panel-dragging .panel-header { + cursor: grabbing; +} +#right-context-rail > .panel-floating { + position: fixed; + inset: auto; + max-height: calc(100vh - 12px); + margin: 0; + flex: none; + pointer-events: auto; +} +/* A collapsed window keeps its stored height for later but shows only its + header strip, so the resize handles have nothing to attach to. */ +#right-context-rail > .panel-floating.collapsed { + max-height: max-content; +} +.panel-floating[style*='height'] > .cctv-panel-inner { + box-sizing: border-box; + height: 100%; + max-height: none; +} +.panel-resize-edge, +.panel-resize-grip { + position: absolute; + z-index: 3; + touch-action: none; +} +.panel-resize-edge[data-dir='n'], +.panel-resize-edge[data-dir='s'] { + left: 10px; + right: 10px; + height: 7px; + cursor: ns-resize; +} +.panel-resize-edge[data-dir='n'] { + top: -3px; +} +.panel-resize-edge[data-dir='s'] { + bottom: -3px; +} +.panel-resize-edge[data-dir='e'], +.panel-resize-edge[data-dir='w'] { + top: 10px; + bottom: 10px; + width: 7px; + cursor: ew-resize; +} +.panel-resize-edge[data-dir='e'] { + right: -3px; +} +.panel-resize-edge[data-dir='w'] { + left: -3px; +} +.panel-resize-edge[data-dir='ne'], +.panel-resize-edge[data-dir='nw'], +.panel-resize-edge[data-dir='sw'] { + width: 14px; + height: 14px; +} +.panel-resize-edge[data-dir='ne'] { + top: -3px; + right: -3px; + cursor: nesw-resize; +} +.panel-resize-edge[data-dir='nw'] { + top: -3px; + left: -3px; + cursor: nwse-resize; +} +.panel-resize-edge[data-dir='sw'] { + bottom: -3px; + left: -3px; + cursor: nesw-resize; +} +.panel-resize-grip { + right: 3px; + bottom: 3px; + width: 14px; + height: 14px; + cursor: nwse-resize; + opacity: 0.55; + clip-path: polygon(100% 0, 100% 100%, 0 100%); + background: repeating-linear-gradient( + 135deg, + transparent 0 3px, + rgba(0, 212, 255, 0.6) 3px 4px + ); + transition: opacity var(--transition-fast); +} +.panel-portable:hover .panel-resize-grip, +.panel-resizing .panel-resize-grip { + opacity: 1; +} +.panel-collapsible.collapsed > .panel-resize-edge, +.panel-collapsible.collapsed > .panel-resize-grip { + display: none; +} +.panel-resizing, +.panel-resizing * { + user-select: none; +} + .panel-title { font-family: var(--font-mono); font-size: 9px; diff --git a/src/ui/styles/layers.css b/src/ui/styles/layers.css index 72207fa52..79ad8c268 100644 --- a/src/ui/styles/layers.css +++ b/src/ui/styles/layers.css @@ -161,7 +161,7 @@ max-height var(--transition-fast); } #right-context-rail > #pp-toggles, -#right-context-rail > #cctv-panel, +#right-context-rail > #cctv-panel:not(.panel-floating), #right-context-rail > #global-context-panel { position: relative; inset: auto; @@ -173,12 +173,12 @@ max-height: 100%; pointer-events: auto; } -#right-context-rail #cctv-panel { +#right-context-rail #cctv-panel:not(.panel-floating) { width: 100%; max-height: 100%; pointer-events: auto; } -#right-context-rail #cctv-panel.collapsed { +#right-context-rail #cctv-panel.collapsed:not(.panel-floating) { width: var(--right-display-collapsed-width); margin-left: auto; } @@ -234,18 +234,22 @@ #right-context-rail.layout-focus { height: var(--right-stack-max-height); } -#right-context-rail.layout-exclusive > [data-panel-id].collapsed { +#right-context-rail.layout-exclusive + > [data-panel-id].collapsed:not(.panel-floating) { display: none; } -#right-context-rail.layout-focus > [data-panel-id].collapsed { +#right-context-rail.layout-focus + > [data-panel-id].collapsed:not(.panel-floating) { display: block; flex: 0 0 auto; } -#right-context-rail.layout-focus.layout-exclusive > [data-panel-id].collapsed { +#right-context-rail.layout-focus.layout-exclusive + > [data-panel-id].collapsed:not(.panel-floating) { display: none; } #right-context-rail.layout-focus > #pp-toggles:not(.collapsed), -#right-context-rail.layout-focus > #cctv-panel:not(.collapsed), +#right-context-rail.layout-focus + > #cctv-panel:not(.collapsed):not(.panel-floating), #right-context-rail.layout-focus > #global-context-panel:not(.collapsed) { flex: 0 1 var(--right-panel-allocated-height); height: var(--right-panel-allocated-height); @@ -259,7 +263,7 @@ scrollbar-color: rgba(0, 212, 255, 0.4) transparent; } #right-context-rail.layout-focus - > #cctv-panel:not(.collapsed) + > #cctv-panel:not(.collapsed):not(.panel-floating) .cctv-panel-inner, #right-context-rail.layout-focus > #global-context-panel:not(.collapsed) diff --git a/src/ui/styles/responsive.css b/src/ui/styles/responsive.css index 7ebdaadae..38fe8efb3 100644 --- a/src/ui/styles/responsive.css +++ b/src/ui/styles/responsive.css @@ -202,8 +202,8 @@ } #right-context-rail #pp-toggles.collapsed .pp-header-row, - #right-context-rail #cctv-panel, - #right-context-rail #cctv-panel.collapsed, + #right-context-rail #cctv-panel:not(.panel-floating), + #right-context-rail #cctv-panel.collapsed:not(.panel-floating), #right-context-rail #radio-panel, #right-context-rail #radio-panel.collapsed, #right-context-rail #global-context-panel, From 335b4b08a2b9816d616ed079a065babe45a3ab39 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 23 Sep 2026 15:18:21 +0100 Subject: [PATCH 009/320] test(ui): cover rail exclusion and add the panel-resize QA harness The right-rail test now proves a .panel-floating panel is neither measured, allocated nor auto-collapsed while its docked sibling still is. scripts/qa-panel-resize.mjs drives the CCTV panel in a real browser: lift-out by header drag, all eight handles with the opposite edge pinned, minimum size, reload restore and double-click snap-back. Its pure helpers sit behind a main guard so src/tooling/panelResizeQa.test.mjs can check them without a browser. Run it with `npm run qa:panel-resize`. Co-Authored-By: Claude Fable 5.1 --- package.json | 3 +- scripts/format-scope.json | 2 + scripts/qa-panel-resize.mjs | 285 +++++++++++++++++++++++++++++ src/tooling/panelResizeQa.test.mjs | 57 ++++++ src/ui/panelRails.test.mjs | 22 +++ 5 files changed, 368 insertions(+), 1 deletion(-) create mode 100644 scripts/qa-panel-resize.mjs create mode 100644 src/tooling/panelResizeQa.test.mjs diff --git a/package.json b/package.json index 809841ba2..baeb1ba4f 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,8 @@ "test:track": "node scripts/track-regression.mjs", "qa:map-source-tray": "node scripts/qa-map-source-tray.mjs", "check:boundaries": "node scripts/check-import-directions.mjs && node scripts/check-package-boundaries.mjs", - "qa:transit": "node scripts/qa-transit.mjs" + "qa:transit": "node scripts/qa-transit.mjs", + "qa:panel-resize": "node scripts/qa-panel-resize.mjs" }, "dependencies": { "@mapbox/vector-tile": "^3.0.0", diff --git a/scripts/format-scope.json b/scripts/format-scope.json index cc248f38d..b0dfd39a0 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -26,6 +26,8 @@ "src/main.js", "docs/APPLICATION.md", "scripts/qa-application.mjs", + "scripts/qa-panel-resize.mjs", + "src/tooling/panelResizeQa.test.mjs", "src/app/application.js", "src/app/application.test.mjs", "src/app/mapLifecycle.test.mjs", diff --git a/scripts/qa-panel-resize.mjs b/scripts/qa-panel-resize.mjs new file mode 100644 index 000000000..fc97614e9 --- /dev/null +++ b/scripts/qa-panel-resize.mjs @@ -0,0 +1,285 @@ +#!/usr/bin/env node +/** + * Prove the portable CCTV panel in a real browser: a header drag lifts it out + * of the right rail, every resize handle moves only its own edges, minimum + * sizes hold, the window survives a reload and a header double-click snaps + * it back and forgets the stored position. + */ +import assert from 'node:assert/strict'; +import { pathToFileURL } from 'node:url'; +import { RESIZE_DIRECTIONS, resizeBox } from '../src/ui/panelResize.js'; + +export const PANEL_ID = 'cctv-panel'; +export const STORAGE_KEY = `godsEyeView.v8.panelPos.${PANEL_ID}`; +export const MIN_SIZE = { width: 300, height: 160 }; +const VIEWPORT = { width: 1400, height: 900 }; +const EDGE_BY_SIDE = { n: 'top', s: 'bottom', e: 'right', w: 'left' }; + +/** Box edges a resize in `dir` must leave exactly where they were. */ +export function pinnedEdges(dir) { + return Object.entries(EDGE_BY_SIDE) + .filter(([side]) => !dir.includes(side)) + .map(([, edge]) => edge); +} + +/** Pointer travel that grows a panel by `amount` on every edge named in `dir`. */ +export function growthDelta(dir, amount) { + return { + dx: dir.includes('e') ? amount : dir.includes('w') ? -amount : 0, + dy: dir.includes('s') ? amount : dir.includes('n') ? -amount : 0, + }; +} + +/** + * Viewport point inside the handle for `dir`, given the panel's client rect. + * Edge strips straddle the border by 3px; corners and the grip sit inside. + */ +export function handlePoint(rect, dir) { + const inset = dir.length === 2 ? 4 : 1; + const x = dir.includes('w') + ? rect.left + inset + : dir.includes('e') + ? rect.right - inset + : rect.left + rect.width / 2; + const y = dir.includes('n') + ? rect.top + inset + : dir.includes('s') + ? rect.bottom - inset + : rect.top + rect.height / 2; + return { x, y }; +} + +/** Edges of `after` that moved although a resize in `dir` should have pinned them. */ +export function driftedEdges(before, after, dir, tolerance = 1.5) { + const edges = (box) => ({ + left: box.left, + top: box.top, + right: box.left + box.width, + bottom: box.top + box.height, + }); + const a = edges(before); + const b = edges(after); + return pinnedEdges(dir).filter( + (edge) => Math.abs(a[edge] - b[edge]) > tolerance, + ); +} + +function near(actual, expected, tolerance, label) { + assert.ok( + Math.abs(actual - expected) <= tolerance, + `${label}: expected ${expected} ± ${tolerance}, got ${actual}`, + ); +} + +async function main() { + const { default: puppeteer } = await import('puppeteer'); + const args = process.argv.slice(2); + const urlIndex = args.indexOf('--url'); + const url = urlIndex >= 0 ? args[urlIndex + 1] : 'http://localhost:4173'; + const browser = await puppeteer.launch({ + headless: true, + executablePath: + process.env.PUPPETEER_EXECUTABLE_PATH || + (await puppeteer.executablePath()), + args: ['--no-sandbox', '--use-gl=angle', '--use-angle=swiftshader'], + }); + try { + const page = await browser.newPage(); + await page.setViewport(VIEWPORT); + const errors = []; + page.on('pageerror', (error) => errors.push(error.stack || error.message)); + + const boot = async () => { + await page.goto(`${url}/`, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => window.__godsEyeView?.viewer, { + timeout: 90_000, + }); + await page.waitForSelector(`#${PANEL_ID} .panel-header`); + // The first-run dialog sits over the globe until a choice is made. + await page.evaluate(() => { + document.querySelector('.first-run-explore')?.click(); + }); + await page.keyboard.press('Escape'); + await page.evaluate(() => { + document.getElementById('first-run-launcher')?.remove(); + for (const node of document.querySelectorAll('[class*=first-run]')) + node.remove(); + }); + await page.mouse.click(VIEWPORT.width / 2, VIEWPORT.height / 2); + await new Promise((resolve) => setTimeout(resolve, 400)); + }; + const readPanel = () => + page.evaluate((id) => { + const panel = document.getElementById(id); + const rect = panel.getBoundingClientRect(); + return { + left: rect.left, + top: rect.top, + width: rect.width, + height: rect.height, + floating: panel.classList.contains('panel-floating'), + collapsed: panel.classList.contains('collapsed'), + inRail: panel.parentElement?.id === 'right-context-rail', + allocated: panel.style.getPropertyValue( + '--right-panel-allocated-height', + ), + inlineWidth: panel.style.width, + inlineHeight: panel.style.height, + stored: localStorage.getItem(`godsEyeView.v8.panelPos.${id}`), + }; + }, PANEL_ID); + const headerPoint = async () => { + const rect = await page.evaluate((id) => { + const title = document.querySelector( + `#${id} .panel-header .panel-title`, + ); + const r = title.getBoundingClientRect(); + return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; + }, PANEL_ID); + return rect; + }; + const drag = async (from, dx, dy) => { + await page.mouse.move(from.x, from.y); + await page.mouse.down(); + await page.mouse.move(from.x + dx / 2, from.y + dy / 2, { steps: 6 }); + await page.mouse.move(from.x + dx, from.y + dy, { steps: 6 }); + await page.mouse.up(); + await new Promise((resolve) => setTimeout(resolve, 120)); + }; + + await page.goto(`${url}/`, { waitUntil: 'domcontentloaded' }); + await page.evaluate((key) => { + localStorage.removeItem(key); + localStorage.removeItem('godsEyeView.v8.panelFloatHintShown'); + }, STORAGE_KEY); + await boot(); + + // Expand CCTV so the panel has a body worth resizing. + await page.evaluate((id) => { + const panel = document.getElementById(id); + if (panel.classList.contains('collapsed')) + document + .querySelector(`.panel-collapse-btn[data-collapse-target="${id}"]`) + .click(); + }, PANEL_ID); + await page.waitForFunction( + (id) => !document.getElementById(id).classList.contains('collapsed'), + {}, + PANEL_ID, + ); + await new Promise((resolve) => setTimeout(resolve, 600)); + const docked = await readPanel(); + assert.equal(docked.floating, false); + assert.equal(docked.inRail, true); + + // Lift out: drag the header 200px left and 100px up. + await drag(await headerPoint(), -200, -100); + const lifted = await readPanel(); + assert.equal(lifted.floating, true, 'a header drag lifts the panel out'); + assert.equal( + lifted.collapsed, + false, + 'the drag-ending click must not collapse', + ); + near(lifted.left, Math.max(6, docked.left - 200), 2, 'lifted left'); + near(lifted.top, Math.max(6, docked.top - 100), 2, 'lifted top'); + near(lifted.width, docked.width, 2, 'lifted width'); + await new Promise((resolve) => setTimeout(resolve, 700)); + const afterLayout = await readPanel(); + assert.equal(afterLayout.allocated, '', 'the rail no longer allocates it'); + const railExcludes = await page.evaluate((id) => { + const rail = document.getElementById('right-context-rail'); + const counted = [...rail.children].filter((panel) => + panel.matches('[data-panel-id]:not(.panel-floating):not(.collapsed)'), + ); + return { + expandedCount: rail.dataset.expandedCount, + counted: counted.length, + includesPanel: counted.some((panel) => panel.id === id), + }; + }, PANEL_ID); + assert.equal(railExcludes.includesPanel, false); + if (railExcludes.expandedCount !== undefined) + assert.equal(Number(railExcludes.expandedCount), railExcludes.counted); + const storedAfterLift = JSON.parse(afterLayout.stored); + assert.equal(storedAfterLift.floating, true); + assert.equal( + 'height' in storedAfterLift, + false, + 'a drag alone must not freeze the measured height', + ); + console.log('PASS: header drag lifts CCTV out of the rail'); + + // Every handle moves only its own edges. + const limits = { + minWidth: MIN_SIZE.width, + minHeight: MIN_SIZE.height, + viewportWidth: VIEWPORT.width, + viewportHeight: VIEWPORT.height, + }; + for (const dir of RESIZE_DIRECTIONS) { + const before = await readPanel(); + const { dx, dy } = growthDelta(dir, 40); + await drag(handlePoint(before, dir), dx, dy); + const after = await readPanel(); + const expected = resizeBox(before, dir, dx, dy, limits); + for (const key of ['left', 'top', 'width', 'height']) + near(after[key], expected[key], 2, `${dir} ${key}`); + assert.deepEqual(driftedEdges(before, after, dir, 2), [], `${dir} pins`); + } + console.log('PASS: eight resize handles keep the opposite edge pinned'); + + // Minimum size stops the moving edge. + let before = await readPanel(); + await drag(handlePoint(before, 'se'), -2000, -2000); + let after = await readPanel(); + near(after.width, MIN_SIZE.width, 2, 'min width (se)'); + near(after.height, MIN_SIZE.height, 2, 'min height (se)'); + assert.deepEqual(driftedEdges(before, after, 'se', 2), []); + before = after; + await drag(handlePoint(before, 'nw'), 2000, 2000); + after = await readPanel(); + near(after.width, MIN_SIZE.width, 2, 'min width (nw)'); + near(after.height, MIN_SIZE.height, 2, 'min height (nw)'); + assert.deepEqual(driftedEdges(before, after, 'nw', 2), []); + console.log('PASS: minimum size holds from both corners'); + + // Reload restores the window. + const saved = await readPanel(); + const record = JSON.parse(saved.stored); + assert.equal(record.floating, true); + await boot(); + const restored = await readPanel(); + assert.equal(restored.floating, true, 'floating survives a reload'); + for (const key of ['left', 'top', 'width', 'height']) + near(restored[key], record[key], 2, `restored ${key}`); + console.log('PASS: reload restores position and size'); + + // Double-click the header to snap back. + const point = await headerPoint(); + await page.mouse.click(point.x, point.y, { clickCount: 1 }); + await page.mouse.click(point.x, point.y, { clickCount: 2 }); + await new Promise((resolve) => setTimeout(resolve, 300)); + const snapped = await readPanel(); + assert.equal(snapped.floating, false, 'double-click snaps back'); + assert.equal(snapped.inRail, true); + assert.equal(snapped.inlineWidth, ''); + assert.equal(snapped.inlineHeight, ''); + assert.equal(snapped.stored, null, 'the storage key is gone'); + console.log('PASS: header double-click returns the panel to the rail'); + + assert.deepEqual(errors, []); + } finally { + await browser.close(); + } +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + main().catch((error) => { + console.error(error.stack || error.message); + process.exit(1); + }); +} diff --git a/src/tooling/panelResizeQa.test.mjs b/src/tooling/panelResizeQa.test.mjs new file mode 100644 index 000000000..586f22c08 --- /dev/null +++ b/src/tooling/panelResizeQa.test.mjs @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + driftedEdges, + growthDelta, + handlePoint, + MIN_SIZE, + pinnedEdges, + STORAGE_KEY, +} from '../../scripts/qa-panel-resize.mjs'; +import { RESIZE_DIRECTIONS } from '../ui/panelResize.js'; + +test('the harness targets the key and minimums the controls use', () => { + assert.equal(STORAGE_KEY, 'godsEyeView.v8.panelPos.cctv-panel'); + assert.deepEqual(MIN_SIZE, { width: 300, height: 160 }); +}); + +test('pinned edges are exactly the sides a direction does not name', () => { + assert.deepEqual(pinnedEdges('n'), ['bottom', 'right', 'left']); + assert.deepEqual(pinnedEdges('se'), ['top', 'left']); + assert.deepEqual(pinnedEdges('nw'), ['bottom', 'right']); + for (const dir of RESIZE_DIRECTIONS) + assert.equal(pinnedEdges(dir).length, 4 - dir.length); +}); + +test('growth deltas push each named edge outward', () => { + assert.deepEqual(growthDelta('n', 40), { dx: 0, dy: -40 }); + assert.deepEqual(growthDelta('e', 40), { dx: 40, dy: 0 }); + assert.deepEqual(growthDelta('sw', 40), { dx: -40, dy: 40 }); + assert.deepEqual(growthDelta('se', 40), { dx: 40, dy: 40 }); +}); + +test('handle points land inside the strip or corner for every direction', () => { + const rect = { left: 100, top: 200, width: 400, height: 300 }; + rect.right = rect.left + rect.width; + rect.bottom = rect.top + rect.height; + assert.deepEqual(handlePoint(rect, 'n'), { x: 300, y: 201 }); + assert.deepEqual(handlePoint(rect, 'w'), { x: 101, y: 350 }); + assert.deepEqual(handlePoint(rect, 'se'), { x: 496, y: 496 }); + assert.deepEqual(handlePoint(rect, 'nw'), { x: 104, y: 204 }); + for (const dir of RESIZE_DIRECTIONS) { + const { x, y } = handlePoint(rect, dir); + assert.ok(x >= rect.left && x <= rect.right, `${dir} x inside`); + assert.ok(y >= rect.top && y <= rect.bottom, `${dir} y inside`); + } +}); + +test('drift detection reports only pinned edges that moved', () => { + const before = { left: 100, top: 100, width: 400, height: 300 }; + const grewEast = { left: 100, top: 100, width: 440, height: 300 }; + assert.deepEqual(driftedEdges(before, grewEast, 'e'), []); + const slidWest = { left: 60, top: 100, width: 440, height: 300 }; + assert.deepEqual(driftedEdges(before, slidWest, 'e'), ['left']); + assert.deepEqual(driftedEdges(before, slidWest, 'w'), []); + const wobble = { left: 101, top: 100, width: 439, height: 300 }; + assert.deepEqual(driftedEdges(before, wobble, 'e'), []); +}); diff --git a/src/ui/panelRails.test.mjs b/src/ui/panelRails.test.mjs index 2b03c1406..448479169 100644 --- a/src/ui/panelRails.test.mjs +++ b/src/ui/panelRails.test.mjs @@ -243,6 +243,28 @@ test('right layout retains Display allocation during measurement and caps restor ); }); +test('right rail neither measures nor allocates a panel floating out of it', () => { + const f = fixture('right'); + f.expand(f.first, 900); + f.expand(f.second, 300); + f.first.classList.add('panel-floating'); + f.first.style.setProperty('--right-panel-allocated-height', '500px'); + f.first.writes.length = 0; + f.run(); + assert.equal(f.stack.dataset.expandedCount, '1'); + assert.deepEqual( + f.first.writes, + [], + 'a floating panel keeps whatever inline style it left the rail with', + ); + assert.equal(f.first.getAttribute('aria-hidden'), undefined); + assert.equal(f.first.classList.contains('layout-auto-collapsed'), false); + assert.ok( + f.second.style.getPropertyValue('--right-panel-allocated-height'), + 'the docked panel still receives its allocation', + ); +}); + test('right rail aligns to the current left rail and excludes hidden obstacles', () => { const f = fixture('right'); f.options.leftStack.rect.top = 200; From d7d45b48905e3ac317e46f9af0eb567d723998f7 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 23 Sep 2026 15:18:49 +0100 Subject: [PATCH 010/320] docs(ui): describe portable panel position storage CURRENT-STATE and KNOWN-ISSUES said the panel position key is never written; a portable panel now stores its floating window there, so both documents describe the record and how a header double-click clears it. CHANGELOG notes the CCTV panel's portable mode. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 5 +++++ docs/CURRENT-STATE.md | 4 ++-- docs/KNOWN-ISSUES.md | 7 ++++--- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f3fd7822..f037c3e23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +- Rail panels can opt into a portable mode: dragging the CCTV panel by its + header lifts it out of the right rail into a floating window that resizes + from any edge or corner, remembers its position and size, and snaps back on + a header double-click. + - Distinguish PARTIAL vessel snapshots from STALE data in the layer panel, with accepted-record counts and unchanged retention, freshness and outage safeguards. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index b5727f353..033330624 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3779,8 +3779,8 @@ easier to meet (detection is now on more often), but does not create it. samples and constant elevation during E/N drag; one shared-floor resolution on release; late one-shot shared-cell work is permitted during viewshed idle. The A+B harness intentionally excludes citywide LOD assertions. -- Panel positions have a versioned storage name, `godsEyeView.v8.panelPos.`, but the current rails lay panels out adaptively and write none. Collapsed state does persist for every panel at `godsEyeView.v6.panelCollapsed.` (`'0'` open, `'1'` closed, absent means the panel's own default). -- Legacy draggable-panel position keys may remain in local storage for backward compatibility, but the map-mode right rail ignores them; collapsed states still persist at `godsEyeView.v6.panelCollapsed.`. +- Panel positions have a versioned storage name, `godsEyeView.v8.panelPos.`. The rails lay docked panels out adaptively and write nothing for them; a portable panel (currently CCTV) persists `{ left, top, width, height, floating: true }` under that key once a header drag lifts it out of the rail, and a header double-click removes the record again. Collapsed state does persist for every panel at `godsEyeView.v6.panelCollapsed.` (`'0'` open, `'1'` closed, absent means the panel's own default). +- Legacy draggable-panel position keys may remain in local storage for backward compatibility, but the map-mode right rail ignores them unless they describe a portable panel's floating window; collapsed states still persist at `godsEyeView.v6.panelCollapsed.`. - Flight/military tracked entities cache dead-reckoned positions per frame to avoid callback desync flicker. - Aircraft 3D-model and tracking invariants are covered by `npm run test:track`; run this before touching `flights.js`, `militaryFlights.js`, `detection.js`, or `trackedReadout.js`. - Annotation resolver behavior is pinned by `src/annotations/annotationResolver.test.mjs`; re-run that suite before changing place-resolution scoring. diff --git a/docs/KNOWN-ISSUES.md b/docs/KNOWN-ISSUES.md index 92c92c1f9..9857052c7 100644 --- a/docs/KNOWN-ISSUES.md +++ b/docs/KNOWN-ISSUES.md @@ -57,9 +57,10 @@ Related keys (current versions): - Panel collapsed state: `godsEyeView.v6.panelCollapsed.` — `'0'` open, `'1'` closed, absent means the panel's own default. A view opened from a share link ignores the stored value entirely. -- Panel positions: `godsEyeView.v8.panelPos.` — the versioned name for a - stored position. The current layout writes none, so deleting one changes - nothing. +- Panel positions: `godsEyeView.v8.panelPos.` — written only for a + portable panel (currently CCTV) lifted out of its rail, as + `{ left, top, width, height, floating: true }`. Deleting it, or double-clicking + the panel header, returns that panel to the rail; docked panels store nothing. - CCTV calibration: `godsEyeView.cctv.calibration.v2` --- From fa5710bcf29664037042f086557aaa6af8df5666 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 23 Sep 2026 15:44:32 +0100 Subject: [PATCH 011/320] =?UTF-8?q?feat(mapillary):=20street-level=20layer?= =?UTF-8?q?=20core=20=E2=80=94=20coverage,=20sequences,=20viewer=20bridge,?= =?UTF-8?q?=20query=20engine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Camera-driven Mapillary coverage (z0–5 overview points from orbit, z11–14 ground-clamped sequence lines below 60 km), per-sequence image cones, an embedded MapillaryJS viewer bridge with a camera-follow mode, sprite icons for map features, and a query engine that turns a plan into a streamed z14 tile fan-out. Registers an on-globe CC BY-SA 4.0 credit while enabled. Co-Authored-By: Claude Fable 5.1 --- .env.example | 28 +- README.md | 67 +- server/providers/local.js | 2 + server/providers/mapillary.js | 24 + server/providers/mapillary/constants.js | 93 ++ server/providers/mapillary/decode.js | 60 + server/providers/mapillary/features.js | 165 +++ server/providers/mapillary/planner.js | 293 ++++ server/providers/mapillary/routes.js | 79 ++ server/providers/mapillary/sprites.js | 99 ++ server/providers/mapillary/tiles.js | 203 +++ server/providers/mapillary/trim.js | 58 + src/app/catalog.js | 1 + src/app/catalog.test.mjs | 1 + src/app/constructCatalog.js | 14 + src/app/constructCatalog.test.mjs | 2 +- src/app/layers/mapillary.js | 19 + src/data/layerState.js | 1 + src/keySetupCore.mjs | 20 +- src/layers/mapillary/coverage.js | 524 ++++++++ src/layers/mapillary/decode.js | 145 ++ src/layers/mapillary/decode.test.mjs | 25 + src/layers/mapillary/features.js | 313 +++++ src/layers/mapillary/glyphs.js | 167 +++ src/layers/mapillary/index.js | 435 ++++++ src/layers/mapillary/ndjson.js | 61 + src/layers/mapillary/ndjson.test.mjs | 30 + src/layers/mapillary/policy.js | 65 + src/layers/mapillary/query.js | 516 +++++++ src/layers/mapillary/query.test.mjs | 79 ++ src/layers/mapillary/selection.js | 114 ++ src/layers/mapillary/sequences.js | 262 ++++ src/layers/mapillary/source.js | 233 ++++ src/layers/mapillary/state.js | 107 ++ src/layers/mapillary/taxonomy.js | 1605 ++++++++++++++++++++++ src/layers/mapillary/tileMath.js | 163 +++ src/layers/mapillary/tileMath.test.mjs | 87 ++ src/layers/mapillary/values.js | 162 +++ src/layers/mapillary/values.test.mjs | 96 ++ src/layers/mapillary/viewerBridge.js | 361 +++++ src/standalone/layerSources.js | 4 + src/ui/layerPanel.js | 3 +- src/ui/mapillaryControls.js | 586 ++++++++ src/ui/shellElements.js | 1 + src/ui/styles/mapillary-js.css | 1635 +++++++++++++++++++++++ src/ui/styles/mapillary.css | 959 +++++++++++++ 46 files changed, 9935 insertions(+), 32 deletions(-) create mode 100644 server/providers/mapillary.js create mode 100644 server/providers/mapillary/constants.js create mode 100644 server/providers/mapillary/decode.js create mode 100644 server/providers/mapillary/features.js create mode 100644 server/providers/mapillary/planner.js create mode 100644 server/providers/mapillary/routes.js create mode 100644 server/providers/mapillary/sprites.js create mode 100644 server/providers/mapillary/tiles.js create mode 100644 server/providers/mapillary/trim.js create mode 100644 src/app/layers/mapillary.js create mode 100644 src/layers/mapillary/coverage.js create mode 100644 src/layers/mapillary/decode.js create mode 100644 src/layers/mapillary/decode.test.mjs create mode 100644 src/layers/mapillary/features.js create mode 100644 src/layers/mapillary/glyphs.js create mode 100644 src/layers/mapillary/index.js create mode 100644 src/layers/mapillary/ndjson.js create mode 100644 src/layers/mapillary/ndjson.test.mjs create mode 100644 src/layers/mapillary/policy.js create mode 100644 src/layers/mapillary/query.js create mode 100644 src/layers/mapillary/query.test.mjs create mode 100644 src/layers/mapillary/selection.js create mode 100644 src/layers/mapillary/sequences.js create mode 100644 src/layers/mapillary/source.js create mode 100644 src/layers/mapillary/state.js create mode 100644 src/layers/mapillary/taxonomy.js create mode 100644 src/layers/mapillary/tileMath.js create mode 100644 src/layers/mapillary/tileMath.test.mjs create mode 100644 src/layers/mapillary/values.js create mode 100644 src/layers/mapillary/values.test.mjs create mode 100644 src/layers/mapillary/viewerBridge.js create mode 100644 src/ui/mapillaryControls.js create mode 100644 src/ui/styles/mapillary-js.css create mode 100644 src/ui/styles/mapillary.css diff --git a/.env.example b/.env.example index 5bc02df9c..b626be34b 100644 --- a/.env.example +++ b/.env.example @@ -13,8 +13,8 @@ # externally and never touched. This file remains the reference for headless # and self-hosted setups. # -# NOTE ON CLIENT-EXPOSED KEYS: GOOGLE_MAPS_API_KEY and CESIUM_ION_TOKEN are -# injected into the browser bundle by design (they're used client-side) and +# NOTE ON CLIENT-EXPOSED KEYS: GOOGLE_MAPS_API_KEY, CESIUM_ION_TOKEN and +# MAPILLARY_CLIENT_TOKEN are injected into the browser bundle by design (they're used client-side) and # WILL be visible in devtools. Restrict/scope them rather than trying to hide # them (see SECURITY.md). All other keys below stay server-side. @@ -52,6 +52,10 @@ GOOGLE_MAPS_SERVER_API_KEY= # CLIENT-EXPOSED — use a public assets:read token with URL restrictions. CESIUM_ION_TOKEN= +# Optional: switch the voice agent OFF entirely (no GEV MIC control, no +# Realtime session). Typed controls such as Mapillary AI keep working. +# Default is on. Restart the dev server after changing it. +# GEV_VOICE_UI=off # Optional: OpenAI Realtime voice control. Do not prefix with VITE_. OPENAI_API_KEY= OPENAI_REALTIME_MODEL=gpt-realtime-2 @@ -75,6 +79,26 @@ OPENAI_HUD_SUMMARY_MODEL=gpt-5-nano # provider-side budget: OpenAI platform -> Settings -> Limits (usage limits). # GEV_RATELIMIT_OPENAI_PER_MIN=30 +# Optional: Mapillary street-level imagery — coverage lines, detected objects +# and traffic signs, the embedded MapillaryJS viewer and the Mapillary AI query. +# Free client token: https://www.mapillary.com/dashboard/developers (register an +# application, copy the "Client Token"). CLIENT-EXPOSED by design: MapillaryJS +# fetches imagery straight from Mapillary in the browser, so this token is in +# the bundle like GOOGLE_MAPS_API_KEY. Mapillary client tokens are meant to be +# public; the dev server also uses it to fetch and cache vector tiles. +MAPILLARY_CLIENT_TOKEN= +# Optional: Anthropic key for Mapillary AI ("show me all fire hydrants in +# Sacramento" -> a query plan drawn on the globe). SERVER-SIDE ONLY: the +# browser posts the sentence to /api/mapillary/plan and never sees the key. +# The SDK also honours an `ant auth login` profile when this is unset. +ANTHROPIC_API_KEY= +# Planner model. Defaults to claude-opus-5 at low effort; model ids drift, so +# override here rather than editing server/providers/mapillary/constants.js. +# ANTHROPIC_PLANNER_MODEL=claude-opus-5 +# Optional: opt-in per-IP rate limit for the planner endpoint (requests/min). +# DEFAULT IS UNLIMITED. Recommended whenever HOST is not localhost. +# GEV_RATELIMIT_ANTHROPIC_PER_MIN=20 + # Optional: OpenSky auth mode (oauth | basic | auto | anon) # Default: oauth — requires client credentials below # Set to "anon" to skip aircraft auth (rate-limited but works) diff --git a/README.md b/README.md index 4224bacc0..28561c222 100644 --- a/README.md +++ b/README.md @@ -225,7 +225,7 @@ _Why cockpit mode exists: you're riding a real aircraft over real terrain — an ## 🎙️ Talk to It -> Voice needs an **OpenAI key**. Without one the entire app still runs — the mic button just reports voice is unavailable. The same key drives the **AI HUD summary**: a terse, five-word intelligence-style readout of the current view that regenerates as you move. +> Voice needs an **OpenAI key**. Without one the entire app still runs — the mic button just reports voice is unavailable. Prefer typing? Set `GEV_VOICE_UI=off` in `.env` and the mic control disappears altogether; the typed **Mapillary AI** box below still works. The same key drives the **AI HUD summary**: a terse, five-word intelligence-style readout of the current view that regenerates as you move. Click **GEV MIC**, grant the microphone, and just talk. This is more than a voice-controlled remote: @@ -269,27 +269,38 @@ _Ask for radio near anywhere and the globe starts broadcasting — every station --- +## 📷 Street Level (Mapillary) + +> Needs a free **Mapillary client token**; the AI query additionally needs an **Anthropic key**. Both go in POWER UP. + +Turn on **Street Level** and Mapillary's coverage appears at every altitude: capture points from orbit, then the sequence web drawn onto the streets in green as you descend (magenta for 360° captures, dimmer for imagery older than two years). The **IMAGERY** filters narrow all of it to 360° or flat captures and to a capture date. Click a sequence for its image cones; click a cone, or press **LOOK HERE**, and the embedded MapillaryJS viewer opens inside the Mapillary dock. **STREET COCKPIT** locks the globe camera to the street-level pose, **⤢ EXPAND** grows the viewer to most of the screen (Esc shrinks it), and **GOOGLE 3D** switches the globe to Photorealistic 3D Tiles when a Google key or Cesium ion token is configured. The Mapillary dock and the CCTV panel are portable windows: drag a header to move one, pull the corner grip to resize, double-click the header to snap it back. + +**Mapillary AI** is the typed box at the top of the dock. _"show me all fire hydrants in Sacramento"_ becomes a plan (place, taxonomy classes, time window, 360° preference), the place is geocoded, and the city is fanned out over z14 map-feature tiles that stream back as NDJSON — results fill in tile by tile from the centre outwards, first as dots, then as Mapillary's own sprite icons for every class. Follow-ups refine the previous plan: _"only the ones seen after 2024"_, _"same thing in Detroit"_. Click any result and it is ringed on the globe, the image that detected it opens, and the detection itself is outlined in the photo. **3D OBJECTS** stands result sets of up to 1,500 up as procedural models — signs as textured plates on poles facing Mapillary's aligned direction, hydrants, street lights, benches, traffic lights and more. The planner runs on Claude with a prompt-cached taxonomy of 43 object classes and 900 traffic-sign families; unknown classes are reported rather than guessed. Works for any place Nominatim can find, capped at 600 tiles per run. + +--- + ## 🛰️ What's on the Globe Fifteen layers and map sources. **Thirteen have a keyless path.** Some offer additional capabilities with a provider key. (🟢 no key · 🟡 free key · 🔴 metered.) -| Layer | What you get | Source | Auth | -| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------- | -| 🗺️ **Map Stack** | Esri satellite imagery, Google Photorealistic 3D, OSM, plus additional ion-hosted stacks | Esri / Google / Ion / OSM | 🟢 Esri satellite + OSM · 🟡 ion-hosted Google 3D + world terrain · 🔴 direct Google + place search | -| ✈️ **Live Flights** | 11,000+ live aircraft + route history | OpenSky + adsb.lol | 🟢 (🟡 optional for more polling credits) | -| 🎖️ **Military Flights** | ADS-B military traffic in amber | adsb.lol | 🟢 | -| 🚢 **Live Vessels** | Thousands of ships worldwide | AISStream | 🟡 | -| 🛰️ **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,600 public cameras projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · New South Wales (Live Traffic NSW) · Calgary. Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 | -| 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 | -| 🚌 **Transit** | Live buses, trams, metros, trains and ferries with delayed playback between reports, selected-vehicle trails, and mode-coloured DETECT labels — Boston, Austin, Minneapolis, Helsinki, the Netherlands, Norway, South East Queensland | Operator GTFS-Realtime feeds | 🟢 | -| 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 | -| 🧭 **Directions** | Click A and B on the globe for a street-following drive, walk or cycle route draped on the terrain with turn-by-turn steps — then FLY the camera along it. No key, no geocoder, no mic | OSRM on FOSSGIS servers (OpenStreetMap) | 🟢 | -| 🔥 **Active Fires** | Live NASA FIRMS detections, trailing 24h | NASA FIRMS | 🟡 | -| 🚀 **Space Missions** | Rolling 30-day launches with payload, stage, and recovery detail | Launch Library 2 | 🟢 (🟡 optional token raises the allowance) | -| 🎖️ **Mapped Installations** | Viewport-bounded military-site context from community mapping — incomplete by nature, and labeled that way | OpenStreetMap | 🟢 | +| Layer | What you get | Source | Auth | +| --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------- | +| 🗺️ **Map Stack** | Esri satellite imagery, Google Photorealistic 3D, OSM, plus additional ion-hosted stacks | Esri / Google / Ion / OSM | 🟢 Esri satellite + OSM · 🟡 ion-hosted Google 3D + world terrain · 🔴 direct Google + place search | +| ✈️ **Live Flights** | 11,000+ live aircraft + route history | OpenSky + adsb.lol | 🟢 (🟡 optional for more polling credits) | +| 🎖️ **Military Flights** | ADS-B military traffic in amber | adsb.lol | 🟢 | +| 🚢 **Live Vessels** | Thousands of ships worldwide | AISStream | 🟡 | +| 🛰️ **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,600 public cameras projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · New South Wales (Live Traffic NSW) · Calgary. Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 | +| 📷 **Street Level** | Mapillary coverage drawn onto the streets, click a sequence for its image cones, open any image in the embedded MapillaryJS viewer, and **Street Cockpit** — the globe camera follows the street-level view. Ask **Mapillary AI** in plain English (_"show me all fire hydrants in Sacramento"_) and the detected objects and traffic signs stream onto the globe tile by tile with Mapillary's own icons | Mapillary + Anthropic | 🟡 Mapillary client token · 🔴 Anthropic key for the AI query | +| 📻 **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 | 🟢 | +| 🚌 **Transit** | Live buses, trams, metros, trains and ferries with delayed playback between reports, selected-vehicle trails, and mode-coloured DETECT labels — Boston, Austin, Minneapolis, Helsinki, the Netherlands, Norway, South East Queensland | Operator GTFS-Realtime feeds | 🟢 | +| 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 | +| 🧭 **Directions** | Click A and B on the globe for a street-following drive, walk or cycle route draped on the terrain with turn-by-turn steps — then FLY the camera along it. No key, no geocoder, no mic | OSRM on FOSSGIS servers (OpenStreetMap) | 🟢 | +| 🔥 **Active Fires** | Live NASA FIRMS detections, trailing 24h | NASA FIRMS | 🟡 | +| 🚀 **Space Missions** | Rolling 30-day launches with payload, stage, and recovery detail | Launch Library 2 | 🟢 (🟡 optional token raises the allowance) | +| 🎖️ **Mapped Installations** | Viewport-bounded military-site context from community mapping — incomplete by nature, and labeled that way | OpenStreetMap | 🟢 | **The basemap ladder — what each tier buys you:** @@ -354,7 +365,7 @@ How the globe handles live data: - **Honest satellites.** SGP4 propagation with orbit rings that stay locked to their satellites via GMST realignment — no drift, no per-second flicker. - **Sits on the real ground.** Entity heights are aligned to work with Google 3D tiles, so aircraft park on aprons and cameras stand on street corners instead of floating. - **Caching and request budgets.** An OpenSky credit governor, a TomTom daily tile budget, and disk-cached TLEs reduce repeated requests. These controls do not replace provider quotas or billing controls. -- **Server-side credentials.** Every API that touches a private key (OpenAI, AISStream, OpenSky OAuth, camera frames) is brokered through a hardened server-side proxy with SSRF protection, response caps, and sanitized errors. The only keys the browser sees are Google Maps and Cesium ion (restrict both at the provider). +- **Server-side credentials.** Every API that touches a private key (OpenAI, AISStream, OpenSky OAuth, camera frames) is brokered through a hardened server-side proxy with SSRF protection, response caps, and sanitized errors. The only keys the browser sees are Google Maps, Cesium ion and the Mapillary client token (restrict them at the provider; Mapillary client tokens are designed to be public). - **No framework.** Vanilla JavaScript, **CesiumJS**, and **Vite** — plus **Google Photorealistic 3D Tiles** for the planet and the **OpenAI Realtime API** for voice. Fast to read, fast to hack on. ``` @@ -388,14 +399,16 @@ and configuration details. Six keys. Four have a free tier, and the two 🔴 ones are metered: -| | Key | Why | Get it | -| --- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 🟡 | **Cesium ion** | 🗺️ Google Photorealistic 3D, world terrain, and additional ion-hosted imagery stacks. The free Community plan is for eligible individual, personal/non-commercial use and has quotas | [cesium.com/ion](https://cesium.com/ion) — use a public `assets:read` token and check current [pricing/eligibility](https://cesium.com/platform/cesium-ion/pricing/) | -| 🔴 | **Google Maps** | Direct Google Photorealistic 3D + Google place search ([Map Tiles API](https://developers.google.com/maps/documentation/tile)) | [Google Cloud Console](https://console.cloud.google.com/) — URL-restrict it | -| 🔴 | **OpenAI** | 🎙️ The voice experience + AI HUD summary. The mini model works; the standard model is noticeably smarter. Want Gemini or another provider behind the mic? PRs welcome | [platform.openai.com](https://platform.openai.com) — metered, see costs below | -| 🟡 | **AISStream** | 🚢 Live global ships | [aisstream.io](https://aisstream.io) — free signup | -| 🟡 | **NASA FIRMS** | 🔥 Live active fires | [firms.modaps.eosdis.nasa.gov](https://firms.modaps.eosdis.nasa.gov/api/map_key/) — free | -| 🟡 | **TomTom** | 🚦 Live flow speeds and congestion colors for the simulated traffic layer | [developer.tomtom.com](https://developer.tomtom.com) — free tier available | +| | Key | Why | Get it | +| --- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 🟡 | **Cesium ion** | 🗺️ Google Photorealistic 3D, world terrain, and additional ion-hosted imagery stacks. The free Community plan is for eligible individual, personal/non-commercial use and has quotas | [cesium.com/ion](https://cesium.com/ion) — use a public `assets:read` token and check current [pricing/eligibility](https://cesium.com/platform/cesium-ion/pricing/) | +| 🔴 | **Google Maps** | Direct Google Photorealistic 3D + Google place search ([Map Tiles API](https://developers.google.com/maps/documentation/tile)) | [Google Cloud Console](https://console.cloud.google.com/) — URL-restrict it | +| 🔴 | **OpenAI** | 🎙️ The voice experience + AI HUD summary. The mini model works; the standard model is noticeably smarter. Want Gemini or another provider behind the mic? PRs welcome | [platform.openai.com](https://platform.openai.com) — metered, see costs below | +| 🟡 | **AISStream** | 🚢 Live global ships | [aisstream.io](https://aisstream.io) — free signup | +| 🟡 | **NASA FIRMS** | 🔥 Live active fires | [firms.modaps.eosdis.nasa.gov](https://firms.modaps.eosdis.nasa.gov/api/map_key/) — free | +| 🟡 | **TomTom** | 🚦 Live flow speeds and congestion colors for the simulated traffic layer | [developer.tomtom.com](https://developer.tomtom.com) — free tier available | +| 🟡 | **Mapillary** | 📷 Street-level coverage, sequences and the embedded viewer, plus the detected objects + traffic signs behind Mapillary AI. A client token runs in the browser by design (MapillaryJS fetches imagery directly) | [mapillary.com/dashboard/developers](https://www.mapillary.com/dashboard/developers) — register an app, copy the client token | +| 🔴 | **Anthropic** | 🧠 Mapillary AI — turns a sentence into a Mapillary query plan (Claude, low effort, prompt-cached; a few cents per hundred questions). Server-side only | [console.anthropic.com](https://console.anthropic.com/settings/keys) — metered | ![Diving from city-scale live congestion straight into an intersection's public camera](docs/media/05-traffic-to-cctv.gif) diff --git a/server/providers/local.js b/server/providers/local.js index 68f61a3cb..273358f93 100644 --- a/server/providers/local.js +++ b/server/providers/local.js @@ -19,6 +19,7 @@ import { aisLiveProxy } from './vessels/ais-live.js'; import { trackBackfillProxies } from './aircraft/tracks.js'; import { openAiRealtimeProxy } from './openai.js'; import { googlePlacesContextProxy } from './places.js'; +import { mapillaryProxy } from './mapillary.js'; import { keySetupEndpoint } from '../standalone/key-setup.js'; /** Construct the local provider plugins in their established order. */ @@ -45,6 +46,7 @@ function localProviderPlugins() { trackBackfillProxies(), openAiRealtimeProxy(), googlePlacesContextProxy(), + mapillaryProxy(), keySetupEndpoint(), ]; } diff --git a/server/providers/mapillary.js b/server/providers/mapillary.js new file mode 100644 index 000000000..51f9512ed --- /dev/null +++ b/server/providers/mapillary.js @@ -0,0 +1,24 @@ +import { installMapillaryRoutes } from './mapillary/routes.js'; + +/** + * Vite plugin: Mapillary street-level data. + * + * Serves cached vector tiles (coverage, point features, traffic signs) with + * the Mapillary token added server-side, fans a city-sized bbox out over + * z14 feature tiles as an NDJSON stream, turns natural-language requests + * into query plans with Claude (ANTHROPIC_API_KEY stays on this machine), + * and proxies the MIT-licensed Mapillary sprite icons. + */ +function mapillaryProxy() { + return { + name: 'mapillary-proxy', + configureServer(server) { + installMapillaryRoutes(server.middlewares); + }, + configurePreviewServer(server) { + installMapillaryRoutes(server.middlewares); + }, + }; +} + +export { mapillaryProxy }; diff --git a/server/providers/mapillary/constants.js b/server/providers/mapillary/constants.js new file mode 100644 index 000000000..3444622a1 --- /dev/null +++ b/server/providers/mapillary/constants.js @@ -0,0 +1,93 @@ +import path from 'node:path'; + +/** Mapillary vector tile root; the path template is /{layer}/2/{z}/{x}/{y}. */ +export const MAPILLARY_TILE_HOST = 'https://tiles.mapillary.com/maps/vtp'; + +/** Public tile layer names this proxy exposes, mapped to Mapillary's ids. */ +export const TILE_LAYERS = Object.freeze({ + // Overview points (z0–5), sequences (z6–14) and image points (z14 only). + // The z14 `image` point layer is ~98% of a 10 MB tile and unused here: + // image positions come from the graph API per sequence. Dropped in transit. + coverage: Object.freeze({ + upstream: 'mly1_public', + minZoom: 0, + maxZoom: 14, + dropLayers: Object.freeze(['image']), + }), + // Point map features such as fire hydrants, benches and utility poles. + points: Object.freeze({ + upstream: 'mly_map_feature_point', + minZoom: 14, + maxZoom: 14, + }), + // Classified traffic-sign detections. + signs: Object.freeze({ + upstream: 'mly_map_feature_traffic_sign', + minZoom: 14, + maxZoom: 14, + }), +}); + +/** Zoom at which Mapillary publishes map-feature tiles. */ +export const FEATURE_TILE_ZOOM = 14; + +/** Disk cache root, alongside the other providers' caches. */ +export const MAPILLARY_CACHE_DIR = path.join( + process.cwd(), + '.gev-cache', + 'mapillary', +); +export const TILE_DISK_DIR = path.join(MAPILLARY_CACHE_DIR, 'tiles'); +export const SPRITE_DISK_DIR = path.join(MAPILLARY_CACHE_DIR, 'sprites'); + +/** + * Tiles change when new imagery is processed, which is far slower than the + * 15-minute upstream Cache-Control. A day keeps a city-wide query from + * re-downloading hundreds of megabytes on every run. + */ +export const TILE_DISK_TTL_MS = 24 * 60 * 60 * 1000; +export const SPRITE_DISK_TTL_MS = 30 * 24 * 60 * 60 * 1000; + +/** A z14 image tile over a dense city is ~11 MB; anything past this is wrong. */ +export const TILE_MAX_BYTES = 48 * 1024 * 1024; +export const SPRITE_MAX_BYTES = 512 * 1024; + +/** In-memory tile cache budget (bytes) and upstream fetch timeout. */ +export const TILE_MEMORY_BUDGET_BYTES = 96 * 1024 * 1024; +export const TILE_FETCH_TIMEOUT_MS = 60_000; + +/** Executor limits for one natural-language query. */ +export const QUERY_MAX_TILES = 600; +export const QUERY_MAX_FEATURES = 120_000; +export const QUERY_TILE_CONCURRENCY = 6; +export const QUERY_MAX_BODY_BYTES = 32 * 1024; + +/** Sprite SVG source (MIT) — one file per taxonomy value. */ +export const SPRITE_SOURCE_ROOT = + 'https://raw.githubusercontent.com/mapillary/mapillary_sprite_source/master'; + +/** Planner defaults; the model id is external and may be overridden by env. */ +export const PLANNER_MODEL_DEFAULT = 'claude-opus-5'; +export const PLANNER_MAX_TOKENS = 1500; +export const PLANNER_MAX_BODY_BYTES = 32 * 1024; +export const PLANNER_MAX_QUERY_CHARS = 600; + +/** The client token lives in the browser by design; the server adds it to tile URLs too. */ +export function mapillaryToken() { + return String(process.env.MAPILLARY_CLIENT_TOKEN || '').trim(); +} + +/** Anthropic credentials are server-side only. */ +export function anthropicConfigured() { + return Boolean( + String(process.env.ANTHROPIC_API_KEY || '').trim() || + String(process.env.ANTHROPIC_AUTH_TOKEN || '').trim(), + ); +} + +export function plannerModel() { + return ( + String(process.env.ANTHROPIC_PLANNER_MODEL || '').trim() || + PLANNER_MODEL_DEFAULT + ); +} diff --git a/server/providers/mapillary/decode.js b/server/providers/mapillary/decode.js new file mode 100644 index 000000000..51a211705 --- /dev/null +++ b/server/providers/mapillary/decode.js @@ -0,0 +1,60 @@ +import { PbfReader } from 'pbf'; +import { VectorTile } from '@mapbox/vector-tile'; +import { tileLocalToLonLat } from '../../../src/layers/mapillary/tileMath.js'; + +/** Vector-tile layer name inside each Mapillary map-feature tile. */ +const FEATURE_LAYER_NAMES = Object.freeze({ + points: 'point', + signs: 'traffic_sign', +}); + +/** + * Decode one map-feature tile into compact rows, keeping only features whose + * value passes `matcher` and whose sighting window overlaps [since, until]. + * Rows are positional arrays to keep the streamed payload small: + * [lon, lat, id, value, firstSeenMs, lastSeenMs]. + * @param {Buffer|Uint8Array} bytes + * @param {{x:number,y:number,z:number,layer:'points'|'signs'}} address + * @param {{matcher?: (value:string)=>boolean, since?: number|null, until?: number|null}} [filter] + * @returns {Array<[number, number, string, string, number, number]>} + */ +export function decodeFeatureRows(bytes, address, filter = {}) { + if (!bytes || !bytes.length) return []; + const layerName = FEATURE_LAYER_NAMES[address.layer]; + const tile = new VectorTile(new PbfReader(bytes)); + const layer = layerName ? tile.layers[layerName] : null; + if (!layer) return []; + const matcher = filter.matcher || (() => true); + const since = Number.isFinite(filter.since) ? filter.since : null; + const until = Number.isFinite(filter.until) ? filter.until : null; + const rows = []; + for (let i = 0; i < layer.length; i++) { + const feature = layer.feature(i); + const value = feature.properties?.value; + if (typeof value !== 'string' || !matcher(value)) continue; + const first = Number(feature.properties.first_seen_at) || 0; + const last = Number(feature.properties.last_seen_at) || first; + if (since !== null && last < since) continue; + if (until !== null && first > until) continue; + const geometry = feature.loadGeometry(); + const point = geometry?.[0]?.[0]; + if (!point) continue; + const [lon, lat] = tileLocalToLonLat( + point.x, + point.y, + layer.extent, + address.x, + address.y, + address.z, + ); + rows.push([ + Number(lon.toFixed(6)), + Number(lat.toFixed(6)), + String(feature.properties.id ?? feature.id ?? ''), + value, + first, + last, + ]); + } + return rows; +} diff --git a/server/providers/mapillary/features.js b/server/providers/mapillary/features.js new file mode 100644 index 000000000..ffa038b35 --- /dev/null +++ b/server/providers/mapillary/features.js @@ -0,0 +1,165 @@ +import { readRequestBody } from '../common/request.js'; +import { + FEATURE_TILE_ZOOM, + QUERY_MAX_BODY_BYTES, + QUERY_MAX_FEATURES, + QUERY_MAX_TILES, + QUERY_TILE_CONCURRENCY, + mapillaryToken, +} from './constants.js'; +import { fetchTile, TileRequestError } from './tiles.js'; +import { decodeFeatureRows } from './decode.js'; +import { + normalizeBbox, + tilesForBbox, +} from '../../../src/layers/mapillary/tileMath.js'; +import { + createValueMatcher, + normalizeValuePattern, +} from '../../../src/layers/mapillary/values.js'; + +function sendJson(res, status, payload) { + res.statusCode = status; + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.setHeader('Cache-Control', 'no-store'); + res.end(JSON.stringify(payload)); +} + +/** Parse an ISO date (or epoch ms) into epoch ms, or null when absent/invalid. */ +export function parseEpochMs(value) { + if (value === null || value === undefined || value === '') return null; + if (typeof value === 'number') return Number.isFinite(value) ? value : null; + const parsed = Date.parse(String(value)); + return Number.isFinite(parsed) ? parsed : null; +} + +/** + * Validate a feature query body into an executable request. + * @returns {{ok:true, request:object}|{ok:false, status:number, error:string, detail?:object}} + */ +export function normalizeFeatureQuery(body) { + const bbox = normalizeBbox(body?.bbox); + if (!bbox) + return { + ok: false, + status: 400, + error: 'A bbox [west,south,east,north] is required', + }; + const layer = body?.layer === 'signs' ? 'signs' : 'points'; + const values = Array.isArray(body?.values) + ? body.values.map(normalizeValuePattern).filter(Boolean).slice(0, 64) + : []; + const since = parseEpochMs(body?.seenAfter); + const until = parseEpochMs(body?.seenBefore); + const { tiles, truncated, total } = tilesForBbox(bbox, FEATURE_TILE_ZOOM, { + limit: QUERY_MAX_TILES, + }); + if (truncated) + return { + ok: false, + status: 413, + error: 'area_too_large', + detail: { tiles: total, limit: QUERY_MAX_TILES }, + }; + return { ok: true, request: { bbox, layer, values, since, until, tiles } }; +} + +/** + * POST /api/mapillary/features — fan a bbox out over z14 map-feature tiles + * and stream the matching features back as NDJSON, one line per tile, so + * the globe fills in progressively while the rest of the city downloads. + */ +export async function handleFeatureQuery(req, res) { + if (req.method !== 'POST') + return sendJson(res, 405, { error: 'Method not allowed' }); + if (!mapillaryToken()) + return sendJson(res, 503, { error: 'no_key', keyRequired: true }); + let body; + try { + body = JSON.parse( + (await readRequestBody(req, QUERY_MAX_BODY_BYTES)) || '{}', + ); + } catch (error) { + return sendJson(res, 400, { error: error?.message || 'Invalid JSON body' }); + } + const normalized = normalizeFeatureQuery(body); + if (!normalized.ok) + return sendJson(res, normalized.status, { + error: normalized.error, + ...normalized.detail, + }); + const { layer, values, since, until, tiles, bbox } = normalized.request; + const matcher = createValueMatcher(values); + + const abandoned = new AbortController(); + req.on?.('aborted', () => abandoned.abort()); + res.on?.('close', () => abandoned.abort()); + + res.statusCode = 200; + res.setHeader('Content-Type', 'application/x-ndjson; charset=utf-8'); + res.setHeader('Cache-Control', 'no-store'); + res.setHeader('X-Accel-Buffering', 'no'); + const write = (line) => { + if (res.writableEnded || abandoned.signal.aborted) return false; + res.write(`${JSON.stringify(line)}\n`); + return true; + }; + write({ + type: 'start', + tiles: tiles.length, + zoom: FEATURE_TILE_ZOOM, + layer, + values, + bbox, + }); + + let total = 0; + let failed = 0; + let truncated = false; + let cursor = 0; + const worker = async () => { + while (cursor < tiles.length && !abandoned.signal.aborted) { + if (total >= QUERY_MAX_FEATURES) { + truncated = true; + return; + } + const index = cursor++; + const tile = tiles[index]; + try { + const { bytes, source } = await fetchTile( + { layer, z: tile.z, x: tile.x, y: tile.y }, + { signal: abandoned.signal }, + ); + const rows = decodeFeatureRows( + bytes, + { ...tile, layer }, + { matcher, since, until }, + ); + total += rows.length; + write({ type: 'tile', i: index, x: tile.x, y: tile.y, source, rows }); + } catch (error) { + if (abandoned.signal.aborted) return; + failed++; + write({ + type: 'tile', + i: index, + x: tile.x, + y: tile.y, + rows: [], + error: + error instanceof TileRequestError + ? error.message + : `tile unavailable (${error?.status || error?.message || 'error'})`, + }); + } + } + }; + await Promise.all( + Array.from( + { length: Math.min(QUERY_TILE_CONCURRENCY, tiles.length) }, + worker, + ), + ); + write({ type: 'done', total, failed, truncated, tiles: tiles.length }); + if (!res.writableEnded) res.end(); +} diff --git a/server/providers/mapillary/planner.js b/server/providers/mapillary/planner.js new file mode 100644 index 000000000..1001b9371 --- /dev/null +++ b/server/providers/mapillary/planner.js @@ -0,0 +1,293 @@ +import Anthropic from '@anthropic-ai/sdk'; +import { z } from 'zod'; +import { zodOutputFormat } from '@anthropic-ai/sdk/helpers/zod'; +import { readRequestBody } from '../common/request.js'; +import { makeOptInRateLimiter, clientKey } from '../common/rate-limit.js'; +import { + PLANNER_MAX_BODY_BYTES, + PLANNER_MAX_QUERY_CHARS, + PLANNER_MAX_TOKENS, + anthropicConfigured, + mapillaryToken, + plannerModel, +} from './constants.js'; +import { + OBJECT_VALUES, + SIGN_FAMILIES, + SIGN_VALUES, +} from '../../../src/layers/mapillary/taxonomy.js'; +import { + createValueMatcher, + isSignValue, + normalizeValuePattern, +} from '../../../src/layers/mapillary/values.js'; + +/** + * The plan the model returns. Every field is required (structured outputs + * reject optional keys); "unknown" is spelled with null. + */ +export const PlanSchema = z.object({ + intent: z.enum([ + 'map_features', + 'traffic_signs', + 'coverage', + 'nearest_image', + 'unsupported', + ]), + place: z.string().nullable(), + use_current_view: z.boolean(), + values: z.array(z.string()), + seen_after: z.string().nullable(), + seen_before: z.string().nullable(), + prefer_pano: z.boolean(), + visualise: z.enum(['icons', 'points', 'count']), + title: z.string(), + answer: z.string(), +}); + +/** + * Frozen system prompt. Nothing volatile lives here so the block can be + * prompt-cached across every query; the date and view context ride in the + * user turn instead. + */ +export const PLANNER_SYSTEM_PROMPT = [ + "You translate natural-language requests about street-level map data into a query plan for God's Eye View, a 3D globe that can draw Mapillary map features (objects detected in street imagery) and traffic signs, show Mapillary coverage, or open the nearest street-level image.", + '', + 'Return ONLY a plan object. Field rules:', + '- intent: "map_features" for objects (hydrants, benches, poles, manholes, traffic lights, road markings…); "traffic_signs" for signs (stop, speed limit, no parking, pedestrian crossing…); "coverage" when the user asks where imagery exists or how recent it is; "nearest_image" when they want to see/open/look at the street view of a place; "unsupported" when the request is not about Mapillary data (then explain in `answer`).', + '- place: the place to search, as a geocodable string ("Sacramento, California"; "Detroit, Michigan"; "Shinjuku, Tokyo"). null when the user means the current view ("here", "around me", "in this area") or gives no place at all.', + '- use_current_view: true when place is null and the current view should be used.', + '- values: Mapillary taxonomy values for the requested things. Use exact values from the lists below. A wildcard `*` matches a run of characters: use `regulatory--stop--*` to cover every regional variant of a sign family, `object--traffic-light--*` for all traffic lights, `marking--discrete--arrow--*` for all arrow markings. Include several values when the request is broad ("street furniture" → benches, trash cans, bike racks, mailboxes). Leave empty only for coverage or nearest_image intents.', + '- seen_after / seen_before: ISO dates (YYYY-MM-DD) when the user constrains time ("since 2024", "before 2022", "in the last two years" relative to today); otherwise null.', + '- prefer_pano: true when the user asks for 360°, panoramic or immersive imagery (nearest_image and coverage intents); otherwise false.', + '- visualise: "icons" by default; "points" when the user asks for density or a very broad class; "count" when they only ask how many.', + '- title: a short HUD title, e.g. "Fire hydrants · Sacramento".', + '- answer: one plain sentence confirming what will be shown, mentioning any interpretation you made. Never claim counts; the data has not been fetched yet.', + '', + 'Follow-ups: when a previous plan is supplied and the new request refines the same subject ("only the ones seen after 2024", "just the panoramas", "same thing in Detroit"), start from the previous plan and change only what the request changes. A request that names a new subject or a new place ("stop signs in downtown Detroit", "now benches here") starts fresh: keep nothing from the previous plan unless the user says "same", "also", "too" or "keep".', + '', + 'Point object values (layer map_features):', + OBJECT_VALUES.join(', '), + '', + 'Traffic-sign families (layer traffic_signs). Each family exists as several regional variants suffixed --g1, --g2…; always request a family with the wildcard form `--*`:', + SIGN_FAMILIES.join(', '), +].join('\n'); + +const KNOWN_VALUES = new Set([...OBJECT_VALUES, ...SIGN_VALUES]); + +/** + * Keep only values that exist in the taxonomy (exact) or match at least one + * taxonomy value (wildcard). Returns the surviving patterns plus what was + * dropped so the UI can say so. + */ +export function reconcilePlanValues(values) { + const kept = []; + const dropped = []; + for (const raw of values || []) { + const pattern = normalizeValuePattern(raw); + if (!pattern) continue; + if (pattern.includes('*')) { + const matches = createValueMatcher([pattern]); + if ([...KNOWN_VALUES].some(matches)) kept.push(pattern); + else dropped.push(pattern); + } else if (KNOWN_VALUES.has(pattern)) kept.push(pattern); + else if (KNOWN_VALUES.has(`${pattern}--g1`)) kept.push(`${pattern}--*`); + else dropped.push(pattern); + } + return { values: [...new Set(kept)], dropped }; +} + +/** + * Turn the model's plan into the executable plan the browser runs: values + * reconciled against the taxonomy, the tile layer derived from the values, + * and an honest downgrade to "unsupported" when nothing usable survived. + */ +export function finalizePlan(plan) { + const { values, dropped } = reconcilePlanValues(plan.values); + let intent = plan.intent; + let layer = null; + if (intent === 'map_features' || intent === 'traffic_signs') { + if (!values.length) { + return { + ...plan, + intent: 'unsupported', + values: [], + dropped, + layer: null, + answer: dropped.length + ? `I could not map "${dropped.join('", "')}" onto a Mapillary object or sign class.` + : plan.answer, + }; + } + const signs = values.filter(isSignValue).length; + layer = signs && signs === values.length ? 'signs' : 'points'; + intent = layer === 'signs' ? 'traffic_signs' : 'map_features'; + if (signs && signs !== values.length) { + // Mixed request: signs live in a different tile set. Keep the object + // half and say so rather than silently dropping either. + return { + ...plan, + intent, + layer, + values: values.filter((value) => !isSignValue(value)), + dropped: [...dropped, ...values.filter(isSignValue)], + answer: `${plan.answer} Traffic signs and objects come from different layers, so this run shows the objects only.`, + }; + } + } + return { ...plan, intent, layer, values, dropped }; +} + +let _client; +function client() { + return (_client ||= new Anthropic()); +} + +let _limiter; +function limiter() { + if (_limiter === undefined) + _limiter = makeOptInRateLimiter( + process.env.GEV_RATELIMIT_ANTHROPIC_PER_MIN, + ); + return _limiter; +} + +function sendJson(res, status, payload) { + res.statusCode = status; + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.setHeader('Cache-Control', 'no-store'); + res.end(JSON.stringify(payload)); +} + +/** Build the volatile user turn: the request plus whatever the globe knows. */ +export function buildPlannerUserMessage({ query, context = {}, today }) { + const lines = [`Today is ${today}.`]; + const view = context.view; + if (view && Number.isFinite(view.lat) && Number.isFinite(view.lon)) { + lines.push( + `Current view: ${view.lat.toFixed(4)}, ${view.lon.toFixed(4)}` + + (Number.isFinite(view.heightM) + ? `, camera ${Math.round(view.heightM)} m above ground` + : '') + + (view.label ? `, near ${String(view.label).slice(0, 120)}` : '') + + '.', + ); + } + if (context.previousPlan) { + lines.push( + `Previous plan: ${JSON.stringify(context.previousPlan).slice(0, 1500)}`, + ); + } + lines.push('', `Request: ${query}`); + return lines.join('\n'); +} + +/** + * Ask Claude for a plan. Exported for tests with an injectable client. + * @returns {Promise<{plan: object, usage: object|null, model: string}>} + */ +export async function planQuery( + { query, context }, + { anthropic = client(), model = plannerModel(), now = new Date() } = {}, +) { + const response = await anthropic.messages.parse({ + model, + max_tokens: PLANNER_MAX_TOKENS, + system: [ + { + type: 'text', + text: PLANNER_SYSTEM_PROMPT, + cache_control: { type: 'ephemeral' }, + }, + ], + messages: [ + { + role: 'user', + content: buildPlannerUserMessage({ + query, + context, + today: now.toISOString().slice(0, 10), + }), + }, + ], + output_config: { format: zodOutputFormat(PlanSchema), effort: 'low' }, + }); + if (response.stop_reason === 'refusal') { + return { + plan: finalizePlan({ + intent: 'unsupported', + place: null, + use_current_view: false, + values: [], + seen_after: null, + seen_before: null, + prefer_pano: false, + visualise: 'icons', + title: 'Query declined', + answer: 'The planner declined this request.', + }), + usage: response.usage ?? null, + model, + }; + } + if (!response.parsed_output) + throw Object.assign(new Error('Planner returned no plan'), { status: 502 }); + return { + plan: finalizePlan(response.parsed_output), + usage: response.usage ?? null, + model, + }; +} + +function statusForError(error) { + if (error instanceof Anthropic.AuthenticationError) return 503; + if (error instanceof Anthropic.RateLimitError) return 429; + if (error instanceof Anthropic.APIConnectionError) return 502; + if (error instanceof Anthropic.APIStatusError) return error.status || 502; + return error?.status || 500; +} + +/** POST /api/mapillary/plan — natural language in, executable plan out. */ +export async function handlePlan(req, res) { + if (req.method !== 'POST') + return sendJson(res, 405, { error: 'Method not allowed' }); + if (!mapillaryToken()) + return sendJson(res, 503, { error: 'no_key', keyRequired: 'mapillary' }); + if (!anthropicConfigured()) + return sendJson(res, 503, { error: 'no_key', keyRequired: 'anthropic' }); + const allow = limiter(); + if (allow && !allow(clientKey(req))) { + res.setHeader('Retry-After', '5'); + return sendJson(res, 429, { error: 'Rate limit exceeded' }); + } + let body; + try { + body = JSON.parse( + (await readRequestBody(req, PLANNER_MAX_BODY_BYTES)) || '{}', + ); + } catch (error) { + return sendJson(res, 400, { error: error?.message || 'Invalid JSON body' }); + } + const query = String(body?.query || '').trim(); + if (!query || query.length > PLANNER_MAX_QUERY_CHARS) + return sendJson(res, 400, { + error: `A query of 1–${PLANNER_MAX_QUERY_CHARS} characters is required`, + }); + try { + const result = await planQuery({ + query, + context: + body?.context && typeof body.context === 'object' ? body.context : {}, + }); + sendJson(res, 200, result); + } catch (error) { + const status = statusForError(error); + console.warn('[Mapillary Planner]', error?.message || error); + sendJson(res, status, { + error: + status === 503 + ? 'Anthropic authentication failed' + : error?.message || 'Planner request failed', + }); + } +} diff --git a/server/providers/mapillary/routes.js b/server/providers/mapillary/routes.js new file mode 100644 index 000000000..34347d31d --- /dev/null +++ b/server/providers/mapillary/routes.js @@ -0,0 +1,79 @@ +import { fetchTile, TileRequestError, TileUpstreamError } from './tiles.js'; +import { handleFeatureQuery } from './features.js'; +import { handlePlan } from './planner.js'; +import { handleSprite } from './sprites.js'; +import { + anthropicConfigured, + mapillaryToken, + plannerModel, + QUERY_MAX_TILES, + FEATURE_TILE_ZOOM, +} from './constants.js'; + +function sendJson(res, status, payload) { + res.statusCode = status; + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.setHeader('Cache-Control', 'no-store'); + res.end(JSON.stringify(payload)); +} + +/** GET /api/mapillary/status — what this install can do, never a value. */ +function handleStatus(req, res) { + if (req.method !== 'GET') + return sendJson(res, 405, { error: 'Method not allowed' }); + sendJson(res, 200, { + configured: Boolean(mapillaryToken()), + planner: anthropicConfigured(), + plannerModel: anthropicConfigured() ? plannerModel() : null, + limits: { maxTiles: QUERY_MAX_TILES, featureZoom: FEATURE_TILE_ZOOM }, + }); +} + +/** GET /api/mapillary/tiles/{layer}/{z}/{x}/{y} — cached protobuf tile. */ +async function handleTile(req, res) { + if (req.method !== 'GET') + return sendJson(res, 405, { error: 'Method not allowed' }); + const match = + /^\/(coverage|points|signs)\/(\d{1,2})\/(\d{1,6})\/(\d{1,6})$/.exec( + (req.url || '').split('?')[0], + ); + if (!match) + return sendJson(res, 400, { + error: 'Tile path must be /{layer}/{z}/{x}/{y}', + }); + if (!mapillaryToken()) + return sendJson(res, 503, { error: 'no_key', keyRequired: true }); + const abandoned = new AbortController(); + req.on?.('aborted', () => abandoned.abort()); + res.on?.('close', () => abandoned.abort()); + try { + const { bytes, source } = await fetchTile( + { layer: match[1], z: match[2], x: match[3], y: match[4] }, + { signal: abandoned.signal }, + ); + if (res.writableEnded) return; + res.statusCode = bytes.length ? 200 : 204; + res.setHeader('Content-Type', 'application/x-protobuf'); + res.setHeader('Cache-Control', 'public, max-age=3600'); + res.setHeader('X-Gev-Cache', source); + res.end(bytes.length ? bytes : undefined); + } catch (error) { + if (res.writableEnded || abandoned.signal.aborted) return; + if (error instanceof TileRequestError) + return sendJson(res, error.status, { error: error.message }); + if (error instanceof TileUpstreamError) + return sendJson(res, error.status >= 500 ? 502 : error.status, { + error: error.message, + }); + sendJson(res, 502, { error: error?.message || 'Tile fetch failed' }); + } +} + +/** Attach every Mapillary route to a connect-style middleware stack. */ +export function installMapillaryRoutes(middlewares) { + middlewares.use('/api/mapillary/status', handleStatus); + middlewares.use('/api/mapillary/tiles', handleTile); + middlewares.use('/api/mapillary/features', handleFeatureQuery); + middlewares.use('/api/mapillary/plan', handlePlan); + middlewares.use('/api/mapillary/sprite', handleSprite); +} diff --git a/server/providers/mapillary/sprites.js b/server/providers/mapillary/sprites.js new file mode 100644 index 000000000..c2f888555 --- /dev/null +++ b/server/providers/mapillary/sprites.js @@ -0,0 +1,99 @@ +import path from 'node:path'; +import { promises as fsp } from 'node:fs'; +import { + SPRITE_DISK_DIR, + SPRITE_DISK_TTL_MS, + SPRITE_MAX_BYTES, + SPRITE_SOURCE_ROOT, +} from './constants.js'; +import { isSignValue } from '../../../src/layers/mapillary/values.js'; + +/** A taxonomy value: lower-case segments joined by `--`, words by `-`. */ +const VALUE_GRAMMAR = + /^[a-z0-9]+(?:-[a-z0-9]+)*(?:--[a-z0-9]+(?:-[a-z0-9]+)*)*$/; + +/** @type {Map>} */ +const _inFlight = new Map(); + +function spritePath(value) { + return path.join(SPRITE_DISK_DIR, `${value}.svg`); +} + +async function readDisk(value) { + try { + const file = spritePath(value); + const stat = await fsp.stat(file); + if (Date.now() - stat.mtimeMs > SPRITE_DISK_TTL_MS) return null; + return await fsp.readFile(file); + } catch { + return null; + } +} + +async function fetchSprite(value) { + const pkg = isSignValue(value) ? 'package_signs' : 'package_objects'; + const response = await fetch(`${SPRITE_SOURCE_ROOT}/${pkg}/${value}.svg`, { + signal: AbortSignal.timeout(20_000), + }); + if (response.status === 404) { + await response.body?.cancel().catch(() => {}); + return null; + } + if (!response.ok) { + await response.body?.cancel().catch(() => {}); + throw new Error(`sprite source HTTP ${response.status}`); + } + const bytes = Buffer.from(await response.arrayBuffer()); + if ( + bytes.length > SPRITE_MAX_BYTES || + !bytes.subarray(0, 512).toString().includes(' fsp.writeFile(file, bytes)) + .catch(() => {}); + return bytes; +} + +/** + * GET /api/mapillary/sprite/.svg — the Mapillary icon for a taxonomy + * value, fetched once from the MIT sprite source and cached on disk. + */ +export async function handleSprite(req, res) { + const match = /^\/([a-z0-9-]+)\.svg$/.exec((req.url || '').split('?')[0]); + const value = match?.[1]; + if (!value || !VALUE_GRAMMAR.test(value)) { + res.statusCode = 400; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ error: 'Invalid sprite value' })); + return; + } + try { + let bytes = await readDisk(value); + if (!bytes) { + let pending = _inFlight.get(value); + if (!pending) { + pending = fetchSprite(value).finally(() => _inFlight.delete(value)); + _inFlight.set(value, pending); + } + bytes = await pending; + } + if (!bytes) { + res.statusCode = 404; + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Cache-Control', 'public, max-age=3600'); + res.end(JSON.stringify({ error: 'No sprite for value' })); + return; + } + res.statusCode = 200; + res.setHeader('Content-Type', 'image/svg+xml; charset=utf-8'); + res.setHeader('Cache-Control', 'public, max-age=86400'); + res.end(bytes); + } catch (error) { + res.statusCode = 502; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ error: error?.message || 'Sprite unavailable' })); + } +} diff --git a/server/providers/mapillary/tiles.js b/server/providers/mapillary/tiles.js new file mode 100644 index 000000000..3d794fd04 --- /dev/null +++ b/server/providers/mapillary/tiles.js @@ -0,0 +1,203 @@ +import path from 'node:path'; +import { promises as fsp } from 'node:fs'; +import { stripTileLayers } from './trim.js'; +import { + MAPILLARY_TILE_HOST, + TILE_LAYERS, + TILE_DISK_DIR, + TILE_DISK_TTL_MS, + TILE_MAX_BYTES, + TILE_MEMORY_BUDGET_BYTES, + TILE_FETCH_TIMEOUT_MS, + mapillaryToken, +} from './constants.js'; + +/** Thrown for a request this proxy refuses before contacting Mapillary. */ +export class TileRequestError extends Error { + constructor(message, status = 400) { + super(message); + this.name = 'TileRequestError'; + this.status = status; + } +} + +/** Thrown when Mapillary answered with an error status. */ +export class TileUpstreamError extends Error { + constructor(status, message) { + super(message || `Mapillary tiles HTTP ${status}`); + this.name = 'TileUpstreamError'; + this.status = status; + } +} + +/** + * Validate and normalize a tile address. Layer names are the proxy's public + * ids (coverage/points/signs); zoom must be inside the layer's published range. + * @returns {{layer:string, upstream:string, z:number, x:number, y:number, key:string}} + */ +export function normalizeTileAddress({ layer, z, x, y }) { + const spec = TILE_LAYERS[layer]; + if (!spec) throw new TileRequestError(`Unknown tile layer: ${layer}`); + const zi = Number(z); + const xi = Number(x); + const yi = Number(y); + if ( + ![zi, xi, yi].every((v) => Number.isInteger(v) && v >= 0) || + zi < spec.minZoom || + zi > spec.maxZoom + ) + throw new TileRequestError( + `Tile zoom for ${layer} must be ${spec.minZoom}–${spec.maxZoom}`, + ); + const n = 2 ** zi; + if (xi >= n || yi >= n) + throw new TileRequestError('Tile address out of range'); + return { + layer, + upstream: spec.upstream, + dropLayers: spec.dropLayers || [], + z: zi, + x: xi, + y: yi, + key: `${layer}/${zi}/${xi}/${yi}`, + }; +} + +/** @type {Map} insertion-ordered LRU */ +const _memory = new Map(); +let _memoryBytes = 0; +/** @type {Map>} */ +const _inFlight = new Map(); + +function memoryGet(key) { + const hit = _memory.get(key); + if (!hit) return null; + // Re-insert to mark as most recently used. + _memory.delete(key); + _memory.set(key, hit); + return hit.bytes; +} + +function memoryPut(key, bytes) { + if (bytes.length > TILE_MEMORY_BUDGET_BYTES / 2) return; + const existing = _memory.get(key); + if (existing) _memoryBytes -= existing.bytes.length; + _memory.set(key, { bytes, at: Date.now() }); + _memoryBytes += bytes.length; + while (_memoryBytes > TILE_MEMORY_BUDGET_BYTES && _memory.size) { + const [oldest, entry] = _memory.entries().next().value; + _memory.delete(oldest); + _memoryBytes -= entry.bytes.length; + } +} + +function diskPath({ layer, z, x, y }) { + return path.join(TILE_DISK_DIR, layer, String(z), `${x}-${y}.pbf`); +} + +async function readDisk(address) { + const file = diskPath(address); + try { + const stat = await fsp.stat(file); + if (Date.now() - stat.mtimeMs > TILE_DISK_TTL_MS) return null; + return await fsp.readFile(file); + } catch { + return null; + } +} + +function writeDisk(address, bytes) { + const file = diskPath(address); + fsp + .mkdir(path.dirname(file), { recursive: true }) + .then(() => fsp.writeFile(`${file}.tmp`, bytes)) + .then(() => fsp.rename(`${file}.tmp`, file)) + .catch((error) => + console.warn( + '[Mapillary Proxy] tile cache write failed:', + error?.message || error, + ), + ); +} + +async function fetchUpstream(address, signal) { + const token = mapillaryToken(); + if (!token) throw new TileRequestError('Mapillary token not configured', 503); + const url = `${MAPILLARY_TILE_HOST}/${address.upstream}/2/${address.z}/${address.x}/${address.y}?access_token=${encodeURIComponent(token)}`; + const timeout = AbortSignal.timeout(TILE_FETCH_TIMEOUT_MS); + const response = await fetch(url, { + signal: signal ? AbortSignal.any([signal, timeout]) : timeout, + headers: { Accept: 'application/x-protobuf' }, + }); + if (response.status === 404 || response.status === 204) { + await response.body?.cancel().catch(() => {}); + return Buffer.alloc(0); + } + if (!response.ok) { + await response.body?.cancel().catch(() => {}); + throw new TileUpstreamError(response.status); + } + const declared = Number(response.headers.get('content-length')); + if (Number.isFinite(declared) && declared > TILE_MAX_BYTES) { + await response.body?.cancel().catch(() => {}); + throw new TileUpstreamError(502, 'Mapillary tile exceeds size cap'); + } + const bytes = Buffer.from(await response.arrayBuffer()); + if (bytes.length > TILE_MAX_BYTES) + throw new TileUpstreamError(502, 'Mapillary tile exceeds size cap'); + return bytes; +} + +/** Strip the layers this proxy never serves for the address's layer spec. */ +function trim(address, bytes) { + if (!address.dropLayers.length || !bytes.length) return bytes; + try { + return stripTileLayers(bytes, address.dropLayers); + } catch (error) { + console.warn( + '[Mapillary Proxy] tile trim failed, serving raw:', + error?.message || error, + ); + return bytes; + } +} + +/** + * Fetch one tile through memory, disk and in-flight coalescing, then + * Mapillary. Returns the raw protobuf bytes (empty for a tile with no data). + * @param {{layer:string,z:number|string,x:number|string,y:number|string}} request + * @param {{signal?: AbortSignal}} [options] + * @returns {Promise<{bytes: Buffer, source: 'memory'|'disk'|'inflight'|'upstream', address: object}>} + */ +export async function fetchTile(request, { signal } = {}) { + const address = normalizeTileAddress(request); + const memory = memoryGet(address.key); + if (memory) return { bytes: memory, source: 'memory', address }; + const disk = await readDisk(address); + if (disk) { + // Older cache files may still hold the untrimmed tile: trim and replace. + const bytes = trim(address, disk); + if (bytes !== disk) writeDisk(address, bytes); + memoryPut(address.key, bytes); + return { bytes, source: 'disk', address }; + } + const pending = _inFlight.get(address.key); + if (pending) return { bytes: await pending, source: 'inflight', address }; + const work = fetchUpstream(address, signal) + .then((raw) => trim(address, raw)) + .then((bytes) => { + memoryPut(address.key, bytes); + writeDisk(address, bytes); + return bytes; + }) + .finally(() => _inFlight.delete(address.key)); + _inFlight.set(address.key, work); + return { bytes: await work, source: 'upstream', address }; +} + +/** Test seam: forget every cached tile held in memory. */ +export function _resetTileMemoryForTest() { + _memory.clear(); + _memoryBytes = 0; + _inFlight.clear(); +} diff --git a/server/providers/mapillary/trim.js b/server/providers/mapillary/trim.js new file mode 100644 index 000000000..6111988df --- /dev/null +++ b/server/providers/mapillary/trim.js @@ -0,0 +1,58 @@ +import { PbfReader, PbfWriter } from 'pbf'; + +/** Field number of `layers` in a Mapbox Vector Tile, and of `name` in a layer. */ +const TILE_LAYER_FIELD = 3; +const LAYER_NAME_FIELD = 1; + +/** Read a layer's name without touching its features. */ +function layerName(layerBytes) { + const pbf = new PbfReader(layerBytes); + let name = ''; + pbf.readFields((tag, _result, reader) => { + if (tag !== LAYER_NAME_FIELD) return; + name = reader.readString(); + reader.pos = reader.length; // the name is all we need + }, null); + return name; +} + +/** + * Drop whole layers from a vector tile at the protobuf level. Nothing inside + * a kept layer is decoded or re-encoded, so a 12 MB tile whose bulk is an + * unused layer trims in a few milliseconds. Returns the input when nothing + * was dropped. + * @param {Buffer|Uint8Array} bytes + * @param {Iterable} dropNames + */ +export function stripTileLayers(bytes, dropNames) { + const drop = new Set(dropNames || []); + if (!bytes?.length || !drop.size) return bytes; + const reader = new PbfReader(bytes); + const writer = new PbfWriter(); + let dropped = false; + reader.readFields((tag, _result, pbf) => { + if (tag !== TILE_LAYER_FIELD) return; // pbf skips what we leave alone + const end = pbf.readVarint() + pbf.pos; + const layerBytes = bytes.subarray(pbf.pos, end); + pbf.pos = end; + if (drop.has(layerName(layerBytes))) { + dropped = true; + return; + } + writer.writeBytesField(TILE_LAYER_FIELD, layerBytes); + }, null); + return dropped ? Buffer.from(writer.finish()) : bytes; +} + +/** Names of the layers inside a vector tile, in order. */ +export function listTileLayers(bytes) { + const names = []; + if (!bytes?.length) return names; + new PbfReader(bytes).readFields((tag, _result, pbf) => { + if (tag !== TILE_LAYER_FIELD) return; + const end = pbf.readVarint() + pbf.pos; + names.push(layerName(bytes.subarray(pbf.pos, end))); + pbf.pos = end; + }, null); + return names; +} diff --git a/src/app/catalog.js b/src/app/catalog.js index 0d22ec9a1..03aaf154b 100644 --- a/src/app/catalog.js +++ b/src/app/catalog.js @@ -4,6 +4,7 @@ const CONTROL_LAYER_IDS = Object.freeze({ militaryFlightsLayer: 'military', satellitesLayer: 'satellites', cctvLayer: 'cctv', + mapillaryLayer: 'mapillary', radioLayer: 'radio', bikeshareLayer: 'bikeshare', transitLayer: 'transit', diff --git a/src/app/catalog.test.mjs b/src/app/catalog.test.mjs index 820943049..2260eef0a 100644 --- a/src/app/catalog.test.mjs +++ b/src/app/catalog.test.mjs @@ -93,6 +93,7 @@ test('controls bind catalog instances rather than similarly named defaults', () 'military-awareness', 'military-installations', 'rocket-launches', + 'mapillary', ]; const layers = ids.map((id) => ({ id })); const catalog = createLayerCatalog(layers, metadata(layers)); diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index f22ce2cd4..fad78c9df 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -18,6 +18,7 @@ import { createApplicationAwareness } from './layers/militaryAwareness.js'; import { createApplicationFirms } from './layers/firms.js'; import { createApplicationEarthquakes } from './layers/earthquakes.js'; import { createApplicationCables } from './layers/submarineCables.js'; +import { createApplicationMapillary } from './layers/mapillary.js'; import { createInfrastructureLayers } from '../data/infrastructure.js'; import { localGeoJsonServices } from './localGeojsonServices.js'; import { createBhoteKoshiEventLayer } from '../data/bhoteKoshiEvent.js'; @@ -44,6 +45,18 @@ const SOURCE_METHODS = Object.freeze({ firms: ['getSnapshot'], earthquakes: ['getSnapshot'], cables: ['fetch'], + mapillary: [ + 'getStatus', + 'getTile', + 'queryFeatures', + 'plan', + 'geocode', + 'getImage', + 'getSequenceImages', + 'nearestImages', + 'getMapFeature', + 'spriteUrl', + ], }); /** Construct the current catalog without choosing any source provider. @@ -112,6 +125,7 @@ export function createApplicationCatalog({ military, createApplicationEarthquakes({ source: sources.earthquakes }), createApplicationAlpr({ surface, source: sources.alpr }), + createApplicationMapillary({ surface, source: sources.mapillary }), satellites, createApplicationLaunches({ source: sources.launches, satellites }), createApplicationTraffic({ source: sources.traffic }), diff --git a/src/app/constructCatalog.test.mjs b/src/app/constructCatalog.test.mjs index 6d1c0224f..6095ddd4b 100644 --- a/src/app/constructCatalog.test.mjs +++ b/src/app/constructCatalog.test.mjs @@ -39,7 +39,7 @@ test('catalogs construct distinct layers and classification from their supplied signal: b.signal, surface: fixtureSurface(b.signal), }); - assert.equal(first.layers.length, 21); + assert.equal(first.layers.length, 22); assert.ok(first.get('transit')); const order = first.layers.map(({ id }) => id); assert.deepEqual( diff --git a/src/app/layers/mapillary.js b/src/app/layers/mapillary.js new file mode 100644 index 000000000..311caf517 --- /dev/null +++ b/src/app/layers/mapillary.js @@ -0,0 +1,19 @@ +import { createMapillaryLayer } from '../../layers/mapillary/index.js'; +import * as sprites from '../../data/spriteOrder.js'; +import * as picking from '../../data/pickRegistry.js'; +import * as input from '../../data/inputOwnership.js'; +import * as render from '../../renderGovernor.js'; + +/** Construct the Mapillary layer using the application scene owners and a supplied source. */ +export function createApplicationMapillary({ surface, source }) { + return createMapillaryLayer({ + source, + services: { + sprites, + picking, + input, + render, + ground: surface?.groundFloor ?? null, + }, + }); +} diff --git a/src/data/layerState.js b/src/data/layerState.js index 68eebd777..afcc9072e 100644 --- a/src/data/layerState.js +++ b/src/data/layerState.js @@ -344,6 +344,7 @@ export const LAYER_STATE_REGISTRY = Object.freeze([ disposition: 'enabled-only', }), Object.freeze({ id: 'local-firms', token: 'w', disposition: 'enabled-only' }), + Object.freeze({ id: 'mapillary', token: 'y', disposition: 'enabled-only' }), Object.freeze({ id: 'military', token: 'm', diff --git a/src/keySetupCore.mjs b/src/keySetupCore.mjs index e8bfef92c..74c52d1a2 100644 --- a/src/keySetupCore.mjs +++ b/src/keySetupCore.mjs @@ -25,7 +25,7 @@ export const KEY_SETUP_APPEND_HEADER = * Provider credentials, in display order — most magic per * minute first. `tier` mirrors the README's color legend: 'metered' (🔴) is a * billing-enabled account, 'free' (🟡) is a register-and-paste key. - * `clientExposed` marks the two keys that are injected into the browser + * `clientExposed` marks the keys that are injected into the browser * bundle by design (restrict them at the provider, per SECURITY.md). * `hidden` keeps advanced configuration out of the panel and missing-key count. */ @@ -98,6 +98,24 @@ export const KEY_SETUP_KEYS = Object.freeze([ envVars: Object.freeze(['OPENSKY_CLIENT_ID', 'OPENSKY_CLIENT_SECRET']), tier: 'free', }), + Object.freeze({ + id: 'mapillary', + title: 'MAPILLARY', + unlocks: + 'Street-level imagery, detected objects + traffic signs, and the Mapillary AI query', + getUrl: 'https://www.mapillary.com/dashboard/developers', + envVars: Object.freeze(['MAPILLARY_CLIENT_TOKEN']), + tier: 'free', + clientExposed: true, + }), + Object.freeze({ + id: 'anthropic', + title: 'ANTHROPIC', + unlocks: 'Mapillary AI — type a question, get it drawn on the globe', + getUrl: 'https://console.anthropic.com/settings/keys', + envVars: Object.freeze(['ANTHROPIC_API_KEY']), + tier: 'metered', + }), Object.freeze({ id: 'launch-library', title: 'LAUNCH LIBRARY', diff --git a/src/layers/mapillary/coverage.js b/src/layers/mapillary/coverage.js new file mode 100644 index 000000000..8cfab5490 --- /dev/null +++ b/src/layers/mapillary/coverage.js @@ -0,0 +1,524 @@ +import * as Cesium from 'cesium'; +import { decodeCoverageTile } from './decode.js'; +import { + coverageZoomForHeight, + overviewZoomForHeight, + tilesForBbox, +} from './tileMath.js'; +import { + COLORS, + COVERAGE_LINE_WIDTH_PX, + COVERAGE_MAX_SEQUENCES, + COVERAGE_MAX_TILES, + COVERAGE_MOVE_DEBOUNCE_MS, + COVERAGE_OVERVIEW_MAX_TILES, + COVERAGE_OVERVIEW_POINT_PX, + COVERAGE_RECENT_DAYS, + PICK_PREFIX, +} from './policy.js'; + +/** Old-zoom tiles are kept at most this long after a zoom change. */ +const STALE_TILE_MAX_MS = 6000; +/** + * Sequences per ground primitive. Ground polyline geometry is built on a + * worker in proportion to the instance count, so smaller batches put the + * first lines on screen sooner instead of one big batch arriving late. + */ +const SEQUENCE_PRIMITIVE_BATCH = 120; + +const PER_TILE_SEQUENCE_CAP = Math.floor( + COVERAGE_MAX_SEQUENCES / COVERAGE_MAX_TILES, +); + +/** Colour for a sequence: brand green, dimmer with age, magenta for panoramas. */ +function sequenceColor(sequence, { selected = false, now = Date.now() } = {}) { + if (selected) return Cesium.Color.fromCssColorString(COLORS.selected); + if (sequence.isPano) + return Cesium.Color.fromCssColorString(COLORS.pano).withAlpha(0.9); + const ageDays = (now - (sequence.capturedAt || 0)) / 86_400_000; + return ageDays <= COVERAGE_RECENT_DAYS + ? Cesium.Color.fromCssColorString(COLORS.coverage).withAlpha(0.92) + : Cesium.Color.fromCssColorString(COLORS.coverageOld).withAlpha(0.7); +} + +/** + * Whether one capture passes the imagery filter: panorama mode + * ('all' | 'pano' | 'flat') and an optional earliest capture time. + */ +export function passesImageryFilter(record, filter) { + if (!filter) return true; + if (filter.pano === 'pano' && !record.isPano) return false; + if (filter.pano === 'flat' && record.isPano) return false; + if ( + Number.isFinite(filter.sinceMs) && + filter.sinceMs > 0 && + (record.capturedAt || 0) < filter.sinceMs + ) + return false; + return true; +} + +/** + * Camera height above the surface under the camera, in metres. Falls back to + * the ellipsoidal height when the globe has no height sample yet. + */ +function cameraHeightAboveGround(viewer) { + const carto = viewer?.camera?.positionCartographic; + if (!carto) return null; + const ground = viewer.scene?.globe?.getHeight?.(carto); + return carto.height - (Number.isFinite(ground) ? ground : 0); +} + +/** + * Visible bbox as [west, south, east, north] degrees, or null when the camera + * does not see the ground. A grid of screen rays is cast onto the ellipsoid + * and only the rays that hit count, so a view that includes the horizon (or a + * canvas whose frustum is stale) cannot inflate the box to the whole world; + * `computeViewRectangle` is the fallback when too few rays land. + */ +export function visibleBbox(viewer, { grid = 5 } = {}) { + const scene = viewer?.scene; + const camera = viewer?.camera; + if (!scene || !camera) return null; + const width = scene.canvas?.clientWidth || scene.canvas?.width || 0; + const height = scene.canvas?.clientHeight || scene.canvas?.height || 0; + const hits = []; + if (width > 0 && height > 0) { + const ellipsoid = scene.globe?.ellipsoid || Cesium.Ellipsoid.WGS84; + const point = new Cesium.Cartesian2(); + for (let i = 0; i <= grid; i++) { + for (let j = 0; j <= grid; j++) { + point.x = (width * i) / grid; + point.y = (height * j) / grid; + let cartesian = null; + try { + cartesian = camera.pickEllipsoid(point, ellipsoid); + } catch { + cartesian = null; + } + if (!cartesian) continue; + const carto = Cesium.Cartographic.fromCartesian(cartesian, ellipsoid); + if (carto) hits.push(carto); + } + } + } + if (hits.length >= 4) { + let west = Infinity; + let south = Infinity; + let east = -Infinity; + let north = -Infinity; + for (const carto of hits) { + const lon = Cesium.Math.toDegrees(carto.longitude); + const lat = Cesium.Math.toDegrees(carto.latitude); + west = Math.min(west, lon); + east = Math.max(east, lon); + south = Math.min(south, lat); + north = Math.max(north, lat); + } + if (east - west > 0 && north - south > 0) return [west, south, east, north]; + } + const rectangle = camera.computeViewRectangle?.(scene.globe?.ellipsoid); + if (!rectangle) return null; + return [ + Cesium.Math.toDegrees(rectangle.west), + Cesium.Math.toDegrees(rectangle.south), + Cesium.Math.toDegrees(rectangle.east), + Cesium.Math.toDegrees(rectangle.north), + ]; +} + +/** + * Camera-driven coverage: z0–5 `overview` points from orbit down to 60 km, + * then z11–14 sequence polylines clamped to terrain and 3D tiles. Decoded + * tiles are kept so the imagery filter can rebuild without refetching. + */ +export function createCoverage({ state, source }) { + const { render } = state.services; + + function requestRender() { + render?.governorRequestRender?.('mapillary-coverage'); + } + + function ensureTerrainReady() { + return (state.coverage.terrainReady ||= + Cesium.GroundPolylinePrimitive.initializeTerrainHeights()); + } + + function tileKey({ x, y, z }) { + return `${z}/${x}/${y}`; + } + + function filter() { + return state.coverage.filter; + } + + /** One or more ground primitives for a tile's sequences, in draw batches. */ + function buildSequencePrimitives(sequences) { + const now = Date.now(); + const instances = []; + for (const sequence of sequences) { + if (!passesImageryFilter(sequence, filter())) continue; + const flat = []; + for (const [lon, lat] of sequence.coordinates) flat.push(lon, lat); + let positions; + try { + positions = Cesium.Cartesian3.fromDegreesArray(flat); + } catch { + continue; + } + if (positions.length < 2) continue; + instances.push( + new Cesium.GeometryInstance({ + geometry: new Cesium.GroundPolylineGeometry({ + positions, + width: COVERAGE_LINE_WIDTH_PX, + }), + id: `${PICK_PREFIX.sequence}${sequence.id}`, + attributes: { + color: Cesium.ColorGeometryInstanceAttribute.fromColor( + sequenceColor(sequence, { + now, + selected: sequence.id === state.sequence.selectedId, + }), + ), + }, + }), + ); + } + const primitives = []; + for (let i = 0; i < instances.length; i += SEQUENCE_PRIMITIVE_BATCH) + primitives.push( + new Cesium.GroundPolylinePrimitive({ + geometryInstances: instances.slice(i, i + SEQUENCE_PRIMITIVE_BATCH), + appearance: new Cesium.PolylineColorAppearance(), + classificationType: Cesium.ClassificationType.BOTH, + asynchronous: true, + allowPicking: true, + }), + ); + return { primitives, count: instances.length }; + } + + function buildOverviewCollection(points) { + const collection = new Cesium.PointPrimitiveCollection({ + blendOption: Cesium.BlendOption.TRANSLUCENT, + }); + const green = Cesium.Color.fromCssColorString(COLORS.coverage).withAlpha( + 0.85, + ); + const pink = Cesium.Color.fromCssColorString(COLORS.pano).withAlpha(0.9); + let count = 0; + for (const point of points) { + if (!passesImageryFilter(point, filter())) continue; + collection.add({ + position: Cesium.Cartesian3.fromDegrees(point.lon, point.lat), + color: point.isPano ? pink : green, + pixelSize: COVERAGE_OVERVIEW_POINT_PX, + disableDepthTestDistance: Number.POSITIVE_INFINITY, + }); + count++; + } + return { collection, count }; + } + + function attachPrimitive(entry) { + const scene = state.viewer?.scene; + if (!scene) return; + if (entry.kind === 'sequence') { + const { primitives, count } = buildSequencePrimitives(entry.sequenceList); + entry.primitives = primitives; + entry.count = count; + for (const primitive of primitives) { + primitive.show = !state.coverage.resting; + scene.groundPrimitives.add(primitive); + } + } else { + const { collection, count } = buildOverviewCollection(entry.points); + entry.primitive = collection; + entry.count = count; + // Orbit-level points never rest: results are invisible at that scale. + collection.show = true; + scene.primitives.add(collection); + } + } + + function detachPrimitive(entry) { + const scene = state.viewer?.scene; + for (const primitive of entry.primitives || []) { + try { + scene?.groundPrimitives?.remove(primitive); + } catch { + /* already gone */ + } + } + entry.primitives = []; + if (entry.primitive) { + try { + scene?.primitives?.remove(entry.primitive); + } catch { + /* already gone */ + } + entry.primitive = null; + } + } + + function removeTile(key) { + const entry = state.coverage.tiles.get(key); + if (!entry) return; + detachPrimitive(entry); + state.coverage.tiles.delete(key); + } + + async function loadTile(tile, generation, kind) { + const key = tileKey(tile); + if (state.coverage.tiles.has(key) || state.coverage.pending.has(key)) + return; + const controller = new AbortController(); + state.coverage.pending.set(key, controller); + state.coverage.loading++; + notify(); + try { + // Fetch and the one-time terrain-height table load run side by side. + const fetching = source.getTile('coverage', tile.z, tile.x, tile.y, { + signal: controller.signal, + }); + if (kind === 'sequence') await ensureTerrainReady(); + const bytes = await fetching; + // A refresh that still wants this tile must not throw the bytes away; + // only an abort (tile no longer wanted, or zoom changed) does. + if ( + controller.signal.aborted || + kind !== state.coverage.kind || + tile.z !== state.coverage.zoom + ) + return; + const decoded = decodeCoverageTile(bytes, tile); + const entry = { kind, primitive: null, primitives: [], count: 0 }; + if (kind === 'sequence') { + // Newest first, capped per tile so a dense city stays within budget. + const sequences = decoded.sequences + .sort((a, b) => (b.capturedAt || 0) - (a.capturedAt || 0)) + .slice(0, PER_TILE_SEQUENCE_CAP); + entry.sequenceList = sequences; + entry.sequences = new Map(sequences.map((s) => [s.id, s])); + entry.total = decoded.sequences.length; + } else { + entry.points = decoded.overview; + entry.sequences = new Map(); + entry.total = decoded.overview.length; + } + attachPrimitive(entry); + state.coverage.tiles.set(key, entry); + state.coverage.lastError = null; + requestRender(); + } catch (error) { + if (controller.signal.aborted) return; + state.coverage.lastError = error?.message || 'Coverage tile failed'; + if (error?.keyRequired) state.keyRequired = true; + } finally { + state.coverage.pending.delete(key); + state.coverage.loading = Math.max(0, state.coverage.loading - 1); + if (!state.coverage.pending.size) purgeStale(); + notify(); + } + } + + /** Drop the previous zoom's tiles once the new ones are on screen. */ + function purgeStale() { + clearTimeout(state.coverage.staleTimer); + state.coverage.staleTimer = null; + if (!state.coverage.stale.size) return; + for (const entry of state.coverage.stale.values()) detachPrimitive(entry); + state.coverage.stale.clear(); + requestRender(); + } + + /** + * Move every loaded tile to the stale set instead of removing it, so the + * old zoom stays visible while the new zoom streams in (no blank globe). + */ + function retire() { + for (const controller of state.coverage.pending.values()) + controller.abort(); + state.coverage.pending.clear(); + state.coverage.loading = 0; + for (const [key, entry] of state.coverage.tiles) { + const previous = state.coverage.stale.get(key); + if (previous) detachPrimitive(previous); + state.coverage.stale.set(key, entry); + } + state.coverage.tiles.clear(); + clearTimeout(state.coverage.staleTimer); + state.coverage.staleTimer = setTimeout(purgeStale, STALE_TILE_MAX_MS); + } + + function notify() { + state.notify?.(); + } + + /** Recompute the tile set for the current camera and reconcile primitives. */ + function refresh() { + const viewer = state.viewer; + if (!viewer || !state.enabled || state.keyRequired) return; + const height = cameraHeightAboveGround(viewer); + const sequenceZoom = coverageZoomForHeight(height); + const overviewZoom = sequenceZoom ? null : overviewZoomForHeight(height); + const zoom = sequenceZoom ?? overviewZoom; + const kind = sequenceZoom ? 'sequence' : 'overview'; + let bbox = visibleBbox(viewer); + if (kind === 'overview' && (!bbox || zoom <= 1)) + bbox = [-180, -85, 180, 85]; + if (!zoom || !bbox) { + state.coverage.hint = + 'Point the camera at the globe for Mapillary coverage'; + clear(); + notify(); + return; + } + state.coverage.hint = ''; + if (zoom !== state.coverage.zoom || kind !== state.coverage.kind) { + retire(); + state.coverage.zoom = zoom; + state.coverage.kind = kind; + } + state.coverage.generation++; + const generation = state.coverage.generation; + const { tiles } = tilesForBbox(bbox, zoom, { + limit: + kind === 'sequence' ? COVERAGE_MAX_TILES : COVERAGE_OVERVIEW_MAX_TILES, + }); + const wanted = new Set(tiles.map(tileKey)); + for (const key of [...state.coverage.tiles.keys()]) + if (!wanted.has(key)) removeTile(key); + for (const [key, controller] of [...state.coverage.pending]) + if (!wanted.has(key)) { + controller.abort(); + state.coverage.pending.delete(key); + } + for (const tile of tiles) loadTile(tile, generation, kind); + notify(); + } + + function scheduleRefresh() { + clearTimeout(state.coverage.debounceTimer); + state.coverage.debounceTimer = setTimeout( + refresh, + COVERAGE_MOVE_DEBOUNCE_MS, + ); + } + + function attach(viewer) { + detach(); + const remove = viewer.camera.changed.addEventListener(scheduleRefresh); + const removeEnd = viewer.camera.moveEnd.addEventListener(scheduleRefresh); + state.coverage.removeCameraListener = () => { + remove(); + removeEnd(); + }; + refresh(); + } + + function detach() { + clearTimeout(state.coverage.debounceTimer); + state.coverage.debounceTimer = null; + state.coverage.removeCameraListener?.(); + state.coverage.removeCameraListener = null; + } + + function clear() { + state.coverage.generation++; + for (const controller of state.coverage.pending.values()) + controller.abort(); + state.coverage.pending.clear(); + state.coverage.loading = 0; + for (const key of [...state.coverage.tiles.keys()]) removeTile(key); + purgeStale(); + state.coverage.zoom = null; + state.coverage.kind = null; + requestRender(); + } + + /** Apply a new imagery filter and rebuild every loaded tile from its cache. */ + function setFilter(next) { + const current = state.coverage.filter; + const pano = ['all', 'pano', 'flat'].includes(next?.pano) + ? next.pano + : current.pano; + const sinceMs = + next && 'sinceMs' in next + ? Number.isFinite(next.sinceMs) && next.sinceMs > 0 + ? next.sinceMs + : null + : current.sinceMs; + if (pano === current.pano && sinceMs === current.sinceMs) return; + state.coverage.filter = { pano, sinceMs }; + purgeStale(); + for (const entry of state.coverage.tiles.values()) { + detachPrimitive(entry); + attachPrimitive(entry); + } + requestRender(); + notify(); + } + + /** Look a sequence up across loaded tiles. */ + function findSequence(id) { + for (const entry of state.coverage.tiles.values()) { + const hit = entry.sequences.get(id); + if (hit) return hit; + } + return null; + } + + /** Recolour one sequence in place (selection highlight). */ + function recolorSequence(id, selected) { + const instanceId = `${PICK_PREFIX.sequence}${id}`; + for (const entry of state.coverage.tiles.values()) { + const sequence = entry.sequences.get(id); + if (!sequence) continue; + for (const primitive of entry.primitives || []) { + if (!primitive.ready) continue; + try { + const attributes = + primitive.getGeometryInstanceAttributes(instanceId); + if (attributes) + attributes.color = Cesium.ColorGeometryInstanceAttribute.toValue( + sequenceColor(sequence, { selected }), + attributes.color, + ); + } catch { + /* instance not in this primitive */ + } + } + } + requestRender(); + } + + /** Rest (hide) the street-level sequence web while results are shown; orbit points stay. */ + function setResting(resting) { + state.coverage.resting = resting === true; + for (const entry of state.coverage.tiles.values()) + if (entry.kind === 'sequence') + for (const primitive of entry.primitives || []) + primitive.show = !state.coverage.resting; + requestRender(); + } + + function sequenceCount() { + let count = 0; + for (const entry of state.coverage.tiles.values()) count += entry.count; + return count; + } + + return { + attach, + detach, + refresh, + clear, + findSequence, + recolorSequence, + sequenceCount, + setResting, + setFilter, + }; +} diff --git a/src/layers/mapillary/decode.js b/src/layers/mapillary/decode.js new file mode 100644 index 000000000..a4c305650 --- /dev/null +++ b/src/layers/mapillary/decode.js @@ -0,0 +1,145 @@ +import { PbfReader } from 'pbf'; +import { VectorTile } from '@mapbox/vector-tile'; +import { tileLocalToLonLat } from './tileMath.js'; + +/** + * Decode a Mapillary coverage tile (`mly1_public`) in the browser. + * Sequences are LineStrings with capture metadata; the `image` layer is only + * present at z14 and is skipped unless asked for (it can hold >150k points). + * @param {Uint8Array} bytes + * @param {{x:number,y:number,z:number}} address + * @param {{images?: boolean}} [options] + * @returns {{sequences: Array, images: Array}} + */ +export function decodeCoverageTile(bytes, address, { images = false } = {}) { + const result = { sequences: [], images: [], overview: [] }; + if (!bytes || !bytes.length) return result; + const tile = new VectorTile(new PbfReader(bytes)); + const { x, y, z } = address; + const sequenceLayer = tile.layers.sequence; + if (sequenceLayer) { + for (let i = 0; i < sequenceLayer.length; i++) { + const feature = sequenceLayer.feature(i); + const props = feature.properties || {}; + const coordinates = []; + for (const ring of feature.loadGeometry()) { + for (const point of ring) { + coordinates.push( + tileLocalToLonLat(point.x, point.y, sequenceLayer.extent, x, y, z), + ); + } + } + if (coordinates.length < 2) continue; + result.sequences.push({ + id: String(props.id ?? feature.id ?? `${x}/${y}/${i}`), + imageId: props.image_id != null ? String(props.image_id) : null, + capturedAt: Number(props.captured_at) || 0, + isPano: props.is_pano === true, + onFoot: props.foot === true, + quality: Number.isFinite(props.quality_score) + ? props.quality_score + : null, + coordinates, + }); + } + } + // z0–5 tiles carry an `overview` point layer instead of sequences. + const overviewLayer = tile.layers.overview; + if (overviewLayer) { + for (let i = 0; i < overviewLayer.length; i++) { + const feature = overviewLayer.feature(i); + const props = feature.properties || {}; + const point = feature.loadGeometry()?.[0]?.[0]; + if (!point) continue; + const [lon, lat] = tileLocalToLonLat( + point.x, + point.y, + overviewLayer.extent, + x, + y, + z, + ); + result.overview.push({ + id: String(props.id ?? feature.id ?? i), + lon, + lat, + capturedAt: Number(props.captured_at) || 0, + isPano: props.is_pano === true, + sequenceId: props.sequence_id ? String(props.sequence_id) : null, + }); + } + } + const imageLayer = images ? tile.layers.image : null; + if (imageLayer) { + for (let i = 0; i < imageLayer.length; i++) { + const feature = imageLayer.feature(i); + const props = feature.properties || {}; + const point = feature.loadGeometry()?.[0]?.[0]; + if (!point) continue; + const [lon, lat] = tileLocalToLonLat( + point.x, + point.y, + imageLayer.extent, + x, + y, + z, + ); + result.images.push({ + id: String(props.id ?? feature.id), + lon, + lat, + compassAngle: Number(props.compass_angle) || 0, + capturedAt: Number(props.captured_at) || 0, + isPano: props.is_pano === true, + sequenceId: props.sequence_id ? String(props.sequence_id) : null, + }); + } + } + return result; +} + +/** Decode a base64 string into bytes without Node's Buffer. */ +function base64ToBytes(base64) { + if (typeof Buffer !== 'undefined' && typeof Buffer.from === 'function') + return new Uint8Array(Buffer.from(base64, 'base64')); + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return bytes; +} + +/** + * Decode a Mapillary detection `geometry` (a base64 vector tile whose single + * layer holds the segmentation polygon in a 4096 grid) into polygons of + * image-normalized [x, y] points in [0, 1], the coordinate space the + * MapillaryJS tag component expects. + * @param {string} base64 + * @returns {Array>} One ring per polygon. + */ +export function decodeDetectionPolygons(base64) { + if (!base64) return []; + let tile; + try { + tile = new VectorTile(new PbfReader(base64ToBytes(base64))); + } catch { + return []; + } + const polygons = []; + for (const layer of Object.values(tile.layers)) { + for (let i = 0; i < layer.length; i++) { + const feature = layer.feature(i); + for (const ring of feature.loadGeometry()) { + if (ring.length < 3) continue; + const points = ring.map((point) => [ + Math.min(1, Math.max(0, point.x / layer.extent)), + Math.min(1, Math.max(0, point.y / layer.extent)), + ]); + const [fx, fy] = points[0]; + const [lx, ly] = points[points.length - 1]; + if (fx !== lx || fy !== ly) points.push([fx, fy]); + polygons.push(points); + } + } + } + return polygons; +} diff --git a/src/layers/mapillary/decode.test.mjs b/src/layers/mapillary/decode.test.mjs new file mode 100644 index 000000000..0392c0df2 --- /dev/null +++ b/src/layers/mapillary/decode.test.mjs @@ -0,0 +1,25 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { decodeDetectionPolygons } from './decode.js'; + +// A real detection geometry from graph.mapillary.com (construction--barrier--curb). +const SAMPLE = + 'Gjp4AgoGbXB5LW9yKIAgEisYAwgBIiUJzi+gKHoACAMAAwADAAMAAwADAAMAAAcEAAQABAAEAAQABAAP'; + +test('a detection geometry decodes into a closed, normalized polygon', () => { + const polygons = decodeDetectionPolygons(SAMPLE); + assert.equal(polygons.length, 1); + const ring = polygons[0]; + assert.ok(ring.length >= 4); + for (const [x, y] of ring) { + assert.ok(x >= 0 && x <= 1, `x in range: ${x}`); + assert.ok(y >= 0 && y <= 1, `y in range: ${y}`); + } + assert.deepEqual(ring[0], ring[ring.length - 1]); + assert.ok(Math.abs(ring[0][0] - 3047 / 4096) < 1e-9); +}); + +test('garbage input yields no polygons instead of throwing', () => { + assert.deepEqual(decodeDetectionPolygons('not base64!!'), []); + assert.deepEqual(decodeDetectionPolygons(''), []); +}); diff --git a/src/layers/mapillary/features.js b/src/layers/mapillary/features.js new file mode 100644 index 000000000..d89efcbb6 --- /dev/null +++ b/src/layers/mapillary/features.js @@ -0,0 +1,313 @@ +import * as Cesium from 'cesium'; +import { + featureDotGlyph, + rasterizeSprite, + selectionRingGlyph, +} from './glyphs.js'; +import { + COLORS, + FEATURE_ICON_LIMIT, + FEATURE_ICON_MAX, + FEATURE_ICON_SIZE_PX, + FEATURE_POINT_SIZE_PX, + PICK_PREFIX, +} from './policy.js'; +import { colorForValue } from './values.js'; + +/** + * Query-result rendering: fast points while a query streams, Mapillary + * sprite icons once it completes (clamped to the ground for modest sets, + * height-sampled for city-sized ones), plus a selection ring. + */ +export function createFeatures({ state, source }) { + const { render, sprites } = state.services; + /** @type {Map>} */ + const spriteImages = new Map(); + + function requestRender() { + render?.governorRequestRender?.('mapillary-features'); + } + + function ensureCollections(viewer) { + const scene = viewer.scene; + if (!state.features.points) { + state.features.points = new Cesium.PointPrimitiveCollection({ + blendOption: Cesium.BlendOption.TRANSLUCENT, + }); + scene.primitives.add(state.features.points); + sprites?.registerSpriteCollection?.('mapillary', state.features.points); + } + if (!state.features.collection) { + state.features.collection = new Cesium.BillboardCollection({ scene }); + scene.primitives.add(state.features.collection); + sprites?.registerSpriteCollection?.( + 'mapillary', + state.features.collection, + ); + } + if (!state.features.highlight) { + state.features.highlight = new Cesium.BillboardCollection({ scene }); + scene.primitives.add(state.features.highlight); + sprites?.registerSpriteCollection?.( + 'mapillary', + state.features.highlight, + ); + } + } + + function groundPosition(lon, lat, lift = 1) { + const carto = Cesium.Cartographic.fromDegrees(lon, lat); + const height = state.viewer?.scene?.globe?.getHeight?.(carto); + return Cesium.Cartesian3.fromDegrees( + lon, + lat, + Number.isFinite(height) ? height + lift : lift, + ); + } + + const colorCache = new Map(); + function cesiumColor(value) { + let color = colorCache.get(value); + if (!color) { + color = Cesium.Color.fromCssColorString(colorForValue(value)); + colorCache.set(value, color); + } + return color; + } + + /** Sprite canvas for a value, cached; falls back to a coloured dot. */ + function spriteImage(value) { + let pending = spriteImages.get(value); + if (!pending) { + pending = fetch(source.spriteUrl(value)) + .then((response) => { + if (!response.ok) throw new Error(`sprite ${response.status}`); + return response.text(); + }) + .then((svg) => rasterizeSprite(svg, { size: 60 })) + .catch(() => + featureDotGlyph({ size: 60, color: colorForValue(value) }), + ); + spriteImages.set(value, pending); + } + return pending; + } + + /** Raw sprite SVG text for a value (3D sign faces), or null. */ + async function spriteSvg(value) { + try { + const response = await fetch(source.spriteUrl(value)); + return response.ok ? await response.text() : null; + } catch { + return null; + } + } + + /** Reset the result set and its primitives. */ + function reset() { + state.features.points?.removeAll(); + state.features.collection?.removeAll(); + state.features.highlight?.removeAll(); + Object.assign(state.features, { + rows: [], + byId: new Map(), + billboardById: new Map(), + counts: new Map(), + earliest: null, + latest: null, + total: 0, + truncated: false, + failed: 0, + progress: { done: 0, tiles: 0 }, + iconMode: false, + selectedId: null, + iconsHidden: false, + }); + if (state.features.points) state.features.points.show = true; + if (state.features.collection) state.features.collection.show = true; + requestRender(); + } + + /** Append streamed rows: [lon, lat, id, value, first, last]. */ + function appendRows(rows) { + const points = state.features.points; + if (!points) return; + const features = state.features; + for (const row of rows) { + const [lon, lat, id, value, first, last] = row; + if (features.byId.has(id)) continue; + features.byId.set(id, row); + features.rows.push(row); + features.counts.set(value, (features.counts.get(value) || 0) + 1); + if (first) + features.earliest = features.earliest + ? Math.min(features.earliest, first) + : first; + if (last) + features.latest = features.latest + ? Math.max(features.latest, last) + : last; + points.add({ + id: `${PICK_PREFIX.feature}${id}`, + position: groundPosition(lon, lat), + color: cesiumColor(value), + outlineColor: Cesium.Color.fromBytes(255, 255, 255, 200), + outlineWidth: 1.5, + pixelSize: FEATURE_POINT_SIZE_PX, + disableDepthTestDistance: Number.POSITIVE_INFINITY, + scaleByDistance: new Cesium.NearFarScalar(500, 1.2, 40_000, 0.5), + }); + } + features.total = features.rows.length; + requestRender(); + } + + /** + * Once a query completes, swap the dots for Mapillary icons. Sets up to + * FEATURE_ICON_LIMIT clamp to terrain and 3D tiles; larger sets sit on the + * sampled globe height (clamping tens of thousands of billboards stalls the + * frame). Beyond FEATURE_ICON_MAX the points stay. + */ + async function upgradeToIcons() { + const features = state.features; + if (!features.collection || features.rows.length > FEATURE_ICON_MAX) return; + const values = [...features.counts.keys()]; + const images = new Map( + await Promise.all( + values.map(async (value) => [value, await spriteImage(value)]), + ), + ); + if (features.loading || !state.viewer) return; + const clamp = features.rows.length <= FEATURE_ICON_LIMIT; + features.collection.removeAll(); + features.billboardById = new Map(); + for (const [lon, lat, id, value] of features.rows) { + const billboard = features.collection.add({ + id: `${PICK_PREFIX.feature}${id}`, + position: clamp + ? Cesium.Cartesian3.fromDegrees(lon, lat) + : groundPosition(lon, lat, 0.5), + image: images.get(value), + imageId: `mly-sprite:${value}`, + width: FEATURE_ICON_SIZE_PX, + height: FEATURE_ICON_SIZE_PX, + ...(clamp + ? { heightReference: Cesium.HeightReference.CLAMP_TO_GROUND } + : {}), + disableDepthTestDistance: Number.POSITIVE_INFINITY, + verticalOrigin: Cesium.VerticalOrigin.CENTER, + scaleByDistance: new Cesium.NearFarScalar(300, 1.1, 20_000, 0.35), + }); + features.billboardById.set(id, billboard); + } + features.points.show = false; + features.collection.show = !features.iconsHidden; + features.iconMode = true; + if (features.selectedId) select(features.selectedId); + requestRender(); + } + + /** Highlight one result: scale its icon and draw a ring behind it. */ + function select(id) { + const features = state.features; + clearSelection({ keepId: true }); + const row = features.byId.get(id); + if (!row) { + features.selectedId = null; + return null; + } + features.selectedId = id; + const billboard = features.billboardById?.get(id); + if (billboard) billboard.scale = 1.45; + const [lon, lat] = row; + const clamp = features.rows.length <= FEATURE_ICON_LIMIT; + features.highlight?.add({ + id: `${PICK_PREFIX.feature}${id}`, + position: clamp + ? Cesium.Cartesian3.fromDegrees(lon, lat) + : groundPosition(lon, lat, 0.5), + image: selectionRingGlyph({ size: 64, color: COLORS.selected }), + imageId: 'mly-selection-ring', + width: 58, + height: 58, + ...(clamp + ? { heightReference: Cesium.HeightReference.CLAMP_TO_GROUND } + : {}), + disableDepthTestDistance: Number.POSITIVE_INFINITY, + verticalOrigin: Cesium.VerticalOrigin.CENTER, + }); + requestRender(); + return row; + } + + function clearSelection({ keepId = false } = {}) { + const features = state.features; + if (features.selectedId) { + const previous = features.billboardById?.get(features.selectedId); + if (previous) previous.scale = 1; + } + features.highlight?.removeAll(); + if (!keepId) features.selectedId = null; + requestRender(); + } + + function setVisible(visible) { + const features = state.features; + if (features.points) features.points.show = visible && !features.iconMode; + if (features.collection) + features.collection.show = + visible && features.iconMode && !features.iconsHidden; + if (features.highlight) features.highlight.show = visible; + requestRender(); + } + + /** Hide the flat icons while 3D objects stand in for them. */ + function setIconsHidden(hidden) { + state.features.iconsHidden = hidden === true; + if (state.features.collection) + state.features.collection.show = + state.features.iconMode && !state.features.iconsHidden; + requestRender(); + } + + /** Fly the camera to frame the query bbox. */ + function frame(bbox = state.features.bbox, { duration = 2.2 } = {}) { + if (!bbox || !state.viewer) return; + const [west, south, east, north] = bbox; + state.viewer.camera.flyTo({ + destination: Cesium.Rectangle.fromDegrees(west, south, east, north), + duration, + }); + } + + function findRow(id) { + return state.features.byId.get(id) || null; + } + + function destroy(viewer) { + for (const key of ['points', 'collection', 'highlight']) { + const collection = state.features[key]; + if (collection) { + sprites?.unregisterSpriteCollection?.('mapillary', collection); + viewer?.scene?.primitives?.remove(collection); + state.features[key] = null; + } + } + } + + return { + ensureCollections, + reset, + appendRows, + upgradeToIcons, + setVisible, + setIconsHidden, + frame, + findRow, + select, + clearSelection, + spriteImage, + spriteSvg, + destroy, + }; +} diff --git a/src/layers/mapillary/glyphs.js b/src/layers/mapillary/glyphs.js new file mode 100644 index 000000000..b649a6942 --- /dev/null +++ b/src/layers/mapillary/glyphs.js @@ -0,0 +1,167 @@ +/** + * Canvas glyphs for billboards: a camera cone for images, a ring for + * panoramas, a position marker for the street-level viewer, and a fallback + * dot for map features whose sprite is unavailable. Pure canvas drawing so + * it can run without Cesium. + */ + +const _cache = new Map(); + +function canvas(size) { + const element = document.createElement('canvas'); + element.width = size; + element.height = size; + return element; +} + +/** Cone glyph pointing "up"; rotate the billboard by the compass angle. */ +export function imageConeGlyph({ + size = 32, + color = '#e8eaed', + pano = false, +} = {}) { + const key = `cone:${size}:${color}:${pano}`; + if (_cache.has(key)) return _cache.get(key); + const element = canvas(size); + const ctx = element.getContext('2d'); + const c = size / 2; + if (!pano) { + ctx.beginPath(); + ctx.moveTo(c, c); + ctx.arc(c, c, size * 0.46, -Math.PI / 2 - 0.62, -Math.PI / 2 + 0.62); + ctx.closePath(); + ctx.fillStyle = color; + ctx.globalAlpha = 0.42; + ctx.fill(); + ctx.globalAlpha = 1; + } else { + ctx.beginPath(); + ctx.arc(c, c, size * 0.4, 0, Math.PI * 2); + ctx.strokeStyle = color; + ctx.globalAlpha = 0.5; + ctx.lineWidth = Math.max(1.5, size * 0.09); + ctx.stroke(); + ctx.globalAlpha = 1; + } + ctx.beginPath(); + ctx.arc(c, c, size * 0.16, 0, Math.PI * 2); + ctx.fillStyle = color; + ctx.fill(); + ctx.lineWidth = Math.max(1, size * 0.05); + ctx.strokeStyle = 'rgba(10,10,15,0.85)'; + ctx.stroke(); + _cache.set(key, element); + return element; +} + +/** Pulsing-looking marker for the image the viewer currently shows. */ +export function positionMarkerGlyph({ size = 44, color = '#ffb300' } = {}) { + const key = `pos:${size}:${color}`; + if (_cache.has(key)) return _cache.get(key); + const element = canvas(size); + const ctx = element.getContext('2d'); + const c = size / 2; + ctx.beginPath(); + ctx.moveTo(c, c); + ctx.arc(c, c, size * 0.48, -Math.PI / 2 - 0.55, -Math.PI / 2 + 0.55); + ctx.closePath(); + ctx.fillStyle = color; + ctx.globalAlpha = 0.55; + ctx.fill(); + ctx.globalAlpha = 1; + ctx.beginPath(); + ctx.arc(c, c, size * 0.2, 0, Math.PI * 2); + ctx.fillStyle = color; + ctx.fill(); + ctx.lineWidth = 2; + ctx.strokeStyle = '#0a0a0f'; + ctx.stroke(); + ctx.beginPath(); + ctx.arc(c, c, size * 0.3, 0, Math.PI * 2); + ctx.strokeStyle = color; + ctx.lineWidth = 1.5; + ctx.globalAlpha = 0.8; + ctx.stroke(); + _cache.set(key, element); + return element; +} + +/** Fallback feature dot when no sprite exists for a value. */ +export function featureDotGlyph({ size = 30, color = '#00d4ff' } = {}) { + const key = `dot:${size}:${color}`; + if (_cache.has(key)) return _cache.get(key); + const element = canvas(size); + const ctx = element.getContext('2d'); + const c = size / 2; + ctx.beginPath(); + ctx.arc(c, c, size * 0.42, 0, Math.PI * 2); + ctx.fillStyle = 'rgba(10,10,15,0.82)'; + ctx.fill(); + ctx.beginPath(); + ctx.arc(c, c, size * 0.26, 0, Math.PI * 2); + ctx.fillStyle = color; + ctx.fill(); + _cache.set(key, element); + return element; +} + +/** + * Wrap a Mapillary sprite SVG (viewBox only, no intrinsic size) into a fixed + * square canvas with a dark backing disc so it reads over imagery. + * @param {string} svgText + * @param {{size?: number}} [options] + * @returns {Promise} + */ +export function rasterizeSprite(svgText, { size = 60 } = {}) { + const sized = svgText.replace( + /]*)>/i, + (match, attributes) => + ``, + ); + const url = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(sized)}`; + return new Promise((resolve, reject) => { + const image = new Image(); + image.onload = () => { + const element = canvas(size); + const ctx = element.getContext('2d'); + const c = size / 2; + ctx.beginPath(); + ctx.arc(c, c, size * 0.5, 0, Math.PI * 2); + ctx.fillStyle = 'rgba(10,10,15,0.78)'; + ctx.fill(); + ctx.lineWidth = 1.5; + ctx.strokeStyle = 'rgba(255,255,255,0.35)'; + ctx.stroke(); + const inset = size * 0.14; + ctx.drawImage(image, inset, inset, size - inset * 2, size - inset * 2); + resolve(element); + }; + image.onerror = () => reject(new Error('sprite rasterization failed')); + image.src = url; + }); +} + +/** Selection ring drawn behind the picked map feature. */ +export function selectionRingGlyph({ size = 64, color = '#00d4ff' } = {}) { + const key = `ring:${size}:${color}`; + if (_cache.has(key)) return _cache.get(key); + const element = canvas(size); + const ctx = element.getContext('2d'); + const c = size / 2; + ctx.beginPath(); + ctx.arc(c, c, size * 0.46, 0, Math.PI * 2); + ctx.strokeStyle = color; + ctx.lineWidth = Math.max(2, size * 0.06); + ctx.globalAlpha = 0.95; + ctx.stroke(); + ctx.beginPath(); + ctx.arc(c, c, size * 0.34, 0, Math.PI * 2); + ctx.lineWidth = Math.max(1, size * 0.03); + ctx.globalAlpha = 0.5; + ctx.stroke(); + ctx.globalAlpha = 1; + _cache.set(key, element); + return element; +} diff --git a/src/layers/mapillary/index.js b/src/layers/mapillary/index.js new file mode 100644 index 000000000..9cd211b5b --- /dev/null +++ b/src/layers/mapillary/index.js @@ -0,0 +1,435 @@ +import { createState } from './state.js'; +import { createCoverage } from './coverage.js'; +import { createSequences } from './sequences.js'; +import { createFeatures } from './features.js'; +import { createViewerBridge } from './viewerBridge.js'; +import { createQuery } from './query.js'; +import { createSelection } from './selection.js'; +import * as Cesium from 'cesium'; +import { + COLORS, + COVERAGE_RECENT_DAYS, + MAPILLARY_CREDIT_HTML, + MAPILLARY_KEY_ID, + MAPILLARY_LAYER_ID, +} from './policy.js'; +import { groupCountsByLabel, humanizeValue } from './values.js'; + +export { MAPILLARY_LAYER_ID } from './policy.js'; + +const SOURCE_METHODS = [ + 'getStatus', + 'getTile', + 'queryFeatures', + 'plan', + 'geocode', + 'getImage', + 'getSequenceImages', + 'nearestImages', + 'getMapFeature', + 'spriteUrl', +]; + +/** + * Construct the Mapillary street-level layer: camera-driven coverage, + * per-sequence image cones, the embedded viewer, and the natural-language + * feature query. Application services are injected; nothing here reads the + * DOM except the viewer host the UI hands over. + */ +export function createMapillaryLayer({ source, services = {} }) { + if (!SOURCE_METHODS.every((method) => typeof source?.[method] === 'function')) + throw new TypeError('A Mapillary source is required'); + const state = createState({ services }); + const parts = {}; + const context = { state, source, parts }; + parts.coverage = createCoverage(context); + parts.sequences = createSequences(context); + parts.features = createFeatures(context); + parts.viewer = createViewerBridge(context); + parts.query = createQuery(context); + parts.selection = createSelection(context); + + // Street-level actions shared by clicks, the query engine and the UI. + parts.street = { + async openImage(imageId, { keepFeature = false } = {}) { + const host = state.street.host; + if (!host) { + state.street.error = 'Open the Mapillary panel to view imagery'; + notify(); + return; + } + if (!keepFeature) { + parts.features.clearSelection(); + state.street.feature = null; + } + await parts.viewer.open(imageId, host); + }, + async openFeature(featureId) { + const row = parts.features.findRow(featureId); + parts.features.select(featureId); + state.street.loading = true; + state.street.error = null; + state.street.feature = row + ? { + id: featureId, + value: row[3], + label: humanizeValue(row[3]), + firstSeen: row[4], + lastSeen: row[5], + } + : { id: featureId }; + notify(); + try { + const detail = await source.getMapFeature(featureId); + const images = detail?.images?.data || []; + state.street.feature = { + ...state.street.feature, + value: detail.object_value, + label: humanizeValue(detail.object_value), + firstSeen: + Date.parse(detail.first_seen_at) || state.street.feature.firstSeen, + lastSeen: + Date.parse(detail.last_seen_at) || state.street.feature.lastSeen, + imageCount: images.length, + }; + if (!images.length) throw new Error('No image detected this feature'); + await parts.street.openImage(images[0].id, { keepFeature: true }); + await parts.viewer.highlightDetections( + images[0].id, + detail.object_value, + humanizeValue(detail.object_value), + ); + } catch (error) { + state.street.error = error?.message || 'Feature detail unavailable'; + state.street.loading = false; + notify(); + } + }, + }; + + /** Run low-priority work when the browser is idle (or soon, headless). */ + function scheduleIdle(task) { + if (typeof globalThis.requestIdleCallback === 'function') + globalThis.requestIdleCallback(() => task(), { timeout: 1500 }); + else setTimeout(task, 200); + } + + let notifyQueued = false; + function notify() { + if (notifyQueued) return; + notifyQueued = true; + queueMicrotask(() => { + notifyQueued = false; + const snapshot = getUIState(); + for (const listener of [...state.listeners]) { + try { + listener(snapshot); + } catch (error) { + console.warn('[Data:Mapillary] listener error:', error); + } + } + }); + } + state.notify = notify; + + async function refreshStatus() { + try { + state.status = await source.getStatus(); + state.statusError = null; + state.keyRequired = state.status?.configured !== true; + } catch (error) { + state.statusError = error?.message || 'status unavailable'; + state.keyRequired = !source.hasToken(); + } + notify(); + } + + /** Colour key for the coverage web, in the order the panel lists it. */ + function coverageLegend() { + const years = Math.round(COVERAGE_RECENT_DAYS / 365); + return [ + { key: 'recent', label: `Recent (≤${years} yr)`, color: COLORS.coverage }, + { key: 'older', label: 'Older', color: COLORS.coverageOld }, + { key: 'pano', label: '360°', color: COLORS.pano }, + { key: 'selected', label: 'Selected', color: COLORS.selected }, + ]; + } + + /** CC BY-SA attribution shown on the globe while the layer is on. */ + function presentCredit(viewer) { + if (state.credit || !viewer?.creditDisplay) return; + try { + state.credit = new Cesium.Credit(MAPILLARY_CREDIT_HTML, true); + viewer.creditDisplay.addStaticCredit(state.credit); + } catch { + state.credit = null; + } + } + + function hideCredit(viewer) { + if (!state.credit) return; + try { + viewer?.creditDisplay?.removeStaticCredit?.(state.credit); + } catch { + /* credit display already torn down */ + } + state.credit = null; + } + + function getUIState() { + return { + enabled: state.enabled, + keyRequired: state.keyRequired, + planner: state.status?.planner === true, + plannerModel: state.status?.plannerModel || null, + coverage: { + zoom: state.coverage.zoom, + kind: state.coverage.kind, + filter: { ...state.coverage.filter }, + legend: coverageLegend(), + loading: state.coverage.loading > 0, + sequences: parts.coverage.sequenceCount(), + hint: state.coverage.hint, + error: state.coverage.lastError, + }, + sequence: { + selectedId: state.sequence.selectedId, + images: state.sequence.images.length, + loading: state.sequence.loading, + }, + features: { + total: state.features.total, + title: state.features.title, + loading: state.features.loading, + progress: { ...state.features.progress }, + counts: groupCountsByLabel(state.features.counts.entries()), + truncated: state.features.truncated, + failed: state.features.failed, + iconMode: state.features.iconMode, + hasBbox: Boolean(state.features.bbox), + selectedId: state.features.selectedId, + bbox: state.features.bbox ? [...state.features.bbox] : null, + }, + street: { + open: state.street.open, + follow: state.street.follow, + loading: state.street.loading, + error: state.street.error, + imageId: state.street.imageId, + position: state.street.position ? { ...state.street.position } : null, + bearing: state.street.bearing, + isPano: state.street.isPano, + capturedAt: state.street.capturedAt, + sequenceId: state.street.sequenceId, + creator: state.street.creator || null, + renderMode: state.street.renderMode, + feature: state.street.feature ? { ...state.street.feature } : null, + highlight: state.street.highlight + ? { ...state.street.highlight } + : null, + }, + query: { + busy: state.query.busy, + stage: state.query.stage, + prompt: state.query.prompt, + place: state.query.place, + answer: state.query.answer, + error: state.query.error, + title: state.query.title || '', + usage: state.query.usage, + history: state.query.history.map(({ prompt, total }) => ({ + prompt, + total, + })), + }, + }; + } + + const layer = { + id: MAPILLARY_LAYER_ID, + name: 'Street Level', + icon: '📷', + source: 'Mapillary', + updateInterval: 0, + statsRefreshInterval: 1000, + requiresKeyId: MAPILLARY_KEY_ID, + + init(viewer) { + if (state.initialized) + throw new Error('Mapillary layer is already initialized'); + state.viewer = viewer; + state.initialized = true; + parts.sequences.ensureCollections(viewer); + parts.features.ensureCollections(viewer); + parts.sequences.setVisible(false); + parts.features.setVisible(false); + refreshStatus(); + console.log('[Data:Mapillary] Initialized'); + }, + + enable(viewer) { + state.enabled = true; + presentCredit(viewer); + parts.sequences.setVisible(true); + parts.features.setVisible(true); + parts.selection.install(viewer); + parts.coverage.attach(viewer); + if (!state.status) refreshStatus(); + scheduleIdle(() => parts.viewer.prewarm()); + notify(); + }, + + disable() { + state.enabled = false; + hideCredit(state.viewer); + parts.query.abort(); + parts.features.clearSelection(); + parts.coverage.setResting(false); + parts.coverage.detach(); + parts.coverage.clear(); + parts.sequences.clearSelection(); + parts.selection.uninstall(); + parts.viewer.destroy(); + parts.sequences.setVisible(false); + parts.features.setVisible(false); + notify(); + }, + + async update() { + return state.enabled; + }, + + destroy(viewer = state.viewer) { + layer.disable(); + parts.features.destroy(viewer); + parts.sequences.destroy(viewer); + state.listeners.clear(); + state.viewer = null; + state.initialized = false; + state.destroyed = true; + }, + + getStats() { + const loading = + state.coverage.loading > 0 || + state.features.loading || + state.query.busy; + let loadingLabel = ''; + if (state.keyRequired) loadingLabel = 'KEY REQUIRED'; + else if (state.query.busy) loadingLabel = `AI · ${state.query.stage}`; + else if (state.features.loading) + loadingLabel = `tiles ${state.features.progress.done}/${state.features.progress.tiles}`; + else if (state.coverage.loading > 0) loadingLabel = 'loading coverage...'; + else if (state.features.total) + loadingLabel = `${state.features.total.toLocaleString()} features`; + else if (state.coverage.hint && state.enabled) + loadingLabel = state.coverage.hint; + return { + count: state.features.total || parts.coverage.sequenceCount(), + sequences: parts.coverage.sequenceCount(), + features: state.features.total, + loading, + keyRequired: state.keyRequired, + error: state.keyRequired + ? 'KEY REQUIRED' + : state.coverage.lastError || state.query.error, + loadingLabel, + }; + }, + + // ── Public surface used by the panel and the voice/analyst seams ───── + subscribe(listener) { + if (typeof listener !== 'function') return () => {}; + state.listeners.add(listener); + return () => state.listeners.delete(listener); + }, + getUIState, + /** The DOM element the MapillaryJS viewer renders into. */ + attachViewerHost(element) { + state.street.host = element || null; + if (element && state.street.open) parts.viewer.resize(); + if (element && state.enabled) + scheduleIdle(() => parts.viewer.prewarm(element)); + }, + runQuery: (prompt) => parts.query.run(prompt), + /** Stop a running query (planning, geocoding or streaming). */ + abortQuery: () => parts.query.abort(), + /** Imagery filter for coverage, cones and nearest-image lookups. */ + setCoverageFilter(next) { + parts.coverage.setFilter(next); + parts.sequences.rerender(); + }, + getCoverageFilter: () => ({ ...state.coverage.filter }), + selectFeature: (id) => parts.features.select(id), + clearFeatureSelection: () => parts.features.clearSelection(), + /** Execute a ready-made plan (voice agent, tests) without the planner. */ + runPlan: (plan, options) => parts.query.runPlan(plan, options), + clearQuery: () => parts.query.clear(), + frameResults: () => parts.features.frame(), + openImage: (imageId) => parts.street.openImage(imageId), + openFeature: (featureId) => parts.street.openFeature(featureId), + async openNearest(point) { + const view = point || parts.query.viewContext(); + if (!Number.isFinite(view?.lat) || !Number.isFinite(view?.lon)) + return false; + state.street.loading = true; + state.street.error = null; + notify(); + try { + const images = await source.nearestImages({ + lat: view.lat, + lon: view.lon, + radius: 50, + limit: 5, + }); + if (!images.length) + throw new Error( + 'No Mapillary imagery within 50 m of the view centre', + ); + await parts.street.openImage(images[0].id); + return true; + } catch (error) { + state.street.error = error?.message || 'Nearest image unavailable'; + state.street.loading = false; + notify(); + return false; + } + }, + /** + * Close the image and deselect it everywhere on the map: position + * marker, highlighted sequence and its cones, result ring and outline. + */ + closeViewer() { + parts.viewer.close(); + parts.features.clearSelection(); + parts.sequences.clearSelection(); + }, + setViewerRenderMode: (mode) => parts.viewer.setRenderMode(mode), + setFollow: (enabled) => parts.viewer.setFollow(enabled), + lookAtImage: () => parts.viewer.lookAtPosition(), + resizeViewer: () => parts.viewer.resize(), + selectSequence: (id) => parts.sequences.select(id), + clearSequence: () => parts.sequences.clearSelection(), + refreshCoverage: () => parts.coverage.refresh(), + + /** Result rows for the analyst query engine: plain JSON-safe objects. */ + getAnalystRecords(maxCount = 2000) { + const limit = Number.isFinite(maxCount) + ? Math.max(1, Math.floor(maxCount)) + : 2000; + return state.features.rows + .slice(0, limit) + .map(([lon, lat, id, value, first, last]) => ({ + id, + type: 'mapillary-feature', + value, + label: humanizeValue(value), + lat, + lon, + firstSeen: first ? new Date(first).toISOString() : null, + lastSeen: last ? new Date(last).toISOString() : null, + })); + }, + }; + return layer; +} diff --git a/src/layers/mapillary/ndjson.js b/src/layers/mapillary/ndjson.js new file mode 100644 index 000000000..76bc6049b --- /dev/null +++ b/src/layers/mapillary/ndjson.js @@ -0,0 +1,61 @@ +/** + * Incremental NDJSON parsing for the streamed feature query. The server + * writes one JSON object per line; chunks arrive split at arbitrary byte + * boundaries, so a parser keeps the unfinished tail between pushes. + */ +export function createNdjsonParser(onRecord, onError = () => {}) { + let tail = ''; + const emitLine = (line) => { + const text = line.trim(); + if (!text) return; + try { + onRecord(JSON.parse(text)); + } catch (error) { + onError(error, text); + } + }; + return { + /** Feed one decoded text chunk. */ + push(chunk) { + tail += chunk; + let index; + while ((index = tail.indexOf('\n')) >= 0) { + emitLine(tail.slice(0, index)); + tail = tail.slice(index + 1); + } + }, + /** Flush a final line that had no trailing newline. */ + end() { + if (tail) emitLine(tail); + tail = ''; + }, + }; +} + +/** + * Read a fetch Response body as NDJSON, invoking `onRecord` per object. + * Resolves when the stream ends; rejects on network failure or abort. + */ +export async function readNdjsonResponse(response, onRecord, { signal } = {}) { + const parser = createNdjsonParser(onRecord); + const decoder = new TextDecoder(); + const reader = response.body?.getReader?.(); + if (!reader) { + parser.push(await response.text()); + parser.end(); + return; + } + const abort = () => reader.cancel().catch(() => {}); + signal?.addEventListener('abort', abort, { once: true }); + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + parser.push(decoder.decode(value, { stream: true })); + } + parser.push(decoder.decode()); + parser.end(); + } finally { + signal?.removeEventListener('abort', abort); + } +} diff --git a/src/layers/mapillary/ndjson.test.mjs b/src/layers/mapillary/ndjson.test.mjs new file mode 100644 index 000000000..f5c81dd58 --- /dev/null +++ b/src/layers/mapillary/ndjson.test.mjs @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createNdjsonParser } from './ndjson.js'; + +test('records split across chunks are reassembled in order', () => { + const seen = []; + const parser = createNdjsonParser((record) => seen.push(record)); + parser.push('{"type":"start","tiles":2}\n{"type":"ti'); + parser.push('le","i":0,"rows":[[1,2,"a","v",0,0]]}\n'); + parser.push('{"type":"done"}'); + parser.end(); + assert.deepEqual( + seen.map((record) => record.type), + ['start', 'tile', 'done'], + ); + assert.deepEqual(seen[1].rows, [[1, 2, 'a', 'v', 0, 0]]); +}); + +test('a malformed line is reported and skipped', () => { + const seen = []; + const errors = []; + const parser = createNdjsonParser( + (record) => seen.push(record), + (error, line) => errors.push(line), + ); + parser.push('not json\n{"ok":true}\n\n'); + parser.end(); + assert.deepEqual(seen, [{ ok: true }]); + assert.deepEqual(errors, ['not json']); +}); diff --git a/src/layers/mapillary/policy.js b/src/layers/mapillary/policy.js new file mode 100644 index 000000000..746bbc0c8 --- /dev/null +++ b/src/layers/mapillary/policy.js @@ -0,0 +1,65 @@ +/** Identity and tuning for the Mapillary street-level layer. */ +export const MAPILLARY_LAYER_ID = 'mapillary'; +export const MAPILLARY_KEY_ID = 'mapillary'; +export const MAPILLARY_GRAPH_HOST = 'https://graph.mapillary.com'; + +/** Stable id prefixes for picked primitives. */ +export const PICK_PREFIX = Object.freeze({ + sequence: 'mly:seq:', + image: 'mly:img:', + feature: 'mly:feat:', + object: 'mly:obj:', + position: 'mly:pos', +}); + +/** Brand green for coverage; panoramas get a distinct hue; selection is GEV cyan. */ +export const COLORS = Object.freeze({ + coverage: '#05cb63', + coverageOld: '#2e7d5b', + pano: '#ff4fd8', + selected: '#00d4ff', + image: '#e8eaed', + position: '#ffb300', +}); + +/** Camera-driven coverage refresh. */ +export const COVERAGE_MOVE_DEBOUNCE_MS = 320; +export const COVERAGE_MAX_TILES = 9; +/** Overview (z0–5) coverage points seen from orbit. */ +export const COVERAGE_OVERVIEW_MAX_TILES = 16; +export const COVERAGE_OVERVIEW_POINT_PX = 2.5; +export const COVERAGE_MAX_SEQUENCES = 6000; +export const COVERAGE_LINE_WIDTH_PX = 2.5; +/** Sequences newer than this many days draw at full brightness. */ +export const COVERAGE_RECENT_DAYS = 730; + +/** Per-sequence image cones after a sequence is selected. */ +export const SEQUENCE_IMAGES_LIMIT = 2000; +export const IMAGE_CONE_SIZE_PX = 26; +export const IMAGE_CONE_MIN_SPACING_M = 3; + +/** Nearest-image search when the user asks to look at a place. */ +export const NEAREST_RADIUS_M = 50; +export const NEAREST_LIMIT = 8; + +/** Query-result rendering: clamped icons up to this many features, unclamped + * icons up to FEATURE_ICON_MAX, coloured points beyond. */ +export const FEATURE_ICON_LIMIT = 6000; +export const FEATURE_ICON_MAX = 80_000; +export const FEATURE_ICON_SIZE_PX = 30; +export const FEATURE_POINT_SIZE_PX = 9; + +/** + * On-globe credit shown while the layer is enabled. Mapillary imagery and + * derived data are CC BY-SA 4.0 and require visible attribution. + */ +export const MAPILLARY_CREDIT_HTML = + 'Street Level: imagery © Mapillary contributors, CC BY-SA 4.0'; + +/** Fields requested from the graph API. */ +export const IMAGE_FIELDS = + 'id,captured_at,compass_angle,computed_compass_angle,geometry,computed_geometry,computed_altitude,is_pano,sequence,thumb_256_url,creator,quality_score'; +export const SEQUENCE_IMAGE_FIELDS = + 'id,captured_at,compass_angle,geometry,is_pano,computed_altitude'; +export const FEATURE_FIELDS = + 'id,object_value,geometry,first_seen_at,last_seen_at,images,aligned_direction'; diff --git a/src/layers/mapillary/query.js b/src/layers/mapillary/query.js new file mode 100644 index 000000000..71059752c --- /dev/null +++ b/src/layers/mapillary/query.js @@ -0,0 +1,516 @@ +import * as Cesium from 'cesium'; +import { passesImageryFilter, visibleBbox } from './coverage.js'; +import { countTilesForBbox, normalizeBbox } from './tileMath.js'; +import { groupCountsByLabel } from './values.js'; + +/** Fallback half-size (degrees) around a geocoded point with no bounds. */ +const POINT_PLACE_HALF_DEG = 0.02; +/** + * Smallest place box worth querying (degrees, ~1.1 km). Nominatim returns a + * node-sized box for many neighbourhoods and landmarks; a query on that would + * cover one intersection and read as "nothing here". + */ +const MIN_PLACE_SPAN_DEG = 0.01; +/** Radii (metres) of the probe rings used when nothing sits within 50 m. */ +const NEAREST_RING_M = [90, 180, 300]; +/** Cap for a "current view" query when the server does not report one. */ +const DEFAULT_MAX_TILES = 600; +const FEATURE_ZOOM = 14; +/** A current-view query wider than this is the horizon, not an area. */ +const MAX_VIEW_SPAN_DEG = 4; + +/** Shrink a bbox around its centre until it fits the tile budget. */ +export function fitBboxToTileBudget(bbox, maxTiles, zoom = FEATURE_ZOOM) { + let box = normalizeBbox(bbox); + if (!box) return null; + let guard = 0; + while ( + countTilesForBbox([box.west, box.south, box.east, box.north], zoom) > + maxTiles && + guard++ < 40 + ) { + const cx = (box.west + box.east) / 2; + const cy = (box.south + box.north) / 2; + const hw = ((box.east - box.west) / 2) * 0.85; + const hh = ((box.north - box.south) / 2) * 0.85; + box = { west: cx - hw, east: cx + hw, south: cy - hh, north: cy + hh }; + } + return [box.west, box.south, box.east, box.north]; +} + +/** Approximate metres between two lon/lat points (small distances). */ +function metresBetween(a, b) { + const lat = ((a.lat + b.lat) / 2) * (Math.PI / 180); + const dx = (b.lon - a.lon) * 111_320 * Math.cos(lat); + const dy = (b.lat - a.lat) * 110_540; + return Math.hypot(dx, dy); +} + +/** Grow a bbox symmetrically until both sides reach `minSpan` degrees. */ +export function enforceMinimumSpan(bbox, minSpan) { + const [west, south, east, north] = bbox; + if (east - west >= minSpan && north - south >= minSpan) return bbox; + const cx = (west + east) / 2; + const cy = (south + north) / 2; + const hw = Math.max((east - west) / 2, minSpan / 2); + const hh = Math.max((north - south) / 2, minSpan / 2); + return [cx - hw, cy - hh, cx + hw, cy + hh]; +} + +/** Turn a geocode result into a bbox, label and centre. */ +export function bboxFromGeocode(result) { + const geometry = result?.geometry; + const location = geometry?.location; + if ( + !location || + !Number.isFinite(location.lat) || + !Number.isFinite(location.lng) + ) + return null; + const bounds = geometry.bounds || geometry.viewport; + const bbox = bounds + ? [ + bounds.southwest.lng, + bounds.southwest.lat, + bounds.northeast.lng, + bounds.northeast.lat, + ] + : [ + location.lng - POINT_PLACE_HALF_DEG, + location.lat - POINT_PLACE_HALF_DEG, + location.lng + POINT_PLACE_HALF_DEG, + location.lat + POINT_PLACE_HALF_DEG, + ]; + return { + bbox: enforceMinimumSpan(bbox, MIN_PLACE_SPAN_DEG), + label: result.formatted_address || '', + center: { lat: location.lat, lon: location.lng }, + }; +} + +/** "1 tile" / "3 tiles". */ +function plural(count, singular, pluralForm = `${singular}s`) { + return `${count.toLocaleString()} ${count === 1 ? singular : pluralForm}`; +} + +/** Pluralise a humanised class label for a count ("fire hydrant" → "fire hydrants"). */ +export function pluralLabel(label, count) { + const word = String(label || '').toLowerCase(); + if (count === 1 || !word) return word; + if (/(s|x|z|ch|sh)$/.test(word)) return `${word}es`; + if (/[^aeiou]y$/.test(word)) return `${word.slice(0, -1)}ies`; + return `${word}s`; +} + +/** + * Compose the one-line result summary shown under the query box. Returns an + * empty string for zero results so the caller can replace, not append to, + * the planner's optimistic answer. + */ +export function summarizeResults(features) { + const total = features.total || 0; + if (!total) return ''; + const values = groupCountsByLabel(features.counts.entries()); + const classes = values + .slice(0, 3) + .map( + ({ label, count }) => + `${count.toLocaleString()} ${pluralLabel(label, count)}`, + ) + .join(', '); + const more = + values.length > 3 + ? ` and ${plural(values.length - 3, 'more class', 'more classes')}` + : ''; + const years = + features.earliest && features.latest + ? ` · seen ${new Date(features.earliest).getFullYear()}–${new Date(features.latest).getFullYear()}` + : ''; + const truncated = features.truncated ? ' · capped' : ''; + const failed = features.failed + ? ` · ${plural(features.failed, 'tile')} failed` + : ''; + // One class: "834 fire hydrants · seen 2017–2021". Several: lead with the total. + const head = + values.length === 1 + ? classes + : `${plural(total, 'feature')}: ${classes}${more}`; + return `${head}${years}${truncated}${failed}`; +} + +/** What the panel says when a query found nothing where it looked. */ +export function noResultsAnswer(plan, area) { + const subject = (plan?.title || 'matching features') + .replace(/\s*·.*$/, '') + .toLowerCase(); + const where = area?.label ? ` in ${area.label}` : ' here'; + return `No ${subject} mapped${where} yet. Try a larger area, another place, or set SINCE back to any date.`; +} + +/** Natural-language query orchestration: plan → resolve area → stream → draw. */ +export function createQuery({ state, source, parts }) { + function notify() { + state.notify?.(); + } + + function viewContext() { + const viewer = state.viewer; + if (!viewer) return {}; + const carto = viewer.camera.positionCartographic; + const bbox = visibleBbox(viewer); + const center = bbox + ? { lat: (bbox[1] + bbox[3]) / 2, lon: (bbox[0] + bbox[2]) / 2 } + : { + lat: Cesium.Math.toDegrees(carto.latitude), + lon: Cesium.Math.toDegrees(carto.longitude), + }; + const ground = viewer.scene.globe?.getHeight?.(carto); + return { + lat: center.lat, + lon: center.lon, + heightM: carto.height - (Number.isFinite(ground) ? ground : 0), + }; + } + + function maxTiles() { + return state.status?.limits?.maxTiles || DEFAULT_MAX_TILES; + } + + async function resolveArea(plan, signal) { + if (plan.place) { + const results = await source.geocode(plan.place, { signal }); + const resolved = bboxFromGeocode(results[0]); + if (!resolved) + throw new Error(`Could not find "${plan.place}" on the map`); + return { + ...resolved, + bbox: fitBboxToTileBudget(resolved.bbox, maxTiles()), + fromPlace: true, + }; + } + const bbox = visibleBbox(state.viewer); + // The horizon (or a camera still in flight) yields a continent-sized + // rectangle; querying that would silently pick a box in the middle of it. + if ( + !bbox || + bbox[2] - bbox[0] > MAX_VIEW_SPAN_DEG || + bbox[3] - bbox[1] > MAX_VIEW_SPAN_DEG + ) + throw new Error('Zoom in closer to the ground, or name a place'); + const view = viewContext(); + return { + bbox: fitBboxToTileBudget(bbox, maxTiles()), + label: 'current view', + center: { lat: view.lat, lon: view.lon }, + fromPlace: false, + }; + } + + async function runFeatures(plan, area, signal) { + const features = state.features; + parts.features.reset(); + Object.assign(features, { + bbox: area.bbox, + title: plan.title, + layer: plan.layer, + values: plan.values, + loading: true, + }); + state.query.stage = 'fetching'; + notify(); + // Results are the subject now: rest the coverage web until CLEAR so tens + // of thousands of markers are not read against a web of green lines. + parts.coverage.setResting(true); + // A named place is somewhere else: fly there. The current view already + // shows the area, so leave the camera where the user put it. + if (area.fromPlace) parts.features.frame(area.bbox); + let lastNotify = 0; + await source.queryFeatures( + { + bbox: area.bbox, + layer: plan.layer, + values: plan.values, + seenAfter: plan.seen_after, + seenBefore: plan.seen_before, + }, + (record) => { + if (record.type === 'start') + features.progress = { done: 0, tiles: record.tiles }; + else if (record.type === 'tile') { + features.progress.done++; + if (record.rows?.length) parts.features.appendRows(record.rows); + const now = Date.now(); + if (now - lastNotify > 120) { + lastNotify = now; + notify(); + } + } else if (record.type === 'done') { + features.truncated = record.truncated === true; + features.failed = record.failed || 0; + } + }, + { signal }, + ); + features.loading = false; + notify(); + await parts.features.upgradeToIcons(); + const summary = summarizeResults(features); + state.query.answer = summary + ? `${plan.answer} ${summary}`.trim() + : noResultsAnswer(plan, area); + notify(); + } + + async function runNearest(plan, area, signal) { + const found = await nearestImageAround(area.center, signal, { + preferPano: + plan.prefer_pano === true || state.coverage.filter.pano === 'pano', + }); + if (!found) + throw new Error( + `No Mapillary imagery within ${NEAREST_RING_M[NEAREST_RING_M.length - 1] + 50} m of ${area.label || 'that point'}`, + ); + await parts.street.openImage(found.image.id); + state.query.answer = `${plan.answer} Opened the closest image, about ${Math.round(found.distanceM)} m away.`; + } + + /** + * Mapillary's radius search stops at 50 m, which misses a landmark whose + * geocoded centre sits inside a large footprint. Probe the centre first, + * then rings of points around it, and keep the closest hit overall. + */ + async function nearestImageAround( + center, + signal, + { preferPano = false } = {}, + ) { + const rings = [[center]]; + for (const ring of NEAREST_RING_M) { + const probes = []; + for (let k = 0; k < 8; k++) { + const angle = (k / 8) * Math.PI * 2; + probes.push({ + lat: center.lat + (ring * Math.cos(angle)) / 110_540, + lon: + center.lon + + (ring * Math.sin(angle)) / + (111_320 * Math.cos((center.lat * Math.PI) / 180)), + }); + } + rings.push(probes); + } + for (const probes of rings) { + const results = await Promise.all( + probes.map((probe) => + source + .nearestImages( + { lat: probe.lat, lon: probe.lon, radius: 50, limit: 3 }, + { signal }, + ) + .catch(() => []), + ), + ); + let best = null; + let fallback = null; + for (const images of results) + for (const image of images) { + const coordinates = + image?.computed_geometry?.coordinates || + image?.geometry?.coordinates; + if (!coordinates) continue; + const distanceM = metresBetween(center, { + lon: coordinates[0], + lat: coordinates[1], + }); + const record = { + isPano: image.is_pano === true, + capturedAt: Number(image.captured_at) || 0, + }; + // The dock's imagery filter is a hard constraint; a 360° preference + // from the request only breaks ties within the filtered set. + const candidate = { + image, + distanceM, + score: distanceM + (preferPano && !record.isPano ? 1000 : 0), + }; + if (!fallback || distanceM < fallback.distanceM) fallback = candidate; + if (!passesImageryFilter(record, state.coverage.filter)) continue; + if (!best || candidate.score < best.score) best = candidate; + } + if (best) return best; + if ( + fallback && + state.coverage.filter.pano === 'all' && + !state.coverage.filter.sinceMs + ) + return fallback; + } + return null; + } + + /** + * Execute an already-built plan (from the planner, the voice agent, or a + * test). Resolves the area, streams features or opens imagery, and records + * the turn in the history. + */ + async function execute(plan, { prompt = plan?.title || '', signal } = {}) { + if (!plan || typeof plan !== 'object') + throw new Error('A plan is required'); + state.query.lastPlan = plan; + state.query.answer = plan.answer || ''; + state.query.title = plan.title || ''; + notify(); + if (plan.intent === 'unsupported') { + state.query.stage = 'idle'; + } else if (plan.intent === 'coverage') { + state.query.stage = 'resolving'; + notify(); + const area = await resolveArea(plan, signal); + state.query.place = area?.label || plan.place || null; + parts.features.frame(area.bbox, { duration: 2.4 }); + state.query.stage = 'idle'; + } else if (plan.intent === 'nearest_image') { + state.query.stage = 'resolving'; + notify(); + const area = await resolveArea(plan, signal); + state.query.place = area?.label || plan.place || null; + await runNearest(plan, area, signal); + state.query.stage = 'idle'; + } else { + if (!plan.layer) plan = { ...plan, layer: 'points' }; + state.query.stage = 'resolving'; + notify(); + const area = await resolveArea(plan, signal); + state.query.place = area?.label || plan.place || null; + await runFeatures(plan, area, signal); + state.query.stage = 'done'; + } + state.query.history.push({ prompt, plan, total: state.features.total }); + } + + /** Run one natural-language request end to end. */ + async function run(prompt) { + const text = String(prompt || '').trim(); + if (!text) return; + abort(); + const controller = new AbortController(); + state.query.abort = controller; + Object.assign(state.query, { + busy: true, + stage: 'planning', + prompt: text, + answer: '', + error: null, + usage: null, + }); + notify(); + try { + const { plan, usage, model } = await source.plan( + { + query: text, + context: { view: viewContext(), previousPlan: state.query.lastPlan }, + }, + { signal: controller.signal }, + ); + if (controller.signal.aborted) return; + state.query.usage = usage ? { ...usage, model } : null; + await execute(plan, { prompt: text, signal: controller.signal }); + } catch (error) { + if (controller.signal.aborted) return; + state.features.loading = false; + state.query.stage = 'error'; + state.query.error = describeError(error); + } finally { + if (state.query.abort === controller) { + state.query.busy = false; + state.query.abort = null; + } + notify(); + } + } + + /** Execute a supplied plan with the same lifecycle bookkeeping as run(). */ + async function runPlan(plan, { prompt } = {}) { + abort(); + const controller = new AbortController(); + state.query.abort = controller; + Object.assign(state.query, { + busy: true, + stage: 'resolving', + prompt: prompt || plan?.title || '', + answer: '', + error: null, + usage: null, + }); + notify(); + try { + await execute(plan, { prompt, signal: controller.signal }); + } catch (error) { + if (controller.signal.aborted) return; + state.features.loading = false; + state.query.stage = 'error'; + state.query.error = describeError(error); + } finally { + if (state.query.abort === controller) { + state.query.busy = false; + state.query.abort = null; + } + notify(); + } + } + + function describeError(error) { + if ( + error?.keyRequired === 'anthropic' || + error?.payload?.keyRequired === 'anthropic' + ) + return 'Mapillary AI needs an Anthropic key — add one in POWER UP.'; + if ( + error?.keyRequired === 'mapillary' || + error?.payload?.keyRequired === 'mapillary' || + error?.payload?.keyRequired === true + ) + return 'Add a Mapillary client token in POWER UP first.'; + if (error?.payload?.error === 'area_too_large') { + const { tiles, limit } = error.payload.detail || error.payload; + const times = + Number.isFinite(tiles) && Number.isFinite(limit) && limit > 0 + ? ` (about ${Math.max(2, Math.round(tiles / limit))}× what one query covers)` + : ''; + return `That area is too large for one query${times}. Zoom in closer to the ground or name a smaller place.`; + } + return error?.message || 'Query failed'; + } + + function abort() { + state.query.abort?.abort(); + state.query.abort = null; + state.query.busy = false; + state.features.loading = false; + } + + /** Drop the drawn results and the conversational context. */ + function clear() { + abort(); + parts.features.reset(); + parts.coverage.setResting(false); + Object.assign(state.query, { + stage: 'idle', + prompt: '', + answer: '', + error: null, + lastPlan: null, + usage: null, + title: '', + }); + Object.assign(state.features, { + bbox: null, + title: '', + layer: null, + values: [], + }); + notify(); + } + + return { run, runPlan, execute, clear, abort, viewContext }; +} diff --git a/src/layers/mapillary/query.test.mjs b/src/layers/mapillary/query.test.mjs new file mode 100644 index 000000000..e30ef47f1 --- /dev/null +++ b/src/layers/mapillary/query.test.mjs @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + bboxFromGeocode, + enforceMinimumSpan, + fitBboxToTileBudget, + summarizeResults, +} from './query.js'; +import { countTilesForBbox } from './tileMath.js'; + +test('bboxFromGeocode prefers bounds and falls back to a point box', () => { + const withBounds = bboxFromGeocode({ + geometry: { + location: { lat: 38.58, lng: -121.49 }, + bounds: { + southwest: { lat: 38.44, lng: -121.56 }, + northeast: { lat: 38.69, lng: -121.36 }, + }, + }, + formatted_address: 'Sacramento, California', + }); + assert.deepEqual(withBounds.bbox, [-121.56, 38.44, -121.36, 38.69]); + assert.equal(withBounds.label, 'Sacramento, California'); + const point = bboxFromGeocode({ + geometry: { location: { lat: 42.33, lng: -83.05 } }, + }); + assert.ok(point.bbox[0] < -83.05 && point.bbox[2] > -83.05); + assert.equal(bboxFromGeocode(null), null); +}); + +test('fitBboxToTileBudget shrinks an oversized box around its centre', () => { + const california = [-125, 32, -114, 42]; + const fitted = fitBboxToTileBudget(california, 600); + assert.ok(countTilesForBbox(fitted, 14) <= 600); + const cx = (fitted[0] + fitted[2]) / 2; + assert.ok(Math.abs(cx - -119.5) < 1e-6); + const small = [-121.5, 38.5, -121.4, 38.6]; + assert.deepEqual(fitBboxToTileBudget(small, 600), small); +}); + +test('summarizeResults reports totals, top classes and the sighting window', () => { + const text = summarizeResults({ + total: 1234, + counts: new Map([ + ['object--fire-hydrant', 1000], + ['object--bench', 200], + ['object--mailbox', 20], + ['object--trash-can', 14], + ]), + earliest: Date.UTC(2019, 0, 1), + latest: Date.UTC(2026, 5, 1), + truncated: true, + failed: 2, + }); + assert.match( + text, + /^1,234 features: 1,000 fire hydrant, 200 bench, 20 mailbox and 1 more classes/, + ); + assert.match(text, /seen 2019–2026 · capped · 2 tiles failed$/); + assert.equal( + summarizeResults({ total: 0, counts: new Map() }), + 'No matching features in this area.', + ); +}); + +test('node-sized geocode bounds are grown to a queryable neighbourhood box', () => { + const tiny = bboxFromGeocode({ + geometry: { + location: { lat: 42.3292, lng: -83.0527 }, + bounds: { + southwest: { lat: 42.329, lng: -83.053 }, + northeast: { lat: 42.3294, lng: -83.0524 }, + }, + }, + }); + assert.ok(tiny.bbox[2] - tiny.bbox[0] >= 0.01 - 1e-9); + assert.ok(tiny.bbox[3] - tiny.bbox[1] >= 0.01 - 1e-9); + assert.deepEqual(enforceMinimumSpan([0, 0, 1, 1], 0.5), [0, 0, 1, 1]); +}); diff --git a/src/layers/mapillary/selection.js b/src/layers/mapillary/selection.js new file mode 100644 index 000000000..1c3ab24a8 --- /dev/null +++ b/src/layers/mapillary/selection.js @@ -0,0 +1,114 @@ +import * as Cesium from 'cesium'; +import { MAPILLARY_LAYER_ID, PICK_PREFIX } from './policy.js'; + +/** Click handling for coverage lines, image cones and query results. */ +export function createSelection({ state, parts }) { + const { picking, input } = state.services; + + function ownsPick(pickedId) { + return ( + typeof pickedId === 'string' && + Object.values(PICK_PREFIX).some((prefix) => pickedId.startsWith(prefix)) + ); + } + + function onClick(click) { + const viewer = state.viewer; + if (!viewer || !state.enabled) return; + if (input?.isPointerFree && !input.isPointerFree()) return; + const picked = viewer.scene.pick(click.position); + const id = picking?.resolvePickId + ? picking.resolvePickId(picked) + : picked?.id; + if (!ownsPick(id)) return; + if (id.startsWith(PICK_PREFIX.sequence)) { + parts.sequences.select(id.slice(PICK_PREFIX.sequence.length)); + } else if (id.startsWith(PICK_PREFIX.image)) { + parts.street.openImage(id.slice(PICK_PREFIX.image.length)); + } else if (id.startsWith(PICK_PREFIX.feature)) { + parts.street.openFeature(id.slice(PICK_PREFIX.feature.length)); + } else if (id.startsWith(PICK_PREFIX.object)) { + // 3D object entities are id'd `mly:obj::`. + parts.street.openFeature(id.split(':')[2]); + } + } + + /** + * Esc clears the selected sequence, but only once nothing closer to the + * user wants it: an expanded viewer, an open panel or a text field. + */ + function onKeyDown(event) { + if (event.key !== 'Escape' || event.defaultPrevented) return; + if ( + event.target?.closest?.( + '.panel-collapsible, [role="dialog"], input, textarea, select', + ) + ) + return; + if (!state.sequence.selectedId) return; + event.preventDefault(); + parts.sequences.clearSelection(); + } + + let hoverQueued = false; + let hoverCursor = false; + /** Pointer cursor over anything this layer owns, so lines read as clickable. */ + function onMove(movement) { + const viewer = state.viewer; + if (!viewer || !state.enabled || hoverQueued) return; + hoverQueued = true; + requestAnimationFrame(() => { + hoverQueued = false; + const canvas = viewer.scene?.canvas; + if (!canvas || viewer.isDestroyed?.()) return; + let hit = false; + try { + const picked = viewer.scene.pick(movement.endPosition); + const id = picking?.resolvePickId + ? picking.resolvePickId(picked) + : picked?.id; + hit = ownsPick(id); + } catch { + hit = false; + } + if (hit && !hoverCursor) { + canvas.style.cursor = 'pointer'; + hoverCursor = true; + } else if (!hit && hoverCursor) { + canvas.style.cursor = ''; + hoverCursor = false; + } + }); + } + + function install(viewer) { + if (state.clickHandler) return; + state.clickHandler = new Cesium.ScreenSpaceEventHandler( + viewer.scene.canvas, + ); + state.clickHandler.setInputAction( + onClick, + Cesium.ScreenSpaceEventType.LEFT_CLICK, + ); + state.clickHandler.setInputAction( + onMove, + Cesium.ScreenSpaceEventType.MOUSE_MOVE, + ); + document.addEventListener('keydown', onKeyDown); + picking?.registerPickOwner?.(MAPILLARY_LAYER_ID, ownsPick); + } + + function uninstall() { + if (hoverCursor && state.viewer?.scene?.canvas) { + state.viewer.scene.canvas.style.cursor = ''; + hoverCursor = false; + } + if (state.clickHandler && !state.clickHandler.isDestroyed()) + state.clickHandler.destroy(); + state.clickHandler = null; + document.removeEventListener('keydown', onKeyDown); + picking?.unregisterPickOwner?.(MAPILLARY_LAYER_ID); + } + + return { install, uninstall, ownsPick }; +} diff --git a/src/layers/mapillary/sequences.js b/src/layers/mapillary/sequences.js new file mode 100644 index 000000000..96b7c1513 --- /dev/null +++ b/src/layers/mapillary/sequences.js @@ -0,0 +1,262 @@ +import * as Cesium from 'cesium'; +import { imageConeGlyph } from './glyphs.js'; +import { passesImageryFilter } from './coverage.js'; +import { + COLORS, + IMAGE_CONE_MIN_SPACING_M, + IMAGE_CONE_SIZE_PX, + PICK_PREFIX, +} from './policy.js'; + +/** How many recently viewed sequences keep their image list in memory. */ +const SEQUENCE_CACHE_SIZE = 40; + +/** Approximate metres between two lon/lat points (small distances). */ +function metresBetween(a, b) { + const lat = ((a.lat + b.lat) / 2) * (Math.PI / 180); + const dx = (b.lon - a.lon) * 111_320 * Math.cos(lat); + const dy = (b.lat - a.lat) * 110_540; + return Math.hypot(dx, dy); +} + +/** Normalize a graph image record into the shape the cones use. */ +function normalizeSequenceImage(record) { + const coordinates = record?.geometry?.coordinates; + if (!Array.isArray(coordinates) || coordinates.length < 2) return null; + return { + id: String(record.id), + lon: Number(coordinates[0]), + lat: Number(coordinates[1]), + compassAngle: Number(record.compass_angle) || 0, + capturedAt: Number(record.captured_at) || 0, + isPano: record.is_pano === true, + altitude: Number.isFinite(record.computed_altitude) + ? record.computed_altitude + : null, + }; +} + +/** Drop images closer than the spacing to the previous kept one. */ +function thinImages(images, spacingM = IMAGE_CONE_MIN_SPACING_M) { + const kept = []; + let last = null; + for (const image of images) { + if (!last || metresBetween(last, image) >= spacingM) { + kept.push(image); + last = image; + } + } + return kept; +} + +/** Image cones for one selected sequence, plus the viewer position marker. */ +export function createSequences({ state, source, parts }) { + const { render, sprites } = state.services; + + function requestRender() { + render?.governorRequestRender?.('mapillary-sequence'); + } + + function ensureCollections(viewer) { + if (!state.sequence.collection) { + state.sequence.collection = new Cesium.BillboardCollection({ + scene: viewer.scene, + }); + viewer.scene.primitives.add(state.sequence.collection); + sprites?.registerSpriteCollection?.( + 'mapillary', + state.sequence.collection, + ); + } + if (!state.street.markerCollection) { + state.street.markerCollection = new Cesium.BillboardCollection({ + scene: viewer.scene, + }); + viewer.scene.primitives.add(state.street.markerCollection); + sprites?.registerSpriteCollection?.( + 'mapillary', + state.street.markerCollection, + ); + } + } + + function clearCones() { + state.sequence.collection?.removeAll(); + state.sequence.images = []; + } + + function renderCones(images) { + const collection = state.sequence.collection; + if (!collection) return; + collection.removeAll(); + const cone = imageConeGlyph({ size: 32, color: COLORS.image }); + const ring = imageConeGlyph({ size: 32, color: COLORS.pano, pano: true }); + for (const image of images) { + if (!passesImageryFilter(image, state.coverage.filter)) continue; + collection.add({ + id: `${PICK_PREFIX.image}${image.id}`, + position: Cesium.Cartesian3.fromDegrees(image.lon, image.lat), + image: image.isPano ? ring : cone, + imageId: image.isPano ? 'mly-cone-pano' : 'mly-cone', + width: IMAGE_CONE_SIZE_PX, + height: IMAGE_CONE_SIZE_PX, + rotation: -Cesium.Math.toRadians(image.compassAngle), + alignedAxis: Cesium.Cartesian3.UNIT_Z, + heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, + disableDepthTestDistance: Number.POSITIVE_INFINITY, + verticalOrigin: Cesium.VerticalOrigin.CENTER, + scaleByDistance: new Cesium.NearFarScalar(200, 1.1, 6000, 0.35), + }); + } + requestRender(); + } + + function remember(sequenceId, images) { + const { cache } = state.sequence; + cache.delete(sequenceId); + cache.set(sequenceId, images); + while (cache.size > SEQUENCE_CACHE_SIZE) + cache.delete(cache.keys().next().value); + } + + /** Select a sequence: highlight its line and load its image cones. */ + async function select(sequenceId) { + if (!sequenceId || !state.viewer) return; + if ( + state.sequence.selectedId === sequenceId && + state.sequence.images.length + ) + return; + if (state.sequence.selectedId && state.sequence.selectedId !== sequenceId) + parts.coverage.recolorSequence(state.sequence.selectedId, false); + state.sequence.abort?.abort(); + state.sequence.selectedId = sequenceId; + parts.coverage.recolorSequence(sequenceId, true); + const cached = state.sequence.cache.get(sequenceId); + if (cached) { + state.sequence.abort = null; + state.sequence.loading = false; + state.sequence.images = cached; + renderCones(cached); + state.notify?.(); + return; + } + const controller = new AbortController(); + state.sequence.abort = controller; + state.sequence.loading = true; + state.notify?.(); + try { + const records = await source.getSequenceImages(sequenceId, { + signal: controller.signal, + }); + if (controller.signal.aborted) return; + const images = thinImages( + records + .map(normalizeSequenceImage) + .filter(Boolean) + .sort((a, b) => a.capturedAt - b.capturedAt), + ); + remember(sequenceId, images); + state.sequence.images = images; + renderCones(images); + } catch (error) { + if (!controller.signal.aborted) + state.street.error = error?.message || 'Sequence images unavailable'; + } finally { + if (state.sequence.abort === controller) { + state.sequence.loading = false; + state.sequence.abort = null; + } + state.notify?.(); + } + } + + function clearSelection() { + state.sequence.abort?.abort(); + state.sequence.abort = null; + if (state.sequence.selectedId) + parts.coverage.recolorSequence(state.sequence.selectedId, false); + state.sequence.selectedId = null; + state.sequence.loading = false; + clearCones(); + requestRender(); + state.notify?.(); + } + + /** Move (or create) the viewer position marker. */ + function setMarker(position, bearing) { + const collection = state.street.markerCollection; + if (!collection) return; + if (!position) { + collection.removeAll(); + state.street.marker = null; + requestRender(); + return; + } + const cartesian = Cesium.Cartesian3.fromDegrees(position.lon, position.lat); + if (!state.street.marker) { + state.street.marker = collection.add({ + id: PICK_PREFIX.position, + position: cartesian, + image: (id) => + import('./glyphs.js').then((m) => + m.positionMarkerGlyph({ color: COLORS.position }), + ), + imageId: 'mly-position', + width: 40, + height: 40, + alignedAxis: Cesium.Cartesian3.UNIT_Z, + heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, + disableDepthTestDistance: Number.POSITIVE_INFINITY, + verticalOrigin: Cesium.VerticalOrigin.CENTER, + }); + } else state.street.marker.position = cartesian; + state.street.marker.rotation = -Cesium.Math.toRadians(bearing || 0); + requestRender(); + } + + /** Re-draw the current sequence's cones (after an imagery filter change). */ + function rerender() { + if (state.sequence.images.length) renderCones(state.sequence.images); + } + + function hide(visible) { + if (state.sequence.collection) state.sequence.collection.show = visible; + if (state.street.markerCollection) + state.street.markerCollection.show = visible; + requestRender(); + } + + function destroy(viewer) { + state.sequence.abort?.abort(); + for (const key of ['collection']) { + const collection = state.sequence[key]; + if (collection) { + sprites?.unregisterSpriteCollection?.('mapillary', collection); + viewer?.scene?.primitives?.remove(collection); + state.sequence[key] = null; + } + } + if (state.street.markerCollection) { + sprites?.unregisterSpriteCollection?.( + 'mapillary', + state.street.markerCollection, + ); + viewer?.scene?.primitives?.remove(state.street.markerCollection); + state.street.markerCollection = null; + state.street.marker = null; + } + state.sequence.images = []; + state.sequence.selectedId = null; + } + + return { + ensureCollections, + select, + clearSelection, + setMarker, + rerender, + setVisible: hide, + destroy, + }; +} diff --git a/src/layers/mapillary/source.js b/src/layers/mapillary/source.js new file mode 100644 index 000000000..c8043b163 --- /dev/null +++ b/src/layers/mapillary/source.js @@ -0,0 +1,233 @@ +import { readNdjsonResponse } from './ndjson.js'; +import { + FEATURE_FIELDS, + IMAGE_FIELDS, + MAPILLARY_GRAPH_HOST, + NEAREST_LIMIT, + NEAREST_RADIUS_M, + SEQUENCE_IMAGE_FIELDS, + SEQUENCE_IMAGES_LIMIT, +} from './policy.js'; + +/** Error carrying the HTTP status and any server-provided payload. */ +export class MapillarySourceError extends Error { + constructor(message, { status = 0, payload = null } = {}) { + super(message); + this.name = 'MapillarySourceError'; + this.status = status; + this.payload = payload; + this.keyRequired = payload?.keyRequired ?? payload?.error === 'no_key'; + } +} + +async function readJsonOrThrow(response, label) { + let payload = null; + try { + payload = await response.json(); + } catch { + /* status is authoritative */ + } + if (!response.ok) + throw new MapillarySourceError( + payload?.error?.message || + payload?.error || + `${label} HTTP ${response.status}`, + { status: response.status, payload }, + ); + return payload; +} + +/** + * Every network path the Mapillary layer uses. Tiles, the feature stream, + * the planner and sprites go through the local dev-server proxy; entity + * lookups go straight to graph.mapillary.com with the client token, exactly + * as the embedded MapillaryJS viewer does. + * @param {{token?: string, fetchImpl?: Function, endpoints?: object}} options + */ +export function createMapillarySource({ + token = '', + fetchImpl = (...args) => globalThis.fetch(...args), + endpoints = {}, +} = {}) { + const urls = { + status: '/api/mapillary/status', + tiles: '/api/mapillary/tiles', + features: '/api/mapillary/features', + plan: '/api/mapillary/plan', + sprite: '/api/mapillary/sprite', + geocode: '/api/geocode', + graph: MAPILLARY_GRAPH_HOST, + ...endpoints, + }; + + async function graph(path, params = {}, { signal } = {}) { + if (!token) + throw new MapillarySourceError('Mapillary token not configured', { + status: 503, + payload: { keyRequired: true }, + }); + const search = new URLSearchParams({ access_token: token, ...params }); + const response = await fetchImpl(`${urls.graph}/${path}?${search}`, { + signal, + }); + return readJsonOrThrow(response, 'Mapillary graph'); + } + + return { + get token() { + return token; + }, + hasToken: () => Boolean(token), + + async getStatus({ signal } = {}) { + const response = await fetchImpl(urls.status, { + signal, + cache: 'no-store', + }); + return readJsonOrThrow(response, 'Mapillary status'); + }, + + /** Raw protobuf bytes for one tile; an empty array means no data. */ + async getTile(layer, z, x, y, { signal } = {}) { + const response = await fetchImpl( + `${urls.tiles}/${layer}/${z}/${x}/${y}`, + { + signal, + }, + ); + if (response.status === 204) return new Uint8Array(0); + if (!response.ok) { + let payload = null; + try { + payload = await response.json(); + } catch { + /* ignore */ + } + throw new MapillarySourceError( + payload?.error || `Tile HTTP ${response.status}`, + { + status: response.status, + payload, + }, + ); + } + return new Uint8Array(await response.arrayBuffer()); + }, + + /** + * Stream a feature query. `onEvent` receives each NDJSON record + * ({type:'start'|'tile'|'done'}); resolves with the done record. + */ + async queryFeatures(body, onEvent, { signal } = {}) { + const response = await fetchImpl(urls.features, { + method: 'POST', + signal, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!response.ok) await readJsonOrThrow(response, 'Feature query'); + let done = null; + await readNdjsonResponse( + response, + (record) => { + if (record?.type === 'done') done = record; + onEvent?.(record); + }, + { signal }, + ); + return done; + }, + + /** Natural language → plan, via the server-side Claude planner. */ + async plan(body, { signal } = {}) { + const response = await fetchImpl(urls.plan, { + method: 'POST', + signal, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + return readJsonOrThrow(response, 'Planner'); + }, + + /** Place name → geocode results through the app's existing Nominatim route. */ + async geocode(query, { signal } = {}) { + const response = await fetchImpl( + `${urls.geocode}?${new URLSearchParams({ q: query })}`, + { signal }, + ); + const payload = await readJsonOrThrow(response, 'Geocode'); + return Array.isArray(payload?.results) ? payload.results : []; + }, + + getImage(id, { signal, fields = IMAGE_FIELDS } = {}) { + return graph(String(id), { fields }, { signal }); + }, + + async getSequenceImages( + sequenceId, + { signal, limit = SEQUENCE_IMAGES_LIMIT } = {}, + ) { + const payload = await graph( + 'images', + { + sequence_ids: String(sequenceId), + fields: SEQUENCE_IMAGE_FIELDS, + limit: String(limit), + }, + { signal }, + ); + return Array.isArray(payload?.data) ? payload.data : []; + }, + + async nearestImages( + { lat, lon, radius = NEAREST_RADIUS_M, limit = NEAREST_LIMIT }, + { signal } = {}, + ) { + const payload = await graph( + 'images', + { + lat: String(lat), + lng: String(lon), + radius: String(Math.min(50, Math.max(1, radius))), + limit: String(Math.min(100, Math.max(1, limit))), + fields: IMAGE_FIELDS, + }, + { signal }, + ); + return Array.isArray(payload?.data) ? payload.data : []; + }, + + getMapFeature(id, { signal } = {}) { + return graph(String(id), { fields: FEATURE_FIELDS }, { signal }); + }, + + /** Batched entity lookup (`/?ids=`), up to 50 ids per call. */ + async getMapFeaturesBatch( + ids, + { signal, fields = 'id,object_value,aligned_direction' } = {}, + ) { + const list = [...new Set((ids || []).map(String))].slice(0, 50); + if (!list.length) return {}; + const payload = await graph( + '', + { ids: list.join(','), fields }, + { signal }, + ); + return payload && typeof payload === 'object' ? payload : {}; + }, + + /** Detections (segmentation polygons + values) inside one image. */ + async getImageDetections(imageId, { signal } = {}) { + const payload = await graph( + `${String(imageId)}/detections`, + { fields: 'id,value,geometry,created_at' }, + { signal }, + ); + return Array.isArray(payload?.data) ? payload.data : []; + }, + + spriteUrl(value) { + return `${urls.sprite}/${encodeURIComponent(value)}.svg`; + }, + }; +} diff --git a/src/layers/mapillary/state.js b/src/layers/mapillary/state.js new file mode 100644 index 000000000..f0115d217 --- /dev/null +++ b/src/layers/mapillary/state.js @@ -0,0 +1,107 @@ +/** Mutable per-layer state, created once per layer instance. */ +export function createState({ services }) { + return { + services, + viewer: null, + enabled: false, + initialized: false, + destroyed: false, + status: null, + statusError: null, + keyRequired: false, + /** Cesium.Credit shown while enabled (CC BY-SA attribution). */ + credit: null, + listeners: new Set(), + notifyScheduled: false, + + coverage: { + zoom: null, + /** @type {Map, count: number}>} */ + tiles: new Map(), + /** Tiles from the previous zoom, kept on screen until replacements land. */ + stale: new Map(), + staleTimer: null, + pending: new Map(), + generation: 0, + loading: 0, + lastError: null, + debounceTimer: null, + removeCameraListener: null, + terrainReady: null, + hint: '', + resting: false, + kind: null, + filter: { pano: 'all', sinceMs: null }, + }, + + sequence: { + selectedId: null, + images: [], + /** @type {Map>} recent sequences' thinned images */ + cache: new Map(), + collection: null, + loading: false, + abort: null, + }, + + features: { + collection: null, + points: null, + rows: [], + byId: new Map(), + counts: new Map(), + earliest: null, + latest: null, + bbox: null, + title: '', + layer: null, + values: [], + total: 0, + truncated: false, + failed: 0, + loading: false, + progress: { done: 0, tiles: 0 }, + abort: null, + iconMode: false, + iconsHidden: false, + selectedId: null, + billboardById: new Map(), + highlight: null, + }, + + street: { + bridge: null, + container: null, + open: false, + follow: false, + imageId: null, + position: null, + bearing: null, + tilt: null, + isPano: false, + capturedAt: null, + sequenceId: null, + loading: false, + error: null, + marker: null, + markerCollection: null, + /** 'letterbox' shows the whole image; 'fill' crops it to the frame. */ + renderMode: 'letterbox', + }, + + query: { + busy: false, + stage: 'idle', + prompt: '', + place: null, + answer: '', + error: null, + lastPlan: null, + history: [], + abort: null, + usage: null, + }, + + clickHandler: null, + }; +} diff --git a/src/layers/mapillary/taxonomy.js b/src/layers/mapillary/taxonomy.js new file mode 100644 index 000000000..e5fcbd9a9 --- /dev/null +++ b/src/layers/mapillary/taxonomy.js @@ -0,0 +1,1605 @@ +/** + * Mapillary map-feature taxonomy, derived from the class names in + * mapillary/mapillary_sprite_source (MIT). OBJECT_VALUES is the point-object + * layer (`mly_map_feature_point`, property `value`); SIGN_VALUES is the + * traffic-sign layer (`mly_map_feature_traffic_sign`). Sign values end in a + * regional group suffix (`--g1`, `--g3`); a wildcard such as + * `regulatory--stop--*` matches every regional variant of one sign. + * Regenerate from the sprite repository rather than editing by hand. + */ +export const OBJECT_VALUES = Object.freeze([ + 'construction--barrier--temporary', + 'construction--flat--crosswalk-plain', + 'construction--flat--driveway', + 'construction--flat--flat-driveway', + 'marking--discrete--arrow--left', + 'marking--discrete--arrow--right', + 'marking--discrete--arrow--split-left-or-straight', + 'marking--discrete--arrow--split-right-or-straight', + 'marking--discrete--arrow--straight', + 'marking--discrete--crosswalk-zebra', + 'marking--discrete--give-way-row', + 'marking--discrete--give-way-single', + 'marking--discrete--other-marking', + 'marking--discrete--stop-line', + 'marking--discrete--symbol--bicycle', + 'marking--discrete--text', + 'object--banner', + 'object--bench', + 'object--bike-rack', + 'object--catch-basin', + 'object--cctv-camera', + 'object--fire-hydrant', + 'object--junction-box', + 'object--mailbox', + 'object--manhole', + 'object--parking-meter', + 'object--phone-booth', + 'object--sign--advertisement', + 'object--sign--information', + 'object--sign--store', + 'object--street-light', + 'object--support--pole', + 'object--support--traffic-sign-frame', + 'object--support--utility-pole', + 'object--traffic-cone', + 'object--traffic-light--cyclists', + 'object--traffic-light--general-horizontal', + 'object--traffic-light--general-single', + 'object--traffic-light--general-upright', + 'object--traffic-light--other', + 'object--traffic-light--pedestrians', + 'object--trash-can', + 'object--water-valve', +]); + +export const SIGN_VALUES = Object.freeze([ + 'complementary--accident-area--g1', + 'complementary--accident-area--g2', + 'complementary--accident-area--g3', + 'complementary--accident-area--g4', + 'complementary--advisory-exit-or-ramp-speed--g1', + 'complementary--bicycles--g1', + 'complementary--bicycles-and-pedestrians-detour--g1', + 'complementary--bicycles-or-pedestrians-detour--g1', + 'complementary--bicycles-turn-right--g1', + 'complementary--bike-route--g1', + 'complementary--bike-route--g3', + 'complementary--both-directions--g1', + 'complementary--both-directions--g2', + 'complementary--buses--g1', + 'complementary--buses-and-trucks--g1', + 'complementary--camera--g1', + 'complementary--camera--g2', + 'complementary--camera--g3', + 'complementary--camera--g4', + 'complementary--camera--g5', + 'complementary--caravan-trailers--g2', + 'complementary--caravan-trailers--g3', + 'complementary--caravans--g2', + 'complementary--carts--g1', + 'complementary--chevron-left--g1', + 'complementary--chevron-left--g2', + 'complementary--chevron-left--g3', + 'complementary--chevron-left--g4', + 'complementary--chevron-left--g5', + 'complementary--chevron-right--g1', + 'complementary--chevron-right--g2', + 'complementary--chevron-right--g3', + 'complementary--chevron-right--g4', + 'complementary--chevron-right--g5', + 'complementary--dangerous-or-pollutant-good--g1', + 'complementary--dead-end--g1', + 'complementary--detour--g1', + 'complementary--disabled-persons--g1', + 'complementary--distance--g1', + 'complementary--distance--g2', + 'complementary--distance--g3', + 'complementary--end-of-road-works--g1', + 'complementary--except-bicycles--g1', + 'complementary--except-bicycles--g2', + 'complementary--except-buses--g1', + 'complementary--except-carts--g1', + 'complementary--except-motorcycles--g1', + 'complementary--except-motorcycles--g2', + 'complementary--except-polluting-level-green--g1', + 'complementary--except-polluting-level-green-yellow--g1', + 'complementary--except-polluting-level-green-yellow-red--g1', + 'complementary--except-tractors--g1', + 'complementary--except-tractors--g2', + 'complementary--except-trailers--g1', + 'complementary--except-trailers--g2', + 'complementary--except-trains--g1', + 'complementary--except-trams--g1', + 'complementary--except-trucks--g1', + 'complementary--except-vehicles--g1', + 'complementary--except-vehicles--g2', + 'complementary--go-left--g1', + 'complementary--go-right--g1', + 'complementary--go-straight-or-turn-left--g1', + 'complementary--go-straight-or-turn-right--g1', + 'complementary--height-limit--g1', + 'complementary--height-limit--g2', + 'complementary--including-bicycles-and-motorcycles--g1', + 'complementary--including-buses-vehicles--g1', + 'complementary--keep-left--g1', + 'complementary--keep-right--g1', + 'complementary--lane-control--g1', + 'complementary--lane-control--g2', + 'complementary--lane-control--g3', + 'complementary--maximum-speed-limit-10--g1', + 'complementary--maximum-speed-limit-15--g1', + 'complementary--maximum-speed-limit-20--g1', + 'complementary--maximum-speed-limit-25--g1', + 'complementary--maximum-speed-limit-30--g1', + 'complementary--maximum-speed-limit-35--g1', + 'complementary--maximum-speed-limit-40--g1', + 'complementary--maximum-speed-limit-45--g1', + 'complementary--maximum-speed-limit-50--g1', + 'complementary--maximum-speed-limit-55--g1', + 'complementary--maximum-speed-limit-60--g1', + 'complementary--maximum-speed-limit-65--g1', + 'complementary--maximum-speed-limit-70--g1', + 'complementary--maximum-speed-limit-75--g1', + 'complementary--maximum-speed-limit-80--g1', + 'complementary--maximum-speed-limit-85--g1', + 'complementary--maximum-speed-limit-90--g1', + 'complementary--maximum-speed-limit-95--g1', + 'complementary--motorcycles--g1', + 'complementary--motorcycles--g2', + 'complementary--motorcycles--g3', + 'complementary--motorcycles--g4', + 'complementary--obstacle-delineator--g1', + 'complementary--obstacle-delineator--g2', + 'complementary--obstacle-delineator--g3', + 'complementary--one-direction-left--g1', + 'complementary--one-direction-right--g1', + 'complementary--pass-left--g1', + 'complementary--pass-right--g1', + 'complementary--pedestrians-and-bicycles--g1', + 'complementary--pedestrians-left--g1', + 'complementary--pedestrians-right--g1', + 'complementary--photo-enforced--g1', + 'complementary--playground--g1', + 'complementary--priority-route-at-intersection--g1', + 'complementary--priority-route-at-intersection--g2', + 'complementary--priority-route-at-intersection--g3', + 'complementary--priority-route-at-intersection--g4', + 'complementary--priority-route-at-intersection--g5', + 'complementary--priority-route-at-intersection--g6', + 'complementary--railroad--g1', + 'complementary--railroad--g2', + 'complementary--railroad--g3', + 'complementary--restriction-in-both-directions--g1', + 'complementary--roundabout-go-left--g1', + 'complementary--roundabout-go-right--g1', + 'complementary--roundabout-go-straight--g1', + 'complementary--slippery-for-caravan-trailers--g1', + 'complementary--snow--g2', + 'complementary--snow--g4', + 'complementary--snow--g5', + 'complementary--snowmobiles--g1', + 'complementary--soft-shoulder--g1', + 'complementary--soft-shoulder--g2', + 'complementary--steep-ascent--g1', + 'complementary--steep-descent--g1', + 'complementary--time-restrictions--g1', + 'complementary--time-restrictions--g3', + 'complementary--tow-away-zone--g1', + 'complementary--tow-away-zone--g3', + 'complementary--tractors--g1', + 'complementary--traffic-queues--g1', + 'complementary--trailers--g1', + 'complementary--trailers--g2', + 'complementary--trailers--g3', + 'complementary--trailers--g4', + 'complementary--trains--g1', + 'complementary--trams--g1', + 'complementary--trees--g1', + 'complementary--trucks--g1', + 'complementary--trucks--g2', + 'complementary--trucks--g3', + 'complementary--trucks-and-trailers--g1', + 'complementary--trucks-buses-trailers--g1', + 'complementary--trucks-go-left--g1', + 'complementary--trucks-go-left-ahead--g1', + 'complementary--trucks-go-right--g1', + 'complementary--trucks-go-right-ahead--g1', + 'complementary--trucks-go-straight--g1', + 'complementary--trucks-turn-left--g1', + 'complementary--trucks-turn-right--g1', + 'complementary--turn-left--g1', + 'complementary--turn-left--g2', + 'complementary--turn-right--g1', + 'complementary--turn-right--g2', + 'complementary--two-way-traffic--g1', + 'complementary--two-way-traffic--g2', + 'complementary--two-way-traffic--g3', + 'complementary--two-way-traffic--g4', + 'complementary--two-way-traffic--g5', + 'complementary--vehicles--g1', + 'complementary--vehicles--g2', + 'complementary--vehicles-or-buses--g1', + 'complementary--weekends-or-holidays--g1', + 'complementary--weight-limit--g1', + 'complementary--when-foggy--g1', + 'complementary--when-rainy--g1', + 'complementary--when-rainy--g2', + 'complementary--when-rainy--g3', + 'complementary--when-snowy--g1', + 'complementary--when-snowy--g2', + 'complementary--when-snowy-or-rainy--g1', + 'complementary--when-snowy-or-rainy--g2', + 'complementary--when-wet--g1', + 'complementary--width-limit--g1', + 'complementary--working-days--g1', + 'information--airport--g1', + 'information--airport--g2', + 'information--bicycle-lane--g1', + 'information--bicycles-both-ways--g1', + 'information--bicycles-crossing--g1', + 'information--bicycles-crossing--g2', + 'information--bicycles-crossing--g3', + 'information--bike-route--g1', + 'information--bike-route--g2', + 'information--built-up-area--g1', + 'information--built-up-area--g2', + 'information--bus-lane-straight--g1', + 'information--bus-stop--g1', + 'information--bus-stop--g2', + 'information--camera--g1', + 'information--camera--g2', + 'information--camera--g3', + 'information--camp--g1', + 'information--camp--g2', + 'information--car-pool-lane--g1', + 'information--caravan-parking--g1', + 'information--caravan-trailer-parking--g1', + 'information--cargo-loading-zone--g1', + 'information--central-lane--g1', + 'information--charging-station--g1', + 'information--children--g1', + 'information--children--g2', + 'information--children-crossing--g5', + 'information--cycling-two-abreast-permitted--g1', + 'information--dead-end--g1', + 'information--dead-end--g2', + 'information--dead-end--g3', + 'information--dead-end--g4', + 'information--dead-end-except-bicycles--g1', + 'information--dead-end-except-bicycles-and-pedestrians--g1', + 'information--dead-end-except-bicycles-and-pedestrians--g2', + 'information--dead-end-left--g1', + 'information--dead-end-left--g2', + 'information--dead-end-right--g1', + 'information--dead-end-right--g2', + 'information--dead-end-right--g3', + 'information--directions--g1', + 'information--disabled-persons--g1', + 'information--disabled-persons--g2', + 'information--disabled-persons--g3', + 'information--emergency-facility--g1', + 'information--end-of-advisory-maximum-speed-limit-20--g1', + 'information--end-of-advisory-maximum-speed-limit-40--g1', + 'information--end-of-advisory-maximum-speed-limit-60--g1', + 'information--end-of-advisory-maximum-speed-limit-70--g1', + 'information--end-of-advisory-maximum-speed-limit-80--g1', + 'information--end-of-advisory-maximum-speed-limit-90--g1', + 'information--end-of-bicycle-lane--g1', + 'information--end-of-built-up-area--g1', + 'information--end-of-built-up-area--g2', + 'information--end-of-built-up-area--g3', + 'information--end-of-built-up-area--g4', + 'information--end-of-car-pool-lane--g1', + 'information--end-of-limited-access-road--g1', + 'information--end-of-living-street--g1', + 'information--end-of-living-street--g2', + 'information--end-of-minimum-speed-10--g1', + 'information--end-of-minimum-speed-100--g1', + 'information--end-of-minimum-speed-110--g1', + 'information--end-of-minimum-speed-120--g1', + 'information--end-of-minimum-speed-130--g1', + 'information--end-of-minimum-speed-20--g1', + 'information--end-of-minimum-speed-25--g1', + 'information--end-of-minimum-speed-30--g1', + 'information--end-of-minimum-speed-35--g1', + 'information--end-of-minimum-speed-40--g1', + 'information--end-of-minimum-speed-50--g1', + 'information--end-of-minimum-speed-60--g1', + 'information--end-of-minimum-speed-70--g1', + 'information--end-of-minimum-speed-75--g1', + 'information--end-of-minimum-speed-80--g1', + 'information--end-of-minimum-speed-90--g1', + 'information--end-of-motorway--g1', + 'information--end-of-overtaking-permitted-heavy-good-vehicles--g1', + 'information--end-of-road-works--g1', + 'information--end-of-tunnel--g1', + 'information--end-of-tunnel--g2', + 'information--end-of-two-way-traffic--g1', + 'information--equestrians-permitted--g1', + 'information--exit-ahead--g1', + 'information--exit-ahead--g2', + 'information--exit-ahead--g3', + 'information--flight-port--g1', + 'information--food--g1', + 'information--food--g2', + 'information--gas-station--g1', + 'information--gas-station--g2', + 'information--gas-station--g3', + 'information--general-speed-limit-at-city-border--g1', + 'information--go-left--g1', + 'information--go-right--g1', + 'information--go-straight--g1', + 'information--go-straight-or-left--g1', + 'information--go-straight-or-right--g1', + 'information--go-straight-or-turn-left--g1', + 'information--go-straight-or-turn-right--g1', + 'information--hazardous-goods-vehicles-lane--g1', + 'information--height-limit--g1', + 'information--height-limit--g2', + 'information--highway-directions--g1', + 'information--highway-exit--g1', + 'information--highway-interchange--g1', + 'information--highway-interstate-route--g1', + 'information--highway-interstate-route--g2', + 'information--highway-preferential-lane--g1', + 'information--highway-preferential-lane--g2', + 'information--highway-reference-location--g1', + 'information--highway-reference-location--g2', + 'information--hiking--g1', + 'information--hospital--g1', + 'information--hurricane-evacuation-route--g1', + 'information--interstate-route--g1', + 'information--lane-control-intersections--g1', + 'information--lane-control-left-turn--g1', + 'information--lane-control-multiple-lanes--g1', + 'information--lane-control-multiple-lanes--g2', + 'information--lane-control-right-turn--g1', + 'information--limited-access-road--g1', + 'information--litter-container--g1', + 'information--living-street--g1', + 'information--living-street--g2', + 'information--living-street--g3', + 'information--lodging--g1', + 'information--lodging--g2', + 'information--minimum-speed-10--g1', + 'information--minimum-speed-100--g1', + 'information--minimum-speed-110--g1', + 'information--minimum-speed-120--g1', + 'information--minimum-speed-130--g1', + 'information--minimum-speed-20--g1', + 'information--minimum-speed-25--g1', + 'information--minimum-speed-30--g1', + 'information--minimum-speed-35--g1', + 'information--minimum-speed-40--g1', + 'information--minimum-speed-50--g1', + 'information--minimum-speed-60--g1', + 'information--minimum-speed-70--g1', + 'information--minimum-speed-75--g1', + 'information--minimum-speed-80--g1', + 'information--minimum-speed-90--g1', + 'information--motorway--g1', + 'information--motorway-exit-ahead--g1', + 'information--motorway-exit-ahead--g2', + 'information--motorway-exit-ahead--g3', + 'information--overtaking-allowed-heavy-good-vehicles--g1', + 'information--parallel-parking--g1', + 'information--park-and-ride--g1', + 'information--park-and-ride--g2', + 'information--parking--g1', + 'information--parking--g2', + 'information--parking--g3', + 'information--parking--g4', + 'information--parking--g5', + 'information--parking--g6', + 'information--parking-area--g1', + 'information--parking-with-restrictions--g1', + 'information--pass-on-either-side--g1', + 'information--passenger-loading-zone--g1', + 'information--pedestrians-crossing--g1', + 'information--pedestrians-crossing--g2', + 'information--pedestrians-crossing--g3', + 'information--pedestrians-only--g4', + 'information--pedestrians-permitted--g1', + 'information--perpendicular-parking--g1', + 'information--picnic-site--g1', + 'information--playground--g1', + 'information--recreational-vehicle-sanitary-station--g1', + 'information--recycle-collection-center--g1', + 'information--rest-area--g1', + 'information--road-bump--g1', + 'information--road-skating--g1', + 'information--safety-zone--g1', + 'information--safety-zone--g2', + 'information--safety-zone--g3', + 'information--shared-path-vehicles-and-motorcycles--g1', + 'information--stairs--g1', + 'information--stairs--g2', + 'information--stairs--g3', + 'information--stairs--g4', + 'information--stop-line--g1', + 'information--stop-permitted--g1', + 'information--street-name-one-line--g1', + 'information--street-name-three-lines--g1', + 'information--street-name-two-lines--g1', + 'information--subway--g1', + 'information--telephone--g1', + 'information--telephone--g2', + 'information--telephone-device-for-the-deaf--g1', + 'information--toll-station--g1', + 'information--tourism-information--g1', + 'information--tourist-attraction--g1', + 'information--traffic-merges-left--g1', + 'information--traffic-merges-right--g1', + 'information--trail-crossing--g1', + 'information--trail-crossing--g2', + 'information--trail-crossing--g3', + 'information--trailer-camping--g1', + 'information--train-or-light-rail-station--g1', + 'information--tram-bus-stop--g1', + 'information--tram-bus-stop--g2', + 'information--trams-crossing--g1', + 'information--truck-lane-left--g1', + 'information--truck-parking--g1', + 'information--truck-trailer-lane-right--g1', + 'information--truck-trailer-lane-straight--g1', + 'information--trucks-both-ways--g1', + 'information--trucks-only--g1', + 'information--tsunami-evacuation_route--g1', + 'information--tunnel--g1', + 'information--tunnel--g2', + 'information--tunnel--g3', + 'information--tunnel-ahead--g1', + 'information--turn-left--g1', + 'information--turn-left-ahead--g1', + 'information--turn-right--g1', + 'information--turn-right-ahead--g1', + 'information--urban-area--g1', + 'information--vehicles-on-rails--g1', + 'information--water-protection-zone--g1', + 'information--weight-and-height-limit--g1', + 'information--weight-limit--g1', + 'information--wireless-internet--g1', + 'regulatory--advisory-maximum-speed-limit--g1', + 'regulatory--all-directions-permitted--g1', + 'regulatory--all-way--g1', + 'regulatory--atvs-permitted--g1', + 'regulatory--axle-limit--g1', + 'regulatory--axle-limit--g2', + 'regulatory--bicycle-lane-left--g1', + 'regulatory--bicycle-parking--g1', + 'regulatory--bicycles-and-buses-only--g1', + 'regulatory--bicycles-only--g1', + 'regulatory--bicycles-only--g2', + 'regulatory--bicycles-only--g3', + 'regulatory--bicycles-only--g4', + 'regulatory--bicycles-push-button--g1', + 'regulatory--bicycles-push-button--g2', + 'regulatory--bicycles-stop-on-red--g1', + 'regulatory--bicycles-wrong-way--g1', + 'regulatory--bicycles-yield-or-use-signal--g1', + 'regulatory--bike-route--g1', + 'regulatory--building-direction--g1', + 'regulatory--bus-priority-lane--g1', + 'regulatory--buses-and-taxi-only--g1', + 'regulatory--buses-only--g1', + 'regulatory--buses-only--g2', + 'regulatory--circular-intersection--g1', + 'regulatory--circular-intersection--g2', + 'regulatory--circular-intersection--g3', + 'regulatory--circular-intersection--g4', + 'regulatory--cross-only-on-green--g1', + 'regulatory--cross-only-on-pedestrian-signal--g1', + 'regulatory--crosswalk-stop-on-red--g1', + 'regulatory--cycling-restriction--g1', + 'regulatory--cyclists-dismount-and-walk--g1', + 'regulatory--detour-left--g1', + 'regulatory--detour-right--g1', + 'regulatory--divided-highway-crossing--g1', + 'regulatory--divided-highway-ends--g1', + 'regulatory--divided-highway-starts--g1', + 'regulatory--do-not-block-intersection--g1', + 'regulatory--do-not-pass--g1', + 'regulatory--do-not-stop-on-tracks--g1', + 'regulatory--dual-lanes-all-directions-on-left--g1', + 'regulatory--dual-lanes-all-directions-on-right--g1', + 'regulatory--dual-lanes-bicyclists-and-pedestrians--g1', + 'regulatory--dual-lanes-go-left-or-right--g1', + 'regulatory--dual-lanes-go-straight-on-left--g1', + 'regulatory--dual-lanes-go-straight-on-right--g1', + 'regulatory--dual-lanes-turn-left--g1', + 'regulatory--dual-lanes-turn-left-no-u-turn--g1', + 'regulatory--dual-lanes-turn-left-or-straight--g1', + 'regulatory--dual-lanes-turn-right-or-straight--g1', + 'regulatory--dual-path-bicycles-and-pedestrians--g1', + 'regulatory--dual-path-bicycles-and-pedestrians--g2', + 'regulatory--dual-path-bicycles-and-pedestrians--g3', + 'regulatory--dual-path-equestrians-and-pedestrians--g1', + 'regulatory--dual-path-equestrians-and-pedestrians-bicycles--g1', + 'regulatory--dual-path-pedestrians-and-bicycles--g1', + 'regulatory--dual-path-pedestrians-and-bicycles--g2', + 'regulatory--dual-path-pedestrians-and-equestrians--g1', + 'regulatory--dual-path-pedestrians-bicycles-and-equestrians--g1', + 'regulatory--dual-speed-limits--g1', + 'regulatory--dual-speed-limits--g2', + 'regulatory--end-of-bicycles-only--g1', + 'regulatory--end-of-bicycles-only--g2', + 'regulatory--end-of-bus-and-taxi-only--g1', + 'regulatory--end-of-buses-only--g1', + 'regulatory--end-of-buses-only--g2', + 'regulatory--end-of-cycling-restriction--g1', + 'regulatory--end-of-dual-path-bicycles-and-pedestrians--g1', + 'regulatory--end-of-dual-path-pedestrians-and-bicycles--g1', + 'regulatory--end-of-equestrians-only--g1', + 'regulatory--end-of-low-beam-headlights--g1', + 'regulatory--end-of-maximum-speed-limit--g1', + 'regulatory--end-of-maximum-speed-limit-10--g1', + 'regulatory--end-of-maximum-speed-limit-10--g2', + 'regulatory--end-of-maximum-speed-limit-100--g1', + 'regulatory--end-of-maximum-speed-limit-100--g2', + 'regulatory--end-of-maximum-speed-limit-110--g1', + 'regulatory--end-of-maximum-speed-limit-110--g2', + 'regulatory--end-of-maximum-speed-limit-120--g1', + 'regulatory--end-of-maximum-speed-limit-120--g2', + 'regulatory--end-of-maximum-speed-limit-130--g1', + 'regulatory--end-of-maximum-speed-limit-130--g2', + 'regulatory--end-of-maximum-speed-limit-20--g1', + 'regulatory--end-of-maximum-speed-limit-20--g2', + 'regulatory--end-of-maximum-speed-limit-25--g1', + 'regulatory--end-of-maximum-speed-limit-25--g2', + 'regulatory--end-of-maximum-speed-limit-30--g1', + 'regulatory--end-of-maximum-speed-limit-30--g2', + 'regulatory--end-of-maximum-speed-limit-35--g1', + 'regulatory--end-of-maximum-speed-limit-35--g2', + 'regulatory--end-of-maximum-speed-limit-40--g1', + 'regulatory--end-of-maximum-speed-limit-40--g2', + 'regulatory--end-of-maximum-speed-limit-50--g1', + 'regulatory--end-of-maximum-speed-limit-50--g2', + 'regulatory--end-of-maximum-speed-limit-60--g1', + 'regulatory--end-of-maximum-speed-limit-60--g2', + 'regulatory--end-of-maximum-speed-limit-65--g1', + 'regulatory--end-of-maximum-speed-limit-65--g2', + 'regulatory--end-of-maximum-speed-limit-70--g1', + 'regulatory--end-of-maximum-speed-limit-70--g2', + 'regulatory--end-of-maximum-speed-limit-75--g1', + 'regulatory--end-of-maximum-speed-limit-75--g2', + 'regulatory--end-of-maximum-speed-limit-80--g1', + 'regulatory--end-of-maximum-speed-limit-80--g2', + 'regulatory--end-of-maximum-speed-limit-90--g1', + 'regulatory--end-of-maximum-speed-limit-90--g2', + 'regulatory--end-of-mopeds-and-bicycles-only--g1', + 'regulatory--end-of-no-heavy-goods-vehicles--g1', + 'regulatory--end-of-no-horn--g1', + 'regulatory--end-of-no-overtaking--g1', + 'regulatory--end-of-no-overtaking--g2', + 'regulatory--end-of-no-overtaking--g3', + 'regulatory--end-of-no-overtaking--g4', + 'regulatory--end-of-no-overtaking--g5', + 'regulatory--end-of-no-overtaking-by-heavy-goods-vehicles--g1', + 'regulatory--end-of-no-overtaking-by-heavy-goods-vehicles--g2', + 'regulatory--end-of-no-overtaking-by-motorcycles--g1', + 'regulatory--end-of-no-parking--g1', + 'regulatory--end-of-no-parking--g2', + 'regulatory--end-of-no-parking-or-stopping--g1', + 'regulatory--end-of-one-way-straight--g1', + 'regulatory--end-of-parking-zone--g1', + 'regulatory--end-of-parking-zone--g2', + 'regulatory--end-of-pedestrians-only--g1', + 'regulatory--end-of-pedestrians-only--g2', + 'regulatory--end-of-pedestrians-only--g3', + 'regulatory--end-of-pedestrians-only--g4', + 'regulatory--end-of-priority-road--g1', + 'regulatory--end-of-prohibition--g1', + 'regulatory--end-of-school-zone--g1', + 'regulatory--end-of-shared-path-bicycles-and-pedestrians--g1', + 'regulatory--end-of-shared-path-pedestrians-and-bicycles--g1', + 'regulatory--end-of-snow-chains--g1', + 'regulatory--end-of-snow-chains--g2', + 'regulatory--end-of-snowmobiles-only--g1', + 'regulatory--end-of-speed-limit-zone--g1', + 'regulatory--end-of-speed-limit-zone--g2', + 'regulatory--end-of-speed-limit-zone--g3', + 'regulatory--end-of-tractors-only--g1', + 'regulatory--end-of-trams-and-buses-only--g1', + 'regulatory--end-of-trams-only--g1', + 'regulatory--end-of-trucks-and-buses-only--g1', + 'regulatory--end-of-trucks-only--g1', + 'regulatory--end-of-trucks-only--g2', + 'regulatory--equestrians-only--g1', + 'regulatory--except-railroad-crossing--g1', + 'regulatory--fine-for-littering--g1', + 'regulatory--give-way-to-bicycles--g1', + 'regulatory--give-way-to-oncoming-traffic--g1', + 'regulatory--give-way-to-oncoming-traffic--g2', + 'regulatory--go-left-bicycles--g1', + 'regulatory--go-right-bicycles--g1', + 'regulatory--go-straight--g1', + 'regulatory--go-straight--g3', + 'regulatory--go-straight-bicycles--g1', + 'regulatory--go-straight-or-turn-left--g1', + 'regulatory--go-straight-or-turn-left--g2', + 'regulatory--go-straight-or-turn-left--g3', + 'regulatory--go-straight-or-turn-right--g1', + 'regulatory--go-straight-or-turn-right--g2', + 'regulatory--go-straight-or-turn-right--g3', + 'regulatory--heavy-goods-vehicles-permitted--g1', + 'regulatory--height-limit--g1', + 'regulatory--high-beam-headlights--g1', + 'regulatory--horn--g1', + 'regulatory--in-street-pedestrian-crossing--g1', + 'regulatory--keep-left--g1', + 'regulatory--keep-left--g2', + 'regulatory--keep-left--g3', + 'regulatory--keep-left--g4', + 'regulatory--keep-left--g5', + 'regulatory--keep-left--g6', + 'regulatory--keep-left--g7', + 'regulatory--keep-right--g1', + 'regulatory--keep-right--g2', + 'regulatory--keep-right--g3', + 'regulatory--keep-right--g4', + 'regulatory--keep-right--g5', + 'regulatory--keep-right--g6', + 'regulatory--keep-right--g7', + 'regulatory--keep-right--g8', + 'regulatory--keep-right--g9', + 'regulatory--lane-control--g1', + 'regulatory--left-turn-yield-on-green--g1', + 'regulatory--length-limit--g1', + 'regulatory--length-limit--g2', + 'regulatory--light-rail-divided-highway--g1', + 'regulatory--light-rail-do-not-pass--g1', + 'regulatory--light-rail-only--g1', + 'regulatory--look--g1', + 'regulatory--low-beam-headlights--g1', + 'regulatory--low-beam-headlights--g2', + 'regulatory--low-beam-headlights--g3', + 'regulatory--low-speed-vehicle-permitted--g1', + 'regulatory--maximum-speed-limit-10--g1', + 'regulatory--maximum-speed-limit-10--g3', + 'regulatory--maximum-speed-limit-100--g1', + 'regulatory--maximum-speed-limit-100--g3', + 'regulatory--maximum-speed-limit-110--g1', + 'regulatory--maximum-speed-limit-110--g3', + 'regulatory--maximum-speed-limit-120--g1', + 'regulatory--maximum-speed-limit-120--g3', + 'regulatory--maximum-speed-limit-130--g1', + 'regulatory--maximum-speed-limit-130--g3', + 'regulatory--maximum-speed-limit-15--g1', + 'regulatory--maximum-speed-limit-15--g3', + 'regulatory--maximum-speed-limit-20--g1', + 'regulatory--maximum-speed-limit-20--g3', + 'regulatory--maximum-speed-limit-25--g1', + 'regulatory--maximum-speed-limit-25--g2', + 'regulatory--maximum-speed-limit-30--g1', + 'regulatory--maximum-speed-limit-30--g3', + 'regulatory--maximum-speed-limit-35--g1', + 'regulatory--maximum-speed-limit-35--g2', + 'regulatory--maximum-speed-limit-40--g1', + 'regulatory--maximum-speed-limit-40--g3', + 'regulatory--maximum-speed-limit-45--g1', + 'regulatory--maximum-speed-limit-45--g3', + 'regulatory--maximum-speed-limit-5--g1', + 'regulatory--maximum-speed-limit-5--g3', + 'regulatory--maximum-speed-limit-50--g1', + 'regulatory--maximum-speed-limit-50--g3', + 'regulatory--maximum-speed-limit-55--g2', + 'regulatory--maximum-speed-limit-60--g1', + 'regulatory--maximum-speed-limit-60--g3', + 'regulatory--maximum-speed-limit-65--g1', + 'regulatory--maximum-speed-limit-65--g2', + 'regulatory--maximum-speed-limit-70--g1', + 'regulatory--maximum-speed-limit-70--g3', + 'regulatory--maximum-speed-limit-75--g2', + 'regulatory--maximum-speed-limit-80--g1', + 'regulatory--maximum-speed-limit-80--g3', + 'regulatory--maximum-speed-limit-85--g2', + 'regulatory--maximum-speed-limit-90--g1', + 'regulatory--maximum-speed-limit-90--g3', + 'regulatory--maximum-speed-limit-led-10--g1', + 'regulatory--maximum-speed-limit-led-10--g2', + 'regulatory--maximum-speed-limit-led-10--g3', + 'regulatory--maximum-speed-limit-led-100--g1', + 'regulatory--maximum-speed-limit-led-110--g1', + 'regulatory--maximum-speed-limit-led-120--g1', + 'regulatory--maximum-speed-limit-led-130--g1', + 'regulatory--maximum-speed-limit-led-15--g2', + 'regulatory--maximum-speed-limit-led-15--g3', + 'regulatory--maximum-speed-limit-led-20--g1', + 'regulatory--maximum-speed-limit-led-20--g2', + 'regulatory--maximum-speed-limit-led-20--g3', + 'regulatory--maximum-speed-limit-led-25--g1', + 'regulatory--maximum-speed-limit-led-25--g2', + 'regulatory--maximum-speed-limit-led-25--g3', + 'regulatory--maximum-speed-limit-led-30--g1', + 'regulatory--maximum-speed-limit-led-30--g2', + 'regulatory--maximum-speed-limit-led-30--g3', + 'regulatory--maximum-speed-limit-led-35--g1', + 'regulatory--maximum-speed-limit-led-35--g2', + 'regulatory--maximum-speed-limit-led-35--g3', + 'regulatory--maximum-speed-limit-led-40--g1', + 'regulatory--maximum-speed-limit-led-40--g2', + 'regulatory--maximum-speed-limit-led-40--g3', + 'regulatory--maximum-speed-limit-led-45--g2', + 'regulatory--maximum-speed-limit-led-45--g3', + 'regulatory--maximum-speed-limit-led-5--g2', + 'regulatory--maximum-speed-limit-led-5--g3', + 'regulatory--maximum-speed-limit-led-50--g1', + 'regulatory--maximum-speed-limit-led-50--g2', + 'regulatory--maximum-speed-limit-led-50--g3', + 'regulatory--maximum-speed-limit-led-55--g2', + 'regulatory--maximum-speed-limit-led-55--g3', + 'regulatory--maximum-speed-limit-led-60--g1', + 'regulatory--maximum-speed-limit-led-60--g2', + 'regulatory--maximum-speed-limit-led-60--g3', + 'regulatory--maximum-speed-limit-led-65--g2', + 'regulatory--maximum-speed-limit-led-65--g3', + 'regulatory--maximum-speed-limit-led-70--g1', + 'regulatory--maximum-speed-limit-led-70--g2', + 'regulatory--maximum-speed-limit-led-70--g3', + 'regulatory--maximum-speed-limit-led-75--g1', + 'regulatory--maximum-speed-limit-led-75--g2', + 'regulatory--maximum-speed-limit-led-75--g3', + 'regulatory--maximum-speed-limit-led-80--g1', + 'regulatory--maximum-speed-limit-led-80--g2', + 'regulatory--maximum-speed-limit-led-80--g3', + 'regulatory--maximum-speed-limit-led-85--g2', + 'regulatory--maximum-speed-limit-led-85--g3', + 'regulatory--maximum-speed-limit-led-90--g1', + 'regulatory--minimum-safe-distance--g1', + 'regulatory--minimum-safe-distance--g2', + 'regulatory--mopeds-and-bicycles-only--g1', + 'regulatory--motorcycles-and-bicycles-only--g1', + 'regulatory--motorcycles-only--g1', + 'regulatory--motorcycles-only--g2', + 'regulatory--night-speed-limit-10--g1', + 'regulatory--night-speed-limit-15--g1', + 'regulatory--night-speed-limit-20--g1', + 'regulatory--night-speed-limit-25--g1', + 'regulatory--night-speed-limit-30--g1', + 'regulatory--night-speed-limit-35--g1', + 'regulatory--night-speed-limit-40--g1', + 'regulatory--night-speed-limit-45--g1', + 'regulatory--night-speed-limit-5--g1', + 'regulatory--night-speed-limit-50--g1', + 'regulatory--night-speed-limit-55--g1', + 'regulatory--night-speed-limit-60--g1', + 'regulatory--night-speed-limit-65--g1', + 'regulatory--night-speed-limit-70--g1', + 'regulatory--night-speed-limit-75--g1', + 'regulatory--night-speed-limit-80--g1', + 'regulatory--night-speed-limit-85--g1', + 'regulatory--no-abnormal-vehicles--g1', + 'regulatory--no-atvs--g1', + 'regulatory--no-bicycles--g1', + 'regulatory--no-bicycles--g2', + 'regulatory--no-bicycles--g3', + 'regulatory--no-bicycles-carts-or-hand-carts--g1', + 'regulatory--no-bicycles-mopeds-or-motorcycles--g1', + 'regulatory--no-bicycles-mopeds-or-motorcycles--g2', + 'regulatory--no-bicycles-or-hand-carts--g1', + 'regulatory--no-bicycles-or-motorcycles--g1', + 'regulatory--no-bicycles-tractors-or-carts--g1', + 'regulatory--no-buses--g1', + 'regulatory--no-buses--g2', + 'regulatory--no-buses--g3', + 'regulatory--no-caravan-trailers--g1', + 'regulatory--no-caravans--g1', + 'regulatory--no-caravans-or-caravan-trailers--g1', + 'regulatory--no-cargo-loading--g1', + 'regulatory--no-carts--g1', + 'regulatory--no-carts--g2', + 'regulatory--no-carts--g3', + 'regulatory--no-carts-or-tractors--g1', + 'regulatory--no-construction-vehicles--g1', + 'regulatory--no-entry--g1', + 'regulatory--no-equestrians--g1', + 'regulatory--no-go-straight-or-turn-left--g1', + 'regulatory--no-go-straight-or-turn-right--g1', + 'regulatory--no-good-trailers--g2', + 'regulatory--no-goods-vehicle-trailers--g1', + 'regulatory--no-hand-carts--g1', + 'regulatory--no-hand-carts--g2', + 'regulatory--no-hand-carts-or-bicycles--g1', + 'regulatory--no-hawkers--g1', + 'regulatory--no-heavy-goods-vehicles--g1', + 'regulatory--no-heavy-goods-vehicles--g2', + 'regulatory--no-heavy-goods-vehicles--g3', + 'regulatory--no-heavy-goods-vehicles--g4', + 'regulatory--no-heavy-goods-vehicles--g5', + 'regulatory--no-heavy-goods-vehicles-motorcycles-or-bicycles--g1', + 'regulatory--no-heavy-goods-vehicles-motorcycles-or-bicycles--g2', + 'regulatory--no-heavy-goods-vehicles-or-buses--g1', + 'regulatory--no-heavy-goods-vehicles-or-tractors--g1', + 'regulatory--no-heavy-goods-vehicles-or-trailers--g1', + 'regulatory--no-horizontal-turn--g1', + 'regulatory--no-horn--g1', + 'regulatory--no-horn--g2', + 'regulatory--no-lane-change-to-left--g1', + 'regulatory--no-lane-change-to-right--g1', + 'regulatory--no-learner-drivers--g1', + 'regulatory--no-left-or-u-turn--g1', + 'regulatory--no-left-turn--g1', + 'regulatory--no-left-turn--g2', + 'regulatory--no-left-turn--g3', + 'regulatory--no-left-turn--g4', + 'regulatory--no-left-turn--g5', + 'regulatory--no-low-speed-vehicles--g1', + 'regulatory--no-mopeds-or-bicycles--g1', + 'regulatory--no-motor-vehicle-trailers--g1', + 'regulatory--no-motor-vehicles--g1', + 'regulatory--no-motor-vehicles--g3', + 'regulatory--no-motor-vehicles--g4', + 'regulatory--no-motor-vehicles--g5', + 'regulatory--no-motor-vehicles--g6', + 'regulatory--no-motor-vehicles--g7', + 'regulatory--no-motor-vehicles-except-motorcycles--g1', + 'regulatory--no-motor-vehicles-except-motorcycles--g2', + 'regulatory--no-motor-vehicles-except-motorcycles--g3', + 'regulatory--no-motor-vehicles-or-bicycles--g1', + 'regulatory--no-motor-vehicles-or-buses--g1', + 'regulatory--no-motor-vehicles-or-carts--g1', + 'regulatory--no-motorcycles--g1', + 'regulatory--no-motorcycles--g2', + 'regulatory--no-overtaking--g1', + 'regulatory--no-overtaking--g2', + 'regulatory--no-overtaking--g4', + 'regulatory--no-overtaking--g5', + 'regulatory--no-overtaking--g6', + 'regulatory--no-overtaking--g7', + 'regulatory--no-overtaking-atvs--g1', + 'regulatory--no-overtaking-by-heavy-goods-vehicles--g1', + 'regulatory--no-parking--g1', + 'regulatory--no-parking--g2', + 'regulatory--no-parking--g3', + 'regulatory--no-parking--g4', + 'regulatory--no-parking--g5', + 'regulatory--no-parking--g6', + 'regulatory--no-parking--g7', + 'regulatory--no-parking--g8', + 'regulatory--no-parking--g9', + 'regulatory--no-parking-bicycles-or-motorcycles--g1', + 'regulatory--no-parking-bus-stop--g1', + 'regulatory--no-parking-or-no-stopping--g1', + 'regulatory--no-parking-or-no-stopping--g2', + 'regulatory--no-parking-or-no-stopping--g3', + 'regulatory--no-parking-or-no-stopping--g4', + 'regulatory--no-parking-or-no-stopping--g5', + 'regulatory--no-passenger-loading--g1', + 'regulatory--no-pedestrians--g1', + 'regulatory--no-pedestrians--g2', + 'regulatory--no-pedestrians--g3', + 'regulatory--no-pedestrians--g4', + 'regulatory--no-pedestrians--g5', + 'regulatory--no-pedestrians--g6', + 'regulatory--no-pedestrians-bicycles-animals-or-hand-carts--g1', + 'regulatory--no-pedestrians-or-bicycles--g1', + 'regulatory--no-pedestrians-or-bicycles--g2', + 'regulatory--no-pedestrians-or-bicycles--g3', + 'regulatory--no-rickshaws--g1', + 'regulatory--no-rickshaws--g2', + 'regulatory--no-rickshaws--g3', + 'regulatory--no-right-turn--g1', + 'regulatory--no-right-turn--g2', + 'regulatory--no-right-turn--g3', + 'regulatory--no-right-turn-on-red--g1', + 'regulatory--no-skiing--g1', + 'regulatory--no-snowmobiles--g1', + 'regulatory--no-snowmobiles--g2', + 'regulatory--no-snowmobiles-or-atvs--g1', + 'regulatory--no-stopping--g1', + 'regulatory--no-stopping--g2', + 'regulatory--no-stopping--g3', + 'regulatory--no-stopping--g5', + 'regulatory--no-stopping--g6', + 'regulatory--no-stopping--g7', + 'regulatory--no-stopping-on-pavement--g1', + 'regulatory--no-straight-through--g1', + 'regulatory--no-straight-through--g2', + 'regulatory--no-studded-snow-chains--g1', + 'regulatory--no-through-trucks--g1', + 'regulatory--no-tour-buses--g1', + 'regulatory--no-tractors--g1', + 'regulatory--no-tractors-mopeds-or-bicycles--g1', + 'regulatory--no-tractors-or-carts--g1', + 'regulatory--no-trailers--g1', + 'regulatory--no-tricycles--g1', + 'regulatory--no-tricycles-or-hand-carts--g1', + 'regulatory--no-turn-on-red--g1', + 'regulatory--no-turn-on-red--g2', + 'regulatory--no-turn-on-red--g3', + 'regulatory--no-turns--g1', + 'regulatory--no-turns--g2', + 'regulatory--no-two-stage-right-turn-for-mopeds--g1', + 'regulatory--no-u-turn--g1', + 'regulatory--no-u-turn--g2', + 'regulatory--no-u-turn--g3', + 'regulatory--no-vehicles-carrying-dangerous-goods--g1', + 'regulatory--no-vehicles-carrying-dangerous-goods--g2', + 'regulatory--no-vehicles-carrying-dangerous-goods--g3', + 'regulatory--no-vehicles-carrying-dangerous-goods--g4', + 'regulatory--no-vehicles-carrying-dangerous-water-pollutants--g1', + 'regulatory--no-vehicles-carrying-dangerous-water-pollutants--g2', + 'regulatory--no-vehicles-carrying-explosives--g1', + 'regulatory--no-vehicles-carrying-explosives-or-dangerous-water-pollutants--g1', + 'regulatory--one-way-left--g1', + 'regulatory--one-way-left--g2', + 'regulatory--one-way-left--g3', + 'regulatory--one-way-right--g1', + 'regulatory--one-way-right--g2', + 'regulatory--one-way-right--g3', + 'regulatory--one-way-straight--g1', + 'regulatory--one-way-straight--g3', + 'regulatory--parking-fee-station--g1', + 'regulatory--parking-restrictions--g1', + 'regulatory--parking-restrictions--g2', + 'regulatory--parking-restrictions--g3', + 'regulatory--pass-on-either-side--g1', + 'regulatory--pass-on-either-side--g2', + 'regulatory--pass-on-either-side--g3', + 'regulatory--pass-with-care--g1', + 'regulatory--passing-lane-ahead--g1', + 'regulatory--pedestrians-bicycles-permitted--g1', + 'regulatory--pedestrians-keep-left--g1', + 'regulatory--pedestrians-only--g1', + 'regulatory--pedestrians-only--g2', + 'regulatory--pedestrians-only--g3', + 'regulatory--pedestrians-priority-zone--g1', + 'regulatory--pedestrians-push-button--g1', + 'regulatory--pedestrians-push-button--g2', + 'regulatory--priority-over-oncoming-vehicles--g1', + 'regulatory--priority-over-oncoming-vehicles--g2', + 'regulatory--priority-road--g1', + 'regulatory--priority-road--g2', + 'regulatory--radar-enforced--g1', + 'regulatory--reserved-parking--g1', + 'regulatory--reversible-lanes--g1', + 'regulatory--reversible-lanes--g2', + 'regulatory--road-closed--g1', + 'regulatory--road-closed--g2', + 'regulatory--road-closed-to-vehicles--g1', + 'regulatory--road-closed-to-vehicles--g3', + 'regulatory--roundabout--g1', + 'regulatory--roundabout--g2', + 'regulatory--roundabout--g3', + 'regulatory--shared-path-bicycles-and-pedestrians--g1', + 'regulatory--shared-path-pedestrians-and-bicycles--g1', + 'regulatory--sidewalk-closed--g1', + 'regulatory--slanted-parking--g1', + 'regulatory--snow-chains--g1', + 'regulatory--snow-chains--g2', + 'regulatory--snow-chains--g3', + 'regulatory--snowmobiles-only--g1', + 'regulatory--snowmobiles-permitted--g1', + 'regulatory--speed-limit-zone--g1', + 'regulatory--speeding-fines-increased--g1', + 'regulatory--state-route--g1', + 'regulatory--stay-in-lane--g1', + 'regulatory--stop--g1', + 'regulatory--stop--g10', + 'regulatory--stop--g2', + 'regulatory--stop--g3', + 'regulatory--stop--g4', + 'regulatory--stop--g5', + 'regulatory--stop--g6', + 'regulatory--stop--g7', + 'regulatory--stop--g8', + 'regulatory--stop--g9', + 'regulatory--stop-here-on-red-or-flashing-light--g1', + 'regulatory--stop-here-on-red-or-flashing-light--g2', + 'regulatory--stop-signals--g1', + 'regulatory--stop-signals--g2', + 'regulatory--tanks-only--g1', + 'regulatory--taxi-only--g1', + 'regulatory--text--g1', + 'regulatory--text--g2', + 'regulatory--toll-pass-only--g1', + 'regulatory--tractors-only--g1', + 'regulatory--traffic-signal-photo-enforced--g1', + 'regulatory--trams-and-buses-only--g1', + 'regulatory--trams-only--g1', + 'regulatory--triple-lanes--g1', + 'regulatory--triple-lanes-go-straight-center-lane--g1', + 'regulatory--triple-lanes-turn-left-center-lane--g1', + 'regulatory--triple-lanes-turn-right-center-lane--g1', + 'regulatory--truck-route--g1', + 'regulatory--truck-speed-limit-10--g1', + 'regulatory--truck-speed-limit-15--g1', + 'regulatory--truck-speed-limit-20--g1', + 'regulatory--truck-speed-limit-25--g1', + 'regulatory--truck-speed-limit-30--g1', + 'regulatory--truck-speed-limit-35--g1', + 'regulatory--truck-speed-limit-40--g1', + 'regulatory--truck-speed-limit-45--g1', + 'regulatory--truck-speed-limit-5--g1', + 'regulatory--truck-speed-limit-50--g1', + 'regulatory--truck-speed-limit-55--g1', + 'regulatory--truck-speed-limit-60--g1', + 'regulatory--truck-speed-limit-65--g1', + 'regulatory--truck-speed-limit-70--g1', + 'regulatory--truck-speed-limit-75--g1', + 'regulatory--truck-speed-limit-80--g1', + 'regulatory--truck-speed-limit-85--g1', + 'regulatory--trucks-and-buses-only--g1', + 'regulatory--trucks-on-right--g1', + 'regulatory--trucks-only--g1', + 'regulatory--turn-left--g1', + 'regulatory--turn-left--g2', + 'regulatory--turn-left--g3', + 'regulatory--turn-left-ahead--g1', + 'regulatory--turn-left-ahead--g2', + 'regulatory--turn-left-or-right--g1', + 'regulatory--turn-left-or-right--g2', + 'regulatory--turn-left-or-right--g3', + 'regulatory--turn-left-or-u-turn--g1', + 'regulatory--turn-right--g1', + 'regulatory--turn-right--g2', + 'regulatory--turn-right--g3', + 'regulatory--turn-right-ahead--g1', + 'regulatory--turn-right-ahead--g2', + 'regulatory--turning-vehicles-yield-to-pedestrians--g1', + 'regulatory--two-stage-right-turn-for-mopeds--g1', + 'regulatory--two-way--g1', + 'regulatory--u-turn--g1', + 'regulatory--u-turn--g2', + 'regulatory--u-turn--g3', + 'regulatory--use-crosswalk--g1', + 'regulatory--vehicles-carrying-dangerous-goods-only--g1', + 'regulatory--vehicles-carrying-dangerous-goods-permitted--g1', + 'regulatory--vehicles-carrying-explosives-only--g1', + 'regulatory--vehicles-carrying-hazardous-goods-permitted--g1', + 'regulatory--vehicles-only--g1', + 'regulatory--wear-seat-belt--g1', + 'regulatory--wear-seat-belt--g2', + 'regulatory--wear-seat-belt--g3', + 'regulatory--weight-limit--g1', + 'regulatory--weight-limit--g2', + 'regulatory--weight-limit--g3', + 'regulatory--weight-limit--g4', + 'regulatory--weight-limit--g5', + 'regulatory--weight-limit--g6', + 'regulatory--weight-limit--g7', + 'regulatory--weight-limit-per-axle--g1', + 'regulatory--weight-limit-per-tandem-axle--g1', + 'regulatory--weight-limit-with-trucks--g1', + 'regulatory--width-limit--g1', + 'regulatory--wrong-way--g1', + 'regulatory--yield--g1', + 'regulatory--yield-or-stop-for-pedestrians--g1', + 'regulatory--your-speed--g1', + 'warning--accident-area--g1', + 'warning--accident-area--g2', + 'warning--accident-area--g3', + 'warning--accident-area--g4', + 'warning--accident-area--g5', + 'warning--accident-area--g6', + 'warning--accident-area--g7', + 'warning--accident-area--g8', + 'warning--added-lane-from-entering-roadway--g1', + 'warning--added-lane-from-entering-roadway--g2', + 'warning--added-lane-left--g1', + 'warning--added-lane-right--g1', + 'warning--animal-drawn-vehicles--g1', + 'warning--arch-bridge--g1', + 'warning--atv-and-snowmobiles--g1', + 'warning--atv-crossing--g1', + 'warning--atv-crossing--g2', + 'warning--axle-restriction--g1', + 'warning--bear-crossing--g1', + 'warning--bear-crossing--g2', + 'warning--bicycles-and-others--g1', + 'warning--bicycles-caution-on-rail-tracks--g1', + 'warning--bicycles-crossing--g1', + 'warning--bicycles-crossing--g2', + 'warning--bicycles-crossing--g3', + 'warning--bicycles-crossing--g4', + 'warning--bollard--g1', + 'warning--bridge--g1', + 'warning--bridge--g2', + 'warning--bus-stop-ahead--g1', + 'warning--bus-stop-ahead--g2', + 'warning--bus-stop-ahead--g3', + 'warning--camel-crossing--g1', + 'warning--camel-crossing--g2', + 'warning--camera--g1', + 'warning--camera--g2', + 'warning--carts--g1', + 'warning--carts--g2', + 'warning--checkpoint--g1', + 'warning--children--g1', + 'warning--children--g2', + 'warning--children--g3', + 'warning--children--g4', + 'warning--children--g6', + 'warning--cliff--g1', + 'warning--cliff--g2', + 'warning--closed-lane-in-triple-lanes--g1', + 'warning--closed-lane-in-triple-lanes--g2', + 'warning--construction-ahead--g1', + 'warning--crossroads--g1', + 'warning--crossroads--g2', + 'warning--crossroads--g3', + 'warning--crossroads--g4', + 'warning--crossroads--g5', + 'warning--crossroads--g6', + 'warning--crossroads-with-priority-to-the-right--g1', + 'warning--curve-left--g1', + 'warning--curve-left--g2', + 'warning--curve-left--g3', + 'warning--curve-left-with-junction--g1', + 'warning--curve-out-intersection-left--g1', + 'warning--curve-out-intersection-right--g1', + 'warning--curve-right--g1', + 'warning--curve-right--g2', + 'warning--curve-right--g3', + 'warning--curve-right-with-junction--g1', + 'warning--dangerous-crosswinds-left--g1', + 'warning--dangerous-crosswinds-left--g2', + 'warning--dangerous-crosswinds-left--g4', + 'warning--dangerous-crosswinds-right--g1', + 'warning--dangerous-crosswinds-right--g2', + 'warning--dangerous-crosswinds-right--g3', + 'warning--dangerous-crosswinds-right--g4', + 'warning--dead-end--g1', + 'warning--dead-end--g2', + 'warning--dead-end--g3', + 'warning--dead-end-go-left--g1', + 'warning--dead-end-go-right--g1', + 'warning--descent-or-climbing-lanes-in-triple-lanes--g1', + 'warning--detour-ahead--g1', + 'warning--detour-or-construction-ahead--g1', + 'warning--dip--g1', + 'warning--dip--g2', + 'warning--disabled-persons-crossing--g1', + 'warning--disabled-persons-crossing--g2', + 'warning--divided-highway--g1', + 'warning--divided-highway--g2', + 'warning--divided-highway--g3', + 'warning--divided-highway--g4', + 'warning--divided-highway--g5', + 'warning--divided-highway--g6', + 'warning--divided-highway--g7', + 'warning--divided-highway--g8', + 'warning--divided-highway--g9', + 'warning--divided-highway-ends--g1', + 'warning--divided-highway-ends--g2', + 'warning--divided-highway-ends--g3', + 'warning--divided-highway-ends--g4', + 'warning--divided-highway-on-left--g1', + 'warning--divided-highway-on-left--g2', + 'warning--divided-highway-on-right--g1', + 'warning--divided-highway-on-right--g2', + 'warning--divided-highway-to-left--g1', + 'warning--divided-highway-to-right--g1', + 'warning--domestic-animals--g1', + 'warning--domestic-animals--g2', + 'warning--domestic-animals--g3', + 'warning--domestic-animals--g4', + 'warning--domestic-animals--g5', + 'warning--domestic-animals--g6', + 'warning--domestic-animals--g7', + 'warning--domestic-animals--g8', + 'warning--double-curve-first-left--g1', + 'warning--double-curve-first-left--g2', + 'warning--double-curve-first-right--g1', + 'warning--double-curve-first-right--g2', + 'warning--double-descent--g1', + 'warning--double-reverse-curve-left--g1', + 'warning--double-reverse-curve-left--g2', + 'warning--double-reverse-curve-right--g1', + 'warning--double-reverse-curve-right--g2', + 'warning--double-side-roads-left--g1', + 'warning--double-side-roads-left--g3', + 'warning--double-side-roads-right--g1', + 'warning--double-side-roads-right--g3', + 'warning--double-turn-first-left--g1', + 'warning--double-turn-first-right--g1', + 'warning--dual-lanes-all-directions-on-left--g1', + 'warning--dual-lanes-all-directions-on-right--g1', + 'warning--dual-lanes-go-straight-or-turn-left--g1', + 'warning--dual-lanes-go-straight-or-turn-right--g1', + 'warning--dual-lanes-left-turn--g1', + 'warning--dual-lanes-left-turn-or-go-straight--g1', + 'warning--dual-lanes-right-turn--g1', + 'warning--dual-lanes-right-turn-or-go-straight--g1', + 'warning--dual-lanes-turn-left--g1', + 'warning--dual-lanes-turn-left-or-right--g1', + 'warning--dual-lanes-turn-left-or-right--g2', + 'warning--dual-lanes-turn-left-or-right--g3', + 'warning--dual-lanes-turn-left-or-right--g4', + 'warning--dual-lanes-turn-right--g1', + 'warning--dual-path-cyclists-and-pedestrians--g1', + 'warning--electricity--g1', + 'warning--electricity--g2', + 'warning--elephant-crossing--g1', + 'warning--emergency-vehicles--g1', + 'warning--emu-crossing--g1', + 'warning--emu-crossing--g2', + 'warning--entering-roadway-merge--g1', + 'warning--entering-roadway-merge--g2', + 'warning--equestrians-crossing--g1', + 'warning--equestrians-crossing--g2', + 'warning--expressway--g1', + 'warning--falling-rocks-or-debris-left--g1', + 'warning--falling-rocks-or-debris-left--g2', + 'warning--falling-rocks-or-debris-left--g3', + 'warning--falling-rocks-or-debris-left--g4', + 'warning--falling-rocks-or-debris-right--g1', + 'warning--falling-rocks-or-debris-right--g2', + 'warning--falling-rocks-or-debris-right--g3', + 'warning--falling-rocks-or-debris-right--g4', + 'warning--ferry--g1', + 'warning--flaggers-in-road--g1', + 'warning--flaggers-in-road--g2', + 'warning--foggy-road--g1', + 'warning--foggy-road--g2', + 'warning--ford--g1', + 'warning--forest--g1', + 'warning--fresh-oil--g1', + 'warning--frog-crossing--g1', + 'warning--gate--g1', + 'warning--gate--g2', + 'warning--gate-left--g1', + 'warning--gate-right--g1', + 'warning--go-left--g1', + 'warning--go-right--g1', + 'warning--golf-carts-crossing--g1', + 'warning--gravel-road-surface--g1', + 'warning--hairpin-curve-left--g1', + 'warning--hairpin-curve-left--g2', + 'warning--hairpin-curve-left--g3', + 'warning--hairpin-curve-right--g1', + 'warning--hairpin-curve-right--g3', + 'warning--height-restriction--g2', + 'warning--height-restriction--g3', + 'warning--height-restriction--g4', + 'warning--height-restriction--g5', + 'warning--horizontal-alignment-left--g1', + 'warning--horizontal-alignment-left--g3', + 'warning--horizontal-alignment-right--g1', + 'warning--horizontal-alignment-right--g3', + 'warning--horse-crossing--g1', + 'warning--icy-road--g1', + 'warning--junction-with-a-side-road-acute-left--g1', + 'warning--junction-with-a-side-road-acute-left--g2', + 'warning--junction-with-a-side-road-acute-right--g1', + 'warning--junction-with-a-side-road-acute-right--g2', + 'warning--junction-with-a-side-road-perpendicular-left--g1', + 'warning--junction-with-a-side-road-perpendicular-left--g2', + 'warning--junction-with-a-side-road-perpendicular-left--g3', + 'warning--junction-with-a-side-road-perpendicular-left--g4', + 'warning--junction-with-a-side-road-perpendicular-right--g1', + 'warning--junction-with-a-side-road-perpendicular-right--g2', + 'warning--junction-with-a-side-road-perpendicular-right--g3', + 'warning--junction-with-a-side-road-perpendicular-right--g4', + 'warning--junction-with-merge-from-left--g1', + 'warning--junction-with-merge-from-right--g1', + 'warning--junction-with-side-roads--g1', + 'warning--kangaloo-crossing--g1', + 'warning--keep-distance--g1', + 'warning--keep-left--g1', + 'warning--keep-right--g1', + 'warning--kiwi-crossing--g1', + 'warning--kiwi-crossing--g2', + 'warning--koala-crossing--g1', + 'warning--koala-crossing--g2', + 'warning--koala-crossing--g3', + 'warning--koala-crossing--g4', + 'warning--lane-closed-in-dual-lanes-left--g1', + 'warning--lane-closed-in-dual-lanes-left--g2', + 'warning--lane-closed-in-dual-lanes-right--g1', + 'warning--lane-closed-in-dual-lanes-right--g2', + 'warning--length-restriction--g1', + 'warning--length-restriction--g2', + 'warning--light-rail-transit-vehicles--g1', + 'warning--limited-lighting-under-trees--g1', + 'warning--logging-vehicles--g1', + 'warning--loop-270-degree--g1', + 'warning--loop-pretzel--g1', + 'warning--loose-road-surface--g1', + 'warning--loose-road-surface--g2', + 'warning--loose-road-surface--g3', + 'warning--loose-road-surface--g4', + 'warning--low-flying-aircraft--g1', + 'warning--low-flying-aircraft--g2', + 'warning--low-flying-aircraft--g3', + 'warning--low-flying-aircraft--g4', + 'warning--low-flying-aircraft--g5', + 'warning--low-flying-aircraft--g6', + 'warning--low-flying-aircraft--g7', + 'warning--low-flying-aircraft--g8', + 'warning--low-ground-clearance--g1', + 'warning--low-ground-clearance--g2', + 'warning--low-ground-clearance--g3', + 'warning--monkey-crossing--g1', + 'warning--motorcycles-crossing--g1', + 'warning--narrow-bridge--g1', + 'warning--narrow-bridge--g2', + 'warning--narrow-bridge--g3', + 'warning--no-passing-zone--g1', + 'warning--no-passing-zone--g2', + 'warning--occupied-lanes--g1', + 'warning--offset-roads--g1', + 'warning--offset-roads--g2', + 'warning--offset-roads--g3', + 'warning--offset-roads--g4', + 'warning--opening-or-swing-bridge--g1', + 'warning--opening-or-swing-bridge--g2', + 'warning--other-danger--g1', + 'warning--other-danger--g2', + 'warning--other-danger--g3', + 'warning--panda-crossing--g1', + 'warning--pass-left-or-right--g1', + 'warning--pass-left-or-right--g2', + 'warning--pass-left-or-right--g3', + 'warning--pavement-ahead--g1', + 'warning--pavement-ends--g1', + 'warning--pavement-ends--g2', + 'warning--pavement-ends--g3', + 'warning--pavement-ends--g4', + 'warning--pavement-ends--g5', + 'warning--pedestrians-crossing--g1', + 'warning--pedestrians-crossing--g10', + 'warning--pedestrians-crossing--g11', + 'warning--pedestrians-crossing--g12', + 'warning--pedestrians-crossing--g4', + 'warning--pedestrians-crossing--g5', + 'warning--pedestrians-crossing--g6', + 'warning--pedestrians-crossing--g7', + 'warning--pedestrians-crossing--g8', + 'warning--pedestrians-crossing--g9', + 'warning--playground--g1', + 'warning--playground--g3', + 'warning--polar-bear-crossing--g1', + 'warning--quay-or-river-bank--g1', + 'warning--quay-or-river-bank--g2', + 'warning--quay-or-river-bank--g3', + 'warning--quay-or-river-bank--g4', + 'warning--rabbit-crossing--g1', + 'warning--raccoon-crossing--g1', + 'warning--railroad-crossing--g1', + 'warning--railroad-crossing--g2', + 'warning--railroad-crossing--g3', + 'warning--railroad-crossing--g4', + 'warning--railroad-crossing-with-barriers--g1', + 'warning--railroad-crossing-with-barriers--g2', + 'warning--railroad-crossing-with-barriers--g3', + 'warning--railroad-crossing-with-barriers--g4', + 'warning--railroad-crossing-with-barriers--g5', + 'warning--railroad-crossing-with-barriers--g6', + 'warning--railroad-crossing-with-barriers--g7', + 'warning--railroad-crossing-without-barriers--g1', + 'warning--railroad-crossing-without-barriers--g2', + 'warning--railroad-crossing-without-barriers--g3', + 'warning--railroad-crossing-without-barriers--g4', + 'warning--railroad-crossing-without-barriers--g5', + 'warning--railroad-crossing-without-barriers--g6', + 'warning--railroad-intersection--g1', + 'warning--railroad-intersection--g2', + 'warning--railroad-intersection--g3', + 'warning--railroad-intersection--g4', + 'warning--railroad-intersection--g5', + 'warning--railroad-intersection--g6', + 'warning--railroad-intersection--g7', + 'warning--railroad-intersection--g8', + 'warning--railroad-intersection--g9', + 'warning--ramp-closed--g1', + 'warning--reduced-maximum-speed-limit--g1', + 'warning--reserved-lane--g1', + 'warning--restricted-zone--g1', + 'warning--reversible-lanes--g1', + 'warning--reversible-lanes--g2', + 'warning--rickshaws-crossing--g1', + 'warning--road-blocks--g1', + 'warning--road-bump--g1', + 'warning--road-bump--g2', + 'warning--road-bump--g3', + 'warning--road-bump-with-speed-limit--g1', + 'warning--road-closed--g3', + 'warning--road-narrows--g1', + 'warning--road-narrows--g2', + 'warning--road-narrows-left--g1', + 'warning--road-narrows-left--g2', + 'warning--road-narrows-left-ahead--g1', + 'warning--road-narrows-right--g1', + 'warning--road-narrows-right--g2', + 'warning--road-narrows-right-ahead--g1', + 'warning--road-toll-ahead--g1', + 'warning--road-widens--g1', + 'warning--road-widens-left--g1', + 'warning--road-widens-right--g1', + 'warning--roadworks--g1', + 'warning--roadworks--g10', + 'warning--roadworks--g11', + 'warning--roadworks--g2', + 'warning--roadworks--g3', + 'warning--roadworks--g5', + 'warning--roadworks--g6', + 'warning--roadworks--g8', + 'warning--roadworks--g9', + 'warning--roadworks-go-left-or-straight--g1', + 'warning--roadworks-go-right-or-straight--g1', + 'warning--roundabout--g1', + 'warning--roundabout--g2', + 'warning--roundabout--g3', + 'warning--roundabout--g4', + 'warning--roundabout--g5', + 'warning--roundabout--g6', + 'warning--roundabout--g7', + 'warning--ruts--g1', + 'warning--sand--g1', + 'warning--sand-drift--g1', + 'warning--school-zone--g2', + 'warning--severe-weather--g1', + 'warning--shared-lane-motorcycles-bicycles--g1', + 'warning--sharp-turn--g1', + 'warning--single-reverse-curve--g1', + 'warning--skewed-t-roads-left--g1', + 'warning--skewed-t-roads-left--g2', + 'warning--skewed-t-roads-left--g3', + 'warning--skewed-t-roads-right--g1', + 'warning--skewed-t-roads-right--g2', + 'warning--skewed-t-roads-right--g3', + 'warning--skiers--g1', + 'warning--skiers--g2', + 'warning--skiers--g3', + 'warning--slippery-bicycles--g1', + 'warning--slippery-motorcycles--g1', + 'warning--slippery-motorcycles--g2', + 'warning--slippery-road-surface--g1', + 'warning--slippery-road-surface--g2', + 'warning--slow--g1', + 'warning--snow-tractors--g1', + 'warning--snowmobiles--g1', + 'warning--snowmobiles--g2', + 'warning--snowmobiles--g3', + 'warning--snowmobiles-and-others--g1', + 'warning--soft-road-surface--g1', + 'warning--soft-road-surface--g2', + 'warning--soft-shoulder--g1', + 'warning--soft-shoulder--g2', + 'warning--soft-shoulder--g3', + 'warning--soft-shoulder--g4', + 'warning--speed-camera--g1', + 'warning--steep-ascent--g1', + 'warning--steep-ascent--g2', + 'warning--steep-ascent--g3', + 'warning--steep-ascent--g4', + 'warning--steep-ascent-and-descent--g1', + 'warning--steep-descent--g1', + 'warning--steep-descent--g2', + 'warning--steep-descent--g3', + 'warning--steep-descent--g4', + 'warning--steep-descent--g5', + 'warning--steep-descent--g6', + 'warning--stop-ahead--g1', + 'warning--stop-ahead--g3', + 'warning--stop-ahead--g4', + 'warning--stop-ahead--g5', + 'warning--stop-ahead--g6', + 'warning--t-roads--g1', + 'warning--t-roads--g2', + 'warning--tanks-crossing--g1', + 'warning--tanks-crossing--g2', + 'warning--texts--g1', + 'warning--texts--g2', + 'warning--texts--g3', + 'warning--towing--g1', + 'warning--tractors--g1', + 'warning--tractors--g2', + 'warning--tractors--g3', + 'warning--tractors--g4', + 'warning--tractors--g5', + 'warning--tractors--g6', + 'warning--tractors--g7', + 'warning--traffic-merges-at-signalized-intersections--g1', + 'warning--traffic-merges-left--g1', + 'warning--traffic-merges-left--g2', + 'warning--traffic-merges-left--g3', + 'warning--traffic-merges-left--g4', + 'warning--traffic-merges-left-and-right--g1', + 'warning--traffic-merges-left-buses--g1', + 'warning--traffic-merges-right--g1', + 'warning--traffic-merges-right--g2', + 'warning--traffic-merges-right--g3', + 'warning--traffic-merges-right-buses--g1', + 'warning--traffic-queues-likely--g1', + 'warning--traffic-queues-likely--g2', + 'warning--traffic-queues-likely--g3', + 'warning--traffic-queues-likely--g4', + 'warning--traffic-queues-likely--g5', + 'warning--traffic-signals--g1', + 'warning--traffic-signals--g2', + 'warning--traffic-signals--g3', + 'warning--traffic-signals--g4', + 'warning--traffic-signals--g5', + 'warning--traffic-signals--g6', + 'warning--traffic-slow--g1', + 'warning--trail-crossing--g1', + 'warning--trail-crossing--g2', + 'warning--trail-crossing--g3', + 'warning--trail-crossing--g4', + 'warning--trail-crossing--g5', + 'warning--trail-crossing--g6', + 'warning--trams-crossing--g1', + 'warning--trams-crossing--g2', + 'warning--triple-curve-left--g1', + 'warning--triple-curve-right--g1', + 'warning--triple-lanes-left-turn--g1', + 'warning--triple-lanes-left-turn-or-go-straight--g1', + 'warning--triple-lanes-right-turn--g1', + 'warning--triple-lanes-right-turn-or-go-straight--g1', + 'warning--triple-lanes-with-directions--g1', + 'warning--triple-reverse-curve-left--g1', + 'warning--triple-reverse-curve-right--g1', + 'warning--trucks-crossing--g1', + 'warning--trucks-crossing--g2', + 'warning--trucks-rollover--g1', + 'warning--trucks-rollover--g2', + 'warning--trucks-rollover--g3', + 'warning--trucks-rollover--g4', + 'warning--trucks-rollover--g5', + 'warning--tunnel--g1', + 'warning--tunnel--g2', + 'warning--tunnel--g3', + 'warning--tunnel--g4', + 'warning--tunnel--g5', + 'warning--tunnel--g6', + 'warning--tunnel--g7', + 'warning--turn-left--g1', + 'warning--turn-left--g2', + 'warning--turn-left--g3', + 'warning--turn-left-or-right--g1', + 'warning--turn-right--g1', + 'warning--turn-right--g2', + 'warning--turn-right--g3', + 'warning--two-way-traffic--g1', + 'warning--two-way-traffic--g2', + 'warning--two-way-traffic--g3', + 'warning--two-way-traffic--g4', + 'warning--two-way-traffic--g5', + 'warning--two-way-traffic--g6', + 'warning--u-turn--g1', + 'warning--u-turn--g2', + 'warning--uneven-road--g1', + 'warning--uneven-road--g2', + 'warning--uneven-roads-ahead--g1', + 'warning--vehicles-and-others--g1', + 'warning--vehicles-crossing--g1', + 'warning--village--g1', + 'warning--weight-limit--g5', + 'warning--weight-limit-per-tandem-axle--g1', + 'warning--width-restriction--g1', + 'warning--width-restriction--g2', + 'warning--width-restriction--g3', + 'warning--width-restriction--g4', + 'warning--wild-animals--g1', + 'warning--wild-animals--g2', + 'warning--wild-animals--g3', + 'warning--wild-animals--g4', + 'warning--wild-animals--g5', + 'warning--wild-animals--g6', + 'warning--wild-animals--g7', + 'warning--wild-animals--g8', + 'warning--wind--g1', + 'warning--winding-road--g1', + 'warning--winding-road-first-left--g1', + 'warning--winding-road-first-left--g2', + 'warning--winding-road-first-left--g3', + 'warning--winding-road-first-right--g1', + 'warning--winding-road-first-right--g2', + 'warning--winding-road-first-right--g3', + 'warning--winding-road-first-right--g4', + 'warning--winding-road-to-left--g1', + 'warning--winding-road-to-right--g1', + 'warning--wombat-crossing--g1', + 'warning--y-roads--g1', + 'warning--y-roads--g2', + 'warning--yield-ahead--g1', + 'warning--yield-ahead--g3', +]); + +/** Sign values collapsed to their family (regional suffix removed), sorted. */ +export const SIGN_FAMILIES = Object.freeze( + [...new Set(SIGN_VALUES.map((value) => value.replace(/--g\d+$/, '')))].sort(), +); diff --git a/src/layers/mapillary/tileMath.js b/src/layers/mapillary/tileMath.js new file mode 100644 index 000000000..efc495ad3 --- /dev/null +++ b/src/layers/mapillary/tileMath.js @@ -0,0 +1,163 @@ +/** + * Web-Mercator tile arithmetic for Mapillary vector tiles. Pure functions, + * no Cesium dependency, shared by the browser layer and the server executor. + */ + +const MAX_LAT = 85.05112878; + +function clampLat(lat) { + return Math.max(-MAX_LAT, Math.min(MAX_LAT, lat)); +} + +/** Tile column for a longitude at zoom z. */ +export function lonToTileX(lon, z) { + const n = 2 ** z; + const wrapped = ((((lon + 180) % 360) + 360) % 360) - 180; + return Math.min(n - 1, Math.max(0, Math.floor(((wrapped + 180) / 360) * n))); +} + +/** Tile row for a latitude at zoom z. */ +export function latToTileY(lat, z) { + const n = 2 ** z; + const rad = (clampLat(lat) * Math.PI) / 180; + const y = Math.floor( + ((1 - Math.log(Math.tan(rad) + 1 / Math.cos(rad)) / Math.PI) / 2) * n, + ); + return Math.min(n - 1, Math.max(0, y)); +} + +/** West longitude of tile column x at zoom z. */ +function tileXToLon(x, z) { + return (x / 2 ** z) * 360 - 180; +} + +/** North latitude of tile row y at zoom z. */ +function tileYToLat(y, z) { + const n = Math.PI - (2 * Math.PI * y) / 2 ** z; + return (180 / Math.PI) * Math.atan(0.5 * (Math.exp(n) - Math.exp(-n))); +} + +/** + * Geographic bounds of one tile. + * @returns {{west:number,south:number,east:number,north:number}} + */ +export function tileBounds(x, y, z) { + return { + west: tileXToLon(x, z), + east: tileXToLon(x + 1, z), + north: tileYToLat(y, z), + south: tileYToLat(y + 1, z), + }; +} + +/** + * Normalize a bbox given as [west, south, east, north] (or an object) into + * finite, ordered numbers. Returns null when the input is not a usable box. + */ +export function normalizeBbox(input) { + const values = Array.isArray(input) + ? input + : input && typeof input === 'object' + ? [input.west, input.south, input.east, input.north] + : null; + if (!values || values.length !== 4) return null; + const [w, s, e, n] = values.map(Number); + if (![w, s, e, n].every(Number.isFinite)) return null; + const west = Math.max(-180, Math.min(w, e)); + const east = Math.min(180, Math.max(w, e)); + const south = Math.max(-MAX_LAT, Math.min(s, n)); + const north = Math.min(MAX_LAT, Math.max(s, n)); + if (east - west <= 0 || north - south <= 0) return null; + return { west, south, east, north }; +} + +/** Area of a bbox in square degrees (Mapillary's bbox limit unit). */ +export function bboxAreaDeg2(bbox) { + const box = normalizeBbox(bbox); + return box ? (box.east - box.west) * (box.north - box.south) : 0; +} + +/** + * Number of tiles at zoom z covering a bbox, without allocating them. + */ +export function countTilesForBbox(bbox, z) { + const box = normalizeBbox(bbox); + if (!box) return 0; + const x0 = lonToTileX(box.west, z); + const x1 = lonToTileX(box.east, z); + const y0 = latToTileY(box.north, z); + const y1 = latToTileY(box.south, z); + return (x1 - x0 + 1) * (y1 - y0 + 1); +} + +/** + * Enumerate the tiles at zoom z covering a bbox, ordered from the centre of + * the box outwards so a progressive renderer fills in what the user is most + * likely looking at first. `limit` caps the list; the result reports whether + * the cap was hit. + * @returns {{tiles: Array<{x:number,y:number,z:number}>, truncated: boolean, total: number}} + */ +export function tilesForBbox(bbox, z, { limit = Infinity } = {}) { + const box = normalizeBbox(bbox); + if (!box) return { tiles: [], truncated: false, total: 0 }; + const x0 = lonToTileX(box.west, z); + const x1 = lonToTileX(box.east, z); + const y0 = latToTileY(box.north, z); + const y1 = latToTileY(box.south, z); + const total = (x1 - x0 + 1) * (y1 - y0 + 1); + const cx = (x0 + x1) / 2; + const cy = (y0 + y1) / 2; + const tiles = []; + for (let y = y0; y <= y1; y++) + for (let x = x0; x <= x1; x++) tiles.push({ x, y, z }); + tiles.sort( + (a, b) => + (a.x - cx) ** 2 + (a.y - cy) ** 2 - ((b.x - cx) ** 2 + (b.y - cy) ** 2), + ); + const truncated = tiles.length > limit; + return { tiles: truncated ? tiles.slice(0, limit) : tiles, truncated, total }; +} + +/** + * Convert a vector-tile-local coordinate (0..extent) into longitude and + * latitude for tile (x, y, z). + * @returns {[number, number]} [lon, lat] + */ +export function tileLocalToLonLat(px, py, extent, x, y, z) { + const n = 2 ** z; + const lon = ((x + px / extent) / n) * 360 - 180; + const merc = Math.PI - (2 * Math.PI * (y + py / extent)) / n; + const lat = + (180 / Math.PI) * Math.atan(0.5 * (Math.exp(merc) - Math.exp(-merc))); + return [lon, lat]; +} + +/** + * Pick a coverage zoom for a camera height (metres above ground). Higher + * cameras get coarser sequence tiles; below the floor Mapillary has no + * finer tiles than z14. + */ +export function coverageZoomForHeight(heightM) { + if (!Number.isFinite(heightM)) return null; + if (heightM > 60_000) return null; + if (heightM > 12_000) return 11; + if (heightM > 5_000) return 12; + if (heightM > 1_800) return 13; + return 14; +} + +/** + * Pick the low-zoom `overview` tile level for a camera far above the ground. + * Mapillary publishes coverage points at z0–5; sequences start at z6 and are + * far too heavy for a continent-sized view. Returns null below the overview + * ceiling (60 km), where `coverageZoomForHeight` takes over. + */ +export function overviewZoomForHeight(heightM) { + if (!Number.isFinite(heightM) || heightM <= 60_000) return null; + if (heightM > 15_000_000) return 0; + if (heightM > 7_000_000) return 1; + if (heightM > 3_000_000) return 2; + if (heightM > 1_200_000) return 3; + if (heightM > 400_000) return 4; + return 5; +} diff --git a/src/layers/mapillary/tileMath.test.mjs b/src/layers/mapillary/tileMath.test.mjs new file mode 100644 index 000000000..40e450a2d --- /dev/null +++ b/src/layers/mapillary/tileMath.test.mjs @@ -0,0 +1,87 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + bboxAreaDeg2, + countTilesForBbox, + coverageZoomForHeight, + overviewZoomForHeight, + latToTileY, + lonToTileX, + normalizeBbox, + tileBounds, + tileLocalToLonLat, + tilesForBbox, +} from './tileMath.js'; + +test('lon/lat to tile matches the Sacramento reference tile', () => { + assert.equal(lonToTileX(-121.4944, 14), 2662); + assert.equal(latToTileY(38.5816, 14), 6286); + const bounds = tileBounds(2662, 6286, 14); + assert.ok(bounds.west < -121.4944 && -121.4944 < bounds.east); + assert.ok(bounds.south < 38.5816 && 38.5816 < bounds.north); +}); + +test('tile-local coordinates round-trip into the tile bounds', () => { + const bounds = tileBounds(2662, 6286, 14); + const [lon, lat] = tileLocalToLonLat(0, 0, 4096, 2662, 6286, 14); + assert.ok(Math.abs(lon - bounds.west) < 1e-9); + assert.ok(Math.abs(lat - bounds.north) < 1e-9); + const [lon2, lat2] = tileLocalToLonLat(4096, 4096, 4096, 2662, 6286, 14); + assert.ok(Math.abs(lon2 - bounds.east) < 1e-9); + assert.ok(Math.abs(lat2 - bounds.south) < 1e-9); +}); + +test('normalizeBbox orders and clamps coordinates', () => { + assert.deepEqual(normalizeBbox([-121.36, 38.69, -121.56, 38.44]), { + west: -121.56, + south: 38.44, + east: -121.36, + north: 38.69, + }); + assert.equal(normalizeBbox([0, 0, 0, 1]), null); + assert.equal(normalizeBbox('nope'), null); + assert.equal(normalizeBbox([1, 2, Number.NaN, 3]), null); +}); + +test('city-scale boxes exceed the Mapillary bbox limit and need tiles', () => { + const sacramento = [-121.56, 38.44, -121.36, 38.69]; + assert.ok(bboxAreaDeg2(sacramento) > 0.01); + assert.equal(countTilesForBbox(sacramento, 14), 160); + const detroit = [-83.29, 42.25, -82.91, 42.45]; + assert.equal(countTilesForBbox(detroit, 14), 234); +}); + +test('tilesForBbox orders from the centre outwards and honours the cap', () => { + const result = tilesForBbox([-121.56, 38.44, -121.36, 38.69], 14, { + limit: 5, + }); + assert.equal(result.total, 160); + assert.equal(result.truncated, true); + assert.equal(result.tiles.length, 5); + const centreX = lonToTileX(-121.46, 14); + const centreY = latToTileY(38.565, 14); + assert.ok(Math.abs(result.tiles[0].x - centreX) <= 1); + assert.ok(Math.abs(result.tiles[0].y - centreY) <= 1); + const full = tilesForBbox([-121.56, 38.44, -121.36, 38.69], 14); + assert.equal(full.truncated, false); + assert.equal(full.tiles.length, 160); +}); + +test('coverageZoomForHeight steps from coarse to z14 as the camera descends', () => { + assert.equal(coverageZoomForHeight(100_000), null); + assert.equal(coverageZoomForHeight(30_000), 11); + assert.equal(coverageZoomForHeight(8_000), 12); + assert.equal(coverageZoomForHeight(3_000), 13); + assert.equal(coverageZoomForHeight(500), 14); + assert.equal(coverageZoomForHeight(Number.NaN), null); +}); + +test('overviewZoomForHeight covers the globe above the sequence ceiling', () => { + assert.equal(overviewZoomForHeight(30_000), null); + assert.equal(overviewZoomForHeight(100_000), 5); + assert.equal(overviewZoomForHeight(900_000), 4); + assert.equal(overviewZoomForHeight(2_000_000), 3); + assert.equal(overviewZoomForHeight(5_000_000), 2); + assert.equal(overviewZoomForHeight(10_000_000), 1); + assert.equal(overviewZoomForHeight(25_000_000), 0); +}); diff --git a/src/layers/mapillary/values.js b/src/layers/mapillary/values.js new file mode 100644 index 000000000..4082819b5 --- /dev/null +++ b/src/layers/mapillary/values.js @@ -0,0 +1,162 @@ +/** + * Mapillary map-feature value helpers shared by the browser layer and the + * server query executor. Values look like `object--fire-hydrant` or + * `regulatory--stop--g1`; the segments are separated by `--`. + */ + +const SIGN_PREFIXES = Object.freeze([ + 'regulatory', + 'warning', + 'information', + 'complementary', +]); + +/** Escape a string for use inside a RegExp source. */ +function escapeRegExp(text) { + return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Normalize one user-supplied value pattern: trimmed, lower-cased, spaces + * folded to hyphens, and only the characters a Mapillary value can contain. + * Returns '' when nothing usable remains. + * @param {unknown} pattern + * @returns {string} + */ +export function normalizeValuePattern(pattern) { + return String(pattern ?? '') + .trim() + .toLowerCase() + .replace(/\s+/g, '-') + .replace(/[^a-z0-9*-]/g, '') + .slice(0, 120); +} + +/** + * Build a matcher for a list of value patterns. `*` matches any run of + * characters, so `regulatory--stop--*` covers every regional variant and + * `object--traffic-light--*` every traffic-light subtype. An empty pattern + * list matches everything. + * @param {Iterable} patterns + * @returns {(value: string) => boolean} + */ +export function createValueMatcher(patterns) { + const exact = new Set(); + const wild = []; + for (const raw of patterns || []) { + const pattern = normalizeValuePattern(raw); + if (!pattern) continue; + if (pattern.includes('*')) { + wild.push( + new RegExp(`^${pattern.split('*').map(escapeRegExp).join('.*')}$`), + ); + } else exact.add(pattern); + } + if (!exact.size && !wild.length) return () => true; + return (value) => { + if (typeof value !== 'string') return false; + if (exact.has(value)) return true; + for (const regex of wild) if (regex.test(value)) return true; + return false; + }; +} + +/** + * Whether a value belongs to the traffic-sign layer rather than the point + * object layer. Sign values start with one of four regulatory families. + * @param {string} value + * @returns {boolean} + */ +export function isSignValue(value) { + const head = String(value || '').split('--')[0]; + return SIGN_PREFIXES.includes(head); +} + +/** + * Human label for a value: `object--fire-hydrant` → "Fire hydrant", + * `regulatory--stop--g1` → "Stop (regulatory)", + * `object--traffic-light--general-upright` → "Traffic light · general upright". + * @param {string} value + * @returns {string} + */ +export function humanizeValue(value) { + const parts = String(value || '') + .split('--') + .filter(Boolean); + if (!parts.length) return 'Unknown'; + const [head, ...rest] = parts; + const words = (segment) => + segment.replace(/-/g, ' ').replace(/^\w/, (c) => c.toUpperCase()); + if (SIGN_PREFIXES.includes(head)) { + const body = rest.filter((segment) => !/^g\d+$/.test(segment)); + return `${body.map(words).join(' · ') || words(head)} (${head})`; + } + if (head === 'object' || head === 'marking' || head === 'construction') { + const body = rest.length ? rest : [head]; + return body.map(words).join(' · '); + } + return parts.map(words).join(' · '); +} + +/** Coarse category used for colouring and grouping results. */ +export function categoryOf(value) { + const head = String(value || '').split('--')[0]; + if (SIGN_PREFIXES.includes(head)) return 'sign'; + if (head === 'marking') return 'marking'; + if (head === 'construction') return 'construction'; + return 'object'; +} + +/** + * Stable CSS colour per value, so one class keeps one colour across a query. + * Signs lean warm, objects lean cool; the hue within a category is hashed + * from the value so neighbouring classes differ. + * @param {string} value + * @returns {string} CSS colour. + */ +export function colorForValue(value) { + let hash = 0; + const text = String(value || ''); + for (let i = 0; i < text.length; i++) + hash = (hash * 31 + text.charCodeAt(i)) >>> 0; + const category = categoryOf(text); + // Objects sit in the cyan-blue band so they never blend into the + // Mapillary-green coverage lines; signs lean warm, markings violet. + const base = + category === 'sign' + ? 20 + : category === 'marking' + ? 280 + : category === 'construction' + ? 45 + : 205; + const hue = (base + (hash % 60) - 30 + 360) % 360; + return `hsl(${hue} 90% 60%)`; +} + +/** + * Merge per-value counts whose human labels coincide (regional sign variants + * such as `regulatory--stop--g1` and `--g3`), summing counts and keeping the + * first value's colour. Input and output are sorted by count, descending. + * @param {Iterable<[string, number]>} counts + * @returns {Array<{value: string, values: string[], count: number, label: string, color: string}>} + */ +export function groupCountsByLabel(counts) { + const groups = new Map(); + for (const [value, count] of counts) { + const label = humanizeValue(value); + const group = groups.get(label); + if (group) { + group.count += count; + group.values.push(value); + } else + groups.set(label, { + value, + values: [value], + count, + label, + color: colorForValue(value), + }); + } + return [...groups.values()].sort((a, b) => b.count - a.count); +} diff --git a/src/layers/mapillary/values.test.mjs b/src/layers/mapillary/values.test.mjs new file mode 100644 index 000000000..d2e17c860 --- /dev/null +++ b/src/layers/mapillary/values.test.mjs @@ -0,0 +1,96 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + categoryOf, + groupCountsByLabel, + colorForValue, + createValueMatcher, + humanizeValue, + isSignValue, + normalizeValuePattern, +} from './values.js'; + +test('normalizeValuePattern folds case, spaces and stray characters', () => { + assert.equal( + normalizeValuePattern(' Object--Fire Hydrant '), + 'object--fire-hydrant', + ); + assert.equal( + normalizeValuePattern('regulatory--stop--*'), + 'regulatory--stop--*', + ); + assert.equal(normalizeValuePattern('drop table;'), 'drop-table'); + assert.equal(normalizeValuePattern(null), ''); +}); + +test('createValueMatcher matches exact values and wildcards only', () => { + const matches = createValueMatcher([ + 'object--fire-hydrant', + 'regulatory--stop--*', + ]); + assert.equal(matches('object--fire-hydrant'), true); + assert.equal(matches('regulatory--stop--g1'), true); + assert.equal(matches('regulatory--stop--g3'), true); + assert.equal(matches('regulatory--stop-ahead--g1'), false); + assert.equal(matches('object--bench'), false); + assert.equal(matches(undefined), false); +}); + +test('an empty pattern list matches everything', () => { + const matches = createValueMatcher([]); + assert.equal(matches('anything--at-all'), true); +}); + +test('a wildcard only spans the segment it is placed in', () => { + const matches = createValueMatcher(['object--sign--*']); + assert.equal(matches('object--sign--store'), true); + assert.equal(matches('object--signal'), false); + // Regex metacharacters are stripped by normalization, never interpreted. + assert.equal( + createValueMatcher(['object--sign.*'])('object--sign--store'), + true, + ); +}); + +test('isSignValue and categoryOf split the taxonomy by family', () => { + assert.equal(isSignValue('regulatory--stop--g1'), true); + assert.equal(isSignValue('warning--pedestrians-crossing--g4'), true); + assert.equal(isSignValue('object--fire-hydrant'), false); + assert.equal(categoryOf('object--fire-hydrant'), 'object'); + assert.equal(categoryOf('marking--discrete--stop-line'), 'marking'); + assert.equal(categoryOf('information--general-directions--g1'), 'sign'); +}); + +test('humanizeValue produces readable labels', () => { + assert.equal(humanizeValue('object--fire-hydrant'), 'Fire hydrant'); + assert.equal(humanizeValue('regulatory--stop--g1'), 'Stop (regulatory)'); + assert.equal( + humanizeValue('object--traffic-light--general-upright'), + 'Traffic light · General upright', + ); + assert.equal(humanizeValue(''), 'Unknown'); +}); + +test('colorForValue is stable and category-shifted', () => { + assert.equal(colorForValue('object--bench'), colorForValue('object--bench')); + assert.match(colorForValue('regulatory--stop--g1'), /^hsl\(\d+ 90% 60%\)$/); +}); + +test('groupCountsByLabel merges regional sign variants under one label', () => { + const groups = groupCountsByLabel([ + ['regulatory--stop--g1', 100], + ['object--bench', 30], + ['regulatory--stop--g3', 5], + ]); + assert.deepEqual( + groups.map(({ label, count, values }) => ({ label, count, values })), + [ + { + label: 'Stop (regulatory)', + count: 105, + values: ['regulatory--stop--g1', 'regulatory--stop--g3'], + }, + { label: 'Bench', count: 30, values: ['object--bench'] }, + ], + ); +}); diff --git a/src/layers/mapillary/viewerBridge.js b/src/layers/mapillary/viewerBridge.js new file mode 100644 index 000000000..c28e76143 --- /dev/null +++ b/src/layers/mapillary/viewerBridge.js @@ -0,0 +1,361 @@ +import * as Cesium from 'cesium'; +import { decodeDetectionPolygons } from './decode.js'; + +/** Eye height above the sampled ground when the globe camera follows the viewer. */ +const FOLLOW_EYE_HEIGHT_M = 2.4; + +/** + * Own the embedded MapillaryJS viewer: lazy-load the library, open images, + * mirror its pose onto the globe, and optionally drive the Cesium camera + * from it ("street cockpit"). + */ +export function createViewerBridge({ state, source, parts }) { + const { render } = state.services; + let viewer = null; + let Library = null; + let pendingOpen = null; + let prewarming = false; + /** In-flight viewer construction, so pre-warm and open never build two. */ + let creating = null; + + function requestRender() { + render?.governorRequestRender?.('mapillary-viewer'); + } + + function libraryRenderMode(mode) { + const { RenderMode } = Library || {}; + if (!RenderMode) return undefined; + return mode === 'fill' ? RenderMode.Fill : RenderMode.Letterbox; + } + + async function ensureLibrary() { + if (Library) return Library; + // The viewer stylesheet is vendored into src/ui/styles/mapillary-js.css. + Library = await import('mapillary-js'); + return Library; + } + + function groundHeightAt(lon, lat, fallback) { + const scene = state.viewer?.scene; + const carto = Cesium.Cartographic.fromDegrees(lon, lat); + let height = null; + try { + if (scene?.sampleHeightSupported) height = scene.sampleHeight(carto); + } catch { + /* not sampleable yet */ + } + if (!Number.isFinite(height)) height = scene?.globe?.getHeight?.(carto); + if (!Number.isFinite(height)) height = fallback; + return Number.isFinite(height) ? height : 0; + } + + /** Put the Cesium camera where the street-level camera is. */ + function followCamera() { + const { position, bearing, tilt } = state.street; + if (!state.street.follow || !position || !state.viewer) return; + const ground = groundHeightAt( + position.lon, + position.lat, + state.street.altitude, + ); + state.viewer.camera.setView({ + destination: Cesium.Cartesian3.fromDegrees( + position.lon, + position.lat, + ground + FOLLOW_EYE_HEIGHT_M, + ), + orientation: { + heading: Cesium.Math.toRadians(bearing || 0), + pitch: Cesium.Math.toRadians(Number.isFinite(tilt) ? tilt : 0), + roll: 0, + }, + }); + requestRender(); + } + + /** Frame the current image from a short distance behind it. */ + function lookAtPosition() { + const { position, bearing } = state.street; + if (!position || !state.viewer) return; + const ground = groundHeightAt( + position.lon, + position.lat, + state.street.altitude, + ); + state.viewer.camera.flyToBoundingSphere( + new Cesium.BoundingSphere( + Cesium.Cartesian3.fromDegrees(position.lon, position.lat, ground + 2), + 4, + ), + { + offset: new Cesium.HeadingPitchRange( + Cesium.Math.toRadians(bearing || 0), + Cesium.Math.toRadians(-32), + 140, + ), + duration: 1.6, + }, + ); + } + + async function publishPose(image) { + if (!viewer) return; + try { + const [lngLat, pov] = await Promise.all([ + viewer.getPosition(), + viewer.getPointOfView(), + ]); + state.street.position = { lon: lngLat.lng, lat: lngLat.lat }; + state.street.bearing = pov?.bearing ?? state.street.bearing; + state.street.tilt = pov?.tilt ?? 0; + if (image) { + if (state.street.imageId && state.street.imageId !== image.id) + clearHighlight(); + state.street.imageId = image.id; + state.street.isPano = image.merged + ? image.cameraType === 'spherical' + : image.cameraType === 'spherical'; + state.street.capturedAt = image.capturedAt ?? null; + state.street.sequenceId = image.sequenceId ?? null; + state.street.altitude = Number.isFinite(image.computedAltitude) + ? image.computedAltitude + : Number.isFinite(image.originalAltitude) + ? image.originalAltitude + : null; + state.street.creator = image.creatorUsername || null; + // mapillary.com highlights the selected image's sequence on the map; + // mirror that once the image itself is on screen, so the sequence + // lookup never competes with the image download. + if (!state.street.loading) selectCurrentSequence(); + } + parts.sequences.setMarker(state.street.position, state.street.bearing); + followCamera(); + state.notify?.(); + } catch { + /* viewer torn down mid-flight */ + } + } + + function selectCurrentSequence() { + const { sequenceId } = state.street; + if (sequenceId && state.enabled && sequenceId !== state.sequence.selectedId) + parts.sequences.select(sequenceId); + } + + /** + * Load the library and stand the viewer up ahead of the first image, so + * opening one only costs the image download. Safe to call repeatedly. + */ + async function prewarm(container = state.street.host) { + if (prewarming || !state.enabled) return; + prewarming = true; + try { + await ensureLibrary(); + if (container && state.enabled && !viewer) await ensureViewer(container); + } catch { + /* the real open reports errors */ + } finally { + prewarming = false; + } + } + + function ensureViewer(container) { + if (viewer && state.street.container === container) return viewer; + if (creating?.container === container) return creating.promise; + const promise = createViewer(container).finally(() => { + if (creating?.promise === promise) creating = null; + }); + creating = { container, promise }; + return promise; + } + + async function createViewer(container) { + destroyViewer(); + const { Viewer } = await ensureLibrary(); + viewer = new Viewer({ + accessToken: source.token, + container, + component: { + cover: false, + bearing: true, + zoom: true, + attribution: true, + tag: true, + }, + trackResize: true, + renderMode: libraryRenderMode(state.street.renderMode), + }); + state.street.container = container; + viewer.on('image', (event) => publishPose(event.image)); + viewer.on('pov', () => publishPose(null)); + viewer.on('position', () => publishPose(null)); + return viewer; + } + + /** + * Open an image in the viewer inside `container`. Returns once the first + * image has loaded; the pose then streams through `state.street`. + */ + async function open(imageId, container, { frame = true } = {}) { + if (!imageId || !container) return; + const id = String(imageId); + state.street.loading = true; + state.street.error = null; + state.street.open = true; + state.notify?.(); + pendingOpen = id; + try { + const instance = await ensureViewer(container); + if (pendingOpen !== id) return; + const image = await instance.moveTo(id); + if (pendingOpen !== id) return; + await publishPose(image); + if (frame && !state.street.follow) lookAtPosition(); + } catch (error) { + if (pendingOpen === id) + state.street.error = error?.message || 'Image could not be opened'; + } finally { + if (pendingOpen === id) state.street.loading = false; + state.notify?.(); + } + if (pendingOpen === id && !state.street.error) selectCurrentSequence(); + } + + function clearHighlight() { + if (!state.street.highlight) return; + state.street.highlight = null; + try { + viewer?.getComponent('tag')?.removeAll(); + } catch { + /* component not ready */ + } + } + + /** + * Outline every detection of `value` inside the current image using the + * MapillaryJS tag component. Detections are per image on the Graph API; + * their geometry is a small vector tile in image-normalized coordinates. + */ + async function highlightDetections(imageId, value, label) { + if (!viewer || !imageId) return 0; + const { OutlineTag, PolygonGeometry } = Library || {}; + let component; + try { + component = viewer.getComponent('tag'); + component.removeAll(); + } catch { + return 0; + } + state.street.highlight = { value, count: 0, loading: true }; + state.notify?.(); + let detections = []; + try { + detections = await source.getImageDetections(imageId); + } catch { + detections = []; + } + if (pendingOpen !== String(imageId) || !viewer) return 0; + const tags = []; + for (const detection of detections) { + if (value && detection.value !== value) continue; + decodeDetectionPolygons(detection.geometry).forEach((polygon, index) => { + try { + tags.push( + new OutlineTag( + `mly-det-${detection.id}-${index}`, + new PolygonGeometry(polygon), + { + lineColor: 0x00d4ff, + lineWidth: 3, + fillColor: 0x00d4ff, + fillOpacity: 0.22, + text: tags.length === 0 ? label || undefined : undefined, + textColor: 0xffffff, + }, + ), + ); + } catch { + /* degenerate polygon */ + } + }); + } + if (tags.length) component.add(tags); + state.street.highlight = { value, count: tags.length, loading: false }; + state.notify?.(); + return tags.length; + } + + /** Switch between showing the whole image ('letterbox') and cropping ('fill'). */ + function setRenderMode(mode) { + state.street.renderMode = mode === 'fill' ? 'fill' : 'letterbox'; + try { + const value = libraryRenderMode(state.street.renderMode); + if (viewer && value !== undefined) viewer.setRenderMode(value); + } catch { + /* viewer not ready */ + } + state.notify?.(); + } + + function setFollow(enabled) { + state.street.follow = enabled === true; + if (state.street.follow) followCamera(); + state.notify?.(); + } + + function resize() { + try { + viewer?.resize(); + } catch { + /* no-op */ + } + } + + function destroyViewer() { + if (viewer) { + try { + viewer.remove(); + } catch { + /* already removed */ + } + } + viewer = null; + state.street.container = null; + } + + /** Close the image. The viewer instance is kept warm for the next open. */ + function close() { + pendingOpen = null; + clearHighlight(); + Object.assign(state.street, { + open: false, + follow: false, + imageId: null, + sequenceId: null, + position: null, + bearing: null, + tilt: null, + loading: false, + error: null, + highlight: null, + feature: null, + }); + parts.sequences.setMarker(null); + state.notify?.(); + } + + return { + open, + close, + setFollow, + setRenderMode, + resize, + prewarm, + lookAtPosition, + highlightDetections, + destroy() { + close(); + destroyViewer(); + }, + }; +} diff --git a/src/standalone/layerSources.js b/src/standalone/layerSources.js index f0bff95cc..2a4cc63f6 100644 --- a/src/standalone/layerSources.js +++ b/src/standalone/layerSources.js @@ -13,6 +13,7 @@ import { createSatelliteSource } from '../layers/satellites/source.js'; import { createLaunchSource } from '../layers/launches/source.js'; import { createOverpassAlprSource } from '../layers/alpr/source.js'; import { createFirmsSource } from '../layers/firms/source.js'; +import { createMapillarySource } from '../layers/mapillary/source.js'; import { createReferenceSources } from '../sources/reference.js'; export { createReferenceSources as createStandaloneReferenceSources } from '../sources/reference.js'; @@ -35,5 +36,8 @@ export function createStandaloneLayerSources() { launches: createLaunchSource(), alpr: createOverpassAlprSource(), firms: createFirmsSource(), + mapillary: createMapillarySource({ + token: import.meta.env?.MAPILLARY_CLIENT_TOKEN || '', + }), }; } diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js index c5d07b098..8408b2137 100644 --- a/src/ui/layerPanel.js +++ b/src/ui/layerPanel.js @@ -28,7 +28,7 @@ const PANEL_GROUPS = [ }, { label: 'Cameras', - ids: ['cctv', 'alpr-cameras'], + ids: ['cctv', 'mapillary', 'alpr-cameras'], }, { label: 'Infrastructure', @@ -58,6 +58,7 @@ const PANEL_LABELS = { 'ais-live-vessels': 'Live Vessels', bikeshare: 'Bike Share', cctv: 'Cameras', + mapillary: 'Street Level (Mapillary)', 'alpr-cameras': 'Mapped ALPR Cameras', 'local-datacenters': 'Data Centers', 'local-firms': 'Active Fires', diff --git a/src/ui/mapillaryControls.js b/src/ui/mapillaryControls.js new file mode 100644 index 000000000..dabda83ac --- /dev/null +++ b/src/ui/mapillaryControls.js @@ -0,0 +1,586 @@ +import { makeFloating } from './floatingWindow.js'; + +const DOCK_STORAGE_KEY = 'gev:mapillary-dock:collapsed'; +const DOCK_MINIMIZED_KEY = 'gev:mapillary-dock:minimized'; +const DOCK_WINDOW_KEY = 'gev:mapillary-dock:window:v1'; +const RENDER_MODE_KEY = 'gev:mapillary:render-mode'; +const MAPILLARY_APP_URL = 'https://www.mapillary.com/app/'; + +/** Deep link to an image on mapillary.com, as the web app shares them. */ +export function mapillaryImageUrl(imageId) { + const id = String(imageId || '').trim(); + if (!id) return MAPILLARY_APP_URL; + return `${MAPILLARY_APP_URL}?pKey=${encodeURIComponent(id)}&focus=photo`; +} + +function formatDate(ms) { + if (!Number.isFinite(ms)) return ''; + try { + return new Date(ms).toISOString().slice(0, 10); + } catch { + return ''; + } +} + +/** + * Own the Mapillary dock: the AI query box, result chips, progress, the + * street-level viewer host and its controls. Receives the layer instance and + * application actions; never reaches into the layer's internals. + */ +export class MapillaryControls { + constructor({ root, mapillary, actions }) { + this.root = root; + this.mapillary = mapillary; + this.actions = actions; + this.destroyed = false; + this.listeners = new AbortController(); + this._unsubscribe = null; + this._state = null; + this._lastStage = null; + this._elements = this._collect(); + this._bind(); + this._restoreCollapsed(); + this._restoreMinimized(); + } + + _collect() { + const byId = (id) => this.root?.querySelector(`#${id}`) || null; + return { + toggle: byId('mly-dock-toggle'), + body: byId('mly-body'), + statusChip: byId('mly-status-chip'), + enableBtn: byId('mly-enable-btn'), + minimizeBtn: byId('mly-minimize-btn'), + minimized: byId('mly-minimized'), + minimizedBadge: byId('mly-minimized-badge'), + form: byId('mly-query-form'), + input: byId('mly-query-input'), + runBtn: byId('mly-query-run'), + answer: byId('mly-answer'), + progress: byId('mly-progress'), + progressFill: byId('mly-progress-fill'), + progressLabel: byId('mly-progress-label'), + results: byId('mly-results'), + chips: byId('mly-result-chips'), + frameBtn: byId('mly-frame-btn'), + threeDBtn: byId('mly-3d-btn'), + threeDNote: byId('mly-3d-note'), + photorealBtn: byId('mly-photoreal-btn'), + sinceSelect: byId('mly-since'), + aiHint: byId('mly-ai-hint'), + aiModel: byId('mly-ai-model'), + clearBtn: byId('mly-clear-btn'), + lookBtn: byId('mly-look-btn'), + followBtn: byId('mly-follow-btn'), + closeBtn: byId('mly-viewer-close'), + viewerWrap: byId('mly-viewer-wrap'), + viewerExpand: byId('mly-viewer-expand'), + viewerFit: byId('mly-viewer-fit'), + imageBy: byId('mly-image-by'), + imageWhen: byId('mly-image-when'), + imageLink: byId('mly-image-link'), + header: this.root?.querySelector('.mly-header') || null, + viewer: byId('mly-viewer'), + imageMeta: byId('mly-image-meta'), + coverageMeta: byId('mly-coverage-meta'), + }; + } + + listen(target, type, handler, options = {}) { + target?.addEventListener(type, handler, { + ...options, + signal: this.listeners.signal, + }); + } + + _bind() { + const el = this._elements; + if (!this.root) return; + this.mapillary.attachViewerHost?.(el.viewer); + this.listen(el.toggle, 'click', () => + this.setCollapsed(!this.root.classList.contains('collapsed')), + ); + // Portable, resizable window: drag the header, resize from the grip. + this._floating = makeFloating(this.root, { + handle: el.header, + storageKey: DOCK_WINDOW_KEY, + dragThrough: '.mly-title-btn', + minWidth: 320, + minHeight: 220, + onChange: () => this.mapillary.resizeViewer?.(), + }); + this.listen(el.viewerExpand, 'click', () => + this.setViewerExpanded( + !this.root.classList.contains('mly-viewer-expanded'), + ), + ); + this.listen(document, 'keydown', (event) => { + if ( + event.key === 'Escape' && + this.root.classList.contains('mly-viewer-expanded') + ) + this.setViewerExpanded(false); + }); + this.listen(el.viewerFit, 'click', () => { + const next = + this._state?.street?.renderMode === 'fill' ? 'letterbox' : 'fill'; + this.mapillary.setViewerRenderMode?.(next); + try { + localStorage.setItem(RENDER_MODE_KEY, next); + } catch { + /* storage unavailable */ + } + }); + try { + const stored = localStorage.getItem(RENDER_MODE_KEY); + if (stored === 'fill' || stored === 'letterbox') + this.mapillary.setViewerRenderMode?.(stored); + } catch { + /* storage unavailable */ + } + // MapillaryJS only tracks window resizes; the dock resizes on its own + // (drag grip, expand, reflow), so watch the host element directly. + if (typeof ResizeObserver === 'function' && el.viewer) { + let queued = false; + this._resizeObserver = new ResizeObserver(() => { + if (queued) return; + queued = true; + requestAnimationFrame(() => { + queued = false; + this.mapillary.resizeViewer?.(); + }); + }); + this._resizeObserver.observe(el.viewer); + } + this.listen(el.minimizeBtn, 'click', () => this.setMinimized(true)); + this.listen(el.minimized, 'click', () => this.setMinimized(false)); + this.listen(el.enableBtn, 'click', () => this._toggleEnabled()); + this.listen(el.form, 'submit', (event) => { + event.preventDefault(); + this._run(el.input?.value); + }); + for (const button of this.root.querySelectorAll('[data-mly-suggestion]')) { + this.listen(button, 'click', () => { + if (el.input) el.input.value = button.dataset.mlySuggestion || ''; + this._run(button.dataset.mlySuggestion); + }); + } + this.listen(el.frameBtn, 'click', () => this.mapillary.frameResults?.()); + this.listen(el.clearBtn, 'click', () => this.mapillary.clearQuery?.()); + this.listen(el.lookBtn, 'click', async () => { + if (!(await this._ensureEnabled())) return; + this.setCollapsed(false); + this.mapillary.openNearest?.(); + }); + this.listen(el.followBtn, 'click', () => { + const next = !(this._state?.street?.follow === true); + this.mapillary.setFollow?.(next); + }); + this.listen(el.closeBtn, 'click', () => this.mapillary.closeViewer?.()); + this.listen(el.threeDBtn, 'click', () => { + const next = !(this._state?.objects3d?.enabled === true); + this.mapillary.setObjects3d?.(next); + }); + for (const button of this.root.querySelectorAll('[data-mly-pano]')) { + this.listen(button, 'click', () => + this.mapillary.setCoverageFilter?.({ pano: button.dataset.mlyPano }), + ); + } + this.listen(el.sinceSelect, 'change', () => { + const value = String(el.sinceSelect.value || '0'); + let sinceMs = null; + if (value.startsWith('year:')) + sinceMs = Date.UTC(Number(value.slice(5)), 0, 1); + else if (Number(value) > 0) + sinceMs = Date.now() - Number(value) * 86_400_000; + this.mapillary.setCoverageFilter?.({ sinceMs }); + }); + this.listen(el.photorealBtn, 'click', async () => { + const result = await this.actions.setMapStack?.('photoreal'); + if (result?.error) this.actions.showToast?.(result.error); + }); + // Keep the app's keyboard shortcuts from firing while typing a query. + this.listen(el.input, 'keydown', (event) => event.stopPropagation()); + this.listen(el.input, 'keyup', (event) => event.stopPropagation()); + } + + async _ensureEnabled() { + if (this.actions.isEnabled?.()) return true; + try { + await this.actions.setEnabled?.(true); + } catch (error) { + this.actions.showToast?.( + error?.message || 'Street Level could not start', + ); + return false; + } + return this.actions.isEnabled?.() === true; + } + + async _toggleEnabled() { + const enabled = this.actions.isEnabled?.() === true; + try { + await this.actions.setEnabled?.(!enabled); + } catch (error) { + this.actions.showToast?.(error?.message || 'Street Level toggle failed'); + } + if (!enabled) this.setCollapsed(false); + } + + async _run(prompt) { + const text = String(prompt || '').trim(); + if (!text) return; + if (!(await this._ensureEnabled())) return; + this.setCollapsed(false); + this.mapillary.runQuery?.(text); + } + + connect() { + this._unsubscribe?.(); + this._unsubscribe = null; + if (this.destroyed || !this.root) return; + this._unsubscribe = this.mapillary.subscribe?.((state) => + this.render(state), + ); + if (this.mapillary.getUIState) this.render(this.mapillary.getUIState()); + } + + _restoreCollapsed() { + // Open by default: the typed Mapillary AI box is the front door. + let collapsed = false; + try { + const stored = localStorage.getItem(DOCK_STORAGE_KEY); + if (stored === '1') collapsed = true; + } catch { + /* storage unavailable */ + } + this.setCollapsed(collapsed, { persist: false }); + } + + _restoreMinimized() { + let minimized = false; + try { + minimized = localStorage.getItem(DOCK_MINIMIZED_KEY) === '1'; + } catch { + /* storage unavailable */ + } + this.setMinimized(minimized, { persist: false }); + } + + /** + * Shrink the whole panel to a round Mapillary logo, or bring it back. + * The logo carries the layer's on/off state and a result-count badge. + */ + setMinimized(minimized, { persist = true } = {}) { + if (!this.root) return; + const on = minimized === true; + if (on) this.setViewerExpanded(false); + this.root.classList.toggle('minimized', on); + if (this._elements.minimized) this._elements.minimized.hidden = !on; + this.root.setAttribute('aria-expanded', String(!on)); + if (!on) { + this.setCollapsed(false, { persist }); + requestAnimationFrame(() => this.mapillary.resizeViewer?.()); + } + if (persist) { + try { + localStorage.setItem(DOCK_MINIMIZED_KEY, on ? '1' : '0'); + } catch { + /* storage unavailable */ + } + } + this._renderMinimized(this._state); + } + + isMinimized() { + return this.root?.classList.contains('minimized') === true; + } + + _renderMinimized(state) { + const el = this._elements; + if (!el.minimized) return; + const enabled = state?.enabled === true; + el.minimized.classList.toggle('is-off', !enabled); + const total = state?.features?.total || 0; + if (el.minimizedBadge) { + el.minimizedBadge.hidden = !(enabled && total > 0); + el.minimizedBadge.textContent = + total >= 1000 ? `${Math.round(total / 1000)}k` : String(total); + } + el.minimized.title = enabled + ? `Open Mapillary · Street Level on${total ? ` · ${total.toLocaleString()} results` : ''}` + : 'Open Mapillary'; + } + + /** Grow the street-level viewer to most of the screen, or shrink it back. */ + setViewerExpanded(expanded) { + if (!this.root) return; + const on = expanded === true; + const wrap = this._elements.viewerWrap; + if (on === this.root.classList.contains('mly-viewer-expanded')) return; + this.root.classList.toggle('mly-viewer-expanded', on); + if (wrap) { + if (on) { + // Lift the viewer out of the dock: the dock's backdrop-filter would + // otherwise pin a fixed-position child inside the panel. + this._wrapHome = { parent: wrap.parentNode, next: wrap.nextSibling }; + document.body.appendChild(wrap); + wrap.classList.add('mly-viewer-wrap-expanded'); + } else if (this._wrapHome?.parent) { + wrap.classList.remove('mly-viewer-wrap-expanded'); + this._wrapHome.parent.insertBefore(wrap, this._wrapHome.next); + this._wrapHome = null; + } + } + const button = this._elements.viewerExpand; + if (button) { + const icon = button.querySelector('.mly-btn-icon'); + const text = button.querySelector('.mly-btn-text'); + if (icon) icon.textContent = on ? '⤡' : '⤢'; + if (text) text.textContent = on ? 'SHRINK' : 'EXPAND'; + button.setAttribute('aria-pressed', String(on)); + button.setAttribute('aria-label', on ? 'Shrink' : 'Expand'); + } + requestAnimationFrame(() => this.mapillary.resizeViewer?.()); + } + + setCollapsed(collapsed, { persist = true } = {}) { + if (!this.root) return; + this.root.classList.toggle('collapsed', collapsed); + this._elements.toggle?.setAttribute('aria-expanded', String(!collapsed)); + this._elements.toggle?.setAttribute( + 'title', + collapsed ? 'Expand Mapillary' : 'Collapse Mapillary', + ); + if (!collapsed) this.mapillary.resizeViewer?.(); + if (persist) { + try { + localStorage.setItem(DOCK_STORAGE_KEY, collapsed ? '1' : '0'); + } catch { + /* storage unavailable */ + } + } + } + + render(state) { + if (this.destroyed || !state || !this.root) return; + this._state = state; + const el = this._elements; + const enabled = state.enabled === true; + + el.enableBtn.textContent = enabled ? 'STREET LEVEL ON' : 'STREET LEVEL OFF'; + el.enableBtn.setAttribute('aria-pressed', String(enabled)); + + let chip = enabled ? 'ON' : 'OFF'; + let chipClass = enabled ? 'is-on' : ''; + if (state.keyRequired) { + chip = 'KEY REQUIRED'; + chipClass = 'is-warn'; + } else if (state.query.busy) { + chip = `AI · ${state.query.stage.toUpperCase()}`; + chipClass = 'is-busy'; + } else if (state.coverage.loading || state.features.loading) { + chip = 'LOADING'; + chipClass = 'is-busy'; + } + el.statusChip.textContent = chip; + el.statusChip.className = `mly-chip ${chipClass}`.trim(); + + if (enabled && this._wasEnabled === false) this.setCollapsed(false); + this._wasEnabled = enabled; + this._renderMinimized(state); + // Imagery needs the panel: opening an image brings a minimized dock back. + if (state.street.open && !this._wasStreetOpen && this.isMinimized()) + this.setMinimized(false); + this._wasStreetOpen = state.street.open === true; + + el.runBtn.disabled = state.query.busy; + el.runBtn.textContent = state.query.busy ? '…' : 'ASK'; + el.input.disabled = false; + el.input.placeholder = state.planner + ? 'Type a question, e.g. show me all fire hydrants in Sacramento' + : 'Add an Anthropic key in POWER UP to enable Mapillary AI'; + if (el.aiModel) + el.aiModel.textContent = state.plannerModel + ? `· ${state.plannerModel}` + : ''; + if (el.aiHint) { + el.aiHint.classList.toggle('is-warn', !state.planner); + el.aiHint.textContent = state.planner + ? 'Type what you want to see in plain English — objects, traffic signs, coverage or a street view of any place — and press Enter. Follow-ups refine the last answer.' + : 'Mapillary AI is off until an Anthropic key is added in POWER UP. Coverage, sequences and the viewer work without it.'; + } + + // Imagery filters. + const filter = state.coverage.filter || { pano: 'all', sinceMs: null }; + for (const button of this.root.querySelectorAll('[data-mly-pano]')) { + const active = button.dataset.mlyPano === filter.pano; + button.classList.toggle('is-active', active); + button.setAttribute('aria-checked', String(active)); + } + + // 3D objects. + const objects = state.objects3d || {}; + el.threeDBtn.setAttribute('aria-pressed', String(objects.enabled === true)); + el.threeDBtn.textContent = objects.building + ? '3D OBJECTS …' + : objects.enabled + ? '3D OBJECTS ON' + : '3D OBJECTS OFF'; + el.threeDNote.hidden = !( + objects.enabled && + (objects.error || objects.active) + ); + el.threeDNote.textContent = objects.error + ? objects.error + : objects.active + ? `${objects.count.toLocaleString()} objects standing in 3D · click one to open its image` + : ''; + + el.photorealBtn.setAttribute( + 'aria-pressed', + String(this.actions.isMapStackActive?.('photoreal') === true), + ); + + // Answer / error line. + const error = state.query.error || state.street.error; + if (error) { + el.answer.textContent = error; + el.answer.classList.add('is-error'); + } else { + el.answer.classList.remove('is-error'); + if (state.query.answer) el.answer.textContent = state.query.answer; + else if (state.query.busy) el.answer.textContent = 'Planning…'; + else if (!enabled) + el.answer.textContent = + 'Turn on Street Level to see Mapillary coverage, then ask for anything that appears in street imagery.'; + else if (state.coverage.hint) el.answer.textContent = state.coverage.hint; + else + el.answer.textContent = + 'Click a green sequence to see its images, or ask Mapillary AI for objects and signs.'; + } + + // Progress bar while tiles stream. + const { progress } = state.features; + const showProgress = state.features.loading && progress.tiles > 0; + el.progress.hidden = !showProgress; + if (showProgress) { + const pct = Math.round((progress.done / progress.tiles) * 100); + el.progressFill.style.width = `${pct}%`; + el.progressLabel.textContent = `${progress.done}/${progress.tiles} tiles · ${state.features.total.toLocaleString()} found`; + } + + // Result chips. + const hasResults = state.features.total > 0 || state.features.hasBbox; + el.results.hidden = !hasResults; + if (hasResults) { + el.chips.replaceChildren( + ...state.features.counts + .slice(0, 8) + .map(({ value, count, label, color }) => { + const chipEl = document.createElement('span'); + chipEl.className = 'mly-value-chip'; + chipEl.title = value; + const dot = document.createElement('i'); + dot.style.background = color; + const text = document.createElement('span'); + text.textContent = label; + const num = document.createElement('b'); + num.textContent = count.toLocaleString(); + chipEl.append(dot, text, num); + return chipEl; + }), + ); + if (state.features.counts.length > 8) { + const more = document.createElement('span'); + more.className = 'mly-value-chip'; + more.textContent = `+${state.features.counts.length - 8} classes`; + el.chips.append(more); + } + } + + // Street-level viewer. + const { street } = state; + el.viewerWrap.hidden = !street.open; + this.root.classList.toggle('mly-has-viewer', street.open === true); + if (!street.open && this.root.classList.contains('mly-viewer-expanded')) + this.setViewerExpanded(false); + el.followBtn.setAttribute('aria-pressed', String(street.follow === true)); + el.followBtn.textContent = street.follow + ? 'STREET COCKPIT ON' + : 'STREET COCKPIT OFF'; + el.followBtn.disabled = !street.open; + if (el.viewerFit) { + const fill = street.renderMode === 'fill'; + el.viewerFit.textContent = fill ? 'FILL' : 'FIT'; + el.viewerFit.title = fill + ? 'Filling the frame (cropped) · click to show the whole image' + : 'Showing the whole image · click to fill the frame'; + } + if (street.open) { + // Caption bar as on mapillary.com: "Image by …" left, date right. + const left = []; + if (street.loading) left.push('LOADING…'); + if (street.feature?.label) + left.push( + `${street.feature.label}${street.feature.imageCount ? ` · ${street.feature.imageCount} sightings` : ''}`, + ); + if (street.highlight?.count) + left.push( + `${street.highlight.count} detection${street.highlight.count === 1 ? '' : 's'} outlined`, + ); + if (street.creator) left.push(`Image by ${street.creator}`); + const right = []; + if (street.isPano) right.push('360°'); + if (Number.isFinite(street.bearing)) + right.push(`${Math.round(street.bearing)}°`); + if (street.capturedAt) right.push(formatDate(street.capturedAt)); + if (el.imageBy) el.imageBy.textContent = left.join(' · '); + if (el.imageWhen) el.imageWhen.textContent = right.join(' · '); + if (el.imageLink) { + el.imageLink.href = mapillaryImageUrl(street.imageId); + el.imageLink.hidden = !street.imageId; + } + this.mapillary.resizeViewer?.(); + } + + // Coverage meta line. + const coverageBits = []; + if (enabled && state.coverage.zoom) + coverageBits.push( + `coverage z${state.coverage.zoom} · ${state.coverage.sequences.toLocaleString()} sequences`, + ); + if (state.sequence.selectedId) + coverageBits.push( + state.sequence.loading + ? 'loading sequence…' + : `${state.sequence.images} images in sequence · Esc clears`, + ); + if (state.query.usage?.model) + coverageBits.push( + `${state.query.usage.model} · ${state.query.usage.input_tokens ?? '?'}→${state.query.usage.output_tokens ?? '?'} tok`, + ); + el.coverageMeta.textContent = coverageBits.join(' · '); + + if (state.query.stage !== this._lastStage) { + this._lastStage = state.query.stage; + if (state.query.stage === 'done' || state.query.stage === 'error') { + this.setCollapsed(false); + if (this.isMinimized()) this.setMinimized(false); + } + } + } + + destroy() { + if (this.destroyed) return; + this.destroyed = true; + this.setViewerExpanded(false); + this.listeners.abort(); + this._resizeObserver?.disconnect(); + this._resizeObserver = null; + this._floating?.destroy(); + this._unsubscribe?.(); + this._unsubscribe = null; + this.mapillary.attachViewerHost?.(null); + } +} diff --git a/src/ui/shellElements.js b/src/ui/shellElements.js index a666dcc88..065380dfb 100644 --- a/src/ui/shellElements.js +++ b/src/ui/shellElements.js @@ -44,6 +44,7 @@ export function readShellElements(document = globalThis.document) { _dataPanel: document.getElementById('data-panel'), _scenePanel: document.getElementById('scene-panel'), _cctvPanel: document.getElementById('cctv-panel'), + _mapillaryDock: document.getElementById('mapillary-dock'), _radioPanel: document.getElementById('radio-panel'), _contextRadioDock: document.getElementById('context-radio-dock'), _contextRadioToggleBtn: document.getElementById('context-radio-toggle-btn'), diff --git a/src/ui/styles/mapillary-js.css b/src/ui/styles/mapillary-js.css new file mode 100644 index 000000000..d38be7cc3 --- /dev/null +++ b/src/ui/styles/mapillary-js.css @@ -0,0 +1,1635 @@ +/* Vendored from mapillary-js@4.1.2 dist/mapillary.css (MIT, Copyright Mapillary). + Loaded up front so the embedded street-level viewer has its chrome the + moment it opens. Regenerate by copying the file from node_modules. */ +.mapillary-attribution-container { + background-color: rgba(0, 0, 0, 0.5); + font-size: 9px; + font-weight: 300; + position: absolute; + bottom: 0; + right: 0; + height: 16px; + line-height: initial; + z-index: 10; + font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", + "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", + "Helvetica Neue", sans-serif; +} + +.mapillary-attribution-image-container { + display: inline-block; + color: white; + text-decoration: none; + margin-left: 8px; + vertical-align: middle; + height: 100%; +} + +.mapillary-attribution-image-container:hover, +.mapillary-attribution-image-container:active, +.mapillary-attribution-image-container:visited { + color: white; +} + +.mapillary-attribution-compact .mapillary-attribution-image-container { + margin-left: 4px; +} + +.mapillary-attribution-username { + display: inline-block; + vertical-align: middle; + line-height: 16px; +} + +.mapillary-attribution-date { + display: inline-block; + vertical-align: middle; + color: white; + line-height: 16px; + margin-right: 8px; + margin-left: 4px; +} + +.mapillary-attribution-compact .mapillary-attribution-date { + margin-right: 6px; +} + +.mapillary-attribution-icon-container { + display: inline-block; + margin-left: 8px; + vertical-align: middle; + height: 100%; +} + +.mapillary-attribution-compact .mapillary-attribution-icon-container { + margin-left: 6px; +} + +.mapillary-attribution-image-container .mapillary-attribution-icon-container { + margin-left: 2px; + margin-right: 2px; +} + +.mapillary-attribution-logo { + margin-top: 3px; + width: 44px; + height: 10px; + background-size: contain; + background-repeat: no-repeat; + background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI2MSIgaGVpZ2h0PSIxNCIgdmlld0JveD0iMCAwIDYxIDE0Ij48ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPiAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTIzLjk5NDgzMDMgMi44MTQ1MjI4NUwyMy45OTQ4MzAzIDExLjMxMzg1MyAyMi4zNzA4NjM3IDExLjMxMzg1MyAyMi4zNzA4NjM3IDYuODkyMjM2MTQgMjAuNTQzOTAxMiAxMS4zMTM4NTMgMTkuNjYwMjcyNCAxMS4zMTM4NTMgMTcuODMzMzA5OSA2Ljg5MjIzNjE0IDE3LjgzMzMwOTkgMTEuMzEzODUzIDE2LjIwOTM0MzMgMTEuMzEzODUzIDE2LjIwOTM0MzMgMi44MTQ1MjI4NSAxNy44ODEwNzM3IDIuODE0NTIyODUgMjAuMTAyMDg2OCA4LjE2OTU5MjExIDIyLjMyMzEgMi44MTQ1MjI4NSAyMy45OTQ4MzAzIDIuODE0NTIyODUgMjMuOTk0ODMwMyAyLjgxNDUyMjg1ek0zMS4wODE5NTg0IDQuNjY5MTQ1NDZMMzIuNTAyOTI5MiA0LjY2OTE0NTQ2IDMyLjUwMjkyOTIgNS4yMjE4NDc1NkMzMi41OTg0NTcyIDUuMDI1MzMwMjggMzIuNzg5NTEwMSA0Ljg1NzQ3NDI4IDMzLjA3NjA5MzkgNC43MTgyNzQ1MyAzMy4zNjI2Nzc3IDQuNTc5MDc0NzkgMzMuNjc3MTE5MSA0LjUwOTQ3NTk2IDM0LjAxOTQyNzUgNC41MDk0NzU5NiAzNC4zNDU4MTQ1IDQuNTA5NDc1OTYgMzQuNjUyMjk1NCA0LjU4NTIxNTg2IDM0LjkzODg3OTEgNC43MzY2OTc5NCAzNS4yMjU0NjI5IDQuODg4MTgwMDEgMzUuNDc0MjI5OCA1LjExMTMwNTY3IDM1LjY4NTE4NzMgNS40MDYwODE2IDM1Ljg5NjE0NDggNS43MDA4NTc1MiAzNi4wNjMzMTYyIDYuMDY1MjI3ODYgMzYuMTg2NzA2NCA2LjQ5OTIwMzUzIDM2LjMxMDA5NjYgNi45MzMxNzkyIDM2LjM3MTc5MDggNy40Mjg1NTkxIDM2LjM3MTc5MDggNy45ODUzNTgwNyAzNi4zNzE3OTA4IDguNTU4NTMzNDkgMzYuMzEyMDg2OCA5LjA2MjEwMTQ4IDM2LjE5MjY3NjkgOS40OTYwNzcxNSAzNi4wNzMyNjcgOS45MzAwNTI4MiAzNS45MTAwNzU5IDEwLjI5NDQyMzIgMzUuNzAzMDk4NyAxMC41ODkxOTkxIDM1LjQ5NjEyMTUgMTAuODgzOTc1IDM1LjI1MTMzNDkgMTEuMTA1MDUzNiAzNC45Njg3MzE1IDExLjI1MjQ0MTYgMzQuNjg2MTI4IDExLjM5OTgyOTYgMzQuMzc3NjU3MSAxMS40NzM1MjI1IDM0LjA0MzMwOTMgMTEuNDczNTIyNSAzMy42ODUwNzk2IDExLjQ3MzUyMjUgMzMuMzk2NTEgMTEuNDE2MjA1OCAzMy4xNzc1OTE4IDExLjMwMTU3MDcgMzIuOTU4NjczNyAxMS4xODY5MzU2IDMyLjgwMTQ1MyAxMS4wNDM2NDM5IDMyLjcwNTkyNTEgMTAuODcxNjkxM0wzMi43MDU5MjUxIDEzLjUyMDU0MjMgMzEuMDgxOTU4NCAxMy44Nzg3ODc2IDMxLjA4MTk1ODQgNC42NjkxNDU0NiAzMS4wODE5NTg0IDQuNjY5MTQ1NDZ6TTMzLjYzNzMxNzcgOS44ODkxMDk3NkMzNC40MjU0MjMgOS44ODkxMDk3NiAzNC44MTk0Njk4IDkuMjU4NjI2MjYgMzQuODE5NDY5OCA3Ljk5NzY0MDM0IDM0LjgxOTQ2OTggNy4zODM1MjM4MiAzNC43MjE5NTMyIDYuOTE2ODAyMjcgMzQuNTI2OTE3IDYuNTk3NDYxNjggMzQuMzMxODgwOCA2LjI3ODEyMTA5IDM0LjA1MTI3MTggNi4xMTg0NTMxOSAzMy42ODUwODE0IDYuMTE4NDUzMTkgMzMuNDE0NDE4OSA2LjExODQ1MzE5IDMzLjE4OTUzMzYgNi4yMTQ2NjMzNCAzMy4wMTA0MTg4IDYuNDA3MDg2NTEgMzIuODMxMzAzOSA2LjU5OTUwOTY5IDMyLjY5Mzk4NDYgNi44MjY3MjkzOSAzMi41OTg0NTY3IDcuMDg4NzUyNDRMMzIuNTk4NDU2NyA4Ljk4MDIyMTg2QzMyLjcwOTkwNTkgOS4yODMxODYgMzIuODU5MTY2MSA5LjUxMDQwNTcxIDMzLjA0NjI0MTYgOS42NjE4ODc3OCAzMy4yMzMzMTcxIDkuODEzMzY5ODYgMzMuNDMwMzQwNSA5Ljg4OTEwOTc2IDMzLjYzNzMxNzcgOS44ODkxMDk3NkwzMy42MzczMTc3IDkuODg5MTA5NzYgMzMuNjM3MzE3NyA5Ljg4OTEwOTc2ek0zOC43MTIyMTMzIDQuNjY5MTQ1NDZMMzguNzEyMjEzMyAxMS4zMTM4NTMgMzcuMDc2MzA1OCAxMS4zMTM4NTMgMzcuMDc2MzA1OCA0LjY2OTE0NTQ2IDM4LjcxMjIxMzMgNC42NjkxNDU0NiAzOC43MTIyMTMzIDQuNjY5MTQ1NDZ6TTM3LjA3NjMwNTggMy4xOTUyNzMxOUMzNy4wNzYzMDU4IDIuOTU3ODE0OCAzNy4xNTE5MzA5IDIuNzUzMTEyMzYgMzcuMzAzMTgzNSAyLjU4MTE1OTc0IDM3LjQ1NDQzNiAyLjQwOTIwNzExIDM3LjY0OTQ2OTMgMi4zMjMyMzIwOSAzNy44ODgyODkxIDIuMzIzMjMyMDkgMzguMTExMTg3NiAyLjMyMzIzMjA5IDM4LjMwNDIzMDcgMi40MDkyMDcxMSAzOC40Njc0MjQyIDIuNTgxMTU5NzQgMzguNjMwNjE3OCAyLjc1MzExMjM2IDM4LjcxMjIxMzMgMi45NTc4MTQ4IDM4LjcxMjIxMzMgMy4xOTUyNzMxOSAzOC43MTIyMTMzIDMuNDI0NTQzMzUgMzguNjMwNjE3OCAzLjYxOTAxMDY3IDM4LjQ2NzQyNDIgMy43Nzg2ODA5NiAzOC4zMDQyMzA3IDMuOTM4MzUxMjYgMzguMTExMTg3NiA0LjAxODE4NTIxIDM3Ljg4ODI4OTEgNC4wMTgxODUyMSAzNy42NDk0NjkzIDQuMDE4MTg1MjEgMzcuNDU0NDM2IDMuOTM4MzUxMjYgMzcuMzAzMTgzNSAzLjc3ODY4MDk2IDM3LjE1MTkzMDkgMy42MTkwMTA2NyAzNy4wNzYzMDU4IDMuNDI0NTQzMzUgMzcuMDc2MzA1OCAzLjE5NTI3MzE5TDM3LjA3NjMwNTggMy4xOTUyNzMxOSAzNy4wNzYzMDU4IDMuMTk1MjczMTl6TTM5LjU2MDAxOTQgMi45MjUwNjMyN0w0MS4xODM5ODYgMi4zNjAwNzg5IDQxLjE4Mzk4NiAxMS4zMTM4NTMgMzkuNTYwMDE5NCAxMS4zMTM4NTMgMzkuNTYwMDE5NCAyLjkyNTA2MzI3IDM5LjU2MDAxOTQgMi45MjUwNjMyN3pNNDIuMDMxNzkyMSAyLjkyNTA2MzI3TDQzLjY1NTc1ODcgMi4zNjAwNzg5IDQzLjY1NTc1ODcgMTEuMzEzODUzIDQyLjAzMTc5MjEgMTEuMzEzODUzIDQyLjAzMTc5MjEgMi45MjUwNjMyNyA0Mi4wMzE3OTIxIDIuOTI1MDYzMjd6TTUyLjQ2Nzg0OTEgNC42NjkxNDU0Nkw1Mi40Njc4NDkxIDUuMTIzNTg5NDFDNTIuNTc5Mjk4MyA0LjkzNTI2MDM0IDUyLjc2MjM5MDggNC43ODU4Mjc1NyA1My4wMTcxMzE5IDQuNjc1Mjg2NTkgNTMuMjcxODczMSA0LjU2NDc0NTYyIDUzLjUzODU1MTIgNC41MDk0NzU5NiA1My44MTcxNzQzIDQuNTA5NDc1OTYgNTQuMDAwMjY5NSA0LjUwOTQ3NTk2IDU0LjE3NzM5MTUgNC41MzgxMzQzIDU0LjM0ODU0NTcgNC41OTU0NTE4NSA1NC41MTk2OTk5IDQuNjUyNzY5MzkgNTQuNjUzMDM5IDQuNzQ2OTMyNTEgNTQuNzQ4NTY2OSA0Ljg3Nzk0NDAzTDU0LjE2MzQ2MTMgNi4zNjQwOTg1N0M1My45NjQ0NDQ4IDYuMTgzOTU3NzMgNTMuNzQ1NTI5OSA2LjA5Mzg4ODY2IDUzLjUwNjcxMDEgNi4wOTM4ODg2NiA1My4zMDc2OTM2IDYuMDkzODg4NjYgNTMuMTI2NTkxMyA2LjE2OTYyODU2IDUyLjk2MzM5NzcgNi4zMjExMTA2MyA1Mi44MDAyMDQyIDYuNDcyNTkyNzEgNTIuNjY2ODY1MSA2LjcwODAwMDUxIDUyLjU2MzM3NjUgNy4wMjczNDExTDUyLjU2MzM3NjUgMTEuMzEzODUzIDUwLjkzOTQwOTkgMTEuMzEzODUzIDUwLjkzOTQwOTkgNC42NjkxNDU0NiA1Mi40Njc4NDkxIDQuNjY5MTQ1NDYgNTIuNDY3ODQ5MSA0LjY2OTE0NTQ2ek00OC40NjE2MDg4IDEwLjgyOTMxOTJDNDguMDI4ODg4MiAxMS4yMTU4NzI0IDQ3LjUyNDc0MDUgMTEuNDg0MTExIDQ2Ljk4NjY2NzQgMTEuNDg0MTExIDQ1LjEyMzA5NDEgMTEuNDg0MTExIDQ0LjM0MTk0NzMgOS45MjgwNTIxIDQ0LjM0MTk0NzMgOC4wMDg1NTYxNiA0NC4zNDE5NDczIDYuMDg5MDYwMjMgNDUuMTIzMDk0MSA0LjUzMzAwMTMzIDQ2Ljk4NjY2NzQgNC41MzMwMDEzMyA0Ny41NjU5MDA3IDQuNTMzMDAxMzMgNDguMDU4MTgzNSA0Ljc4MzA1OTI5IDQ4LjQ2MTYwODggNS4xNTg1MTc4Mkw0OC40NjE2MDg4IDQuNjIzMDg3MTcgNTAuMTE2NTEzOSA0LjYyMzA4NzE3IDUwLjExNjUxMzkgMTEuMzk0MDI1MiA0OC40NjE2MDg4IDExLjM5NDAyNTIgNDguNDYxNjA4OCAxMC44MjkzMTkyIDQ4LjQ2MTYwODggMTAuODI5MzE5MnpNNDguNDYxNjA4OCA5LjE1MTIyMDIxQzQ4LjI1NDM5MjQgOS42MTI3NDY0MyA0Ny44ODgwNjAxIDkuOTAwOTc4NTIgNDcuMzU4NTk4MyA5LjkxMDM5ODE0IDQ2LjM1NzM2OTUgOS45MjgyMTA5MyA0NS45NzMyNTE2IDkuMDgwNTg1NDcgNDUuOTU1MjA2MSA4LjAyNjg3Nzc4IDQ1LjkzNzE2MDcgNi45NzMxNzAwOSA0Ni4yOTIwMjEyIDYuMTEyMzk3NzMgNDcuMjkzMjUgNi4wOTQ1ODUwNiA0Ny44MDk0MTU2IDYuMDg1NDAyMDUgNDguMjI3OTIyMiA2LjQyMTYyMDk0IDQ4LjQ2MTYwODggNi45NjI1MjM2TDQ4LjQ2MTYwODggOS4xNTEyMjAyMSA0OC40NjE2MDg4IDkuMTUxMjIwMjF6TTI4Ljc4NjM2ODkgMTAuODI5MzE5MkMyOC4zNTM2NDg0IDExLjIxNTg3MjQgMjcuODQ5NTAwNyAxMS40ODQxMTEgMjcuMzExNDI3NiAxMS40ODQxMTEgMjUuNDQ3ODU0MiAxMS40ODQxMTEgMjQuNjY2NzA3NSA5LjkyODA1MjEgMjQuNjY2NzA3NSA4LjAwODU1NjE2IDI0LjY2NjcwNzUgNi4wODkwNjAyMyAyNS40NDc4NTQyIDQuNTMzMDAxMzMgMjcuMzExNDI3NiA0LjUzMzAwMTMzIDI3Ljg5MDY2MDkgNC41MzMwMDEzMyAyOC4zODI5NDM2IDQuNzgzMDU5MjkgMjguNzg2MzY4OSA1LjE1ODUxNzgyTDI4Ljc4NjM2ODkgNC42MjMwODcxNyAzMC40NDEyNzQgNC42MjMwODcxNyAzMC40NDEyNzQgMTEuMzk0MDI1MiAyOC43ODYzNjg5IDExLjM5NDAyNTIgMjguNzg2MzY4OSAxMC44MjkzMTkyIDI4Ljc4NjM2ODkgMTAuODI5MzE5MiAyOC43ODYzNjg5IDEwLjgyOTMxOTJ6TTI4Ljc4NjM2ODkgOS4xNTEyMjAyMUMyOC41NzkxNTI2IDkuNjEyNzQ2NDMgMjguMjEyODIwMiA5LjkwMDk3ODUyIDI3LjY4MzM1ODQgOS45MTAzOTgxNCAyNi42ODIxMjk2IDkuOTI4MjEwOTMgMjYuMjk4MDExNyA5LjA4MDU4NTQ3IDI2LjI3OTk2NjMgOC4wMjY4Nzc3OCAyNi4yNjE5MjA4IDYuOTczMTcwMDkgMjYuNjE2NzgxMyA2LjExMjM5NzczIDI3LjYxODAxMDEgNi4wOTQ1ODUwNiAyOC4xMzQxNzU3IDYuMDg1NDAyMDUgMjguNTUyNjgyNCA2LjQyMTYyMDk0IDI4Ljc4NjM2ODkgNi45NjI1MjM2TDI4Ljc4NjM2ODkgOS4xNTEyMjAyMSAyOC43ODYzNjg5IDkuMTUxMjIwMjEgMjguNzg2MzY4OSA5LjE1MTIyMDIxek01Ny43NDU1ODIzIDEzLjY3MjA0ODZMNTYuMDQ5OTcwMSAxMy42NzIwNDg2IDU3LjA2NDk0OTIgMTAuNzM2NTg2MyA1NC45Mzk0NjM1IDQuNjY5MTQ1NDYgNTYuNzMwNjAzMSA0LjY2OTE0NTQ2IDU3LjkzNjYzNzIgOC41MjU3Nzc5MSA1OS4xNDI2NzEyIDQuNjY5MTQ1NDYgNjAuODk3OTg4IDQuNjY5MTQ1NDYgNTcuNzQ1NTgyMyAxMy42NzIwNDg2IDU3Ljc0NTU4MjMgMTMuNjcyMDQ4NnpNNi4zODE1NDI3NCA5LjY1MDUwNjdDNi4xNDAwNzQxMiA5LjUyNzg2NDA0IDYuMDgxMjQwMDYgOS4zMTcwOTQ1MyA2LjIxMDQ0MDM3IDkuMDg4NjU1NjUgNi4yNzYxNDk0MSA4Ljk3MjQ3NTYxIDYuNDE4NTkyMzQgOC43NDA3NDQyMiA2LjUzNDY4MTY4IDguNTUzOTgyMiA2LjY5NDMyNzI0IDguMjk3MTQ3ODYgNi45MjcwMTQzNCA4LjI5NjM3NTg4IDcuMDU4MzU4NjYgOC4zNzI1MzM2MiA3LjE4OTcwMjk4IDguNDQ4NjkxMzUgOS4wNDg1MzYwNCA5LjQyMDg3MDYzIDkuMTY4NzQ4NzEgOS40ODY3MDY1NiA5LjQ1MjU5MjYgOS42NDIxNTcxMSA5Ljg3MTE4NjI5IDkuMjg2MjM3NDEgOS42ODM0MjI1MSA4Ljk3NDQxMzI2IDkuNTkzNzUzMzIgOC44MjU0OTczMSA4LjgxMDM5MzggNy40MDQ0NzgzIDguNTYxNDk3OCA2LjkzNjEwNDM4IDguNDQ4MjUxNTYgNi43MjI5OTY5NiA4LjQ3OTIxMDA1IDYuNTMxMzIyNTEgOC43MzE2NDcgNi40MDM3NzQ2MyA4Ljg1NjU2NDM2IDYuMzQwNjU4MSA5LjA4MDA0MjM5IDYuMjA4OTA3OTggOS4yNzM5NDc2MiA2LjA5MTkwNTg2IDkuNDcxODkyMTkgNS45NzI0NjY0IDkuNzM1MzQ4OCA2LjA2MjIzODg1IDkuODQyNDU1NTUgNi4yNDAyMjYyMSA5Ljk0OTU2MjI5IDYuNDE4MjEzNTcgMTMuMTIwOTE4MiAxMi4yMjk0MDE5IDEzLjIyMjE1MTcgMTIuNDA5ODE2OSAxMy40MjMxMjk0IDEyLjc2Nzk5MjggMTMuMDMyMjg1MyAxMy4xNTExMTM3IDEyLjY2Mjk0OSAxMi45NTMwOTQgMTIuNDc4MTQzNiAxMi44NTQwMTA1IDYuNjIzMDExMzYgOS43NzMxNDkzNiA2LjM4MTU0Mjc0IDkuNjUwNTA2N3pNLjIwMDE0NjA2MiA2LjQwMDgxMjA1Qy0uMTExNTU0MjgyIDYuMjMyNzQxMDYtLjA1NDU0NDIzMjggNS43OTYwNDA1Mi4zMzg3ODI2NDggNS42ODQ2NjAzNi42NzIwMzAwODMgNS41OTAyOTMxNyAzLjM3ODIwNjQ3IDQuNjg4MjYzNCA0LjE5NzE1MTE1IDQuNDE0OTAwNyA0LjM0NDc5NDQzIDQuMzY1NjE3NTcgNC40NjAwNTgwOSA0LjI1MjI4MjMyIDQuNTA5NjU3NTcgNC4xMDc1ODY3IDQuNzg4NTg4NTMgMy4yOTM4NjY3MSA1LjcyMDI0NTU2LjU3NTk2MTMzNCA1LjgyNDY4OTExLjI3MTI2OTc5IDUuOTQ3NzA0OC0uMDg3NjAxOTc5IDYuMzkzMDQwNzctLjA4ODkxODQ4NjcgNi41NzQ1NDY3MS4yNTg1MDIyNTEgNi42MzkwMTU2Ni4zODE5MDIzNDYgOS4wODk0MDQ4NCA0Ljc5ODEzNTQzIDkuMTc0NjU5NDYgNC45ODU0NzA1MSA5LjI1OTkxNDA4IDUuMTcyODA1NTkgOS4yMDQ5ODczMSA1LjM4MDMxNDM0IDkuMDExNzY0MDYgNS41MDAwMDUxNSA4LjgxODU0MDggNS42MTk2OTU5NiA4LjUwMzQzNTIyIDUuNzk4NzIwNjUgOC4zOTM2NTA3IDUuODY2NTQzMjMgOC4xOTQ4OTQ2MyA1Ljk4OTMzMDU3IDguMDA0MzkxMzUgNS45MDM4NDMyNCA3LjkxMzY1NTAxIDUuNzEzNjQ4MTYgNy44MjI5MTg2NiA1LjUyMzQ1MzA3IDcuMTIzODc4MjggNC4yOTYyNjg4NiA2LjgzODgyOTUyIDMuNzc1Mjc3OTUgNi42OTYzNDk0MSAzLjUxNDg2MzQxIDYuMjY3MTEzMiAzLjQxNDkyNzk2IDYuMTIyODM3MjEgMy44MzU4MjE1OSA2LjAxODQ4NDg4IDQuMTQwMjQ2NjkgNS43NzUyNjI2NCA0Ljg0OTc5NDM5IDUuNjQ4NjQ1NTcgNS4yMTkxNzIgNS42MDAyMDM3NSA1LjM2MDQ5MDQgNS40Nzc0MTk0NCA1LjQ4MjEyNTU0IDUuMzMzOTgxMjQgNS41MjkzNzAwMSA0Ljk3ODAxNjMgNS42NDY2MTQ3OCA0LjMwMTYyOTQ0IDUuODY5Mzk3NDQgMy45MDY3MjM3NSA1Ljk5OTQ2ODE4IDMuNjI0OTE0NjkgNi4wOTIyODgxIDMuNDg3OTM1MjMgNi41MjE1MDI1NyAzLjg2ODQxNzc2IDYuNzAyMjQ2NzQgMy45NDc4OTUyNSA2Ljc0MDAwMTY3IDUuNjYwOTcxNTMgNy42NTQwNDM5MSA1LjgzNzcyMDU0IDcuNzM3NjQ1NTggNi4wMTQ0Njk1NCA3LjgyMTI0NzI0IDYuMTEyNzUyODEgOC4wNjI1Nzk0NSA2LjAwOTI1MzE2IDguMjMzNTUwMDQgNS44NjgwODA0IDguNDY2NzUyNjggNS42ODczNDc5NiA4Ljc2MjcyMDUyIDUuNjIxNzg2MiA4Ljg1ODgwODE1IDUuNTA4MTU4MzggOS4wMjUzNDE2MyA1LjI4NDk1MDc3IDkuMDgwODAyODYgNS4wOTc0ODk5NiA4Ljk4MjUxMDc2IDQuOTEwMDI5MTUgOC44ODQyMTg2Ni4zMTE1MjIyODkgNi40NjA4NjY4OS4yMDAxNDYwNjIgNi40MDA4MTIwNXoiLz48L2c+ICA8L3N2Zz4gIA=="); +} + +.mapillary-attribution-compact .mapillary-attribution-logo { + width: 11px; + background-size: cover; +} +.mapillary-bearing-indicator-container { + bottom: 34px; + height: 40px; + pointer-events: none; + position: absolute; + right: 10px; + width: 40px; + z-index: 10; +} + +.mapillary-bearing-indicator-container.mapillary-bearing-compact { + -webkit-transform: scale(0.6); + transform: scale(0.6); + bottom: 16px; + right: 2px; +} + +.mapillary-bearing-indicator-background { + position: absolute; + top: 0; + left: 0; + width: 40px; + height: 40px; + opacity: 0.5; + -webkit-transform-origin: 50% 50%; + transform-origin: 50% 50%; +} + +.mapillary-bearing-indicator-background-circle { + pointer-events: auto; + position: absolute; + top: 0; + left: 0; + width: 40px; + height: 40px; + border-radius: 50%; + background-color: #000; +} + +.mapillary-bearing-indicator-background-arrow-container { + position: absolute; + left: 14px; + top: -3.5px; + height: 8px; + width: 12px; + overflow: hidden; +} + +.mapillary-bearing-indicator-background-arrow { + position: absolute; + width: 12px; + height: 12px; + left: 0; + top: 2px; + background-color: #000; + -webkit-transform: rotate(45deg); + transform: rotate(45deg); + border-radius: 2px; +} + +.mapillary-bearing-north-container { + position: absolute; + left: 16px; + top: -1px; + width: 8px; + height: 21px; + -webkit-transform-origin: 50% 100%; + transform-origin: 50% 100%; +} + +.mapillary-bearing-north { + position: absolute; + border-left: 4px solid transparent; + border-right: 4px solid transparent; + border-bottom: 4px solid white; +} +.mapillary-cover-container { + background-color: rgba(0, 0, 0, 0.5); + bottom: 0; + left: 0; + overflow: hidden; + position: absolute; + right: 0; + top: 0; + z-index: 100; +} + +.mapillary-cover-background { + background-position: center; + background-size: cover; + bottom: 0; + left: 0; + margin: -3px; + position: absolute; + right: 0; + top: 0; + z-index: 0; +} + +.mapillary-cover-indicator { + bottom: 0; + left: 0; + margin: -3px; + position: absolute; + right: 0; + top: 0; + z-index: 0; + background-color: rgba(0, 0, 0, 0.2); + -webkit-transition: opacity 0.1s ease-in-out; + transition: opacity 0.1s ease-in-out; + cursor: pointer; +} + +.mapillary-cover-indicator:hover { + opacity: 0; +} + +.mapillary-cover-button { + background-color: rgba(0, 0, 0, 0.5); + border-radius: 64px; + width: 128px; + height: 128px; + left: 50%; + top: 50%; + letter-spacing: 1px; + margin-left: -64px; + margin-top: -64px; + position: absolute; + -webkit-transition: opacity 0.2s ease-in-out; + transition: opacity 0.2s ease-in-out; + z-index: 1; + pointer-events: none; +} + +.mapillary-cover-compact .mapillary-cover-button { + width: 72px; + height: 72px; + margin-left: -36px; + margin-top: -36px; +} + +.mapillary-cover-button-icon { + background-size: contain; + background-repeat: no-repeat; + background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMjgiIGhlaWdodD0iMTI4IiB2aWV3Qm94PSIwIDAgMTI4IDEyOCI+PGcgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIj4gIDxwYXRoIGZpbGw9IiNGRkYiIGQ9Ik01Ny43NDc4MjExLDU2LjkzMTc4NDUgTDQwLjkxNDUzODEsNjQuNjM0MjkyIEMzOC40NjQ1MzU5LDY1Ljc1NDY0MzIgMzQuNzg1NzMyMiw2NS43NTMzNDM1IDMyLjc2MzkxMDMsNjQuNjMyOTkyMyBDMzAuNzczNzU4Niw2My41Mjk1MzczIDMxLjE4ODAwNCw2MS43Nzg4MjYxIDMzLjYyNTMzODIsNjAuNzE5NTYxMyBMNjAuMjgzOTk3MSw0OS4xNTIxMzAyIEM2Mi4yODU1NTAxLDQ4LjI4MjYyMzMgNjUuNTAwNzAzLDQ4LjI4MjYyMzMgNjcuNTE2MTkwOCw0OS4xNTIxMzAyIEw5NC4zNDIwNjgyLDYwLjcyNjA1OTkgQzk2Ljc5NDYwNCw2MS43ODQwMjUgOTcuMjM0MTg1Niw2My41MzYwMzU5IDk1LjI2MDUwMjMsNjQuNjM5NDkwOCBDOTMuMjU1MTQ4OSw2NS43NTk4NDIgODkuNTczODExNSw2NS43NTk4NDIgODcuMTA4NjA3Nyw2NC42MzgxOTExIEw3MC42MjEzMjk1LDU3LjE0MDMxMjIgTDcxLjc5NzkzNTgsNzcuMzkyODQ3MiBDNzEuODk1MjAzMSw3OS4wODYzOTEyIDY4LjU0MjcyMzIsODAuNSA2NC4zNDU5NjMsODAuNSBDNjAuMTQ5MjAyOSw4MC41IDU2Ljc3NzI2OTUsNzkuMDg2MzkxMiA1Ni44NTExOTI2LDc3LjM5Mjg0NzIgTDU3Ljc0NzgyMTEsNTYuOTMxNzg0NSBaIi8+PC9nPiAgPC9zdmc+ICA="); + height: 100%; + width: 100%; + position: absolute; + pointer-events: none; +} + +.mapillary-cover-compact .mapillary-cover-button-icon { + width: 72px; + height: 72px; +} + +.mapillary-cover-logo { + background-size: contain; + background-repeat: no-repeat; + background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNTciIGhlaWdodD0iNTIiIHZpZXdCb3g9IjAgMCAxNTcgNTIiPjxnIGZpbGw9Im5vbmUiIGZpbGwtcnVsZT0iZXZlbm9kZCI+ICA8cGF0aCBmaWxsPSIjMDAwIiBmaWxsLW9wYWNpdHk9Ii41IiBkPSJNODMuMjMwNCAxOS4wNjg2TDgzLjIzMDQgMjAuMjk1NkM4Mi44NDQ0IDE5LjkxNDYgODIuNDIzNCAxOS41ODI2IDgxLjk2MTQgMTkuMzQwNiA4MS4xMTI0IDE4Ljg5NTYgODAuMTkyNCAxOC42NzE2IDc5LjIyODQgMTguNjcxNiA3OC41MzQ0IDE4LjY3MTYgNzcuODY4NCAxOC43NzM2IDc3LjIzNjQgMTguOTgwNkw3NC4yMDI0IDE4Ljk4MDYgNjcuNDg3NCAxOC45Nzg2QzY3LjAyNzQgMTguODYzNiA2Ni41NDk0IDE4LjgwMzYgNjYuMDU2NCAxOC44MDM2IDY0LjIwMzQgMTguODAzNiA2Mi42OTU0IDE5LjM4MjYgNjEuNTQ0NCAyMC4zOTA2TDYxLjU0NDQgMTUuNDU3NiA1NC45MzA0IDE1LjQ1NzYgNTEuOTAwNCAyMi43MDE2IDQ4Ljg3MDQgMTUuNDU3NiA0Mi4yNTY0IDE1LjQ1NzYgNDIuMjU2NCAzNi4wMDY2IDYxLjU0NDQgMzYuMDA2NiA2MS41NDQ0IDM0Ljc1MTZDNjIuNjk1NCAzNS43NTg2IDY0LjIwMzQgMzYuMzM4NiA2Ni4wNTY0IDM2LjMzODYgNjYuNTIwNCAzNi4zMzg2IDY2Ljk3NDQgMzYuMjc3NiA2Ny40MTc0IDM2LjE2MjZMNzEuNDYwNCAzNi4xNjI2IDcxLjQ2MDQgNDEuNDg1NiA3OC42NDk0IDM5LjkxMzYgNzguNjQ5NCAzNi4yOTM2Qzc4Ljg1MjQgMzYuMzA5NiA3OS4wNjA0IDM2LjMxNzYgNzkuMjc1NCAzNi4zMTc2IDgwLjI0NzQgMzYuMzE3NiA4MS4xNjg0IDM2LjA5OTYgODIuMDEwNCAzNS42NjM2IDgyLjQ1MzQgMzUuNDM0NiA4Mi44NTk0IDM1LjE0NzYgODMuMjMwNCAzNC44MDk2TDgzLjIzMDQgMzYuMDA2NiA4OC4xMDg0IDM2LjAwNjYgOTAuNDQzNCAzNi4wMDY2IDkyLjk2MTQgMzYuMDA2NiA5NS4yOTY0IDM2LjAwNjYgMTAwLjE1MDQgMzYuMDA2NiAxMDAuMTUwNCAzNC43MjU2QzEwMS4zMDU0IDM1Ljc0OTYgMTAyLjgyMTQgMzYuMzM4NiAxMDQuNjkwNCAzNi4zMzg2IDEwNS4xNTQ0IDM2LjMzODYgMTA1LjYwODQgMzYuMjc3NiAxMDYuMDUxNCAzNi4xNjI2TDExMi44MzY0IDM2LjE2MjYgMTE3LjY0MTQgMzYuMTYxNiAxMTcuNjQxNCAyNi4wMzE2IDExOS41ODM0IDI3Ljc3NDYgMTIwLjA5NjQgMjYuNDc4NiAxMjIuMzYwNCAzMi44ODY2IDExOS42NzE0IDQwLjU5NzYgMTI3LjIzMTQgNDAuNTk3NiAxMzQuODM0NCAxOS4wNjg2IDEyNy4wOTM0IDE5LjA2ODYgMTI2LjE5MjQgMjEuOTI3NiAxMjUuMjkwNCAxOS4wNjg2IDExOS45NTk0IDE5LjA2ODZDMTE5Ljg5ODQgMTkuMDQ0NiAxMTkuODQwNCAxOS4wMTE2IDExOS43NzY0IDE4Ljk5MDYgMTE5LjIzNjQgMTguODExNiAxMTguNjc1NCAxOC42NzE2IDExOC4xMDI0IDE4LjY3MTYgMTE3LjQ0ODQgMTguNjcxNiAxMTYuODEwNCAxOC43NzM2IDExNi4xOTc0IDE4Ljk4MDZMMTEyLjgzNjQgMTguOTgwNiAxMDYuMTIxNCAxOC45Nzg2QzEwNS42NjE0IDE4Ljg2MzYgMTA1LjE4MzQgMTguODAzNiAxMDQuNjkwNCAxOC44MDM2IDEwMi44MjE0IDE4LjgwMzYgMTAxLjMwNTQgMTkuMzkyNiAxMDAuMTUwNCAyMC40MTY2TDEwMC4xNTA0IDEzLjc2NjYgOTUuMjk2NCAxNS40NDE2IDk1LjI5NjQgMTMuNzY2NiA4OS41MTg0IDE1Ljc1OTZDODkuNDc4NCAxNS43MTQ2IDg5LjQ0OTQgMTUuNjY0NiA4OS40MDc0IDE1LjYyMDYgODguNzE1NCAxNC44OTc2IDg3LjgxNzQgMTQuNTAxNiA4Ni44MjU0IDE0LjUwMTYgODUuNzkyNCAxNC41MDE2IDg0Ljg2MjQgMTQuOTA3NiA4NC4xODA0IDE1LjY3NTYgODMuNTU1NCAxNi4zODA2IDgzLjIzMDQgMTcuMjUyNiA4My4yMzA0IDE4LjE5ODZMODMuMjMwNCAxOS4wNjg2ek0zOS44ODkxIDM1LjM0NUMzOS44NTAxIDM1LjI3NiAzOS41MDExIDM0LjY0NCAzOC44MDIxIDMzLjM3NSAzOC44NTMxIDMzLjQ2OCAzNy4xMTExIDMwLjMwNyAzNi41NTgxIDI5LjMwNiAzNC4zNDkxIDI1LjMwMSAzMy4yOTIxIDIzLjM4OCAzMy4xNzYxIDIzLjE5NyAzMi45MzUxIDIyLjc5OSAzMi41OTUxIDIyLjQ4OSAzMi4yMDkxIDIyLjI1OCAzMi4yMzMxIDIxLjgyIDMyLjE2MTEgMjEuMzcgMzEuOTY0MSAyMC45NDEgMzEuODU1MSAyMC43MDIgMzEuMDg0MSAxOS4zMSAyOS4zMjQxIDE2LjE1MSAyOS4yNDIxIDE2LjAwMyAyNi44MDUxIDExLjYzMiAyNi43NzUxIDExLjU3NSAyNS42MzMxIDkuNDA4IDIyLjQ3NDEgOS40NDYgMjEuNjMzMSAxMS44NzlMMjEuMjE3MSAxMy4wODIgMjAuNDYyMSAxNS4yNjYgMTkuMjAxMSAxOC45MTVDMTUuNjQ5MSAyMC4wODkgMTIuNTg3MSAyMS4wOTMgMTIuMTM2MSAyMS4yMiA5LjcxMzEgMjEuOTAxIDkuMTY3MSAyNS4wODcgMTEuNDU5MSAyNi4zMTMgMTEuNjU1MSAyNi40MTggMjAuNjMzMSAzMS4xMSAyMS4xNjIxIDMxLjM4NSAyMS42MTgxIDMxLjYyMiAyMi4xMTIxIDMxLjcyMSAyMi41OTUxIDMxLjY5OSAyMi44NjAxIDMyLjEwNiAyMy4yMzIxIDMyLjQ1OSAyMy43MjQxIDMyLjcwNiAyNC4xMTgxIDMyLjkwNSAzNS42MzAxIDM4LjkwOCAzNi4xMDQxIDM5LjE2IDM4LjU2MjEgNDAuNDY4IDQxLjI5NDEgMzcuODI3IDM5Ljg4OTEgMzUuMzQ1Ii8+ICA8cGF0aCBmaWxsPSIjRkZGIiBkPSJNNTkuNTQ0NCAxNy40NTczTDU5LjU0NDQgMzQuMDA3MyA1Ni4zNTU0IDM0LjAwNzMgNTYuMzU1NCAyNS4zOTczIDUyLjc2ODQgMzQuMDA3MyA1MS4wMzM0IDM0LjAwNzMgNDcuNDQ1NCAyNS4zOTczIDQ3LjQ0NTQgMzQuMDA3MyA0NC4yNTY0IDM0LjAwNzMgNDQuMjU2NCAxNy40NTczIDQ3LjUzOTQgMTcuNDU3MyA1MS45MDA0IDI3Ljg4NDMgNTYuMjYxNCAxNy40NTczIDU5LjU0NDQgMTcuNDU3M3pNNzMuNDYxNCAyMS4wNjgzTDc2LjI1MTQgMjEuMDY4MyA3Ni4yNTE0IDIyLjE0NDNDNzYuNDM4NCAyMS43NjIzIDc2LjgxMzQgMjEuNDM2MyA3Ny4zNzY0IDIxLjE2NDMgNzcuOTM4NCAyMC44OTQzIDc4LjU1NjQgMjAuNzU4MyA3OS4yMjg0IDIwLjc1ODMgNzkuODY5NCAyMC43NTgzIDgwLjQ3MDQgMjAuOTA1MyA4MS4wMzQ0IDIxLjIwMDMgODEuNTk2NCAyMS40OTUzIDgyLjA4NTQgMjEuOTMwMyA4Mi40OTk0IDIyLjUwMzMgODIuOTEzNCAyMy4wNzczIDgzLjI0MjQgMjMuNzg3MyA4My40ODQ0IDI0LjYzMjMgODMuNzI2NCAyNS40NzczIDgzLjg0NzQgMjYuNDQxMyA4My44NDc0IDI3LjUyNTMgODMuODQ3NCAyOC42NDIzIDgzLjczMDQgMjkuNjIyMyA4My40OTY0IDMwLjQ2NzMgODMuMjYxNCAzMS4zMTIzIDgyLjk0MDQgMzIuMDIxMyA4Mi41MzQ0IDMyLjU5NjMgODIuMTI4NCAzMy4xNzAzIDgxLjY0NzQgMzMuNjAwMyA4MS4wOTI0IDMzLjg4NzMgODAuNTM4NCAzNC4xNzQzIDc5LjkzMTQgMzQuMzE3MyA3OS4yNzU0IDM0LjMxNzMgNzguNTcyNCAzNC4zMTczIDc4LjAwNDQgMzQuMjA2MyA3Ny41NzU0IDMzLjk4MjMgNzcuMTQ1NCAzMy43NjAzIDc2LjgzNjQgMzMuNDgwMyA3Ni42NDk0IDMzLjE0NTNMNzYuNjQ5NCAzOC4zMDMzIDczLjQ2MTQgMzkuMDAxMyA3My40NjE0IDIxLjA2ODN6TTc4LjQ3ODQgMzEuMjMyM0M4MC4wMjU0IDMxLjIzMjMgODAuNzk5NCAzMC4wMDUzIDgwLjc5OTQgMjcuNTUwMyA4MC43OTk0IDI2LjM1MzMgODAuNjA4NCAyNS40NDUzIDgwLjIyNDQgMjQuODIzMyA3OS44NDE0IDI0LjIwMTMgNzkuMjkxNCAyMy44OTEzIDc4LjU3MjQgMjMuODkxMyA3OC4wNDA0IDIzLjg5MTMgNzcuNTk4NCAyNC4wNzgzIDc3LjI0NzQgMjQuNDUyMyA3Ni44OTU0IDI0LjgyNzMgNzYuNjI2NCAyNS4yNjkzIDc2LjQzODQgMjUuNzc5M0w3Ni40Mzg0IDI5LjQ2MzNDNzYuNjU3NCAzMC4wNTMzIDc2Ljk1MDQgMzAuNDk1MyA3Ny4zMTc0IDMwLjc5MDMgNzcuNjg0NCAzMS4wODUzIDc4LjA3MTQgMzEuMjMyMyA3OC40Nzg0IDMxLjIzMjN6TTg1LjIzMDQgMzQuMDA3M0w4OC40NDM0IDM0LjAwNzMgODguNDQzNCAyMS4wNjkzIDg1LjIzMDQgMjEuMDY5MyA4NS4yMzA0IDM0LjAwNzN6TTg1LjIzMDQgMTguMTk4M0M4NS4yMzA0IDE3LjczNjMgODUuMzc5NCAxNy4zMzgzIDg1LjY3NjQgMTcuMDAzMyA4NS45NzM0IDE2LjY2ODMgODYuMzU2NCAxNi41MDEzIDg2LjgyNTQgMTYuNTAxMyA4Ny4yNjI0IDE2LjUwMTMgODcuNjQxNCAxNi42NjgzIDg3Ljk2MjQgMTcuMDAzMyA4OC4yODM0IDE3LjMzODMgODguNDQzNCAxNy43MzYzIDg4LjQ0MzQgMTguMTk4MyA4OC40NDM0IDE4LjY0NTMgODguMjgzNCAxOS4wMjMzIDg3Ljk2MjQgMTkuMzM1MyA4Ny42NDE0IDE5LjY0NTMgODcuMjYyNCAxOS44MDEzIDg2LjgyNTQgMTkuODAxMyA4Ni4zNTY0IDE5LjgwMTMgODUuOTczNCAxOS42NDUzIDg1LjY3NjQgMTkuMzM1MyA4NS4zNzk0IDE5LjAyMzMgODUuMjMwNCAxOC42NDUzIDg1LjIzMDQgMTguMTk4M3pNOTAuMTA3NCAxNy42NzMzTDkzLjI5NjQgMTYuNTcyMyA5My4yOTY0IDM0LjAwNzMgOTAuMTA3NCAzNC4wMDczIDkwLjEwNzQgMTcuNjczM3pNOTQuOTYxNCAxNy42NzMzTDk4LjE1MDQgMTYuNTcyMyA5OC4xNTA0IDM0LjAwNzMgOTQuOTYxNCAzNC4wMDczIDk0Ljk2MTQgMTcuNjczM3pNMTE1LjQ1MzQgMjEuMDY4M0wxMTUuNDUzNCAyMS45NTMzQzExNS42NzE0IDIxLjU4NzMgMTE2LjAzMjQgMjEuMjk2MyAxMTYuNTMyNCAyMS4wODAzIDExNy4wMzI0IDIwLjg2NTMgMTE3LjU1NTQgMjAuNzU4MyAxMTguMTAzNCAyMC43NTgzIDExOC40NjI0IDIwLjc1ODMgMTE4LjgxMDQgMjAuODEzMyAxMTkuMTQ2NCAyMC45MjUzIDExOS40ODI0IDIxLjAzNjMgMTE5Ljc0NDQgMjEuMjIwMyAxMTkuOTMxNCAyMS40NzUzTDExOC43ODM0IDI0LjM2OTNDMTE4LjM5MjQgMjQuMDE4MyAxMTcuOTYxNCAyMy44NDMzIDExNy40OTM0IDIzLjg0MzMgMTE3LjEwMjQgMjMuODQzMyAxMTYuNzQ3NCAyMy45OTAzIDExNi40MjY0IDI0LjI4NTMgMTE2LjEwNjQgMjQuNTgwMyAxMTUuODQ0NCAyNS4wMzgzIDExNS42NDE0IDI1LjY2MDNMMTE1LjY0MTQgMzQuMDA3MyAxMTIuNDUyNCAzNC4wMDczIDExMi40NTI0IDIxLjA2ODMgMTE1LjQ1MzQgMjEuMDY4M3pNMTA3LjU4NjQgMzMuMDYzM0MxMDYuNzM3NCAzMy44MTUzIDEwNS43NDc0IDM0LjMzODMgMTA0LjY5MDQgMzQuMzM4MyAxMDEuMDMxNCAzNC4zMzgzIDk5LjQ5ODQgMzEuMzA5MyA5OS40OTg0IDI3LjU3MDMgOTkuNDk4NCAyMy44MzMzIDEwMS4wMzE0IDIwLjgwNDMgMTA0LjY5MDQgMjAuODA0MyAxMDUuODI4NCAyMC44MDQzIDEwNi43OTU0IDIxLjI5MDMgMTA3LjU4NjQgMjIuMDIxM0wxMDcuNTg2NCAyMC45NzgzIDExMC44MzY0IDIwLjk3ODMgMTEwLjgzNjQgMzQuMTYyMyAxMDcuNTg2NCAzNC4xNjIzIDEwNy41ODY0IDMzLjA2MzN6TTEwNy41ODY0IDI5Ljc5NjNDMTA3LjE3OTQgMzAuNjk0MyAxMDYuNDYxNCAzMS4yNTYzIDEwNS40MjA0IDMxLjI3MzMgMTAzLjQ1NTQgMzEuMzA5MyAxMDIuNzAxNCAyOS42NTgzIDEwMi42NjU0IDI3LjYwNjMgMTAyLjYyOTQgMjUuNTU1MyAxMDMuMzI3NCAyMy44NzkzIDEwNS4yOTM0IDIzLjg0NDMgMTA2LjMwNjQgMjMuODI2MyAxMDcuMTI4NCAyNC40ODAzIDEwNy41ODY0IDI1LjUzNDNMMTA3LjU4NjQgMjkuNzk2M3pNNjguOTUzNCAzMy4wNjMzQzY4LjEwMzQgMzMuODE1MyA2Ny4xMTM0IDM0LjMzODMgNjYuMDU2NCAzNC4zMzgzIDYyLjM5NzQgMzQuMzM4MyA2MC44NjM0IDMxLjMwOTMgNjAuODYzNCAyNy41NzAzIDYwLjg2MzQgMjMuODMzMyA2Mi4zOTc0IDIwLjgwNDMgNjYuMDU2NCAyMC44MDQzIDY3LjE5NDQgMjAuODA0MyA2OC4xNjE0IDIxLjI5MDMgNjguOTUzNCAyMi4wMjEzTDY4Ljk1MzQgMjAuOTc4MyA3Mi4yMDI0IDIwLjk3ODMgNzIuMjAyNCAzNC4xNjIzIDY4Ljk1MzQgMzQuMTYyMyA2OC45NTM0IDMzLjA2MzN6TTY4Ljk1MzQgMjkuNzk2M0M2OC41NDU0IDMwLjY5NDMgNjcuODI2NCAzMS4yNTYzIDY2Ljc4NzQgMzEuMjczMyA2NC44MjE0IDMxLjMwOTMgNjQuMDY2NCAyOS42NTgzIDY0LjAzMTQgMjcuNjA2MyA2My45OTY0IDI1LjU1NTMgNjQuNjkyNCAyMy44NzkzIDY2LjY1ODQgMjMuODQ0MyA2Ny42NzI0IDIzLjgyNjMgNjguNDk0NCAyNC40ODAzIDY4Ljk1MzQgMjUuNTM0M0w2OC45NTM0IDI5Ljc5NjN6TTEyNS44MTY0IDM4LjU5OTNMMTIyLjQ4NzQgMzguNTk5MyAxMjQuNDgwNCAzMi44ODMzIDEyMC4zMDY0IDIxLjA2ODMgMTIzLjgyNDQgMjEuMDY4MyAxMjYuMTkyNCAyOC41NzgzIDEyOC41NjA0IDIxLjA2ODMgMTMyLjAwNjQgMjEuMDY4MyAxMjUuODE2NCAzOC41OTkzek0yNC42MjQgMzAuOTIwMUMyNC4xNDYgMzAuNjgwMSAyNC4wMyAzMC4yNjcxIDI0LjI4NiAyOS44MTgxIDI0LjQxNiAyOS41OTExIDI0LjY5NyAyOS4xMzcxIDI0LjkyNyAyOC43NzExIDI1LjI0MiAyOC4yNjcxIDI1LjcwMyAyOC4yNjYxIDI1Ljk2MiAyOC40MTUxIDI2LjIyMiAyOC41NjUxIDI5Ljg5NyAzMC40NzAxIDMwLjEzNSAzMC41OTkxIDMwLjY5NiAzMC45MDMxIDMxLjUyMyAzMC4yMDYxIDMxLjE1MiAyOS41OTUxIDMwLjk3NSAyOS4zMDMxIDI5LjQyNiAyNi41MTcxIDI4LjkzNCAyNS41OTkxIDI4LjcxIDI1LjE4MTEgMjguNzcxIDI0LjgwNTEgMjkuMjcgMjQuNTU1MSAyOS41MTggMjQuNDMyMSAyOS45NTkgMjQuMTczMSAzMC4zNDMgMjMuOTQzMSAzMC43MzQgMjMuNzA5MSAzMS4yNTUgMjMuODg2MSAzMS40NjcgMjQuMjM0MSAzMS42NzggMjQuNTgzMSAzNy45NDkgMzUuOTc2MSAzOC4xNDkgMzYuMzI5MSAzOC41NDYgMzcuMDMyMSAzNy43NzMgMzcuNzgzMSAzNy4wNDMgMzcuMzk1MSAzNi42NzggMzcuMjAwMSAyNS4xMDIgMzEuMTYwMSAyNC42MjQgMzAuOTIwMU0xMi40MDIgMjQuNTQ5MUMxMS43ODYgMjQuMjIwMSAxMS44OTkgMjMuMzYzMSAxMi42NzcgMjMuMTQ2MSAxMy4zMzUgMjIuOTYwMSAxOC42ODYgMjEuMTkxMSAyMC4zMDUgMjAuNjU2MSAyMC41OTcgMjAuNTYwMSAyMC44MjUgMjAuMzM3MSAyMC45MjMgMjAuMDU0MSAyMS40NzUgMTguNDU4MSAyMy4zMTYgMTMuMTMwMSAyMy41MjMgMTIuNTMyMSAyMy43NjYgMTEuODI5MSAyNC42NDYgMTEuODI2MSAyNS4wMDUgMTIuNTA4MSAyNS4xMzMgMTIuNzQ5MSAyOS45NzggMjEuNDA3MSAzMC4xNDYgMjEuNzc0MSAzMC4zMTUgMjIuMTQyMSAzMC4yMDYgMjIuNTQ5MSAyOS44MjQgMjIuNzgzMSAyOS40NDIgMjMuMDE4MSAyOC44MTkgMjMuMzY5MSAyOC42MDIgMjMuNTAyMSAyOC4yMDkgMjMuNzQyMSAyNy44MzIgMjMuNTc1MSAyNy42NTMgMjMuMjAyMSAyNy40NzQgMjIuODI5MSAyNi4wOTIgMjAuNDI0MSAyNS41MjggMTkuNDAyMSAyNS4yNDcgMTguODkyMSAyNC4zOTggMTguNjk1MSAyNC4xMTIgMTkuNTIxMSAyMy45MDYgMjAuMTE3MSAyMy40MjUgMjEuNTA5MSAyMy4xNzUgMjIuMjMyMSAyMy4wNzkgMjIuNTEwMSAyMi44MzYgMjIuNzQ4MSAyMi41NTMgMjIuODQxMSAyMS44NDkgMjMuMDcwMSAyMC41MTIgMjMuNTA4MSAxOS43MzEgMjMuNzYzMSAxOS4xNzQgMjMuOTQ0MSAxOC45MDMgMjQuNzg2MSAxOS42NTUgMjUuMTQxMSAxOS44MTIgMjUuMjE0MSAyMy4xOTkgMjcuMDA2MSAyMy41NDkgMjcuMTcwMSAyMy44OTggMjcuMzM0MSAyNC4wOTMgMjcuODA3MSAyMy44ODggMjguMTQzMSAyMy42MDkgMjguNjAwMSAyMy4yNTEgMjkuMTgwMSAyMy4xMjIgMjkuMzY4MSAyMi44OTcgMjkuNjk0MSAyMi40NTYgMjkuODAzMSAyMi4wODUgMjkuNjEwMSAyMS43MTQgMjkuNDE4MSAxMi42MjMgMjQuNjY3MSAxMi40MDIgMjQuNTQ5MSIvPjwvZz4gIDwvc3ZnPiAg"); + bottom: 0px; + height: 52px; + left: 50%; + margin-left: -73px; + position: absolute; + -webkit-transition: opacity 0.2s ease-in-out; + transition: opacity 0.2s ease-in-out; + width: 157px; + z-index: 1; +} + +.mapillary-cover-compact .mapillary-cover-logo { + width: 104px; + height: 35px; + margin-left: -49px; +} + +.mapillary-cover-done { + visibility: hidden; + opacity: 0; +} + +.mapillary-cover.mapillary-cover-loading .mapillary-cover-button, +.mapillary-cover.mapillary-cover-loading .mapillary-cover-logo { + opacity: 0; +} + +.mapillary-cover-spinner { + -webkit-animation: rotate 1s linear infinite; + animation: rotate 1s linear infinite; + background-repeat: no-repeat; + background-size: 100%; + background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI4NCIgaGVpZ2h0PSI4NCIgdmlld0JveD0iMCAwIDg0IDg0Ij48ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPiAgPHBhdGggZD0iTTQyLDg0IEM2NS4xOTYsODQgODQsNjUuMTk2IDg0LDQyIEM4NCwxOC44MDQgNjUuMTk2LDAgNDIsMCBDMTguODA0LDAgMCwxOC44MDQgMCw0MiBDMCw2NS4xOTYgMTguODA0LDg0IDQyLDg0Ii8+ICA8cGF0aCBmaWxsPSIjRkZGIiBmaWxsLXJ1bGU9Im5vbnplcm8iIGQ9Ik00Miw4My45OTg1MTA0IEM0MC4zNDMxNDU4LDgzLjk5ODUxMDQgMzksODIuNjU1MzY0NiAzOSw4MC45OTg1MTA0IEMzOSw3OS4zNDE2NTYxIDQwLjM0MzE0NTgsNzcuOTk4NTEwNCA0Miw3Ny45OTg1MTA0IEM2MS44ODIyODg2LDc3Ljk5ODUxMDQgNzgsNjEuODgwNzk5IDc4LDQxLjk5ODUxMDQgQzc4LDIyLjIzNDkyOTQgNjIuMDY2MzMxNiw2LjE3MDg3ODA2IDQyLjMxODc2ODIsNS45OTk4ODk5NCBDNDAuNjYxOTc2LDUuOTg1NTQ0MjggMzkuMzMwNTEwMSw0LjYzMDgxOTQyIDM5LjM0NDg1NTgsMi45NzQwMjcyOCBDMzkuMzU5MjAxNCwxLjMxNzIzNTE0IDQwLjcxMzkyNjMsLTAuMDE0MjMwODE0NiA0Mi4zNzA3MTg0LDAuMDAwMTE0ODQyOTUyIEM2NS40MTA5MTU3LDAuMTk5NjEyODggODQsMTguOTQwODA5MSA4NCw0MS45OTg1MTA0IEM4NCw2NS4xOTQ1MDc1IDY1LjE5NTk5NzEsODMuOTk4NTEwNCA0Miw4My45OTg1MTA0IFoiLz48L2c+ICA8L3N2Zz4gIA=="); + height: 32px; + left: 50%; + margin-left: -16px; + margin-top: -16px; + position: absolute; + top: 50%; + width: 32px; + z-index: 1; +} + +@-webkit-keyframes rotate { + 100% { + -webkit-transform: rotate(360deg); + transform: rotate(360deg); + } +} + +@keyframes rotate { + 100% { + -webkit-transform: rotate(360deg); + transform: rotate(360deg); + } +} +.mapillary-direction-perspective { + display: block; + position: absolute; + pointer-events: none; + -webkit-transform-style: preserve-3d; + transform-style: preserve-3d; + z-index: 10; +} + +.mapillary-direction-circle, +.mapillary-direction-circle-sequence, +.mapillary-direction-circle-inactive, +.mapillary-direction-circle-highlight, +.mapillary-direction-circle-sequence-highlight { + position: absolute; + border-radius: 50%; +} + +.mapillary-direction-circle, +.mapillary-direction-circle-sequence, +.mapillary-direction-circle-highlight, +.mapillary-direction-circle-sequence-highlight { + pointer-events: auto; +} + +.mapillary-direction-circle:hover, +.mapillary-direction-circle-sequence:hover, +.mapillary-direction-circle-highlight:hover, +.mapillary-direction-circle-sequence-highlight:hover { + cursor: pointer; +} + +.mapillary-direction-circle, +.mapillary-direction-circle-sequence { + background: rgba(0, 0, 0, 0); +} + +.mapillary-direction-circle-inactive { + background: rgba(0, 0, 0, 0); + z-index: -1; +} + +.mapillary-direction-circle-highlight, +.mapillary-direction-circle-sequence-highlight { + background: rgba(0, 0, 0, 0.4); +} + +.mapillary-direction-arrow-step, +.mapillary-direction-arrow-spherical, +.mapillary-direction-arrow-inactive { + position: absolute; + top: 44%; + left: 50%; + -webkit-transform: translate(-50%, -50%); + transform: translate(-50%, -50%); + width: 75%; + height: 44.0775%; + pointer-events: none; +} + +.mapillary-direction-arrow-step, +.mapillary-direction-arrow-inactive { + background: url("data:image/svg+xml;base64,PHN2ZyBpZD0iTGF5ZXJfMSIgZGF0YS1uYW1lPSJMYXllciAxIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgNTguNzciPgogICAgPHBhdGggZmlsbD0iI0ZGRkZGRiIgZD0iTTk3LjQ0LDQzLjgyTDU2LjE4LDIuNTZhOC43NSw4Ljc1LDAsMCwwLTEyLjM3LDBoMEwyLjU2LDQzLjhBOC43NSw4Ljc1LDAsMCwwLDE0Ljk0LDU2LjE4bDM1LTM1TDg1LjA1LDU2LjJBOC43NSw4Ljc1LDAsMSwwLDk3LjQ0LDQzLjgyaDBaIi8+Cjwvc3ZnPg=="); + background-repeat: no-repeat; +} + +.mapillary-direction-arrow-inactive { + opacity: 0.2; + z-index: -1; +} + +.mapillary-direction-arrow-spherical { + background: url("data:image/svg+xml;base64,PHN2ZyBpZD0iTGF5ZXJfMSIgZGF0YS1uYW1lPSJMYXllciAxIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgNTguNzciPgogICAgPHBhdGggZmlsbD0iI0Y1REVCMyIgZD0iTTk3LjQ0LDQzLjgyTDU2LjE4LDIuNTZhOC43NSw4Ljc1LDAsMCwwLTEyLjM3LDBoMEwyLjU2LDQzLjhBOC43NSw4Ljc1LDAsMCwwLDE0Ljk0LDU2LjE4bDM1LTM1TDg1LjA1LDU2LjJBOC43NSw4Ljc1LDAsMSwwLDk3LjQ0LDQzLjgyaDBaIi8+Cjwvc3ZnPg=="); + background-repeat: no-repeat; +} + +.mapillary-direction-turn-circle, +.mapillary-direction-turn-circle-sequence, +.mapillary-direction-turn-circle-highlight, +.mapillary-direction-turn-circle-sequence-highlight { + position: absolute; + border-radius: 50%; + pointer-events: auto; +} + +.mapillary-direction-turn-circle, +.mapillary-direction-turn-circle-sequence { + background: rgba(0, 0, 0, 0); +} + +.mapillary-direction-turn-circle-highlight, +.mapillary-direction-turn-circle-sequence-highlight { + background: rgba(0, 0, 0, 0.4); +} + +.mapillary-direction-turn-circle:hover, +.mapillary-direction-turn-circle-sequence:hover, +.mapillary-direction-turn-circle-highlight:hover, +.mapillary-direction-turn-circle-sequence-highlight:hover { + cursor: pointer; +} + +.mapillary-direction-turn-left, +.mapillary-direction-turn-right, +.mapillary-direction-turn-around { + position: absolute; + top: 50%; + pointer-events: none; + -webkit-filter: drop-shadow(0px 2px 1px rgba(0, 0, 0, 0.8)); + filter: drop-shadow(0px 2px 1px rgba(0, 0, 0, 0.8)); +} + +.mapillary-direction-turn-left, +.mapillary-direction-turn-right { + width: 58.794%; + height: 70%; +} + +.mapillary-direction-turn-around { + left: 50%; + width: 75%; + height: 63.2475%; +} + +.mapillary-direction-turn-left { + left: 45%; + background: url("data:image/svg+xml;base64,PHN2ZyBpZD0iTGF5ZXJfMSIgZGF0YS1uYW1lPSJMYXllciAxIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTE5LjA2Ij4KICAgIDxwYXRoIGZpbGw9IiNGRkZGRkYiIGQ9Ik05OS4yLDc2Qzk4LDY0LjkxLDk1LjA2LDU2LjYsODkuNTcsNTAsNzksMzcuMzgsNjAuMywzMi43MiwyNC40MiwzMi4zOUw0NC44OSwxMS45M0E3LDcsMCwwLDAsMzUsMmgwbC0zMywzM2E3LDcsMCwwLDAsMCw5Ljg4TDM1LDc3Ljg1YTcsNywwLDEsMCw5LjktOS45TDI0Ljc5LDQ3Ljg5QzU2LDQ4LjE4LDcxLDUxLjkzLDc3LjcyLDYwYzMuMjEsMy44OCw1LjE4LDkuNDIsNi4xMSwxNy43NywwLjg1LDcuNTkuODcsMTYuMjIsMC4zNCwzMy4zNmE3Ljc0LDcuNzQsMCwxLDAsMTUuNDYuNDhDMTAwLjE5LDkzLjcyLDEwMC4xNiw4NC42LDk5LjIsNzZaIi8+Cjwvc3ZnPg=="); + background-repeat: no-repeat; + -webkit-transform: translate(-50%, -50%); + transform: translate(-50%, -50%); +} + +.mapillary-direction-turn-right { + left: 55%; + background: url("data:image/svg+xml;base64,PHN2ZyBpZD0iTGF5ZXJfMSIgZGF0YS1uYW1lPSJMYXllciAxIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTE5LjA2Ij4KICAgIDxwYXRoIGZpbGw9IiNGRkZGRkYiIGQ9Ik05OS4yLDc2Qzk4LDY0LjkxLDk1LjA2LDU2LjYsODkuNTcsNTAsNzksMzcuMzgsNjAuMywzMi43MiwyNC40MiwzMi4zOUw0NC44OSwxMS45M0E3LDcsMCwwLDAsMzUsMmgwbC0zMywzM2E3LDcsMCwwLDAsMCw5Ljg4TDM1LDc3Ljg1YTcsNywwLDEsMCw5LjktOS45TDI0Ljc5LDQ3Ljg5QzU2LDQ4LjE4LDcxLDUxLjkzLDc3LjcyLDYwYzMuMjEsMy44OCw1LjE4LDkuNDIsNi4xMSwxNy43NywwLjg1LDcuNTkuODcsMTYuMjIsMC4zNCwzMy4zNmE3Ljc0LDcuNzQsMCwxLDAsMTUuNDYuNDhDMTAwLjE5LDkzLjcyLDEwMC4xNiw4NC42LDk5LjIsNzZaIi8+Cjwvc3ZnPg=="); + background-repeat: no-repeat; + -webkit-transform: translate(-50%, -50%) scaleX(-1); + transform: translate(-50%, -50%) scaleX(-1); +} + +.mapillary-direction-turn-around { + background: url("data:image/svg+xml;base64,PHN2ZyBpZD0iTGF5ZXJfMSIgZGF0YS1uYW1lPSJMYXllciAxIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgODQuMzMiPgogICAgPHBhdGggZmlsbD0iI0ZGRkZGRiIgZD0iTTY1LjQ3LDBDNTYsMCw0Ni44Niw1LjM5LDQwLjIxLDE2LjA3LDMzLjQ3LDI3LDI5LjM2LDQzLjE4LDI4LjQzLDY1TDEwLDQ2LjU5YTUuODcsNS44NywwLDAsMC04LjMsOC4zTDI5LjQ0LDgyLjYxYTUuODgsNS44OCwwLDAsMCw4LjMsMEw2NS40Nyw1NWE1Ljg4LDUuODgsMCwxLDAtOC4zMi04LjMyTDM5LjMyLDY0LjQ3YzAuOTItMTkuNTYsNC41LTMzLjcsMTAuMTEtNDIuNzIsNC43Mi03LjYyLDEwLjQ1LTExLDE2LjEtMTFhMTYuNjQsMTYuNjQsMCwwLDEsMTQuNzgsOEM4NiwyNyw4OS4yMSw0MS44NSw4OS4yMSw2NC40N2E1LjM5LDUuMzksMCwxLDAsMTAuNzksMEMxMDAuMDUsMTguNzgsODcuMjIsMCw2NS40NywwWiIvPgo8L3N2Zz4="); + background-repeat: no-repeat; + -webkit-transform: translate(-50%, -50%); + transform: translate(-50%, -50%); +} + +@media (hover: hover) { + .mapillary-direction-circle:hover, + .mapillary-direction-circle-sequence:hover { + background: rgba(0, 0, 0, 0.4); + } + + .mapillary-direction-turn-circle:hover, + .mapillary-direction-turn-circle-sequence:hover { + background: rgba(0, 0, 0, 0.4); + } +} +.mapillary-viewer { + background-color: #0F0F0F; + position: relative; + overflow: hidden; + -webkit-tap-highlight-color: rgba(0,0,0,0); +} + +.mapillary-interactive { + cursor: -webkit-grab; + cursor: grab; +} + +.mapillary-interactive:active { + cursor: -webkit-grabbing; + cursor: grabbing; +} + +.mapillary-viewer .mapillary-dom { + bottom: 0; + left: 0; + overflow: hidden; + pointer-events: none; + position: absolute; + right: 0; + top: 0; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +.mapillary-dom .mapillary-dom-renderer { + pointer-events: auto; +} + +.mapillary-viewer div { + -webkit-box-sizing: content-box; + box-sizing: content-box; +} + +.mapillary-viewer input:focus { + background: none; +} +.mapillary-navigation-sequence, +.mapillary-navigation-spatial { + position: absolute; + pointer-events: none; + z-index: 10; +} + +.mapillary-navigation-sequence { + top: 12px; + left: 50%; + margin-left: -40px; +} + +.mapillary-navigation-spatial { + bottom: 18px; + left: 50%; + margin-left: -100px; +} + +.mapillary-navigation-spatial-bottom { + margin-left: 40px; +} + +.mapillary-navigation-button { + width: 36px; + height: 36px; + background-size: contain; + background-repeat: no-repeat; + pointer-events: auto; + margin: 0px 2px; + cursor: pointer; + display: inline-block; + -webkit-filter: drop-shadow(0 2px 0 rgba(0, 0, 0, 1)); + filter: drop-shadow(0 2px 0 rgba(0, 0, 0, 1)); +} + +.mapillary-navigation-prev, +.mapillary-navigation-left { + background-image: url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTgwcHgiIGhlaWdodD0iMTgwcHgiIHZpZXdCb3g9IjAgMCAxODAgMTgwIiB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgaWQ9IlBhZ2UtMSIgZmlsbD0ibm9uZSIgdHJhbnNmb3JtPSJyb3RhdGUoLTkwIDkwLDkwKSI+PGcgaWQ9ImFycm93LXVwIiBmaWxsPSIjRkZGRkZGIj48cGF0aCBkPSJNODguODg0MzgxNyw0NS43Mzc4ODA2IEM4OC4yOTk4ODUzLDQ1Ljk0OTc5MTYgODcuNzUxNTY5Miw0Ni4yODk5Mjk5IDg3LjI4MzA4NTMsNDYuNzU4NDEzOCBMMzEuMzYzMzk0NywxMDIuNjc4MTA0IEMyOS42NDYwNjE0LDEwNC4zOTU0MzggMjkuNjUxODM2MywxMDcuMTcwNDk4IDMxLjM2NzAwMzEsMTA4Ljg4NTY2NCBMNDAuNjg2NzQ1MywxMTguMjA1NDA3IEM0Mi40MDAzOTY3LDExOS45MTkwNTggNDUuMTgxMTMxMywxMTkuOTIyMTg5IDQ2Ljg5NDMwNTIsMTE4LjIwOTAxNSBMOTAuODAwOTI3Niw3NC4zMDIzOTI2IEwxMzQuNDUwNTUsMTE3Ljk1MjAxNSBDMTM2LjE2Nzg4MywxMTkuNjY5MzQ5IDEzOC45NDI5NDMsMTE5LjY2MzU3NCAxNDAuNjU4MTEsMTE3Ljk0ODQwNyBMMTQ5Ljk3Nzg1MiwxMDguNjI4NjY1IEMxNTEuNjkxNTA0LDEwNi45MTUwMTMgMTUxLjY5NDYzNSwxMDQuMTM0Mjc5IDE0OS45ODE0NjEsMTAyLjQyMTEwNSBMOTQuMDYxNzcwMSw0Ni41MDE0MTQxIEM5Mi42NTczMTA5LDQ1LjA5Njk1NDkgOTAuNTQ1NDIzMSw0NC44NDQ5NDQ1IDg4Ljg4NDM4MTcsNDUuNzM3ODgwNiBMODguODg0MzgxNyw0NS43Mzc4ODA2IFoiIGlkPSJBcnJvdyIgPjwvcGF0aD48cGF0aCBkPSJNOTAsMTcwLjEzODgxMiBDMTM0LjI1OTQ0NCwxNzAuMTM4ODEyIDE3MC4xMzg4MTIsMTM0LjI1OTQ0NCAxNzAuMTM4ODEyLDkwIEMxNzAuMTM4ODEyLDQ1Ljc0MDU1NjIgMTM0LjI1OTQ0NCw5Ljg2MTE4Nzg1IDkwLDkuODYxMTg3ODUgQzQ1Ljc0MDU1NjIsOS44NjExODc4NSA5Ljg2MTE4Nzg1LDQ1Ljc0MDU1NjIgOS44NjExODc4NSw5MCBDOS44NjExODc4NSwxMzQuMjU5NDQ0IDQ1Ljc0MDU1NjIsMTcwLjEzODgxMiA5MCwxNzAuMTM4ODEyIEw5MCwxNzAuMTM4ODEyIEw5MCwxNzAuMTM4ODEyIFogTTAsOTAgQzAsNDAuMjk0MzcyNSA0MC4yOTQzNzI1LDAgOTAsMCBDMTM5LjcwNTYyNywwIDE4MCw0MC4yOTQzNzI1IDE4MCw5MCBDMTgwLDEzOS43MDU2MjcgMTM5LjcwNTYyNywxODAgOTAsMTgwIEM0MC4yOTQzNzI1LDE4MCAwLDEzOS43MDU2MjcgMCw5MCBaIiBpZD0iQ2lyY2xlIj48L3BhdGg+PC9nPjwvZz48L3N2Zz4="); +} + +.DirectionNext, +.DirectionRight { + background-image: url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTgwcHgiIGhlaWdodD0iMTgwcHgiIHZpZXdCb3g9IjAgMCAxODAgMTgwIiB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgaWQ9IlBhZ2UtMSIgZmlsbD0ibm9uZSIgdHJhbnNmb3JtPSJyb3RhdGUoOTAgOTAsOTApIj48ZyBpZD0iYXJyb3ctdXAiIGZpbGw9IiNGRkZGRkYiPjxwYXRoIGQ9Ik04OC44ODQzODE3LDQ1LjczNzg4MDYgQzg4LjI5OTg4NTMsNDUuOTQ5NzkxNiA4Ny43NTE1NjkyLDQ2LjI4OTkyOTkgODcuMjgzMDg1Myw0Ni43NTg0MTM4IEwzMS4zNjMzOTQ3LDEwMi42NzgxMDQgQzI5LjY0NjA2MTQsMTA0LjM5NTQzOCAyOS42NTE4MzYzLDEwNy4xNzA0OTggMzEuMzY3MDAzMSwxMDguODg1NjY0IEw0MC42ODY3NDUzLDExOC4yMDU0MDcgQzQyLjQwMDM5NjcsMTE5LjkxOTA1OCA0NS4xODExMzEzLDExOS45MjIxODkgNDYuODk0MzA1MiwxMTguMjA5MDE1IEw5MC44MDA5Mjc2LDc0LjMwMjM5MjYgTDEzNC40NTA1NSwxMTcuOTUyMDE1IEMxMzYuMTY3ODgzLDExOS42NjkzNDkgMTM4Ljk0Mjk0MywxMTkuNjYzNTc0IDE0MC42NTgxMSwxMTcuOTQ4NDA3IEwxNDkuOTc3ODUyLDEwOC42Mjg2NjUgQzE1MS42OTE1MDQsMTA2LjkxNTAxMyAxNTEuNjk0NjM1LDEwNC4xMzQyNzkgMTQ5Ljk4MTQ2MSwxMDIuNDIxMTA1IEw5NC4wNjE3NzAxLDQ2LjUwMTQxNDEgQzkyLjY1NzMxMDksNDUuMDk2OTU0OSA5MC41NDU0MjMxLDQ0Ljg0NDk0NDUgODguODg0MzgxNyw0NS43Mzc4ODA2IEw4OC44ODQzODE3LDQ1LjczNzg4MDYgWiIgaWQ9IkFycm93IiA+PC9wYXRoPjxwYXRoIGQ9Ik05MCwxNzAuMTM4ODEyIEMxMzQuMjU5NDQ0LDE3MC4xMzg4MTIgMTcwLjEzODgxMiwxMzQuMjU5NDQ0IDE3MC4xMzg4MTIsOTAgQzE3MC4xMzg4MTIsNDUuNzQwNTU2MiAxMzQuMjU5NDQ0LDkuODYxMTg3ODUgOTAsOS44NjExODc4NSBDNDUuNzQwNTU2Miw5Ljg2MTE4Nzg1IDkuODYxMTg3ODUsNDUuNzQwNTU2MiA5Ljg2MTE4Nzg1LDkwIEM5Ljg2MTE4Nzg1LDEzNC4yNTk0NDQgNDUuNzQwNTU2MiwxNzAuMTM4ODEyIDkwLDE3MC4xMzg4MTIgTDkwLDE3MC4xMzg4MTIgTDkwLDE3MC4xMzg4MTIgWiBNMCw5MCBDMCw0MC4yOTQzNzI1IDQwLjI5NDM3MjUsMCA5MCwwIEMxMzkuNzA1NjI3LDAgMTgwLDQwLjI5NDM3MjUgMTgwLDkwIEMxODAsMTM5LjcwNTYyNyAxMzkuNzA1NjI3LDE4MCA5MCwxODAgQzQwLjI5NDM3MjUsMTgwIDAsMTM5LjcwNTYyNyAwLDkwIFoiIGlkPSJDaXJjbGUiPjwvcGF0aD48L2c+PC9nPjwvc3ZnPg=="); +} + +.mapillary-navigation-forward { + background-image: url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTgwcHgiIGhlaWdodD0iMTgwcHgiIHZpZXdCb3g9IjAgMCAxODAgMTgwIiB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgaWQ9IlBhZ2UtMSIgZmlsbD0ibm9uZSIgdHJhbnNmb3JtPSJyb3RhdGUoMCA5MCw5MCkiPjxnIGlkPSJhcnJvdy11cCIgZmlsbD0iI0ZGRkZGRiI+PHBhdGggZD0iTTg4Ljg4NDM4MTcsNDUuNzM3ODgwNiBDODguMjk5ODg1Myw0NS45NDk3OTE2IDg3Ljc1MTU2OTIsNDYuMjg5OTI5OSA4Ny4yODMwODUzLDQ2Ljc1ODQxMzggTDMxLjM2MzM5NDcsMTAyLjY3ODEwNCBDMjkuNjQ2MDYxNCwxMDQuMzk1NDM4IDI5LjY1MTgzNjMsMTA3LjE3MDQ5OCAzMS4zNjcwMDMxLDEwOC44ODU2NjQgTDQwLjY4Njc0NTMsMTE4LjIwNTQwNyBDNDIuNDAwMzk2NywxMTkuOTE5MDU4IDQ1LjE4MTEzMTMsMTE5LjkyMjE4OSA0Ni44OTQzMDUyLDExOC4yMDkwMTUgTDkwLjgwMDkyNzYsNzQuMzAyMzkyNiBMMTM0LjQ1MDU1LDExNy45NTIwMTUgQzEzNi4xNjc4ODMsMTE5LjY2OTM0OSAxMzguOTQyOTQzLDExOS42NjM1NzQgMTQwLjY1ODExLDExNy45NDg0MDcgTDE0OS45Nzc4NTIsMTA4LjYyODY2NSBDMTUxLjY5MTUwNCwxMDYuOTE1MDEzIDE1MS42OTQ2MzUsMTA0LjEzNDI3OSAxNDkuOTgxNDYxLDEwMi40MjExMDUgTDk0LjA2MTc3MDEsNDYuNTAxNDE0MSBDOTIuNjU3MzEwOSw0NS4wOTY5NTQ5IDkwLjU0NTQyMzEsNDQuODQ0OTQ0NSA4OC44ODQzODE3LDQ1LjczNzg4MDYgTDg4Ljg4NDM4MTcsNDUuNzM3ODgwNiBaIiBpZD0iQXJyb3ciID48L3BhdGg+PHBhdGggZD0iTTkwLDE3MC4xMzg4MTIgQzEzNC4yNTk0NDQsMTcwLjEzODgxMiAxNzAuMTM4ODEyLDEzNC4yNTk0NDQgMTcwLjEzODgxMiw5MCBDMTcwLjEzODgxMiw0NS43NDA1NTYyIDEzNC4yNTk0NDQsOS44NjExODc4NSA5MCw5Ljg2MTE4Nzg1IEM0NS43NDA1NTYyLDkuODYxMTg3ODUgOS44NjExODc4NSw0NS43NDA1NTYyIDkuODYxMTg3ODUsOTAgQzkuODYxMTg3ODUsMTM0LjI1OTQ0NCA0NS43NDA1NTYyLDE3MC4xMzg4MTIgOTAsMTcwLjEzODgxMiBMOTAsMTcwLjEzODgxMiBMOTAsMTcwLjEzODgxMiBaIE0wLDkwIEMwLDQwLjI5NDM3MjUgNDAuMjk0MzcyNSwwIDkwLDAgQzEzOS43MDU2MjcsMCAxODAsNDAuMjk0MzcyNSAxODAsOTAgQzE4MCwxMzkuNzA1NjI3IDEzOS43MDU2MjcsMTgwIDkwLDE4MCBDNDAuMjk0MzcyNSwxODAgMCwxMzkuNzA1NjI3IDAsOTAgWiIgaWQ9IkNpcmNsZSI+PC9wYXRoPjwvZz48L2c+PC9zdmc+"); +} + +.mapillary-navigation-backward { + background-image: url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTgwcHgiIGhlaWdodD0iMTgwcHgiIHZpZXdCb3g9IjAgMCAxODAgMTgwIiB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgaWQ9IlBhZ2UtMSIgZmlsbD0ibm9uZSIgdHJhbnNmb3JtPSJyb3RhdGUoMTgwIDkwLDkwKSI+PGcgaWQ9ImFycm93LXVwIiBmaWxsPSIjRkZGRkZGIj48cGF0aCBkPSJNODguODg0MzgxNyw0NS43Mzc4ODA2IEM4OC4yOTk4ODUzLDQ1Ljk0OTc5MTYgODcuNzUxNTY5Miw0Ni4yODk5Mjk5IDg3LjI4MzA4NTMsNDYuNzU4NDEzOCBMMzEuMzYzMzk0NywxMDIuNjc4MTA0IEMyOS42NDYwNjE0LDEwNC4zOTU0MzggMjkuNjUxODM2MywxMDcuMTcwNDk4IDMxLjM2NzAwMzEsMTA4Ljg4NTY2NCBMNDAuNjg2NzQ1MywxMTguMjA1NDA3IEM0Mi40MDAzOTY3LDExOS45MTkwNTggNDUuMTgxMTMxMywxMTkuOTIyMTg5IDQ2Ljg5NDMwNTIsMTE4LjIwOTAxNSBMOTAuODAwOTI3Niw3NC4zMDIzOTI2IEwxMzQuNDUwNTUsMTE3Ljk1MjAxNSBDMTM2LjE2Nzg4MywxMTkuNjY5MzQ5IDEzOC45NDI5NDMsMTE5LjY2MzU3NCAxNDAuNjU4MTEsMTE3Ljk0ODQwNyBMMTQ5Ljk3Nzg1MiwxMDguNjI4NjY1IEMxNTEuNjkxNTA0LDEwNi45MTUwMTMgMTUxLjY5NDYzNSwxMDQuMTM0Mjc5IDE0OS45ODE0NjEsMTAyLjQyMTEwNSBMOTQuMDYxNzcwMSw0Ni41MDE0MTQxIEM5Mi42NTczMTA5LDQ1LjA5Njk1NDkgOTAuNTQ1NDIzMSw0NC44NDQ5NDQ1IDg4Ljg4NDM4MTcsNDUuNzM3ODgwNiBMODguODg0MzgxNyw0NS43Mzc4ODA2IFoiIGlkPSJBcnJvdyIgPjwvcGF0aD48cGF0aCBkPSJNOTAsMTcwLjEzODgxMiBDMTM0LjI1OTQ0NCwxNzAuMTM4ODEyIDE3MC4xMzg4MTIsMTM0LjI1OTQ0NCAxNzAuMTM4ODEyLDkwIEMxNzAuMTM4ODEyLDQ1Ljc0MDU1NjIgMTM0LjI1OTQ0NCw5Ljg2MTE4Nzg1IDkwLDkuODYxMTg3ODUgQzQ1Ljc0MDU1NjIsOS44NjExODc4NSA5Ljg2MTE4Nzg1LDQ1Ljc0MDU1NjIgOS44NjExODc4NSw5MCBDOS44NjExODc4NSwxMzQuMjU5NDQ0IDQ1Ljc0MDU1NjIsMTcwLjEzODgxMiA5MCwxNzAuMTM4ODEyIEw5MCwxNzAuMTM4ODEyIEw5MCwxNzAuMTM4ODEyIFogTTAsOTAgQzAsNDAuMjk0MzcyNSA0MC4yOTQzNzI1LDAgOTAsMCBDMTM5LjcwNTYyNywwIDE4MCw0MC4yOTQzNzI1IDE4MCw5MCBDMTgwLDEzOS43MDU2MjcgMTM5LjcwNTYyNywxODAgOTAsMTgwIEM0MC4yOTQzNzI1LDE4MCAwLDEzOS43MDU2MjcgMCw5MCBaIiBpZD0iQ2lyY2xlIj48L3BhdGg+PC9nPjwvZz48L3N2Zz4="); +} + +.mapillary-navigation-turn-left { + background-image: url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTgwcHgiIGhlaWdodD0iMTgwcHgiIHZpZXdCb3g9IjAgMCAxODAgMTgwIiB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgaWQ9IlBhZ2UtMSIgZmlsbD0ibm9uZSIgdHJhbnNmb3JtPSJyb3RhdGUoLTQ1IDkwLDkwKSI+PGcgaWQ9ImFycm93LXVwIiBmaWxsPSIjRkZGRkZGIj48cGF0aCBkPSJNODguODg0MzgxNyw0NS43Mzc4ODA2IEM4OC4yOTk4ODUzLDQ1Ljk0OTc5MTYgODcuNzUxNTY5Miw0Ni4yODk5Mjk5IDg3LjI4MzA4NTMsNDYuNzU4NDEzOCBMMzEuMzYzMzk0NywxMDIuNjc4MTA0IEMyOS42NDYwNjE0LDEwNC4zOTU0MzggMjkuNjUxODM2MywxMDcuMTcwNDk4IDMxLjM2NzAwMzEsMTA4Ljg4NTY2NCBMNDAuNjg2NzQ1MywxMTguMjA1NDA3IEM0Mi40MDAzOTY3LDExOS45MTkwNTggNDUuMTgxMTMxMywxMTkuOTIyMTg5IDQ2Ljg5NDMwNTIsMTE4LjIwOTAxNSBMOTAuODAwOTI3Niw3NC4zMDIzOTI2IEwxMzQuNDUwNTUsMTE3Ljk1MjAxNSBDMTM2LjE2Nzg4MywxMTkuNjY5MzQ5IDEzOC45NDI5NDMsMTE5LjY2MzU3NCAxNDAuNjU4MTEsMTE3Ljk0ODQwNyBMMTQ5Ljk3Nzg1MiwxMDguNjI4NjY1IEMxNTEuNjkxNTA0LDEwNi45MTUwMTMgMTUxLjY5NDYzNSwxMDQuMTM0Mjc5IDE0OS45ODE0NjEsMTAyLjQyMTEwNSBMOTQuMDYxNzcwMSw0Ni41MDE0MTQxIEM5Mi42NTczMTA5LDQ1LjA5Njk1NDkgOTAuNTQ1NDIzMSw0NC44NDQ5NDQ1IDg4Ljg4NDM4MTcsNDUuNzM3ODgwNiBMODguODg0MzgxNyw0NS43Mzc4ODA2IFoiIGlkPSJBcnJvdyIgPjwvcGF0aD48cGF0aCBkPSJNOTAsMTcwLjEzODgxMiBDMTM0LjI1OTQ0NCwxNzAuMTM4ODEyIDE3MC4xMzg4MTIsMTM0LjI1OTQ0NCAxNzAuMTM4ODEyLDkwIEMxNzAuMTM4ODEyLDQ1Ljc0MDU1NjIgMTM0LjI1OTQ0NCw5Ljg2MTE4Nzg1IDkwLDkuODYxMTg3ODUgQzQ1Ljc0MDU1NjIsOS44NjExODc4NSA5Ljg2MTE4Nzg1LDQ1Ljc0MDU1NjIgOS44NjExODc4NSw5MCBDOS44NjExODc4NSwxMzQuMjU5NDQ0IDQ1Ljc0MDU1NjIsMTcwLjEzODgxMiA5MCwxNzAuMTM4ODEyIEw5MCwxNzAuMTM4ODEyIEw5MCwxNzAuMTM4ODEyIFogTTAsOTAgQzAsNDAuMjk0MzcyNSA0MC4yOTQzNzI1LDAgOTAsMCBDMTM5LjcwNTYyNywwIDE4MCw0MC4yOTQzNzI1IDE4MCw5MCBDMTgwLDEzOS43MDU2MjcgMTM5LjcwNTYyNywxODAgOTAsMTgwIEM0MC4yOTQzNzI1LDE4MCAwLDEzOS43MDU2MjcgMCw5MCBaIiBpZD0iQ2lyY2xlIj48L3BhdGg+PC9nPjwvZz48L3N2Zz4="); +} + +.mapillary-navigation-turn-right { + background-image: url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTgwcHgiIGhlaWdodD0iMTgwcHgiIHZpZXdCb3g9IjAgMCAxODAgMTgwIiB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgaWQ9IlBhZ2UtMSIgZmlsbD0ibm9uZSIgdHJhbnNmb3JtPSJyb3RhdGUoNDUgOTAsOTApIj48ZyBpZD0iYXJyb3ctdXAiIGZpbGw9IiNGRkZGRkYiPjxwYXRoIGQ9Ik04OC44ODQzODE3LDQ1LjczNzg4MDYgQzg4LjI5OTg4NTMsNDUuOTQ5NzkxNiA4Ny43NTE1NjkyLDQ2LjI4OTkyOTkgODcuMjgzMDg1Myw0Ni43NTg0MTM4IEwzMS4zNjMzOTQ3LDEwMi42NzgxMDQgQzI5LjY0NjA2MTQsMTA0LjM5NTQzOCAyOS42NTE4MzYzLDEwNy4xNzA0OTggMzEuMzY3MDAzMSwxMDguODg1NjY0IEw0MC42ODY3NDUzLDExOC4yMDU0MDcgQzQyLjQwMDM5NjcsMTE5LjkxOTA1OCA0NS4xODExMzEzLDExOS45MjIxODkgNDYuODk0MzA1MiwxMTguMjA5MDE1IEw5MC44MDA5Mjc2LDc0LjMwMjM5MjYgTDEzNC40NTA1NSwxMTcuOTUyMDE1IEMxMzYuMTY3ODgzLDExOS42NjkzNDkgMTM4Ljk0Mjk0MywxMTkuNjYzNTc0IDE0MC42NTgxMSwxMTcuOTQ4NDA3IEwxNDkuOTc3ODUyLDEwOC42Mjg2NjUgQzE1MS42OTE1MDQsMTA2LjkxNTAxMyAxNTEuNjk0NjM1LDEwNC4xMzQyNzkgMTQ5Ljk4MTQ2MSwxMDIuNDIxMTA1IEw5NC4wNjE3NzAxLDQ2LjUwMTQxNDEgQzkyLjY1NzMxMDksNDUuMDk2OTU0OSA5MC41NDU0MjMxLDQ0Ljg0NDk0NDUgODguODg0MzgxNyw0NS43Mzc4ODA2IEw4OC44ODQzODE3LDQ1LjczNzg4MDYgWiIgaWQ9IkFycm93IiA+PC9wYXRoPjxwYXRoIGQ9Ik05MCwxNzAuMTM4ODEyIEMxMzQuMjU5NDQ0LDE3MC4xMzg4MTIgMTcwLjEzODgxMiwxMzQuMjU5NDQ0IDE3MC4xMzg4MTIsOTAgQzE3MC4xMzg4MTIsNDUuNzQwNTU2MiAxMzQuMjU5NDQ0LDkuODYxMTg3ODUgOTAsOS44NjExODc4NSBDNDUuNzQwNTU2Miw5Ljg2MTE4Nzg1IDkuODYxMTg3ODUsNDUuNzQwNTU2MiA5Ljg2MTE4Nzg1LDkwIEM5Ljg2MTE4Nzg1LDEzNC4yNTk0NDQgNDUuNzQwNTU2MiwxNzAuMTM4ODEyIDkwLDE3MC4xMzg4MTIgTDkwLDE3MC4xMzg4MTIgTDkwLDE3MC4xMzg4MTIgWiBNMCw5MCBDMCw0MC4yOTQzNzI1IDQwLjI5NDM3MjUsMCA5MCwwIEMxMzkuNzA1NjI3LDAgMTgwLDQwLjI5NDM3MjUgMTgwLDkwIEMxODAsMTM5LjcwNTYyNyAxMzkuNzA1NjI3LDE4MCA5MCwxODAgQzQwLjI5NDM3MjUsMTgwIDAsMTM5LjcwNTYyNyAwLDkwIFoiIGlkPSJDaXJjbGUiPjwvcGF0aD48L2c+PC9nPjwvc3ZnPg=="); +} + +.mapillary-navigation-turn-around { + background-image: url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTgwcHgiIGhlaWdodD0iMTgwcHgiIHZpZXdCb3g9IjAgMCAxODAgMTgwIiB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgaWQ9IlBhZ2UtMSIgZmlsbD0ibm9uZSIgdHJhbnNmb3JtPSJyb3RhdGUoLTEzNSA5MCw5MCkiPjxnIGlkPSJhcnJvdy11cCIgZmlsbD0iI0ZGRkZGRiI+PHBhdGggZD0iTTg4Ljg4NDM4MTcsNDUuNzM3ODgwNiBDODguMjk5ODg1Myw0NS45NDk3OTE2IDg3Ljc1MTU2OTIsNDYuMjg5OTI5OSA4Ny4yODMwODUzLDQ2Ljc1ODQxMzggTDMxLjM2MzM5NDcsMTAyLjY3ODEwNCBDMjkuNjQ2MDYxNCwxMDQuMzk1NDM4IDI5LjY1MTgzNjMsMTA3LjE3MDQ5OCAzMS4zNjcwMDMxLDEwOC44ODU2NjQgTDQwLjY4Njc0NTMsMTE4LjIwNTQwNyBDNDIuNDAwMzk2NywxMTkuOTE5MDU4IDQ1LjE4MTEzMTMsMTE5LjkyMjE4OSA0Ni44OTQzMDUyLDExOC4yMDkwMTUgTDkwLjgwMDkyNzYsNzQuMzAyMzkyNiBMMTM0LjQ1MDU1LDExNy45NTIwMTUgQzEzNi4xNjc4ODMsMTE5LjY2OTM0OSAxMzguOTQyOTQzLDExOS42NjM1NzQgMTQwLjY1ODExLDExNy45NDg0MDcgTDE0OS45Nzc4NTIsMTA4LjYyODY2NSBDMTUxLjY5MTUwNCwxMDYuOTE1MDEzIDE1MS42OTQ2MzUsMTA0LjEzNDI3OSAxNDkuOTgxNDYxLDEwMi40MjExMDUgTDk0LjA2MTc3MDEsNDYuNTAxNDE0MSBDOTIuNjU3MzEwOSw0NS4wOTY5NTQ5IDkwLjU0NTQyMzEsNDQuODQ0OTQ0NSA4OC44ODQzODE3LDQ1LjczNzg4MDYgTDg4Ljg4NDM4MTcsNDUuNzM3ODgwNiBaIiBpZD0iQXJyb3ciID48L3BhdGg+PHBhdGggZD0iTTkwLDE3MC4xMzg4MTIgQzEzNC4yNTk0NDQsMTcwLjEzODgxMiAxNzAuMTM4ODEyLDEzNC4yNTk0NDQgMTcwLjEzODgxMiw5MCBDMTcwLjEzODgxMiw0NS43NDA1NTYyIDEzNC4yNTk0NDQsOS44NjExODc4NSA5MCw5Ljg2MTE4Nzg1IEM0NS43NDA1NTYyLDkuODYxMTg3ODUgOS44NjExODc4NSw0NS43NDA1NTYyIDkuODYxMTg3ODUsOTAgQzkuODYxMTg3ODUsMTM0LjI1OTQ0NCA0NS43NDA1NTYyLDE3MC4xMzg4MTIgOTAsMTcwLjEzODgxMiBMOTAsMTcwLjEzODgxMiBMOTAsMTcwLjEzODgxMiBaIE0wLDkwIEMwLDQwLjI5NDM3MjUgNDAuMjk0MzcyNSwwIDkwLDAgQzEzOS43MDU2MjcsMCAxODAsNDAuMjk0MzcyNSAxODAsOTAgQzE4MCwxMzkuNzA1NjI3IDEzOS43MDU2MjcsMTgwIDkwLDE4MCBDNDAuMjk0MzcyNSwxODAgMCwxMzkuNzA1NjI3IDAsOTAgWiIgaWQ9IkNpcmNsZSI+PC9wYXRoPjwvZz48L2c+PC9zdmc+"); +} +.mapillary-popup { + position: absolute; + left: 0; + top: 0; + pointer-events: none; + display: -webkit-box; + display: -ms-flexbox; + display: flex; +} + +.mapillary-popup-content, +.mapillary-popup-content-clean { + position: relative; +} + +.mapillary-popup-capture-pointer { + pointer-events: auto; +} + +.mapillary-popup-content { + padding: 5px 10px; + background: white; + border: 1px solid black; + border-radius: 2px; +} + +.mapillary-popup-float-bottom, +.mapillary-popup-float-bottom-left, +.mapillary-popup-float-bottom-right { + -webkit-box-orient: vertical; + -webkit-box-direction: normal; + -ms-flex-direction: column; + flex-direction: column; +} + +.mapillary-popup-float-top, +.mapillary-popup-float-top-left, +.mapillary-popup-float-top-right { + -webkit-box-orient: vertical; + -webkit-box-direction: reverse; + -ms-flex-direction: column-reverse; + flex-direction: column-reverse; +} + +.mapillary-popup-float-left { + -webkit-box-orient: horizontal; + -webkit-box-direction: reverse; + -ms-flex-direction: row-reverse; + flex-direction: row-reverse; +} + +.mapillary-popup-float-right { + -webkit-box-orient: horizontal; + -webkit-box-direction: normal; + -ms-flex-direction: row; + flex-direction: row; +} + +.mapillary-popup-tip, +.mapillary-popup-tip-inner { + position: relative; + z-index: 1; + width: 0; + height: 0; + border: 7px solid transparent; +} + +.mapillary-popup-float-top .mapillary-popup-tip { + -ms-flex-item-align: center; + align-self: center; + border-top-color:black; + border-bottom: none; +} + +.mapillary-popup-float-top .mapillary-popup-tip-inner { + position: relative; + top: -8px; + left: -7px; + border-top-color: white; + border-bottom: none; +} + +.mapillary-popup-float-top-right .mapillary-popup-tip { + -ms-flex-item-align: start; + align-self: flex-start; + border-top-color:black; + border-bottom: none; + border-left: none; +} + +.mapillary-popup-float-top-right .mapillary-popup-tip-inner { + position: relative; + top: -9px; + left: 1px; + border-top-color: white; + border-bottom: none; + border-left: none; +} + +.mapillary-popup-float-top-left .mapillary-popup-tip { + -ms-flex-item-align: end; + align-self: flex-end; + border-top-color:black; + border-bottom: none; + border-right: none; +} + +.mapillary-popup-float-top-left .mapillary-popup-tip-inner { + position: relative; + top: -9px; + left: -8px; + border-top-color: white; + border-bottom: none; + border-right: none; +} + +.mapillary-popup-float-bottom .mapillary-popup-tip { + -ms-flex-item-align: center; + align-self: center; + border-top: none; + border-bottom-color: black; +} + +.mapillary-popup-float-bottom .mapillary-popup-tip-inner { + position: relative; + bottom: -1px; + left: -7px; + border-top: none; + border-bottom-color: white; +} + +.mapillary-popup-float-bottom-right .mapillary-popup-tip { + -ms-flex-item-align: start; + align-self: flex-start; + border-bottom-color:black; + border-top: none; + border-left: none; +} + +.mapillary-popup-float-bottom-right .mapillary-popup-tip-inner { + position: relative; + bottom: -2px; + left: 1px; + border-bottom-color: white; + border-top: none; + border-left: none; +} + +.mapillary-popup-float-bottom-left .mapillary-popup-tip { + -ms-flex-item-align: end; + align-self: flex-end; + border-bottom-color:black; + border-top: none; + border-right: none; +} + +.mapillary-popup-float-bottom-left .mapillary-popup-tip-inner { + position: relative; + bottom: -2px; + left: -8px; + border-bottom-color: white; + border-top: none; + border-right: none; +} + +.mapillary-popup-float-left .mapillary-popup-tip { + -ms-flex-item-align: center; + align-self: center; + border-left-color: black; + border-right: none; +} + +.mapillary-popup-float-left .mapillary-popup-tip-inner { + position: relative; + top: -7px; + left: -8px; + border-left-color: white; + border-right: none; +} + +.mapillary-popup-float-right .mapillary-popup-tip { + -ms-flex-item-align: center; + align-self: center; + border-right-color: black; + border-left: none; +} + +.mapillary-popup-float-right .mapillary-popup-tip-inner { + position: relative; + top: -7px; + left: 1px; + border-right-color: white; + border-left: none; +} + +.mapillary-popup-float-top-left .mapillary-popup-content { + border-bottom-right-radius: 0; +} + +.mapillary-popup-float-top-right .mapillary-popup-content { + border-bottom-left-radius: 0; +} + +.mapillary-popup-float-bottom-left .mapillary-popup-content { + border-top-right-radius: 0; +} + +.mapillary-popup-float-bottom-right .mapillary-popup-content { + border-top-left-radius: 0; +} +.mapillary-sequence-container { + position: absolute; + top: 14px; + pointer-events: none; + font-size: 0; + z-index: 10; + width: 100%; +} + +.mapillary-sequence-stepper, +.mapillary-sequence-controls { + position: absolute; + left: 50%; + cursor: pointer; +} + +.mapillary-sequence-stepper { + -webkit-transform: translate(-50%, 0); + transform: translate(-50%, 0); +} + +.mapillary-sequence-expander-button, +.mapillary-sequence-playback-button, +.mapillary-sequence-timeline-button { + display: inline-block; + height: 100%; + pointer-events: auto; +} + +.mapillary-sequence-expander-button { + width: calc(100% * 16 / 88); + background: rgba(0, 0, 0, 0.5); +} + +.mapillary-sequence-expander-bar { + background-color: white; + width: 12.5%; + border-radius: 1px; + height: calc(100% * 16 / 30); + position: relative; + left: 50%; + top: 50%; + -webkit-transform: translate(-50%, -50%); + transform: translate(-50%, -50%); +} + +.mapillary-sequence-playback-button, +.mapillary-sequence-timeline-button { + width: 0; + background: rgba(0, 0, 0, 0.5); + -webkit-transition: width 0.15s ease-out; + transition: width 0.15s ease-out; +} + +.mapillary-sequence-speed-container, +.mapillary-sequence-position-container, +.mapillary-sequence-position-container-inactive { + display: inline-block; + height: 100%; + vertical-align: top; + background: rgba(0, 0, 0, 0.5); + pointer-events: auto; + padding-right: 3px; +} + +.mapillary-sequence-speed-container { + padding-left: 3px; +} + +.mapillary-sequence-position-container, +.mapillary-sequence-position-container-inactive { + padding-left: 16px; +} + +.mapillary-sequence-speed, +.mapillary-sequence-position { + margin: 0; + padding: 0; + margin-top: 7px; + height: 16px; + cursor: pointer; + outline: none; + background: none; + -webkit-appearance: none; +} + +.mapillary-sequence-position-container-inactive .mapillary-sequence-position { + cursor: default; +} + +.mapillary-sequence-fast-icon, +.mapillary-sequence-fast-icon-gray, +.mapillary-sequence-timeline-icon, +.mapillary-sequence-timeline-icon-gray, +.mapillary-sequence-slow-icon, +.mapillary-sequence-switch-icon, +.mapillary-sequence-close-icon { + position: relative; + left: 50%; + top: 50%; + -webkit-transform: translate(-50%, -50%); + transform: translate(-50%, -50%); + background-size: contain; + background-repeat: no-repeat; + opacity: 0; +} + +.mapillary-sequence-fast-icon, +.mapillary-sequence-timeline-icon { + -webkit-transition-delay: 0s; + transition-delay: 0s; + -webkit-transition-property: opacity; + transition-property: opacity; +} + +.mapillary-sequence-fast-icon, +.mapillary-sequence-fast-icon-gray, +.mapillary-sequence-timeline-icon, +.mapillary-sequence-timeline-icon-gray, +.mapillary-sequence-slow-icon { + width: calc(100% * 24 / 36); + height: calc(100% * 24 / 30); +} + +.mapillary-sequence-switch-icon, +.mapillary-sequence-close-icon { + width: calc(100% * 16 / 36); + height: calc(100% * 16 / 30); +} + +.mapillary-sequence-fast-icon { + background-image: url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayI+CiAgICA8dGl0bGU+CiAgICAgIGZhc3QKICAgIDwvdGl0bGU+CiAgICA8ZGVmcz4KICAgICAgPHBhdGggZD0iTTIwLjE0ODQgMTMuNWMtMi4xNTE3IDAtMi41NDc4Ljg2Mi0zLjU2NzYgMS4zNjA1LTEuODUzMy45MDYtMy4wOTkzIDIuMDcyNi0zLjA5OTMgNC4yNjQ1IDAgMS4xNjE0LS4wMTAzIDEuODc1LS4zNzAzIDEuODc1LS42NTc1IDAtLjk5MzgtLjY5MS0xLjI5ODgtMS4yMi0uMjA4Mi4wNjktLjQyNjcuMTAxMi0uNjQ1Ny4wOTUtLjg0MzcgMC01LjQ2My0xLjY4NjctNS40NjMtMi42MjUgMC0uNTUxLjM3MDMtLjg0Ny4zNzAzLTEuNXYtLjAxYy0uMDE2NS0uNTUzNC0uMjg2Ny0xLjA2NzMtLjczMDgtMS4zOS0uNTk0My0uNDMwNS0xLjA0NC0xLjAzNS0xLjI4ODItMS43MzIzLS4xNzkyLjA4NjMtLjM3NS4xMzE1LS41NzM1LjEzMjNDMi43NzA1IDEyLjc1IDIgMTEuODczIDIgMTAuNWMwLTEuMzY5NC4yNjYtMS44NzUuNTU1Ni0xLjg3NS4zNzU0IDAgLjQ2My4xODc1LjQ2My4xODc1cy4yNTY4LS45NDEuNzc5OC0uOTQxYy4yNzEyLjAwMzcuNTE3Ny4xNjAyLjYzOS40MDU3Ljg4MDItMS4wMDk3IDIuMTU5LTEuNTY5NiAzLjQ4ODQtMS41MjcyIDUuMzcyIDAgNS43Mjk3IDMuMzc1IDcuNDA3NSAzLjM3NS42MjE0IDAgMS4xMTEtLjg5MjcgMS4xMTEtMS4xMjUgMC0uNzIwNi0zLjMzMzMtMi4wNzU3LTMuMzMzMy01LjI1IDAtLjQ5Ni4zOTM1LS43NS45MjYtLjc1LjQ2MyAwIDEuNDUxMy42MTA0IDIuMDM3IDEuNS44OTUgMS4zNTk0Ljc1OSAzIDEuODUyIDMuNzUuNDA5LjI4MDggMS40NTE4LjI2MDMgMi4yMjIuNzUuNzcwNS40ODk3IDEuMjQ2NCAxLjMwMjcgMS40ODE2IDEuNS4yMTcuMTkwMy4zNTAyLjQ2LjM3MDQuNzUgMCAuMzYxMy0uODA1MyAyLjI1LTEuODUxNiAyLjI1em0tOC44ODkgM2MtLjk5MDctLjAxODgtMS45NjY1LS4yNDg0LTIuODY0LS42NzM4LS4wNTkyLjIxOTgtLjA5MjUuNDQ2LS4wOTkuNjczOCAwIC42MjczLjQ3OTcuODY4IDIuMjIyIDEuNS4zOTE2LjEzNC43Njk3LjMwNTIgMS4xMjkzLjUxMTMuMDQwNC0uNjgzLjExNTQtMS4zNjMyLjIyNS0yLjAzODMtLjIwMDQuMDE0My0uNDAyMi4wMjctLjYxMzMuMDI3eiIgaWQ9ImEiLz4KICAgIDwvZGVmcz4KICAgIDx1c2UgZmlsbD0iI0ZGRiIgZmlsbC1ydWxlPSJub256ZXJvIiB4bGluazpocmVmPSIjYSIvPgogIDwvc3ZnPg=="); +} + +.mapillary-sequence-fast-icon-gray { + background-image: url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayI+CiAgPHRpdGxlPgogICAgZmFzdAogIDwvdGl0bGU+CiAgPGRlZnM+CiAgICA8cGF0aCBkPSJNMjAuMTQ4NCAxMy41Yy0yLjE1MTcgMC0yLjU0NzguODYyLTMuNTY3NiAxLjM2MDUtMS44NTMzLjkwNi0zLjA5OTMgMi4wNzI2LTMuMDk5MyA0LjI2NDUgMCAxLjE2MTQtLjAxMDMgMS44NzUtLjM3MDMgMS44NzUtLjY1NzUgMC0uOTkzOC0uNjkxLTEuMjk4OC0xLjIyLS4yMDgyLjA2OS0uNDI2Ny4xMDEyLS42NDU3LjA5NS0uODQzNyAwLTUuNDYzLTEuNjg2Ny01LjQ2My0yLjYyNSAwLS41NTEuMzcwMy0uODQ3LjM3MDMtMS41di0uMDFjLS4wMTY1LS41NTM0LS4yODY3LTEuMDY3My0uNzMwOC0xLjM5LS41OTQzLS40MzA1LTEuMDQ0LTEuMDM1LTEuMjg4Mi0xLjczMjMtLjE3OTIuMDg2My0uMzc1LjEzMTUtLjU3MzUuMTMyM0MyLjc3MDUgMTIuNzUgMiAxMS44NzMgMiAxMC41YzAtMS4zNjk0LjI2Ni0xLjg3NS41NTU2LTEuODc1LjM3NTQgMCAuNDYzLjE4NzUuNDYzLjE4NzVzLjI1NjgtLjk0MS43Nzk4LS45NDFjLjI3MTIuMDAzNy41MTc3LjE2MDIuNjM5LjQwNTcuODgwMi0xLjAwOTcgMi4xNTktMS41Njk2IDMuNDg4NC0xLjUyNzIgNS4zNzIgMCA1LjcyOTcgMy4zNzUgNy40MDc1IDMuMzc1LjYyMTQgMCAxLjExMS0uODkyNyAxLjExMS0xLjEyNSAwLS43MjA2LTMuMzMzMy0yLjA3NTctMy4zMzMzLTUuMjUgMC0uNDk2LjM5MzUtLjc1LjkyNi0uNzUuNDYzIDAgMS40NTEzLjYxMDQgMi4wMzcgMS41Ljg5NSAxLjM1OTQuNzU5IDMgMS44NTIgMy43NS40MDkuMjgwOCAxLjQ1MTguMjYwMyAyLjIyMi43NS43NzA1LjQ4OTcgMS4yNDY0IDEuMzAyNyAxLjQ4MTYgMS41LjIxNy4xOTAzLjM1MDIuNDYuMzcwNC43NSAwIC4zNjEzLS44MDUzIDIuMjUtMS44NTE2IDIuMjV6bS04Ljg4OSAzYy0uOTkwNy0uMDE4OC0xLjk2NjUtLjI0ODQtMi44NjQtLjY3MzgtLjA1OTIuMjE5OC0uMDkyNS40NDYtLjA5OS42NzM4IDAgLjYyNzMuNDc5Ny44NjggMi4yMjIgMS41LjM5MTYuMTM0Ljc2OTcuMzA1MiAxLjEyOTMuNTExMy4wNDA0LS42ODMuMTE1NC0xLjM2MzIuMjI1LTIuMDM4My0uMjAwNC4wMTQzLS40MDIyLjAyNy0uNjEzMy4wMjd6IiBpZD0iYSIvPgogIDwvZGVmcz4KICA8dXNlIGZpbGw9IiNEN0RBREQiIGZpbGwtcnVsZT0ibm9uemVybyIgeGxpbms6aHJlZj0iI2EiLz4KPC9zdmc+"); +} + +.mapillary-sequence-timeline-icon { + background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCI+PGRlZnM+ICA8cGF0aCBpZD0ic2NydWJiZXItYSIgZD0iTTksNiBMMTUsNiBDMTYuMTA0NTY5NSw2IDE3LDYuODk1NDMwNSAxNyw4IEwxNywxNiBDMTcsMTcuMTA0NTY5NSAxNi4xMDQ1Njk1LDE4IDE1LDE4IEw5LDE4IEM3Ljg5NTQzMDUsMTggNywxNy4xMDQ1Njk1IDcsMTYgTDcsOCBDNyw2Ljg5NTQzMDUgNy44OTU0MzA1LDYgOSw2IFogTTIwLjYzMzY4ODEsMTMgTDE5LDEzIEMxOC40NDc3MTUzLDEzIDE4LDEyLjU1MjI4NDcgMTgsMTIgQzE4LDExLjQ0NzcxNTMgMTguNDQ3NzE1MywxMSAxOSwxMSBMMjAuNjMzNjg4MSwxMSBDMjAuNDEyNjQzLDEwLjYxNzU2OTMgMjAuNDY1NzExMywxMC4xMjAwNzUxIDIwLjc5Mjg5MzIsOS43OTI4OTMyMiBDMjEuMTgzNDE3NSw5LjQwMjM2ODkzIDIxLjgxNjU4MjUsOS40MDIzNjg5MyAyMi4yMDcxMDY4LDkuNzkyODkzMjIgTDIzLjcwNzEwNjgsMTEuMjkyODkzMiBDMjMuODg4MDcxMiwxMS40NzM4NTc2IDI0LDExLjcyMzg1NzYgMjQsMTIgQzI0LDEyLjI3NjE0MjQgMjMuODg4MDcxMiwxMi41MjYxNDI0IDIzLjcwNzEwNjgsMTIuNzA3MTA2OCBMMjIuMjA3MTA2OCwxNC4yMDcxMDY4IEMyMS44MTY1ODI1LDE0LjU5NzYzMTEgMjEuMTgzNDE3NSwxNC41OTc2MzExIDIwLjc5Mjg5MzIsMTQuMjA3MTA2OCBDMjAuNDY1NzExMywxMy44Nzk5MjQ5IDIwLjQxMjY0MywxMy4zODI0MzA3IDIwLjYzMzY4ODEsMTMgWiBNMy4zNjYzMTE4NiwxMyBDMy41ODczNTcwNCwxMy4zODI0MzA3IDMuNTM0Mjg4NjgsMTMuODc5OTI0OSAzLjIwNzEwNjc4LDE0LjIwNzEwNjggQzIuODE2NTgyNDksMTQuNTk3NjMxMSAyLjE4MzQxNzUxLDE0LjU5NzYzMTEgMS43OTI4OTMyMiwxNC4yMDcxMDY4IEwwLjI5Mjg5MzIxOSwxMi43MDcxMDY4IEMwLjExMTkyODgxMywxMi41MjYxNDI0IDAsMTIuMjc2MTQyNCAwLDEyIEMwLDExLjcyMzg1NzYgMC4xMTE5Mjg4MTMsMTEuNDczODU3NiAwLjI5Mjg5MzIxOSwxMS4yOTI4OTMyIEwxLjc5Mjg5MzIyLDkuNzkyODkzMjIgQzIuMTgzNDE3NTEsOS40MDIzNjg5MyAyLjgxNjU4MjQ5LDkuNDAyMzY4OTMgMy4yMDcxMDY3OCw5Ljc5Mjg5MzIyIEMzLjUzNDI4ODY4LDEwLjEyMDA3NTEgMy41ODczNTcwNCwxMC42MTc1NjkzIDMuMzY2MzExODYsMTEgTDUsMTEgQzUuNTUyMjg0NzUsMTEgNiwxMS40NDc3MTUzIDYsMTIgQzYsMTIuNTUyMjg0NyA1LjU1MjI4NDc1LDEzIDUsMTMgTDMuMzY2MzExODYsMTMgWiIvPjwvZGVmcz48ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPiAgPHVzZSBmaWxsPSIjRkZGIiB4bGluazpocmVmPSIjc2NydWJiZXItYSIvPjwvZz4gIDwvc3ZnPiAg"); +} + +.mapillary-sequence-timeline-icon-gray { + background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCI+PGRlZnM+ICA8cGF0aCBpZD0ic2NydWJiZXItYSIgZD0iTTksNiBMMTUsNiBDMTYuMTA0NTY5NSw2IDE3LDYuODk1NDMwNSAxNyw4IEwxNywxNiBDMTcsMTcuMTA0NTY5NSAxNi4xMDQ1Njk1LDE4IDE1LDE4IEw5LDE4IEM3Ljg5NTQzMDUsMTggNywxNy4xMDQ1Njk1IDcsMTYgTDcsOCBDNyw2Ljg5NTQzMDUgNy44OTU0MzA1LDYgOSw2IFogTTIwLjYzMzY4ODEsMTMgTDE5LDEzIEMxOC40NDc3MTUzLDEzIDE4LDEyLjU1MjI4NDcgMTgsMTIgQzE4LDExLjQ0NzcxNTMgMTguNDQ3NzE1MywxMSAxOSwxMSBMMjAuNjMzNjg4MSwxMSBDMjAuNDEyNjQzLDEwLjYxNzU2OTMgMjAuNDY1NzExMywxMC4xMjAwNzUxIDIwLjc5Mjg5MzIsOS43OTI4OTMyMiBDMjEuMTgzNDE3NSw5LjQwMjM2ODkzIDIxLjgxNjU4MjUsOS40MDIzNjg5MyAyMi4yMDcxMDY4LDkuNzkyODkzMjIgTDIzLjcwNzEwNjgsMTEuMjkyODkzMiBDMjMuODg4MDcxMiwxMS40NzM4NTc2IDI0LDExLjcyMzg1NzYgMjQsMTIgQzI0LDEyLjI3NjE0MjQgMjMuODg4MDcxMiwxMi41MjYxNDI0IDIzLjcwNzEwNjgsMTIuNzA3MTA2OCBMMjIuMjA3MTA2OCwxNC4yMDcxMDY4IEMyMS44MTY1ODI1LDE0LjU5NzYzMTEgMjEuMTgzNDE3NSwxNC41OTc2MzExIDIwLjc5Mjg5MzIsMTQuMjA3MTA2OCBDMjAuNDY1NzExMywxMy44Nzk5MjQ5IDIwLjQxMjY0MywxMy4zODI0MzA3IDIwLjYzMzY4ODEsMTMgWiBNMy4zNjYzMTE4NiwxMyBDMy41ODczNTcwNCwxMy4zODI0MzA3IDMuNTM0Mjg4NjgsMTMuODc5OTI0OSAzLjIwNzEwNjc4LDE0LjIwNzEwNjggQzIuODE2NTgyNDksMTQuNTk3NjMxMSAyLjE4MzQxNzUxLDE0LjU5NzYzMTEgMS43OTI4OTMyMiwxNC4yMDcxMDY4IEwwLjI5Mjg5MzIxOSwxMi43MDcxMDY4IEMwLjExMTkyODgxMywxMi41MjYxNDI0IDAsMTIuMjc2MTQyNCAwLDEyIEMwLDExLjcyMzg1NzYgMC4xMTE5Mjg4MTMsMTEuNDczODU3NiAwLjI5Mjg5MzIxOSwxMS4yOTI4OTMyIEwxLjc5Mjg5MzIyLDkuNzkyODkzMjIgQzIuMTgzNDE3NTEsOS40MDIzNjg5MyAyLjgxNjU4MjQ5LDkuNDAyMzY4OTMgMy4yMDcxMDY3OCw5Ljc5Mjg5MzIyIEMzLjUzNDI4ODY4LDEwLjEyMDA3NTEgMy41ODczNTcwNCwxMC42MTc1NjkzIDMuMzY2MzExODYsMTEgTDUsMTEgQzUuNTUyMjg0NzUsMTEgNiwxMS40NDc3MTUzIDYsMTIgQzYsMTIuNTUyMjg0NyA1LjU1MjI4NDc1LDEzIDUsMTMgTDMuMzY2MzExODYsMTMgWiIvPjwvZGVmcz48ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPiAgPHVzZSBmaWxsPSIjRDdEQUREIiB4bGluazpocmVmPSIjc2NydWJiZXItYSIvPjwvZz4gIDwvc3ZnPiAg"); +} + +.mapillary-sequence-slow-icon { + background-image: url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayI+PHRpdGxlPiAgc2xvdzwvdGl0bGU+PGRlZnM+ICA8cGF0aCBkPSJNMTguOTMzNyAxMy4yMjIybC0uNzgxNiAxLjg5NjNjLjMzMTcuMjUwMyAxLjMzNSAxLjEwMDIgMS4zMzUgMi4yNTE2LS4wMjIzLjMxMy0uMTU4LjYwNy0uMzgxNi44MjctLjA3NjguMDgwMy0uMTgzNC4xMjU0LS4yOTQ2LjEyNDNoLTIuNjU4Yy0uMTQ1MiAwLS4yNzk2LS4wNzY3LS4zNTM0LS4yMDE3bC0uNzEzNi0xLjIwNzRjLS4xMjAyLS4yMDM3LS4xNDY4LS40NDkyLS4wNzMtLjY3NGwuMTI0OC0uMzgwNUg3LjRjLjM2MS40MjM0LjU2ODguOTU2My41ODk4IDEuNTEyNS0uMDIyLjMxMy0uMTU3Ny42MDctLjM4MTMuODI2Ny0uMDc2Ny4wODA0LS4xODM0LjEyNTUtLjI5NDYuMTI0NEg0LjY1NmMtLjE0NTIgMC0uMjc5Ni0uMDc2Ny0uMzUzNS0uMjAxN2wtLjcxMzYtMS4yMDc1Yy0uMTIwNC0uMjAzNi0uMTQ3LS40NDkyLS4wNzMyLS42NzRsLjAwODItLjAyNDZjLS42MjkuNDgzMi0xLjM4NC43NzQ3LTIuMTc0My44Mzk2LS4xMTk3LjAxNzYtLjIzODUtLjAzNzItLjMwMy0uMTM5Ny0uMDY0My0uMTAyNS0uMDYyLS4yMzM0LjAwNTctLjMzMzdDMy40MjQ2IDEzLjQwMTUgMi43MDggNi4wMDIyIDExLjMxMyA2LjAwMjJjMi42OTQ1LS4wNTkgNS4yNzU1IDEuMDg1IDcuMDQxNyAzLjEyMTcuNzg2LS4yNDIyIDEuNDY1LS43NDY3IDEuOTI0Mi0xLjQyOTIuMzUzLS40NTM0Ljk3MjQtLjYwMyAxLjQ5MzUtLjM2MDdsMS45ODg3LjkyNWMuMTY0NS4wNzYzLjI1OTguMjUxLjIzNS40MzA4LS4yMDI1IDEuNDU1NC0xLjc4ODYgMy43NjYyLTUuMDYyNSA0LjUzMjR6IiBpZD0iYSIvPjwvZGVmcz48dXNlIGZpbGw9IiNGRkYiIGZpbGwtcnVsZT0ibm9uemVybyIgeGxpbms6aHJlZj0iI2EiLz4gIDwvc3ZnPiAg"); +} + +.mapillary-sequence-switch-icon { + background-image: url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICAgIDx0aXRsZT4KICAgICAgcGxheV9kaXJlY3Rpb24KICAgIDwvdGl0bGU+CiAgICA8cGF0aCBkPSJNNiA0aDhjLjU1MjMgMCAxIC40NDc3IDEgMXMtLjQ0NzcgMS0xIDFINnYyLjA1ODRjLS4wMDM0LjE2NjgtLjExMTguMzE4Mi0uMjgxNS4zOTM0LS4xNjk4LjA3NS0uMzczLjA2MTctLjUyOC0uMDM1TC4yMTQzIDUuMzU4NEMuMDggNS4yNzcgMCA1LjE0MyAwIDVjMC0uMTQzLjA4LS4yNzcuMjE0My0uMzU4NWw0Ljk3NjItMy4wNTgzYy4xNTUtLjA5NjcuMzU4My0uMTEuNTI4LS4wMzUuMTY5OC4wNzUyLjI3OC4yMjY2LjI4MTUuMzkzNFY0em00IDZWNy45NDE2Yy4wMDM0LS4xNjY4LjExMTctLjMxODIuMjgxNS0uMzkzNC4xNjk3LS4wNzUuMzczLS4wNjE3LjUyOC4wMzVsNC45NzYyIDMuMDU4M0MxNS45MiAxMC43MjMgMTYgMTAuODU3IDE2IDExYzAgLjE0My0uMDguMjc3LS4yMTQzLjM1ODRsLTQuOTc2MiAzLjA1ODRjLS4xNTUuMDk2Ny0uMzU4Mi4xMS0uNTI4LjAzNS0uMTY5Ny0uMDc1Mi0uMjc4LS4yMjY2LS4yODE1LS4zOTM0VjEySDJjLS41NTIzIDAtMS0uNDQ3Ny0xLTFzLjQ0NzctMSAxLTFoOHoiIGZpbGw9IiNGRkYiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPgogIDwvc3ZnPg=="); +} + +.mapillary-sequence-close-icon { + background-image: url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICAgIDx0aXRsZT4KICAgICAgY2xvc2UKICAgIDwvdGl0bGU+CiAgICA8cGF0aCBkPSJNOS4yNzM0IDguMDA0N2wzLjQ0MTMgMy40NDE0Yy4yMzE1LjIyOS4zMjI3LjU2NDYuMjM5Ljg3OTMtLjA4MzguMzE0Ny0uMzI5Ni41NjA0LS42NDQzLjY0NC0uMzE0Ny4wODQtLjY1LS4wMDczLS44NzktLjIzODhMNy45ODg4IDkuMjg5bC0zLjQzNjUgMy40MzY1Yy0uMzU2LjM0Ni0uOTI0LjM0MTctMS4yNzUtLjAwOTMtLjM1MS0uMzUxLS4zNTUtLjkxOS0uMDA5LTEuMjc1bDMuNDM2Mi0zLjQzNjQtMy40NDk1LTMuNDQ5NmMtLjM0NDQtLjM1NjMtLjMzOTYtLjkyMy4wMTA4LTEuMjczNC4zNTA0LS4zNTA0LjkxNy0uMzU1MiAxLjI3MzMtLjAxMDhMNy45ODkgNi43MjA1bDMuNDU0NC0zLjQ1NDVjLjIyOTQtLjIyOTQuNTYzOC0uMzE5Ljg3NzItLjIzNS4zMTM1LjA4NC41NTgzLjMyODcuNjQyMy42NDIyLjA4MzguMzEzNC0uMDA1OC42NDc4LS4yMzUyLjg3NzJMOS4yNzM0IDguMDA0N3oiIGZpbGw9IiNGRkYiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPgogIDwvc3ZnPg=="); +} + +.mapillary-sequence-icon-visible { + opacity: 1; +} + +.mapillary-sequence-controls-expanded .mapillary-sequence-fast-icon, +.mapillary-sequence-controls-expanded .mapillary-sequence-timeline-icon { + opacity: 1; + -webkit-transition-delay: 0.12s; + transition-delay: 0.12s; + -webkit-transition: 0.2s ease-in; + transition: 0.2s ease-in; + -webkit-transition-property: opacity; + transition-property: opacity; +} + +.mapillary-sequence-controls-expanded .mapillary-sequence-playback-button, +.mapillary-sequence-controls-expanded .mapillary-sequence-timeline-button { + width: calc(100% * 36 / 88); +} + +.mapillary-sequence-playback, +.mapillary-sequence-timeline { + position: absolute; + height: 30px; + width: 405px; + max-width: 80%; + min-width: 271px; + left: 50%; + padding-left: 5px; + -webkit-transform: translate(-50%, 0); + transform: translate(-50%, 0); +} + +.mapillary-sequence-switch-button, +.mapillary-sequence-switch-button-inactive, +.mapillary-sequence-slow-container, +.mapillary-sequence-fast-container, +.mapillary-sequence-close-button { + width: 36px; + height: 100%; + display: inline-block; + pointer-events: auto; + background: rgba(0, 0, 0, 0.5); +} + +.mapillary-sequence-switch-button, +.mapillary-sequence-switch-button-inactive, +.mapillary-sequence-close-button { + cursor: pointer; +} + +.mapillary-sequence-play, +.mapillary-sequence-play-inactive, +.mapillary-sequence-stop, +.mapillary-sequence-step-next, +.mapillary-sequence-step-next-inactive, +.mapillary-sequence-step-next-highlight, +.mapillary-sequence-step-prev, +.mapillary-sequence-step-prev-inactive, +.mapillary-sequence-step-prev-highlight { + display: inline-block; + width: calc(100% / 3); + height: 100%; + pointer-events: auto; +} + +.mapillary-sequence-play-inactive, +.mapillary-sequence-step-next-inactive, +.mapillary-sequence-step-prev-inactive { + cursor: default; +} + +.mapillary-sequence-play, +.mapillary-sequence-play-inactive, +.mapillary-sequence-stop, +.mapillary-sequence-step-next, +.mapillary-sequence-step-next-inactive, +.mapillary-sequence-step-prev, +.mapillary-sequence-step-prev-inactive { + background: rgba(0, 0, 0, 0.5); +} + +.mapillary-sequence-step-next-highlight, +.mapillary-sequence-step-prev-highlight { + background: rgba(0, 0, 0, 0.5); +} + +.mapillary-sequence-switch-button, +.mapillary-sequence-switch-button-inactive, +.mapillary-sequence-position-container, +.mapillary-sequence-position-container-inactive { + border-bottom-left-radius: 8px; + border-top-left-radius: 8px; +} + +.mapillary-sequence-close-button { + border-bottom-right-radius: 8px; + border-top-right-radius: 8px; +} + +.mapillary-sequence-play .mapillary-sequence-icon, +.mapillary-sequence-play-inactive .mapillary-sequence-icon { + background-image: url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICA8dGl0bGU+CiAgICBwbGF5CiAgPC90aXRsZT4KICA8cGF0aCBkPSJNMTQuODk1MiA4LjY4NjVMMy41MDgyIDE1Ljc1N0MyLjY3ODMgMTYuMzAyIDIgMTUuODk4IDIgMTQuODY1VjEuMTM2QzIgLjEgMi42NzgzLS4zMDEgMy41MDk2LjI0M2wxMS4zODcgNy4wNzNjLS4wMDE0IDAgLjQwNDMuMjg2Ny40MDQzLjY4NTMgMCAuMzk4OC0uNDA1OC42ODU1LS40MDU4LjY4NTV6IiBmaWxsPSIjRkZGIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiLz4KPC9zdmc+Cg=="); +} + +.mapillary-sequence-stop .mapillary-sequence-icon, +.mapillary-sequence-stop-inactive .mapillary-sequence-icon { + background-image: url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICA8dGl0bGU+CiAgICBzdG9wCiAgPC90aXRsZT4KICA8cGF0aCBkPSJNMCAxLjk5NEMwIC44OTMuODk0NSAwIDEuOTk0IDBoMTIuMDEyQzE1LjEwNyAwIDE2IC44OTQ1IDE2IDEuOTk0djEyLjAxMkMxNiAxNS4xMDcgMTUuMTA1NSAxNiAxNC4wMDYgMTZIMS45OTRDLjg5MyAxNiAwIDE1LjEwNTUgMCAxNC4wMDZWMS45OTR6IiBmaWxsPSIjRkZGIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiLz4KPC9zdmc+Cg=="); +} + +.mapillary-sequence-step-prev .mapillary-sequence-icon, +.mapillary-sequence-step-prev-inactive .mapillary-sequence-icon, +.mapillary-sequence-step-prev-highlight .mapillary-sequence-icon { + background-image: url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICA8dGl0bGU+CiAgICBjaGV2cm9ubGVmdAogIDwvdGl0bGU+CiAgPHBhdGggZD0iTTYuMjY4IDguMDAwMmw1LjUyMTUtNS45NDczYy40NzEtLjQ2OTcuNDcxLTEuMjI5NiAwLTEuNzAxLS40NzEtLjQ2OTQtMS4yMzIzLS40Njk0LTEuNzAzNCAwTDMuMzUzNCA3LjE0OGMtLjQ3MS40NzE0LS40NzEgMS4yMzMgMCAxLjcwMjZsNi43MzI4IDYuNzk2Yy40Njk1LjQ3MyAxLjIzMjQuNDY5NSAxLjcwMzUgMCAuNDcxLS40Njc4LjQ3MS0xLjIyOTUgMC0xLjcwMDhMNi4yNjggOC4wMDAyeiIgZmlsbD0iI0ZGRiIgZmlsbC1ydWxlPSJldmVub2RkIi8+Cjwvc3ZnPgo="); +} + +.mapillary-sequence-step-next .mapillary-sequence-icon, +.mapillary-sequence-step-next-inactive .mapillary-sequence-icon, +.mapillary-sequence-step-next-highlight .mapillary-sequence-icon { + background-image: url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICA8dGl0bGU+CiAgICBjaGV2cm9ucmlnaHQKICA8L3RpdGxlPgogIDxwYXRoIGQ9Ik05Ljg3NSA4LjAwMDJMNC4zNTMyIDIuMDUzYy0uNDcxLS40Njk3LS40NzEtMS4yMjk2IDAtMS43MDEuNDcxMi0uNDY5NCAxLjIzMjMtLjQ2OTQgMS43MDM0IDBsNi43MzI4IDYuNzk2Yy40NzEuNDcxNC40NzEgMS4yMzMgMCAxLjcwMjZsLTYuNzMyOCA2Ljc5NmMtLjQ2OTMuNDczLTEuMjMyMi40Njk1LTEuNzAzNCAwLS40NzEtLjQ2NzgtLjQ3MS0xLjIyOTUgMC0xLjcwMDhMOS44NzUgOC4wMDAyeiIgZmlsbD0iI0ZGRiIgZmlsbC1ydWxlPSJldmVub2RkIi8+Cjwvc3ZnPgo="); +} + +.mapillary-sequence-play-inactive .mapillary-sequence-icon, +.mapillary-sequence-stop-inactive .mapillary-sequence-icon, +.mapillary-sequence-step-next-inactive .mapillary-sequence-icon, +.mapillary-sequence-step-prev-inactive .mapillary-sequence-icon, +.mapillary-sequence-switch-button-inactive .mapillary-sequence-switch-icon { + opacity: 0.35; +} + +.mapillary-sequence-icon { + position: relative; + left: 50%; + top: 50%; + -webkit-transform: translate(-50%, -50%); + transform: translate(-50%, -50%); + width: calc(100% * 16 / 36); + height: calc(100% * 16 / 30); + background-size: contain; + background-repeat: no-repeat; +} + +.mapillary-sequence-speed::-webkit-slider-thumb { + -webkit-appearance: none; + width: 16px; + height: 16px; + border-radius: 50%; + border: 2px solid white; + background: white; + cursor: pointer; + margin-top: -6px; +} + +.mapillary-sequence-position::-webkit-slider-thumb { + -webkit-appearance: none; + width: 16px; + height: 16px; + border-radius: 50%; + border: 2px solid white; + background: white; + cursor: pointer; + margin-top: -6px; +} + +.mapillary-sequence-position-container-inactive + .mapillary-sequence-position::-webkit-slider-thumb { + border: none; + background: none; + -webkit-box-shadow: none; + box-shadow: none; +} + +.mapillary-sequence-speed::-webkit-slider-runnable-track, +.mapillary-sequence-position::-webkit-slider-runnable-track { + height: 4px; + border-radius: 2px; + background-color: white; +} + +.mapillary-sequence-position-container-inactive + .mapillary-sequence-position::-webkit-slider-runnable-track { + background-color: #d7dadd; +} + +.mapillary-sequence-speed::-moz-focus-outer, +.mapillary-sequence-position::-moz-focus-outer { + border: 0; +} + +.mapillary-sequence-speed::-moz-range-track, +.mapillary-sequence-position::-moz-range-track { + height: 4px; + border-radius: 2px; + background-color: white; +} + +.mapillary-sequence-position-container-inactive + .mapillary-sequence-position::-moz-range-track { + background-color: #d7dadd; +} + +.mapillary-sequence-speed::-moz-range-thumb { + width: 12px; + height: 12px; + border-radius: 50%; + border: 2px solid white; + background: #dee5ed; + cursor: pointer; + margin-top: -6px; + box-shadow: 1px 2px 4px 0 rgba(0, 0, 0, 0.5); +} + +.mapillary-sequence-position::-moz-range-thumb { + width: 12px; + height: 12px; + border-radius: 50%; + border: 2px solid white; + background: #ff861b; + cursor: pointer; + margin-top: -6px; + box-shadow: 1px 2px 4px 0 rgba(0, 0, 0, 0.5); +} + +.mapillary-sequence-position-container-inactive + .mapillary-sequence-position::-moz-range-thumb { + border: none; + background: none; + box-shadow: none; +} + +.mapillary-sequence-speed::-ms-track, +.mapillary-sequence-position::-ms-track { + height: 2px; + border-radius: 2px; + background-color: white; +} + +.mapillary-sequence-position-container-inactive + .mapillary-sequence-position::-ms-track { + background-color: #d7dadd; +} + +.mapillary-sequence-speed::-ms-fill-lower, +.mapillary-sequence-position::-ms-fill-lower { + height: 0; + background: none; +} + +.mapillary-sequence-speed::-ms-fill-upper, +.mapillary-sequence-position::-ms-fill-upper { + height: 0; + background: none; +} + +.mapillary-sequence-speed::-ms-thumb { + width: 12px; + height: 12px; + border-radius: 50%; + border: 2px solid white; + background: white; + cursor: pointer; + margin-top: 0; +} + +.mapillary-sequence-position::-ms-thumb { + width: 12px; + height: 12px; + border-radius: 50%; + border: 2px solid white; + background: white; + cursor: pointer; + margin-top: 0; +} + +.mapillary-sequence-position-container-inactive + .mapillary-sequence-position::-ms-thumb { + border: none; + background: none; + box-shadow: none; +} + +.mapillary-sequence-speed::-ms-tooltip, +.mapillary-sequence-position::-ms-tooltip { + display: none; +} + +@media (hover: hover) { + .mapillary-sequence-play:hover, + .mapillary-sequence-stop:hover, + .mapillary-sequence-step-prev:hover, + .mapillary-sequence-step-next:hover { + background: rgba(0, 0, 0, 0.5); + } + + .mapillary-sequence-play-inactive:hover, + .mapillary-sequence-step-prev-inactive:hover, + .mapillary-sequence-step-next-inactive:hover { + background: rgba(0, 0, 0, 0.5); + } + + .mapillary-sequence-switch-button-inactive:hover { + background: rgba(0, 0, 0, 0.5); + } + + .mapillary-sequence-playback-button:hover, + .mapillary-sequence-timeline-button:hover, + .mapillary-sequence-switch-button:hover, + .mapillary-sequence-close-button:hover { + background: rgba(0, 0, 0, 0.5); + } +} +.mapillary-slider-container { + position: absolute; + bottom: 40px; + height: 30px; + left: 50%; + padding-left: 5px; + pointer-events: none; + -webkit-transform: translate(-50%, 0); + transform: translate(-50%, 0); + z-index: 10; +} + +.mapillary-slider-border { + display: inline-block; + width: 8px; + height: 100%; + border-bottom-left-radius: 8px; + border-top-left-radius: 8px; + pointer-events: auto; + cursor: default; + background: rgba(0, 0, 0, 0.5); +} + +.mapillary-slider-mode-button, +.mapillary-slider-mode-button-2d, +.mapillary-slider-mode-button-inactive, +.mapillary-slider-mode-button-2d-inactive { + width: 26px; + height: 100%; + display: inline-block; + pointer-events: auto; + background: rgba(0, 0, 0, 0.5); + +} + +.mapillary-slider-mode-button, +.mapillary-slider-mode-button-2d { + cursor: default; +} + +.mapillary-slider-mode-button-inactive, +.mapillary-slider-mode-button-2d-inactive { + cursor: pointer; +} + +.mapillary-slider-mode-icon, +.mapillary-slider-mode-icon-2d { + position: relative; + top: 50%; + -webkit-transform: translate(0, -50%); + transform: translate(0, -50%); + background-size: contain; + background-repeat: no-repeat; + opacity: 1; + width: 18px; + height: 18px; + margin-left: 4px; +} + +.mapillary-slider-mode-icon { + background-image: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTE4IDE3di44NjRjMCAuNTA1IDAgLjk5Ni0uMDM0IDEuNDEyLS4wMzcuNDU4LS4xMjUuOTk3LS40MDIgMS41NGE0IDQgMCAwIDEtMS43NDggMS43NDhjLS41NDMuMjc3LTEuMDgyLjM2NS0xLjU0LjQwMi0uNDE2LjAzNC0uOTA3LjAzNC0xLjQxMi4wMzRINi4xMzZjLS41MDUgMC0uOTk2IDAtMS40MTItLjAzNC0uNDU4LS4wMzctLjk5Ny0uMTI1LTEuNTQtLjQwMmE0IDQgMCAwIDEtMS43NDgtMS43NDhjLS4yNzctLjU0My0uMzY1LTEuMDgyLS40MDItMS41NEExOC4zNiAxOC4zNiAwIDAgMSAxIDE3Ljg2NHYtNi43MjhjMC0uNTA1IDAtLjk5Ni4wMzQtMS40MTIuMDM3LS40NTguMTI1LS45OTcuNDAyLTEuNTRhNCA0IDAgMCAxIDEuNzQ4LTEuNzQ4Yy41NDMtLjI3NyAxLjA4Mi0uMzY1IDEuNTQtLjQwMkM1LjE0IDYgNS42MyA2IDYuMTM2IDZIN3YyaC0uOGMtMS4xMiAwLTEuNjggMC0yLjEwOC4yMThhMiAyIDAgMCAwLS44NzQuODc0QzMgOS41MiAzIDEwLjA4IDMgMTEuMnY2LjZjMCAxLjEyIDAgMS42OC4yMTggMi4xMDhhMiAyIDAgMCAwIC44NzQuODc0QzQuNTIgMjEgNS4wOCAyMSA2LjIgMjFoNi42YzEuMTIgMCAxLjY4IDAgMi4xMDgtLjIxOGEyIDIgMCAwIDAgLjg3NC0uODc0QzE2IDE5LjQ4IDE2IDE4LjEyIDE2IDE3aDJ6TTE2LjUxIDcuMjM2djMuNzFoLjc4YzEuMDk0IDAgMS42Ny0uNTYzIDEuNjctMS44NiAwLTEuMjkzLS41NzYtMS44NS0xLjY2Ny0xLjg1aC0uNzg0eiIgZmlsbD0iI2ZmZiIvPjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNNy4zMjcgMi42MzhDNyAzLjI4IDcgNC4xMiA3IDUuOHY2LjRjMCAxLjY4IDAgMi41Mi4zMjcgMy4xNjJhMyAzIDAgMCAwIDEuMzExIDEuMzExQzkuMjggMTcgMTAuMTIgMTcgMTEuOCAxN2g2LjRjMS42OCAwIDIuNTIgMCAzLjE2Mi0uMzI3YTMgMyAwIDAgMCAxLjMxMS0xLjMxMUMyMyAxNC43MiAyMyAxMy44OCAyMyAxMi4yVjUuOGMwLTEuNjggMC0yLjUyLS4zMjctMy4xNjJhMyAzIDAgMCAwLTEuMzExLTEuMzExQzIwLjcyIDEgMTkuODggMSAxOC4yIDFoLTYuNGMtMS42OCAwLTIuNTIgMC0zLjE2Mi4zMjdhMyAzIDAgMCAwLTEuMzExIDEuMzExem03LjAzNCA3LjcyOWMuMDAzLjk5NC0uOTMyIDEuNzEyLTIuMjM5IDEuNzEyLTEuMjc4IDAtMi4xODctLjcwNy0yLjIwNy0xLjcyNGgxLjIzOGMuMDIzLjQyNi40My43MS45NzIuNzEuNTYzIDAgLjk2LS4zMTguOTU3LS43OC4wMDMtLjQ3LS40MDktLjgtMS4wNjgtLjhoLS41NDJ2LS45MDNoLjU0MmMuNTYgMCAuOTUtLjMxLjk0Ni0uNzY3LjAwMy0uNDM3LS4zMy0uNzM4LS44MjctLjczOC0uNTAyIDAtLjkwOS4yODctLjkyMy43M2gtMS4xNzljLjAxNy0xLjAwNi45MTUtMS43MDUgMi4xMDgtMS43MDUgMS4yMDUgMCAyLjAwOS43MjIgMi4wMDYgMS42NC4wMDMuNjUzLS40NDYgMS4xMTMtMS4wOTcgMS4yM3YuMDQ1Yy44NTUuMTA4IDEuMzE2LjYyNSAxLjMxMyAxLjM1em01LjgyNy0xLjI4MmMwIDEuODE4LTEuMDc0IDIuOTE1LTIuODQ2IDIuOTE1aC0yLjA2M1Y2LjE4MmgyLjA4YzEuNzU1IDAgMi44MyAxLjA5IDIuODMgMi45MDN6IiBmaWxsPSIjZmZmIi8+PC9zdmc+'); +} + +.mapillary-slider-mode-icon-2d { + background-image: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTEzLjM0NSAxMC4yMzZ2My43MWguNzgxYzEuMDk0IDAgMS42Ny0uNTYzIDEuNjctMS44NiAwLTEuMjkzLS41NzYtMS44NS0xLjY2Ny0xLjg1aC0uNzg0eiIgZmlsbD0iI2ZmZiIvPjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNNC4zMjcgNS42MzhDNCA2LjI4IDQgNy4xMiA0IDguOHY2LjRjMCAxLjY4IDAgMi41Mi4zMjcgMy4xNjJhMyAzIDAgMCAwIDEuMzExIDEuMzExQzYuMjggMjAgNy4xMiAyMCA4LjggMjBoNi40YzEuNjggMCAyLjUyIDAgMy4xNjItLjMyN2EzIDMgMCAwIDAgMS4zMTEtMS4zMTFDMjAgMTcuNzIgMjAgMTYuODggMjAgMTUuMlY4LjhjMC0xLjY4IDAtMi41Mi0uMzI3LTMuMTYyYTMgMyAwIDAgMC0xLjMxMS0xLjMxMUMxNy43MiA0IDE2Ljg4IDQgMTUuMiA0SDguOGMtMS42OCAwLTIuNTIgMC0zLjE2Mi4zMjdhMyAzIDAgMCAwLTEuMzExIDEuMzExek0xMS4xODcgMTVINy4wNHYtLjg4NmwyLjA3LTEuOTE4Yy41MTgtLjUuODEtLjgyNy44MTMtMS4yOS0uMDAzLS41MTEtLjM4OS0uODMtLjkwMy0uODMtLjUzNCAwLS44OTguMzM5LS44OTguODk4SDYuOTU1Yy0uMDAzLTEuMTQyLjg1NS0xLjg3MiAyLjA3My0xLjg3MiAxLjI1NiAwIDIuMDQ2Ljc4MiAyLjA1MSAxLjc5LjAwMy43NDQtLjM4NiAxLjI5My0xLjQxMSAyLjIzbC0uODU1LjgzMnYuMDRoMi4zNzRWMTV6bTUuODM3LTIuOTE1YzAgMS44MTgtMS4wNzQgMi45MTUtMi44NDYgMi45MTVoLTIuMDYzVjkuMTgyaDIuMDhjMS43NTUgMCAyLjgzIDEuMDkgMi44MyAyLjkwM3oiIGZpbGw9IiNmZmYiLz48L3N2Zz4gIA=='); +} + +.mapillary-slider-mode-button-inactive .mapillary-slider-mode-icon { + opacity: 0.35; +} + +.mapillary-slider-mode-button-2d-inactive .mapillary-slider-mode-icon-2d { + opacity: 0.35; +} + +.mapillary-slider-position-container { + display: inline-block; + height: 100%; + vertical-align: top; + background: rgba(0, 0, 0, 0.5); + pointer-events: auto; + padding-right: 16px; + border-bottom-right-radius: 8px; + border-top-right-radius: 8px; + padding-left: 8px; +} + +.mapillary-slider-position { + pointer-events: auto; + margin: 0; + padding: 0; + margin-top: 7px; + height: 16px; + cursor: pointer; + outline: none; + background: none; + -webkit-appearance: none; +} + +.mapillary-slider-position::-webkit-slider-thumb { + -webkit-appearance: none; + width: 16px; + height: 16px; + border-radius: 50%; + border: 2px solid white; + background: white; + cursor: pointer; + margin-top: -6px; +} + +.mapillary-slider-position::-webkit-slider-runnable-track { + height: 4px; + border-radius: 2px; + background-color: white; +} + +.mapillary-slider-position::-moz-focus-outer { + border: 0; +} + +.mapillary-slider-position::-moz-range-track { + height: 4px; + border-radius: 2px; + background-color: white; +} + +.mapillary-slider-position::-moz-range-thumb { + width: 12px; + height: 12px; + border-radius: 50%; + border: 2px solid white; + background: white; + cursor: pointer; + margin-top: -6px; +} + +.mapillary-slider-position::-ms-track { + height: 2px; + border-radius: 2px; + background-color: white; +} + +.mapillary-slider-position::-ms-fill-lower { + height: 0; + background: none; +} + +.mapillary-slider-position::-ms-fill-upper { + height: 0; + background: none; +} + +.mapillary-slider-position::-ms-thumb { + width: 12px; + height: 12px; + border-radius: 50%; + border: 2px solid white; + background: white; + cursor: pointer; + margin-top: 0; +} + +.mapillary-slider-position::-ms-tooltip { + display: none; +} +.mapillary-viewer.component-tag-create .mapillary-interactive, +.mapillary-viewer.component-tag-edit-crosshair .mapillary-interactive { + cursor: crosshair; +} + +.mapillary-viewer.component-tag-edit-move .mapillary-interactive { + cursor: move; +} + +.mapillary-viewer.component-tag-edit-nesw-resize .mapillary-interactive { + cursor: nesw-resize; +} + +.mapillary-viewer.component-tag-edit-nwse-resize .mapillary-interactive { + cursor: nwse-resize; +} + +.mapillary-tag-container { + font-size: 12px; + font-family: system-ui, -apple-system, BlinkMacSystemFont, + "Segoe UI", "Roboto", "Oxygen", "Ubuntu", + "Cantarell", "Fira Sans", "Droid Sans", + "Helvetica Neue", sans-serif; +} + +.mapillary-tag-completer, +.mapillary-tag-interactor, +.mapillary-tag-mover, +.mapillary-tag-resizer, +.mapillary-tag-spot-interactor, +.mapillary-tag-symbol, +.mapillary-tag-vertex { + position: absolute; + left: 0; + top: 0; +} + +.mapillary-tag-symbol { + cursor: pointer; + pointer-events: auto; +} + +.mapillary-tag-mover, +.mapillary-tag-resizer { + pointer-events: auto; +} + +.mapillary-tag-mover { + border-radius: 7px; + width: 14px; + height: 14px; + opacity: 0.2; + cursor: move; +} + +.mapillary-tag-mover:hover { + opacity: 0.5; +} + +.mapillary-tag-resizer { + border-radius: 7px; + width: 14px; + height: 14px; + opacity: 0; + cursor: crosshair; +} + +.mapillary-tag-resizer:hover { + opacity: 0.3; +} + +.mapillary-tag-vertex { + pointer-events: none; + border-radius: 3px; + width: 6px; + height: 6px; +} + +.mapillary-tag-dot { + pointer-events: none; + border-radius: 1.5px; + width: 3px; + height: 3px; + background-color: green; +} + +.mapillary-tag-vertex.mapillary-tag-larger { + border-radius: 4px; + width: 8px; + height: 8px; +} + +.mapillary-tag-interactor, +.mapillary-tag-completer, +.mapillary-tag-spot-interactor { + pointer-events: auto; + border-radius: 7px; + width: 14px; + height: 14px; + opacity: 0; +} + +.mapillary-tag-completer.mapillary-tag-larger { + border-radius: 8px; + width: 16px; + height: 16px; +} + +.mapillary-tag-interactor, +.mapillary-tag-completer { + cursor: pointer; +} + +.mapillary-tag-spot-interactor { + cursor: move; +} + +.mapillary-tag-completer { + background: limegreen; +} + +.mapillary-tag-interactor { + background: orangered; +} + +.mapillary-tag-completer:hover, +.mapillary-tag-interactor:hover { + opacity: 0.6; +} + +.mapillary-tag-spot-interactor:hover { + opacity: 0.3; +} +.mapillary-zoom-container { + position: absolute; + bottom: 90px; + right: 14px; + height: 66px; + width: 32px; + pointer-events: none; + z-index: 10; +} + +.mapillary-zoom-in-button, +.mapillary-zoom-in-button-inactive, +.mapillary-zoom-out-button, +.mapillary-zoom-out-button-inactive { + width: 100%; + height: 32px; + pointer-events: auto; + background: rgba(0, 0, 0, 0.5); + cursor: pointer; +} + +.mapillary-zoom-in-button, +.mapillary-zoom-in-button-inactive { + border-top-left-radius: 8px; + border-top-right-radius: 8px; +} + +.mapillary-zoom-out-button, +.mapillary-zoom-out-button-inactive { + margin-top: 2px; + border-bottom-left-radius: 8px; + border-bottom-right-radius: 8px; +} + +.mapillary-zoom-in-button-inactive, +.mapillary-zoom-out-button-inactive { + cursor: default; +} + +.mapillary-zoom-in-icon, +.mapillary-zoom-out-icon { + position: relative; + top: 0; + left: 0; + background-size: contain; + background-repeat: no-repeat; + opacity: 1; + width: 32px; + height: 32px; +} + +.mapillary-zoom-compact.mapillary-zoom-container { + height: 34px; + width: 16px; + bottom: 58px; +} + +.mapillary-zoom-compact .mapillary-zoom-in-button, +.mapillary-zoom-compact .mapillary-zoom-in-button-inactive, +.mapillary-zoom-compact .mapillary-zoom-out-button, +.mapillary-zoom-compact .mapillary-zoom-out-button-inactive, +.mapillary-zoom-compact .mapillary-zoom-in-icon, +.mapillary-zoom-compact .mapillary-zoom-out-icon { + width: 16px; + height: 16px; +} + +.mapillary-zoom-compact .mapillary-zoom-in-button, +.mapillary-zoom-compact .mapillary-zoom-in-button-inactive { + border-top-left-radius: 4px; + border-top-right-radius: 4px; +} + +.mapillary-zoom-compact .mapillary-zoom-out-button, +.mapillary-zoom-compact .mapillary-zoom-out-button-inactive { + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; +} + +.mapillary-zoom-in-icon { + background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMCIgaGVpZ2h0PSIzMCIgdmlld0JveD0iMCAwIDMwIDMwIj48ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPiAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTE2LDE0IEwyMSwxNCBDMjEuNTUyMjg0NywxNCAyMiwxNC40NDc3MTUzIDIyLDE1IEMyMiwxNS41NTIyODQ3IDIxLjU1MjI4NDcsMTYgMjEsMTYgTDE2LDE2IEwxNiwyMSBDMTYsMjEuNTUyMjg0NyAxNS41NTIyODQ3LDIyIDE1LDIyIEMxNC40NDc3MTUzLDIyIDE0LDIxLjU1MjI4NDcgMTQsMjEgTDE0LDE2IEw5LDE2IEM4LjQ0NzcxNTI1LDE2IDgsMTUuNTUyMjg0NyA4LDE1IEM4LDE0LjQ0NzcxNTMgOC40NDc3MTUyNSwxNCA5LDE0IEwxNCwxNCBMMTQsOSBDMTQsOC40NDc3MTUyNSAxNC40NDc3MTUzLDggMTUsOCBDMTUuNTUyMjg0Nyw4IDE2LDguNDQ3NzE1MjUgMTYsOSBMMTYsMTQgWiIvPjwvZz4gIDwvc3ZnPiAg'); +} + +.mapillary-zoom-out-icon { + background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMCIgaGVpZ2h0PSIzMCIgdmlld0JveD0iMCAwIDMwIDMwIj48ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPiAgPHBhdGggZmlsbD0iI0ZGRiIgZD0iTTIyLDE1IEMyMiwxNS41NTIyODQ3IDIxLjU1MjI4NDcsMTYgMjEsMTYgTDksMTYgQzguNDQ3NzE1MjUsMTYgOCwxNS41NTIyODQ3IDgsMTUgQzgsMTQuNDQ3NzE1MyA4LjQ0NzcxNTI1LDE0IDksMTQgTDIxLDE0IEMyMS41NTIyODQ3LDE0IDIyLDE0LjQ0NzcxNTMgMjIsMTUgWiIvPjwvZz4gIDwvc3ZnPiAg'); +} + +.mapillary-zoom-in-button-inactive .mapillary-zoom-in-icon, +.mapillary-zoom-out-button-inactive .mapillary-zoom-out-icon { + opacity: 0.35; +} diff --git a/src/ui/styles/mapillary.css b/src/ui/styles/mapillary.css new file mode 100644 index 000000000..8aa634ad0 --- /dev/null +++ b/src/ui/styles/mapillary.css @@ -0,0 +1,959 @@ +/* ── Mapillary dock ─────────────────────────── */ +#mapillary-dock { + --mly-green: #05cb63; + --mly-width: clamp(320px, 30vw, 460px); + position: fixed; + /* Sit beside the right context rail (DISPLAY / CONTEXT / CCTV), which owns + the right-hand 330px column from 26vh down, so the two never overlap. */ + right: calc(var(--right-rail-x, 52px) + 330px + 16px); + bottom: 3.4rem; + z-index: 118; + width: var(--mly-width); + max-width: calc(100vw - 32px); + max-height: calc(100vh - 7rem); + display: flex; + flex-direction: column; + pointer-events: auto; + font-family: var(--font-mono); +} + +#mapillary-dock .panel-glow { + position: absolute; + inset: -18px; + background: radial-gradient( + ellipse at center, + rgba(5, 203, 99, 0.16) 0%, + transparent 65% + ); + filter: blur(16px); + opacity: 0.25; + pointer-events: none; +} + +.mly-dock-inner { + position: relative; + display: flex; + flex-direction: column; + gap: 8px; + padding: 12px 14px; + background: var(--glass-bg); + border: 1px solid var(--glass-border); + border-radius: var(--panel-radius); + backdrop-filter: blur(24px) saturate(1.4); + -webkit-backdrop-filter: blur(24px) saturate(1.4); + box-shadow: + 0 8px 32px rgba(0, 0, 0, 0.5), + 0 0 0 1px rgba(255, 255, 255, 0.03) inset; + flex: 1 1 auto; + min-height: 0; + overflow: hidden; +} + +.mly-header { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.mly-title-btn { + display: flex; + align-items: center; + gap: 8px; + background: none; + border: 0; + color: var(--text-primary); + cursor: pointer; + padding: 2px 0; + font: inherit; + letter-spacing: 0.12em; + font-size: 0.72rem; +} + +.mly-brand-dot { + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--mly-green); + box-shadow: 0 0 10px rgba(5, 203, 99, 0.8); +} + +.mly-chip { + font-size: 0.58rem; + letter-spacing: 0.1em; + padding: 2px 7px; + border-radius: 999px; + border: 1px solid var(--glass-border-hover); + color: var(--text-secondary); +} + +.mly-chip.is-on { + color: var(--mly-green); + border-color: rgba(5, 203, 99, 0.55); +} + +.mly-chip.is-busy { + color: var(--accent); + border-color: rgba(0, 212, 255, 0.55); +} + +.mly-chip.is-warn { + color: #ffb300; + border-color: rgba(255, 179, 0, 0.55); +} + +.mly-header-tools { + display: flex; + align-items: center; + gap: 6px; +} + +.mly-icon-btn { + width: 26px; + height: 26px; + border-radius: var(--btn-radius); + border: 1px solid var(--glass-border-hover); + background: rgba(255, 255, 255, 0.04); + color: var(--text-secondary); + font: inherit; + font-size: 0.7rem; + line-height: 1; + cursor: pointer; + display: grid; + place-items: center; + transition: all var(--transition-fast); +} + +.mly-icon-btn:hover { + color: var(--text-primary); + border-color: var(--accent); + background: rgba(0, 212, 255, 0.14); +} + +/* Author display rules would otherwise beat the UA's [hidden] { display: none }. */ +.mly-viewer-wrap[hidden], +.mly-minimized[hidden], +.mly-minimized-badge[hidden], +.mly-image-link[hidden] { + display: none !important; +} + +/* ── Minimized: only the Mapillary logo remains ─ */ +.mly-minimized { + position: relative; + width: 52px; + height: 52px; + border-radius: 50%; + border: 1px solid rgba(5, 203, 99, 0.55); + background: var(--glass-bg); + backdrop-filter: blur(18px) saturate(1.4); + -webkit-backdrop-filter: blur(18px) saturate(1.4); + color: var(--mly-green); + cursor: pointer; + display: grid; + place-items: center; + box-shadow: + 0 6px 24px rgba(0, 0, 0, 0.5), + 0 0 18px rgba(5, 203, 99, 0.35); + transition: + transform var(--transition-fast), + box-shadow var(--transition-fast); +} + +.mly-minimized:hover { + transform: scale(1.06); + box-shadow: + 0 8px 28px rgba(0, 0, 0, 0.55), + 0 0 26px rgba(5, 203, 99, 0.6); +} + +.mly-minimized .mly-logo { + width: 28px; + height: 28px; + display: block; +} + +.mly-minimized-badge { + position: absolute; + top: -4px; + right: -6px; + min-width: 18px; + height: 18px; + padding: 0 5px; + border-radius: 999px; + background: var(--accent); + color: #0a0a0f; + font-size: 0.52rem; + font-weight: 600; + letter-spacing: 0.04em; + display: grid; + place-items: center; + box-shadow: 0 0 10px rgba(0, 212, 255, 0.6); +} + +.mly-minimized.is-off { + color: var(--text-secondary); + border-color: var(--glass-border-hover); + box-shadow: 0 6px 24px rgba(0, 0, 0, 0.5); +} + +#mapillary-dock.minimized { + width: auto !important; + height: auto !important; + min-width: 0 !important; + min-height: 0 !important; + max-height: none !important; + align-items: flex-end; +} + +#mapillary-dock.minimized .mly-dock-inner, +#mapillary-dock.minimized .panel-glow, +#mapillary-dock.minimized .gev-resize-grip, +#mapillary-dock.minimized .gev-resize-edge { + display: none; +} + +.mly-power { + font: inherit; + font-size: 0.6rem; + letter-spacing: 0.1em; + padding: 6px 10px; + border-radius: var(--btn-radius); + border: 1px solid var(--glass-border-hover); + background: rgba(255, 255, 255, 0.04); + color: var(--text-secondary); + cursor: pointer; + transition: all var(--transition-fast); +} + +.mly-power[aria-pressed='true'] { + color: #0a0a0f; + background: var(--mly-green); + border-color: var(--mly-green); + box-shadow: 0 0 14px rgba(5, 203, 99, 0.45); +} + +#mapillary-dock.collapsed .mly-body { + display: none; +} + +.mly-body { + display: flex; + flex-direction: column; + gap: 8px; + flex: 1 1 auto; + min-height: 0; +} + +/* Everything above the viewer scrolls; the viewer itself never does. */ +.mly-scroll { + display: flex; + flex-direction: column; + gap: 8px; + flex: 0 1 auto; + min-height: 0; + overflow-y: auto; + scrollbar-width: thin; + padding-right: 2px; +} + +/* Children keep their natural height; the region scrolls instead. */ +.mly-scroll > * { + flex-shrink: 0; +} + +#mapillary-dock.mly-has-viewer .mly-scroll { + /* Let the imagery win the height fight when the window is short. */ + flex-shrink: 3; +} + +.mly-kicker { + font-size: 0.56rem; + letter-spacing: 0.16em; + color: var(--accent); +} + +.mly-query { + display: flex; + flex-direction: column; + gap: 6px; +} + +.mly-query-row { + display: flex; + gap: 6px; +} + +#mly-query-input { + flex: 1; + min-width: 0; + padding: 8px 10px; + border-radius: var(--btn-radius); + border: 1px solid var(--glass-border-hover); + background: rgba(0, 0, 0, 0.35); + color: var(--text-primary); + font: inherit; + font-size: 0.72rem; +} + +#mly-query-input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-dim); +} + +.mly-run { + min-width: 56px; +} + +.mly-suggestions { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.mly-suggestion { + font: inherit; + font-size: 0.56rem; + letter-spacing: 0.06em; + padding: 3px 8px; + border-radius: 999px; + border: 1px solid var(--glass-border); + background: rgba(255, 255, 255, 0.03); + color: var(--text-secondary); + cursor: pointer; +} + +.mly-suggestion:hover { + color: var(--text-primary); + border-color: var(--glass-border-hover); +} + +.mly-answer { + font-family: var(--font-sans); + font-size: 0.74rem; + line-height: 1.4; + color: var(--text-primary); + min-height: 1.2em; +} + +.mly-answer.is-error { + color: #ff8a80; +} + +.mly-progress { + position: relative; + height: 14px; + border-radius: 999px; + overflow: hidden; + background: rgba(255, 255, 255, 0.06); +} + +.mly-progress-fill { + height: 100%; + width: 0%; + background: linear-gradient(90deg, var(--mly-green), var(--accent)); + transition: width 160ms linear; +} + +.mly-progress-label { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.55rem; + letter-spacing: 0.1em; + color: var(--text-primary); + text-shadow: 0 0 4px rgba(0, 0, 0, 0.8); +} + +.mly-chips { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.mly-value-chip { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.58rem; + padding: 3px 8px; + border-radius: 999px; + border: 1px solid var(--glass-border); + color: var(--text-primary); +} + +.mly-value-chip i { + width: 8px; + height: 8px; + border-radius: 50%; + display: inline-block; +} + +.mly-value-chip b { + color: var(--text-secondary); + font-weight: 500; +} + +.mly-actions { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.mly-actions .scene-btn { + flex: 1 1 auto; +} + +.scene-btn[aria-pressed='true'] { + color: #0a0a0f; + background: var(--accent); + border-color: var(--accent); +} + +.mly-viewer-wrap { + position: relative; + container-type: inline-size; + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 230px; + border-radius: 12px; + overflow: hidden; + border: 1px solid var(--glass-border-hover); + background: #000; +} + +.mly-viewer { + width: 100%; + flex: 1 1 auto; + min-height: 0; + /* Intrinsic height while the dock is auto-sized; grows when floating. */ + aspect-ratio: 16 / 10; +} + +#mapillary-dock.gev-floating .mly-viewer, +.mly-viewer-wrap-expanded .mly-viewer { + aspect-ratio: auto; + min-height: 180px; +} + +/* Caption bar, mapillary.com style: creator left, date right. */ +.mly-image-meta { + flex: 0 0 auto; + padding: 6px 10px; + font-size: 0.58rem; + letter-spacing: 0.06em; + color: var(--text-secondary); + background: rgba(0, 0, 0, 0.7); + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + min-height: 26px; +} + +.mly-image-by { + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +.mly-image-right { + display: inline-flex; + align-items: center; + gap: 10px; + white-space: nowrap; +} + +.mly-image-link { + color: var(--mly-green); + text-decoration: none; + letter-spacing: 0.1em; +} + +.mly-image-link:hover { + text-decoration: underline; +} + +.mly-viewer-tools.mly-viewer-tools-right { + left: auto; + right: 6px; +} + +/* Only the buttons catch clicks; the containers let the viewer through. */ +.mly-viewer-tools { + pointer-events: none; +} + +.mly-viewer-tools > * { + pointer-events: auto; +} + +.mly-viewer-x { + width: 30px; + height: 30px; + border-radius: 50%; + border: 1px solid rgba(255, 255, 255, 0.25); + background: rgba(10, 10, 15, 0.8); + color: var(--text-primary); + font: inherit; + font-size: 1.1rem; + line-height: 1; + cursor: pointer; + display: grid; + place-items: center; + transition: all var(--transition-fast); +} + +.mly-viewer-x:hover { + color: #0a0a0f; + background: #fff; + border-color: #fff; +} + +.mly-viewer-wrap-expanded .mly-viewer-tools.mly-viewer-tools-right { + left: auto; + right: 10px; + top: 10px; +} + +.mly-viewer-wrap-expanded .mly-viewer-x { + width: 40px; + height: 40px; + font-size: 1.5rem; +} + +.mly-meta { + font-size: 0.56rem; + letter-spacing: 0.06em; + color: var(--text-dim); +} + +/* Match the app's chrome-hiding modes. */ +body.ui-clean-view #mapillary-dock, +body.recording-mode #mapillary-dock { + opacity: 0 !important; + visibility: hidden !important; + pointer-events: none !important; +} + +body.cockpit-mode #mapillary-dock { + display: none !important; +} + +/* Not enough room beside the rail: fall back to the corner (still draggable). */ +@media (max-width: 1100px) { + #mapillary-dock { + right: 16px; + } +} + +@media (max-width: 720px) { + #mapillary-dock { + left: 16px; + right: 16px; + width: auto; + max-width: none; + bottom: 6.4rem; + max-height: calc(100vh - 8rem); + } +} + +/* Short viewports: trim the chrome so the imagery keeps its room. */ +@media (max-height: 640px) { + .mly-dock-inner { + padding: 8px 10px; + gap: 6px; + } + + #mapillary-dock.mly-has-viewer .mly-ai-hint, + #mapillary-dock.mly-has-viewer .mly-suggestions { + display: none; + } + + .mly-viewer-wrap { + min-height: 200px; + } +} + +/* ── Prominent AI entry ─────────────────────── */ +.mly-ai-head { + display: flex; + align-items: center; + gap: 8px; +} + +.mly-ai-head .mly-kicker { + font-size: 0.66rem; + color: var(--text-primary); +} + +.mly-ai-model { + margin-left: auto; + font-size: 0.52rem; + letter-spacing: 0.08em; + color: var(--text-dim); +} + +.mly-ai-pulse { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--accent); + box-shadow: 0 0 0 0 rgba(0, 212, 255, 0.6); + animation: mly-pulse 2.2s ease-out infinite; +} + +@keyframes mly-pulse { + 0% { + box-shadow: 0 0 0 0 rgba(0, 212, 255, 0.6); + } + 70% { + box-shadow: 0 0 0 9px rgba(0, 212, 255, 0); + } + 100% { + box-shadow: 0 0 0 0 rgba(0, 212, 255, 0); + } +} + +#mapillary-dock #mly-query-input { + padding: 11px 12px; + font-size: 0.8rem; + border: 1px solid rgba(0, 212, 255, 0.45); + background: rgba(0, 212, 255, 0.06); + box-shadow: 0 0 18px rgba(0, 212, 255, 0.12) inset; +} + +#mapillary-dock #mly-query-input::placeholder { + color: rgba(232, 234, 237, 0.55); +} + +#mapillary-dock #mly-query-input:focus { + background: rgba(0, 212, 255, 0.1); + box-shadow: + 0 0 0 2px var(--accent-dim), + 0 0 22px rgba(0, 212, 255, 0.25); +} + +.mly-ask { + min-width: 64px; + font: inherit; + font-size: 0.66rem; + letter-spacing: 0.12em; + border-radius: var(--btn-radius); + border: 1px solid var(--accent); + background: var(--accent); + color: #0a0a0f; + cursor: pointer; + font-weight: 600; + transition: all var(--transition-fast); +} + +.mly-ask:hover:not(:disabled) { + box-shadow: 0 0 16px rgba(0, 212, 255, 0.5); +} + +.mly-ask:disabled { + opacity: 0.55; + cursor: progress; +} + +.mly-ai-hint { + font-family: var(--font-sans); + font-size: 0.66rem; + line-height: 1.35; + color: var(--text-secondary); +} + +.mly-ai-hint.is-warn { + color: #ffb300; +} + +/* ── Imagery filters ────────────────────────── */ +.mly-filters { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.mly-filter-label { + font-size: 0.54rem; + letter-spacing: 0.14em; + color: var(--text-dim); +} + +.mly-seg { + display: inline-flex; + border: 1px solid var(--glass-border-hover); + border-radius: 999px; + overflow: hidden; +} + +.mly-seg-btn { + font: inherit; + font-size: 0.56rem; + letter-spacing: 0.08em; + padding: 4px 9px; + border: 0; + background: transparent; + color: var(--text-secondary); + cursor: pointer; +} + +.mly-seg-btn + .mly-seg-btn { + border-left: 1px solid var(--glass-border); +} + +.mly-seg-btn.is-active { + background: rgba(5, 203, 99, 0.22); + color: var(--text-primary); +} + +.mly-select { + font: inherit; + font-size: 0.56rem; + letter-spacing: 0.06em; + padding: 4px 8px; + border-radius: 999px; + border: 1px solid var(--glass-border-hover); + background: rgba(0, 0, 0, 0.35); + color: var(--text-primary); +} + +.mly-select option { + background: #0a0a0f; +} + +/* ── Portable, resizable windows ────────────── */ +.gev-floating-handle { + cursor: grab; +} + +.gev-floating-dragging, +.gev-floating-dragging * { + cursor: grabbing !important; + user-select: none; +} + +.gev-floating { + position: fixed !important; + right: auto !important; + bottom: auto !important; + margin: 0 !important; + max-width: calc(100vw - 12px) !important; + z-index: 132 !important; + display: flex; + flex-direction: column; +} + +.gev-resize-grip { + position: absolute; + right: 5px; + bottom: 5px; + width: 16px; + height: 16px; + cursor: nwse-resize; + z-index: 6; + opacity: 0.6; + border-radius: 3px; + background: linear-gradient( + 135deg, + transparent 0 45%, + var(--accent) 45% 55%, + transparent 55% 70%, + var(--accent) 70% 80%, + transparent 80% + ); +} + +.gev-resize-grip:hover { + opacity: 1; +} + +/* Invisible strips along the other edges and corners. */ +.gev-resize-edge { + position: absolute; + z-index: 7; +} + +.gev-resize-edge[data-dir='n'], +.gev-resize-edge[data-dir='s'] { + left: 10px; + right: 10px; + height: 7px; + cursor: ns-resize; +} + +.gev-resize-edge[data-dir='e'], +.gev-resize-edge[data-dir='w'] { + top: 10px; + bottom: 10px; + width: 7px; + cursor: ew-resize; +} + +.gev-resize-edge[data-dir='n'] { + top: -3px; +} + +.gev-resize-edge[data-dir='s'] { + bottom: -3px; +} + +.gev-resize-edge[data-dir='e'] { + right: -3px; +} + +.gev-resize-edge[data-dir='w'] { + left: -3px; +} + +.gev-resize-edge[data-dir='ne'], +.gev-resize-edge[data-dir='nw'], +.gev-resize-edge[data-dir='sw'] { + width: 14px; + height: 14px; +} + +.gev-resize-edge[data-dir='ne'] { + top: -3px; + right: -3px; + cursor: nesw-resize; +} + +.gev-resize-edge[data-dir='nw'] { + top: -3px; + left: -3px; + cursor: nwse-resize; +} + +.gev-resize-edge[data-dir='sw'] { + bottom: -3px; + left: -3px; + cursor: nesw-resize; +} + +#mapillary-dock.gev-floating { + max-height: none !important; +} + +#mapillary-dock.gev-floating .mly-dock-inner { + flex: 1 1 auto; + min-height: 0; + max-height: none; + height: 100%; +} + +/* The CCTV panel lives in a rail until dragged; then it floats with the + width it had (set inline when lifted), never the viewport's. */ +#cctv-panel.gev-floating { + position: fixed !important; + flex: none !important; + max-height: calc(100vh - 12px) !important; +} + +#cctv-panel.gev-floating:not([style*='height']) { + height: auto !important; +} + +#cctv-panel.gev-floating .cctv-panel-inner { + flex: 1 1 auto; + min-height: 0; + height: 100%; + max-height: none; +} + +#cctv-panel.gev-floating.collapsed .cctv-panel-inner { + height: auto; +} + +/* ── Expanded street-level viewer ───────────── */ +.mly-viewer-tools { + position: absolute; + top: 6px; + left: 6px; + /* Above MapillaryJS's own overlays (z 10); the corners are free — its + sequence bar is top-centre, bearing/zoom/attribution bottom-right. */ + z-index: 12; + display: flex; + gap: 4px; +} + +.mly-viewer-tools .scene-btn { + padding: 4px 8px; + font-size: 0.56rem; + background: rgba(10, 10, 15, 0.75); + /* Hug the label: the playback bar MapillaryJS centres at the top needs + the middle of narrow viewers. */ + flex: none; + min-width: 0; + display: inline-flex; + align-items: center; + gap: 4px; +} + +/* Narrow viewer: icon-only expand button so nothing meets the playback bar. */ +@container (max-width: 380px) { + .mly-viewer-tools .mly-btn-text { + display: none; + } + + .mly-viewer-tools .scene-btn { + padding: 4px 7px; + } +} + +/* The dock's backdrop-filter makes it the containing block for fixed + descendants, so the expanded viewer is re-parented to . */ +.mly-viewer-wrap-expanded { + position: fixed; + left: 4vw; + right: 4vw; + top: 5vh; + bottom: 9vh; + z-index: 141; + display: flex; + flex-direction: column; + font-family: var(--font-mono); + border-color: rgba(0, 212, 255, 0.55); + box-shadow: 0 24px 80px rgba(0, 0, 0, 0.7); +} + +.mly-viewer-wrap-expanded .mly-viewer { + flex: 1 1 auto; + min-height: 0; + aspect-ratio: auto; +} + +.mly-viewer-wrap-expanded .mly-image-meta { + padding: 10px 14px; +} + +.mly-viewer-wrap-expanded .mly-image-meta { + font-size: 0.64rem; +} + +.mly-viewer-wrap-expanded .mly-viewer-tools { + top: 10px; + left: 10px; +} + +.mly-viewer-wrap-expanded .mly-viewer-tools .scene-btn { + padding: 8px 14px; + font-size: 0.7rem; + letter-spacing: 0.12em; + color: var(--text-primary); + border-color: rgba(0, 212, 255, 0.6); + background: rgba(10, 10, 15, 0.82); +} + +.mly-viewer-wrap-expanded .mly-viewer-tools .scene-btn::after { + content: ' · ESC'; + color: var(--text-secondary); +} From 791e77fa3368fdd66844a57262f620cd611269aa Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 23 Sep 2026 15:44:32 +0100 Subject: [PATCH 012/320] =?UTF-8?q?feat(mapillary):=20dev-server=20provide?= =?UTF-8?q?r=20=E2=80=94=20cached=20tiles,=20feature=20stream,=20sprites,?= =?UTF-8?q?=20optional=20Claude=20planner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vite middleware for /api/mapillary: status (capabilities, never values), vector tiles cached in memory and on disk with the 12 MB z14 image layer stripped in transit, an NDJSON feature stream capped at 600 tiles, MIT sprite icons behind a grammar-guarded path, and a structured-output planner that only activates when ANTHROPIC_API_KEY is set server-side. Co-Authored-By: Claude Fable 5.1 --- server/providers/mapillary/features.js | 2 +- server/providers/mapillary/planner.js | 41 ++++++++++++++++++++++---- server/providers/mapillary/sprites.js | 4 +++ 3 files changed, 40 insertions(+), 7 deletions(-) diff --git a/server/providers/mapillary/features.js b/server/providers/mapillary/features.js index ffa038b35..40f907d52 100644 --- a/server/providers/mapillary/features.js +++ b/server/providers/mapillary/features.js @@ -26,7 +26,7 @@ function sendJson(res, status, payload) { } /** Parse an ISO date (or epoch ms) into epoch ms, or null when absent/invalid. */ -export function parseEpochMs(value) { +function parseEpochMs(value) { if (value === null || value === undefined || value === '') return null; if (typeof value === 'number') return Number.isFinite(value) ? value : null; const parsed = Date.parse(String(value)); diff --git a/server/providers/mapillary/planner.js b/server/providers/mapillary/planner.js index 1001b9371..0160a72ce 100644 --- a/server/providers/mapillary/planner.js +++ b/server/providers/mapillary/planner.js @@ -1,6 +1,5 @@ import Anthropic from '@anthropic-ai/sdk'; import { z } from 'zod'; -import { zodOutputFormat } from '@anthropic-ai/sdk/helpers/zod'; import { readRequestBody } from '../common/request.js'; import { makeOptInRateLimiter, clientKey } from '../common/rate-limit.js'; import { @@ -26,7 +25,7 @@ import { * The plan the model returns. Every field is required (structured outputs * reject optional keys); "unknown" is spelled with null. */ -export const PlanSchema = z.object({ +const PlanSchema = z.object({ intent: z.enum([ 'map_features', 'traffic_signs', @@ -182,6 +181,35 @@ export function buildPlannerUserMessage({ query, context = {}, today }) { return lines.join('\n'); } +/** + * Structured-output format for the plan: the Zod schema as JSON Schema. Kept + * free of the SDK's zod helper so the provider only depends on package roots. + */ +function planOutputFormat() { + const { $schema: _schema, ...schema } = z.toJSONSchema(PlanSchema, { + reused: 'ref', + }); + return { type: 'json_schema', schema }; +} + +/** The plan object from a structured-output response, or null. */ +function parsePlanResponse(response) { + if (response?.parsed_output) return response.parsed_output; + const text = (response?.content || []) + .filter((block) => block?.type === 'text') + .map((block) => block.text) + .join(''); + if (!text.trim()) return null; + let raw; + try { + raw = JSON.parse(text); + } catch { + return null; + } + const result = PlanSchema.safeParse(raw); + return result.success ? result.data : null; +} + /** * Ask Claude for a plan. Exported for tests with an injectable client. * @returns {Promise<{plan: object, usage: object|null, model: string}>} @@ -190,7 +218,7 @@ export async function planQuery( { query, context }, { anthropic = client(), model = plannerModel(), now = new Date() } = {}, ) { - const response = await anthropic.messages.parse({ + const response = await anthropic.messages.create({ model, max_tokens: PLANNER_MAX_TOKENS, system: [ @@ -210,7 +238,7 @@ export async function planQuery( }), }, ], - output_config: { format: zodOutputFormat(PlanSchema), effort: 'low' }, + output_config: { format: planOutputFormat(), effort: 'low' }, }); if (response.stop_reason === 'refusal') { return { @@ -230,10 +258,11 @@ export async function planQuery( model, }; } - if (!response.parsed_output) + const parsed = parsePlanResponse(response); + if (!parsed) throw Object.assign(new Error('Planner returned no plan'), { status: 502 }); return { - plan: finalizePlan(response.parsed_output), + plan: finalizePlan(parsed), usage: response.usage ?? null, model, }; diff --git a/server/providers/mapillary/sprites.js b/server/providers/mapillary/sprites.js index c2f888555..15cf2ef15 100644 --- a/server/providers/mapillary/sprites.js +++ b/server/providers/mapillary/sprites.js @@ -1,3 +1,7 @@ +/** + * Sprite proxy. Icons come from github.com/mapillary/mapillary_sprite_source + * (MIT, © Mapillary), fetched on demand and cached on disk. + */ import path from 'node:path'; import { promises as fsp } from 'node:fs'; import { From 3169d205d9fa8f2a44ca6d3da48d99229909d401 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 23 Sep 2026 15:44:32 +0100 Subject: [PATCH 013/320] feat(mapillary): Street Level panel in the right context rail An ordinary collapsible GEV panel between CCTV and Context, starting collapsed with the Mapillary mark in its strip. Body: a gated empty state when no client token is present, plain-English query box (ASK becomes STOP while running), streamed result chips, imagery filters with a colour legend, and the embedded viewer whose expanded mode is a focus-trapped dialog. All wording lives in a pure presentation module. Co-Authored-By: Claude Fable 5.1 --- src/ui/applicationShell.js | 26 + src/ui/mapillaryControls.js | 710 ++++++++----------- src/ui/mapillaryPresentation.js | 205 ++++++ src/ui/panelChrome.js | 3 + src/ui/panelLayoutController.js | 15 + src/ui/shellElements.js | 2 +- src/ui/styles/layers.css | 15 +- src/ui/styles/mapillary.css | 1061 +++++++++++----------------- src/ui/styles/responsive.css | 2 + src/ui/templates/layer-panels.html | 101 +++ style.css | 5 + 11 files changed, 1073 insertions(+), 1072 deletions(-) create mode 100644 src/ui/mapillaryPresentation.js diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index b22c21c3f..c180c4e97 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -14,6 +14,7 @@ import { readShellElements } from './shellElements.js'; import { CockpitCoordinator } from './cockpitCoordinator.js'; import { ContextControls } from './context.js'; import { CctvControls } from './cctv.js'; +import { MapillaryControls } from './mapillaryControls.js'; import { RadioControls } from './radio.js'; import { LocationNavigation } from './locationNavigation.js'; import { bindClearLayersControl } from './layers.js'; @@ -536,6 +537,7 @@ export class StyleManager extends ShellFacade { this._initRightPanelAdaptiveLayout(); this._initRadioPanel(); this._initCctvPanel(); + this._initMapillaryPanel(); this._initGlobalContextPanel(); this._initLocationBar(); this._initShareButton(); @@ -876,6 +878,29 @@ export class StyleManager extends ShellFacade { } /** Compose camera panel controls from the existing camera port and application actions. */ + _initMapillaryPanel() { + const { mapillaryLayer } = this.services; + this._mapillaryControls?.destroy(); + this._mapillaryControls = null; + if (!this._mapillaryPanel || !mapillaryLayer) return; + this._mapillaryControls = new MapillaryControls({ + root: this._mapillaryPanel, + mapillary: mapillaryLayer, + actions: { + isEnabled: () => this._dataManager?.isEnabled('mapillary') === true, + setEnabled: (enabled) => + this._dataManager?.setEnabled('mapillary', enabled, { + origin: 'user', + }), + setPanelCollapsed: (collapsed, options) => + this.setPanelCollapsed('mapillary-panel', collapsed, options), + openKeySetup: () => document.getElementById('key-setup-chip')?.click(), + showToast: (message) => this._showToast(message), + }, + }); + this._mapillaryControls.connect(); + } + _initCctvPanel() { const { cctvLayer } = this.services; this._cctvControls?.destroy(); @@ -1471,6 +1496,7 @@ export class StyleManager extends ShellFacade { this._cameraOrientationControls?.destroy(); this._clearLayersControl?.destroy(); this._cctvControls?.destroy(); + this._mapillaryControls?.destroy(); this._radioControls?.destroy(); this._cockpitCoordinator.stop(); this._visualSettings.stop(); diff --git a/src/ui/mapillaryControls.js b/src/ui/mapillaryControls.js index dabda83ac..ce42e0379 100644 --- a/src/ui/mapillaryControls.js +++ b/src/ui/mapillaryControls.js @@ -1,31 +1,17 @@ -import { makeFloating } from './floatingWindow.js'; +import { presentMapillaryPanel } from './mapillaryPresentation.js'; + +export { mapillaryImageUrl } from './mapillaryPresentation.js'; -const DOCK_STORAGE_KEY = 'gev:mapillary-dock:collapsed'; -const DOCK_MINIMIZED_KEY = 'gev:mapillary-dock:minimized'; -const DOCK_WINDOW_KEY = 'gev:mapillary-dock:window:v1'; const RENDER_MODE_KEY = 'gev:mapillary:render-mode'; -const MAPILLARY_APP_URL = 'https://www.mapillary.com/app/'; - -/** Deep link to an image on mapillary.com, as the web app shares them. */ -export function mapillaryImageUrl(imageId) { - const id = String(imageId || '').trim(); - if (!id) return MAPILLARY_APP_URL; - return `${MAPILLARY_APP_URL}?pKey=${encodeURIComponent(id)}&focus=photo`; -} - -function formatDate(ms) { - if (!Number.isFinite(ms)) return ''; - try { - return new Date(ms).toISOString().slice(0, 10); - } catch { - return ''; - } -} +const FOCUSABLE = + 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'; /** - * Own the Mapillary dock: the AI query box, result chips, progress, the - * street-level viewer host and its controls. Receives the layer instance and - * application actions; never reaches into the layer's internals. + * Own the Street Level panel: the query box, result chips, progress, the + * imagery filters and the embedded MapillaryJS viewer. The panel itself is + * ordinary GEV chrome (collapse button, rail layout, persistence) driven by + * the application shell; this class only fills the body and asks the shell + * to open the panel when something worth seeing arrives. */ export class MapillaryControls { constructor({ root, mapillary, actions }) { @@ -36,52 +22,50 @@ export class MapillaryControls { this.listeners = new AbortController(); this._unsubscribe = null; this._state = null; + this._view = null; + this._wasEnabled = null; + this._wasStreetOpen = false; this._lastStage = null; + this._wrapHome = null; + this._expandReturnFocus = null; this._elements = this._collect(); this._bind(); - this._restoreCollapsed(); - this._restoreMinimized(); } _collect() { const byId = (id) => this.root?.querySelector(`#${id}`) || null; return { - toggle: byId('mly-dock-toggle'), - body: byId('mly-body'), - statusChip: byId('mly-status-chip'), + status: byId('mly-status'), + keyless: byId('mly-keyless'), + keylessBtn: byId('mly-keyless-btn'), + controls: byId('mly-controls'), enableBtn: byId('mly-enable-btn'), - minimizeBtn: byId('mly-minimize-btn'), - minimized: byId('mly-minimized'), - minimizedBadge: byId('mly-minimized-badge'), + lookBtn: byId('mly-look-btn'), form: byId('mly-query-form'), input: byId('mly-query-input'), runBtn: byId('mly-query-run'), + hint: byId('mly-ai-hint'), answer: byId('mly-answer'), + error: byId('mly-error'), + errorText: byId('mly-error-text'), progress: byId('mly-progress'), progressFill: byId('mly-progress-fill'), progressLabel: byId('mly-progress-label'), results: byId('mly-results'), chips: byId('mly-result-chips'), frameBtn: byId('mly-frame-btn'), - threeDBtn: byId('mly-3d-btn'), - threeDNote: byId('mly-3d-note'), - photorealBtn: byId('mly-photoreal-btn'), - sinceSelect: byId('mly-since'), - aiHint: byId('mly-ai-hint'), - aiModel: byId('mly-ai-model'), clearBtn: byId('mly-clear-btn'), - lookBtn: byId('mly-look-btn'), + sinceSelect: byId('mly-since'), + legend: byId('mly-legend'), followBtn: byId('mly-follow-btn'), - closeBtn: byId('mly-viewer-close'), viewerWrap: byId('mly-viewer-wrap'), viewerExpand: byId('mly-viewer-expand'), - viewerFit: byId('mly-viewer-fit'), + viewerClose: byId('mly-viewer-close'), + viewerPlaceholder: byId('mly-viewer-placeholder'), + viewer: byId('mly-viewer'), imageBy: byId('mly-image-by'), imageWhen: byId('mly-image-when'), imageLink: byId('mly-image-link'), - header: this.root?.querySelector('.mly-header') || null, - viewer: byId('mly-viewer'), - imageMeta: byId('mly-image-meta'), coverageMeta: byId('mly-coverage-meta'), }; } @@ -97,67 +81,17 @@ export class MapillaryControls { const el = this._elements; if (!this.root) return; this.mapillary.attachViewerHost?.(el.viewer); - this.listen(el.toggle, 'click', () => - this.setCollapsed(!this.root.classList.contains('collapsed')), - ); - // Portable, resizable window: drag the header, resize from the grip. - this._floating = makeFloating(this.root, { - handle: el.header, - storageKey: DOCK_WINDOW_KEY, - dragThrough: '.mly-title-btn', - minWidth: 320, - minHeight: 220, - onChange: () => this.mapillary.resizeViewer?.(), - }); - this.listen(el.viewerExpand, 'click', () => - this.setViewerExpanded( - !this.root.classList.contains('mly-viewer-expanded'), - ), - ); - this.listen(document, 'keydown', (event) => { - if ( - event.key === 'Escape' && - this.root.classList.contains('mly-viewer-expanded') - ) - this.setViewerExpanded(false); - }); - this.listen(el.viewerFit, 'click', () => { - const next = - this._state?.street?.renderMode === 'fill' ? 'letterbox' : 'fill'; - this.mapillary.setViewerRenderMode?.(next); - try { - localStorage.setItem(RENDER_MODE_KEY, next); - } catch { - /* storage unavailable */ - } - }); - try { - const stored = localStorage.getItem(RENDER_MODE_KEY); - if (stored === 'fill' || stored === 'letterbox') - this.mapillary.setViewerRenderMode?.(stored); - } catch { - /* storage unavailable */ - } - // MapillaryJS only tracks window resizes; the dock resizes on its own - // (drag grip, expand, reflow), so watch the host element directly. - if (typeof ResizeObserver === 'function' && el.viewer) { - let queued = false; - this._resizeObserver = new ResizeObserver(() => { - if (queued) return; - queued = true; - requestAnimationFrame(() => { - queued = false; - this.mapillary.resizeViewer?.(); - }); - }); - this._resizeObserver.observe(el.viewer); - } - this.listen(el.minimizeBtn, 'click', () => this.setMinimized(true)); - this.listen(el.minimized, 'click', () => this.setMinimized(false)); + + this.listen(el.keylessBtn, 'click', () => this.actions.openKeySetup?.()); this.listen(el.enableBtn, 'click', () => this._toggleEnabled()); + this.listen(el.lookBtn, 'click', async () => { + if (!(await this._ensureEnabled())) return; + this.mapillary.openNearest?.(); + }); this.listen(el.form, 'submit', (event) => { event.preventDefault(); - this._run(el.input?.value); + if (this._view?.query.submitIsStop) this.mapillary.abortQuery?.(); + else this._run(el.input?.value); }); for (const button of this.root.querySelectorAll('[data-mly-suggestion]')) { this.listen(button, 'click', () => { @@ -167,19 +101,14 @@ export class MapillaryControls { } this.listen(el.frameBtn, 'click', () => this.mapillary.frameResults?.()); this.listen(el.clearBtn, 'click', () => this.mapillary.clearQuery?.()); - this.listen(el.lookBtn, 'click', async () => { - if (!(await this._ensureEnabled())) return; - this.setCollapsed(false); - this.mapillary.openNearest?.(); - }); - this.listen(el.followBtn, 'click', () => { - const next = !(this._state?.street?.follow === true); - this.mapillary.setFollow?.(next); - }); - this.listen(el.closeBtn, 'click', () => this.mapillary.closeViewer?.()); - this.listen(el.threeDBtn, 'click', () => { - const next = !(this._state?.objects3d?.enabled === true); - this.mapillary.setObjects3d?.(next); + this.listen(el.chips, 'click', (event) => { + const more = event.target.closest?.('[data-mly-more]'); + if (!more) return; + const expanded = el.chips.classList.toggle('is-expanded'); + more.setAttribute('aria-expanded', String(expanded)); + more.textContent = expanded + ? 'FEWER' + : `+${more.dataset.mlyMore} CLASSES`; }); for (const button of this.root.querySelectorAll('[data-mly-pano]')) { this.listen(button, 'click', () => @@ -195,13 +124,53 @@ export class MapillaryControls { sinceMs = Date.now() - Number(value) * 86_400_000; this.mapillary.setCoverageFilter?.({ sinceMs }); }); - this.listen(el.photorealBtn, 'click', async () => { - const result = await this.actions.setMapStack?.('photoreal'); - if (result?.error) this.actions.showToast?.(result.error); + this.listen(el.followBtn, 'click', () => { + this.mapillary.setFollow?.(!(this._state?.street?.follow === true)); }); - // Keep the app's keyboard shortcuts from firing while typing a query. - this.listen(el.input, 'keydown', (event) => event.stopPropagation()); - this.listen(el.input, 'keyup', (event) => event.stopPropagation()); + this.listen(el.viewerClose, 'click', () => this.mapillary.closeViewer?.()); + this.listen(el.viewerExpand, 'click', () => + this.setViewerExpanded(!this.isViewerExpanded()), + ); + for (const button of this.root.querySelectorAll('[data-mly-render]')) { + this.listen(button, 'click', () => { + const mode = button.dataset.mlyRender; + this.mapillary.setViewerRenderMode?.(mode); + try { + localStorage.setItem(RENDER_MODE_KEY, mode); + } catch { + /* storage unavailable */ + } + }); + } + try { + const stored = localStorage.getItem(RENDER_MODE_KEY); + if (stored === 'fill' || stored === 'letterbox') + this.mapillary.setViewerRenderMode?.(stored); + } catch { + /* storage unavailable */ + } + // The expanded viewer is a dialog: Esc closes it, Tab stays inside. + this.listen(el.viewerWrap, 'keydown', (event) => this._onDialogKey(event)); + // Keep the app's keyboard shortcuts from firing while typing a query, + // but let Escape through so GEV's panel disclosure can collapse the panel. + const shield = (event) => { + if (event.key !== 'Escape') event.stopPropagation(); + }; + this.listen(el.input, 'keydown', shield); + this.listen(el.input, 'keyup', shield); + // MapillaryJS only tracks window resizes; the panel resizes on its own. + if (typeof ResizeObserver === 'function' && el.viewer) { + let queued = false; + this._resizeObserver = new ResizeObserver(() => { + if (queued) return; + queued = true; + requestAnimationFrame(() => { + queued = false; + this.mapillary.resizeViewer?.(); + }); + }); + this._resizeObserver.observe(el.viewer); + } } async _ensureEnabled() { @@ -224,14 +193,12 @@ export class MapillaryControls { } catch (error) { this.actions.showToast?.(error?.message || 'Street Level toggle failed'); } - if (!enabled) this.setCollapsed(false); } async _run(prompt) { const text = String(prompt || '').trim(); if (!text) return; if (!(await this._ensureEnabled())) return; - this.setCollapsed(false); this.mapillary.runQuery?.(text); } @@ -245,92 +212,53 @@ export class MapillaryControls { if (this.mapillary.getUIState) this.render(this.mapillary.getUIState()); } - _restoreCollapsed() { - // Open by default: the typed Mapillary AI box is the front door. - let collapsed = false; - try { - const stored = localStorage.getItem(DOCK_STORAGE_KEY); - if (stored === '1') collapsed = true; - } catch { - /* storage unavailable */ - } - this.setCollapsed(collapsed, { persist: false }); - } - - _restoreMinimized() { - let minimized = false; - try { - minimized = localStorage.getItem(DOCK_MINIMIZED_KEY) === '1'; - } catch { - /* storage unavailable */ - } - this.setMinimized(minimized, { persist: false }); - } - - /** - * Shrink the whole panel to a round Mapillary logo, or bring it back. - * The logo carries the layer's on/off state and a result-count badge. - */ - setMinimized(minimized, { persist = true } = {}) { - if (!this.root) return; - const on = minimized === true; - if (on) this.setViewerExpanded(false); - this.root.classList.toggle('minimized', on); - if (this._elements.minimized) this._elements.minimized.hidden = !on; - this.root.setAttribute('aria-expanded', String(!on)); - if (!on) { - this.setCollapsed(false, { persist }); + /** Ask the shell to open (or close) the rail panel. */ + setCollapsed(collapsed, options = {}) { + this.actions.setPanelCollapsed?.(collapsed, options); + if (!collapsed) requestAnimationFrame(() => this.mapillary.resizeViewer?.()); - } - if (persist) { - try { - localStorage.setItem(DOCK_MINIMIZED_KEY, on ? '1' : '0'); - } catch { - /* storage unavailable */ - } - } - this._renderMinimized(this._state); } - isMinimized() { - return this.root?.classList.contains('minimized') === true; - } - - _renderMinimized(state) { - const el = this._elements; - if (!el.minimized) return; - const enabled = state?.enabled === true; - el.minimized.classList.toggle('is-off', !enabled); - const total = state?.features?.total || 0; - if (el.minimizedBadge) { - el.minimizedBadge.hidden = !(enabled && total > 0); - el.minimizedBadge.textContent = - total >= 1000 ? `${Math.round(total / 1000)}k` : String(total); - } - el.minimized.title = enabled - ? `Open Mapillary · Street Level on${total ? ` · ${total.toLocaleString()} results` : ''}` - : 'Open Mapillary'; + isViewerExpanded() { + return ( + this._elements.viewerWrap?.classList.contains( + 'mly-viewer-wrap-expanded', + ) === true + ); } /** Grow the street-level viewer to most of the screen, or shrink it back. */ setViewerExpanded(expanded) { - if (!this.root) return; - const on = expanded === true; const wrap = this._elements.viewerWrap; - if (on === this.root.classList.contains('mly-viewer-expanded')) return; - this.root.classList.toggle('mly-viewer-expanded', on); - if (wrap) { - if (on) { - // Lift the viewer out of the dock: the dock's backdrop-filter would - // otherwise pin a fixed-position child inside the panel. - this._wrapHome = { parent: wrap.parentNode, next: wrap.nextSibling }; - document.body.appendChild(wrap); - wrap.classList.add('mly-viewer-wrap-expanded'); - } else if (this._wrapHome?.parent) { - wrap.classList.remove('mly-viewer-wrap-expanded'); + if (!wrap) return; + const on = expanded === true; + if (on === this.isViewerExpanded()) return; + if (on) { + // Lift the viewer out of the panel: the panel's backdrop-filter would + // otherwise pin a fixed-position child inside it. + this._wrapHome = { parent: wrap.parentNode, next: wrap.nextSibling }; + this._expandReturnFocus = document.activeElement; + document.body.appendChild(wrap); + wrap.classList.add('mly-viewer-wrap-expanded'); + wrap.setAttribute('role', 'dialog'); + wrap.setAttribute('aria-modal', 'true'); + wrap.setAttribute('aria-label', 'Street-level image'); + wrap.tabIndex = -1; + wrap.focus({ preventScroll: true }); + } else { + wrap.classList.remove('mly-viewer-wrap-expanded'); + wrap.removeAttribute('role'); + wrap.removeAttribute('aria-modal'); + wrap.removeAttribute('aria-label'); + wrap.removeAttribute('tabindex'); + if (this._wrapHome?.parent) this._wrapHome.parent.insertBefore(wrap, this._wrapHome.next); - this._wrapHome = null; - } + this._wrapHome = null; + const target = this._expandReturnFocus; + this._expandReturnFocus = null; + if (target?.isConnected && typeof target.focus === 'function') + target.focus({ preventScroll: true }); + else this._elements.viewerExpand?.focus?.({ preventScroll: true }); } const button = this._elements.viewerExpand; if (button) { @@ -344,230 +272,213 @@ export class MapillaryControls { requestAnimationFrame(() => this.mapillary.resizeViewer?.()); } - setCollapsed(collapsed, { persist = true } = {}) { - if (!this.root) return; - this.root.classList.toggle('collapsed', collapsed); - this._elements.toggle?.setAttribute('aria-expanded', String(!collapsed)); - this._elements.toggle?.setAttribute( - 'title', - collapsed ? 'Expand Mapillary' : 'Collapse Mapillary', + _onDialogKey(event) { + if (!this.isViewerExpanded()) return; + if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + this.setViewerExpanded(false); + return; + } + if (event.key !== 'Tab') return; + const wrap = this._elements.viewerWrap; + const focusable = [...wrap.querySelectorAll(FOCUSABLE)].filter( + (node) => node.offsetParent !== null || node === document.activeElement, ); - if (!collapsed) this.mapillary.resizeViewer?.(); - if (persist) { - try { - localStorage.setItem(DOCK_STORAGE_KEY, collapsed ? '1' : '0'); - } catch { - /* storage unavailable */ - } + if (!focusable.length) return; + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } else if (!wrap.contains(document.activeElement)) { + event.preventDefault(); + first.focus(); } } render(state) { if (this.destroyed || !state || !this.root) return; this._state = state; + const view = presentMapillaryPanel(state); + this._view = view; + this._renderHeader(view); + this._renderEmptyState(view); + this._renderQuery(view); + this._renderResults(view); + this._renderFilters(view); + this._renderViewer(view, state); + this._renderMeta(view); + this._reactToTransitions(view, state); + } + + _renderHeader(view) { const el = this._elements; - const enabled = state.enabled === true; - - el.enableBtn.textContent = enabled ? 'STREET LEVEL ON' : 'STREET LEVEL OFF'; - el.enableBtn.setAttribute('aria-pressed', String(enabled)); - - let chip = enabled ? 'ON' : 'OFF'; - let chipClass = enabled ? 'is-on' : ''; - if (state.keyRequired) { - chip = 'KEY REQUIRED'; - chipClass = 'is-warn'; - } else if (state.query.busy) { - chip = `AI · ${state.query.stage.toUpperCase()}`; - chipClass = 'is-busy'; - } else if (state.coverage.loading || state.features.loading) { - chip = 'LOADING'; - chipClass = 'is-busy'; + this.root.dataset.mlyEnabled = String(view.enabled); + if (el.status) { + el.status.textContent = view.status.text; + el.status.className = `mly-status${view.status.tone ? ` is-${view.status.tone}` : ''}`; } - el.statusChip.textContent = chip; - el.statusChip.className = `mly-chip ${chipClass}`.trim(); - - if (enabled && this._wasEnabled === false) this.setCollapsed(false); - this._wasEnabled = enabled; - this._renderMinimized(state); - // Imagery needs the panel: opening an image brings a minimized dock back. - if (state.street.open && !this._wasStreetOpen && this.isMinimized()) - this.setMinimized(false); - this._wasStreetOpen = state.street.open === true; - - el.runBtn.disabled = state.query.busy; - el.runBtn.textContent = state.query.busy ? '…' : 'ASK'; - el.input.disabled = false; - el.input.placeholder = state.planner - ? 'Type a question, e.g. show me all fire hydrants in Sacramento' - : 'Add an Anthropic key in POWER UP to enable Mapillary AI'; - if (el.aiModel) - el.aiModel.textContent = state.plannerModel - ? `· ${state.plannerModel}` - : ''; - if (el.aiHint) { - el.aiHint.classList.toggle('is-warn', !state.planner); - el.aiHint.textContent = state.planner - ? 'Type what you want to see in plain English — objects, traffic signs, coverage or a street view of any place — and press Enter. Follow-ups refine the last answer.' - : 'Mapillary AI is off until an Anthropic key is added in POWER UP. Coverage, sequences and the viewer work without it.'; + if (el.enableBtn) { + el.enableBtn.textContent = view.enableButton.text; + el.enableBtn.setAttribute( + 'aria-pressed', + String(view.enableButton.pressed), + ); } + } - // Imagery filters. - const filter = state.coverage.filter || { pano: 'all', sinceMs: null }; + _renderEmptyState(view) { + const el = this._elements; + if (el.keyless) el.keyless.hidden = !view.keyless.visible; + if (el.controls) el.controls.disabled = view.controlsDisabled; + } + + _renderQuery(view) { + const el = this._elements; + const { query } = view; + if (el.input) { + el.input.placeholder = query.placeholder; + el.input.disabled = query.inputDisabled; + } + if (el.runBtn) { + el.runBtn.textContent = query.submitLabel; + el.runBtn.classList.toggle('is-stop', query.submitIsStop); + el.runBtn.disabled = query.inputDisabled && !query.submitIsStop; + el.runBtn.setAttribute( + 'aria-label', + query.submitIsStop ? 'Stop the query' : 'Ask', + ); + } + for (const button of this.root.querySelectorAll('[data-mly-suggestion]')) + button.disabled = query.suggestionsDisabled; + if (el.hint) { + el.hint.textContent = query.hint; + el.hint.classList.toggle('is-warn', query.hintWarn); + } + if (el.answer) el.answer.textContent = view.answer; + if (el.error) { + el.error.hidden = !view.error; + if (el.errorText) el.errorText.textContent = view.error || ''; + } + if (el.progress) { + el.progress.hidden = !view.progress.visible; + el.progress.classList.toggle( + 'is-indeterminate', + view.progress.indeterminate, + ); + if (el.progressFill && !view.progress.indeterminate) + el.progressFill.style.width = `${view.progress.percent}%`; + if (el.progressFill && view.progress.indeterminate) + el.progressFill.style.removeProperty('width'); + if (el.progressLabel) el.progressLabel.textContent = view.progress.label; + } + } + + _renderResults(view) { + const el = this._elements; + if (!el.results) return; + el.results.hidden = !view.results.visible; + if (!view.results.visible || !el.chips) return; + const expanded = el.chips.classList.contains('is-expanded'); + const chip = (entry, overflow) => { + const node = document.createElement('span'); + node.className = `mly-value-chip${overflow ? ' is-overflow' : ''}`; + node.title = entry.value; + const dot = document.createElement('i'); + dot.style.background = entry.color; + const text = document.createElement('span'); + text.textContent = entry.label; + const num = document.createElement('b'); + num.textContent = entry.count; + node.append(dot, text, num); + return node; + }; + const nodes = view.results.all.map((entry, index) => + chip(entry, index >= view.results.chips.length), + ); + if (view.results.more) { + const more = document.createElement('button'); + more.type = 'button'; + more.className = 'mly-value-chip'; + more.dataset.mlyMore = String(view.results.more.count); + more.title = view.results.more.title; + more.setAttribute('aria-expanded', String(expanded)); + more.textContent = expanded + ? 'FEWER' + : `+${view.results.more.count} CLASSES`; + nodes.push(more); + } else el.chips.classList.remove('is-expanded'); + el.chips.replaceChildren(...nodes); + } + + _renderFilters(view) { + const el = this._elements; for (const button of this.root.querySelectorAll('[data-mly-pano]')) { - const active = button.dataset.mlyPano === filter.pano; + const active = button.dataset.mlyPano === view.filter.pano; button.classList.toggle('is-active', active); button.setAttribute('aria-checked', String(active)); } - - // 3D objects. - const objects = state.objects3d || {}; - el.threeDBtn.setAttribute('aria-pressed', String(objects.enabled === true)); - el.threeDBtn.textContent = objects.building - ? '3D OBJECTS …' - : objects.enabled - ? '3D OBJECTS ON' - : '3D OBJECTS OFF'; - el.threeDNote.hidden = !( - objects.enabled && - (objects.error || objects.active) - ); - el.threeDNote.textContent = objects.error - ? objects.error - : objects.active - ? `${objects.count.toLocaleString()} objects standing in 3D · click one to open its image` - : ''; - - el.photorealBtn.setAttribute( - 'aria-pressed', - String(this.actions.isMapStackActive?.('photoreal') === true), - ); - - // Answer / error line. - const error = state.query.error || state.street.error; - if (error) { - el.answer.textContent = error; - el.answer.classList.add('is-error'); - } else { - el.answer.classList.remove('is-error'); - if (state.query.answer) el.answer.textContent = state.query.answer; - else if (state.query.busy) el.answer.textContent = 'Planning…'; - else if (!enabled) - el.answer.textContent = - 'Turn on Street Level to see Mapillary coverage, then ask for anything that appears in street imagery.'; - else if (state.coverage.hint) el.answer.textContent = state.coverage.hint; - else - el.answer.textContent = - 'Click a green sequence to see its images, or ask Mapillary AI for objects and signs.'; - } - - // Progress bar while tiles stream. - const { progress } = state.features; - const showProgress = state.features.loading && progress.tiles > 0; - el.progress.hidden = !showProgress; - if (showProgress) { - const pct = Math.round((progress.done / progress.tiles) * 100); - el.progressFill.style.width = `${pct}%`; - el.progressLabel.textContent = `${progress.done}/${progress.tiles} tiles · ${state.features.total.toLocaleString()} found`; - } - - // Result chips. - const hasResults = state.features.total > 0 || state.features.hasBbox; - el.results.hidden = !hasResults; - if (hasResults) { - el.chips.replaceChildren( - ...state.features.counts - .slice(0, 8) - .map(({ value, count, label, color }) => { - const chipEl = document.createElement('span'); - chipEl.className = 'mly-value-chip'; - chipEl.title = value; - const dot = document.createElement('i'); - dot.style.background = color; - const text = document.createElement('span'); - text.textContent = label; - const num = document.createElement('b'); - num.textContent = count.toLocaleString(); - chipEl.append(dot, text, num); - return chipEl; - }), + if (el.legend && el.legend.childElementCount !== view.legend.length) { + el.legend.replaceChildren( + ...view.legend.map((entry) => { + const item = document.createElement('li'); + const swatch = document.createElement('i'); + swatch.className = 'mly-legend-swatch'; + swatch.style.background = entry.color; + const label = document.createElement('span'); + label.textContent = entry.label; + item.append(swatch, label); + return item; + }), ); - if (state.features.counts.length > 8) { - const more = document.createElement('span'); - more.className = 'mly-value-chip'; - more.textContent = `+${state.features.counts.length - 8} classes`; - el.chips.append(more); - } } + } - // Street-level viewer. - const { street } = state; - el.viewerWrap.hidden = !street.open; - this.root.classList.toggle('mly-has-viewer', street.open === true); - if (!street.open && this.root.classList.contains('mly-viewer-expanded')) - this.setViewerExpanded(false); - el.followBtn.setAttribute('aria-pressed', String(street.follow === true)); - el.followBtn.textContent = street.follow - ? 'STREET COCKPIT ON' - : 'STREET COCKPIT OFF'; - el.followBtn.disabled = !street.open; - if (el.viewerFit) { - const fill = street.renderMode === 'fill'; - el.viewerFit.textContent = fill ? 'FILL' : 'FIT'; - el.viewerFit.title = fill - ? 'Filling the frame (cropped) · click to show the whole image' - : 'Showing the whole image · click to fill the frame'; + _renderViewer(view, state) { + const el = this._elements; + const { viewer } = view; + if (el.viewerWrap) el.viewerWrap.hidden = !viewer.open; + if (!viewer.open && this.isViewerExpanded()) this.setViewerExpanded(false); + if (el.viewerPlaceholder) el.viewerPlaceholder.hidden = !viewer.loading; + if (el.followBtn) { + el.followBtn.setAttribute('aria-pressed', String(viewer.follow.pressed)); + el.followBtn.disabled = viewer.follow.disabled; } - if (street.open) { - // Caption bar as on mapillary.com: "Image by …" left, date right. - const left = []; - if (street.loading) left.push('LOADING…'); - if (street.feature?.label) - left.push( - `${street.feature.label}${street.feature.imageCount ? ` · ${street.feature.imageCount} sightings` : ''}`, - ); - if (street.highlight?.count) - left.push( - `${street.highlight.count} detection${street.highlight.count === 1 ? '' : 's'} outlined`, - ); - if (street.creator) left.push(`Image by ${street.creator}`); - const right = []; - if (street.isPano) right.push('360°'); - if (Number.isFinite(street.bearing)) - right.push(`${Math.round(street.bearing)}°`); - if (street.capturedAt) right.push(formatDate(street.capturedAt)); - if (el.imageBy) el.imageBy.textContent = left.join(' · '); - if (el.imageWhen) el.imageWhen.textContent = right.join(' · '); - if (el.imageLink) { - el.imageLink.href = mapillaryImageUrl(street.imageId); - el.imageLink.hidden = !street.imageId; - } - this.mapillary.resizeViewer?.(); + for (const button of this.root.querySelectorAll('[data-mly-render]')) { + const active = button.dataset.mlyRender === viewer.renderMode; + button.classList.toggle('is-active', active); + button.setAttribute('aria-checked', String(active)); } + if (!viewer.open) return; + if (el.imageBy) el.imageBy.textContent = viewer.captionLeft; + if (el.imageWhen) el.imageWhen.textContent = viewer.captionRight; + if (el.imageLink) { + el.imageLink.hidden = !viewer.link; + if (viewer.link) el.imageLink.href = viewer.link; + } + if (state.street.open) this.mapillary.resizeViewer?.(); + } - // Coverage meta line. - const coverageBits = []; - if (enabled && state.coverage.zoom) - coverageBits.push( - `coverage z${state.coverage.zoom} · ${state.coverage.sequences.toLocaleString()} sequences`, - ); - if (state.sequence.selectedId) - coverageBits.push( - state.sequence.loading - ? 'loading sequence…' - : `${state.sequence.images} images in sequence · Esc clears`, - ); - if (state.query.usage?.model) - coverageBits.push( - `${state.query.usage.model} · ${state.query.usage.input_tokens ?? '?'}→${state.query.usage.output_tokens ?? '?'} tok`, - ); - el.coverageMeta.textContent = coverageBits.join(' · '); + _renderMeta(view) { + const el = this._elements; + if (el.coverageMeta) el.coverageMeta.textContent = view.meta; + } + /** Open the panel at the moments a user would look for it. */ + _reactToTransitions(view, state) { + const enabled = view.enabled; + if (enabled && this._wasEnabled === false) this.setCollapsed(false); + this._wasEnabled = enabled; + if (state.street.open && !this._wasStreetOpen) this.setCollapsed(false); + this._wasStreetOpen = state.street.open === true; if (state.query.stage !== this._lastStage) { this._lastStage = state.query.stage; - if (state.query.stage === 'done' || state.query.stage === 'error') { + if (state.query.stage === 'done' || state.query.stage === 'error') this.setCollapsed(false); - if (this.isMinimized()) this.setMinimized(false); - } } } @@ -578,7 +489,6 @@ export class MapillaryControls { this.listeners.abort(); this._resizeObserver?.disconnect(); this._resizeObserver = null; - this._floating?.destroy(); this._unsubscribe?.(); this._unsubscribe = null; this.mapillary.attachViewerHost?.(null); diff --git a/src/ui/mapillaryPresentation.js b/src/ui/mapillaryPresentation.js new file mode 100644 index 000000000..eeb9482aa --- /dev/null +++ b/src/ui/mapillaryPresentation.js @@ -0,0 +1,205 @@ +/** + * Turn the Street Level layer's UI state into the strings and flags the panel + * renders. Pure: no DOM, no layer calls, so every wording decision is testable. + */ + +const HINT_READY = + 'Objects, traffic signs, coverage or a street view of any place. Follow-ups refine the last answer.'; +const HINT_NO_PLANNER = + 'Plain-English questions need an Anthropic key in POWER UP. Coverage, photos and filters work without it.'; +const IDLE_ANSWER_OFF = + 'Turn on Street Level to see Mapillary coverage on the globe.'; +const IDLE_ANSWER_ON = + 'Click a green line to see its photos, or ask for objects and signs.'; + +function formatDate(ms) { + if (!Number.isFinite(ms)) return ''; + try { + return new Date(ms).toISOString().slice(0, 10); + } catch { + return ''; + } +} + +/** Deep link to an image on mapillary.com, as the web app shares them. */ +export function mapillaryImageUrl(imageId) { + const id = String(imageId || '').trim(); + if (!id) return 'https://www.mapillary.com/app/'; + return `https://www.mapillary.com/app/?pKey=${encodeURIComponent(id)}&focus=photo`; +} + +function presentStatus(state) { + if (state.keyRequired) return { text: 'KEY REQUIRED', tone: 'warn' }; + if (state.query.busy) + return { text: `AI · ${state.query.stage.toUpperCase()}`, tone: 'busy' }; + if (state.coverage.loading || state.features.loading) + return { text: 'LOADING', tone: 'busy' }; + return state.enabled ? { text: 'ON', tone: 'on' } : { text: 'OFF', tone: '' }; +} + +function presentProgress(state) { + const { stage, busy } = state.query; + const { progress, loading, total } = state.features; + if (loading && progress.tiles > 0) { + const pct = Math.round((progress.done / progress.tiles) * 100); + return { + visible: true, + indeterminate: false, + percent: pct, + label: `${progress.done}/${progress.tiles} tiles · ${total.toLocaleString()} found`, + }; + } + if (busy && stage === 'planning') + return { + visible: true, + indeterminate: true, + percent: 0, + label: 'Planning…', + }; + if (busy && stage === 'resolving') + return { + visible: true, + indeterminate: true, + percent: 0, + label: `Finding ${state.query.place || 'the area'}…`, + }; + if (busy) + return { + visible: true, + indeterminate: true, + percent: 0, + label: 'Working…', + }; + return { visible: false, indeterminate: false, percent: 0, label: '' }; +} + +function presentResults(state, { maxChips = 8 } = {}) { + const counts = state.features.counts || []; + const visible = state.features.total > 0 || state.features.hasBbox === true; + const chips = counts.map(({ value, count, label, color }) => ({ + value, + label, + color, + count: count.toLocaleString(), + })); + const hidden = chips.slice(maxChips); + return { + visible, + chips: chips.slice(0, maxChips), + more: hidden.length + ? { + count: hidden.length, + title: hidden.map((chip) => `${chip.label} ${chip.count}`).join(', '), + } + : null, + all: chips, + }; +} + +function presentViewer(state) { + const { street } = state; + const left = []; + if (street.feature?.label) + left.push( + `${street.feature.label}${street.feature.imageCount ? ` · ${street.feature.imageCount} sightings` : ''}`, + ); + if (street.highlight?.count) + left.push( + `${street.highlight.count} detection${street.highlight.count === 1 ? '' : 's'} outlined`, + ); + if (street.creator) left.push(`Image by ${street.creator}`); + const right = []; + if (street.isPano) right.push('360°'); + if (Number.isFinite(street.bearing)) + right.push(`${Math.round(street.bearing)}°`); + if (street.capturedAt) right.push(formatDate(street.capturedAt)); + return { + open: street.open === true, + loading: street.loading === true && !street.imageId, + renderMode: street.renderMode === 'fill' ? 'fill' : 'letterbox', + captionLeft: left.join(' · '), + captionRight: right.join(' · '), + link: street.imageId ? mapillaryImageUrl(street.imageId) : null, + follow: { + pressed: street.follow === true, + disabled: street.open !== true, + }, + }; +} + +function presentMeta(state) { + if (!state.enabled) return ''; + if (state.sequence.selectedId) + return state.sequence.loading + ? 'Loading this sequence…' + : `${state.sequence.images.toLocaleString()} images in this sequence · Esc clears`; + const bits = []; + if (state.coverage.zoom && state.coverage.sequences > 0) + bits.push( + `${state.coverage.sequences.toLocaleString()} sequences in view · click a line for its photos`, + ); + else if (state.coverage.hint) bits.push(state.coverage.hint); + if (state.query.usage?.model) + bits.push( + `${state.query.usage.model} · ${state.query.usage.input_tokens ?? '?'}→${state.query.usage.output_tokens ?? '?'} tok`, + ); + return bits.join(' · '); +} + +/** + * @param {object} state Snapshot from the layer's `getUIState()`. + * @returns {object} Everything the panel needs, already worded. + */ +export function presentMapillaryPanel(state) { + const enabled = state.enabled === true; + const keyRequired = state.keyRequired === true; + const planner = state.planner === true; + const busy = state.query.busy === true; + const error = state.query.error || state.street.error || null; + let answer = state.query.answer || ''; + if (!answer && !error) { + if (busy) answer = ''; + else if (!enabled) answer = IDLE_ANSWER_OFF; + else if (state.coverage.hint) answer = state.coverage.hint; + else answer = IDLE_ANSWER_ON; + } + return { + enabled, + keyRequired, + status: presentStatus(state), + keyless: { + visible: keyRequired, + text: 'Street Level needs a free Mapillary client token (MAPILLARY_CLIENT_TOKEN). Coverage, photos and queries stay off until one is added.', + }, + controlsDisabled: keyRequired, + enableButton: { + text: enabled ? 'STREET LEVEL ON' : 'STREET LEVEL OFF', + pressed: enabled, + }, + query: { + placeholder: planner + ? 'Ask about objects, signs or a place' + : 'Anthropic key needed — see POWER UP', + inputDisabled: !planner, + suggestionsDisabled: !planner, + hint: planner ? HINT_READY : HINT_NO_PLANNER, + hintWarn: !planner, + model: state.plannerModel ? `· ${state.plannerModel}` : '', + submitLabel: busy ? 'STOP' : 'ASK', + submitIsStop: busy, + }, + progress: presentProgress(state), + answer, + error, + results: presentResults(state), + filter: state.coverage.filter || { pano: 'all', sinceMs: null }, + legend: state.coverage.legend || [], + viewer: presentViewer(state), + meta: presentMeta(state), + /** The panel opens itself at these moments (a native panel stays put otherwise). */ + wantsOpen: + state.street.open === true || + state.query.stage === 'done' || + state.query.stage === 'error', + }; +} diff --git a/src/ui/panelChrome.js b/src/ui/panelChrome.js index 2562f137b..66652c306 100644 --- a/src/ui/panelChrome.js +++ b/src/ui/panelChrome.js @@ -11,6 +11,7 @@ const SHARE_PANEL_STATE_SPECS = Object.freeze([ { id: 'location-bar', pinnable: true }, { id: 'data-panel' }, { id: 'cctv-panel' }, + { id: 'mapillary-panel' }, { id: 'radio-panel' }, { id: 'scene-panel' }, { id: 'global-context-panel' }, @@ -21,6 +22,7 @@ const SHARE_PANEL_STATE_SPECS = Object.freeze([ const COCKPIT_ENTRY_COLLAPSE_PANEL_IDS = Object.freeze([ 'data-panel', 'cctv-panel', + 'mapillary-panel', 'scene-panel', 'pp-toggles', 'global-context-panel', @@ -281,6 +283,7 @@ export class PanelChrome { const isRightRail = [ 'pp-toggles', 'cctv-panel', + 'mapillary-panel', 'global-context-panel', ].includes(panelEl?.id); const collapsed = panelEl.classList.contains('collapsed'); diff --git a/src/ui/panelLayoutController.js b/src/ui/panelLayoutController.js index f7bdd1977..5b2567898 100644 --- a/src/ui/panelLayoutController.js +++ b/src/ui/panelLayoutController.js @@ -94,6 +94,7 @@ export class PanelLayoutController { this._rightPanelStack = document.getElementById('right-context-rail'); this._ppToggles = document.getElementById('pp-toggles'); this._cctvPanel = document.getElementById('cctv-panel'); + this._mapillaryPanel = document.getElementById('mapillary-panel'); this._sliderPanel = document.getElementById('param-slider-panel'); this._detectionBtn = document.getElementById('detection-toggle'); } @@ -204,6 +205,19 @@ export class PanelLayoutController { stack.insertBefore(this._cctvPanel, globalContextPanel); this._syncPanelCollapseButton(this._cctvPanel); } + if (this._mapillaryPanel) { + this._mapillaryPanel.style.removeProperty('top'); + this._mapillaryPanel.style.removeProperty('right'); + this._mapillaryPanel.style.removeProperty('bottom'); + this._mapillaryPanel.style.removeProperty('left'); + this._mapillaryPanel.style.removeProperty('z-index'); + this._mapillaryPanel.classList.remove( + 'panel-draggable', + 'panel-dragging', + ); + stack.insertBefore(this._mapillaryPanel, globalContextPanel); + this._syncPanelCollapseButton(this._mapillaryPanel); + } if (this._sliderPanel) { this._sliderPanel.style.removeProperty('top'); this._sliderPanel.style.removeProperty('right'); @@ -222,6 +236,7 @@ export class PanelLayoutController { for (const panel of [ this._ppToggles, this._cctvPanel, + this._mapillaryPanel, globalContextPanel, ]) { if (panel) this._rightStackResizeObserver.observe(panel); diff --git a/src/ui/shellElements.js b/src/ui/shellElements.js index 065380dfb..8e8f0fb96 100644 --- a/src/ui/shellElements.js +++ b/src/ui/shellElements.js @@ -44,7 +44,7 @@ export function readShellElements(document = globalThis.document) { _dataPanel: document.getElementById('data-panel'), _scenePanel: document.getElementById('scene-panel'), _cctvPanel: document.getElementById('cctv-panel'), - _mapillaryDock: document.getElementById('mapillary-dock'), + _mapillaryPanel: document.getElementById('mapillary-panel'), _radioPanel: document.getElementById('radio-panel'), _contextRadioDock: document.getElementById('context-radio-dock'), _contextRadioToggleBtn: document.getElementById('context-radio-toggle-btn'), diff --git a/src/ui/styles/layers.css b/src/ui/styles/layers.css index 72207fa52..5dc48030a 100644 --- a/src/ui/styles/layers.css +++ b/src/ui/styles/layers.css @@ -162,6 +162,7 @@ } #right-context-rail > #pp-toggles, #right-context-rail > #cctv-panel, +#right-context-rail > #mapillary-panel, #right-context-rail > #global-context-panel { position: relative; inset: auto; @@ -173,12 +174,14 @@ max-height: 100%; pointer-events: auto; } -#right-context-rail #cctv-panel { +#right-context-rail #cctv-panel, +#right-context-rail #mapillary-panel { width: 100%; max-height: 100%; pointer-events: auto; } -#right-context-rail #cctv-panel.collapsed { +#right-context-rail #cctv-panel.collapsed, +#right-context-rail #mapillary-panel.collapsed { width: var(--right-display-collapsed-width); margin-left: auto; } @@ -191,6 +194,7 @@ } #right-context-rail > #pp-toggles.collapsed .pp-header-row, #right-context-rail #cctv-panel.collapsed .cctv-panel-inner, +#right-context-rail #mapillary-panel.collapsed .mapillary-panel-inner, #right-context-rail #global-context-panel.collapsed .global-context-panel-inner { @@ -198,7 +202,8 @@ min-height: 50px; box-sizing: border-box; } -#right-context-rail #cctv-panel:not(.collapsed) .cctv-panel-inner { +#right-context-rail #cctv-panel:not(.collapsed) .cctv-panel-inner, +#right-context-rail #mapillary-panel:not(.collapsed) .mapillary-panel-inner { max-height: 100%; overflow-y: auto; scrollbar-gutter: stable; @@ -246,6 +251,7 @@ } #right-context-rail.layout-focus > #pp-toggles:not(.collapsed), #right-context-rail.layout-focus > #cctv-panel:not(.collapsed), +#right-context-rail.layout-focus > #mapillary-panel:not(.collapsed), #right-context-rail.layout-focus > #global-context-panel:not(.collapsed) { flex: 0 1 var(--right-panel-allocated-height); height: var(--right-panel-allocated-height); @@ -261,6 +267,9 @@ #right-context-rail.layout-focus > #cctv-panel:not(.collapsed) .cctv-panel-inner, +#right-context-rail.layout-focus + > #mapillary-panel:not(.collapsed) + .mapillary-panel-inner, #right-context-rail.layout-focus > #global-context-panel:not(.collapsed) .global-context-panel-inner { diff --git a/src/ui/styles/mapillary.css b/src/ui/styles/mapillary.css index 8aa634ad0..aed38ac55 100644 --- a/src/ui/styles/mapillary.css +++ b/src/ui/styles/mapillary.css @@ -1,23 +1,13 @@ -/* ── Mapillary dock ─────────────────────────── */ -#mapillary-dock { +/* ── Street Level (Mapillary) panel ───────────────────────────────────────── + Lives in the right context rail next to CCTV; the rail owns position and + width (see layers.css). Everything here is panel-internal. */ +#mapillary-panel { --mly-green: #05cb63; - --mly-width: clamp(320px, 30vw, 460px); - position: fixed; - /* Sit beside the right context rail (DISPLAY / CONTEXT / CCTV), which owns - the right-hand 330px column from 26vh down, so the two never overlap. */ - right: calc(var(--right-rail-x, 52px) + 330px + 16px); - bottom: 3.4rem; - z-index: 118; - width: var(--mly-width); - max-width: calc(100vw - 32px); - max-height: calc(100vh - 7rem); - display: flex; - flex-direction: column; - pointer-events: auto; - font-family: var(--font-mono); + --mly-text: 0.62rem; + --mly-text-small: 0.6rem; } -#mapillary-dock .panel-glow { +#mapillary-panel .panel-glow { position: absolute; inset: -18px; background: radial-gradient( @@ -30,211 +20,71 @@ pointer-events: none; } -.mly-dock-inner { +.mapillary-panel-inner { position: relative; - display: flex; - flex-direction: column; - gap: 8px; - padding: 12px 14px; background: var(--glass-bg); border: 1px solid var(--glass-border); border-radius: var(--panel-radius); + padding: 14px; + display: flex; + flex-direction: column; + gap: 8px; backdrop-filter: blur(24px) saturate(1.4); -webkit-backdrop-filter: blur(24px) saturate(1.4); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5), - 0 0 0 1px rgba(255, 255, 255, 0.03) inset; - flex: 1 1 auto; - min-height: 0; - overflow: hidden; + 0 0 0 1px rgba(255, 255, 255, 0.03) inset, + 0 1px 0 rgba(255, 255, 255, 0.06) inset; + scrollbar-width: thin; + scrollbar-color: rgba(0, 212, 255, 0.46) rgba(255, 255, 255, 0.06); } -.mly-header { +#mapillary-panel .panel-header { + gap: 8px; +} + +.mly-mark { + width: 14px; + height: 14px; flex: 0 0 auto; - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; + color: var(--text-dim); + transition: color var(--transition-fast); } -.mly-title-btn { - display: flex; - align-items: center; - gap: 8px; - background: none; - border: 0; - color: var(--text-primary); - cursor: pointer; - padding: 2px 0; - font: inherit; - letter-spacing: 0.12em; - font-size: 0.72rem; +#mapillary-panel[data-mly-enabled='true'] .mly-mark { + color: var(--mly-green); + filter: drop-shadow(0 0 6px rgba(5, 203, 99, 0.7)); } -.mly-brand-dot { - width: 10px; - height: 10px; - border-radius: 50%; - background: var(--mly-green); - box-shadow: 0 0 10px rgba(5, 203, 99, 0.8); -} - -.mly-chip { - font-size: 0.58rem; +.mly-status { + font-family: var(--font-mono); + font-size: var(--mly-text-small); letter-spacing: 0.1em; padding: 2px 7px; border-radius: 999px; border: 1px solid var(--glass-border-hover); color: var(--text-secondary); + white-space: nowrap; } -.mly-chip.is-on { +.mly-status.is-on { color: var(--mly-green); border-color: rgba(5, 203, 99, 0.55); } -.mly-chip.is-busy { +.mly-status.is-busy { color: var(--accent); border-color: rgba(0, 212, 255, 0.55); } -.mly-chip.is-warn { +.mly-status.is-warn { color: #ffb300; border-color: rgba(255, 179, 0, 0.55); } -.mly-header-tools { - display: flex; - align-items: center; - gap: 6px; -} - -.mly-icon-btn { - width: 26px; - height: 26px; - border-radius: var(--btn-radius); - border: 1px solid var(--glass-border-hover); - background: rgba(255, 255, 255, 0.04); - color: var(--text-secondary); - font: inherit; - font-size: 0.7rem; - line-height: 1; - cursor: pointer; - display: grid; - place-items: center; - transition: all var(--transition-fast); -} - -.mly-icon-btn:hover { - color: var(--text-primary); - border-color: var(--accent); - background: rgba(0, 212, 255, 0.14); -} - -/* Author display rules would otherwise beat the UA's [hidden] { display: none }. */ -.mly-viewer-wrap[hidden], -.mly-minimized[hidden], -.mly-minimized-badge[hidden], -.mly-image-link[hidden] { - display: none !important; -} - -/* ── Minimized: only the Mapillary logo remains ─ */ -.mly-minimized { - position: relative; - width: 52px; - height: 52px; - border-radius: 50%; - border: 1px solid rgba(5, 203, 99, 0.55); - background: var(--glass-bg); - backdrop-filter: blur(18px) saturate(1.4); - -webkit-backdrop-filter: blur(18px) saturate(1.4); - color: var(--mly-green); - cursor: pointer; - display: grid; - place-items: center; - box-shadow: - 0 6px 24px rgba(0, 0, 0, 0.5), - 0 0 18px rgba(5, 203, 99, 0.35); - transition: - transform var(--transition-fast), - box-shadow var(--transition-fast); -} - -.mly-minimized:hover { - transform: scale(1.06); - box-shadow: - 0 8px 28px rgba(0, 0, 0, 0.55), - 0 0 26px rgba(5, 203, 99, 0.6); -} - -.mly-minimized .mly-logo { - width: 28px; - height: 28px; - display: block; -} - -.mly-minimized-badge { - position: absolute; - top: -4px; - right: -6px; - min-width: 18px; - height: 18px; - padding: 0 5px; - border-radius: 999px; - background: var(--accent); - color: #0a0a0f; - font-size: 0.52rem; - font-weight: 600; - letter-spacing: 0.04em; - display: grid; - place-items: center; - box-shadow: 0 0 10px rgba(0, 212, 255, 0.6); -} - -.mly-minimized.is-off { - color: var(--text-secondary); - border-color: var(--glass-border-hover); - box-shadow: 0 6px 24px rgba(0, 0, 0, 0.5); -} - -#mapillary-dock.minimized { - width: auto !important; - height: auto !important; - min-width: 0 !important; - min-height: 0 !important; - max-height: none !important; - align-items: flex-end; -} - -#mapillary-dock.minimized .mly-dock-inner, -#mapillary-dock.minimized .panel-glow, -#mapillary-dock.minimized .gev-resize-grip, -#mapillary-dock.minimized .gev-resize-edge { - display: none; -} - -.mly-power { - font: inherit; - font-size: 0.6rem; - letter-spacing: 0.1em; - padding: 6px 10px; - border-radius: var(--btn-radius); - border: 1px solid var(--glass-border-hover); - background: rgba(255, 255, 255, 0.04); - color: var(--text-secondary); - cursor: pointer; - transition: all var(--transition-fast); -} - -.mly-power[aria-pressed='true'] { - color: #0a0a0f; - background: var(--mly-green); - border-color: var(--mly-green); - box-shadow: 0 0 14px rgba(5, 203, 99, 0.45); -} - -#mapillary-dock.collapsed .mly-body { +/* Collapsed strips hide the body entirely: nothing to paint or tab into. */ +#mapillary-panel.collapsed .mly-body, +#mapillary-panel.collapsed .mly-status { display: none; } @@ -242,38 +92,50 @@ display: flex; flex-direction: column; gap: 8px; - flex: 1 1 auto; min-height: 0; } -/* Everything above the viewer scrolls; the viewer itself never does. */ -.mly-scroll { +.mly-controls { display: flex; flex-direction: column; gap: 8px; - flex: 0 1 auto; - min-height: 0; - overflow-y: auto; - scrollbar-width: thin; - padding-right: 2px; + min-width: 0; + margin: 0; + padding: 0; + border: 0; } -/* Children keep their natural height; the region scrolls instead. */ -.mly-scroll > * { - flex-shrink: 0; +.mly-controls:disabled { + opacity: 0.45; + pointer-events: none; } -#mapillary-dock.mly-has-viewer .mly-scroll { - /* Let the imagery win the height fight when the window is short. */ - flex-shrink: 3; +/* ── Keyless empty state ─────────────────────────────────────────────────── */ +.mly-keyless { + display: flex; + flex-direction: column; + gap: 8px; + padding: 10px 12px; + border: 1px solid rgba(255, 179, 0, 0.45); + border-radius: 9px; + background: rgba(255, 179, 0, 0.06); } -.mly-kicker { - font-size: 0.56rem; - letter-spacing: 0.16em; - color: var(--accent); +.mly-keyless-text { + margin: 0; + font-family: var(--font-sans); + font-size: 0.72rem; + line-height: 1.4; + color: var(--text-primary); } +.mly-keyless-text code { + font-family: var(--font-mono); + font-size: 0.66rem; + color: #ffb300; +} + +/* ── Query ───────────────────────────────────────────────────────────────── */ .mly-query { display: flex; flex-direction: column; @@ -288,22 +150,68 @@ #mly-query-input { flex: 1; min-width: 0; - padding: 8px 10px; + padding: 9px 11px; border-radius: var(--btn-radius); - border: 1px solid var(--glass-border-hover); - background: rgba(0, 0, 0, 0.35); + border: 1px solid rgba(0, 212, 255, 0.45); + background: rgba(0, 212, 255, 0.06); color: var(--text-primary); font: inherit; - font-size: 0.72rem; + font-family: var(--font-mono); + font-size: 0.74rem; +} + +#mly-query-input::placeholder { + color: rgba(232, 234, 237, 0.55); } #mly-query-input:focus { + outline: none; border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-dim); } -.mly-run { - min-width: 56px; +#mly-query-input:disabled { + border-color: var(--glass-border); + background: rgba(255, 255, 255, 0.03); +} + +.mly-ask { + flex: 0 0 auto; + min-width: 64px; + color: #0a0a0f; + background: var(--accent); + border-color: var(--accent); + font-weight: 600; +} + +.mly-ask:hover:not(:disabled) { + color: #0a0a0f; + background: var(--accent); + box-shadow: 0 0 16px rgba(0, 212, 255, 0.5); +} + +.mly-ask.is-stop { + color: #ff8a80; + background: rgba(255, 88, 88, 0.15); + border-color: #ff7272; +} + +.mly-ask.is-stop:hover:not(:disabled) { + color: #ff8a80; + background: rgba(255, 88, 88, 0.25); + border-color: #ff7272; +} + +.mly-hint { + margin: 0; + font-family: var(--font-sans); + font-size: 0.68rem; + line-height: 1.35; + color: var(--text-secondary); +} + +.mly-hint.is-warn { + color: #ffb300; } .mly-suggestions { @@ -313,8 +221,8 @@ } .mly-suggestion { - font: inherit; - font-size: 0.56rem; + font-family: var(--font-mono); + font-size: var(--mly-text-small); letter-spacing: 0.06em; padding: 3px 8px; border-radius: 999px; @@ -324,11 +232,17 @@ cursor: pointer; } -.mly-suggestion:hover { +.mly-suggestion:hover:not(:disabled) { color: var(--text-primary); border-color: var(--glass-border-hover); } +.mly-suggestion:disabled { + opacity: 0.5; + cursor: default; +} + +/* ── Answer, error, progress ─────────────────────────────────────────────── */ .mly-answer { font-family: var(--font-sans); font-size: 0.74rem; @@ -337,7 +251,14 @@ min-height: 1.2em; } -.mly-answer.is-error { +.mly-answer:empty { + display: none; +} + +.mly-error { + font-family: var(--font-sans); + font-size: 0.72rem; + line-height: 1.4; color: #ff8a80; } @@ -356,35 +277,67 @@ transition: width 160ms linear; } +.mly-progress.is-indeterminate .mly-progress-fill { + width: 40%; + animation: mly-indeterminate 1.4s ease-in-out infinite; +} + +@keyframes mly-indeterminate { + 0% { + transform: translateX(-100%); + } + 100% { + transform: translateX(250%); + } +} + .mly-progress-label { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; - font-size: 0.55rem; + font-family: var(--font-mono); + font-size: var(--mly-text-small); letter-spacing: 0.1em; color: var(--text-primary); text-shadow: 0 0 4px rgba(0, 0, 0, 0.8); } +/* ── Results ─────────────────────────────────────────────────────────────── */ +.mly-results { + display: flex; + flex-direction: column; + gap: 8px; +} + .mly-chips { display: flex; flex-wrap: wrap; gap: 4px; } +.mly-chips.is-expanded .mly-value-chip.is-overflow { + display: inline-flex; +} + .mly-value-chip { display: inline-flex; align-items: center; gap: 6px; - font-size: 0.58rem; + font-family: var(--font-mono); + font-size: var(--mly-text-small); padding: 3px 8px; border-radius: 999px; border: 1px solid var(--glass-border); + background: transparent; color: var(--text-primary); } +.mly-value-chip.is-overflow { + display: none; +} + .mly-value-chip i { width: 8px; height: 8px; @@ -397,54 +350,212 @@ font-weight: 500; } -.mly-actions { +button.mly-value-chip { + cursor: pointer; +} + +button.mly-value-chip:hover { + border-color: var(--glass-border-hover); +} + +/* ── Filters and legend ──────────────────────────────────────────────────── */ +.mly-filters { display: flex; - gap: 6px; + align-items: center; + gap: 8px; flex-wrap: wrap; } -.mly-actions .scene-btn { - flex: 1 1 auto; +.mly-seg { + display: inline-flex; + border: 1px solid var(--glass-border-hover); + border-radius: 999px; + overflow: hidden; } -.scene-btn[aria-pressed='true'] { - color: #0a0a0f; - background: var(--accent); - border-color: var(--accent); +.mly-seg-btn { + font-family: var(--font-mono); + font-size: var(--mly-text-small); + letter-spacing: 0.08em; + padding: 4px 9px; + border: 0; + background: transparent; + color: var(--text-secondary); + cursor: pointer; } +.mly-seg-btn + .mly-seg-btn { + border-left: 1px solid var(--glass-border); +} + +.mly-seg-btn.is-active { + background: rgba(5, 203, 99, 0.22); + color: var(--text-primary); +} + +.mly-select { + font-family: var(--font-mono); + font-size: var(--mly-text-small); + letter-spacing: 0.06em; + padding: 4px 8px; + border-radius: 999px; + border: 1px solid var(--glass-border-hover); + background: rgba(0, 0, 0, 0.35); + color: var(--text-primary); +} + +.mly-select option { + background: #0a0a0f; +} + +.mly-legend { + display: flex; + flex-wrap: wrap; + gap: 4px 12px; + margin: 0; + padding: 0; + list-style: none; +} + +.mly-legend li { + display: inline-flex; + align-items: center; + gap: 6px; + font-family: var(--font-mono); + font-size: var(--mly-text-small); + letter-spacing: 0.06em; + color: var(--text-secondary); +} + +.mly-legend-swatch { + width: 14px; + height: 3px; + border-radius: 2px; + display: inline-block; +} + +/* ── Embedded viewer ─────────────────────────────────────────────────────── */ .mly-viewer-wrap { position: relative; container-type: inline-size; display: flex; flex-direction: column; - flex: 1 1 auto; - min-height: 230px; + flex: 0 0 auto; border-radius: 12px; overflow: hidden; border: 1px solid var(--glass-border-hover); background: #000; } -.mly-viewer { - width: 100%; - flex: 1 1 auto; - min-height: 0; - /* Intrinsic height while the dock is auto-sized; grows when floating. */ - aspect-ratio: 16 / 10; +.mly-viewer-wrap[hidden] { + display: none; } -#mapillary-dock.gev-floating .mly-viewer, -.mly-viewer-wrap-expanded .mly-viewer { - aspect-ratio: auto; +.mly-viewer { + width: 100%; + aspect-ratio: 16 / 10; min-height: 180px; } -/* Caption bar, mapillary.com style: creator left, date right. */ +.mly-viewer-placeholder { + position: absolute; + inset: 0 0 26px; + display: grid; + place-items: center; + z-index: 3; + background: rgba(0, 0, 0, 0.35); +} + +.mly-viewer-placeholder[hidden] { + display: none; +} + +.mly-viewer-placeholder::after { + content: ''; + width: 28px; + height: 28px; + border-radius: 50%; + border: 2px solid rgba(255, 255, 255, 0.2); + border-top-color: var(--mly-green); + animation: mly-spin 0.9s linear infinite; +} + +@keyframes mly-spin { + to { + transform: rotate(360deg); + } +} + +.mly-viewer-tools { + position: absolute; + top: 6px; + left: 6px; + /* Above MapillaryJS's overlays (z 10); its sequence bar is top-centre and + bearing/zoom/attribution bottom-right, so the top corners are free. */ + z-index: 12; + display: flex; + align-items: center; + gap: 4px; + pointer-events: none; +} + +.mly-viewer-tools > * { + pointer-events: auto; +} + +.mly-viewer-tools.mly-viewer-tools-right { + left: auto; + right: 6px; +} + +.mly-viewer-tools .scene-btn { + padding: 4px 8px; + font-size: var(--mly-text-small); + background: rgba(10, 10, 15, 0.75); + flex: none; + min-width: 0; + display: inline-flex; + align-items: center; + gap: 4px; +} + +.mly-viewer-tools .mly-seg { + background: rgba(10, 10, 15, 0.75); +} + +@container (max-width: 380px) { + .mly-viewer-tools .mly-btn-text { + display: none; + } +} + +.mly-viewer-x { + width: 30px; + height: 30px; + border-radius: 50%; + border: 1px solid rgba(255, 255, 255, 0.25); + background: rgba(10, 10, 15, 0.8); + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 1.1rem; + line-height: 1; + cursor: pointer; + display: grid; + place-items: center; + transition: all var(--transition-fast); +} + +.mly-viewer-x:hover { + color: #0a0a0f; + background: #fff; + border-color: #fff; +} + .mly-image-meta { flex: 0 0 auto; padding: 6px 10px; - font-size: 0.58rem; + font-family: var(--font-mono); + font-size: var(--mly-text-small); letter-spacing: 0.06em; color: var(--text-secondary); background: rgba(0, 0, 0, 0.7); @@ -476,441 +587,29 @@ letter-spacing: 0.1em; } +.mly-image-link[hidden] { + display: none; +} + .mly-image-link:hover { text-decoration: underline; } -.mly-viewer-tools.mly-viewer-tools-right { - left: auto; - right: 6px; -} - -/* Only the buttons catch clicks; the containers let the viewer through. */ -.mly-viewer-tools { - pointer-events: none; -} - -.mly-viewer-tools > * { - pointer-events: auto; -} - -.mly-viewer-x { - width: 30px; - height: 30px; - border-radius: 50%; - border: 1px solid rgba(255, 255, 255, 0.25); - background: rgba(10, 10, 15, 0.8); - color: var(--text-primary); - font: inherit; - font-size: 1.1rem; - line-height: 1; - cursor: pointer; - display: grid; - place-items: center; - transition: all var(--transition-fast); -} - -.mly-viewer-x:hover { - color: #0a0a0f; - background: #fff; - border-color: #fff; -} - -.mly-viewer-wrap-expanded .mly-viewer-tools.mly-viewer-tools-right { - left: auto; - right: 10px; - top: 10px; -} - -.mly-viewer-wrap-expanded .mly-viewer-x { - width: 40px; - height: 40px; - font-size: 1.5rem; -} - .mly-meta { - font-size: 0.56rem; + font-family: var(--font-mono); + font-size: var(--mly-text-small); letter-spacing: 0.06em; - color: var(--text-dim); -} - -/* Match the app's chrome-hiding modes. */ -body.ui-clean-view #mapillary-dock, -body.recording-mode #mapillary-dock { - opacity: 0 !important; - visibility: hidden !important; - pointer-events: none !important; -} - -body.cockpit-mode #mapillary-dock { - display: none !important; -} - -/* Not enough room beside the rail: fall back to the corner (still draggable). */ -@media (max-width: 1100px) { - #mapillary-dock { - right: 16px; - } -} - -@media (max-width: 720px) { - #mapillary-dock { - left: 16px; - right: 16px; - width: auto; - max-width: none; - bottom: 6.4rem; - max-height: calc(100vh - 8rem); - } -} - -/* Short viewports: trim the chrome so the imagery keeps its room. */ -@media (max-height: 640px) { - .mly-dock-inner { - padding: 8px 10px; - gap: 6px; - } - - #mapillary-dock.mly-has-viewer .mly-ai-hint, - #mapillary-dock.mly-has-viewer .mly-suggestions { - display: none; - } - - .mly-viewer-wrap { - min-height: 200px; - } -} - -/* ── Prominent AI entry ─────────────────────── */ -.mly-ai-head { - display: flex; - align-items: center; - gap: 8px; -} - -.mly-ai-head .mly-kicker { - font-size: 0.66rem; - color: var(--text-primary); -} - -.mly-ai-model { - margin-left: auto; - font-size: 0.52rem; - letter-spacing: 0.08em; - color: var(--text-dim); -} - -.mly-ai-pulse { - width: 8px; - height: 8px; - border-radius: 50%; - background: var(--accent); - box-shadow: 0 0 0 0 rgba(0, 212, 255, 0.6); - animation: mly-pulse 2.2s ease-out infinite; -} - -@keyframes mly-pulse { - 0% { - box-shadow: 0 0 0 0 rgba(0, 212, 255, 0.6); - } - 70% { - box-shadow: 0 0 0 9px rgba(0, 212, 255, 0); - } - 100% { - box-shadow: 0 0 0 0 rgba(0, 212, 255, 0); - } -} - -#mapillary-dock #mly-query-input { - padding: 11px 12px; - font-size: 0.8rem; - border: 1px solid rgba(0, 212, 255, 0.45); - background: rgba(0, 212, 255, 0.06); - box-shadow: 0 0 18px rgba(0, 212, 255, 0.12) inset; -} - -#mapillary-dock #mly-query-input::placeholder { - color: rgba(232, 234, 237, 0.55); -} - -#mapillary-dock #mly-query-input:focus { - background: rgba(0, 212, 255, 0.1); - box-shadow: - 0 0 0 2px var(--accent-dim), - 0 0 22px rgba(0, 212, 255, 0.25); -} - -.mly-ask { - min-width: 64px; - font: inherit; - font-size: 0.66rem; - letter-spacing: 0.12em; - border-radius: var(--btn-radius); - border: 1px solid var(--accent); - background: var(--accent); - color: #0a0a0f; - cursor: pointer; - font-weight: 600; - transition: all var(--transition-fast); -} - -.mly-ask:hover:not(:disabled) { - box-shadow: 0 0 16px rgba(0, 212, 255, 0.5); -} - -.mly-ask:disabled { - opacity: 0.55; - cursor: progress; -} - -.mly-ai-hint { - font-family: var(--font-sans); - font-size: 0.66rem; - line-height: 1.35; color: var(--text-secondary); } -.mly-ai-hint.is-warn { - color: #ffb300; +.mly-meta:empty { + display: none; } -/* ── Imagery filters ────────────────────────── */ -.mly-filters { - display: flex; - align-items: center; - gap: 8px; - flex-wrap: wrap; -} - -.mly-filter-label { - font-size: 0.54rem; - letter-spacing: 0.14em; - color: var(--text-dim); -} - -.mly-seg { - display: inline-flex; - border: 1px solid var(--glass-border-hover); - border-radius: 999px; - overflow: hidden; -} - -.mly-seg-btn { - font: inherit; - font-size: 0.56rem; - letter-spacing: 0.08em; - padding: 4px 9px; - border: 0; - background: transparent; - color: var(--text-secondary); - cursor: pointer; -} - -.mly-seg-btn + .mly-seg-btn { - border-left: 1px solid var(--glass-border); -} - -.mly-seg-btn.is-active { - background: rgba(5, 203, 99, 0.22); - color: var(--text-primary); -} - -.mly-select { - font: inherit; - font-size: 0.56rem; - letter-spacing: 0.06em; - padding: 4px 8px; - border-radius: 999px; - border: 1px solid var(--glass-border-hover); - background: rgba(0, 0, 0, 0.35); - color: var(--text-primary); -} - -.mly-select option { - background: #0a0a0f; -} - -/* ── Portable, resizable windows ────────────── */ -.gev-floating-handle { - cursor: grab; -} - -.gev-floating-dragging, -.gev-floating-dragging * { - cursor: grabbing !important; - user-select: none; -} - -.gev-floating { - position: fixed !important; - right: auto !important; - bottom: auto !important; - margin: 0 !important; - max-width: calc(100vw - 12px) !important; - z-index: 132 !important; - display: flex; - flex-direction: column; -} - -.gev-resize-grip { - position: absolute; - right: 5px; - bottom: 5px; - width: 16px; - height: 16px; - cursor: nwse-resize; - z-index: 6; - opacity: 0.6; - border-radius: 3px; - background: linear-gradient( - 135deg, - transparent 0 45%, - var(--accent) 45% 55%, - transparent 55% 70%, - var(--accent) 70% 80%, - transparent 80% - ); -} - -.gev-resize-grip:hover { - opacity: 1; -} - -/* Invisible strips along the other edges and corners. */ -.gev-resize-edge { - position: absolute; - z-index: 7; -} - -.gev-resize-edge[data-dir='n'], -.gev-resize-edge[data-dir='s'] { - left: 10px; - right: 10px; - height: 7px; - cursor: ns-resize; -} - -.gev-resize-edge[data-dir='e'], -.gev-resize-edge[data-dir='w'] { - top: 10px; - bottom: 10px; - width: 7px; - cursor: ew-resize; -} - -.gev-resize-edge[data-dir='n'] { - top: -3px; -} - -.gev-resize-edge[data-dir='s'] { - bottom: -3px; -} - -.gev-resize-edge[data-dir='e'] { - right: -3px; -} - -.gev-resize-edge[data-dir='w'] { - left: -3px; -} - -.gev-resize-edge[data-dir='ne'], -.gev-resize-edge[data-dir='nw'], -.gev-resize-edge[data-dir='sw'] { - width: 14px; - height: 14px; -} - -.gev-resize-edge[data-dir='ne'] { - top: -3px; - right: -3px; - cursor: nesw-resize; -} - -.gev-resize-edge[data-dir='nw'] { - top: -3px; - left: -3px; - cursor: nwse-resize; -} - -.gev-resize-edge[data-dir='sw'] { - bottom: -3px; - left: -3px; - cursor: nesw-resize; -} - -#mapillary-dock.gev-floating { - max-height: none !important; -} - -#mapillary-dock.gev-floating .mly-dock-inner { - flex: 1 1 auto; - min-height: 0; - max-height: none; - height: 100%; -} - -/* The CCTV panel lives in a rail until dragged; then it floats with the - width it had (set inline when lifted), never the viewport's. */ -#cctv-panel.gev-floating { - position: fixed !important; - flex: none !important; - max-height: calc(100vh - 12px) !important; -} - -#cctv-panel.gev-floating:not([style*='height']) { - height: auto !important; -} - -#cctv-panel.gev-floating .cctv-panel-inner { - flex: 1 1 auto; - min-height: 0; - height: 100%; - max-height: none; -} - -#cctv-panel.gev-floating.collapsed .cctv-panel-inner { - height: auto; -} - -/* ── Expanded street-level viewer ───────────── */ -.mly-viewer-tools { - position: absolute; - top: 6px; - left: 6px; - /* Above MapillaryJS's own overlays (z 10); the corners are free — its - sequence bar is top-centre, bearing/zoom/attribution bottom-right. */ - z-index: 12; - display: flex; - gap: 4px; -} - -.mly-viewer-tools .scene-btn { - padding: 4px 8px; - font-size: 0.56rem; - background: rgba(10, 10, 15, 0.75); - /* Hug the label: the playback bar MapillaryJS centres at the top needs - the middle of narrow viewers. */ - flex: none; - min-width: 0; - display: inline-flex; - align-items: center; - gap: 4px; -} - -/* Narrow viewer: icon-only expand button so nothing meets the playback bar. */ -@container (max-width: 380px) { - .mly-viewer-tools .mly-btn-text { - display: none; - } - - .mly-viewer-tools .scene-btn { - padding: 4px 7px; - } -} - -/* The dock's backdrop-filter makes it the containing block for fixed - descendants, so the expanded viewer is re-parented to . */ +/* ── Expanded viewer ─────────────────────────────────────────────────────── */ +/* The panel inner's backdrop-filter makes it the containing block for fixed + descendants, so the expanded viewer is re-parented to . z-index sits + above panels (≤139) and below the voice pill (150) and toasts (200). */ .mly-viewer-wrap-expanded { position: fixed; left: 4vw; @@ -923,6 +622,7 @@ body.cockpit-mode #mapillary-dock { font-family: var(--font-mono); border-color: rgba(0, 212, 255, 0.55); box-shadow: 0 24px 80px rgba(0, 0, 0, 0.7); + outline: none; } .mly-viewer-wrap-expanded .mly-viewer { @@ -932,18 +632,20 @@ body.cockpit-mode #mapillary-dock { } .mly-viewer-wrap-expanded .mly-image-meta { + font-size: 0.66rem; padding: 10px 14px; } -.mly-viewer-wrap-expanded .mly-image-meta { - font-size: 0.64rem; -} - .mly-viewer-wrap-expanded .mly-viewer-tools { top: 10px; left: 10px; } +.mly-viewer-wrap-expanded .mly-viewer-tools.mly-viewer-tools-right { + left: auto; + right: 10px; +} + .mly-viewer-wrap-expanded .mly-viewer-tools .scene-btn { padding: 8px 14px; font-size: 0.7rem; @@ -953,7 +655,30 @@ body.cockpit-mode #mapillary-dock { background: rgba(10, 10, 15, 0.82); } -.mly-viewer-wrap-expanded .mly-viewer-tools .scene-btn::after { +.mly-viewer-wrap-expanded #mly-viewer-expand::after { content: ' · ESC'; color: var(--text-secondary); } + +.mly-viewer-wrap-expanded .mly-viewer-x { + width: 40px; + height: 40px; + font-size: 1.5rem; +} + +/* ── Short viewports ─────────────────────────────────────────────────────── */ +@media (max-height: 640px) { + .mapillary-panel-inner { + padding: 10px; + gap: 6px; + } + + #mapillary-panel .mly-hint, + #mapillary-panel .mly-suggestions { + display: none; + } + + .mly-viewer { + min-height: 150px; + } +} diff --git a/src/ui/styles/responsive.css b/src/ui/styles/responsive.css index 7ebdaadae..910877f88 100644 --- a/src/ui/styles/responsive.css +++ b/src/ui/styles/responsive.css @@ -204,6 +204,8 @@ #right-context-rail #pp-toggles.collapsed .pp-header-row, #right-context-rail #cctv-panel, #right-context-rail #cctv-panel.collapsed, + #right-context-rail #mapillary-panel, + #right-context-rail #mapillary-panel.collapsed, #right-context-rail #radio-panel, #right-context-rail #radio-panel.collapsed, #right-context-rail #global-context-panel, diff --git a/src/ui/templates/layer-panels.html b/src/ui/templates/layer-panels.html index 4f41b2452..b701c3870 100644 --- a/src/ui/templates/layer-panels.html +++ b/src/ui/templates/layer-panels.html @@ -68,6 +68,107 @@ + + +