address review findings on voice outlines

- remove the unused Nominatim reserve(); spacing tests now measure the
  actual transport start across processes
- start each queued building's 2 s ground-sampling budget when it
  reaches the head of the queue, and request a frame on promotion
- accept Nominatim and grounds outlines that cross the antimeridian
- let an unnamed school, university, hospital, cemetery, stadium or zoo
  area answer a grounds ask whose head noun names that kind, using only
  the part that holds the point; compare grounds names symmetrically
- charge shared OpenFreeMap consumers their own slice and keep the
  charged budget at the previous 64 MiB
- share one place-name fold, point-in-polygon, line stitching and
  nextTask helper instead of duplicates
- exclude Who's On First sources licensed only as "CC BY (equivalent)"
  or "CC0 (equivalent)"; rebuild the pack from the pinned archives
- whole-word neighbourhood alias guard; correct the outline size-limit
  comment
- restore DATA_SOURCES.md table formatting, keeping only real edits
- test behaviour instead of private engine fields
This commit is contained in:
manjunath committed 2026-10-30 20:47:20 +05:30
1 parent 5a1cfa6800
commit 2e82425d62
37 files changed
+921 -481

No files matched your search

+106 -104
View File
@@ -13,91 +13,91 @@ How to read this:
## Live sources (fetched at runtime — not stored in this repo)
| Source | Used for | License / terms | Attribution |
| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **NOAA GFS (wind)** (`noaa-gfs-bdp-pds.s3.amazonaws.com`) | Global 10 m wind, optional 2 m temperature and mean sea-level pressure for Wind. Keyless; latest 6-hourly 0.25° cycle, byte-range GRIB2 reads, cached for an hour | U.S. public domain (NOAA); keyless via NOAA Open Data on AWS | "NOAA Global Forecast System (GFS)" (courtesy; not an endorsement) |
| **ECMWF IFS (wind)** (`data.ecmwf.int/forecasts`, ECMWF Open Data) | The alternative Wind model: 10 m wind, optional 2 m temperature and mean sea-level pressure. Keyless; latest 6-hourly 0.25° run, byte-range GRIB2 reads, cached for an hour | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) plus the [ECMWF Terms of Use](https://apps.ecmwf.int/datasets/licences/general/) | "This service is based on data and products of the European Centre for Medium-Range Weather Forecasts (ECMWF)", with the CC BY 4.0 link, the modification notice and ECMWF's liability disclaimer — shown in-app |
| **NOAA nowCOAST observed weather** (`nowcoast.noaa.gov/geoserver/observations/{weather_radar,satellite}/ows`, WMS 1.1.1) | Rain radar (MRMS base reflectivity, contiguous US, about 4-minute updates) and Satellite clouds (GOES-19/18 Band 14 regional infrared, about 5-minute updates; global infrared mosaic, hourly). Keyless; layer metadata refreshed every 2 minutes, exact-time images cached for up to 24 hours | [NOAA disclaimer](https://oceanservice.noaa.gov/disclaimer.html) | "NOAA nowCOAST · NWS/OAR MRMS radar; NESDIS GOES and global satellite partners" |
| **NOAA nowCOAST lightning density** (`nowcoast.noaa.gov/geoserver/observations/lightning_detection/ows`) | Lightning density: 15-minute accumulated strike density on an approximately 8 km grid, Americas and Pacific. Keyless; layer metadata refreshed every 10 minutes, exact-time images cached for up to 24 hours | NOAA Level-5 derived product from Vaisala NLDN/GLD360; the [product description](https://ocean.weather.gov/lightning/lightning_pdd.php) permits public distribution of this density product, not of raw Vaisala detections | "NOAA/NWS nowCOAST; derived from Vaisala NLDN/GLD360" |
| **NOAA NHC / CPHC advisories** (`www.nhc.noaa.gov/CurrentStorms.json` + the `mapservices.weather.noaa.gov` tropical weather summary MapServer) | Cyclone advisories: storm positions, forecast tracks, points and cones for the Atlantic and eastern/central North Pacific. Keyless through `/api/cyclones`; cached for 5 minutes | [NWS public-data terms](https://www.weather.gov/disclaimer); no endorsement implied | "NOAA/NWS NHC / CPHC" |
| **Mapillary** (vector tiles, Graph API, MapillaryJS) | Street Level: coverage lines, image cones and the embedded viewer. Coverage tiles kept in server memory for 24 h | Imagery [CC BY-SA 4.0](https://www.mapillary.com/terms); API under the Mapillary terms with a client token | "© Mapillary contributors, CC BY-SA 4.0", shown on the globe while the provider is on |
| **Google Street View** (Maps JavaScript API) | Street Level, on the Google 3D map only (Google's terms): Google's panorama viewer and nearest-panorama lookups; panorama dates and addresses kept for the session only | Google Maps Platform ToS (your own key + billing; every panorama load is billed) | "Street View imagery © Google" on the globe while the provider is on, and Google's logo and terms in the viewer |
| **KartaView** (`api.kartaview.org` coverage tiles and photo API, `cdn.kartaview.org` image proxy, `storage*.openstreetcam.org` photos) | Street Level: coverage lines (z13–14 JSON tiles; with the imagery filter on, KartaView's filtered PNG tiles as a mask), nearest-photo lookups, the photo's sequence and the photo itself, fetched by the browser. Keyless; nothing cached beyond the session | Imagery [CC BY-SA 4.0](https://kartaview.org/terms) (KartaView terms §4); the public API needs no token | "© KartaView contributors, CC BY-SA 4.0", shown on the globe while the provider is on |
| **Panoramax** (`api.panoramax.xyz` meta-catalogue: vector tiles and STAC search; pictures from the federated instance holding each one, e.g. `panoramax.openstreetmap.fr`, `panoramax.ign.fr`) | Street Level: coverage lines, nearest-picture lookups, a sequence's pictures as cones, and the picture itself, fetched by the browser. Keyless; tiles cached by the browser for an hour (the API's own header) | Per picture: mostly [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/), IGN's [Licence Ouverte 2.0](https://www.etalab.gouv.fr/licence-ouverte-open-licence/); the API needs no token | "© Panoramax contributors, licence per picture" on the globe while the provider is on; the caption names each picture's author and licence |
| **OpenStreetMap** | Map and place data (roads, military areas and names, ALPR, place search, outlines); via vector tiles, hourly ALPR extract, bundled data, Nominatim and optional self-configured Overpass | [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/) | © OpenStreetMap contributors |
| **Google Map Tiles API** (Photorealistic 3D Tiles) + Places/Geocoding | The 3D globe, voice scene context, and on-demand nearby installation search | Google Maps Platform ToS (proprietary, your own key + billing) | "Google" / "Google Maps" logo — **shown in-app**, required |
| **OpenSky Network** | Primary worldwide live-flight snapshot | Non-commercial research/education license | Schäfer et al., _"Bringing Up OpenSky"_, IPSN 2014 + opensky-network.org |
| **adsb.lol point API** | Bounded live-flight fallback when OpenSky has no usable snapshot; GNSS Integrity layer (aircraft-reported NIC/NACp and GPS-loss integrity facts, via `/api/gnss-integrity`, 60 s server cache) | ODbL 1.0 | adsb.lol contributors; `api.adsb.lol/v2/lat/{lat}/lon/{lon}/dist/{radius}` |
| **adsb.lol** | Military flights + aircraft traces | ODbL 1.0 | "adsb.lol" (ODbL) |
| **adsbdb** (`api.adsbdb.com`) | Aircraft type, model name and registration by ICAO hex for enriched flights; airline and origin/destination airports for the tracked flight's callsign | No licence or rate limit is published for the service. Its own credits: aircraft data from PlaneBase, ICAO-to-N-number conversion by Guillaume Michel, and flight route data that "is the work of David Taylor, Edinburgh and Jim Mason, Glasgow, and may not be copied, published, or incorporated into other databases without the explicit permission of David J Taylor, Edinburgh" | "adsbdb — api.adsbdb.com" (courtesy); route data credited to David Taylor, Edinburgh, and Jim Mason, Glasgow |
| **AISStream.io** | Live vessels (AIS) | Free, beta, no formal ToS; AIS is a public broadcast | "AISStream.io" (courtesy) |
| **CelesTrak** | Satellite TLEs (SGP4) | US-government-origin data, no license; citation requested | "CelesTrak (celestrak.org), Dr. T.S. Kelso" |
| **The Space Devs — Launch Library 2 v2.3** | Recent launch, payload, stage, and recovery metadata for Space Missions (30d) | [The Space Devs terms of use](https://github.com/TheSpaceDevs/Tutorials/blob/main/faqs/faq_TSD.md#terms-of-use): data may be used and shared in any form; avoid forwarding it without added value; attribution is encouraged (not mandatory). [Official API limits](https://ll.thespacedevs.com/docs/): 15 unauthenticated calls/hour; optional token | "Launch Library 2 — The Space Devs" (courtesy attribution) |
| **Esri World Imagery** (ArcGIS Online tile service) | The keyless satellite basemap — the default landing when no Google/ion credential is configured, and the "Esri Satellite" map stack | [Esri Master Agreement](https://www.esri.com/en-us/legal/terms/full-master-agreement): the public World Imagery service is usable in public-facing apps with attribution; no key is required for this classic endpoint, but Esri governs and can change access — an app at scale should review current ArcGIS Location Platform terms | "Powered by Esri — Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community" (provider carries the service's own credit line) |
| **USGS** | Earthquakes | U.S. public domain | "Data courtesy of the U.S. Geological Survey" |
| **Environment and Climate Change Canada** (MSC GeoMet OGC API) | Air quality (AQHI) — Canadian station observations | [ECCC Data Servers End-use Licence](https://eccc-msc.github.io/open-data/licence/readme_en/) (open, attribution required, no endorsement implied) | "Contains information licensed under the Environment and Climate Change Canada Data Servers End-use Licence" |
| **NASA GIBS** (WMTS tiles + Worldview Snapshots) and **NASA CMR** (granule catalog) | Recent Imagery layer: HLS S30/L30 (Sentinel-2, Landsat 8/9) and VIIRS true-colour tiles draped on the map, per-day thumbnails and PNG exports; the HLS granule catalog (dates, cloud, footprints) for a selected box | U.S. public domain (NASA data); keyless, browser-direct (CORS `*`); no bulk use — one 30-day catalog query per box, tiles bounded to the box, six tile requests in flight | "We acknowledge the use of imagery provided by services from NASA's Global Imagery Browse Services (GIBS), part of NASA's Earth Science Data and Information System (ESDIS)." — [gibs.earthdata.nasa.gov](https://gibs.earthdata.nasa.gov), [HLS](https://lpdaac.usgs.gov/products/hlss30v002/) |
| **NIFC WFIGS** (Wildland Fire Interagency Geospatial Services, ArcGIS feature service) | Fire Perimeters layer: current interagency wildfire incident perimeters, containment, and incident facts | U.S. public domain (interagency wildland-fire data published through the [NIFC Open Data portal](https://data-nifc.opendata.arcgis.com/)); fetched keyless through `/api/fire-perimeters` (5-minute server cache) with `exceededTransferLimit` paging | "Wildfire perimeters: National Interagency Fire Center (WFIGS)" |
| **S2 Underground GhostMaps** (`github.com/s2underground/GhostMaps`) | S2 GhostMaps layer: the current edition of S2's KMZ intel products (Common Intelligence Picture, 30-day kinetic pull, Border Crisis Map, Iran and Yemen products, newest incident drop). Keyless. `/api/ghostmaps` reads the branch head from git's ref advertisement every 15 s (`GHOSTMAPS_POLL_SECONDS`, minimum 5 s), lists the tree through the GitHub REST API only when the head changes (optional `GITHUB_TOKEN`), downloads files by commit-pinned raw URLs into `.gev-cache/ghostmaps/` and pushes new catalogs to browsers over server-sent events | [CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/) (repository README: "S2 Underground © 2025 by S2A1"); non-commercial, attribution required; files are fetched at runtime, never stored in this repository | "S2 GhostMaps intel products: S2 Underground © by S2A1, CC BY-NC-SA 4.0" — shown in-app |
| **InciWeb** (inciweb.wildfire.gov) | Per-incident "InciWeb ↗" card links to official incident pages (narratives, evacuation/closure notices) | U.S. government public incident information. Links are matched best-effort by incident name and state, with incident page origin and update times checked on selection. Same-origin `/api/fire-perimeters/inciweb` routes cache the catalog for 1 hour and publication metadata for 30 minutes; linked pages remain InciWeb content | "Incident information: InciWeb (inciweb.wildfire.gov)" |
| **OpenFreeMap / OpenMapTiles** | Vector tiles | OpenStreetMap data under ODbL 1.0 | OpenFreeMap © OpenMapTiles |
| **Overture Maps Foundation** | Military area names pack | [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/) | Overture Maps Foundation |
| **TomTom Traffic API** (flow vector tiles) | Traffic roads and live congestion (optional, BYOK) | [TomTom for Developers terms](https://docs.tomtom.com/legal/terms-and-conditions) (proprietary, your own key; free tier currently 200K tile requests/month — see [current pricing](https://docs.tomtom.com/pricing/)) | "Traffic flow data © TomTom" — registered when live mode activates |
| **Photon** (komoot) | Keyless place search — the fallback when no Google Maps key is configured, or when Google declines the Geocoding request | Service: free public instance, [fair use](https://photon.komoot.io) (no bulk/heavy use); underlying data: **ODbL 1.0** | "Photon (komoot)" |
| **Open-Meteo** | Current weather in the cockpit Local Info page and cockpit-local dynamic atmospheric effects | [CC BY 4.0 data licence and adjacent-link attribution requirement](https://open-meteo.com/en/licence) | Linked "Weather data by Open-Meteo.com" beside the displayed local data |
| **Google News RSS** | Primary locality-matched headlines in the cockpit Regional News page | [Google News Terms of Service](https://www.google.com/intl/en_us/terms_google_news.html) restrict use to personal, noncommercial use; linked articles remain third-party publisher content and retain publisher terms | "Google News RSS" plus each article's linked publisher/domain |
| **GDELT Project DOC 2.0** | Fail-soft fallback for location-matched cockpit headlines | [GDELT Terms of Use](https://www.gdeltproject.org/about.html#termsofuse): unrestricted academic/commercial/governmental dataset use, with citation and link required; linked articles retain publisher terms | "GDELT Project" plus each article's linked publisher/domain |
| **City of Austin Open Data** | CCTV camera catalog + frames | City of Austin Open Data Terms of Use | "City of Austin, TX — data.austintexas.gov" |
| **TxDOT ITS (its.txdot.gov)** | CCTV camera catalogs + frames, Texas districts | Public TxDOT traffic camera data | "Texas Department of Transportation" (courtesy) |
| **FL511 (Florida DOT)** | CCTV camera catalog + frames, Florida statewide | FL511 content, individual personal use only; any other use needs FDOT's express written consent ([terms](https://fl511.com/privacy#disclaimer)). **Off by default**; set `CCTV_FLORIDA_ENABLED=1` to enable | "Florida DOT — FL511.com" (courtesy) |
| **City of Tallinn (ristmikud.tallinn.ee)** | CCTV camera catalog + frames, Tallinn intersections | Public City of Tallinn traffic camera data | "City of Tallinn — ristmikud.tallinn.ee" (courtesy) |
| **Transpordiamet / Tarktee** | CCTV camera catalog + frames, Estonia road-weather cameras | Public Transpordiamet / Tarktee road camera data | "Transpordiamet / Tarktee — tarktee.transpordiamet.ee" (courtesy) |
| **Stadt Warendorf webcam** | Webcam frames, Marktplatz / Historisches Rathaus (`config/cctv_sources.warendorf.json`) | Public municipal webcam data | "Stadt Warendorf — webcam.warendorf.de" (courtesy) |
| **Live Traffic NSW (Transport for NSW)** | CCTV camera catalog + frames, New South Wales | [CC BY 4.0](https://opendata.transport.nsw.gov.au/) — attribution REQUIRED | "Live Traffic NSW — Transport for NSW" |
| **Caltrans (cwwp2.dot.ca.gov)** | CCTV camera catalogs + frames, California districts | Public Caltrans traffic camera data | "Caltrans — cwwp2.dot.ca.gov" (courtesy) |
| **DelDOT (video.deldot.gov)** | CCTV camera catalog (`tmc.deldot.gov/json/videocamera.json`, cached for 15 minutes) + live HTTPS HLS video, Delaware. Keyless | Public DelDOT traffic camera streams via the [DelDOT traffic map](https://deldot.gov/map/) | "DelDOT — Delaware Department of Transportation" (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" |
| **Ontario 511** | CCTV camera catalog + frames, Ontario highways including Kitchener-area routes | [Open Government Licence – Ontario](https://www.ontario.ca/page/open-government-licence-ontario) | "Ontario 511" plus Open Government Licence - Ontario |
| **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 |
| **King County Road Services** (`services.arcgis.com`, King County GIS) | CCTV camera catalog + frames, unincorporated King County WA road network | [KCGIS Center terms](https://kingcounty.gov/en/dept/kcit/data-information-services/gis-center/about/terms-conditions-copyrights) — attribution required; public ArcGIS layer | "King County Road Services" (partner-city cameras name their owner) |
| **WSDOT** (`data.wsdot.wa.gov`) | CCTV camera catalog + frames, Washington State highways | [WSDOT travel information disclaimer](https://wsdot.wa.gov/about/policies/travel-information-disclaimer) — public "low volume" feed; catalog cached, frames on WSDOT's own image host only | "Washington State Department of Transportation (Travel Center)" (courtesy) |
| **Hong Kong Transport Department / DATA.GOV.HK** | CCTV camera catalog + frames, Hong Kong traffic snapshot cameras | [DATA.GOV.HK Terms of Use](https://data.gov.hk/en/terms-and-conditions) — attribution REQUIRED | "Hong Kong Transport Department / DATA.GOV.HK" |
| **Statens vegvesen** (catalog `ogckart-sn1.atlas.vegvesen.no`, frames `kamera.atlas.vegvesen.no`, HLS `kamera.vegvesen.no`) | CCTV camera catalog + frames + live HLS video, Norway road cameras | [NLOD 2.0](https://data.norge.no/nlod/en/2.0) — attribution REQUIRED | "Contains data under the Norwegian licence for Open Government data (NLOD) distributed by Statens vegvesen" |
| **Kartverket** (`ws.geonorge.no/hoydedata/v1/punkt`) | Terrain heights (NN2000) used as the ground prior for Statens vegvesen cameras | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) — attribution REQUIRED | "© Kartverket", modified (heights over water set to 0 m, rounded to 0.1 m) |
| **NYC DOT Traffic Management Center** (`webcams.nyctmc.org`) | CCTV camera catalog + frames, New York City five boroughs | NYC DOT requires developers to sign a [data-sharing agreement](https://www.nyc.gov/html/dot/html/about/datafeeds.shtml) for the camera feed. **Off by default**; enable only under your own agreement | "NYC DOT Traffic Management Center" + NYC DOT's required application disclaimer (in the Data attribution popover) |
| **City of Toronto Open Data** (`opendata.toronto.ca`) | CCTV camera catalog + frames, Toronto city streets | [Open Government Licence – Toronto](https://www.toronto.ca/city-government/data-research-maps/open-data/open-data-licence/) — attribution REQUIRED | "Contains information licensed under the Open Government Licence – Toronto" |
| **GBFS (Lyft / BCycle / Social Bicycles / PBSC)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle) + its `license_url` |
| **Bike Share Toronto** (`toronto.publicbikesystem.net`) | Bikeshare station availability, Toronto | Operator GBFS feed. No licence is published: the City's open-data package declares none and the feed has no `license_url`. Attribution is given to the operator. | "Bike Share Toronto (Toronto Parking Authority)" |
| **Bike Itaú / Tembici** (`*.publicbikesystem.net`, seven Brazilian cities, Santiago and Bogotá) | Bikeshare station availability: São Paulo, Rio de Janeiro, Salvador, Recife, Porto Alegre, Brasília, Curitiba, Santiago de Chile and Bogotá | Operator GBFS feeds. No licence is published: the feeds' `system_information.json` carries no `license_url`, operator or attribution fields. Attribution is given to the operator. | "Bike Itaú (Tembici)"; "Tembici Bogotá" |
| **Bicing** (`barcelona.publicbikesystem.net`) | Bikeshare station availability, Barcelona | Operator GBFS feed (PBSC), keyless. The feed publishes no licence; the City's own open-data copy of the same station data is CC BY 4.0 but needs a token. Attribution is given to the operator. | "Bicing, Barcelona City Council (B:SM)" |
| **BiciMAD** (`madrid.publicbikesystem.net`) | Bikeshare station availability, Madrid | Operator GBFS feed (PBSC), keyless. Madrid's open-data catalogue publishes this feed (`datos.madrid.es`, dataset `900021-0-bicimad-gbfs`, owner EMT Madrid) under [EMT's open-data terms](https://mobilitylabs.emtmadrid.es/sip/terms-of-use): commercial and non-commercial reuse, with attribution REQUIRED (section 5.2). | "Powered by EMT de Madrid", linking to `https://www.emtmadrid.es` |
| **Spanish city bikes on PBSC** (`*.publicbikesystem.net`: Zaragoza, Valladolid, A Coruña, Donostia-San Sebastián, Bilbao, Rivas-Vaciamadrid) | Bikeshare station availability: Bizi Zaragoza, BIKI, BiciCoruña, Dbizi, Bilbao Bizi, BicinRivas | Operator GBFS feeds, keyless. The feeds' `system_information.json` carries no `license_url`, and no operator or city publishes a licence for them; MobilityData's catalogue lists CC BY 4.0 without naming a source. Attribution is given to the operator. | "Bizi Zaragoza"; "BIKI, AUVASA (Valladolid)"; "BiciCoruña"; "Dbizi (Donostia-San Sebastián)"; "Bilbao Bizi, Bilbao City Council"; "BicinRivas, Rivas-Vaciamadrid City Council" |
| **AMBici** (nextbike, `gbfs.nextbike.net`) | Bikeshare station availability, Barcelona metropolitan area (e-bikes) | Operator GBFS feed, keyless. `system_information.license_id` is **CC0-1.0**; no attribution required, given anyway. | "AMBici, Àrea Metropolitana de Barcelona, operated by nextbike" |
| **Oslo / Bergen / Trondheim Bysykkel** (Urban Sharing, `gbfs.urbansharing.com`) | Bikeshare availability, Oslo, Bergen and Trondheim city bikes. Keyless GBFS; the proxy sends the `Client-Identifier` header the operators ask for | [NLOD 2.0](https://data.norge.no/nlod/en/2.0) — attribution REQUIRED ([Oslo](https://oslobysykkel.no/en/open-data/realtime), [Bergen](https://bergenbysykkel.no/en/apne-data/sanntid), [Trondheim](https://trondheimbysykkel.no/en/open-data/realtime)) | "Contains data under the Norwegian licence for Open Government data (NLOD) made available by Oslo Bysykkel, Bergen Bysykkel and Trondheim Bysykkel" |
| **Kolumbus / Farte Bysykkel** (Entur Mobility, `api.entur.io/mobility/v2/gbfs`) | Bikeshare availability, Kolumbus Bysykkel (Stavanger/Nord-Jæren) and Farte Bysykkel (Skien/Porsgrunn). Keyless GBFS; the proxy sends the `ET-Client-Name` header Entur requires and is limited to Entur's GBFS paths | [NLOD](https://data.norge.no/nlod/en/2.0) per the [Entur Mobility API docs](https://developer.entur.org/pages-mobility-docs-mobility-v2) — attribution REQUIRED | "Contains data under the Norwegian licence for Open Government data (NLOD) made available by Entur (Kolumbus Bysykkel, Farte Bysykkel)" |
| **JCDecaux Cyclocity** (`api.cyclocity.fr`) | Bikeshare availability: Bysykkel Lillestrøm, Valenbisi (Valencia) and Sevici (Seville). Keyless GBFS; the proxy is limited to JCDecaux contract paths | [Licence Ouverte / Etalab](https://developer.jcdecaux.com/files/Open-Licence-fr.pdf) (each feed's `license_url`) — attribution REQUIRED | "Bysykkel Lillestrøm, Valenbisi, Sevici: JCDecaux, Licence Ouverte" |
| **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) |
| **Metro Transit** (Metropolitan Council) | Transit layer live vehicles, Minneapolis–St Paul | Metro Transit / Metropolitan Council open realtime vehicle data, used for the live view; published for developer use and not against their use. | Attribution to Metro Transit / Metropolitan Council (courtesy text) |
| **OVapi / Stichting OpenGeo** | Transit layer live vehicles, Netherlands | OVapi / Stichting OpenGeo open realtime vehicle data, used for the live view; published for developer use and not against their use. The client sends a User-Agent and conditional requests. | Attribution to OVapi / Stichting OpenGeo (courtesy text) |
| **Entur** (api.entur.io) | Transit layer live vehicles, Norway | Entur open realtime vehicle data, used for the live view; published for developer use and not against their use. The client sends the `ET-Client-Name` identifying header. | Attribution to Entur (courtesy text) |
| **TransLink** (Queensland Government) | Transit layer live vehicles, South East Queensland | TransLink / Queensland Government open realtime vehicle data, used for the live view; published for developer use and not against their use. | Attribution to TransLink / Queensland Government (courtesy text) |
| **HSL** (realtime.hsl.fi) | Transit layer live vehicles, Helsinki | HSL (Helsinki Region Transport) open realtime vehicle data, used for the live view; published for developer use and not against their use. | Attribution to HSL (Helsinki Region Transport) (courtesy text) |
| **TTC** (bustime.ttc.ca) | Transit layer live vehicles, Toronto — surface vehicles (buses and streetcars) only | Toronto Transit Commission BusTime open realtime vehicle data, published through City of Toronto Open Data and used for the live view. The subway publishes no vehicle positions, so Lines 1/2/4 are absent rather than inferred. | Attribution to Toronto Transit Commission — City of Toronto Open Data, under the Open Government Licence – Toronto |
| **MiWay** (miapp.ca) | Transit layer live vehicles, Mississauga ON | MiWay open realtime vehicle data, published by the City of Mississauga under its Open Data Terms of Use, used for the live view. The licence is revocable and asks that the Terms of Use link travel with the data. | Attribution to MiWay — City of Mississauga Open Data, linked to the Terms of Use |
| **Hamilton HSR** (opendata.hamilton.ca) | Transit layer live vehicles, Hamilton ON | Hamilton Street Railway open realtime vehicle data, published by the City of Hamilton for commercial and non-commercial use. Not one of its 80 route ids is the number on the bus, so the route-number table in `src/data/transitFeeds.js` is derived from the City’s own published GTFS `routes.txt` under the same licence; two generations of id run at once and both are listed. | "Contains public sector Data made available under the City of Hamilton’s Open Data Licence" — wording prescribed by the licence |
| **Durham Region Transit** (drtonline.durhamregiontransit.com) | Transit layer live vehicles, Durham Region ON | Durham Region Transit open realtime vehicle data, published under the Region of Durham Open Data Licence v.1.0, which permits commercial use. | "Contains public sector information made available under The Regional Municipality of Durham's Open Data Licence" |
| **Guelph Transit** (glphtrpcloud.com) | Transit layer live vehicles, Guelph ON | Guelph Transit open realtime vehicle data, published by the City of Guelph on its open-data developer page and used for the live view. Its route ids are internal database keys, so the 33-row route-number table in `src/data/transitFeeds.js` is derived from the City’s own published GTFS `routes.txt` under the same licence. | "Contains information licensed under the Open Government Licence – City of Guelph" — wording prescribed by the licence |
| **Kingston Transit** (api.cityofkingston.ca) | Transit layer live vehicles, Kingston ON | Kingston Transit open realtime vehicle data, catalogued by the City of Kingston as open data and used for the live view. | "Contains information licensed under the Open Data Licence – City of Kingston" — wording prescribed by the licence |
| **Barrie Transit** (myridebarrie.ca) | Transit layer live vehicles, Barrie ON | Barrie Transit realtime data, published by the City of Barrie under a licence accepted by use of the data. The licence is revocable and requires no attribution. | Courtesy credit to the City of Barrie (the licence requires none, and its trademark clause is broad) |
| **YRT/Viva** (rtu.york.ca) | Transit layer live vehicles, York Region ON | York Region Transit realtime data, first-party York Region infrastructure under the YRT Open Data Licence v2.0, which permits commercial use and makes attribution optional. Viva route ids are named by colour from York Region’s published GTFS. | "Contains public transit Information made available under YRT’s Open Data Licence" — the licence’s own wording, given though not required |
| **Burlington Transit** (opendata.burlington.ca) | Transit layer live vehicles, Burlington ON | Burlington Transit realtime data under the City of Burlington Open Data Terms of Use, which permit any lawful purpose. The terms are revocable and must travel with the data, so the credit links them. Route ids are internal keys and are joined to the City’s published GTFS. | Credit to the City of Burlington plus a link to its Open Data Terms of Use, which the licence requires |
| **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)" |
| **OSRM on the FOSSGIS routing servers** (`routing.openstreetmap.de`) | Street-following routes for the Directions layer and voice route annotations, via `/api/route` | [FOSSGIS routing usage policy](https://routing.openstreetmap.de/about.html): "Display the required attribution and display a link to 'fix the map'", "Use a valid user agent and, if applicable, a correct referrer", "One request per second max", "No scraping, no heavy usage". The full policy is the German [FOSSGIS Nutzungsbedingungen](https://www.fossgis.de/arbeitsgruppen/osm-server/nutzungsbedingungen/); FOSSGIS also states that the server may be embedded in your own pages but "eine gewerbliche Nutzung ist nur mit Einschränkungen erlaubt" (commercial use only with restrictions). Route data derives from OpenStreetMap (ODbL 1.0) | "Routing: OSRM on the FOSSGIS servers" + a "fix the map" link — shown in the Data attribution popover |
| Source | Used for | License / terms | Attribution |
| --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| **NOAA GFS (wind)** (`noaa-gfs-bdp-pds.s3.amazonaws.com`) | Global 10 m wind, optional 2 m temperature and mean sea-level pressure for Wind. Keyless; latest 6-hourly 0.25° cycle, byte-range GRIB2 reads, cached for an hour | U.S. public domain (NOAA); keyless via NOAA Open Data on AWS | "NOAA Global Forecast System (GFS)" (courtesy; not an endorsement) |
| **ECMWF IFS (wind)** (`data.ecmwf.int/forecasts`, ECMWF Open Data) | The alternative Wind model: 10 m wind, optional 2 m temperature and mean sea-level pressure. Keyless; latest 6-hourly 0.25° run, byte-range GRIB2 reads, cached for an hour | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) plus the [ECMWF Terms of Use](https://apps.ecmwf.int/datasets/licences/general/) | "This service is based on data and products of the European Centre for Medium-Range Weather Forecasts (ECMWF)", with the CC BY 4.0 link, the modification notice and ECMWF's liability disclaimer — shown in-app |
| **NOAA nowCOAST observed weather** (`nowcoast.noaa.gov/geoserver/observations/{weather_radar,satellite}/ows`, WMS 1.1.1) | Rain radar (MRMS base reflectivity, contiguous US, about 4-minute updates) and Satellite clouds (GOES-19/18 Band 14 regional infrared, about 5-minute updates; global infrared mosaic, hourly). Keyless; layer metadata refreshed every 2 minutes, exact-time images cached for up to 24 hours | [NOAA disclaimer](https://oceanservice.noaa.gov/disclaimer.html) | "NOAA nowCOAST · NWS/OAR MRMS radar; NESDIS GOES and global satellite partners" |
| **NOAA nowCOAST lightning density** (`nowcoast.noaa.gov/geoserver/observations/lightning_detection/ows`) | Lightning density: 15-minute accumulated strike density on an approximately 8 km grid, Americas and Pacific. Keyless; layer metadata refreshed every 10 minutes, exact-time images cached for up to 24 hours | NOAA Level-5 derived product from Vaisala NLDN/GLD360; the [product description](https://ocean.weather.gov/lightning/lightning_pdd.php) permits public distribution of this density product, not of raw Vaisala detections | "NOAA/NWS nowCOAST; derived from Vaisala NLDN/GLD360" |
| **NOAA NHC / CPHC advisories** (`www.nhc.noaa.gov/CurrentStorms.json` + the `mapservices.weather.noaa.gov` tropical weather summary MapServer) | Cyclone advisories: storm positions, forecast tracks, points and cones for the Atlantic and eastern/central North Pacific. Keyless through `/api/cyclones`; cached for 5 minutes | [NWS public-data terms](https://www.weather.gov/disclaimer); no endorsement implied | "NOAA/NWS NHC / CPHC" |
| **Mapillary** (vector tiles, Graph API, MapillaryJS) | Street Level: coverage lines, image cones and the embedded viewer. Coverage tiles kept in server memory for 24 h | Imagery [CC BY-SA 4.0](https://www.mapillary.com/terms); API under the Mapillary terms with a client token | "© Mapillary contributors, CC BY-SA 4.0", shown on the globe while the provider is on |
| **Google Street View** (Maps JavaScript API) | Street Level, on the Google 3D map only (Google's terms): Google's panorama viewer and nearest-panorama lookups; panorama dates and addresses kept for the session only | Google Maps Platform ToS (your own key + billing; every panorama load is billed) | "Street View imagery © Google" on the globe while the provider is on, and Google's logo and terms in the viewer |
| **KartaView** (`api.kartaview.org` coverage tiles and photo API, `cdn.kartaview.org` image proxy, `storage*.openstreetcam.org` photos) | Street Level: coverage lines (z13–14 JSON tiles; with the imagery filter on, KartaView's filtered PNG tiles as a mask), nearest-photo lookups, the photo's sequence and the photo itself, fetched by the browser. Keyless; nothing cached beyond the session | Imagery [CC BY-SA 4.0](https://kartaview.org/terms) (KartaView terms §4); the public API needs no token | "© KartaView contributors, CC BY-SA 4.0", shown on the globe while the provider is on |
| **Panoramax** (`api.panoramax.xyz` meta-catalogue: vector tiles and STAC search; pictures from the federated instance holding each one, e.g. `panoramax.openstreetmap.fr`, `panoramax.ign.fr`) | Street Level: coverage lines, nearest-picture lookups, a sequence's pictures as cones, and the picture itself, fetched by the browser. Keyless; tiles cached by the browser for an hour (the API's own header) | Per picture: mostly [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/), IGN's [Licence Ouverte 2.0](https://www.etalab.gouv.fr/licence-ouverte-open-licence/); the API needs no token | "© Panoramax contributors, licence per picture" on the globe while the provider is on; the caption names each picture's author and licence |
| **OpenStreetMap** | Map and place data (roads, military areas and names, ALPR, place search, outlines); via vector tiles, hourly ALPR extract, bundled data, Nominatim and optional self-configured Overpass | [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/) | © OpenStreetMap contributors |
| **Google Map Tiles API** (Photorealistic 3D Tiles) + Places/Geocoding | The 3D globe, voice scene context, and on-demand nearby installation search | Google Maps Platform ToS (proprietary, your own key + billing) | "Google" / "Google Maps" logo — **shown in-app**, required |
| **OpenSky Network** | Primary worldwide live-flight snapshot | Non-commercial research/education license | Schäfer et al., _"Bringing Up OpenSky"_, IPSN 2014 + opensky-network.org |
| **adsb.lol point API** | Bounded live-flight fallback when OpenSky has no usable snapshot; GNSS Integrity layer (aircraft-reported NIC/NACp and GPS-loss integrity facts, via `/api/gnss-integrity`, 60 s server cache) | ODbL 1.0 | adsb.lol contributors; `api.adsb.lol/v2/lat/{lat}/lon/{lon}/dist/{radius}` |
| **adsb.lol** | Military flights + aircraft traces | ODbL 1.0 | "adsb.lol" (ODbL) |
| **adsbdb** (`api.adsbdb.com`) | Aircraft type, model name and registration by ICAO hex for enriched flights; airline and origin/destination airports for the tracked flight's callsign | No licence or rate limit is published for the service. Its own credits: aircraft data from PlaneBase, ICAO-to-N-number conversion by Guillaume Michel, and flight route data that "is the work of David Taylor, Edinburgh and Jim Mason, Glasgow, and may not be copied, published, or incorporated into other databases without the explicit permission of David J Taylor, Edinburgh" | "adsbdb — api.adsbdb.com" (courtesy); route data credited to David Taylor, Edinburgh, and Jim Mason, Glasgow |
| **AISStream.io** | Live vessels (AIS) | Free, beta, no formal ToS; AIS is a public broadcast | "AISStream.io" (courtesy) |
| **CelesTrak** | Satellite TLEs (SGP4) | US-government-origin data, no license; citation requested | "CelesTrak (celestrak.org), Dr. T.S. Kelso" |
| **The Space Devs — Launch Library 2 v2.3** | Recent launch, payload, stage, and recovery metadata for Space Missions (30d) | [The Space Devs terms of use](https://github.com/TheSpaceDevs/Tutorials/blob/main/faqs/faq_TSD.md#terms-of-use): data may be used and shared in any form; avoid forwarding it without added value; attribution is encouraged (not mandatory). [Official API limits](https://ll.thespacedevs.com/docs/): 15 unauthenticated calls/hour; optional token | "Launch Library 2 — The Space Devs" (courtesy attribution) |
| **Esri World Imagery** (ArcGIS Online tile service) | The keyless satellite basemap — the default landing when no Google/ion credential is configured, and the "Esri Satellite" map stack | [Esri Master Agreement](https://www.esri.com/en-us/legal/terms/full-master-agreement): the public World Imagery service is usable in public-facing apps with attribution; no key is required for this classic endpoint, but Esri governs and can change access — an app at scale should review current ArcGIS Location Platform terms | "Powered by Esri — Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community" (provider carries the service's own credit line) |
| **USGS** | Earthquakes | U.S. public domain | "Data courtesy of the U.S. Geological Survey" |
| **Environment and Climate Change Canada** (MSC GeoMet OGC API) | Air quality (AQHI) — Canadian station observations | [ECCC Data Servers End-use Licence](https://eccc-msc.github.io/open-data/licence/readme_en/) (open, attribution required, no endorsement implied) | "Contains information licensed under the Environment and Climate Change Canada Data Servers End-use Licence" |
| **NASA GIBS** (WMTS tiles + Worldview Snapshots) and **NASA CMR** (granule catalog) | Recent Imagery layer: HLS S30/L30 (Sentinel-2, Landsat 8/9) and VIIRS true-colour tiles draped on the map, per-day thumbnails and PNG exports; the HLS granule catalog (dates, cloud, footprints) for a selected box | U.S. public domain (NASA data); keyless, browser-direct (CORS `*`); no bulk use — one 30-day catalog query per box, tiles bounded to the box, six tile requests in flight | "We acknowledge the use of imagery provided by services from NASA's Global Imagery Browse Services (GIBS), part of NASA's Earth Science Data and Information System (ESDIS)." — [gibs.earthdata.nasa.gov](https://gibs.earthdata.nasa.gov), [HLS](https://lpdaac.usgs.gov/products/hlss30v002/) |
| **NIFC WFIGS** (Wildland Fire Interagency Geospatial Services, ArcGIS feature service) | Fire Perimeters layer: current interagency wildfire incident perimeters, containment, and incident facts | U.S. public domain (interagency wildland-fire data published through the [NIFC Open Data portal](https://data-nifc.opendata.arcgis.com/)); fetched keyless through `/api/fire-perimeters` (5-minute server cache) with `exceededTransferLimit` paging | "Wildfire perimeters: National Interagency Fire Center (WFIGS)" |
| **S2 Underground GhostMaps** (`github.com/s2underground/GhostMaps`) | S2 GhostMaps layer: the current edition of S2's KMZ intel products (Common Intelligence Picture, 30-day kinetic pull, Border Crisis Map, Iran and Yemen products, newest incident drop). Keyless. `/api/ghostmaps` reads the branch head from git's ref advertisement every 15 s (`GHOSTMAPS_POLL_SECONDS`, minimum 5 s), lists the tree through the GitHub REST API only when the head changes (optional `GITHUB_TOKEN`), downloads files by commit-pinned raw URLs into `.gev-cache/ghostmaps/` and pushes new catalogs to browsers over server-sent events | [CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/) (repository README: "S2 Underground © 2025 by S2A1"); non-commercial, attribution required; files are fetched at runtime, never stored in this repository | "S2 GhostMaps intel products: S2 Underground © by S2A1, CC BY-NC-SA 4.0" — shown in-app |
| **InciWeb** (inciweb.wildfire.gov) | Per-incident "InciWeb ↗" card links to official incident pages (narratives, evacuation/closure notices) | U.S. government public incident information. Links are matched best-effort by incident name and state, with incident page origin and update times checked on selection. Same-origin `/api/fire-perimeters/inciweb` routes cache the catalog for 1 hour and publication metadata for 30 minutes; linked pages remain InciWeb content | "Incident information: InciWeb (inciweb.wildfire.gov)" |
| **OpenFreeMap / OpenMapTiles** | Vector tiles | OpenStreetMap data under ODbL 1.0 | OpenFreeMap © OpenMapTiles |
| **Overture Maps Foundation** | Military area names pack | [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/) | Overture Maps Foundation |
| **TomTom Traffic API** (flow vector tiles) | Traffic roads and live congestion (optional, BYOK) | [TomTom for Developers terms](https://docs.tomtom.com/legal/terms-and-conditions) (proprietary, your own key; free tier currently 200K tile requests/month — see [current pricing](https://docs.tomtom.com/pricing/)) | "Traffic flow data © TomTom" — registered when live mode activates |
| **Photon** (komoot) | Keyless place search — the fallback when no Google Maps key is configured, or when Google declines the Geocoding request | Service: free public instance, [fair use](https://photon.komoot.io) (no bulk/heavy use); underlying data: **ODbL 1.0** | "Photon (komoot)" |
| **Open-Meteo** | Current weather in the cockpit Local Info page and cockpit-local dynamic atmospheric effects | [CC BY 4.0 data licence and adjacent-link attribution requirement](https://open-meteo.com/en/licence) | Linked "Weather data by Open-Meteo.com" beside the displayed local data |
| **Google News RSS** | Primary locality-matched headlines in the cockpit Regional News page | [Google News Terms of Service](https://www.google.com/intl/en_us/terms_google_news.html) restrict use to personal, noncommercial use; linked articles remain third-party publisher content and retain publisher terms | "Google News RSS" plus each article's linked publisher/domain |
| **GDELT Project DOC 2.0** | Fail-soft fallback for location-matched cockpit headlines | [GDELT Terms of Use](https://www.gdeltproject.org/about.html#termsofuse): unrestricted academic/commercial/governmental dataset use, with citation and link required; linked articles retain publisher terms | "GDELT Project" plus each article's linked publisher/domain |
| **City of Austin Open Data** | CCTV camera catalog + frames | City of Austin Open Data Terms of Use | "City of Austin, TX — data.austintexas.gov" |
| **TxDOT ITS (its.txdot.gov)** | CCTV camera catalogs + frames, Texas districts | Public TxDOT traffic camera data | "Texas Department of Transportation" (courtesy) |
| **FL511 (Florida DOT)** | CCTV camera catalog + frames, Florida statewide | FL511 content, individual personal use only; any other use needs FDOT's express written consent ([terms](https://fl511.com/privacy#disclaimer)). **Off by default**; set `CCTV_FLORIDA_ENABLED=1` to enable | "Florida DOT — FL511.com" (courtesy) |
| **City of Tallinn (ristmikud.tallinn.ee)** | CCTV camera catalog + frames, Tallinn intersections | Public City of Tallinn traffic camera data | "City of Tallinn — ristmikud.tallinn.ee" (courtesy) |
| **Transpordiamet / Tarktee** | CCTV camera catalog + frames, Estonia road-weather cameras | Public Transpordiamet / Tarktee road camera data | "Transpordiamet / Tarktee — tarktee.transpordiamet.ee" (courtesy) |
| **Stadt Warendorf webcam** | Webcam frames, Marktplatz / Historisches Rathaus (`config/cctv_sources.warendorf.json`) | Public municipal webcam data | "Stadt Warendorf — webcam.warendorf.de" (courtesy) |
| **Live Traffic NSW (Transport for NSW)** | CCTV camera catalog + frames, New South Wales | [CC BY 4.0](https://opendata.transport.nsw.gov.au/) — attribution REQUIRED | "Live Traffic NSW — Transport for NSW" |
| **Caltrans (cwwp2.dot.ca.gov)** | CCTV camera catalogs + frames, California districts | Public Caltrans traffic camera data | "Caltrans — cwwp2.dot.ca.gov" (courtesy) |
| **DelDOT (video.deldot.gov)** | CCTV camera catalog (`tmc.deldot.gov/json/videocamera.json`, cached for 15 minutes) + live HTTPS HLS video, Delaware. Keyless | Public DelDOT traffic camera streams via the [DelDOT traffic map](https://deldot.gov/map/) | "DelDOT — Delaware Department of Transportation" (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" |
| **Ontario 511** | CCTV camera catalog + frames, Ontario highways including Kitchener-area routes | [Open Government Licence – Ontario](https://www.ontario.ca/page/open-government-licence-ontario) | "Ontario 511" plus Open Government Licence - Ontario |
| **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 |
| **King County Road Services** (`services.arcgis.com`, King County GIS) | CCTV camera catalog + frames, unincorporated King County WA road network | [KCGIS Center terms](https://kingcounty.gov/en/dept/kcit/data-information-services/gis-center/about/terms-conditions-copyrights) — attribution required; public ArcGIS layer | "King County Road Services" (partner-city cameras name their owner) |
| **WSDOT** (`data.wsdot.wa.gov`) | CCTV camera catalog + frames, Washington State highways | [WSDOT travel information disclaimer](https://wsdot.wa.gov/about/policies/travel-information-disclaimer) — public "low volume" feed; catalog cached, frames on WSDOT's own image host only | "Washington State Department of Transportation (Travel Center)" (courtesy) |
| **Hong Kong Transport Department / DATA.GOV.HK** | CCTV camera catalog + frames, Hong Kong traffic snapshot cameras | [DATA.GOV.HK Terms of Use](https://data.gov.hk/en/terms-and-conditions) — attribution REQUIRED | "Hong Kong Transport Department / DATA.GOV.HK" |
| **Statens vegvesen** (catalog `ogckart-sn1.atlas.vegvesen.no`, frames `kamera.atlas.vegvesen.no`, HLS `kamera.vegvesen.no`) | CCTV camera catalog + frames + live HLS video, Norway road cameras | [NLOD 2.0](https://data.norge.no/nlod/en/2.0) — attribution REQUIRED | "Contains data under the Norwegian licence for Open Government data (NLOD) distributed by Statens vegvesen" |
| **Kartverket** (`ws.geonorge.no/hoydedata/v1/punkt`) | Terrain heights (NN2000) used as the ground prior for Statens vegvesen cameras | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) — attribution REQUIRED | "© Kartverket", modified (heights over water set to 0 m, rounded to 0.1 m) |
| **NYC DOT Traffic Management Center** (`webcams.nyctmc.org`) | CCTV camera catalog + frames, New York City five boroughs | NYC DOT requires developers to sign a [data-sharing agreement](https://www.nyc.gov/html/dot/html/about/datafeeds.shtml) for the camera feed. **Off by default**; enable only under your own agreement | "NYC DOT Traffic Management Center" + NYC DOT's required application disclaimer (in the Data attribution popover) |
| **City of Toronto Open Data** (`opendata.toronto.ca`) | CCTV camera catalog + frames, Toronto city streets | [Open Government Licence – Toronto](https://www.toronto.ca/city-government/data-research-maps/open-data/open-data-licence/) — attribution REQUIRED | "Contains information licensed under the Open Government Licence – Toronto" |
| **GBFS (Lyft / BCycle / Social Bicycles / PBSC)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle) + its `license_url` |
| **Bike Share Toronto** (`toronto.publicbikesystem.net`) | Bikeshare station availability, Toronto | Operator GBFS feed. No licence is published: the City's open-data package declares none and the feed has no `license_url`. Attribution is given to the operator. | "Bike Share Toronto (Toronto Parking Authority)" |
| **Bike Itaú / Tembici** (`*.publicbikesystem.net`, seven Brazilian cities, Santiago and Bogotá) | Bikeshare station availability: São Paulo, Rio de Janeiro, Salvador, Recife, Porto Alegre, Brasília, Curitiba, Santiago de Chile and Bogotá | Operator GBFS feeds. No licence is published: the feeds' `system_information.json` carries no `license_url`, operator or attribution fields. Attribution is given to the operator. | "Bike Itaú (Tembici)"; "Tembici Bogotá" |
| **Bicing** (`barcelona.publicbikesystem.net`) | Bikeshare station availability, Barcelona | Operator GBFS feed (PBSC), keyless. The feed publishes no licence; the City's own open-data copy of the same station data is CC BY 4.0 but needs a token. Attribution is given to the operator. | "Bicing, Barcelona City Council (B:SM)" |
| **BiciMAD** (`madrid.publicbikesystem.net`) | Bikeshare station availability, Madrid | Operator GBFS feed (PBSC), keyless. Madrid's open-data catalogue publishes this feed (`datos.madrid.es`, dataset `900021-0-bicimad-gbfs`, owner EMT Madrid) under [EMT's open-data terms](https://mobilitylabs.emtmadrid.es/sip/terms-of-use): commercial and non-commercial reuse, with attribution REQUIRED (section 5.2). | "Powered by EMT de Madrid", linking to `https://www.emtmadrid.es` |
| **Spanish city bikes on PBSC** (`*.publicbikesystem.net`: Zaragoza, Valladolid, A Coruña, Donostia-San Sebastián, Bilbao, Rivas-Vaciamadrid) | Bikeshare station availability: Bizi Zaragoza, BIKI, BiciCoruña, Dbizi, Bilbao Bizi, BicinRivas | Operator GBFS feeds, keyless. The feeds' `system_information.json` carries no `license_url`, and no operator or city publishes a licence for them; MobilityData's catalogue lists CC BY 4.0 without naming a source. Attribution is given to the operator. | "Bizi Zaragoza"; "BIKI, AUVASA (Valladolid)"; "BiciCoruña"; "Dbizi (Donostia-San Sebastián)"; "Bilbao Bizi, Bilbao City Council"; "BicinRivas, Rivas-Vaciamadrid City Council" |
| **AMBici** (nextbike, `gbfs.nextbike.net`) | Bikeshare station availability, Barcelona metropolitan area (e-bikes) | Operator GBFS feed, keyless. `system_information.license_id` is **CC0-1.0**; no attribution required, given anyway. | "AMBici, Àrea Metropolitana de Barcelona, operated by nextbike" |
| **Oslo / Bergen / Trondheim Bysykkel** (Urban Sharing, `gbfs.urbansharing.com`) | Bikeshare availability, Oslo, Bergen and Trondheim city bikes. Keyless GBFS; the proxy sends the `Client-Identifier` header the operators ask for | [NLOD 2.0](https://data.norge.no/nlod/en/2.0) — attribution REQUIRED ([Oslo](https://oslobysykkel.no/en/open-data/realtime), [Bergen](https://bergenbysykkel.no/en/apne-data/sanntid), [Trondheim](https://trondheimbysykkel.no/en/open-data/realtime)) | "Contains data under the Norwegian licence for Open Government data (NLOD) made available by Oslo Bysykkel, Bergen Bysykkel and Trondheim Bysykkel" |
| **Kolumbus / Farte Bysykkel** (Entur Mobility, `api.entur.io/mobility/v2/gbfs`) | Bikeshare availability, Kolumbus Bysykkel (Stavanger/Nord-Jæren) and Farte Bysykkel (Skien/Porsgrunn). Keyless GBFS; the proxy sends the `ET-Client-Name` header Entur requires and is limited to Entur's GBFS paths | [NLOD](https://data.norge.no/nlod/en/2.0) per the [Entur Mobility API docs](https://developer.entur.org/pages-mobility-docs-mobility-v2) — attribution REQUIRED | "Contains data under the Norwegian licence for Open Government data (NLOD) made available by Entur (Kolumbus Bysykkel, Farte Bysykkel)" |
| **JCDecaux Cyclocity** (`api.cyclocity.fr`) | Bikeshare availability: Bysykkel Lillestrøm, Valenbisi (Valencia) and Sevici (Seville). Keyless GBFS; the proxy is limited to JCDecaux contract paths | [Licence Ouverte / Etalab](https://developer.jcdecaux.com/files/Open-Licence-fr.pdf) (each feed's `license_url`) — attribution REQUIRED | "Bysykkel Lillestrøm, Valenbisi, Sevici: JCDecaux, Licence Ouverte" |
| **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) |
| **Metro Transit** (Metropolitan Council) | Transit layer live vehicles, Minneapolis–St Paul | Metro Transit / Metropolitan Council open realtime vehicle data, used for the live view; published for developer use and not against their use. | Attribution to Metro Transit / Metropolitan Council (courtesy text) |
| **OVapi / Stichting OpenGeo** | Transit layer live vehicles, Netherlands | OVapi / Stichting OpenGeo open realtime vehicle data, used for the live view; published for developer use and not against their use. The client sends a User-Agent and conditional requests. | Attribution to OVapi / Stichting OpenGeo (courtesy text) |
| **Entur** (api.entur.io) | Transit layer live vehicles, Norway | Entur open realtime vehicle data, used for the live view; published for developer use and not against their use. The client sends the `ET-Client-Name` identifying header. | Attribution to Entur (courtesy text) |
| **TransLink** (Queensland Government) | Transit layer live vehicles, South East Queensland | TransLink / Queensland Government open realtime vehicle data, used for the live view; published for developer use and not against their use. | Attribution to TransLink / Queensland Government (courtesy text) |
| **HSL** (realtime.hsl.fi) | Transit layer live vehicles, Helsinki | HSL (Helsinki Region Transport) open realtime vehicle data, used for the live view; published for developer use and not against their use. | Attribution to HSL (Helsinki Region Transport) (courtesy text) |
| **TTC** (bustime.ttc.ca) | Transit layer live vehicles, Toronto — surface vehicles (buses and streetcars) only | Toronto Transit Commission BusTime open realtime vehicle data, published through City of Toronto Open Data and used for the live view. The subway publishes no vehicle positions, so Lines 1/2/4 are absent rather than inferred. | Attribution to Toronto Transit Commission — City of Toronto Open Data, under the Open Government Licence – Toronto |
| **MiWay** (miapp.ca) | Transit layer live vehicles, Mississauga ON | MiWay open realtime vehicle data, published by the City of Mississauga under its Open Data Terms of Use, used for the live view. The licence is revocable and asks that the Terms of Use link travel with the data. | Attribution to MiWay — City of Mississauga Open Data, linked to the Terms of Use |
| **Hamilton HSR** (opendata.hamilton.ca) | Transit layer live vehicles, Hamilton ON | Hamilton Street Railway open realtime vehicle data, published by the City of Hamilton for commercial and non-commercial use. Not one of its 80 route ids is the number on the bus, so the route-number table in `src/data/transitFeeds.js` is derived from the City’s own published GTFS `routes.txt` under the same licence; two generations of id run at once and both are listed. | "Contains public sector Data made available under the City of Hamilton’s Open Data Licence" — wording prescribed by the licence |
| **Durham Region Transit** (drtonline.durhamregiontransit.com) | Transit layer live vehicles, Durham Region ON | Durham Region Transit open realtime vehicle data, published under the Region of Durham Open Data Licence v.1.0, which permits commercial use. | "Contains public sector information made available under The Regional Municipality of Durham's Open Data Licence" |
| **Guelph Transit** (glphtrpcloud.com) | Transit layer live vehicles, Guelph ON | Guelph Transit open realtime vehicle data, published by the City of Guelph on its open-data developer page and used for the live view. Its route ids are internal database keys, so the 33-row route-number table in `src/data/transitFeeds.js` is derived from the City’s own published GTFS `routes.txt` under the same licence. | "Contains information licensed under the Open Government Licence – City of Guelph" — wording prescribed by the licence |
| **Kingston Transit** (api.cityofkingston.ca) | Transit layer live vehicles, Kingston ON | Kingston Transit open realtime vehicle data, catalogued by the City of Kingston as open data and used for the live view. | "Contains information licensed under the Open Data Licence – City of Kingston" — wording prescribed by the licence |
| **Barrie Transit** (myridebarrie.ca) | Transit layer live vehicles, Barrie ON | Barrie Transit realtime data, published by the City of Barrie under a licence accepted by use of the data. The licence is revocable and requires no attribution. | Courtesy credit to the City of Barrie (the licence requires none, and its trademark clause is broad) |
| **YRT/Viva** (rtu.york.ca) | Transit layer live vehicles, York Region ON | York Region Transit realtime data, first-party York Region infrastructure under the YRT Open Data Licence v2.0, which permits commercial use and makes attribution optional. Viva route ids are named by colour from York Region’s published GTFS. | "Contains public transit Information made available under YRT’s Open Data Licence" — the licence’s own wording, given though not required |
| **Burlington Transit** (opendata.burlington.ca) | Transit layer live vehicles, Burlington ON | Burlington Transit realtime data under the City of Burlington Open Data Terms of Use, which permit any lawful purpose. The terms are revocable and must travel with the data, so the credit links them. Route ids are internal keys and are joined to the City’s published GTFS. | Credit to the City of Burlington plus a link to its Open Data Terms of Use, which the licence requires |
| **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)" |
| **OSRM on the FOSSGIS routing servers** (`routing.openstreetmap.de`) | Street-following routes for the Directions layer and voice route annotations, via `/api/route` | [FOSSGIS routing usage policy](https://routing.openstreetmap.de/about.html): "Display the required attribution and display a link to 'fix the map'", "Use a valid user agent and, if applicable, a correct referrer", "One request per second max", "No scraping, no heavy usage". The full policy is the German [FOSSGIS Nutzungsbedingungen](https://www.fossgis.de/arbeitsgruppen/osm-server/nutzungsbedingungen/); FOSSGIS also states that the server may be embedded in your own pages but "eine gewerbliche Nutzung ist nur mit Einschränkungen erlaubt" (commercial use only with restrictions). Route data derives from OpenStreetMap (ODbL 1.0) | "Routing: OSRM on the FOSSGIS servers" + a "fix the map" link — shown in the Data attribution popover |
### Local receiver input (not fetched from a service)
@@ -119,7 +119,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
- **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.
<a id="gnss-integrity"></a>
<a id="gnss-integrity"></a>
- **GNSS integrity (adsb.lol).** A separate keyless point request samples a 250 nm
circle around the screen centre, snapped once to whole degrees. This does not
@@ -153,6 +153,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
can be outside coverage or lack enough observations. Attribution: adsb.lol
contributors, ODbL 1.0; GPSJAM for aggregation only.
- **adsbdb enrichment.** `/api/adsbdb` resolves an ICAO hex to aircraft type, model name and registration, and the tracked flight's callsign to an airline and an origin/destination pair. Fan-out is deliberately small because adsbdb is a free community API that publishes no quota: at most 4 requests in flight, dispatches dripped to roughly 5/s, and each key asked for at most once per session, with route lookups reserved for the tracked aircraft. The dev-server proxy caches each key for 24 hours in `.gev-cache/adsbdb.json`, negative answers included, so fresh entries survive server restarts. That gitignored cache remains local to the deployment and is not bundled with this repo. adsbdb publishes no general service license; its README says the route data may not be copied, published, or incorporated into other databases without the explicit permission of David J Taylor, Edinburgh. Deployers are responsible for obtaining any permission their use requires. Enrichment is best-effort and fail-silent — a flight that gets no answer keeps what the flight feed already gave it.
- **Launch Library 2.** `/api/launches` makes a server-side rolling-30-day query against the supported v2.3 detailed launch endpoint, caches successful responses for 15 minutes in memory and on disk, and serves the last successful response during a throttle or transient outage. Anonymous access is limited to 15 calls/hour; deployments can provide `LL2_API_TOKEN` for authenticated access. The Space Devs' published terms permit using and sharing the API data in any form, ask users not to forward it without adding value, disclaim complete accuracy, and encourage—but do not require—attribution. This app keeps a courtesy credit. Payload and stage/recovery records are shown only when supplied. Failed launches expose their source status and never receive fallback orbit geometry or a live/estimated marker. LL2 supplies launch context and event timing, not continuous ascent telemetry or live orbital state.
- **TfL JamCams.** The camera list comes from the keyless `api.tfl.gov.uk` endpoint (an optional `TFL_APP_KEY` raises its rate limit); frames come from TfL's public S3 bucket. The "Powered by TfL Open Data" attribution is required by TfL's terms and is registered in the Data attribution popover.
@@ -174,7 +175,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
- **Statens vegvesen road cameras.** Keyless. The camera list is one GeoJSON request to the OGC API Features view of the official DATEX 3.1 CCTV site table (`ogckart-sn1.atlas.vegvesen.no/ogc/features/v1/collections/datex_3_1:CctvSimple/items`), so no DATEX account is needed; frames are stills on `kamera.atlas.vegvesen.no/api/images/<cameraId>`, and only that exact path per camera is registered. Cameras the feed reports as faulty are dropped. About 135 working cameras also publish live HLS (`kamera.vegvesen.no/public/<cameraId>/manifest.m3u8`, MPEG-TS); those play as live video through the bounded in-memory HLS relay, with the still as the snapshot fallback, and only that exact manifest path per camera is registered. `CCTV_VEGVESEN_VIDEO=0` keeps every camera on stills. `orientationDescription` names the place a camera faces, not a bearing, so it goes into the label and headings use the id-hash fallback at low confidence. By default every live camera loads (~850); `CCTV_VEGVESEN_MAX_SOURCES` (8–1000) lowers the cap, keeping the cameras nearest Oslo, Bergen, Stavanger, Trondheim, Kristiansand, Bodø and Tromsø and `CCTV_VEGVESEN_ENABLED=0` turns the pack off. Frames are fetched at request time and never stored or redistributed. The NLOD attribution is registered in the Data attribution popover.
- **Kartverket ground heights for Statens vegvesen cameras.** The camera feed's points are 2D, so the loader asks Kartverket's keyless point-height API (`ws.geonorge.no/hoydedata/v1/punkt`, CC BY 4.0) for the terrain height at each camera. Requests carry 20 points: the API accepts 50, but above ~20 it answers the extra points from contour lines (`hoydekurver`, metres to ~20 m off) or not at all. Eight requests run at a time, about 36 for the whole country: a cold lookup took 6–13 s in testing (single requests 0.6–9.5 s). Answers are matched by the coordinates the API echoes and recorded only for points that request asked about. Over water the API returns the sea-floor depth (`dybdekurver` or `Havflate`), which is replaced by 0 m, the water surface; for the ~14 bridge cameras that is below the deck, but much closer than the sea floor or the flat prior. Heights are cached in memory by position for the life of the server, and points with no height (a few ferry quays and bridges, or a point a 200 answer leaves out) for 6 h, so the 15-minute catalog refresh only asks about new or moved cameras. The first failing request (a 408, 429 or 5xx, a network error, a request over 10 s, or an unreadable or oversized body) stops the lookup, with no retries, and no new lookup starts until the backoff ends (Retry-After when sent, else 30 min, longer than the refresh; bounded to 1 min–1 h). Any other 4xx marks that request's points as having no height and the lookup carries on. The whole lookup has a 30 s deadline; a lookup cut short by it resumes on the next refresh without a backoff. The lane waits at most 12 s, counted from the start of the loader, and only when more than 10% of the cameras have no answer yet; a few new cameras or expired misses are looked up in the background. A slower lookup keeps filling the cache, and the cameras it had not reached keep the flat prior until the next 15-minute refresh (about 190 of 865 on one slow first run). Only coordinates are sent, with the same User-Agent as the other CCTV requests. `CCTV_VEGVESEN_HEIGHTS=0` turns the lookup off. The Kartverket credit, with a note that heights over water are set to 0 m and heights are rounded to 0.1 m, is shown beside the Statens vegvesen entry in the Data attribution popover.
- **NYC DOT Traffic Management Center cameras.** Off by default. NYC DOT's [developer data feeds page](https://www.nyc.gov/html/dot/html/about/datafeeds.shtml) asks developers who want the camera feed to contact `[email protected]` and sign a data-sharing agreement. This project does not hold one that covers downstream installations, so the pack loads only when an operator who holds their own agreement sets `CCTV_NYC_ENABLED=1`; with the flag unset, empty or any other value, the server makes no request to NYC DOT. The endpoint needs no key. The camera list is the public `webcams.nyctmc.org/api/cameras` catalog (~980 cameras across the five boroughs); frames are stills at `webcams.nyctmc.org/api/cameras/<uuid>/image`, synthesized from a validated UUID rather than taken from the payload, and only that host is registered. Cameras the feed reports as offline are dropped. The catalog publishes no camera facing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. Once enabled, the 250 cameras nearest the borough cores load; `CCTV_NYC_MAX_SOURCES` (8–400) changes the cap. Frames are fetched at request time and never stored or redistributed. NYC DOT asks every application that uses its data to carry its disclaimer; it is registered verbatim in the Data attribution popover.
- **City of Toronto RESCU cameras.** Keyless. The camera list is the City's public `tmcearthcameras.json` on `opendata.toronto.ca` (dataset "Traffic Cameras", Transportation Services), served as JSONP and unwrapped with an anchored pattern. This is the municipal network the Ontario 511 pack does not carry: downtown arterials plus the Gardiner, DVP, Allen and Lake Shore. The list publishes no frame URL, so frame URLs are BUILT from the documented `CameraImages/loc####.jpg` convention and a strictly-digits camera number, pinning the frame proxy to the portal origin by construction; the list fetch refuses redirects and bounds the body it buffers. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing: the `D1`-`D4` fields are the directions of the _comparison_ images (static reference stills captured in 2016 under a different path), not a bearing for the live pan-tilt-zoom frame, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. A camera that is temporarily out of service still answers 200 with a byte-identical "Image Unavailable" placeholder rather than an error (about 6% of the network at any moment), so such a camera reads as healthy; the frame it shows is the agency's, not a synthesized one. The licence requires the attribution statement above. Disable with `CCTV_TORONTO_ENABLED=0`.
- **City of Toronto RESCU cameras.** Keyless. The camera list is the City's public `tmcearthcameras.json` on `opendata.toronto.ca` (dataset "Traffic Cameras", Transportation Services), served as JSONP and unwrapped with an anchored pattern. This is the municipal network the Ontario 511 pack does not carry: downtown arterials plus the Gardiner, DVP, Allen and Lake Shore. The list publishes no frame URL, so frame URLs are BUILT from the documented `CameraImages/loc####.jpg` convention and a strictly-digits camera number, pinning the frame proxy to the portal origin by construction; the list fetch refuses redirects and bounds the body it buffers. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing: the `D1`-`D4` fields are the directions of the *comparison* images (static reference stills captured in 2016 under a different path), not a bearing for the live pan-tilt-zoom frame, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. A camera that is temporarily out of service still answers 200 with a byte-identical "Image Unavailable" placeholder rather than an error (about 6% of the network at any moment), so such a camera reads as healthy; the frame it shows is the agency's, not a synthesized one. The licence requires the attribution statement above. Disable with `CCTV_TORONTO_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 sixteen 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. The TTC feed is BusTime, the surface system: it carries buses and streetcars and no subway at all. About four in ten of its vehicles report no route at all (unassigned or deadheading), and those are shown with the feed default rather than a route-established mode. It sends no ETag or Last-Modified, so its conditional requests fall through to the ordinary snapshot cache. Three Golden Horseshoe neighbours run alongside it — MiWay, Hamilton HSR and Durham Region Transit — each bus-only and each polled only near its own region. Durham Region Transit publishes no bearing on any vehicle, so those sprites take their heading from observed movement like any other feed that omits it. Guelph, Kingston and Barrie extend the same pattern eastward and north. Hamilton, Guelph, Burlington and YRT publish route ids that are not the number on the vehicle, so those entries carry a route-name table taken from each operator’s own published GTFS; any id missing from a table renders unchanged rather than blank. A label is never invented: YRT runs a route `9101` that appears nowhere in its published routes, so it stays as the feed sends it. Burlington’s is the one that cannot be shortcut: `351` is route 1 while `3510` is route 10, so the table is a real join rather than a trimmed prefix. GO Transit / Metrolinx is deliberately absent: its REALTIME feed requires an API key issued by manual review, which the keyless admission rule excludes (its static schedule download is keyless, but carries no vehicle positions). Barrie and YRT/Viva each invite acceptance of their licence through a form, and each licence also states that using the data is itself acceptance; this deployment relies on that clause, which is recorded in both entries. Grand River Transit in Waterloo is absent for a different reason: it answers, but its feed header was months stale and it publishes no per-vehicle timestamps, so it would render vehicles that look live and are not. Ottawa’s OC Transpo falls to the same keyless admission rule as GO Transit — its realtime feed requires an API key. London Transit and Thunder Bay Transit publish vehicle positions over plain HTTP with no HTTPS endpoint available. Every other Ontario system — Brampton, Oakville, Niagara, Milton, Windsor, Greater Sudbury, UP Express and a dozen smaller operators — publishes static GTFS schedules only, with no realtime vehicle positions to register. Between the nine included and these exclusions, every Ontario realtime vehicle-position feed catalogued in the Mobility Database is accounted for.
- **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 non-array or empty successful payloads are rejected atomically. Individual structurally malformed rows are skipped rather than failing the whole query; the response carries their bounded `rejectedStationCount` (at most 10,000), which the client adds to its own rejected rows and reports as degraded coverage without discarding the accepted generation. 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.
@@ -184,7 +185,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
- **OSRM on the FOSSGIS routing servers.** Keyless (no API key). Every route is requested by a person: clicking A and B in the Directions layer, or a voice "route from A to B". The server-side `/api/route` proxy sends an identifying `User-Agent`, pins the upstream host, accepts 2-12 coordinates, rejects any leg over 600 km or a total over 2,500 km, caps the buffered response at 8 MB, times the upstream out at 12 s, rate-limits per client, caches each route for 10 minutes, and coalesces identical requests that are in flight at the same time. Turn-by-turn maneuvers are asked for only when a caller wants them (`steps=1`), so the callers that do not read them send the smaller request. The "fix the map" link the policy asks for is in the Data attribution popover beside the OpenStreetMap credit.
- **Mapped installations.** OpenFreeMap military polygons use a bundled Overture/OSM name index; wide views show named points. Optional operator-configured Overpass and explicit Google Places searches keep their own source paths. Mapped data does not establish current activity or complete coverage.
- **Place search.** Named-place fly-to uses Google Geocoding when a Maps key is configured, then Photon (`PHOTON_URL`; unset uses the public instance, empty disables it), then `/api/geocode` (Nominatim search) when neither answers. `/api/geocode` and the voice outline route `/api/geocode/outline` share one Nominatim queue (`NOMINATIM_URL`; unset, empty and invalid values disable it; an operator must explicitly select an authorized endpoint): one request at a time at least 1.1 s apart, an identifying `User-Agent` and `Referer`, a short queue that refuses a burst, a pause after 429/403/5xx answers (honouring `Retry-After` in full), at most one retry after a network failure, and a per-install daily cap on public-instance requests (`NOMINATIM_DAILY_CAP`, default 50). Processes using the same installation state file hold one lock through the actual fetch invocation and recording the next public start, with the daily slot persisted before invocation; restarts share that count, spacing and any pause. An unfinished dispatch marker fails closed until operator recovery; it is never automatically reclaimed. Public access fails closed if the state file cannot be read, locked, written or semantically validated. This coordination does not span independent installations or state directories and does not establish an application-wide public-service allowance or authorize public use. An operator-selected non-public endpoint is outside the public cap, spacing and durable-state requirement; an upstream cooldown is kept with the endpoint that returned it rather than leaking across operator URLs. Answers are cached on disk (outlines 90 days, places 30 days, no-results 24 hours); failures are not cached. Outlines are looked up only for an explicit ask, after bundled data and any configured Overpass, and only results whose type suits the ask are used. Administrative outline responses expose country/admin-1/admin-2 identity only when the selected Nominatim row has a recognized, unambiguous address type consistent with its own name; numeric rank, ambiguous types, missing fields and contradictory names do not manufacture identity. It is not used for systematic or bulk queries.
- **Street and building outlines.** Voice street highlights, pointed-at buildings and grounds use the OpenFreeMap vector tiles the traffic layer already loads. Traffic and outlines share one bounded pending fetch/decode owner per immutable XYZ tile; materialized outline geometry is charged to the retained-byte ceiling and capped by coordinate count. Consumer result caches and request lifetimes remain independent, while eviction or explicit teardown cancels queued idle projections and releases their decoded owner. No Overpass request is made unless `OVERPASS_UPSTREAMS` is configured. Named grounds require a returned name matching the selected place. Anonymous landcover polygons in the tile schema cannot establish that identity; unsupported grounds stay unavailable rather than borrowing a nearby POI name.
- **Street and building outlines.** Voice street highlights, pointed-at buildings and grounds use the OpenFreeMap vector tiles the traffic layer already loads. Traffic and outlines share one bounded pending fetch/decode owner per immutable XYZ tile; materialized outline geometry is charged to the retained-byte ceiling and capped by coordinate count. Consumer result caches and request lifetimes remain independent, while eviction or explicit teardown cancels queued idle projections and releases their decoded owner. No Overpass request is made unless `OVERPASS_UPSTREAMS` is configured. Named grounds require a returned name matching the selected place, except that an ask whose head noun names a kind of grounds (a school, university or college, hospital, cemetery, stadium or zoo) may use the part of the smallest unnamed tile area of that class that holds the resolved place; a differently named area never answers. Other anonymous landcover polygons cannot establish identity, so unsupported grounds stay unavailable rather than borrowing a nearby POI name.
- **Cockpit regional briefing.** `/api/regional-brief` rounds aircraft coordinates into 0.1° cache cells, caches results for five minutes, and resolves physical regions from bundled public-domain Natural Earth polygons without reverse-geocoding requests. Google News RSS is queried with the resolved locality/region first; GDELT is used only when that RSS query fails or is empty. Google's published Google News terms restrict that source to personal, noncommercial use, so commercial deployments must disable/replace it or obtain separate permission; GDELT permits commercial dataset use with citation. The Data attribution popover identifies the active headline sources; article links retain publisher attribution. Headlines are location-query matches, not verified incidents, risk rankings, or evidence that a location is safe. Empty, partial, stale, and unavailable source states remain distinct. Open-Meteo supplies current conditions independently of the news source. `WX OFF` disables cockpit weather rendering only; the Local Info briefing still fetches its source-backed weather values and displays the required linked Open-Meteo credit.
- **Dynamic weather presentation.** While cockpit mode is active, `/api/weather-effects` requests current Open-Meteo observations for the aircraft/camera location, rounds coordinates into 0.1° cache cells, caches results for five minutes, and may retain a stale observation for up to 30 minutes during a transient outage. WMO condition code selects the visual family; observed cloud cover, precipitation, visibility, wind speed, and wind direction bound its strength and motion. Missing or expired weather renders no synthetic atmospheric effect, and normal globe view never renders the weather overlay.
@@ -196,22 +197,22 @@ Static datasets shipped in the repo for an out-of-the-box experience. **None are
The [Bhote Koshi event pack](public/events/bhote-koshi-2026/README.md), under `public/events/`, contains Vantor imagery crops and a GeoPera-derived river centerline under **CC BY-NC 4.0**, separately from the MIT code. The derived coordinate dataset in `src/data/bhoteKoshiFloodPath.js` has the same non-commercial restriction and is compiled into the Nepal scene. Commercial users must obtain separate permission or exclude both the event pack and that source-derived dataset from their source and build. Deleting only `public/events/bhote-koshi-2026/` does not remove all restricted data; excluding the scene also requires removing its registrations and imports before building. Linked witness posts and geolocation-map records retain their owners' terms; no social-media clips or cached posters are bundled. Other snapshots below are under `src/data/local_data/`.
| Dataset | Folder | License | Commercial use? | Attribution |
| -------------------------------------------------------------------------------------------------- | --------------------------------- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | -------------------------------------------------------------------------------------------- |
| **Datacenters** (4,351) | `datacenters/` | **ODbL 1.0** (OpenStreetMap extract) | ✅ (attribution + share-alike on data) | © OpenStreetMap contributors (shared credit) |
| **Dams** (704) | `dams/` | **ODbL 1.0** (OpenInfraMap / OSM extract) | ✅ (attribution + share-alike on data) | © OpenStreetMap contributors (shared credit) + Open Infrastructure Map |
| **Military area names** (36,466) | `osm_military_names/` | **ODbL 1.0** (OpenStreetMap, via Overture Maps) | ✅ (attribution + share-alike on data) | © OpenStreetMap contributors (shared credit) + Overture Maps Foundation |
| **TeleGeography Submarine Cable Map** (712 cables + 1,917 landing points) | `telegeography_submarine_cables/` | **CC BY-NC-SA 3.0** | ❌ **NonCommercial — remove for commercial use** | "© TeleGeography — submarinecablemap.com" |
| **Natural Earth physical regions** (1,046 land + 292 marine named polygons) | `natural_earth/` | **Public domain** | ✅ (no restrictions) | "Made with Natural Earth" (courtesy credit — not legally required) |
| **DataSF Analysis Neighborhoods** (41 SF neighborhood polygons) | `neighborhoods/` | **PDDL 1.0** (public domain) | ✅ (no restrictions) | "City & County of San Francisco — DataSF" (courtesy — not legally required) |
| **Natural Earth countries, states and provinces** (260 admin-0/map units + 4,587 admin-1 polygons) | `natural_earth/` | **Public domain** | ✅ (no restrictions) | "Made with Natural Earth" (courtesy credit — not legally required) |
| **US Census Bureau counties** (3,235 county polygons) | `us_census_counties/` | **Public domain** | ✅ (no restrictions) | "U.S. Census Bureau" (courtesy — not legally required) |
| **US Census Bureau places** (32,629 city, town and CDP polygons) | `us_census_places/` | **Public domain** | ✅ (no restrictions) | "U.S. Census Bureau" (courtesy — not legally required) |
| **Who's On First neighborhoods** (61,665 polygons) | `wof_neighborhoods/` | Per source: CC BY, ODC-By, Open Government licences, CC0, PDDL, public domain (folder `ATTRIBUTION.md`) | ✅ (attribution) | "Neighborhood boundaries: Who's On First and contributors"; per-source credits in the folder |
| **Yale Bright Star Catalogue** (9,096 stars, sky background) | `sky/` | **Public domain** | ✅ (no restrictions) | "Yale Bright Star Catalogue (Hoffleit & Warren, via CDS)" (courtesy credit) |
| **ESO Milky Way panorama** (eso0932a, blurred cube map, sky background) | `sky/` | **CC BY 4.0** | ✅ (attribution; changes stated) | "ESO/S. Brunier" |
| **CCTV ground heights** (3,445 cameras) | `cctv_ground_heights/` | Precomputed camera placement heights, aligned to work with Google Photorealistic 3D Tiles (folder README) | — | — |
| **CCTV Camera Database** model-enrichment extract v2.26.0 (17 camera-model records) | `src/layers/cctv/modelSpecs.js` | **CC0-1.0** (public domain dedication) | ✅ (no restrictions) | "Specs: cctv-database.com" (courtesy — not legally required) |
| Dataset | Folder | License | Commercial use? | Attribution |
| --------------------------------------------------------------------------- | --------------------------------- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | --------------------------------------------------------------------------- |
| **Datacenters** (4,351) | `datacenters/` | **ODbL 1.0** (OpenStreetMap extract) | ✅ (attribution + share-alike on data) | © OpenStreetMap contributors (shared credit) |
| **Dams** (704) | `dams/` | **ODbL 1.0** (OpenInfraMap / OSM extract) | ✅ (attribution + share-alike on data) | © OpenStreetMap contributors (shared credit) + Open Infrastructure Map |
| **Military area names** (36,466) | `osm_military_names/` | **ODbL 1.0** (OpenStreetMap, via Overture Maps) | ✅ (attribution + share-alike on data) | © OpenStreetMap contributors (shared credit) + Overture Maps Foundation |
| **TeleGeography Submarine Cable Map** (712 cables + 1,917 landing points) | `telegeography_submarine_cables/` | **CC BY-NC-SA 3.0** | ❌ **NonCommercial — remove for commercial use** | "© TeleGeography — submarinecablemap.com" |
| **Natural Earth physical regions** (1,046 land + 292 marine named polygons) | `natural_earth/` | **Public domain** | ✅ (no restrictions) | "Made with Natural Earth" (courtesy credit — not legally required) |
| **DataSF Analysis Neighborhoods** (41 SF neighborhood polygons) | `neighborhoods/` | **PDDL 1.0** (public domain) | ✅ (no restrictions) | "City & County of San Francisco — DataSF" (courtesy — not legally required) |
| **Natural Earth countries, states and provinces** (260 admin-0/map units + 4,587 admin-1 polygons) | `natural_earth/` | **Public domain** | ✅ (no restrictions) | "Made with Natural Earth" (courtesy credit — not legally required) |
| **US Census Bureau counties** (3,235 county polygons) | `us_census_counties/` | **Public domain** | ✅ (no restrictions) | "U.S. Census Bureau" (courtesy — not legally required) |
| **US Census Bureau places** (32,629 city, town and CDP polygons) | `us_census_places/` | **Public domain** | ✅ (no restrictions) | "U.S. Census Bureau" (courtesy — not legally required) |
| **Who's On First neighborhoods** (61,441 polygons) | `wof_neighborhoods/` | Per source: CC BY, ODC-By, Open Government licences, CC0, PDDL, public domain (folder `ATTRIBUTION.md`) | ✅ (attribution) | "Neighborhood boundaries: Who's On First and contributors"; per-source credits in the folder |
| **Yale Bright Star Catalogue** (9,096 stars, sky background) | `sky/` | **Public domain** | ✅ (no restrictions) | "Yale Bright Star Catalogue (Hoffleit & Warren, via CDS)" (courtesy credit) |
| **ESO Milky Way panorama** (eso0932a, blurred cube map, sky background) | `sky/` | **CC BY 4.0** | ✅ (attribution; changes stated) | "ESO/S. Brunier" |
| **CCTV ground heights** (3,445 cameras) | `cctv_ground_heights/` | Precomputed camera placement heights, aligned to work with Google Photorealistic 3D Tiles (folder README) | — | — |
| **CCTV Camera Database** model-enrichment extract v2.26.0 (17 camera-model records) | `src/layers/cctv/modelSpecs.js` | **CC0-1.0** (public domain dedication) | ✅ (no restrictions) | "Specs: cctv-database.com" (courtesy — not legally required) |
### ⚠️ TeleGeography is bundled but NonCommercial
@@ -394,6 +395,7 @@ Community context: [#85](https://github.com/bilawalsidhu/gods-eye-view/issues/85
[#457 clouds](https://github.com/bilawalsidhu/gods-eye-view/pull/457).
This implementation is original; those contributions have not been merged here.
### Weather: NHC/CPHC cyclone advisories
- **Sources:** [NHC current tropical cyclone status](https://www.nhc.noaa.gov/CurrentStorms.json) and
+12 -5
View File
@@ -10,13 +10,20 @@
- Keep Census labels in filled geometry, handle local dateline outlines in one
coordinate frame, and require whole settlement names or explicit returned
aliases while retaining landmark shorthand (Manjunath, #842).
- Harden and integrate voice outlines with main, and exclude the 90 Ottawa and
Saskatoon source records whose redistribution terms remain unverified for
this derived pack (Manjunath, #842).
- Harden and integrate voice outlines with main (Manjunath, #842).
- Wait for the original building installation before reporting duplicate results,
including deferred outline installs, without restarting probes or changing
timeout behavior (Manjunath, #842).
including deferred outline installs, without restarting probes
(Manjunath, #842).
- Carry actual outline provenance to visible geometry and retain inline map credits only while the owning outline is displayed (Manjunath, #842).
- Require a named building's resolved anchor inside its tile footprint, outside
courtyard holes, while keeping nearby selection for pointed-at buildings
(Manjunath, #842).
- Start each queued building's ground-sampling budget when it reaches the head
of the queue, keep Nominatim outlines that cross the antimeridian, let an
unnamed school, university, hospital, cemetery, stadium or zoo area answer a
grounds ask whose head noun names that kind, keep the shared OpenFreeMap tile
caches within the 64 MiB budget of the single source traffic and installations
shared before, fold place names in one shared helper, and exclude Who's On
First sources whose licence is only listed as CC BY or CC0 "equivalent",
including the Ottawa, Saskatoon, Gatineau and Madrid records
(Manjunath, #842).
+9 -3
View File
@@ -5370,7 +5370,7 @@ request viewbox and continues searching without that geographic preference.
- Runtime entry: `src/main.js` calls `initAnnotations({ viewer, tileset })`, exposes `window.__gevAnnotations`, and passes the engine into the voice action runner.
- Engine contract: `src/annotations/annotationEngine.js` owns annotation state, TTL/fade lifecycle, concurrent anchor resolution, duplicate detection keyed on geometry, cancellation on clear/newer generations, and a hard cap of 120 live marks. Deferred outline upgrades drain FIFO at concurrency 2; queued work retains the owning abort controller and is discarded on a generation change before it can fetch.
- Resolver contract: `src/annotations/resolver.js` converts names, coordinates and screen pixels into world anchors and optional geometry. Google Geocode/Places supplies a centroid and scope; route requests use `/api/route`; ambiguous or distant results are rejected or recovered near the current view instead of drawing misleading blobs. Outline resolution keeps this order: bundled admin/place/neighborhood/Natural Earth geometry; OpenFreeMap street or pointed-building geometry where applicable; the configured OSM/Overpass admin/place/footprint/street/enclosing-area ladder; guarded Nominatim for eligible named cities, admin units, landmarks, buildings and grounds; then an OpenFreeMap building fallback that accepts a named building only when its resolved anchor lies in the selected filled footprint. Pointed-at building requests retain nearby selection within 40 m. If no rung can supply honest geometry, the mark remains a point or reports the capability unavailable. A real polygon, definitive miss, configured-ladder approximation, rate limit or transient result keeps its existing finality; Nominatim is only tried after higher-priority configured sources cannot answer. Building tile selection returns the selected component and its holes; a shared vector feature ID does not establish that unrelated components are one building. Other trusted multi-part outlines and holes are retained in `anno.polygons`, with `ring` anchored to the part whose filled area contains the resolved point, outside its holes, when possible. Missing capability never invents a radius outline. Only explicitly country/state/county-scoped asks bypass near-view recovery and proximity gating; state scope requires a leading `state of …`/`the state of …` phrase, while bare names, proper names ending in “State,” and administrative geocode result types alone remain guarded. Overpass throttles remain distinct from normal transients: `Retry-After` is honored for one retry, and a repeated throttle ends only that mark's outline upgrade.
- Resolver contract: `src/annotations/resolver.js` converts names, coordinates and screen pixels into world anchors and optional geometry. Google Geocode/Places supplies a centroid and scope; route requests use `/api/route`; ambiguous or distant results are rejected or recovered near the current view instead of drawing misleading blobs. Outline resolution keeps this order: bundled admin/place/neighborhood/Natural Earth geometry; OpenFreeMap street or pointed-building geometry where applicable; the configured OSM/Overpass admin/place/footprint/street/enclosing-area ladder; guarded Nominatim for eligible named cities, admin units, landmarks, buildings and grounds; then an OpenFreeMap building fallback that accepts a named building only when its resolved anchor lies in the selected filled footprint. Pointed-at building requests retain nearby selection within 40 m. Tile grounds answer a named grounds ask only from an area of the same name, or, when the ask's head noun names a kind of grounds (school, university/college, hospital, cemetery, stadium, zoo; "campus" alone names none), from the part of the smallest unnamed area of that class that holds the anchor (a class word elsewhere in a name, as in "University Avenue Park", does not count); a differently named area never answers. If no rung can supply honest geometry, the mark remains a point or reports the capability unavailable. A real polygon, definitive miss, configured-ladder approximation, rate limit or transient result keeps its existing finality; Nominatim is only tried after higher-priority configured sources cannot answer. Building tile selection returns the selected component and its holes; a shared vector feature ID does not establish that unrelated components are one building. Other trusted multi-part outlines and holes are retained in `anno.polygons`, with `ring` anchored to the part whose filled area contains the resolved point, outside its holes, when possible. Missing capability never invents a radius outline. Only explicitly country/state/county-scoped asks bypass near-view recovery and proximity gating; state scope requires a leading `state of …`/`the state of …` phrase, while bare names, proper names ending in “State,” and administrative geocode result types alone remain guarded. Overpass throttles remain distinct from normal transients: `Retry-After` is honored for one retry, and a repeated throttle ends only that mark's outline upgrade.
- With no detailed-feature capability, annotations retain their pins and say
"Detailed outline unavailable"; location focus reports the same result without
retrying. Missing capability never synthesizes an outline from a radius.
@@ -5936,8 +5936,14 @@ easier to meet (detection is now on more often), but does not create it.
Only eligible road geometry receives dots;
reverse one-way lines are reversed. The shared tile source caps each view at
16 tiles per pass (32 OpenFreeMap tiles total), four concurrent requests and 4 MiB per response. OpenFreeMap retains
up to 192 decoded tiles/64 MiB (four times body bytes as a storage estimate);
other tile sources retain the 64-tile/24 MiB defaults. Identical source-version/XYZ
up to 192 decoded tiles/32 MiB in one shared decoded cache (four times body
bytes as a storage estimate); traffic and installations each keep their own
slice of those tiles within 12 MiB and outlines within 8 MiB, charged at the
slice's estimated object size. The charged budgets total 64 MiB, the budget
of the single OpenFreeMap source traffic and installations shared before;
they are estimates, not a measured heap bound, and each consumer slice is a
copy (`structuredClone`) made once per cached tile. Other tile sources retain
the 64-tile/24 MiB defaults. Identical source-version/XYZ
requests share one download/decode, with independent caller cancellation. Parsed road views have a separate 24 MiB/64-entry cap; incomplete
views are not retained as complete snapshots. Buffer geometry is clipped and sub-12 m line slivers dropped; tile
road fragments are not stitched. Roads preserve bends and insert
+4 -1
View File
@@ -301,7 +301,8 @@ def licence_label(key, source):
def exclusion_note():
groups = ', '.join(f'{rule["count"]} `{key}` records' for key, rule in RECORD_EXCLUSIONS['sources'].items())
return f'This derived pack excludes {groups}. {RECORD_EXCLUSIONS["reason"]} Original upstream notices remain unchanged.'
lead = f'This derived pack excludes {groups}. ' if groups else ''
return f'{lead}{RECORD_EXCLUSIONS["reason"]} Original upstream notices remain unchanged.'
def attribution(counts, lookup):
@@ -330,6 +331,8 @@ def attribution(counts, lookup):
context = f'{v["context"]}: ' if v.get('context') else ''
lines.append(f'- {context}{v.get("source_name", "")}{note} {v.get("source_link", "")}'.rstrip())
lines.append('')
while lines[-1] == '':
lines.pop()
lines += ['', '## Source-specific exclusions', '', exclusion_note()]
return '\n'.join(lines).rstrip() + '\n'
+5 -1
View File
@@ -334,7 +334,8 @@
"server/providers/regional/nominatimCache.js",
"server/providers/regional/outlineSelect.js",
"src/localRequestGate.mjs",
"src/data/adminBoundaries.js"
"src/data/adminBoundaries.js",
"src/data/placeNames.js"
],
"external": []
},
@@ -2822,6 +2823,7 @@
"src/sources/openFreeMapOutlines.js",
"src/sources/nominatimOutlines.js",
"src/data/placeBoundaries.js",
"src/data/placeNames.js",
"src/data/local_data/us_census_places/files.js",
"src/data/local_data/wof_neighborhoods/files.js"
],
@@ -3412,6 +3414,7 @@
"src/sources/openFreeMapOutlines.js",
"src/sources/nominatimOutlines.js",
"src/data/placeBoundaries.js",
"src/data/placeNames.js",
"src/data/local_data/us_census_places/files.js",
"src/data/local_data/wof_neighborhoods/files.js"
],
@@ -3494,6 +3497,7 @@
"src/sources/openFreeMapOutlines.js",
"src/sources/nominatimOutlines.js",
"src/data/placeBoundaries.js",
"src/data/placeNames.js",
"src/data/local_data/us_census_places/files.js",
"src/data/local_data/wof_neighborhoods/files.js"
],
+2 -33
View File
@@ -12,14 +12,12 @@
"licenses": [
"Attribution 3.0 Unported",
"CC BY",
"CC BY (equivalent)",
"CC BY 2.0",
"CC BY 3.0",
"CC BY 3.0 US",
"CC BY 4.0",
"CC-BY",
"CC0",
"CC0 (equivalent)",
"CC0 1.0 Universal",
"ODC-By, v1.0",
"ODC-By v1.0",
@@ -41,42 +39,13 @@
"Singapore Open Data Licence v1"
],
"recordExclusions": {
"reason": "Original source-specific redistribution terms have not been established for these records in this derived pack.",
"reason": "Sources whose pinned registry licence is only an \"equivalent\" designation (for example \"CC BY (equivalent)\" or \"CC0 (equivalent)\"), including can-ons, can-saskodp, can-gatsudd and esp-aytomad, are not admitted: their redistribution terms have not been established for this derived pack.",
"archives": {
"neighbourhood": "8e5e413aacf2244e4e4167fbd9be860bc407ae6de8b733e47f13abe6d3d10455",
"macrohood": "f5fbfabcb8229b83242dbd38530c27e61867e8b3b097e3393d9c841962a99679",
"microhood": "e8197fa576e8831be8f091ac7e219d29224d89404ab0c56d377339e42a8f29ac"
},
"registrySha256": "d8a5dd873d26eee11583de4438185c046dec33848d5bde27047ca4bff44d9c39",
"sources": {
"can-ons": {
"count": 36,
"ids": [
85874435, 85874449, 85874467, 420782209, 420782213, 420782221,
420782223, 420782233, 1108978003, 1108978007, 1108978009, 1108978013,
1108978037, 1108978041, 1108978055, 1108978063, 1108978067,
1108978075, 1108978077, 1108978091, 1108978097, 1108978105,
1108978109, 1108978115, 1108978127, 1108978129, 1108978131,
1108978133, 1108978135, 1108978139, 1108978141, 1108978143,
1108978147, 1108978167, 1108978175, 1108978187
]
},
"can-saskodp": {
"count": 54,
"ids": [
1126113991, 1126113995, 1126113997, 1126113999, 1126114003,
1126114007, 1126114009, 1126114013, 1126114015, 1126114017,
1126114019, 1126114023, 1126114025, 1126114027, 1126114031,
1126114033, 1126114035, 1126114037, 1126114039, 1126114043,
1126114045, 1126114049, 1126114055, 1126114057, 1126114059,
1126114061, 1126114063, 1126114067, 1126114069, 1126114071,
1126114075, 1126114077, 1126114081, 1126114085, 1126114087,
1126114089, 1126114093, 1126114095, 1126114097, 1126114105,
1126114107, 1126114111, 1126114115, 1126114121, 1126114125,
1126114127, 1126114129, 1126114131, 1126114135, 1126114141,
1126114149, 1126114151, 1126114153, 1126114159
]
}
}
"sources": {}
}
}
+4 -13
View File
@@ -1,6 +1,7 @@
import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
import { foldPlaceName } from '../../../src/data/placeNames.js';
const DAY_MS = 86_400_000;
@@ -15,24 +16,14 @@ export const NOMINATIM_CACHE_TTL_MS = Object.freeze({
});
/**
* Comparable form of a place name: Unicode-normalized (NFKC), Latin accents
* removed, case folded, punctuation and symbols collapsed to single spaces,
* and a leading article dropped. Non-Latin scripts are kept as they are:
* "東京" stays "東京", while "São Paulo", "sao paulo" and "SÃO PAULO!" meet.
* Comparable form of a place name for matching results to the ask: the
* shared fold (see `foldPlaceName`), bounded in length.
*
* @param {string} value
* @returns {string}
*/
export function normalizePlaceName(value) {
return String(value ?? '')
.normalize('NFKD')
.replace(/(\p{Script=Latin})\p{M}+/gu, '$1')
.normalize('NFKC')
.toLocaleLowerCase('en-US')
.replace(/[\p{P}\p{S}\p{Z}\s]+/gu, ' ')
.trim()
.replace(/^(?:the) /, '')
.slice(0, 200);
return foldPlaceName(value).slice(0, 200);
}
/**
@@ -489,63 +489,6 @@ export function createGateStateStore({
nextStartAt: state.nextStartAt,
})).count;
},
reserve(day, { now, dailyCap, minSpacingMs, latestStartAt = Infinity }) {
if (!file) {
const error = new Error(
'no durable Nominatim usage state is configured',
);
report(error);
return { status: 'unavailable', error };
}
try {
let result;
withLock(() => {
if (pendingDispatches({ strict: true }).length)
throw new Error('unfinished durable Nominatim dispatch');
const disk = readDisk({ strict: true });
if (disk) memory = merge(memory, disk);
const pending = pendingPauses({ strict: true });
if (pending.length)
memory = merge(memory, {
...memory,
pausedUntil: Math.max(...pending.map((item) => item.pausedUntil)),
});
const state = memory;
if ((state.pausedUntil || 0) > now) {
result = {
status: 'paused',
retryAfterMs: state.pausedUntil - now,
};
return;
}
const count = state.day === day ? state.count : 0;
if (count >= dailyCap) {
result = { status: 'cap' };
return;
}
const startAt = Math.max(now, state.nextStartAt || 0);
if (startAt > latestStartAt) {
result = { status: 'abandoned' };
return;
}
const next = {
day,
count: count + 1,
pausedUntil: state.pausedUntil || 0,
nextStartAt: startAt + minSpacingMs,
};
writeDisk(next);
memory = next;
clearPersistedPauses(next.pausedUntil);
lastError = null;
result = { status: 'reserved', startAt, count: next.count };
});
return result;
} catch (error) {
report(error);
return { status: 'unavailable', error };
}
},
dispatch(
day,
{ clock, dailyCap, minSpacingMs, latestStartAt = Infinity },
+5 -1
View File
@@ -30,7 +30,11 @@ export const OUTLINE_POLYGON_THRESHOLD = Object.freeze({
building: 0.00005,
});
/** Total vertices returned for one outline, and parts/holes kept. */
/**
* Size limits for one outline. Vertices beyond the budget are simplified away;
* more parts, or more holes in one part, than these limits refuse the outline
* whole, because a truncated multipart place would misrepresent it.
*/
const OUTLINE_MAX_VERTICES = 6000;
const OUTLINE_MAX_PARTS = 24;
const OUTLINE_MAX_HOLES = 16;
+19 -6
View File
@@ -774,7 +774,8 @@ test('replacement cleanup failure still removes the old route and retains failed
const old = engine.list()[0];
const refresh = engine.annotate([{ type: 'area', target: 'Capitol', label: 'New' }]);
await flushMicrotasks();
const replacement = h.pending.at(-1).anno;
const replacementTask = h.pending.at(-1);
const replacement = replacementTask.anno;
const originalRemove = h.renderer.remove;
const removals = [];
let fail = true;
@@ -785,16 +786,22 @@ test('replacement cleanup failure still removes the old route and retains failed
};
assert.throws(() => engine.clear(), /replacement cleanup failed/);
assert.ok(removals.includes(old), 'old removal is attempted even when replacement removal throws');
assert.equal(old._pendingReplacement, null, 'cleanup failure cannot retain install authority');
assert.ok(old._retiredRoutes.has(replacement));
assert.equal(replacementTask.options.isCurrent(), false, 'cleanup failure cannot retain install authority');
assert.equal(engine.list()[0], old, 'failed cleanup remains reachable');
await flushMicrotasks();
fail = false;
const attempts = removals.filter((anno) => anno === replacement).length;
engine.clear();
assert.ok(
removals.filter((anno) => anno === replacement).length > attempts,
'the failed replacement route is retried on the next clear',
);
await refresh;
assert.equal(engine.count(), 0);
assert.equal(h.live.size, 0);
assert.equal(old._pendingReplacement, null);
replacementTask.finish();
await flushMicrotasks();
assert.equal(h.live.size, 0, 'a revoked install cannot commit after cleanup');
});
@@ -840,8 +847,10 @@ test('successful replacement survives failed old-route retirement and keeps clea
await flushMicrotasks(); h.pending[0].finish(); await initial;
const old = engine.list()[0];
const remove = h.renderer.remove;
const removals = [];
let fail = true;
h.renderer.remove = (anno) => {
removals.push(anno);
if (anno === old && fail) throw new Error('retirement failed');
remove(anno);
};
@@ -853,10 +862,14 @@ test('successful replacement survives failed old-route retirement and keeps clea
assert.equal(completed.results[0].cleanupPending, true);
assert.equal(current.label, 'New');
assert.ok(h.live.has(current.id), 'valid installed replacement is not rolled back by old teardown failure');
assert.equal(current._pendingReplacement, undefined);
assert.ok(current._retiredRoutes.has(old));
assert.equal(h.pending.filter((task) => task.options.isCurrent()).length, 0, 'no install is left pending');
fail = false;
const attempts = removals.filter((anno) => anno === old).length;
engine.clear();
assert.ok(
removals.filter((anno) => anno === old).length > attempts,
'the old route that failed to retire is retried on clear',
);
assert.equal(engine.count(), 0);
assert.equal(h.live.size, 0);
});
@@ -225,7 +225,10 @@ function fakeViewer(lon, lat) {
clampToHeightSupported: true,
clampToHeight: (world) => world,
postRender: frames.event,
requestRender() {},
renderRequests: 0,
requestRender() {
scene.renderRequests += 1;
},
};
return {
dataSources,
@@ -716,6 +719,32 @@ test('buildings share one probe per frame and preserve probe geometry, filtering
assert.equal(viewer.credits.size, 0);
});
test('a building queued behind others gets its full budget on a slow device', async (t) => {
installBrowserGlobals(t);
t.mock.timers.enable({ apis: ['setTimeout'] });
t.after(() => t.mock.timers.reset());
const { viewer, frames } = fakeViewer(-99, 31);
const renderer = createHybridAnnotationRenderer(viewer);
viewer.scene.clampToHeight = (position) => position;
const traces = [workTrace(), workTrace(), workTrace()];
const completions = [0, 0.01, 0.02].map((offset, index) =>
renderer.add(buildingAnnotation(`queued-${index}`, offset), {
pendingVisible: false,
workTrace: traces[index],
}));
// Eight probes per building at one probe per frame; 1.5 s passes while
// each of the first two buildings samples, so the third waits 3 s in line.
for (let building = 0; building < 3; building += 1) {
for (let frame = 0; frame < 8; frame += 1) frames.emit();
if (building < 2) t.mock.timers.tick(1500);
}
const results = await Promise.all(completions);
assert.deepEqual(results.map((result) => result.status), ['installed', 'installed', 'installed']);
assert.deepEqual(traces.map((trace) => trace.samplerStatus), ['installed', 'installed', 'installed']);
assert.ok(traces[2].samplerHeadAt >= traces[1].samplerHeadAt);
assert.ok(traces[2].samplerHeadAt >= traces[2].samplerStart);
});
test('building cancellation, queued deadline and destroy prevent saved callbacks from committing', async (t) => {
installBrowserGlobals(t);
t.mock.timers.enable({ apis: ['setTimeout'] });
@@ -745,10 +774,16 @@ test('building cancellation, queued deadline and destroy prevent saved callbacks
frames.emit();
assert.equal(probeCount, 1);
const savedTimeout = frames.snapshot();
// No further renderer frames arrive: both enqueue deadlines must still fire,
// including the tail task that has never had a chance to sample.
// No further renderer frames arrive: the head's deadline fires, then the
// tail gets its own budget from the moment it reaches the head and still
// times out without frames, even though it never had a chance to sample.
const rendersBefore = viewer.scene.renderRequests;
t.mock.timers.tick(2000);
assert.equal((await headCompletion).status, 'timeout');
assert.ok(viewer.scene.renderRequests > rendersBefore, 'a promoted tail asks for a frame');
assert.equal(tailTrace.samplerStatus, undefined, 'queue wait does not spend the tail budget');
assert.ok(Number.isFinite(tailTrace.samplerHeadAt), 'the tail starts its own budget at the head');
t.mock.timers.tick(2000);
const tailResult = await tailCompletion;
assert.equal(tailResult.status, 'timeout');
assert.equal(tailResult.timing.probes, 0);
+47 -3
View File
@@ -13,6 +13,7 @@ import {
buildingFromTiles,
createOpenFreeMapOutlineSource,
enclosingAreaFromTiles,
groundsClassesFor,
streetFromTiles,
} from '../sources/openFreeMapOutlines.js';
import { createNominatimOutlineClient } from '../sources/nominatimOutlines.js';
@@ -80,6 +81,46 @@ export function nominatimKindFor({ scope, groundsLike, pointLike, around }) {
return null;
}
/**
* Whether polygons contain a point. A part whose outer ring crosses the
* antimeridian ([179.99, …], [-179.99, …]) is read contiguously in its own
* longitude frame, with the point moved into that frame; every other part,
* including one genuinely wider than 180°, is tested as stored. Crossing is
* read from an edge jumping more than 180°, which assumes densely noded rings
* (OSM, Nominatim, tiles); a ring that is both wider than 180° and crossing, or
* a non-crossing ring with a single edge over 180°, is not supported.
*/
export function containsAnchor(polygons, lat, lon) {
if (!Number.isFinite(lon)) return false;
for (const part of polygons || []) {
const outer = part?.[0];
if (!Array.isArray(outer) || !outer.length) continue;
const reference = outer[0][0];
const shift = (x) => x - 360 * Math.round((x - reference) / 360);
const span = (ring, f) => {
let west = Infinity;
let east = -Infinity;
for (const [x] of ring) {
west = Math.min(west, f(x));
east = Math.max(east, f(x));
}
return east - west;
};
// An edge jumping more than 180° wraps the line; a polar ring whose
// edges run along ±180 collapses when reframed and is read as stored.
const jumps = outer.some(
([x], i) => i > 0 && Math.abs(x - outer[i - 1][0]) > 180,
);
const framedSpan = jumps ? span(outer, shift) : 0;
const crosses = framedSpan > 0 && framedSpan < 180;
const framed = crosses
? part.map((ring) => ring.map(([x, y]) => [shift(x), y]))
: part;
if (polygonsContain([framed], lat, crosses ? shift(lon) : lon)) return true;
}
return false;
}
/**
* Parts with the one holding the anchor outside its holes first. A boundary
* can be largest at sea (Tokyo's island municipalities), so size alone does
@@ -90,7 +131,7 @@ export function anchorPartFirst(polygons, points) {
for (const { lat, lon } of points) {
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
const at = polygons.findIndex((polygon) =>
polygonsContain([polygon], lat, lon),
containsAnchor([polygon], lat, lon),
);
if (at > 0) return [polygons[at], ...polygons.filter((_, i) => i !== at)];
if (at === 0) return polygons;
@@ -188,9 +229,12 @@ export function createOutlineRungs({
lat: at.lat,
lon: at.lon,
name: outlineQueryText(ctx.target || ctx.matchName),
// The kind the ask names ("… School grounds") lets unnamed tile
// landuse of that class answer; read before grounds words are cut.
unnamedClasses: groundsClassesFor(ctx.target || ctx.matchName),
minAreaM2: GROUNDS_MIN_AREA_M2,
});
if (!area || !polygonsContain(area.polygons, ctx.lat, ctx.lon)) return null;
if (!area || !containsAnchor(area.polygons, ctx.lat, ctx.lon)) return null;
return {
ring: area.ring,
polygons: area.polygons,
@@ -215,7 +259,7 @@ export function createOutlineRungs({
if (!outline?.polygons?.length) return outline; // null | undefined | unavailable | rateLimited
// Named outlines must retain the resolved place's authority,
// including holes; a nearby same-name polygon is not the selected place.
if (!polygonsContain(outline.polygons, ctx.lat, ctx.lon)) return null;
if (!containsAnchor(outline.polygons, ctx.lat, ctx.lon)) return null;
const polygons = anchorPartFirst(outline.polygons, [
{ lat: ctx.lat, lon: ctx.lon },
outline.center || {},
+100
View File
@@ -11,6 +11,7 @@ import os from 'node:os';
import path from 'node:path';
import { Readable } from 'node:stream';
import {
containsAnchor,
createOutlineRungs,
isNamedAsk,
nominatimKindFor,
@@ -324,6 +325,71 @@ test('a city with no configured Overpass is outlined by Nominatim', async () =>
assert.deepEqual(credits, ['osm']);
});
test('a Nominatim outline across the antimeridian still holds its anchor', async () => {
const ring = [
[179.9, -16.9],
[-179.9, -16.9],
[-179.9, -16.7],
[179.9, -16.7],
[179.9, -16.9],
];
const hole = [
[179.99, -16.81],
[-179.99, -16.81],
[-179.99, -16.79],
[179.99, -16.79],
[179.99, -16.81],
];
assert.ok(containsAnchor([[ring]], -16.8, 179.95));
assert.ok(
containsAnchor([[ring]], -16.8, -179.95),
'either side of the line',
);
assert.ok(!containsAnchor([[ring, hole]], -16.8, 180), 'holes still exclude');
assert.ok(!containsAnchor([[ring]], -16.8, 179.5), 'outside stays outside');
assert.ok(
!containsAnchor([[ring]], -16.8, 0),
'far side of the globe is outside',
);
const steps = [-170, -130, -90, -50, -10, 20];
const wide = [
...steps.map((x) => [x, 10]),
...steps.toReversed().map((x) => [x, 20]),
[-170, 10],
];
assert.ok(
containsAnchor([[wide]], 15, 5),
'a ring wider than 180° is read as stored',
);
const polar = [
[-180, -60],
[180, -60],
[180, -90],
[-180, -90],
[-180, -60],
];
assert.ok(
containsAnchor([[polar]], -80, 30),
'a polar ring spanning every longitude',
);
const nominatim = fakeNominatim({ polygons: [[ring]], class: 'boundary' });
const result = await createOutlineRungs({
tiles: fixtureTiles(),
nominatim,
}).resolve(
ctx({
scope: 'city',
target: 'Dateline Town',
matchName: 'Dateline Town',
lat: -16.8,
lon: 179.95,
}),
ladder(UNAVAILABLE),
);
assert.deepEqual(result.ring, ring, 'the outline is accepted, not refused');
assert.equal(result.outlineCredit, 'osm');
});
test('an empty Nominatim polygon list falls back without throwing', async () => {
const nominatim = fakeNominatim({ polygons: [], class: 'boundary' });
const result = await createOutlineRungs({
@@ -399,6 +465,40 @@ test('named outlines must contain the resolved anchor outside holes', async () =
);
});
test('an unnamed school landuse answers a school grounds ask from tiles', async () => {
const schoolRing = square(CAPITOL.lat, CAPITOL.lon, 0.002);
const tiles = {
fetchBounds: async () => ({
tiles: [
{
areas: [
{
name: null,
rings: [schoolRing],
class: 'school',
clipped: false,
},
],
},
],
}),
};
const ask = (target) =>
createOutlineRungs({ tiles, nominatim: fakeNominatim(null) }).resolve(
ctx({ scope: 'building', target, groundsLike: true }),
ladder(UNAVAILABLE),
);
const school = await ask('Lincoln High School grounds');
assert.equal(school.footprintKind, 'area');
assert.deepEqual(school.polygons, [[schoolRing]]);
assert.equal(school.outlineCredit, 'tiles');
assert.equal(
await ask('Texas Capitol grounds'),
UNAVAILABLE,
'other asks still need a name',
);
});
test('a multi-part city leads with the part holding the anchor, not the largest', async () => {
const sea = square(33.5, 139.5, 0.8); // larger, far from the anchor
const outer = square(CAPITOL.lat, CAPITOL.lon, 0.4);
+3 -2
View File
@@ -1040,11 +1040,12 @@ export function createAnnotationResolver({
// Outside a district ask, an alternate name unrelated to the name is too
// weak: Se in Sao Paulo lists "Sao Paulo" among its names, and "outline
// Sao Paulo" means the city ("Richmond" for Richmond District still counts).
// Whole words: an alias hit on "York" does not keep "Yorkville".
if (
!district &&
hood?.byAlias &&
!normalizePlaceName(hood.name).includes(
normalizePlaceName(stripQualifiers(query)),
!` ${normalizePlaceName(hood.name)} `.includes(
` ${normalizePlaceName(stripQualifiers(query))} `,
)
)
hood = null;
+27 -6
View File
@@ -60,9 +60,12 @@ export function createWorldAnnotationRenderer(viewer) {
function sampleBuilding(anno, options) {
pendingGround.get(anno)?.finish('cancelled');
return new Promise((resolve, reject) => {
const started = performance.now();
const queuedAt = performance.now();
// The 2 s budget starts when this task reaches the head of the queue, so
// waiting behind other buildings on a slow device never spends it.
let started = null;
const workTrace = options.workTrace;
if (workTrace) workTrace.samplerStart = started;
if (workTrace) workTrace.samplerStart = queuedAt;
let groundDetailSlots = 0;
let groundDetailReady = true;
if (workTrace) {
@@ -83,24 +86,42 @@ export function createWorldAnnotationRenderer(viewer) {
maxSliceMs: 0,
lastSliceMs: 0,
probes: 0,
deadlineMs: started + 2000,
queuedMs: 0,
deadlineMs: null,
};
task.current = () =>
!destroyed &&
!options.signal?.aborted &&
pendingGround.get(anno) === task &&
(options.isCurrent?.() ?? true);
task.begin = () => {
if (started !== null) return;
started = performance.now();
timing.queuedMs = started - queuedAt;
timing.deadlineMs = started + 2000;
if (workTrace) workTrace.samplerHeadAt = started;
task.timer = setTimeout(() => task.finish('timeout'), 2000);
// A task promoted outside a frame (its predecessor timed out or was
// cancelled) still needs frames in request-render mode.
try {
viewer.scene.requestRender?.();
} catch {
/* the enqueue path reports render failures */
}
};
task.finish = (status, error) => {
if (pendingGround.get(anno) !== task) return;
pendingGround.delete(anno);
groundQueue.splice(groundQueue.indexOf(task), 1);
const position = groundQueue.indexOf(task);
groundQueue.splice(position, 1);
clearTimeout(task.timer);
if (position === 0) groundQueue[0]?.begin();
options.signal?.removeEventListener('abort', task.abort);
if (!groundQueue.length && observingGround) {
viewer.scene.postRender.removeEventListener(onGroundFrame);
observingGround = false;
}
timing.elapsedMs = performance.now() - started;
timing.elapsedMs = performance.now() - (started ?? queuedAt);
if (workTrace) {
workTrace.probes = timing.probes;
workTrace.samplerStatus = status;
@@ -230,7 +251,7 @@ export function createWorldAnnotationRenderer(viewer) {
};
pendingGround.set(anno, task);
groundQueue.push(task);
task.timer = setTimeout(() => task.finish('timeout'), 2000);
if (groundQueue[0] === task) task.begin();
options.signal?.addEventListener('abort', task.abort, { once: true });
if (!task.current()) return task.finish('cancelled');
if (!observingGround) {
@@ -1 +1 @@
{"tile":"213","features":[[85798825,"Ajuda","PT","neighbourhood",[-9.20765,38.70695,-9.19281,38.71981],["Ажуда","אג'ודה","أجودا","აჟუდა","アジュダ"],"quattroshapes",[["f_cw@{{hkF?}Pha@??lLrZ?bAk@nJvMoBjLqS??lLeP??vYia@?eDsEv@wEw@wE_KcD?y\\"]],[-9.20009,38.71445]],[85798831,"Alcantara","PT","neighbourhood",[-9.18457,38.68684,-9.16809,38.71552],["Alcántara","Alcântara","Алкантара","Алькантара","אלקנטרה","আলকান্টারা","アルカンタラ","알칸타라"],"quattroshapes",[["hm~v@kfhkFpGsB?_Hja@??|PbPjF?f[qG??vEhI?v@tEoBlC?|GdP??dg@uL?oBlC?nIcPlF?lCsG??tEqG??tEmB?w@rEgDxE}F`DeHaDoL?mImL?qt@dP??yYpG??uEpG??kLqG??wEqG??kLeP??yYvL?lBkC"]],[-9.1777,38.70052]],[85798847,"Alfama","PT","neighbourhood",[-9.14062,38.68623,-9.1129,38.71445],["Αλφάμα","Алфама","الفاما","ალფამა","アルファマ","阿尔法玛","알파마"],"quattroshapes",[["niuv@ilhkFpG??lLrG??tEpG??jLbP??jBrG??kBpG??pDbA\\?pBcA??vOjI?pAc@dC`@@lLbP??xY{E?mBfLyEvG?vCiQ|m@yB^gKd@cPuAePtAeKe@wJqBwIwDoHyF}GuJ}NiFmHwF}GsJ|b@_m@?yYlZ?nBkLw@wEdDeA?mCpG?"]],[-9.1288,38.70052]],[85798859,"Alto do Pina","PT","neighbourhood",[-9.13788,38.72838,-9.12332,38.7498],["Алту-ду-Пина","Алту-ду-Піна","التو دو پینا"],"quattroshapes",[["fcvv@kckkFeP??kLcP??kLeDsEv@wEw@wEdDuE?cg@bP??kLdP??yYha@??dg@dP??bg@eP??xYcP??jLeP?"]],[-9.13101,38.74016]],[85798867,"Bairro Lopes","PT","neighbourhood",[-9.12964,38.71766,-9.11591,38.72838],["B. Lopes"],"quattroshapes",[["lmsv@}ujkFha@??mLnr@??rt@eP??jLia@??kLeP??mLcP?"]],[-9.12277,38.72409]],[85798871,"Madre de Deus","PT","neighbourhood",[-9.12689,38.72623,-9.11041,38.74016],["Bairro da Madre de Deus"],"quattroshapes",[["f|rv@_glkF@_QjGcA|L{PvE?dDtE?jLja@??vb@lBlCtL??jLuL?mBlCsGsBqGrB?~Gia@??mLeP??kLeP??{M"]],[-9.11797,38.73349]],[85798877,"Alvalade","PT","neighbourhood",[-9.1497,38.7498,-9.12415,38.77336],["Bairro de Alvalade","Алваладе"],"quattroshapes",[["nlwv@coskFlIkLzg@?nBjL|E??xYbP??jLfDtEgDtE?jLwHlJ}BhNv@vEeDtEeP??jLeP??jLia@??}P{EyGw|@??qt@jZ?|E{G?uk@"]],[-9.1365,38.76158]],[85798887,"Bairro do Alto da Ajuda","PT","neighbourhood",[-9.19556,38.70052,-9.18182,38.71338],[],"quattroshapes",[["bw`w@sehkF|u@??lLdP??dg@eP??jLeP??jLia@??kLeP??av@~JcD"]],[-9.18869,38.70802]],[85798893,"Boavista","PT","neighbourhood",[-9.21204,38.71766,-9.19445,38.74552],["Bairro da Boa Vista","Bairro da Boavista","Bairro do Alto da Boavista"],"quattroshapes",[["jpcw@usmkF?kLdP??mLtcA??|dCmZ?}EzG?|PkBhCe@tFcAj@a^?mBkLmZ??qfA}EyG|EwY?yY"]],[-9.20352,38.73196]],[85798901,"Baixa","PT","neighbourhood",[-9.14749,38.7107,-9.13513,38.72088],["Baixa Pombalina"],"quattroshapes",[["pewv@ilhkF?aSvL?lBmC?}Gz]?v@wEfI??jBjDnAEdCfC?ZjBzE??tEqG??tEsG??tEmE??pBcAZ?|GqG??jBsG??jByE?DhD}@tCmH?g@u@eD?g@s@sG??mLhC??uE"]],[-9.13993,38.71606]],[85798907,"Beato","PT","neighbourhood",[-9.11316,38.71623,-9.07295,38.73909],["Beato António","Беату"],"quattroshapes",[["bxlv@oklkF~d@cUrBuCba@?nIjLzlB??xYeP??jLcP??jLgv@?kLrGmEdQ_CfEsGrCaJjBqJpOoHvFyIxDuJnBeKf@eKg@wJoByIyDmHwFyFoHwDyIqBuJe@eKd@eKpBwJvDyIxFmHlHyF~Aq@"]],[-9.08534,38.72759]],[85798913,"Belem","PT","neighbourhood",[-9.20984,38.68838,-9.20036,38.70122],["Belemo","Belém","Santa Maria de Belém","Санта-Мария-де-Белен","Санта-Марыя-дэ-Белен","Санта-Марія-де-Белен","Բելեմ","בלן","بلم","サンタマリア・デ・ベレン","贝伦区","산타마리아드벨렝"],"quattroshapes",[["zcew@onekF?cJrGrB?nFfI?v@tEi@jGcAxA?pNv@vEw@tE?|MePhF{XaJkGbAkCwOhCqD?yPhC??aF~Ai@?kBpAiB~Ah@hC{@fCz@hC{@?mC"]],[-9.20517,38.69516]],[85798919,"Benfica","PT","neighbourhood",[-9.20765,38.7441,-9.19556,38.7555],[],"quattroshapes",[["jpcw@qepkFdPiFdPhFbPiF?rb@dDrEw@vEnBjL}EzGcPiF_KbDmIkLuE?gDuEcP??wYbP?"]],[-9.20105,38.75087]],[85798939,"Braço de Prata","PT","neighbourhood",[-9.09943,38.74766,-9.03999,38.78764],["Braco de Prata","Fábrica de Braço de Prata"],"quattroshapes",[["d~gv@_qvkFtJqBdKe@`OfApc@v|AkGtIoHvFyIxDuJpBgKd@}EU}BrNyDxIwFnHoHvFyIvDuJpBeKf@eKg@wJqByIwDmHwFyFoHwDyIqBuJe@eKd@gKpBuJvDyIxFoHlHwFxIwDvJqBdKe@|ET~BuNvDyIvFmHnHwF"],["|`kv@}frkFd@eKpBwJvDyIxFmH`UwFjQvJzSxYja@??jL~J?p@kBzF??jBzK??dApAb@[jBv@tEvL??pt@ka@?eDtEsS?uGtEqE?{SvY}}@?aDqMe@eKp@kLq@mLp@kLq@kLp@kL"]],[-9.05501,38.77377]],[85798949,"Calhariz","PT","neighbourhood",[-9.20188,38.73981,-9.19006,38.74766],["Calharis"],"quattroshapes",[["`nbw@onnkF?kLha@?fDtEtE?lIjLw@vEsS??zMcPjF}E{GmZ??yY"]],[-9.1959,38.74391]],[85798957,"Campanhã","PT","neighbourhood",[-8.5938,41.1415,-8.55261,41.17915],["Campanha","Кампаньян","坎帕尼扬"],"whosonfirst",[["`dls@gubzF|BnCcHuF}AQmb@[m@pAoMH{QrHyBgDaCvBaFyDvGuGsIe@}EoB}BEIoBuEgKgABgAxByDKwBv@Sv@mFo@i@u@~AcAoKlCyF{I[oHwCg@mDp@uEqCqDc@oKyK{CcAeEMg@s@KuFgAcBhB{MyA}CeJS~BaCXoFnGvApQtHfDnEb@pDnl@w_@gAkCRgLz@aHbDqFnIaCpHAzBmAoAeL}PmF_AgAtCuMkJyJtRcNtOwDr@hDbJrCvJkAhPOzFdFrL`G|IdHbDo@~\\z}@|MxPxKtUiOrCE~TxBzCwCl@iB`Dg@hJyLtBfEtLaFtBuB|Line truncated
{"tile":"213","features":[[85798825,"Ajuda","PT","neighbourhood",[-9.20765,38.70695,-9.19281,38.71981],["Ажуда","אג'ודה","أجودا","აჟუდა","アジュダ"],"quattroshapes",[["f_cw@{{hkF?}Pha@??lLrZ?bAk@nJvMoBjLqS??lLeP??vYia@?eDsEv@wEw@wE_KcD?y\\"]],[-9.20009,38.71445]],[85798831,"Alcantara","PT","neighbourhood",[-9.18457,38.68684,-9.16809,38.71552],["Alcántara","Alcântara","Алкантара","Алькантара","אלקנטרה","আলকান্টারা","アルカンタラ","알칸타라"],"quattroshapes",[["hm~v@kfhkFpGsB?_Hja@??|PbPjF?f[qG??vEhI?v@tEoBlC?|GdP??dg@uL?oBlC?nIcPlF?lCsG??tEqG??tEmB?w@rEgDxE}F`DeHaDoL?mImL?qt@dP??yYpG??uEpG??kLqG??wEqG??kLeP??yYvL?lBkC"]],[-9.1777,38.70052]],[85798847,"Alfama","PT","neighbourhood",[-9.14062,38.68623,-9.1129,38.71445],["Αλφάμα","Алфама","الفاما","ალფამა","アルファマ","阿尔法玛","알파마"],"quattroshapes",[["niuv@ilhkFpG??lLrG??tEpG??jLbP??jBrG??kBpG??pDbA\\?pBcA??vOjI?pAc@dC`@@lLbP??xY{E?mBfLyEvG?vCiQ|m@yB^gKd@cPuAePtAeKe@wJqBwIwDoHyF}GuJ}NiFmHwF}GsJ|b@_m@?yYlZ?nBkLw@wEdDeA?mCpG?"]],[-9.1288,38.70052]],[85798859,"Alto do Pina","PT","neighbourhood",[-9.13788,38.72838,-9.12332,38.7498],["Алту-ду-Пина","Алту-ду-Піна","التو دو پینا"],"quattroshapes",[["fcvv@kckkFeP??kLcP??kLeDsEv@wEw@wEdDuE?cg@bP??kLdP??yYha@??dg@dP??bg@eP??xYcP??jLeP?"]],[-9.13101,38.74016]],[85798867,"Bairro Lopes","PT","neighbourhood",[-9.12964,38.71766,-9.11591,38.72838],["B. Lopes"],"quattroshapes",[["lmsv@}ujkFha@??mLnr@??rt@eP??jLia@??kLeP??mLcP?"]],[-9.12277,38.72409]],[85798871,"Madre de Deus","PT","neighbourhood",[-9.12689,38.72623,-9.11041,38.74016],["Bairro da Madre de Deus"],"quattroshapes",[["f|rv@_glkF@_QjGcA|L{PvE?dDtE?jLja@??vb@lBlCtL??jLuL?mBlCsGsBqGrB?~Gia@??mLeP??kLeP??{M"]],[-9.11797,38.73349]],[85798877,"Alvalade","PT","neighbourhood",[-9.1497,38.7498,-9.12415,38.77336],["Bairro de Alvalade","Алваладе"],"quattroshapes",[["nlwv@coskFlIkLzg@?nBjL|E??xYbP??jLfDtEgDtE?jLwHlJ}BhNv@vEeDtEeP??jLeP??jLia@??}P{EyGw|@??qt@jZ?|E{G?uk@"]],[-9.1365,38.76158]],[85798887,"Bairro do Alto da Ajuda","PT","neighbourhood",[-9.19556,38.70052,-9.18182,38.71338],[],"quattroshapes",[["bw`w@sehkF|u@??lLdP??dg@eP??jLeP??jLia@??kLeP??av@~JcD"]],[-9.18869,38.70802]],[85798893,"Boavista","PT","neighbourhood",[-9.21204,38.71766,-9.19445,38.74552],["Bairro da Boa Vista","Bairro da Boavista","Bairro do Alto da Boavista"],"quattroshapes",[["jpcw@usmkF?kLdP??mLtcA??|dCmZ?}EzG?|PkBhCe@tFcAj@a^?mBkLmZ??qfA}EyG|EwY?yY"]],[-9.20352,38.73196]],[85798901,"Baixa","PT","neighbourhood",[-9.14749,38.7107,-9.13513,38.72088],["Baixa Pombalina"],"quattroshapes",[["pewv@ilhkF?aSvL?lBmC?}Gz]?v@wEfI??jBjDnAEdCfC?ZjBzE??tEqG??tEsG??tEmE??pBcAZ?|GqG??jBsG??jByE?DhD}@tCmH?g@u@eD?g@s@sG??mLhC??uE"]],[-9.13993,38.71606]],[85798907,"Beato","PT","neighbourhood",[-9.11316,38.71623,-9.07295,38.73909],["Beato António","Беату"],"quattroshapes",[["bxlv@oklkF~d@cUrBuCba@?nIjLzlB??xYeP??jLcP??jLgv@?kLrGmEdQ_CfEsGrCaJjBqJpOoHvFyIxDuJnBeKf@eKg@wJoByIyDmHwFyFoHwDyIqBuJe@eKd@eKpBwJvDyIxFmHlHyF~Aq@"]],[-9.08534,38.72759]],[85798913,"Belem","PT","neighbourhood",[-9.20984,38.68838,-9.20036,38.70122],["Belemo","Belém","Santa Maria de Belém","Санта-Мария-де-Белен","Санта-Марыя-дэ-Белен","Санта-Марія-де-Белен","Բելեմ","בלן","بلم","サンタマリア・デ・ベレン","贝伦区","산타마리아드벨렝"],"quattroshapes",[["zcew@onekF?cJrGrB?nFfI?v@tEi@jGcAxA?pNv@vEw@tE?|MePhF{XaJkGbAkCwOhCqD?yPhC??aF~Ai@?kBpAiB~Ah@hC{@fCz@hC{@?mC"]],[-9.20517,38.69516]],[85798919,"Benfica","PT","neighbourhood",[-9.20765,38.7441,-9.19556,38.7555],[],"quattroshapes",[["jpcw@qepkFdPiFdPhFbPiF?rb@dDrEw@vEnBjL}EzGcPiF_KbDmIkLuE?gDuEcP??wYbP?"]],[-9.20105,38.75087]],[85798939,"Braço de Prata","PT","neighbourhood",[-9.09943,38.74766,-9.03999,38.78764],["Braco de Prata","Fábrica de Braço de Prata"],"quattroshapes",[["d~gv@_qvkFtJqBdKe@`OfApc@v|AkGtIoHvFyIxDuJpBgKd@}EU}BrNyDxIwFnHoHvFyIvDuJpBeKf@eKg@wJqByIwDmHwFyFoHwDyIqBuJe@eKd@gKpBuJvDyIxFoHlHwFxIwDvJqBdKe@|ET~BuNvDyIvFmHnHwF"],["|`kv@}frkFd@eKpBwJvDyIxFmH`UwFjQvJzSxYja@??jL~J?p@kBzF??jBzK??dApAb@[jBv@tEvL??pt@ka@?eDtEsS?uGtEqE?{SvY}}@?aDqMe@eKp@kLq@mLp@kLq@kLp@kL"]],[-9.05501,38.77377]],[85798949,"Calhariz","PT","neighbourhood",[-9.20188,38.73981,-9.19006,38.74766],["Calharis"],"quattroshapes",[["`nbw@onnkF?kLha@?fDtEtE?lIjLw@vEsS??zMcPjF}E{GmZ??yY"]],[-9.1959,38.74391]],[85798957,"Campanhã","PT","neighbourhood",[-8.5938,41.1415,-8.55261,41.17915],["Campanha","Кампаньян","坎帕尼扬"],"whosonfirst",[["`dls@gubzF|BnCcHuF}AQmb@[m@pAoMH{QrHyBgDaCvBaFyDvGuGsIe@}EoB}BEIoBuEgKgABgAxByDKwBv@Sv@mFo@i@u@~AcAoKlCyF{I[oHwCg@mDp@uEqCqDc@oKyK{CcAeEMg@s@KuFgAcBhB{MyA}CeJS~BaCXoFnGvApQtHfDnEb@pDnl@w_@gAkCRgLz@aHbDqFnIaCpHAzBmAoAeL}PmF_AgAtCuMkJyJtRcNtOwDr@hDbJrCvJkAhPOzFdFrL`G|IdHbDo@~\\z}@|MxPxKtUiOrCE~TxBzCwCl@iB`Dg@hJyLtBfEtLaFtBuB|Line truncated
@@ -0,0 +1 @@
{"tile":"230010","features":[[85784689,"Carp","CA","neighbourhood",[-76.234,45.27179,-75.92221,45.38403],["Карп"],"quattroshapes",[["f{moMi_}sGdqA{`A|zCp}AtqAk_AjvC`}AxjBb_Al{@ze@tFnHkx@tg@nbGnqCa~@lo@cz@|n@}SmKowBd}Aw~A`oAwcB{~@iBIoBjAeB?y{@ud@yKmGgCqIijAom@yL}J}{Aqy@mdBqu@ibJbbAcR~DwJ`E_pAr}@ieAld@mKfG{{BjbBoP`HsN~Ce}@jGuXs@kNyBoL}DszCw`Bhm@}c@{hA_m@tB{Ao@_@i@^yDsBxLwIuo@}\\al@pPkWaN|ZBpHe@tv@uTh\\aOhYwPjOoNjFuHlCaPUq^iEcN_C{Bo_Awg@lqAw_Ar]`R~VcOfj@`Vbh@k\\mj@id@`b@eYrInEl]yNzd@xVbrAe{@n]zOnIAdt@~c@{[rSvkAbt@l`BwiAlOlJlLdD`d@vSbT{OrGeCjHa@lo@h@rP{EvNiGva@uJti@gTfM}@fUoHx[mNvSiSbFuArQg@"]],[-76.08177,45.32803]],[85784827,"Orléans","CA","neighbourhood",[-75.55983,45.41982,-75.44106,45.51061],["Orleans","Орлеан","אורלינס","اورلئانز","奧爾良"],"quattroshapes",[["nh~kMucttGmRVeEy@sCLaBw@mChAkFgAmGHjFq@o@q@eDX|Bg@fJMnZpAlWpCpBnAnAOz[jI|LrHvLfDzd@pQh_@dJ|QnHjh@hOgy@rkBlI`Bap@dwAcOrb@uVoH}w@~yBuf@cBg^JarErSpfAu|B}r@{MvAyCas@sMmHlOgeB{^oPiUvIuTemAsUfi@ijAwq@uLgZwDqCoFhHsEl]{EdDmDj^oItDC`OoEpT{CvEqEtPkKzNq@jFqDbA`@nIB~BmUs@mKxE{E{CiAx@iAlHoBoCeF~JiB|OmYeOoZnFaIhMlDlBhDfJtBtLtSbJbEfAOjDsF`@f@sBpFtEXn@v@pOdBxKoDgCsCyESwA_AuLyB{IHwI{B}E{DmE_IzA_@wEsCkH}Jfc@bY~D`BlDR~GfEhJ~AzMpGu@dInA~DfDvDvBj@lGw@N|ArED`AhA^wAnAKz@^aAhAz@l@`Ew@pDjAlCw@zCdCvC?bChAiAjChC|AlHBvHsBhDpAlGh@b^QKi@kF}AqGIb@kAhYzFjOjAqAPsNwAWZhRlE`IDfFyA}@e@oLg@jVd@hQhIbElDzPdGjFuDvDAzBiArHcAnBsBYo@yU}EiLBjN_AeAe@"],["jzvkMmzvtGgJuLg_@sJsFqCjFj@hYbIrQbHfJxBXkCtHpGha@fSzC`GnHx@pDjGnU~FtBbFpCv@hAeAwDqA`@k@|L|EgCpA~AN`BeAZvAj@q@xh@hMeGZmLiA|Ax@q@Xeu@aOsFmBjL`B}o@}P{MuI~BGbHtCz@Y]m@cGmCsK_@sA{@}@qHy@w@{@h@GbEgBnAkJt@aPMqVkJ}NoJPu@fBZxLtI~SjGXu@yD}AOq@|BMrAgBbDsAyEyAFu@jIXzNtDhBJ~A{@w@_BmD`@"],["t~wkMeuutGaC]C_BqHkDP}@|El@tG|Ei@|@jQjGc@r@rMlBFa@aCq@bBJtChCgKw@_a@gIuN}EgBuCf@S|HdCpAdAGjAnMlC"],["bhzkM}qttGgk@yHu^wKbtAjW|I`CInAg@\\"],["rqukMydwtG`DeDrCVbA~B{A`BiGCiE}@"],["t_|kMalttGoPgDVe@nLdBhICg@w@h@CnBr@{HvDyCWbDK"],["nwvkMibvtGnA`AeGc@q@e@"],["t`wkM_avtGKp@u@QF{@"]],[-75.50233,45.46587]],[85784831,"Outremont","CA","neighbourhood",[-73.62699,45.50406,-73.59021,45.52778],["Утремон","Утрмон","Օտրեմոն","מונטריאול/אוטרמונט","أترمون","ウトルモン","欧特蒙地区"],"quattroshapes",[["phd`MqqxtGwE}E|gDcjAfl@lr@tNrSaAgA_x@nXtFhGyJlDhEdFqJhDdQ~RmCpA{_@ic@ih@~Qq@|B_OfFwEcFgR~G{Bk@yRvH}XaX`PwIgCsCfSwEf@{G|Aa@fHP{E_FsC}@q_@L"]],[-73.60803,45.51601]],[85784837,"Pointe-aux-Trembles","CA","neighbourhood",[-73.54462,45.63287,-73.4762,45.7038],[],"quattroshapes",[["l|v_MmwtuGlgCtaAwoCbaA{wD~\\_DuDlBwCkFkEaBwFgKoKyC?j@e@c@mCbDuINaRaEsQA_HaDy@pAmCvByRy@sAnC}ApCqJXsHxCgBZeBYk@mAFg@gBvBE|BwBOoCxEiOo@E`BmBUwCtAwBX{D}AyCAsc@w@}JmAuBFcGaC}JDmOmBaQsFmPuHiIsG_Bp@iAoNsLmIwJ_EkJ}CkPqEqH_@wFmBs@mBgDBwAaAr@iD}FpA?tAtC_@yBhB@~FzAvEB~KjCtGFpH|AbDE`FlAjGJ|k@nOjDJpYjFjFFrBu@LyBdC_EzBRxCjBnAdCi@}B}DaCiDm@gAoAj@a@`Mk@fXnBd@lQpD|Orx@`~Bj@pMmGdm@v@pP"],["f}p_Msg}uGWyD`CiAlCFpLvDjTbB~@dBjC`AyAbBlAlFuJbAyCm@cDyAm@cEgApA_HoEcIoC"],["|}r_Msp|uGmBtHoAl@eM_A`IuArB_HtCC"],["tro_Mq`ruGyEkEqBwJa@kGrAoBz@pL`F|L"],["dvo_MezquG_C{A`@gAhB_@`AlC"],["|po_MwzquGY_A`ADjCfD"],["fno_My|quG{@@Ho@Y}@"]],[-73.50516,45.66877]],[85784995,"Richmond","CA","neighbourhood",[-75.97996,45.15017,-75.72446,45.29069],["Ρίτσμοντ","Ричмонд","Річмонд","ריצ'מונד","رچمنڈ","ریچموند","रिचमंड","রিচমন্ড","リッチモンド","리치먼드"],"quattroshapes",[["bzdnMeblsG`b@GbHkAxPgM|LwEfaAhi@spAzbAbhAtl@uS|O|P|Ib@]~Ax@tNwMhOfIkbAxv@kFqCwBbBvAr@cXtS`hAtk@|OyL[`XjIA`[wF|JqCtBwArBfArqCawBvwBbjAvKtBnSYxDtN`JjMfMpJz]`Se}EdnDgfAbz@qK~FohC|rBc@tBwHbGsv@rl@iEhBqbAfw@kvArbAoJvCeLRcF}GmBc@qHa@{D}AyIEg@fAiA_AcHq@BcFuAu@}LGoK|CaG}@mC~BgFcC{EQqIjDmU?}D}Bj@cB[yG}Jw@hA`ByGwAiHOiC]wDcCGmMq@eAUt@w@C]iErIqHPwAkC_IkECsCXt@fAi@ZsIb@w@]uAl@cAbDaDvBwE@qDeAsFj@s|@tn@tAdAaRlQxExDwA~B{ChA}kB}`AsCyCaFiAwc@eUqTmDwyBgp@oE@uaCyt@oKyEy}Bqr@sFMwBsKrtBwnC`tA}iCi@KpHoJlNgJlJqDzOiCtt@aF`U{D|M_GxKsIdy@{_B~uAmdB`CsIVyJaBuJ}DwHtvBtn@bEaKneAv[{A`D|fBlh@vt@pGjIo@zIoC~i@}_@|FkB`SqClG}B"]],[-75.85788,45.22831]],[85785125,"Anjou","CA","neighbourhood",[-73.60308,45.5903,-73.53589,45.6342],["Anchú","Andegawenia","Angeow","Angiò","Anjau","Anjev","Anju","Anĵuo","Anžu","Ducado de Anjou","Ανζού","Анжу","אנז'ו","آنژو","أنجو","اونژو","อ็องฌู","ანჟუ","アンジュー","安茹","앙주"],"quattroshapes",[["vry_MotiuGViBk@FbCmKzBe@m@qApXsIsCuDrCaA_BeGdY_GkD{GbJkBeE_HsFoDue@hFkCoKdAe@IoB{HkUziEmvAnhDzqB|QzI`WhHgChCxGtCeaDpeA~@~AmIn@ge@|No@oBa_Cxi@_NqVtIwB_DsDmHrB}KgRbAWz@qF`A_@cA}EhBmAs@oDcXvFiE_F`SyGwCiE"]],[-73.57365,45.61239]],[85785137,"Greely","CA","neighbourhood",[-75.69564,45.18762,-75.51753,45.31002],["格里利"],"quattroshapes",[["xwflMcujsGdmBo|Df|@|WyDvIpy@zVtEs@v_@l@wN~YjcAsW``Bpf@iOx[trAla@~g@nm@h\\rXzP~PzW`SvOhJlmDxgAyJfQqEcAaBjCubEkrAoQiAqlAs^mWrk@sc@f|@{dAm]q{AfsC}|CvsGcDnAqFmGqScImpBym@mQmIm|FklBjpB{hE|AwDc@}BpEcErLoUzE_Fg@oAbgBcpD"]],[-75.57292,45.24081]],[85864423,"Fabreville","CA","Line truncated
@@ -1 +0,0 @@
{"tile":"2300100","features":[[85784689,"Carp","CA","neighbourhood",[-76.234,45.27179,-75.92221,45.38403],["Карп"],"quattroshapes",[["f{moMi_}sGdqA{`A|zCp}AtqAk_AjvC`}AxjBb_Al{@ze@tFnHkx@tg@nbGnqCa~@lo@cz@|n@}SmKowBd}Aw~A`oAwcB{~@iBIoBjAeB?y{@ud@yKmGgCqIijAom@yL}J}{Aqy@mdBqu@ibJbbAcR~DwJ`E_pAr}@ieAld@mKfG{{BjbBoP`HsN~Ce}@jGuXs@kNyBoL}DszCw`Bhm@}c@{hA_m@tB{Ao@_@i@^yDsBxLwIuo@}\\al@pPkWaN|ZBpHe@tv@uTh\\aOhYwPjOoNjFuHlCaPUq^iEcN_C{Bo_Awg@lqAw_Ar]`R~VcOfj@`Vbh@k\\mj@id@`b@eYrInEl]yNzd@xVbrAe{@n]zOnIAdt@~c@{[rSvkAbt@l`BwiAlOlJlLdD`d@vSbT{OrGeCjHa@lo@h@rP{EvNiGva@uJti@gTfM}@fUoHx[mNvSiSbFuArQg@"]],[-76.08177,45.32803]],[85864481,"Corkery","CA","neighbourhood",[-76.1765,45.17292,-75.96985,45.3213],["Huntley"],"quattroshapes",[["fkynMgzisGx{BkbBlKgGheAmd@~oAs}@vJaEbR_EhbJcbAldBpu@|{Apy@xL|JhjAnm@fCpIxKlGx{@td@dB?nBkAhBHvcBz~@oeEfhDkbEj{C_w@jn@qoAl}@{tFxhEwDkByJgBcDwA{FeK_BeH{K}pBeH_UsLmOoJaIiyCm_Be_B_t@y]sQm[oM}bBcaAaJwImIwPcBuMP{JrWekBw@oMzNt@jPWvi@iDfOmBfJ}B"]],[-76.05726,45.24658]],[85864667,"Constance Bay","CA","neighbourhood",[-76.11378,45.4716,-76.06675,45.51143],[],"quattroshapes",[["nfioM_zstGfHqRnA{N_@_OoB}GiE{FlHwAlJEfGuBhToDbH{GfFyLhSqCxDuBvEwFxMoDfDsEzPp@fMhC~e@bDr[bH~GLxGnBhH`H_@zAe\\pRcW`R}VrLcDfFuB`@eQxJ_@`D_GaAgCpEmDdCcAxCcEh@kBdAeAjH}CsDoRmJqbApp@zp@b_@m}@fk@qr@ub@xCg@pGyDbN_c@pAeS"]],[-76.081,45.49142]],[85864673,"Dunrobin","CA","neighbourhood",[-76.11528,45.34631,-75.91448,45.49113],["Данробин"],"quattroshapes",[["nedoM{ystGhFbD~Ca@`CuB_BmCv@MlAjAA|AuB~CdDn@@f@gBhDsInCg@bDzD~CdB\\~BQnC{DfEhBEf@{BP}B~CjGvGeGhTdB|BnP`Hxf@xLzBSvAiBnB\\vDsBjF`BbEyApM|FkHxDu@lDmIrIS|AfLkIvC_GfE}@RqDbf@zYbd@r[sc@d[puAns@`kCh`BrWvHegAnv@iRhHy`B`lAcUxM}cCtaBenAou@enC`jBanBloAarAb~@gs@xc@{d@yVm]xNsIoEab@dYlj@hd@ch@j\\gj@aVgW`OytAyt@kRsDwQyGbFqDct@u`@gNtJg[eQjqCoqBo{CmcBOw@mAByHvEmIeCaJc@{CyBfFoDzNsDbCiBhc@ij@vWiWre@{o@pKmKAiAvAM~OaQ`MwJdJyOzAMVwGpB{Ev[sYzG{CxHoI`@oCzB{BM_AcDm@}DpCqCKlHqCx@oCcAg@JaBlC}AnCWrBqEdHqFt@gEpDoH|BkBl@mI|AoBhPcO|F{AxBkFrD}DtPkMxC{DhHeAfW}SvIiDfDaDdQwIbCwCzr@e`@jf@wNoFaDf@YpChCnAw@uCqBq@Nx@e@bDxBt@e@qCcCpH`F`AMRyArF}CpL]Q}@lJiElDGnE}BlDe@jFCtQzBdRlFbNiD|Ts@vPwH|JaHl@qBtKt@xH[xReJpDg@bEsDhEm@x@yAvReEpGsCvV{@xBrDtGbAnFsBpF}EnDc@tFcD"]],[-76.01077,45.41797]],[85864683,"Munster","CA","neighbourhood",[-76.05229,45.08001,-75.87256,45.23957],["Манстер"],"quattroshapes",[["p{onMck}rGd}EenDl`Adj@pW`Kx]rQd_B~s@hyCl_BnJ`IrLlOdH~TzK|pB~AdHzFdKbDvAxJfBvDjBomH~yFy}D`{C{p@fj@gFpBkB`DwcAxx@sgA~~@gfD_vAkd@mTsdC_eA{xA}rGjvAsbApbAgw@hEiBj`Awt@b@uBnhC}rBpK_G"]],[-75.957,45.15812]],[1125137919,"Fitzroy Harbour","CA","microhood",[-76.22124,45.45898,-76.20039,45.4779],[],"mz",[["z~dpM{kqtGz]vQkKbJe@jDaEn@gDvB{DfE[xBrBnFPaAhGrBeHtFSpB`AdAnFjAdJ_Bws@xl@kdAaj@tC{J|@{@nHmBtKaKrNiEzIi@bByEvEqDfBaHp\\qJ"]],[-76.21025,45.46849]],[1125223987,"Morgan's Grant","CA","neighbourhood",[-75.95165,45.3401,-75.92488,45.35835],[],"mz",[["zsmnMwoysGjV}OvQxGbPjCtx@`c@mqAx_A{sAyv@"]],[-75.93825,45.34975]],[1125978557,"West Carleton","CA","macrohood",[-76.35392,45.17293,-75.91448,45.51661],[],"mz",[["lnlpM_sgtGuMpDoAi@kB`CjEp@bBlArCvFu@~@_FxAu@zBmEvBa@vAsCbAmAjCh@~CxLjClH~GmAhWeDnNeJlTCpD`D~DLrGcAlGeElJ`AhLmAxDAlEhKhLfKbEtFxDlAnC^~FiCjL?rItJjOfp@th@`TxZlHbRrDnBjYnFnKpGg@dCcI|F{PtGaItEo@bGhDzO}FbPtFvHFrE`GnFdHxC~XvBdKfBijC|gB}qEraDcz@|n@}SmKowBd}AyzGvoFo|ErpDig@ta@qoAl}@grFvfEsA`AwEwByI{AqGkEkEyL}LwtB{CmMyK}QsOgNomBidAqx@eb@mqA}k@y]sQqWaKyiBgfAaGaGwE{H}C}JaAwMjX}rBw@oMkNyBoL}DszCu`Bhm@_d@{hA_m@tB{Ao@_@i@^yDsBxLwIuo@}\\al@pPkWaN|ZBpHe@l~@cXzn@k]tSsS`BqClCaPJaUa@oHiEcN_C{Bo_Aug@lqAy_Aux@ac@cPkCwQyGbFoDct@w`@gNtJg[eQjqCoqBo{CmcBOw@mABwHvEoIeCaJc@{CyBfFoDzNsDbCiBhc@ij@vWiWre@{o@pKmKAiAvAM~OaQ`MwJdJyOzAMVwGpB{Ev[sYzG{CzHoI^oCzB{BM_AcDk@}DnCqCKlHqCx@oCcAg@JaBlC}AnCWrBqEdHqFt@gEpDoH|BkBl@mI|AoBhPcO|F{AxBkFrD}DtPkMxC{DhHeAfW}SvIiDfDaDdQwIbCwCzr@e`@jf@wNoFaDf@YpChCpAw@wCqBq@Nx@e@bDxBt@c@qCeCpH`F`AMRyArF}CpL]Q}@lJiElDGnE}BlDe@jFCtQzBdRlFbNgD|Tu@vPwH|JaHl@oBtKr@xH[xReJpDg@dEsDfEm@x@yAvReEpGsCvV{@xBrDtGbAnFsBpF}EnDc@vFcDdLe@rATpCnCbDc@`CuBcAaDhBpAA|AuB~CdDn@@f@gBhDuGjBoBzAp@zCxFlC~BQnC{DfEhBEf@{BR{B|ChGvGuDbKaArIfSpJxf@xLzBSvAiBnB\\vDsBjF`BbEyApM|FkHxDu@lDmIrIF|AjKkIvC_GfE}@RqDmKyGxCg@pGyDbN_c@pAeSfNqQdHgRpAeO_@_OoB}G}DeG`HmAlJEfGuBhToDbH{GfFyLhSqCxDuBvEwFxMoDfDsEzPp@fMhC~e@bDdL`DlN`C~GLxGnB|F~El@zBg]rSg[~TyRtIcDfFuB`@eQxJ_@`D_GaAgCpEmDdCcAxCcEh@kBdAeAjHhDbB|CMtC}BcAuAv@i@~HEtFuB`VsQvTiMxLcKx\\wP|MiIhFeF|XsNnIaIdl@yVvUoGvKmEba@eJhFFfOfClKI|OlH`IKzPhD`Kf@r^_@vPsAlS`@n^sDjR~EzCFtGuFfZrFvEQ~KeD}Cg@sNpBg@g@zB{ArO_EbQExF{CtKmBzEwBhF?zZtCbJi@dGaBtHUlLz@fD|ArWGfQjB~J?fTtQnL[`IzAjZdAxl@pWr@v@gAxGzMrJx@zDkSdZnBpFoHrGIzG|A|FdBlAcCB_CxDgDjTf@~HxBrFjBFQnCaJrE{@fC}FzD_Cb@mNMiF|CwCjJoBIoAdAcM`AyFxEVVlHoElIa@xGsAnAqGzFqFxNR~JuCnFnAoCLwBxG{Ad@m@rF{A~BfArAyBCwIzEA~AwAPaAhBr@~AeDhC{F`AeBhAyGtGe@jDaEn@cJ~HUnEbAh@DrA|@XG_AjG`CgHfFSpB~@dApFjAdJ_B}Ai@wEx@}CEuAgAz@yC~J_Bp@VxIq@hJcEjQyDaA[Nu@bOqDpGaF\\RfEaBzF?bFmEvBHjCw@dCV{@oCnE}Az@aDdB`@q@zAtJ[TdAoHZwYb`@iNlKfEl[jZrN|o@`DXmA`FXzDkAnAM`@j@nCy@tB`BpCHn@i@PxAvDnAvDeAoBg@dAMi@oBvIaBAcAkAa@hAeBrA?\\mA`E@`@xBlBo@Line truncated
@@ -1 +0,0 @@
{"tile":"2300101","features":[[85784827,"Orléans","CA","neighbourhood",[-75.55983,45.41982,-75.44106,45.51061],["Orleans","Орлеан","אורלינס","اورلئانز","奧爾良"],"quattroshapes",[["nh~kMucttGmRVeEy@sCLaBw@mChAkFgAmGHjFq@o@q@eDX|Bg@fJMnZpAlWpCpBnAnAOz[jI|LrHvLfDzd@pQh_@dJ|QnHjh@hOgy@rkBlI`Bap@dwAcOrb@uVoH}w@~yBuf@cBg^JarErSpfAu|B}r@{MvAyCas@sMmHlOgeB{^oPiUvIuTemAsUfi@ijAwq@uLgZwDqCoFhHsEl]{EdDmDj^oItDC`OoEpT{CvEqEtPkKzNq@jFqDbA`@nIB~BmUs@mKxE{E{CiAx@iAlHoBoCeF~JiB|OmYeOoZnFaIhMlDlBhDfJtBtLtSbJbEfAOjDsF`@f@sBpFtEXn@v@pOdBxKoDgCsCyESwA_AuLyB{IHwI{B}E{DmE_IzA_@wEsCkH}Jfc@bY~D`BlDR~GfEhJ~AzMpGu@dInA~DfDvDvBj@lGw@N|ArED`AhA^wAnAKz@^aAhAz@l@`Ew@pDjAlCw@zCdCvC?bChAiAjChC|AlHBvHsBhDpAlGh@b^QKi@kF}AqGIb@kAhYzFjOjAqAPsNwAWZhRlE`IDfFyA}@e@oLg@jVd@hQhIbElDzPdGjFuDvDAzBiArHcAnBsBYo@yU}EiLBjN_AeAe@"],["jzvkMmzvtGgJuLg_@sJsFqCjFj@hYbIrQbHfJxBXkCtHpGha@fSzC`GnHx@pDjGnU~FtBbFpCv@hAeAwDqA`@k@|L|EgCpA~AN`BeAZvAj@q@xh@hMeGZmLiA|Ax@q@Xeu@aOsFmBjL`B}o@}P{MuI~BGbHtCz@Y]m@cGmCsK_@sA{@}@qHy@w@{@h@GbEgBnAkJt@aPMqVkJ}NoJPu@fBZxLtI~SjGXu@yD}AOq@|BMrAgBbDsAyEyAFu@jIXzNtDhBJ~A{@w@_BmD`@"],["t~wkMeuutGaC]C_BqHkDP}@|El@tG|Ei@|@jQjGc@r@rMlBFa@aCq@bBJtChCgKw@_a@gIuN}EgBuCf@S|HdCpAdAGjAnMlC"],["bhzkM}qttGgk@yHu^wKbtAjW|I`CInAg@\\"],["rqukMydwtG`DeDrCVbA~B{A`BiGCiE}@"],["t_|kMalttGoPgDVe@nLdBhICg@w@h@CnBr@{HvDyCWbDK"],["nwvkMibvtGnA`AeGc@q@e@"],["t`wkM_avtGKp@u@QF{@"]],[-75.50233,45.46587]],[85784831,"Outremont","CA","neighbourhood",[-73.62699,45.50406,-73.59021,45.52778],["Утремон","Утрмон","Օտրեմոն","מונטריאול/אוטרמונט","أترمون","ウトルモン","欧特蒙地区"],"quattroshapes",[["phd`MqqxtGwE}E|gDcjAfl@lr@tNrSaAgA_x@nXtFhGyJlDhEdFqJhDdQ~RmCpA{_@ic@ih@~Qq@|B_OfFwEcFgR~G{Bk@yRvH}XaX`PwIgCsCfSwEf@{G|Aa@fHP{E_FsC}@q_@L"]],[-73.60803,45.51601]],[85784837,"Pointe-aux-Trembles","CA","neighbourhood",[-73.54462,45.63287,-73.4762,45.7038],[],"quattroshapes",[["l|v_MmwtuGlgCtaAwoCbaA{wD~\\_DuDlBwCkFkEaBwFgKoKyC?j@e@c@mCbDuINaRaEsQA_HaDy@pAmCvByRy@sAnC}ApCqJXsHxCgBZeBYk@mAFg@gBvBE|BwBOoCxEiOo@E`BmBUwCtAwBX{D}AyCAsc@w@}JmAuBFcGaC}JDmOmBaQsFmPuHiIsG_Bp@iAoNsLmIwJ_EkJ}CkPqEqH_@wFmBs@mBgDBwAaAr@iD}FpA?tAtC_@yBhB@~FzAvEB~KjCtGFpH|AbDE`FlAjGJ|k@nOjDJpYjFjFFrBu@LyBdC_EzBRxCjBnAdCi@}B}DaCiDm@gAoAj@a@`Mk@fXnBd@lQpD|Orx@`~Bj@pMmGdm@v@pP"],["f}p_Msg}uGWyD`CiAlCFpLvDjTbB~@dBjC`AyAbBlAlFuJbAyCm@cDyAm@cEgApA_HoEcIoC"],["|}r_Msp|uGmBtHoAl@eM_A`IuArB_HtCC"],["tro_Mq`ruGyEkEqBwJa@kGrAoBz@pL`F|L"],["dvo_MezquG_C{A`@gAhB_@`AlC"],["|po_MwzquGY_A`ADjCfD"],["fno_My|quG{@@Ho@Y}@"]],[-73.50516,45.66877]],[85784995,"Richmond","CA","neighbourhood",[-75.97996,45.15017,-75.72446,45.29069],["Ρίτσμοντ","Ричмонд","Річмонд","ריצ'מונד","رچمنڈ","ریچموند","रिचमंड","রিচমন্ড","リッチモンド","리치먼드"],"quattroshapes",[["bzdnMeblsG`b@GbHkAxPgM|LwEfaAhi@spAzbAbhAtl@uS|O|P|Ib@]~Ax@tNwMhOfIkbAxv@kFqCwBbBvAr@cXtS`hAtk@|OyL[`XjIA`[wF|JqCtBwArBfArqCawBvwBbjAvKtBnSYxDtN`JjMfMpJz]`Se}EdnDgfAbz@qK~FohC|rBc@tBwHbGsv@rl@iEhBqbAfw@kvArbAoJvCeLRcF}GmBc@qHa@{D}AyIEg@fAiA_AcHq@BcFuAu@}LGoK|CaG}@mC~BgFcC{EQqIjDmU?}D}Bj@cB[yG}Jw@hA`ByGwAiHOiC]wDcCGmMq@eAUt@w@C]iErIqHPwAkC_IkECsCXt@fAi@ZsIb@w@]uAl@cAbDaDvBwE@qDeAsFj@s|@tn@tAdAaRlQxExDwA~B{ChA}kB}`AsCyCaFiAwc@eUqTmDwyBgp@oE@uaCyt@oKyEy}Bqr@sFMwBsKrtBwnC`tA}iCi@KpHoJlNgJlJqDzOiCtt@aF`U{D|M_GxKsIdy@{_B~uAmdB`CsIVyJaBuJ}DwHtvBtn@bEaKneAv[{A`D|fBlh@vt@pGjIo@zIoC~i@}_@|FkB`SqClG}B"]],[-75.85788,45.22831]],[85785125,"Anjou","CA","neighbourhood",[-73.60308,45.5903,-73.53589,45.6342],["Anchú","Andegawenia","Angeow","Angiò","Anjau","Anjev","Anju","Anĵuo","Anžu","Ducado de Anjou","Ανζού","Анжу","אנז'ו","آنژو","أنجو","اونژو","อ็องฌู","ანჟუ","アンジュー","安茹","앙주"],"quattroshapes",[["vry_MotiuGViBk@FbCmKzBe@m@qApXsIsCuDrCaA_BeGdY_GkD{GbJkBeE_HsFoDue@hFkCoKdAe@IoB{HkUziEmvAnhDzqB|QzI`WhHgChCxGtCeaDpeA~@~AmIn@ge@|No@oBa_Cxi@_NqVtIwB_DsDmHrB}KgRbAWz@qF`A_@cA}EhBmAs@oDcXvFiE_F`SyGwCiE"]],[-73.57365,45.61239]],[85785137,"Greely","CA","neighbourhood",[-75.69564,45.18762,-75.51753,45.31002],["格里利"],"quattroshapes",[["xwflMcujsGdmBo|Df|@|WyDvIpy@zVtEs@v_@l@wN~YjcAsW``Bpf@iOx[trAla@~g@nm@h\\rXzP~PzW`SvOhJlmDxgAyJfQqEcAaBjCubEkrAoQiAqlAs^mWrk@sc@f|@{dAm]q{AfsC}|CvsGcDnAqFmGqScImpBym@mQmIm|FklBjpB{hE|AwDc@}BpEcErLoUzE_Fg@oAbgBcpD"]],[-75.57292,45.24081]],[85864423,"Fabreville","CA","neighbourhood",[-73.87252,45.54957,-73.769,45.61083],[],"can-lvlsu",[["h`naM}wjuGtDaOxKcAxKAzI|DvXzGzKxEhHDnRsFtDBxCdA`HnGdB`FuBfX~AvLxBpEzOzIbGzFnG`KjAfRqAnMr@jEfClCfNlFhDxCfBxDtA|JfFrGfKjE`q@lH`GbCxOjMjShJb^`HxGpFlE~PlB|C|UzLd`@tL_jAffAkCs@sBlBcKcHeDk@y@t@aCqCeLlKkEeA}FlFgUaIkJtImTgTcL}BkBlBaEyA{KU{F_N~CqCmBs@rBiBiBaA\\cA`FkFaFwAyY|WyMYaRd@i]wCam@b@gFOqD{AeFc@ipA_h@dP{O{BqDn@iF{JwFcGe@_DjDiYiDg`@gT_W}IiTa@aGwBgPJcTyJ}EeK}IkC~HHpQqAtKaCld@yNbEqEhCeGpJoDzBuMfDT`StF`GJnq@_TpIsEvHaKjEuTkCeJ`BcJnGuCzCaObKeO_EsB"]],[-73.80453,45.58019]],[85864453,"Navan","CA","neighbourhood",[-75.49517,45.41Line truncated
@@ -19,7 +19,6 @@ Neighborhood boundaries from [Who's On First](https://whosonfirst.org/docs/licen
| Zolk Chicago Neighborhoods Map (`zolk`) | 207 | CC BY 3.0 US | [terms](http://chicagomap.zolk.com/about.html) |
| Azavea, Inc. (`azavea`) | 157 | CC BY 3.0 US | [terms](https://www.opendataphilly.org/dataset/philadelphia-neighborhoods) |
| City of Calgary, Corporate Analytics and Innovation (`can-calcai`) | 151 | Open Government Licence - City of Calgary | [terms](https://data.calgary.ca/stories/s/u45n-7awa) |
| Portal de datos abiertos del Ayuntamiento de Madrid (`esp-aytomad`) | 150 | CC0 (equivalent) | [terms](http://datos.madrid.es/egob/catalogo/aviso-legal) |
| City of Winnipeg Department of Planning, Property, and Development (`can-wpgppd`) | 132 | Open Government License - Winnipeg | [terms](https://data.winnipeg.ca/open-data-licence) |
| Atlanta Department of Planning and Community Development (`atldpcd`) | 130 | CC BY 4.0 | [terms](https://www.arcgis.com/home/item.html?id=716f417a1990446389ef7fd2c381d09f) |
| Turku City Government (`tkugov`) | 129 | CC BY 4.0 | [terms](http://www.lounaistieto.fi/blog/2015/08/18/turun-palvelualuejakotilastoalueet/) |
@@ -32,7 +31,6 @@ Neighborhood boundaries from [Who's On First](https://whosonfirst.org/docs/licen
| CartoBCN (`esp-cartobcn`) | 83 | CC BY | [terms](http://w133.bcn.cat/geoportal/descargas/en_gb_cond_us_carto.pdf) |
| Toronto Social Development, Finance & Administration Department (`torsdfa`) | 83 | Open Government Licence - Ontario, v1.0 | [terms](http://www1.toronto.ca/wps/portal/contentonly?vgnextoid=4a37e03bb8d1e310VgnVCM10000071d60f89RCRD) |
| Denver Department of Community Planning and Development (`denvercpd`) | 77 | CC BY 3.0 | [terms](https://www.denvergov.org/opendata/termsofuse) |
| Gatineau Service de l'urbanisme et du developpement durable (SUDD) (`can-gatsudd`) | 74 | CC BY (equivalent) | [terms](http://www3.gatineau.ca/Infoterritoire/WebInterface/help/fr/Content/1_About/1_About_FR.htm) |
| City of New Orleans, Office of Information Technology and Innovation, Enterprise Information Team (`nolagis`) | 71 | CC0 | [terms](https://data.nola.gov/Geographic-Base-Layers/Neighborhoods/92zg-wzkq) |
| Ciudad Autonoma de Buenos Aires, Iniciativa de Datos Publicos y Transparencia (`arg-caba`) | 48 | CC BY | [terms](https://data.buenosaires.gob.ar/tyc) |
| Montreal Service de la Mise en Valeur du Territoire (`can-mtlsmvt`) | 48 | CC BY 4.0 | [terms](https://creativecommons.org/licenses/by/4.0/) |
@@ -265,4 +263,4 @@ Neighborhood boundaries from [Who's On First](https://whosonfirst.org/docs/licen
## Source-specific exclusions
This derived pack excludes 36 `can-ons` records, 54 `can-saskodp` records. Original source-specific redistribution terms have not been established for these records in this derived pack. Original upstream notices remain unchanged.
Sources whose pinned registry licence is only an "equivalent" designation (for example "CC BY (equivalent)" or "CC0 (equivalent)"), including can-ons, can-saskodp, can-gatsudd and esp-aytomad, are not admitted: their redistribution terms have not been established for this derived pack. Original upstream notices remain unchanged.
@@ -12,7 +12,7 @@ DataSF pack in `../neighborhoods/` is used first inside San Francisco.
| `files.js` | every file's URL, so bundlers emit them (generated) |
| `whosonfirst-LICENSE.md`, `quattroshapes-LICENSE.md` | upstream notices, unchanged |
61,665 named neighbourhood, macrohood and microhood polygons in 218 tiles.
61,441 named neighbourhood, macrohood and microhood polygons in 217 tiles.
**Source:** [Who's On First](https://whosonfirst.org/) and contributors:
`whosonfirst-data-{neighbourhood,macrohood,microhood}-latest.tar.bz2` from
@@ -46,10 +46,13 @@ a `[lon, lat]` point inside the largest polygon. A ring is an encoded
polyline string: longitude then latitude at 10^-5 degrees, zigzag varint
deltas, 5-bit groups offset by 63.
**Source-specific exclusions:** This derived pack excludes 36 `can-ons` records, 54 `can-saskodp` records. Original source-specific redistribution terms have not been established for these records in this derived pack. Original upstream notices remain unchanged.
The original pinned records are partitioned first; exact source/ID exclusions
are then applied within those tiles, preserving every remaining tile path.
**Licence exclusions:** only sources whose exact registry licence is on the
reviewed allowlist in `scripts/wof-open-licenses.json` are admitted. A licence
listed only as an "equivalent" of an open licence ("CC BY (equivalent)",
"CC0 (equivalent)") is not admitted, so `can-ons`, `can-saskodp`,
`can-gatsudd` and `esp-aytomad` records are not in this pack: their
redistribution terms have not been established for this derived pack.
Original upstream notices remain unchanged.
**Curation:** current, non-deprecated, named polygon records; simplified to
about 15 m, snapped to 5 decimals and kept valid under GEOS (checked on the
@@ -48,8 +48,7 @@ export const TILE_URLS = {
t221031: () => new URL('./221031.json', import.meta.url),
t221033: () => new URL('./221033.json', import.meta.url),
t2211: () => new URL('./2211.json', import.meta.url),
t2300100: () => new URL('./2300100.json', import.meta.url),
t2300101: () => new URL('./2300101.json', import.meta.url),
t230010: () => new URL('./230010.json', import.meta.url),
t230011: () => new URL('./230011.json', import.meta.url),
t23111: () => new URL('./23111.json', import.meta.url),
t231130: () => new URL('./231130.json', import.meta.url),
@@ -19,11 +19,8 @@
"url": "https://raw.githubusercontent.com/whosonfirst/whosonfirst-sources/e17af153c144fe6e08aa8db2607b4bf8aecd448d/data/sources-spec-latest.json",
"sha256": "d8a5dd873d26eee11583de4438185c046dec33848d5bde27047ca4bff44d9c39"
},
"recordExclusions": {
"can-ons": 36,
"can-saskodp": 54
},
"recordExclusionReason": "Original source-specific redistribution terms have not been established for these records in this derived pack.",
"recordExclusions": {},
"recordExclusionReason": "Sources whose pinned registry licence is only an \"equivalent\" designation (for example \"CC BY (equivalent)\" or \"CC0 (equivalent)\"), including can-ons, can-saskodp, can-gatsudd and esp-aytomad, are not admitted: their redistribution terms have not been established for this derived pack.",
"license": "Per source; see sources and ATTRIBUTION.md",
"format": "neighborhood-polylines-v1",
"fields": [
@@ -108,12 +105,6 @@
"terms": "https://www.edmonton.ca/city_government/documents/Web-version2.1-OpenDataAgreement.pdf",
"records": 332
},
"can-gatsudd": {
"name": "Gatineau Service de l'urbanisme et du developpement durable (SUDD)",
"license": "CC BY (equivalent)",
"terms": "http://www3.gatineau.ca/Infoterritoire/WebInterface/help/fr/Content/1_About/1_About_FR.htm",
"records": 74
},
"can-lvlsu": {
"name": "Laval Service de l'urbanisme",
"license": "CC BY",
@@ -174,12 +165,6 @@
"terms": "https://www.denvergov.org/opendata/termsofuse",
"records": 77
},
"esp-aytomad": {
"name": "Portal de datos abiertos del Ayuntamiento de Madrid",
"license": "CC0 (equivalent)",
"terms": "http://datos.madrid.es/egob/catalogo/aviso-legal",
"records": 150
},
"esp-cartobcn": {
"name": "CartoBCN",
"license": "CC BY",
@@ -770,7 +755,7 @@
},
{
"key": "213",
"count": 445,
"count": 295,
"bbox": [
-9.8565,
37.09996,
@@ -829,20 +814,10 @@
]
},
{
"key": "2300100",
"count": 16,
"key": "230010",
"count": 375,
"bbox": [
-76.35559,
45.08001,
-75.87256,
45.51661
]
},
{
"key": "2300101",
"count": 433,
"bbox": [
-76.31305,
44.96177,
-73.33863,
45.71368
+4 -23
View File
@@ -42,6 +42,7 @@ import {
rank,
} from './adminBoundaries.js';
import { loadBundledJson } from './bundledJson.js';
import { foldPlaceName } from './placeNames.js';
import { createRetryableLoader } from './retryableLoad.js';
import * as placeFiles from './local_data/us_census_places/files.js';
import * as neighborhoodFiles from './local_data/wof_neighborhoods/files.js';
@@ -63,17 +64,6 @@ export const packLoads = {
neighborhoodTiles: [],
};
const FOLD = {
ß: 'ss',
ø: 'o',
æ: 'ae',
ł: 'l',
đ: 'd',
ı: 'i',
œ: 'oe',
þ: 'th',
};
/**
* A name as spelled: case and punctuation only. Every mark is kept, so
* "की" and "क", "Й" and "И" stay distinct.
@@ -87,20 +77,11 @@ export function exactPlaceName(value) {
}
/**
* Fold a name for matching: case, punctuation, accents on Latin letters
* only, a leading "the", "saint". Other scripts keep their marks
* ("渋谷", "Кунцево", "बांद्रा").
* Fold a name for matching (see `foldPlaceName`), with "saint" and "sainte"
* shortened the way US place names abbreviate them.
*/
export function normalizePlaceName(value) {
return String(value || '')
.toLowerCase()
.replace(/[ßøæłđıœþ]/g, (c) => FOLD[c])
.normalize('NFD')
.replace(/(\p{Script=Latin})\p{M}+/gu, '$1')
.normalize('NFC')
.replace(/[^\p{L}\p{M}\p{N}]+/gu, ' ')
.trim()
.replace(/^the /, '')
return foldPlaceName(value)
.replace(/\bsaint\b/g, 'st')
.replace(/\bsainte\b/g, 'ste');
}
+12 -17
View File
@@ -379,6 +379,7 @@ test('places pack: every state file, unique ids, sane and valid rings', () => {
});
const WOF_LICENSE_POLICY = readJson(WOF_LICENSES);
const WOF_RECORD_COUNT = 61_441;
const ALLOWED_WOF_LICENSES = new Set(WOF_LICENSE_POLICY.licenses);
const licenceAllowed = (value) =>
typeof value === 'string' && ALLOWED_WOF_LICENSES.has(value);
@@ -455,6 +456,8 @@ test('neighborhood pack ships only explicitly allowed and credited sources', ()
'mapzen',
'can-ons',
'can-saskodp',
'can-gatsudd',
'esp-aytomad',
])
assert.equal(sources[excluded], undefined, excluded);
const listed = readFileSync(new URL('files.js', WOF), 'utf8');
@@ -494,23 +497,15 @@ test('neighborhood pack ships only explicitly allowed and credited sources', ()
}
for (const [key, source] of Object.entries(sources))
assert.equal(used[key], source.records, key);
assert.deepEqual(index.meta.recordExclusions, {
'can-ons': 36,
'can-saskodp': 54,
});
const excludedIds = new Set();
for (const rule of Object.values(
WOF_LICENSE_POLICY.recordExclusions.sources,
)) {
assert.equal(rule.ids.length, rule.count);
for (const id of rule.ids) {
assert.ok(!excludedIds.has(id));
excludedIds.add(id);
assert.ok(!ids.has(id), `${id} excluded from shipped rows`);
}
}
assert.equal(excludedIds.size, 90);
assert.equal(ids.size, 61_665);
// A licence listed only as an "equivalent" of an open licence is not one.
for (const [key, source] of Object.entries(sources))
assert.doesNotMatch(source.license, /equivalent/i, key);
assert.ok(
WOF_LICENSE_POLICY.licenses.every((value) => !/equivalent/i.test(value)),
);
assert.deepEqual(index.meta.recordExclusions, {});
assert.deepEqual(WOF_LICENSE_POLICY.recordExclusions.sources, {});
assert.equal(ids.size, WOF_RECORD_COUNT);
});
test('neighborhoods resolve near the view, in any script', async () => {
+34
View File
@@ -0,0 +1,34 @@
/** Latin letters that Unicode decomposition does not fold to plain ASCII. */
const FOLD = {
ß: 'ss',
ø: 'o',
æ: 'ae',
ł: 'l',
đ: 'd',
ı: 'i',
œ: 'oe',
þ: 'th',
};
/**
* Comparable form of a place name, shared by the bundled packs, the
* OpenFreeMap street and grounds matchers and the Nominatim server: case
* folded, accents removed from Latin letters only, punctuation and symbols
* collapsed to single spaces, and a leading "the" dropped. Other scripts keep
* their marks, so "कुल मार्ग" and "काली मार्ग" stay distinct while "São Paulo",
* "sao paulo" and "SÃO PAULO!" meet.
*
* @param {unknown} value
* @returns {string}
*/
export function foldPlaceName(value) {
return String(value ?? '')
.toLowerCase()
.replace(/[ßøæłđıœþ]/g, (c) => FOLD[c])
.normalize('NFKD')
.replace(/(\p{Script=Latin})\p{M}+/gu, '$1')
.normalize('NFKC')
.replace(/[^\p{L}\p{M}\p{N}]+/gu, ' ')
.trim()
.replace(/^the /, '');
}
+1 -5
View File
@@ -12,11 +12,7 @@ import {
roadDotBudget,
} from './policy.js';
import { msg } from '../../i18n/index.js';
const nextTask = () =>
globalThis.scheduler?.yield
? globalThis.scheduler.yield()
: new Promise((resolve) => setTimeout(resolve, 0));
import { nextTask } from '../../sources/vectorTiles.js';
export function createFlow({ state: layerState, services, parts, source }) {
const { registerDynamicCredit, TOMTOM_CREDIT } = services.credits;
+36 -19
View File
@@ -14,28 +14,45 @@ export function ringAreaM2(ring) {
return Math.abs(area) / 2;
}
export function stitchLine(segments) {
const same = (a, b) => approximateDistanceM(a[1], a[0], b[1], b[0]) < 2;
/**
* Join line pieces whose ends meet (within `toleranceM`) into as few chains as
* possible. Each chain grows greedily from the first unused piece.
*/
export function stitchLines(
segments,
{ toleranceM = 2, maxChains = Infinity } = {},
) {
const same = (a, b) =>
approximateDistanceM(a[1], a[0], b[1], b[0]) < toleranceM;
const remaining = segments.map((s) => s.slice());
let line = remaining.shift();
let advanced = true;
while (advanced && remaining.length) {
advanced = false;
for (let i = 0; i < remaining.length; i++) {
const s = remaining[i];
if (same(line[line.length - 1], s[0])) line = line.concat(s.slice(1));
else if (same(line[line.length - 1], s[s.length - 1]))
line = line.concat(s.slice(0, -1).reverse());
else if (same(line[0], s[s.length - 1]))
line = s.slice(0, -1).concat(line);
else if (same(line[0], s[0])) line = s.slice(1).reverse().concat(line);
else continue;
remaining.splice(i, 1);
advanced = true;
break;
const chains = [];
while (remaining.length && chains.length < maxChains) {
let line = remaining.shift();
let advanced = true;
while (advanced && remaining.length) {
advanced = false;
for (let i = 0; i < remaining.length; i++) {
const s = remaining[i];
if (same(line[line.length - 1], s[0])) line = line.concat(s.slice(1));
else if (same(line[line.length - 1], s[s.length - 1]))
line = line.concat(s.slice(0, -1).reverse());
else if (same(line[0], s[s.length - 1]))
line = s.slice(0, -1).concat(line);
else if (same(line[0], s[0])) line = s.slice(1).reverse().concat(line);
else continue;
remaining.splice(i, 1);
advanced = true;
break;
}
}
chains.push(line);
}
return line;
return chains;
}
/** The chain grown from the first segment. */
export function stitchLine(segments) {
return stitchLines(segments, { maxChains: 1 })[0];
}
export function bufferCorridor(line, halfWidthM) {
+7 -2
View File
@@ -23,7 +23,7 @@ const ROAD_TYPES = Object.freeze({
const SHARED_DECODED_TILES = createSharedDecodedTileCache({
maxEntries: 192,
maxCacheBytes: 64 * 1024 * 1024,
maxCacheBytes: 32 * 1024 * 1024,
});
const TILE_PROJECTIONS = new Map();
const MAX_PROJECTED_NUMBERS = 500_000;
@@ -343,6 +343,10 @@ export function createOpenFreeMapSource(options = {}) {
...sourceOptions
} = options;
const shared = !decode;
// Shared consumers hold only their slice of each tile. The shared decode
// (32 MiB) plus traffic and installations (12 MiB each) and outlines
// (8 MiB) are charged 64 MiB together, the budget of the single OpenFreeMap
// source traffic and installations shared before; charges are estimates.
return createVectorTileSource({
tileJsonUrl: 'https://tiles.openfreemap.org/planet',
decode: shared
@@ -351,6 +355,7 @@ export function createOpenFreeMapSource(options = {}) {
...(shared
? {
sharedDecodedCache: sharedDecodedCache || SHARED_DECODED_TILES,
selectedSize: estimateOpenFreeMapProjectionBytes,
selectDecoded:
selectDecoded ||
(projection
@@ -362,7 +367,7 @@ export function createOpenFreeMapSource(options = {}) {
...(selectDecoded ? { selectDecoded } : {}),
}),
maxEntries: 192,
maxCacheBytes: 64 * 1024 * 1024,
maxCacheBytes: (shared ? 12 : 64) * 1024 * 1024,
...sourceOptions,
});
}
+34 -1
View File
@@ -9,7 +9,10 @@ import {
createOpenFreeMapSource,
estimateOpenFreeMapProjectionBytes,
} from './openFreeMap.js';
import { createVectorTileSource } from './vectorTiles.js';
import {
createSharedDecodedTileCache,
createVectorTileSource,
} from './vectorTiles.js';
const fixture = (name) =>
readFileSync(new URL(`../data/fixtures/${name}`, import.meta.url));
@@ -145,6 +148,36 @@ test('Camp Mabry tile emits an unnamed polygon and its centroid marker', () => {
);
});
test('a shared consumer is charged only its own slice of each tile', async () => {
const bytes = fixture('ofm-austin-14-3743-6745.pbf');
const sharedDecodedCache = createSharedDecodedTileCache({
maxEntries: 4,
maxCacheBytes: 64 * 1024 * 1024,
});
const source = createOpenFreeMapSource({
template: 'https://tiles.openfreemap.org/planet/v/test/{z}/{x}/{y}.pbf',
sharedDecodedCache,
selectDecoded: ({ military }) => ({ military }),
fetchImpl: async () => new Response(bytes),
});
try {
const box = { south: 30.267, north: 30.268, west: -97.744, east: -97.743 };
const { tiles } = await source.fetchBounds(box, { zoom: 14 });
const stats = source.getStats();
assert.equal(stats.cacheEntries, 1);
assert.equal(
stats.cacheBytes,
estimateOpenFreeMapProjectionBytes(tiles[0]),
'the slice estimate, not the whole decoded tile',
);
assert.ok(stats.cacheBytes < bytes.byteLength * 4);
assert.ok(sharedDecodedCache.stats().bytes >= bytes.byteLength * 4);
} finally {
source.clear();
sharedDecodedCache.clear();
}
});
test('TileJSON is resolved once and decoded tile LRU prevents per-pan refetch', async () => {
const calls = [];
const source = createOpenFreeMapSource({
+105 -81
View File
@@ -10,9 +10,12 @@ import {
approximateDistanceM,
ringAreaM2,
closeRing,
stitchLines,
} from './featureGeometry.js';
import { phaseTiming } from './phaseTiming.js';
import { normalizePlaceName } from '../data/placeBoundaries.js';
import { foldPlaceName } from '../data/placeNames.js';
import { pointInRing, polygonsContain } from '../data/adminBoundaries.js';
/**
* Street and building outlines from the same OpenFreeMap z14 vector tiles the
@@ -219,7 +222,8 @@ function decodePolygons(
? geometry.coordinates
: [];
// A tile building feature may aggregate unrelated structures. Validate its
// selected component independently; named grounds retain whole topology.
// selected component independently; grounds keep the feature's parts, and
// a match by class alone is narrowed to its own part when selected.
const incompleteParts = polygons.map(
(rings) =>
!Array.isArray(rings) ||
@@ -252,6 +256,7 @@ function decodePolygons(
clipped: componentLocal
? clippedParts[part]
: clippedParts.some(Boolean),
partClipped: clippedParts[part],
incomplete: componentLocal
? incompleteParts[part]
: incompleteParts.some(Boolean),
@@ -267,7 +272,7 @@ export function createOpenFreeMapOutlineSource(options = {}) {
return createOpenFreeMapSource({
projection: 'outlines',
maxEntries: 32,
maxCacheBytes: 16 * 1024 * 1024,
maxCacheBytes: 8 * 1024 * 1024,
...options,
});
}
@@ -307,18 +312,11 @@ const STREET_WORDS = Object.freeze({
const DIRECTIONS = new Set(['north', 'south', 'east', 'west']);
/** Comparable street name: Latin accents off, abbreviations expanded. */
/** Comparable street name: the shared place-name fold, abbreviations expanded. */
export function normalizeStreetName(value) {
return String(value ?? '')
.split(',')[0]
.normalize('NFKD')
.replace(/(\p{Script=Latin})\p{M}+/gu, '$1')
.normalize('NFKC')
.toLocaleLowerCase('en-US')
.replace(/[\p{P}\p{S}]+/gu, ' ')
.split(/\s+/)
return foldPlaceName(String(value ?? '').split(',')[0])
.split(' ')
.filter(Boolean)
.filter((word, index) => !(index === 0 && word === 'the'))
.map((word) => STREET_WORDS[word] || word)
.join(' ');
}
@@ -350,39 +348,6 @@ const lengthM = (line) => {
return total;
};
const near = (a, b) =>
approximateDistanceM(a[1], a[0], b[1], b[0]) <= JOIN_TOLERANCE_M;
/** Join line pieces whose ends meet into as few chains as possible. */
export function mergeLineSegments(segments) {
const pending = segments.filter((s) => s.length >= 2).map((s) => s.slice());
const chains = [];
while (pending.length) {
let chain = pending.shift();
let grew = true;
while (grew) {
grew = false;
for (let i = 0; i < pending.length; i++) {
const s = pending[i];
const head = chain[0];
const tail = chain[chain.length - 1];
if (near(tail, s[0])) chain = chain.concat(s.slice(1));
else if (near(tail, s[s.length - 1]))
chain = chain.concat(s.slice(0, -1).reverse());
else if (near(head, s[s.length - 1]))
chain = s.slice(0, -1).concat(chain);
else if (near(head, s[0])) chain = s.slice(1).reverse().concat(chain);
else continue;
pending.splice(i, 1);
grew = true;
break;
}
}
chains.push(chain);
}
return chains;
}
/**
* The asked street near a point as a bounded set of merged polylines, longest
* first, or null when no tile street carries that name.
@@ -416,7 +381,10 @@ export function streetFromTiles(
}
}
if (!pieces.length) return null;
const chains = mergeLineSegments(pieces)
const chains = stitchLines(
pieces.filter((piece) => piece.length >= 2),
{ toleranceM: JOIN_TOLERANCE_M },
)
.map((line) => ({ line, length: lengthM(line) }))
.filter((entry) => entry.length >= STREET_MIN_CHAIN_M)
.sort((a, b) => b.length - a.length)
@@ -431,28 +399,6 @@ export function streetFromTiles(
return lines.length ? { name: matchedName, lines } : null;
}
function insideRing(lon, lat, ring) {
let inside = false;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
const [xi, yi] = ring[i];
const [xj, yj] = ring[j];
if (
yi > lat !== yj > lat &&
lon < ((xj - xi) * (lat - yi)) / (yj - yi || Number.EPSILON) + xi
)
inside = !inside;
}
return inside;
}
/** Inside the outer ring and outside every hole. */
function insidePolygon(lon, lat, rings) {
if (!insideRing(lon, lat, rings[0])) return false;
for (let i = 1; i < rings.length; i++)
if (insideRing(lon, lat, rings[i])) return false;
return true;
}
/** Distance (m) from a point to the nearest edge of any ring of a polygon. */
function distanceToPolygonM(lon, lat, rings) {
return Math.min(...rings.map((ring) => distanceToLineM(lon, lat, ring)));
@@ -504,12 +450,12 @@ export function buildingFromTiles(
Number.isFinite(point[0]) &&
Number.isFinite(point[1]),
) &&
insideRing(lon, lat, outer)
pointInRing(outer, lat, lon)
)
fragmentContains = true;
continue;
}
if (insidePolygon(lon, lat, building.rings)) {
if (polygonsContain([building.rings], lat, lon)) {
// A fragment cut at a tile edge is never the whole footprint.
if (building.clipped) {
fragmentContains = true;
@@ -545,28 +491,102 @@ export function buildingFromTiles(
};
}
/** Head nouns that say what kind of grounds is meant, and the classes that answer them. */
const GROUNDS_CLASS_HEADS = Object.freeze([
[/\b(?:school|academy|elementary)$/, ['school']],
[
/\b(?:university|college)$|^(?:university|college) of\b/,
['university', 'college'],
],
[/\b(?:hospital|medical cent(?:er|re))$/, ['hospital']],
[/\b(?:cemetery|graveyard)$/, ['cemetery']],
[/\bstadium$/, ['stadium']],
[/\bzoo$/, ['zoo']],
]);
/**
* A grounds name for comparison: folded, possessive " s" dropped and grounds
* words removed wherever they stand, as the outline rung cuts them from the ask
* ("Mueller Sports Complex", "Campus Martius Park", "Lincoln School's").
*/
function groundsKey(value) {
return normalizePlaceName(value)
.replace(/ s(?= |$)/g, '')
.replace(GROUNDS_WORDS_RE, ' ')
.replace(/\s+/g, ' ')
.trim();
}
const GROUNDS_WORDS_RE =
/\b(?:grounds|campus|compound|premises|property|site|area|complex)\b/g;
/** Words that describe grounds rather than name the place. */
const GROUNDS_SUFFIX_RE =
/(?:\s+(?:grounds|campus|compound|premises|property|site|area|complex))+$/;
/**
* Area classes an unnamed tile feature may answer for a grounds ask, from the
* kind of place the ask names: its head noun once grounds words are removed
* ("Lincoln High School campus" → school, "University of Texas grounds" →
* university). "Campus" alone names no kind ("Google campus"), and a class word
* elsewhere in a name ("University Avenue Park", "Zoo Lake") does not count.
* Tile landuse for schools, hospitals and universities is usually unnamed.
*
* @param {string} text
* @returns {Set<string>}
*/
export function groundsClassesFor(text) {
// "Lincoln School's grounds" folds to "lincoln school s grounds".
const head = foldPlaceName(String(text ?? '').split(',')[0])
.replace(/ s(?= |$)/g, '')
.replace(GROUNDS_SUFFIX_RE, '');
const classes = new Set();
for (const [pattern, matches] of GROUNDS_CLASS_HEADS)
if (pattern.test(head)) for (const c of matches) classes.add(c);
return classes;
}
/**
* The open area enclosing grounds at a point: the smallest park, landuse or
* park-like cover feature that contains it and is at least grounds-sized.
* Containment alone is not identity: only a matching named feature can be an
* exact answer to the requested grounds, and holes are not filled grounds.
* Containment alone is not identity: a feature named as asked answers first;
* otherwise only an unnamed feature of a class the ask itself names (a school
* for "… School grounds") may answer. A differently named feature never does,
* and holes are not filled grounds.
*/
export function enclosingAreaFromTiles(
tiles,
{ lat, lon, name, minAreaM2 = 20_000, maxAreaM2 = 60e6 },
{
lat,
lon,
name,
unnamedClasses = null,
minAreaM2 = 20_000,
maxAreaM2 = 60e6,
},
) {
const query = normalizePlaceName(name);
const query = groundsKey(name);
if (!query) return null;
let best = null;
let named = null;
let unnamed = null;
for (const tile of tiles || []) {
for (const area of tile?.areas || []) {
const isNamed = Boolean(area.name) && groundsKey(area.name) === query;
const isClassed = !area.name && Boolean(unnamedClasses?.has(area.class));
// A class match stands for its own part, so only that part's cut counts.
const clipped = isNamed
? area.clipped
: (area.partClipped ?? area.clipped);
if (
area.clipped ||
normalizePlaceName(area.name) !== query ||
!insidePolygon(lon, lat, area.rings)
clipped ||
!(isNamed || isClassed) ||
!polygonsContain([area.rings], lat, lon)
)
continue;
const size = (area.featureParts || [area.rings]).reduce(
// Only a name ties a feature's other parts to the asked place; an
// unnamed class match stands for the part holding the point.
const parts = isNamed ? area.featureParts || [area.rings] : [area.rings];
const size = parts.reduce(
(sum, [outer, ...holes]) =>
sum +
ringAreaM2(outer) -
@@ -574,11 +594,15 @@ export function enclosingAreaFromTiles(
0,
);
if (size < minAreaM2 || size > maxAreaM2) continue;
if (!best || size < best.size) best = { area, size };
if (isNamed) {
if (!named || size < named.size) named = { area, size, parts };
} else if (!unnamed || size < unnamed.size)
unnamed = { area, size, parts };
}
}
const best = named || unnamed;
if (!best) return null;
const parts = best.area.featureParts || [best.area.rings];
const parts = best.parts;
const polygons = [
best.area.rings,
...parts.filter((part) => part !== best.area.rings),
+2 -3
View File
@@ -8,7 +8,6 @@ import {
createOpenFreeMapOutlineSource,
decodeOpenFreeMapOutlineTile,
enclosingAreaFromTiles,
mergeLineSegments,
normalizeStreetName,
streetFromTiles,
streetNameMatches,
@@ -19,7 +18,7 @@ import {
registerOpenFreeMapProjection,
} from './openFreeMap.js';
import { createSharedDecodedTileCache } from './vectorTiles.js';
import { approximateDistanceM } from './featureGeometry.js';
import { approximateDistanceM, stitchLines } from './featureGeometry.js';
const fixture = (y) =>
decodeOpenFreeMapOutlineTile(
@@ -112,7 +111,7 @@ test('merge joins reversed and out-of-order pieces and keeps gaps apart', () =>
[0.01, 0],
[0.011, 0],
]; // separate
const chains = mergeLineSegments([b, c, a]);
const chains = stitchLines([b, c, a], { toleranceM: 3 });
assert.equal(chains.length, 2);
assert.equal(chains.find((ch) => ch.length === 3).length, 3);
});
@@ -6,6 +6,7 @@ import {
buildingFromTiles,
decodeOpenFreeMapOutlineTile,
enclosingAreaFromTiles,
groundsClassesFor,
streetFromTiles,
} from './openFreeMapOutlines.js';
@@ -237,3 +238,209 @@ test('street proximity uses segments and clips to the search extent', () => {
null,
);
});
test('unnamed school, hospital and campus grounds answer only an ask of that kind', () => {
assert.deepEqual(
[...groundsClassesFor('Lincoln High School grounds')],
['school'],
);
assert.deepEqual(
[...groundsClassesFor('Lincoln High School campus')],
['school'],
);
assert.deepEqual([...groundsClassesFor('City College campus site')].sort(), [
'college',
'university',
]);
assert.deepEqual(
[...groundsClassesFor('St. Mary Medical Center')],
['hospital'],
);
assert.deepEqual([...groundsClassesFor('Texas Capitol grounds')], []);
assert.deepEqual(
[...groundsClassesFor('University of Texas grounds')].sort(),
['college', 'university'],
);
for (const name of [
'University Avenue Park grounds',
'Zoo Lake grounds',
'School Street Playground grounds',
'Academy of Sciences grounds',
'Stadium Station',
'Google campus',
'Mayo Clinic campus',
'Google campus',
'Mayo Clinic campus',
])
assert.deepEqual([...groundsClassesFor(name)], [], name);
// About 1.2 km on a side: grounds-sized.
const school = {
rings: [square(0, 0, 0.01)],
clipped: false,
class: 'school',
name: null,
};
const otherSchool = {
...school,
rings: [square(-0.001, -0.001, 0.02)],
name: 'Other Academy',
};
const park = { ...school, class: 'park' };
const at = { lat: 0.005, lon: 0.005, name: 'Lincoln High School' };
const asked = {
...at,
unnamedClasses: groundsClassesFor('Lincoln High School grounds'),
};
assert.deepEqual(
enclosingAreaFromTiles([{ areas: [school] }], asked)?.class,
'school',
);
assert.equal(
enclosingAreaFromTiles([{ areas: [school] }], at),
null,
'no class from the ask, no unnamed answer',
);
assert.equal(
enclosingAreaFromTiles([{ areas: [park] }], asked),
null,
'another class never answers',
);
assert.equal(
enclosingAreaFromTiles([{ areas: [otherSchool] }], asked),
null,
'a differently named school never answers',
);
const named = {
...school,
rings: [square(-0.002, -0.002, 0.03)],
name: 'Lincoln High School',
};
assert.equal(
enclosingAreaFromTiles([{ areas: [school, named] }], asked).name,
'Lincoln High School',
'a named match answers first',
);
assert.equal(
enclosingAreaFromTiles([{ areas: [{ ...school, clipped: true }] }], asked),
null,
'clipped unnamed grounds are not grounds',
);
});
test('an unnamed class match is only the part holding the point', () => {
// A school feature with a second, distant part: only a name could tie them.
const near = square(0, 0, 0.002); // about 49,000 m²
const far = square(0.02, 0.02, 0.002);
const tiny = square(0, 0, 0.0005); // about 3,000 m², below the floor
const ask = {
lat: 0.001,
lon: 0.001,
name: 'Lincoln High School',
unnamedClasses: groundsClassesFor('Lincoln High School grounds'),
};
// As decoded: `rings` is the same array as its entry in `featureParts`.
const area = (rings, others) => ({
rings,
featureParts: [rings, ...others],
clipped: false,
class: 'school',
name: null,
});
const found = enclosingAreaFromTiles(
[{ areas: [area([near], [[far]])] }],
ask,
);
assert.deepEqual(
found.polygons,
[[near]],
'the distant part is not included',
);
assert.equal(
enclosingAreaFromTiles(
[{ areas: [area([tiny], [[far], [square(-0.02, 0, 0.003)]])] }],
{ ...ask, lat: 0.0002, lon: 0.0002 },
),
null,
'other parts cannot lift a small part over the size floor',
);
const named = enclosingAreaFromTiles(
[
{
areas: [{ ...area([near], [[far]]), name: 'Lincoln High School' }],
},
],
ask,
);
assert.equal(named.polygons.length, 2, 'a named feature keeps its parts');
});
test('grounds names keep their own grounds words; possessives and part cuts', () => {
assert.deepEqual(
[...groundsClassesFor("Lincoln School's grounds")],
['school'],
);
const complex = {
rings: [square(0, 0, 0.002)],
clipped: false,
class: 'pitch',
name: 'Mueller Sports Complex',
};
const named = (name) => ({
rings: [square(0, 0, 0.002)],
clipped: false,
class: 'park',
name,
});
const at = { lat: 0.001, lon: 0.001 };
for (const [ask, area] of [
["Lincoln School's", 'Lincoln School'],
['Martius Park', 'Campus Martius Park'],
['Mueller Sports Field', 'Mueller Sports Complex Field'],
])
assert.equal(
enclosingAreaFromTiles([{ areas: [named(area)] }], { ...at, name: ask })
?.name,
area,
`${ask} → ${area}`,
);
assert.equal(
enclosingAreaFromTiles([{ areas: [named('Lincoln Park Zoo')] }], {
...at,
name: 'Lincoln Park',
}),
null,
'a longer, different name never answers',
);
// The ask arrives with its grounds words cut by the rung.
assert.equal(
enclosingAreaFromTiles([{ areas: [complex] }], {
lat: 0.001,
lon: 0.001,
name: 'Mueller Sports',
})?.name,
'Mueller Sports Complex',
);
// A sibling part cut at the tile edge does not refuse a class match's own part.
const school = {
rings: [square(0, 0, 0.002)],
clipped: true,
partClipped: false,
class: 'school',
name: null,
};
const ask = {
lat: 0.001,
lon: 0.001,
name: 'Lincoln High School',
unnamedClasses: groundsClassesFor('Lincoln High School grounds'),
};
assert.ok(enclosingAreaFromTiles([{ areas: [school] }], ask));
assert.equal(
enclosingAreaFromTiles(
[{ areas: [{ ...school, partClipped: true }] }],
ask,
),
null,
'its own cut part is still refused',
);
});
+1
View File
@@ -18,6 +18,7 @@ export function createWorkTrace() {
dropped: 0,
probes: 0,
samplerStart: null,
samplerHeadAt: null,
samplerStatus: null,
rendererStatus: null,
status: 'pending',
+13 -3
View File
@@ -40,8 +40,8 @@ function tileJsonOrigin(tileJsonUrl) {
const DECODER_IDS = new WeakMap();
let nextDecoderId = 1;
// Shared completion must not run every consumer's selection in one task.
const nextTask = () =>
/** Give the page a turn; shared completion must not run every consumer's selection in one task. */
export const nextTask = () =>
globalThis.scheduler?.yield
? globalThis.scheduler.yield()
: new Promise((resolve) => setTimeout(resolve, 0));
@@ -198,6 +198,7 @@ export function createVectorTileSource({
now = Date.now,
sharedDecodedCache = null,
selectDecoded = (value) => value,
selectedSize = null,
}) {
const cache = new Map();
const active = new Set();
@@ -498,7 +499,16 @@ export function createVectorTileSource({
const selected = selectDecoded(decodedEntry.value, tile);
// Shared entries stay authoritative; consumers own their nested arrays.
const value = sharedDecodedCache ? structuredClone(selected) : selected;
const size = decodedEntry.size;
// A consumer of a shared tile retains only its own slice; charge that,
// not the whole decoded tile, which the shared cache already counts.
let size = decodedEntry.size;
if (sharedDecodedCache && selectedSize) {
try {
size = selectedSize(value);
} catch {
size = decodedEntry.size;
}
}
if (epoch === generation && size <= maxCacheBytes) {
if (cache.has(key)) cacheBytes -= cache.get(key).size;
cache.delete(key);
+36 -21
View File
@@ -147,7 +147,7 @@ test('a sweep removes expired and corrupt entries, never ones it could not read'
);
});
test('processes sharing state reserve public spacing and cap atomically', async (t) => {
test('processes sharing state space public sends and cap atomically', async (t) => {
const dir = tempDir(t);
const module = new URL(
'../../server/providers/regional/nominatimGate.js',
@@ -157,12 +157,24 @@ test('processes sharing state reserve public spacing and cap atomically', async
const script = `
const { createGateStateStore } = await import(${JSON.stringify(module)});
const store = createGateStateStore({ file: ${JSON.stringify(file)} });
console.log(JSON.stringify(store.reserve('2026-09-29', {
now: 1000,
dailyCap: ${dailyCap},
minSpacingMs: 1100,
latestStartAt: 10000,
})));
const latestStartAt = Date.now() + 10000;
let sentAt = null;
for (;;) {
const result = store.dispatch(
'2026-09-29',
{ clock: Date.now, dailyCap: ${dailyCap}, minSpacingMs: 300, latestStartAt },
() => {
sentAt = Date.now();
return Promise.resolve();
},
);
if (result.status === 'wait') {
await new Promise((resolve) => setTimeout(resolve, result.waitMs));
continue;
}
console.log(JSON.stringify({ status: result.status, sentAt }));
break;
}
`;
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, [
@@ -183,19 +195,22 @@ test('processes sharing state reserve public spacing and cap atomically', async
};
const spacedFile = path.join(dir, 'spaced.json');
const reservations = await Promise.all([
const sends = await Promise.all([
run(spacedFile, 3),
run(spacedFile, 3),
run(spacedFile, 3),
]);
assert.deepEqual(
reservations.map((result) => result.status),
['reserved', 'reserved', 'reserved'],
);
assert.deepEqual(
reservations.map((result) => result.startAt).sort((a, b) => a - b),
[1000, 2100, 3200],
sends.map((result) => result.status),
['started', 'started', 'started'],
);
// Spacing is measured between actual transport invocations.
const sentAt = sends.map((result) => result.sentAt).sort((a, b) => a - b);
for (let i = 1; i < sentAt.length; i += 1)
assert.ok(
sentAt[i] - sentAt[i - 1] >= 300,
`${sentAt[i] - sentAt[i - 1]}ms apart`,
);
assert.equal(
createGateStateStore({ file: spacedFile }).count('2026-09-29'),
3,
@@ -205,7 +220,7 @@ test('processes sharing state reserve public spacing and cap atomically', async
const capped = await Promise.all([run(cappedFile, 1), run(cappedFile, 1)]);
assert.deepEqual(capped.map((result) => result.status).sort(), [
'cap',
'reserved',
'started',
]);
assert.equal(
createGateStateStore({ file: cappedFile }).count('2026-09-29'),
@@ -427,12 +442,12 @@ test('parseable but invalid public usage state fails closed', async (t) => {
);
const migrated = createGateStateStore({ file });
assert.equal(
migrated.reserve('2026-09-30', {
now: 1000,
dailyCap: 50,
minSpacingMs: 1100,
}).status,
'reserved',
migrated.dispatch(
'2026-09-30',
{ clock: () => 1000, dailyCap: 50, minSpacingMs: 1100 },
() => Promise.resolve(),
).status,
'started',
'the original state format without nextStartAt remains supported',
);
});
+31 -25
View File
@@ -91,11 +91,12 @@ function contenderScript(stateFile, lockWaitMs) {
onError: () => {},
});
process.stdout.write('ATTEMPTING\\n', () => {
const result = store.reserve('2026-10-01', {
now: 1000,
dailyCap: 50,
minSpacingMs: 1100,
});
const result = store.dispatch(
'2026-10-01',
{ clock: () => 1000, dailyCap: 50, minSpacingMs: 1100 },
() => Promise.resolve(),
);
delete result.value;
process.stdout.write('RESULT:' + JSON.stringify(result) + '\\n');
});
`;
@@ -153,16 +154,14 @@ test('a crashed lock owner cannot delete or bypass a successor lock', async (t)
successorOwner.child.stdin.end('release\n');
await successorOwner.waitFor('RELEASED');
assert.equal((await successorOwner.exited).code, 0);
const afterRelease = createGateStateStore({ file: stateFile }).reserve(
const released = createGateStateStore({ file: stateFile });
const afterRelease = released.dispatch(
'2026-10-01',
{
now: 2200,
dailyCap: 50,
minSpacingMs: 1100,
},
{ clock: () => 2200, dailyCap: 50, minSpacingMs: 1100 },
() => Promise.resolve(),
);
assert.equal(afterRelease.status, 'reserved');
assert.equal(afterRelease.count, 1);
assert.equal(afterRelease.status, 'started');
assert.equal(released.count('2026-10-01'), 1);
assert.equal(fs.statSync(lockFile).ino, originalLock.ino);
});
@@ -211,22 +210,29 @@ test('a public pause stays authoritative while another process owns the lock', a
assert.equal((await owner.exited).code, 0);
const successor = createGateStateStore({ file: stateFile });
let sent = 0;
const send = () => {
sent += 1;
return Promise.resolve();
};
assert.deepEqual(
successor.reserve('2026-10-01', {
now: 1000,
dailyCap: 50,
minSpacingMs: 1100,
}),
successor.dispatch(
'2026-10-01',
{ clock: () => 1000, dailyCap: 50, minSpacingMs: 1100 },
send,
),
{ status: 'paused', retryAfterMs: 8000 },
'a fresh process observes the pending refusal pause',
);
const resumed = successor.reserve('2026-10-01', {
now: 9001,
dailyCap: 50,
minSpacingMs: 1100,
});
assert.equal(resumed.status, 'reserved');
assert.equal(resumed.count, 2);
assert.equal(sent, 0, 'a paused dispatch never starts the transport');
const resumed = successor.dispatch(
'2026-10-01',
{ clock: () => 9001, dailyCap: 50, minSpacingMs: 1100 },
send,
);
assert.equal(resumed.status, 'started');
assert.equal(sent, 1);
assert.equal(successor.count('2026-10-01'), 2);
assert.equal(
fs.readdirSync(dir).some((name) => name.startsWith('state.json.pause.')),
false,