mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
Merge origin/main: Google Street View, KartaView and Panoramax on the slim Street Level
Main gained three imagery providers (Google Street View, KartaView,
Panoramax), shared line-coverage, cone and photo-viewer modules, one source
lit at a time and the translated interface. Their shared modules still
carried what this branch removes, so the merge ports them onto it:
- lineCoverage.js is this branch's coverage.js made provider-neutral
(draped lines near the ground only, the selection drawn over them, key
and rate-limit blocking); sequenceCones.js is sequences.js the same way.
Mapillary, KartaView and Panoramax all build on them; Mapillary moves
back to providers/mapillary/ beside the others.
- No ground casting, mesh sampling, surface modes, FOLLOW, legend, prewarm,
orbit overview points (Panoramax's density dots included), stale-tile
swaps or water-filled point budget come back.
- Kept from main because the new providers need them: requiresMapStack and
attachMapStackController (Street View on Google 3D only), ground clicks,
openNearest with an optional provider nearestImage, openImage {frame},
reportError, the view-cone marker (now an SVG glyph), filters /
coverageLabel / sinceBasis, OPEN STREET VIEW, keyAlternatives and i18n.
- photoViewer.js treats the viewer as expanded when its wrap is full screen
or the iPhone popover, as EXPAND now is.
- The fixture browser gate keeps its 8 steps and adds one each for
KartaView and Panoramax; catalogs, docs, boundaries and format scope
follow.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
commit
6390a3346f
809 files changed
+81825
-6035
No files matched your search
@@ -0,0 +1,24 @@
|
|||||||
|
# Keep keys and local state out of the build context: compose bind-mounts the
|
||||||
|
# checkout at run time, so the image never needs them.
|
||||||
|
.git
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
**/ENVIRONMENT
|
||||||
|
pinokio/ENVIRONMENT
|
||||||
|
pinokio/.installed
|
||||||
|
.gev-cache
|
||||||
|
.gev-logs
|
||||||
|
qa-artifacts
|
||||||
|
qa-shots
|
||||||
|
screenshots
|
||||||
|
output
|
||||||
|
3d-models
|
||||||
|
.gstack
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
# AppleDouble sidecars from a macOS `tar` copy; `npm test` would otherwise
|
||||||
|
# collect `._*.test.mjs` as test files.
|
||||||
|
._*
|
||||||
+23
-1
@@ -18,7 +18,8 @@
|
|||||||
# (they're used client-side) and WILL be visible in devtools. Restrict/scope them rather than trying to hide
|
# (they're used client-side) and WILL be visible in devtools. Restrict/scope them rather than trying to hide
|
||||||
# them (see SECURITY.md). All other keys below stay server-side.
|
# them (see SECURITY.md). All other keys below stay server-side.
|
||||||
|
|
||||||
# Optional: direct Google Photorealistic 3D Tiles and GEV place search.
|
# Optional: direct Google Photorealistic 3D Tiles, GEV place search and Street
|
||||||
|
# View (Street View also needs the Maps JavaScript API enabled for the key).
|
||||||
# Without it, a Cesium ion token can still load ion-hosted Google 3D; with
|
# Without it, a Cesium ion token can still load ion-hosted Google 3D; with
|
||||||
# neither credential, the app starts on keyless Esri World Imagery with OSM
|
# neither credential, the app starts on keyless Esri World Imagery with OSM
|
||||||
# available in the map tray and as the automatic provider-failure fallback.
|
# available in the map tray and as the automatic provider-failure fallback.
|
||||||
@@ -95,6 +96,13 @@ OPENAI_HUD_SUMMARY_MODEL=gpt-5-nano
|
|||||||
# spend protection set a provider-side budget: OpenAI platform -> Settings ->
|
# spend protection set a provider-side budget: OpenAI platform -> Settings ->
|
||||||
# Limits (usage limits).
|
# Limits (usage limits).
|
||||||
# GEV_RATELIMIT_OPENAI_PER_MIN=30 # the default; uncomment only to change it
|
# GEV_RATELIMIT_OPENAI_PER_MIN=30 # the default; uncomment only to change it
|
||||||
|
# Optional: exact socket addresses (comma-separated, no wildcards or prefixes)
|
||||||
|
# that Provider Settings treats as this machine in addition to loopback. Leave
|
||||||
|
# unset. The Docker launcher sets it to the container's gateway at start,
|
||||||
|
# because that is where Docker delivers your own browser from; it is safe only
|
||||||
|
# while the port is published on 127.0.0.1. Read SECURITY.md before setting it
|
||||||
|
# by hand.
|
||||||
|
# GEV_KEY_SETUP_TRUSTED_PEERS=
|
||||||
|
|
||||||
# Optional: Mapillary imagery for the Street Level layer. Free client token from
|
# Optional: Mapillary imagery for the Street Level layer. Free client token from
|
||||||
# https://www.mapillary.com/dashboard/developers (register an app, copy its
|
# https://www.mapillary.com/dashboard/developers (register an app, copy its
|
||||||
@@ -211,10 +219,24 @@ AISSTREAM_API_KEY=
|
|||||||
# CCTV_CALGARY_ENABLED=1 # set 0 to disable the Open Calgary (Alberta) cameras
|
# CCTV_CALGARY_ENABLED=1 # set 0 to disable the Open Calgary (Alberta) cameras
|
||||||
# CCTV_CALGARY_MAX_SOURCES=220 # cap cameras loaded from the Open Calgary catalog
|
# CCTV_CALGARY_MAX_SOURCES=220 # cap cameras loaded from the Open Calgary catalog
|
||||||
# CCTV_CALGARY_ROWS_URL= # override the Open Calgary catalog URL
|
# CCTV_CALGARY_ROWS_URL= # override the Open Calgary catalog URL
|
||||||
|
# CCTV_CATALONIA_ENABLED=1 # set 0 to disable the Servei Català de Trànsit cameras
|
||||||
|
# CCTV_CATALONIA_MAX_SOURCES=150 # cap cameras loaded from Catalonia (~130 exist today)
|
||||||
|
# CCTV_KINGCOUNTY_ENABLED=1 # set 0 to disable the King County (Washington) road cameras
|
||||||
|
# CCTV_KINGCOUNTY_MAX_SOURCES=125 # cap cameras loaded from the King County catalog
|
||||||
|
# CCTV_WSDOT_ENABLED=1 # set 0 to disable the WSDOT (Washington State) highway cameras
|
||||||
|
# CCTV_WSDOT_MAX_SOURCES=250 # cap cameras loaded from the WSDOT catalog, nearest Seattle/Spokane first; min 8, max 1200
|
||||||
# CCTV_VEGVESEN_ENABLED=1 # set 0 to disable the Statens vegvesen (Norway) road cameras
|
# CCTV_VEGVESEN_ENABLED=1 # set 0 to disable the Statens vegvesen (Norway) road cameras
|
||||||
# CCTV_VEGVESEN_MAX_SOURCES=900 # cap cameras loaded from Statens vegvesen (default loads all ~850 live; max 1000)
|
# CCTV_VEGVESEN_MAX_SOURCES=900 # cap cameras loaded from Statens vegvesen (default loads all ~850 live; max 1000)
|
||||||
# CCTV_VEGVESEN_VIDEO=1 # set 0 to show stills instead of live HLS for Statens vegvesen video cameras
|
# CCTV_VEGVESEN_VIDEO=1 # set 0 to show stills instead of live HLS for Statens vegvesen video cameras
|
||||||
# CCTV_VEGVESEN_URL= # override the Statens vegvesen catalog URL
|
# CCTV_VEGVESEN_URL= # override the Statens vegvesen catalog URL
|
||||||
|
# CCTV_NYC_ENABLED= # off by default; set 1 only if you hold a NYC DOT data-sharing agreement ([email protected]) for the camera feed
|
||||||
|
# CCTV_NYC_MAX_SOURCES=250 # cap cameras loaded from the NYC TMC catalog (nearest borough cores first)
|
||||||
|
# CCTV_NYC_CAMERAS_URL= # override the NYC TMC catalog URL
|
||||||
|
# CCTV_TORONTO_ENABLED=1 # set 0 to disable the City of Toronto RESCU cameras
|
||||||
|
# CCTV_TORONTO_MAX_SOURCES=336 # cap cameras loaded from Toronto RESCU (clamped to 8–500)
|
||||||
|
# CCTV_TORONTO_LIST_URL= # override the Toronto RESCU camera list URL
|
||||||
|
# CCTV_LITHUANIA_ENABLED= # off by default; set 1 to load Via Lietuva / Eismoinfo road cameras (photo licence unconfirmed; see DATA_SOURCES.md)
|
||||||
|
# CCTV_LITHUANIA_MAX_SOURCES=400 # cap Lithuanian cameras; no API key required
|
||||||
# CCTV_MAX_SOURCES=4000 # catalog-wide ceiling, shared round-robin across packs
|
# CCTV_MAX_SOURCES=4000 # catalog-wide ceiling, shared round-robin across packs
|
||||||
# CCTV_PREFER_AUSTIN=1 # prioritize Austin cameras in the registry
|
# CCTV_PREFER_AUSTIN=1 # prioritize Austin cameras in the registry
|
||||||
# CCTV_FORCE_AUSTIN= # force live default sources when a source-pack JSON is configured
|
# CCTV_FORCE_AUSTIN= # force live default sources when a source-pack JSON is configured
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ jobs:
|
|||||||
run: npm run check:boundaries
|
run: npm run check:boundaries
|
||||||
|
|
||||||
- name: Run focused onboarding tests
|
- name: Run focused onboarding tests
|
||||||
run: node --test src/setupDoctor.test.mjs src/pinokioEnvironment.test.mjs src/pinokioPreflight.test.mjs src/pinokioLauncherContract.test.mjs src/keySetupHardening.test.mjs src/mapStartup.test.mjs src/hudSummaryResponse.test.mjs
|
run: node --test src/setupDoctor.test.mjs src/pinokioEnvironment.test.mjs src/pinokioPreflight.test.mjs src/pinokioLauncherContract.test.mjs src/keySetupHardening.test.mjs src/mapStartup.test.mjs src/hudSummaryResponse.test.mjs src/codexOauthRealtime.test.mjs
|
||||||
|
|
||||||
- name: Build production bundle
|
- name: Build production bundle
|
||||||
run: npm run build
|
run: npm run build
|
||||||
@@ -25,3 +25,6 @@ qa-artifacts/
|
|||||||
3d-models/
|
3d-models/
|
||||||
pinokio/ENVIRONMENT
|
pinokio/ENVIRONMENT
|
||||||
pinokio/.installed
|
pinokio/.installed
|
||||||
|
|
||||||
|
# Transient: a release interrupted mid-assembly (npm run changelog recovers it)
|
||||||
|
changelog.d/.assembling/
|
||||||
+357
@@ -1,5 +1,194 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
- The tracked-aircraft descriptor now publishes `verticalRateMps`, on both the
|
||||||
|
civil and military layers, so a consumer reading `getTrackedInfo()` no longer
|
||||||
|
has to reach past it into the raw poll record for climb/descent. Reported,
|
||||||
|
never derived: an aircraft that has not transmitted a rate reads as `null`
|
||||||
|
rather than a level-flight `0`.
|
||||||
|
|
||||||
|
- Add nine Ontario transit feeds: TTC, MiWay, Hamilton HSR, Durham Region,
|
||||||
|
YRT/Viva, Guelph, Burlington, Barrie and Kingston, each carrying its own
|
||||||
|
licence quote and attribution. A feed can now name its own modes, so Toronto's
|
||||||
|
trams read "Streetcar" while Helsinki's stay "Tram", and a feed whose route ids
|
||||||
|
are internal keys can translate them to the number a rider would say. TTC
|
||||||
|
vehicles take their mode from the fleet number first, so replacement buses on
|
||||||
|
streetcar routes read as buses and out-of-service streetcars stay streetcars.
|
||||||
|
|
||||||
|
- CCTV cameras with identified hardware show the model's datasheet specs. When a
|
||||||
|
source names a camera model found in the vendored CCTV Camera Database records
|
||||||
|
(CC0), the HUD meta line adds its resolution, night vision and PTZ capability.
|
||||||
|
A fixed single-lens model's horizontal FOV fills in when the source states no
|
||||||
|
FOV and the pose is not curated (`FOV n° (DATASHEET)`); a PTZ or varifocal
|
||||||
|
range is what the lens can do, not its current zoom, so those cameras keep the
|
||||||
|
estimate and show `SPEC FOV min–max° (CAPABILITY)` beside it (bassem chagra,
|
||||||
|
#452).
|
||||||
|
|
||||||
|
- Ship a web app manifest and raster home-screen icons, so adding the app to a
|
||||||
|
phone home screen or installing it on desktop uses the God's Eye View mark
|
||||||
|
instead of a blank tile. The icons are generated from `public/logo.svg` by
|
||||||
|
`node scripts/generate-icons.mjs` and committed, so a plain `vite build` needs
|
||||||
|
no image toolchain. The existing SVG favicon still serves the browser tab.
|
||||||
|
Manifest paths are relative to the manifest, so a build served under a path
|
||||||
|
prefix installs and launches inside that prefix.
|
||||||
|
|
||||||
|
- Cyclone advisories no longer go dark while NHC publishes an advisory ahead
|
||||||
|
of its nominal time: advisory and position times up to 90 minutes ahead of
|
||||||
|
the server clock are accepted (previously 5 minutes), so the whole feed is
|
||||||
|
not rejected for about half an hour each advisory cycle.
|
||||||
|
|
||||||
|
- Flights age out vanished aircraft again. OpenSky's routine position-less rows
|
||||||
|
no longer mark every snapshot partial, which had kept vanished and landed
|
||||||
|
aircraft at full opacity for up to five minutes; those rows retain only their
|
||||||
|
own aircraft, and a likely-landed one still leaves on the landed limit.
|
||||||
|
|
||||||
|
- Add WSDOT highway cameras (Washington State) as a keyless CCTV source pack:
|
||||||
|
WSDOT's official Travel Center camera layer, with frames registered only on
|
||||||
|
WSDOT's own image host (partner-hosted cameras such as Oregon DOT and City of
|
||||||
|
Seattle are skipped). Cameras with a single cardinal `CompassDirection` get
|
||||||
|
a high-confidence heading; `B` (both directions), `O`, and unset directions
|
||||||
|
use the shared id-hash fallback at low confidence. By default the 250
|
||||||
|
cameras nearest Seattle and Spokane load; `CCTV_WSDOT_MAX_SOURCES` sets the
|
||||||
|
cap (8–1,200) and `CCTV_WSDOT_ENABLED=0` turns the pack off (bassem chagra,
|
||||||
|
#644).
|
||||||
|
|
||||||
|
- Refuse still-image cameras on the CCTV media relay. `/api/cctv/media` is the
|
||||||
|
live-feed route, but it also relayed any registered still camera through an
|
||||||
|
ordinary fetch that followed redirects to any host, passed through whatever
|
||||||
|
content type came back and had no cap on the body, bypassing every protection
|
||||||
|
`/api/cctv/frame` applies to the same URL. Only HLS, MP4, WebM and MJPEG
|
||||||
|
feeds are relayed now; a still camera, or an unknown id, gets a JSON 404
|
||||||
|
without an upstream request and is served only through `/api/cctv/frame`.
|
||||||
|
The client already loaded stills through `/frame`, so nothing it requests
|
||||||
|
changes.
|
||||||
|
|
||||||
|
- Add Lithuania's public Via Lietuva / Eismoinfo road cameras to CCTV, with published coordinates, on-demand snapshots, attribution and bounded catalog requests. No API key is required. The pack is opt-in (`CCTV_LITHUANIA_ENABLED=1`) because its camera endpoints are the eismoinfo.lt website backend, not a service listed in Via Lietuva's open-data statement, so the licence for the photos is unconfirmed.
|
||||||
|
|
||||||
|
- Add King County road cameras (Washington State) as a keyless CCTV source
|
||||||
|
pack: one public King County DOT ArcGIS layer, fetched with an explicit field
|
||||||
|
list so its staff-name columns are never requested, and frames pinned to the
|
||||||
|
county's own image host and upgraded to HTTPS. WSDOT-owned rows are skipped,
|
||||||
|
and city-owned cameras the county hosts credit their owner. The layer
|
||||||
|
publishes no facing, so headings use the shared id-hash fallback at low
|
||||||
|
confidence. It does publish each camera's hardware, which is served on
|
||||||
|
`/api/cctv/sources` as `model` and `manufacturer`. `CCTV_KINGCOUNTY_MAX_SOURCES`
|
||||||
|
sets the cap and `CCTV_KINGCOUNTY_ENABLED=0` turns the pack off
|
||||||
|
(bassem chagra, #645).
|
||||||
|
|
||||||
|
- Add City of Toronto RESCU traffic cameras as a keyless CCTV source pack: the
|
||||||
|
~336 municipal cameras the Ontario 511 pack does not carry — downtown
|
||||||
|
arterials plus the Gardiner, DVP, Allen and Lake Shore. The list is the
|
||||||
|
City's own open data feed; frames are built from the documented
|
||||||
|
`loc####.jpg` convention and a digits-only camera number, so the frame proxy
|
||||||
|
is pinned to the portal origin by construction. The dataset publishes no
|
||||||
|
camera facing — its `D1`-`D4` fields describe static 2016 comparison images,
|
||||||
|
not the live pan-tilt-zoom frame — so headings use the shared id-hash
|
||||||
|
fallback at low confidence and are corrected with the calibration gizmo.
|
||||||
|
Carries the required Open Government Licence – Toronto attribution. Disable
|
||||||
|
with `CCTV_TORONTO_ENABLED=0`.
|
||||||
|
|
||||||
|
- Stop one retired bikeshare feed from reporting the whole layer as failed.
|
||||||
|
City feeds are fetched independently, but failure was recorded in a single
|
||||||
|
flag that only a successful *activation* cleared — and an already-loaded city
|
||||||
|
never activates again. A dead feed re-entering camera range therefore latched
|
||||||
|
LOAD FAILED permanently over a map still drawing every other city's stations.
|
||||||
|
Failure is now tracked per city: a city that fails beside others still
|
||||||
|
drawing marks the layer DEGRADED, polls that all fail over previously drawn
|
||||||
|
points read STALE without advancing freshness, and LOAD FAILED is reported
|
||||||
|
only when nothing is left to draw. A city that recovers clears its own
|
||||||
|
failure, a failure is dropped once its city leaves range or the camera rises
|
||||||
|
above the activation altitude, and late results from superseded requests are
|
||||||
|
ignored.
|
||||||
|
|
||||||
|
Repointed the three feeds whose operators had moved: Reddy Bikeshare (Buffalo)
|
||||||
|
to Social Bicycles, Tugo (Tucson) and Trinity Metro (Fort Worth) to PBSC.
|
||||||
|
Dropped CoGo (Columbus) and Broward B-cycle (Fort Lauderdale), whose systems
|
||||||
|
have wound down — CoGo's feed reports no stations and last moved a bike in
|
||||||
|
March 2025. The three repointed feeds were verified live.
|
||||||
|
|
||||||
|
- Add seven Brazilian Bike Itaú (Tembici) cities to the GBFS bikeshare registry:
|
||||||
|
São Paulo, Rio de Janeiro, Salvador, Recife, Porto Alegre, Brasília and
|
||||||
|
Curitiba (about 1,000 stations in total). These are the layer's first stations
|
||||||
|
in South America.
|
||||||
|
|
||||||
|
- Add Bike Share Toronto (1,069 stations) to the bikeshare registry. The system
|
||||||
|
publishes the same PBSC GBFS v2 feeds as the Austin, Honolulu and Chattanooga
|
||||||
|
entries, so it needs no new plumbing; a registry test now holds every city's
|
||||||
|
feed URLs against the proxy host and path allowlists, so a city the proxy
|
||||||
|
would refuse fails the suite instead of rendering an empty layer.
|
||||||
|
|
||||||
|
- GBFS v3 station names use English translations when available, then the first
|
||||||
|
usable translation or short name. Empty or malformed names no longer appear as
|
||||||
|
`[object Object]` or interrupt station loading; legacy string names remain
|
||||||
|
supported in the globe and bike-share tools. Thanks to
|
||||||
|
[Kristóf Kékesi](https://github.com/KristofKekesi) (#61).
|
||||||
|
|
||||||
|
- Overpass and military-installation disk caches no longer serve answers from a
|
||||||
|
previously configured `OVERPASS_UPSTREAMS` instance after it is repointed.
|
||||||
|
|
||||||
|
- A Radio Browser mirror answering 204, 205 or 304, or a status outside
|
||||||
|
200–599, no longer crashes the local dev/preview server. Null-body statuses
|
||||||
|
get an empty body; anything else that cannot become a response fails that
|
||||||
|
mirror so the next one is tried.
|
||||||
|
|
||||||
|
- Snow flakes sway within a fixed range instead of drifting sideways faster the
|
||||||
|
longer the app runs; the Wind setting still scales the sway.
|
||||||
|
|
||||||
|
- The right rail ignores panels that other CSS hides, such as during an event
|
||||||
|
view, so they no longer force one-panel-at-a-time layout or collapse visible
|
||||||
|
panels. Panels the rail collapses itself are still restored.
|
||||||
|
|
||||||
|
- Coordinate searches refuse empty latitude or longitude components instead of navigating to an unintended location.
|
||||||
|
|
||||||
|
- Bing Aerial and Bing Labels request HTTPS tiles on HTTP localhost through
|
||||||
|
their ion-backed providers, retaining ion and per-tile attribution without
|
||||||
|
widening the Content Security Policy.
|
||||||
|
|
||||||
|
- Add New York City traffic cameras from the NYC DOT Traffic Management Center
|
||||||
|
as an opt-in CCTV pack. NYC DOT requires a data-sharing agreement for the
|
||||||
|
camera feed, so the pack is off unless `CCTV_NYC_ENABLED=1` is set by an
|
||||||
|
operator who holds one; otherwise no request reaches NYC DOT. NYC DOT's
|
||||||
|
requested disclaimer is in the Data attribution popover. The camera list is one keyless JSON request to
|
||||||
|
`webcams.nyctmc.org/api/cameras`; frames are stills synthesized from each
|
||||||
|
camera UUID on that same host, so a catalog field cannot steer the proxy
|
||||||
|
off-host. Offline cameras are dropped. Once enabled, the 250 cameras nearest
|
||||||
|
the five borough cores load and `CCTV_NYC_MAX_SOURCES` changes the cap. The catalog-wide ceiling is
|
||||||
|
unchanged; extra cameras share it round-robin with the other packs.
|
||||||
|
|
||||||
|
- Spanish interface (`es`), translated by @meirm (#602) and reviewed against the
|
||||||
|
current English.
|
||||||
|
|
||||||
|
- Interface localization, starting with Russian. UI text is marked with
|
||||||
|
`t()`, `tn()` (plurals) and `msg()` in code and `data-i18n*` in markup, keyed
|
||||||
|
by the English text itself; each language is one flat JSON catalog
|
||||||
|
(`src/i18n/locales/ru.json`). The language comes from `?lang=`, the language
|
||||||
|
picker (the globe button in the bottom-right corner), then the browser
|
||||||
|
language; English stays the default and the fallback. `npm run i18n`, also
|
||||||
|
run by `npm test`, fails on missing, unused or broken translations. Built on
|
||||||
|
the i18n foundation by @meirm (#601), with the Russian catalog started
|
||||||
|
from #312. It also fails on UI text that is not marked at all.
|
||||||
|
- Street Level counts unique tiles before ranking so overlapping dateline halves
|
||||||
|
in polar views do not waste slots in the 16-tile overview budget.
|
||||||
|
|
||||||
|
- Directions no longer shows a truncation notice for a route with exactly 200
|
||||||
|
turns, and a roundabout at the 200th turn keeps its exit distance.
|
||||||
|
|
||||||
|
- Weather records keep missing conditions unknown and refuse summaries without a measured temperature.
|
||||||
|
|
||||||
|
- Tool string length validation counts Unicode characters correctly, including characters outside the basic multilingual plane.
|
||||||
|
|
||||||
|
- Director timeline seeks cross the dateline along the short longitude arc.
|
||||||
|
|
||||||
|
- Keep unknown GBFS availability counts neutral instead of turning null or empty
|
||||||
|
values into zero bikes.
|
||||||
|
|
||||||
|
- Recent Imagery continues past malformed records in full catalog pages, respects the requested record limit, and reports results as truncated when that limit drops valid records.
|
||||||
|
|
||||||
|
- Regional articles retain unknown publication times when compact source dates do not identify a real calendar instant.
|
||||||
|
|
||||||
|
- Add Detroit (MoGo), Pittsburgh (POGOH), Aspen (WE-cycle) and Kona & Hilo
|
||||||
|
(HIBike) to the GBFS bikeshare registry, the layer's first stations in
|
||||||
|
Michigan.
|
||||||
|
|
||||||
- Treat explicitly current, complete empty vessel coverage as a successful update,
|
- Treat explicitly current, complete empty vessel coverage as a successful update,
|
||||||
clearing obsolete contacts while retaining stale or incomplete snapshots.
|
clearing obsolete contacts while retaining stale or incomplete snapshots.
|
||||||
|
|
||||||
@@ -95,10 +284,105 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
- Add a keyless Air quality layer to the Weather group, with Environment and
|
||||||
|
Climate Change Canada's AQHI as the first registered provider. Readings carry
|
||||||
|
`{ provider, scale, value, band }` so another network can be added without
|
||||||
|
inventing a shared numeric meaning across indices that do not share one; the
|
||||||
|
row and readout both state that coverage is Canada only. The WEATHER card
|
||||||
|
shows the band legend and the highest readings currently in view. AQHI is
|
||||||
|
reported as an integer from 1 with an open-ended "10+", so readings round to
|
||||||
|
that published form rather than the decimal precision the API returns.
|
||||||
|
Readings older than six hours are dropped, and the layer reports `stale`
|
||||||
|
through `getStats()` (also when every observation was dropped) so feed
|
||||||
|
provenance cannot describe an aged feed as live. Voice can switch it on and
|
||||||
|
query readings by value, band, station and observation time.
|
||||||
|
|
||||||
|
- Bound six upstream body reads (FIRMS CSV, CelesTrak TLE groups, adsb.lol
|
||||||
|
`/v2/mil`, TomTom flow tiles, OpenSky `states/all` and adsbdb lookups) with
|
||||||
|
streaming size caps that cancel the body at the cap or the route's deadline;
|
||||||
|
the adsb.lol military poll gains a deadline of its own. An oversized OpenSky
|
||||||
|
snapshot is not retried, and an OpenSky 429 starts its cooldown before the
|
||||||
|
body is read.
|
||||||
|
|
||||||
- Keep later satellites when a TLE catalog entry is missing an orbital line.
|
- Keep later satellites when a TLE catalog entry is missing an orbital line.
|
||||||
The parser skips the incomplete set and resumes at the next complete named
|
The parser skips the incomplete set and resumes at the next complete named
|
||||||
set, instead of losing the rest of the group (#906).
|
set, instead of losing the rest of the group (#906).
|
||||||
|
|
||||||
|
- Satellite clouds keep Clouds only / Full as quick endpoints and add a continuous
|
||||||
|
cloud-filter slider: 0% is Full, 100% is the existing brightness key, and
|
||||||
|
intermediate values blend the keyed alpha. The slider commits on release to
|
||||||
|
avoid restaging imagery for every drag event. Filter percentages round-trip in
|
||||||
|
share links; legacy mode codes retain their old endpoint meanings. Background
|
||||||
|
panel refreshes preserve the pending drag value until release. Contributed by
|
||||||
|
[NotProniss](https://github.com/NotProniss) (#1084).
|
||||||
|
|
||||||
|
- Street Level: Panoramax is the fourth provider, keyless. Light its chip
|
||||||
|
(orange; off by default, one provider at a time): below about 60 km the
|
||||||
|
federated commons' sequences draw as lines from `api.panoramax.xyz` vector tiles. A click on a
|
||||||
|
line opens that sequence's picture nearest the click without moving the
|
||||||
|
camera, flat or 360°, with cones along the sequence around it and ‹ › /
|
||||||
|
← → following the picture's own links. The 360° and since filters apply,
|
||||||
|
in the lines and in the lookup. The caption names each picture's author
|
||||||
|
and licence (CC BY-SA 4.0, Licence Ouverte 2.0…) and links to it in the
|
||||||
|
Panoramax viewer. HD originals load once, only when the viewer is wider
|
||||||
|
than the 2,048 px version (a panorama: expanded), and every download stops
|
||||||
|
when the photo changes; an image whose header declares more than 64 MP is
|
||||||
|
refused before it decodes. Share links carry it as
|
||||||
|
`0.m.0_0.r.google-0*kartaview-0*panoramax-1` (`0.m.0_0.r.panoramax-1` works
|
||||||
|
too). KartaView's photo viewer
|
||||||
|
is now shared (`photoViewer.js`, `panorama.js`); in it, for KartaView too,
|
||||||
|
Esc clears the selected drive before it collapses the panel. Lines are
|
||||||
|
simplified and drawn within a point budget (a tile's unsimplified lines
|
||||||
|
over Paris hung the tab), decoded in slices so the globe keeps moving,
|
||||||
|
and kept; the cones' tiles have their own connection, and the open
|
||||||
|
picture's tile draws its cones before its neighbours arrive.
|
||||||
|
|
||||||
|
- Flights and Military Flights highlight emergency transponder broadcasts in
|
||||||
|
fleet billboards, 3D models and Cockpit contacts. Colors update when a status
|
||||||
|
starts or clears. Emergency squawks outrank ADS-B priority statuses; GEV
|
||||||
|
reports minimum fuel and lifeguard without red tint. Cockpit names the code
|
||||||
|
or ADS-B status, and analyst/voice results retain broadcast provenance and
|
||||||
|
the latest known contact time. One omitted ADS-B status poll during a source
|
||||||
|
switch may retain the previous status if the squawk is unchanged; an explicit
|
||||||
|
clear takes effect immediately. Broadcasts are not confirmed incidents.
|
||||||
|
Contributed by [Leo Pechnicki](https://github.com/leopechnicki) (#856).
|
||||||
|
|
||||||
|
- Street Level: KartaView is the third provider, keyless. Light its chip
|
||||||
|
(purple; off by default, one provider at a time) and zoom in below about
|
||||||
|
6 km: the lines are the streets KartaView has photos of (z13–14 tiles from
|
||||||
|
`api.kartaview.org`). A click on a line opens the photo nearest the click
|
||||||
|
without moving the camera, flat or 360° (a small WebGL panorama: drag to
|
||||||
|
look, wheel to zoom), with cones along its drive and ‹ › / ← → to step
|
||||||
|
through it. The caption links to the full-size photo (KartaView's photo
|
||||||
|
pages fail for signed-out visitors), and the globe credits "© KartaView
|
||||||
|
contributors, CC BY-SA 4.0". Share links carry it in the provider
|
||||||
|
switches (`0.m.0_0.r.google-0*kartaview-1`). Archived photos (KartaView
|
||||||
|
answers 409) are skipped by the click and by ‹ ›. Shift+← →, ↑ ↓, + − and
|
||||||
|
pinch look around a 360° photo. The open photo's marker is a purple view
|
||||||
|
cone pointing along the drive (KartaView's own compass headings are often
|
||||||
|
0 or far off). The IMAGERY (ALL / 360° / FLAT) and SINCE filters apply to
|
||||||
|
KartaView as to Mapillary: its lines are filtered by KartaView's own
|
||||||
|
server (its filtered PNG tiles, as a mask over the JSON lines), and the
|
||||||
|
photo a click opens, and the cones, by each photo's projection and upload
|
||||||
|
date. KartaView's SINCE is the upload date (all its server can filter by);
|
||||||
|
a note under the slider says so while KartaView is lit (`sinceBasis:
|
||||||
|
'upload'` in the provider contract).
|
||||||
|
Mapillary's line coverage and sequence cones are now shared by both
|
||||||
|
(`lineCoverage.js`, `sequenceCones.js`).
|
||||||
|
|
||||||
|
- Street Level: Google Street View is the second provider. On the Google 3D
|
||||||
|
map (Google's terms keep it off other maps), light its chip (off by
|
||||||
|
default: every panorama is billed to the Google Maps key, which needs the
|
||||||
|
Maps JavaScript API) and click a street, or press OPEN STREET VIEW, to
|
||||||
|
open Google's own panorama in
|
||||||
|
the panel, with its arrows, the address and month in the caption, a link
|
||||||
|
to Google Maps. Share links carry it in the provider switches
|
||||||
|
(`r.google-1`). One provider is lit at a time (lighting one switches the
|
||||||
|
other off), Mapillary by default since it is free. Street View's panoramas
|
||||||
|
are all 360°, so the IMAGERY and SINCE filters apply to
|
||||||
|
Mapillary only and hide while GOOGLE STREET VIEW is lit; its globe marker
|
||||||
|
is a Google-blue view cone.
|
||||||
|
|
||||||
- Add an experimental ChatGPT/Codex OAuth option for cloud voice in Provider
|
- Add an experimental ChatGPT/Codex OAuth option for cloud voice in Provider
|
||||||
Settings. API-key voice remains the default. Local sign-in can start from
|
Settings. API-key voice remains the default. Local sign-in can start from
|
||||||
the auth button and reports completion, failure, timeout, or expired
|
the auth button and reports completion, failure, timeout, or expired
|
||||||
@@ -111,6 +395,21 @@
|
|||||||
[TheSmokeDev](https://github.com/TheSmokeDev) for the credential handling
|
[TheSmokeDev](https://github.com/TheSmokeDev) for the credential handling
|
||||||
from #653.
|
from #653.
|
||||||
|
|
||||||
|
- **Docker install path.** `docker compose up` builds on CI's Node 24.14.0 image,
|
||||||
|
pinned by digest, and serves the app on `http://localhost:4173` with no local
|
||||||
|
Node install. The repo is bind-mounted and the container runs as the host user
|
||||||
|
(never root), so `.env`,
|
||||||
|
`.gev-cache`, and `.gev-logs` persist on the host exactly as they do under
|
||||||
|
`npm run dev` — Provider Settings included. The launcher
|
||||||
|
(`scripts/docker-start.mjs`) trusts the container's gateway address for that
|
||||||
|
panel, since that is where Docker delivers the host's own browser from; the
|
||||||
|
opt-in behind it, `GEV_KEY_SETUP_TRUSTED_PEERS`, takes exact addresses only
|
||||||
|
and is unset (so loopback-only, unchanged) under every other launcher.
|
||||||
|
Thanks to [jklaze](https://github.com/jklaze) for finding and diagnosing the
|
||||||
|
container-to-host 403, and to [14traits](https://github.com/14traits),
|
||||||
|
[PoBruno](https://github.com/PoBruno) and [milu70hub](https://github.com/milu70hub)
|
||||||
|
for testing on Apple silicon, Docker Engine on Linux, and NAS hosts.
|
||||||
|
|
||||||
- Stdio servers from one install share a panel key so a page read from one
|
- Stdio servers from one install share a panel key so a page read from one
|
||||||
process can make requests through another. Concurrent malformed-key repairs
|
process can make requests through another. Concurrent malformed-key repairs
|
||||||
now select one winner; unavailable storage or a busy repair retains the
|
now select one winner; unavailable storage or a busy repair retains the
|
||||||
@@ -130,6 +429,17 @@
|
|||||||
load per IP and upstream. The panel reads KEY REQUIRED without a token and
|
load per IP and upstream. The panel reads KEY REQUIRED without a token and
|
||||||
KEY REJECTED when Mapillary refuses it.
|
KEY REJECTED when Mapillary refuses it.
|
||||||
|
|
||||||
|
- Add Norway's city bikes to the Bike Share layer: Oslo, Bergen and
|
||||||
|
Trondheim Bysykkel (Urban Sharing), Kolumbus Bysykkel in Stavanger and
|
||||||
|
Farte Bysykkel in Skien/Porsgrunn (Entur Mobility), and Bysykkel
|
||||||
|
Lillestrøm (JCDecaux), about 760 stations in all. Every feed is keyless
|
||||||
|
GBFS. `gbfs.urbansharing.com`, `api.entur.io` and `api.cyclocity.fr` join
|
||||||
|
the proxy allowlist; the last two are kept to their GBFS paths. The proxy
|
||||||
|
now sends `Client-Identifier: gods-eye-view` on every request and
|
||||||
|
`ET-Client-Name: gods-eye-view-bikeshare` to Entur, as the operators ask,
|
||||||
|
and accepts Entur's extensionless feed paths. Attributed under NLOD and
|
||||||
|
Licence Ouverte. `get_bike_share` covers the six systems too.
|
||||||
|
|
||||||
- Add Norway's road cameras from Statens vegvesen as a CCTV pack. The camera
|
- Add Norway's road cameras from Statens vegvesen as a CCTV pack. The camera
|
||||||
list is one keyless GeoJSON request to the agency's OGC API view of its
|
list is one keyless GeoJSON request to the agency's OGC API view of its
|
||||||
DATEX 3.1 CCTV table, so no DATEX account is needed. Every working camera
|
DATEX 3.1 CCTV table, so no DATEX account is needed. Every working camera
|
||||||
@@ -141,6 +451,53 @@
|
|||||||
largest cities) and `CCTV_VEGVESEN_VIDEO=0` keeps stills only. Attributed
|
largest cities) and `CCTV_VEGVESEN_VIDEO=0` keeps stills only. Attributed
|
||||||
under NLOD 2.0.
|
under NLOD 2.0.
|
||||||
|
|
||||||
|
- Add Catalonia (Servei Català de Trànsit) as a keyless CCTV source pack: ~130
|
||||||
|
cameras from a WFS/GML feed that aggregates SCT's own highway cameras with
|
||||||
|
hotlinked Barcelona and Terrassa municipal cameras, each partner named as a
|
||||||
|
credit beside the shared provider. Ambient-card refresh paced to 3 minutes,
|
||||||
|
matching the empirically observed upstream cadence. `CCTV_CATALONIA_MAX_SOURCES`
|
||||||
|
sets the cap and `CCTV_CATALONIA_ENABLED=0` turns the pack off. The catalog
|
||||||
|
is fetched over https with redirects refused and a 1 MiB body cap, and frame
|
||||||
|
links carrying userinfo or an explicit port, or outside the publisher's frame
|
||||||
|
directory, are rejected even on an allowlisted host. Camera ids come from
|
||||||
|
each frame's SCT camera file or image path rather than the feed's
|
||||||
|
restart-dependent feature id, and rows the feed repeats collapse to one.
|
||||||
|
|
||||||
|
- Import your own GeoJSON, KML, KMZ and GPX files from DISPLAY or by dropping
|
||||||
|
them onto the app. Show, hide, fly to or remove each file for the session.
|
||||||
|
GeoJSON/GPX draw on the ground; KML retains its styles and explicit heights.
|
||||||
|
Large repeated-style exports use shared local pins, while recorded track
|
||||||
|
vertices and segment gaps stay intact. Imports respect newer camera gestures,
|
||||||
|
leave the shared clock alone, and report malformed or oversized files.
|
||||||
|
File bytes stay on the device; rendered overlays may appear in shared map
|
||||||
|
screenshots. Thanks to [Chase Mills](https://github.com/kapturner) (#925).
|
||||||
|
|
||||||
|
- GNSS Integrity (Events, voice-addressable, share token `3`) draws navigation
|
||||||
|
accuracy scores from a keyless regional adsb.lol sample; aircraft-level analyst
|
||||||
|
queries are unavailable for these aggregate cells. Cells do not intercept
|
||||||
|
map clicks. Pan/zoom refreshes automatically; feed failures retain the display
|
||||||
|
and explain the next retry. Aircraft-reported quality includes MLAT-positioned
|
||||||
|
contacts after GPS loss, excludes TIS-B/MLAT-derived quality, and locates GPS-loss
|
||||||
|
evidence at the last good GNSS position when available. Scores discount one
|
||||||
|
bad aircraft per cell and retain 30 minutes of visited-view evidence; they
|
||||||
|
do not confirm jamming. See [method and limits](DATA_SOURCES.md#gnss-integrity).
|
||||||
|
Contributed by [Leo Pechnicki](https://github.com/leopechnicki) (#855).
|
||||||
|
|
||||||
|
- Live HLS cameras play on a page opened over plain HTTP from another machine
|
||||||
|
(`HOST=0.0.0.0`). The client lease id came from `crypto.randomUUID`, which
|
||||||
|
browsers only expose in secure contexts, so starting a live feed threw
|
||||||
|
before playback. The id now falls back to a v4 UUID built from
|
||||||
|
`crypto.getRandomValues`, which the server's lease check accepts.
|
||||||
|
|
||||||
|
- Move Google geocoding behind the local server. Forward and reverse lookups now
|
||||||
|
go through `/api/google/geocode` and `/api/google/reverse-geocode` with the
|
||||||
|
server key, joining Places and Street View. Google's Geocoding web service
|
||||||
|
refuses referrer-restricted keys, so the previous browser call left
|
||||||
|
`SECURITY.md`'s advice and working search mutually exclusive: the bundled
|
||||||
|
browser key had to be unrestricted for search to work at all. That key now
|
||||||
|
needs only the Map Tiles API and the Maps JavaScript API (Street View). Keyless sessions and the Photon/Nominatim
|
||||||
|
fallback order are unchanged.
|
||||||
|
|
||||||
## [0.2.1] — 2026-10-02 — God's Eye View through Your Agent (with security fixes and other features)
|
## [0.2.1] — 2026-10-02 — God's Eye View through Your Agent (with security fixes and other features)
|
||||||
|
|
||||||
- Ship each bundled data pack once. The region, marine, admin-boundary,
|
- Ship each bundled data pack once. The region, marine, admin-boundary,
|
||||||
|
|||||||
+8
-7
@@ -54,8 +54,8 @@ head -40 scripts/qa-radio.mjs # what this runnable gate proves and how to run i
|
|||||||
Runnable gate entrypoints document what they assert and how to invoke them.
|
Runnable gate entrypoints document what they assert and how to invoke them.
|
||||||
Most need a dev server; some also need a specific provider key or port. Follow
|
Most need a dev server; some also need a specific provider key or port. Follow
|
||||||
the entrypoint's header rather than assuming every matching file accepts the
|
the entrypoint's header rather than assuming every matching file accepts the
|
||||||
same arguments. Only a couple (`qa:map-source-tray`, `qa:transit`) have an
|
same arguments. Four have an `npm run` alias: `qa:map-source-tray`,
|
||||||
`npm run` alias.
|
`qa:street-level`, `qa:street-level:fixtures` and `qa:transit`.
|
||||||
|
|
||||||
If you aren't sure which gate covers your change, search `docs/CURRENT-STATE.md`
|
If you aren't sure which gate covers your change, search `docs/CURRENT-STATE.md`
|
||||||
for the feature: it names the gate for many of them, and it's the authoritative
|
for the feature: it names the gate for many of them, and it's the authoritative
|
||||||
@@ -63,7 +63,7 @@ runtime reference either way. `scripts/qa-l9-matrix.mjs` aggregates the broader
|
|||||||
release-candidate checks and selected harnesses; it does not replace the focused
|
release-candidate checks and selected harnesses; it does not replace the focused
|
||||||
gate for the feature you changed.
|
gate for the feature you changed.
|
||||||
|
|
||||||
> **CI does not cover this for you.** The workflow runs the setup policy
|
> **CI covers only two of these for you.** The workflow runs the setup policy
|
||||||
> checks, the formatting and package-boundary checks, the unit suite and the
|
> checks, the formatting and package-boundary checks, the unit suite and the
|
||||||
> production build, plus a Windows onboarding job. It runs neither
|
> production build, plus a Windows onboarding job. It runs neither
|
||||||
> `npm run test:track` nor any `qa-*.mjs` gate except the Street Level fixture
|
> `npm run test:track` nor any `qa-*.mjs` gate except the Street Level fixture
|
||||||
@@ -79,8 +79,8 @@ if its layer is later removed.
|
|||||||
|
|
||||||
New layers first use the remaining unreserved single-character digits (`0`
|
New layers first use the remaining unreserved single-character digits (`0`
|
||||||
through `9`), then the two-character base-36 namespace (`00` through `zz`). On the
|
through `9`), then the two-character base-36 namespace (`00` through `zz`). On the
|
||||||
current published ledger, `0`, `1` and `2` are already owned, so the next
|
current published ledger, `0`, `1`, `2` and `3` are already owned, so the next
|
||||||
choices are `3` through `9`, then `00`. After rebasing onto the latest `main`, run
|
choices are `4` through `9`, then `00`. After rebasing onto the latest `main`, run
|
||||||
`npm run layer-token:next -- <layer-id>`, add that result to both the permanent
|
`npm run layer-token:next -- <layer-id>`, add that result to both the permanent
|
||||||
JSON reservation ledger and registry entry, and keep the exact registered-layer
|
JSON reservation ledger and registry entry, and keep the exact registered-layer
|
||||||
count assertion up to date. The token is assigned against the merge-time base, not
|
count assertion up to date. The token is assigned against the merge-time base, not
|
||||||
@@ -98,7 +98,7 @@ remove only your still-unpublished layer's registry entry and ledger row so the
|
|||||||
local registry is valid again, then rerun `npm run layer-token:next -- <layer-id>`.
|
local registry is valid again, then rerun `npm run layer-token:next -- <layer-id>`.
|
||||||
For example, if PR A publishes `0` while PR B still provisionally uses `0`,
|
For example, if PR A publishes `0` while PR B still provisionally uses `0`,
|
||||||
PR B removes only its own provisional entry, reruns the helper, and receives
|
PR B removes only its own provisional entry, reruns the helper, and receives
|
||||||
`3`. Put `3` on PR B's registry entry and ledger row, update its pinned count
|
`4`. Put `4` on PR B's registry entry and ledger row, update its pinned count
|
||||||
and token tests, then rerun `npm run layer-token:check -- --base-ref origin/main`.
|
and token tests, then rerun `npm run layer-token:check -- --base-ref origin/main`.
|
||||||
An invalid duplicate ledger can fail during module import before the helper
|
An invalid duplicate ledger can fail during module import before the helper
|
||||||
prints anything; never fix that by renaming or deleting PR A's published token.
|
prints anything; never fix that by renaming or deleting PR A's published token.
|
||||||
@@ -126,6 +126,7 @@ The highest-leverage places to jump in:
|
|||||||
- ES modules, **2-space indent, single quotes, semicolons.**
|
- ES modules, **2-space indent, single quotes, semicolons.**
|
||||||
- JSDoc on exported/public functions.
|
- JSDoc on exported/public functions.
|
||||||
- Match the surrounding code — comment density, naming, and idiom.
|
- Match the surrounding code — comment density, naming, and idiom.
|
||||||
|
- User-visible text goes through `t()`, `tn()` or `msg()`, or a `data-i18n*` attribute in markup, and `npm run i18n` must pass. See [docs/I18N.md](docs/I18N.md).
|
||||||
- Prefer small, reviewable commits. Conventional-commit-style prefixes (`feat:`, `fix:`, `perf:`, `docs:`) are appreciated but not required.
|
- Prefer small, reviewable commits. Conventional-commit-style prefixes (`feat:`, `fix:`, `perf:`, `docs:`) are appreciated but not required.
|
||||||
|
|
||||||
## Formatting and reusable components
|
## Formatting and reusable components
|
||||||
@@ -149,7 +150,7 @@ ownership and adoption process.
|
|||||||
|
|
||||||
1. Branch off `main`.
|
1. Branch off `main`.
|
||||||
2. Keep `npm run build`, `npm test`, and `npm run test:track` green and avoid new console errors, plus the [feature gate](#feature-regression-gates) for the area you touched.
|
2. Keep `npm run build`, `npm test`, and `npm run test:track` green and avoid new console errors, plus the [feature gate](#feature-regression-gates) for the area you touched.
|
||||||
3. If you change runtime behavior, update `docs/CURRENT-STATE.md` and `CHANGELOG.md` in the same PR.
|
3. If you change runtime behavior, update `docs/CURRENT-STATE.md` in the same PR, and add your changelog entry as `changelog.d/<pr-number>-<slug>.md` instead of editing `CHANGELOG.md` — see [changelog.d/README.md](changelog.d/README.md). Distinct filenames don't conflict; a release assembles the directory into `CHANGELOG.md`. A documentation claim that needs a regression test should assert against `docs/CURRENT-STATE.md` or `README.md`, which a PR still edits directly.
|
||||||
4. If you add or change a data source, update [DATA_SOURCES.md](DATA_SOURCES.md) with its license and attribution. **Don't add data you don't have the right to redistribute** — fetch it at runtime instead.
|
4. If you add or change a data source, update [DATA_SOURCES.md](DATA_SOURCES.md) with its license and attribution. **Don't add data you don't have the right to redistribute** — fetch it at runtime instead.
|
||||||
5. Describe what you changed and how you verified it (screenshots welcome for anything visual).
|
5. Describe what you changed and how you verified it (screenshots welcome for anything visual).
|
||||||
|
|
||||||
|
|||||||
+93
-4
@@ -20,11 +20,14 @@ How to read this:
|
|||||||
| **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 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 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" |
|
| **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 layer is on |
|
| **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 |
|
| **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 |
|
| **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 |
|
| **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 | ODbL 1.0 | adsb.lol contributors; `api.adsb.lol/v2/lat/{lat}/lon/{lon}/dist/{radius}` |
|
| **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) |
|
| **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 |
|
| **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) |
|
| **AISStream.io** | Live vessels (AIS) | Free, beta, no formal ToS; AIS is a public broadcast | "AISStream.io" (courtesy) |
|
||||||
@@ -32,6 +35,7 @@ How to read this:
|
|||||||
| **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) |
|
| **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) |
|
| **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" |
|
| **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/) |
|
| **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)" |
|
| **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)" |
|
||||||
| **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)" |
|
| **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)" |
|
||||||
@@ -55,8 +59,22 @@ How to read this:
|
|||||||
| **Fintraffic / Digitraffic (weathercams)** | CCTV camera catalog + frames, Finland | [CC BY 4.0](https://www.digitraffic.fi/en/terms-of-service/) — attribution REQUIRED | "Fintraffic / digitraffic.fi, license CC BY 4.0" |
|
| **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" |
|
| **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" |
|
| **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) |
|
||||||
| **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" |
|
| **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" |
|
||||||
| **GBFS (Lyft / BCycle)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle) + its `license_url` |
|
| **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) |
|
| **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) |
|
| **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) |
|
| **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) |
|
||||||
@@ -64,6 +82,15 @@ How to read this:
|
|||||||
| **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) |
|
| **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) |
|
| **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) |
|
| **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 |
|
| **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)" |
|
| **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 |
|
| **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 |
|
||||||
@@ -74,9 +101,54 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
|
|||||||
|
|
||||||
### Notes on the live sources
|
### Notes on the live sources
|
||||||
|
|
||||||
|
- **Air quality (ECCC AQHI).** Keyless. Read from the MSC GeoMet OGC API (`api.weather.gc.ca`): the `aqhi-stations` catalog plus `aqhi-observations-realtime`. **Coverage is Canada only** — ECCC is the single registered provider, and the layer row and readout card both say so. Roughly 134 stations across ECCC's five administrative zones (Atlantic, Quebec, Ontario, Prairies & North, Pacific & Yukon). AQHI publishes hourly, so readings poll every 20 minutes and the station catalog, which changes a few times a year, is refetched only every 12 hours. Observations are requested with `latest=true`; without it the collection returns the full history, roughly 2,000 rows nationally. Readings older than six hours are dropped rather than displayed as current.
|
||||||
|
|
||||||
|
Readings are stored as `{ provider, scale, value, band }` rather than a bare number, because air-quality indices are not interchangeable: AQHI runs 1-10+ on health risk, the US AQI runs 0-500 on concentration breakpoints, and Europe's EAQI is a five-step category. Naming the index a value belongs to is what lets another network be added later without inventing a shared numeric meaning that does not exist.
|
||||||
|
|
||||||
|
AQHI is reported by ECCC as an integer from 1 to 10 with an open-ended "10+" above that. The API returns the underlying decimal (1.08, 2.32); the layer rounds to the published form rather than displaying a precision the index does not claim.
|
||||||
|
|
||||||
|
**Quebec is thinly represented**, and that is upstream rather than a filter here: the province runs its own Info-Smog program, so only a handful of Quebec stations appear in ECCC's national feed. The reporting station count also varies hour to hour as stations report or fall silent.
|
||||||
|
|
||||||
|
- **Via Lietuva / Eismoinfo (Lithuania).** Public road-camera catalogs and periodically updated still images from [Eismoinfo](https://eismoinfo.lt/), credited to Via Lietuva / eismoinfo.lt in the app. **Off by default.** The camera endpoints are the eismoinfo.lt website backend and are not among the services listed in Via Lietuva's [open-data statement](https://eismoinfo.lt/eismoinfo-backend/content?id=44), whose conditions are attribution and reuse on the same terms. Until the statement is confirmed to cover the camera photos, the pack loads only when an operator sets `CCTV_LITHUANIA_ENABLED=1`; with the flag unset, empty or any other value, the server makes no request to Eismoinfo. `eismoinfo-backend/camera-info-table` is joined by ID to `layer-static-features/VKR`. Without `lks=true`, the latter returns WGS84 `[latitude, longitude]`; the table's `x/y` instead use LKS-94 metres. Only the official HTTPS `/eismoinfo-backend/image-provider/camera/last?id=…` image endpoint is registered. Catalogs use the shared 15-minute cache; frames are fetched on demand and never bundled or archived. These are periodic snapshots, not continuous video. Camera headings are low-confidence estimates. No API key is required. `CCTV_LITHUANIA_MAX_SOURCES` defaults to 400.
|
||||||
|
|
||||||
- **Google Maps Platform.** You supply your own API key and are bound by [Google's ToS](https://cloud.google.com/maps-platform/terms). Google Maps Content (tiles, geocodes, places) **may not be cached, stored, rehosted, or committed** — this app only ever uses it live, which is the compliant pattern. The "Google" attribution is displayed on the globe and must stay visible. Restrict your key (see [SECURITY.md](SECURITY.md)).
|
- **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.
|
- **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.
|
- **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>
|
||||||
|
|
||||||
|
- **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
|
||||||
|
reuse the Flights fallback cache. Cache lifetime is 60 s; 429 backoff is capped
|
||||||
|
at one hour and other upstream failures cool down for 15 s. Future access may
|
||||||
|
require feeder keys under the [API terms](https://github.com/adsblol/api).
|
||||||
|
Airborne decoded ADS-B v1/v2 aircraft with a known 0–60 s position age qualify,
|
||||||
|
including MLAT-positioned aircraft still broadcasting their own quality.
|
||||||
|
TIS-B/MLAT-derived NIC and NACp are ignored. GPS-loss evidence uses
|
||||||
|
`gpsOkLat/gpsOkLon` when present, otherwise the current known position.
|
||||||
|
The proxy strips identity metadata and tags its response with classifier
|
||||||
|
`gev-nic-nacp-v2`; rows carry only integrity facts.
|
||||||
|
|
||||||
|
The method has three separately attributed parts:
|
||||||
|
1. **GEV classifier (unvalidated):** GPS-loss flag, aircraft-reported NACp < 8,
|
||||||
|
or aircraft-reported NIC < 7 except NIC 0 with NACp ≥ 10 (unknown containment).
|
||||||
|
The numerical minima come from US 14 CFR 91.227(c), not a published jamming
|
||||||
|
classifier. Readsb's [JSON contract](https://github.com/wiedehopf/readsb/blob/dev/README-json.md)
|
||||||
|
describes the quality field origins and GPS-loss flag.
|
||||||
|
2. **GPSJAM aggregation:** score = `100 × max(0, bad − 1) / total`, with percent
|
||||||
|
bands < 2, 2–10, > 10. One bad aircraft is discounted. This is not the raw
|
||||||
|
share of bad aircraft. See the [GPSJAM FAQ](https://gpsjam.org/faq).
|
||||||
|
3. **GEV window:** 30 minutes over visited views, at least three aircraft per
|
||||||
|
0.5° cell. These bands were developed for daily totals; with fewer than ten
|
||||||
|
observations a cell can skip the middle band. Source timestamps date evidence
|
||||||
|
even through cache replays, and degraded evidence expires independently.
|
||||||
|
|
||||||
|
Medium/high cells report navigation-integrity anomalies. Interference is one
|
||||||
|
possible explanation, not a detected or located emitter. Avionics faults and
|
||||||
|
manoeuvring military trainers can also lower reported accuracy. Blank areas
|
||||||
|
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.
|
- **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.
|
- **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.
|
- **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.
|
||||||
@@ -90,9 +162,14 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
|
|||||||
- **Warendorf webcam.** The Stadt Warendorf Marktplatz webcam, registered from the curated `config/cctv_sources.warendorf.json` (the Kreis Warendorf registration-office cameras from the contributing PR are indoor waiting rooms and were left out); only the municipal image host is proxied, frames are fetched at request time and never stored or redistributed. Disable with `CCTV_WARENDORF_ENABLED=0`.
|
- **Warendorf webcam.** The Stadt Warendorf Marktplatz webcam, registered from the curated `config/cctv_sources.warendorf.json` (the Kreis Warendorf registration-office cameras from the contributing PR are indoor waiting rooms and were left out); only the municipal image host is proxied, frames are fetched at request time and never stored or redistributed. Disable with `CCTV_WARENDORF_ENABLED=0`.
|
||||||
- **Live Traffic NSW cameras.** The camera list is the keyless public `data.livetraffic.com/cameras/traffic-cam.json` feed (Transport for NSW open data, CC BY 4.0); frames are stills on `webcams.transport.nsw.gov.au`, and only that host is registered. That image host answers non-browser clients with an HTML placeholder, so the proxy identifies as a browser for that one host and as itself everywhere else. Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_NSW_ENABLED=0`.
|
- **Live Traffic NSW cameras.** The camera list is the keyless public `data.livetraffic.com/cameras/traffic-cam.json` feed (Transport for NSW open data, CC BY 4.0); frames are stills on `webcams.transport.nsw.gov.au`, and only that host is registered. That image host answers non-browser clients with an HTML placeholder, so the proxy identifies as a browser for that one host and as itself everywhere else. Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_NSW_ENABLED=0`.
|
||||||
- **Open Calgary traffic cameras.** Keyless. The camera list is the public Socrata dataset `k7p9-kppz` on `data.calgary.ca`; frames are stills on the city's own `trafficcam.calgary.ca` host, and only that host is registered. Most rows publish an `http://` frame URL and the host redirects those to HTTPS, so URLs are upgraded and pinned to that origin before registration. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing: the `quadrant` field and the quadrant suffix on each camera name are Calgary's address grid, not a bearing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. The licence requires the attribution statement above. Disable with `CCTV_CALGARY_ENABLED=0`.
|
- **Open Calgary traffic cameras.** Keyless. The camera list is the public Socrata dataset `k7p9-kppz` on `data.calgary.ca`; frames are stills on the city's own `trafficcam.calgary.ca` host, and only that host is registered. Most rows publish an `http://` frame URL and the host redirects those to HTTPS, so URLs are upgraded and pinned to that origin before registration. Frames are fetched at request time and never stored or redistributed. The dataset publishes no camera facing: the `quadrant` field and the quadrant suffix on each camera name are Calgary's address grid, not a bearing, so headings use the shared id-hash fallback at low confidence and are corrected with the in-app calibration gizmo. The licence requires the attribution statement above. Disable with `CCTV_CALGARY_ENABLED=0`.
|
||||||
|
- **Catalonia (Servei Català de Trànsit) cameras.** The camera list is the keyless `https://www.gencat.cat/transit/opendata/cameres.xml` WFS/GML feed, fetched over https with redirects refused and the body read capped at 1 MiB, since the list decides ids, coordinates and which allowlisted frame path is proxied. It carries a `font` field naming the real publisher per camera; this pack keeps SCT's own highway cameras plus hotlinked Barcelona (IMI) and Terrassa municipal cameras (~130 total), each confirmed to return a real frame, so frame URLs are checked against a small host allowlist (`mct.gencat.cat`, `www.bcn.cat`, `emap.terrassa.cat`) rather than one pinned origin (a link carrying `user:pass@` userinfo or an explicit port, or pointing outside that host's frame directory, is rejected even on an allowed host; an SCT `sctidcam` must be a bare `.gif`/`.jpg` filename), and every non-SCT camera carries its real owner as a `credit` shown beside the shared `provider`. The feed carries no compass heading, so every camera loads at low heading confidence (same as TfL and Fintraffic). SCT's own feed link (`RenderService`) needed a workaround: it always 302s to a second endpoint (`TransitCamera`) on a Location the upstream hardcodes as plain http even when requested over https — refused by `fetchWithinHost`'s same-origin (scheme-included) redirect policy — and separately, `mct.gencat.cat`'s TLS setup fails the handshake with Node's fetch outright (`ERR_SSL_WRONG_SIGNATURE_TYPE`) even though other HTTP clients complete it. The loader therefore builds the `TransitCamera` URL itself, over plain http, from `RenderService`'s `sctidcam` id, rather than registering the (broken) upstream link — the only transport this one host actually serves to this runtime. Camera ids are built from the SCT camera file or the partner image path, not the feed's `fid` (which embeds an upstream server-start stamp and changes on every upstream restart), and rows the feed lists more than once collapse to one camera. The feed carries no camera status field, so a camera its publisher has taken offline still registers and shows the publisher's own "image unavailable" still (SCT and Barcelona both serve one with HTTP 200), the same as other packs without a status field. Frames are fetched at request time and never stored or redistributed. The feed itself states no licence; the pack relies on the Generalitat de Catalunya open-data portal's terms for this dataset (the licence and attribution above). Disable with `CCTV_CATALONIA_ENABLED=0`.
|
||||||
|
- **King County road cameras.** Keyless. The camera list is one public ArcGIS FeatureServer GeoJSON query (King County DOT Road Services, "King County and WSDOT Traffic Cameras"). The `outFields` list is explicit on purpose: the layer also carries asset-administration columns — including staff names — that are never fetched. Only live, active cameras with frames on the county's own `info.kingcounty.gov` image handler are registered (the feed writes `http://`; the handler serves HTTPS, so URLs are upgraded and pinned before registration). WSDOT-owned rows in the layer are skipped because their frames are WSDOT-hosted, outside the pinned image origin; partner-city cameras hosted on the county image handler (e.g. Newcastle) name their owner as the per-camera credit. The layer publishes no facing, so headings use the shared id-hash fallback at low confidence. Uniquely, the county publishes each camera's hardware `Manufacturer`/`Model` (Cohu 3950/3965, Bosch AUTODOME, Axis PTZ domes); both are carried on the source (and served by `/api/cctv/sources`) as `model` and `manufacturer` for identified-hardware consumers (vendor-generic strings such as `PTZ` name no model and are omitted). Frames are fetched at request time and never stored or redistributed. Disable with `CCTV_KINGCOUNTY_ENABLED=0`.
|
||||||
|
- **WSDOT highway cameras.** Keyless. The camera list is the Esri JSON layer `data.wsdot.wa.gov/travelcenter/Cameras.json` — the same layer WSDOT's own Travel Center map consumes (~1,700 point features, EPSG:3857 geometry converted to WGS84 on load; the body carries Windows-1252 bytes and is decoded as such). Only frames on WSDOT's own `images.wsdot.wa.gov` host are registered — the catalog also lists ~70 partner cameras (Oregon DOT, City of Seattle, lodges, airports) whose own terms apply, and those are skipped rather than proxied. WSDOT's layer metadata marks the feed for "low volume" use (see WSDOT's [travel information disclaimer](https://wsdot.wa.gov/about/policies/travel-information-disclaimer)). The catalog is fetched once per CCTV catalog refresh (every 15 minutes), and a camera's frame is fetched only when that camera is viewed; frames are never stored or redistributed. The live feed's `CompassDirection` is a single cardinal (N/E/S/W), `B` (both directions — most rows), `O`, or null. A single cardinal gives a high-confidence heading (the diagonals NE/SE/SW/NW are mapped too should they appear); `B`, `O`, null, and any other code carry no single facing and use the shared id-hash fallback at low confidence. By default the 250 cameras nearest Seattle and Spokane load; `CCTV_WSDOT_MAX_SOURCES` changes the cap (8–1,200) and `CCTV_WSDOT_ENABLED=0` turns the pack off. A keyed WSDOT Traveler Information API serves the same population for higher-volume use and is deliberately not required here.
|
||||||
- **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.
|
- **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.
|
||||||
|
- **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`.
|
||||||
- **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.
|
- **Camera frame content.** Public camera frames can contain people, vehicles and license plates, and the camera layer does nothing with them beyond putting them on the map. A successful upstream response is relayed as the provider served it: nothing in the camera pipeline blurs, redacts, enhances or restores it, and nothing there runs face, plate or object detection or otherwise recognises or classifies what is in the picture. (The ALPR layer described below maps where plate-reader cameras are, from locations contributors tagged in OpenStreetMap; it reads nothing from any camera frame.) When an upstream has no frame for a camera, what you see instead is a Google Street View still or the app's own placeholder card; the panel's source badge names which of them you are looking at, and a refresh that fails after a frame has arrived leaves the last good picture on screen rather than replacing it. The browser resamples each frame for display: onto a fixed 1920x1080 surface for the camera's projection plane in the scene, and onto a 192x108 canvas for the small ambient card. That scaling is the only change made to the picture, and the only pixels the camera layer reads back are a 64x36 downscale hashed to tell whether the picture has changed. Live streams are piped through; the still path reads the image into memory to pass it on — TxDOT wraps its JPEG in a JSON envelope, which is decoded there — and no frame is written to disk (the disk cache, `.gev-cache/`, holds feed JSON for other layers and never camera imagery). Outside that pipeline, the voice assistant is the one feature that sends imagery anywhere: when it answers a question about what is in view it may attach a downscaled screenshot of the rendered scene — including any camera plane on screen — to the request it sends to the voice model. Frames are fetched only from server-registered camera URLs — a client cannot hand the proxy a URL (see [SECURITY.md](SECURITY.md)). What the app does stays inside the line README draws: no named-person search, no face recognition, no tracking individuals.
|
||||||
- **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. All seven registered regions are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth.
|
- **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 malformed or empty successful payloads are rejected atomically. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply.
|
- **Radio Browser.** `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; schema-valid responses whose rows all fail the product's health policy do not count as successful queries. A usable partial cold catalog is explicitly `DEGRADED`, and malformed or empty successful payloads are rejected atomically. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply.
|
||||||
- **TomTom Traffic.** Optional and BYOK: without `TOMTOM_API_KEY` the traffic layer runs its built-in simulation and no TomTom data (or attribution) appears. With a key, flow vector tiles are fetched through the server-side `/api/tomtom` proxy (120 s cache + a daily tile-budget governor — `TOMTOM_DAILY_TILE_BUDGET`, default 6,000 — sized so a 31-day month stays inside TomTom's 200K monthly free allowance; still a configurable application safety ceiling, not a guarantee; TomTom's [current pricing](https://docs.tomtom.com/pricing/) lists 200K free tile requests per month) and the "Traffic flow data © TomTom" credit is registered in the Data attribution popover the moment live mode activates. TomTom data is served live and cached only transiently (≤120 s TTL under `.gev-cache/`, gitignored) — it is not bundled or redistributed. One 23 KB point-in-time tile snapshot is committed as a decode-test fixture (`src/data/fixtures/`, © TomTom, never served to the app).
|
- **TomTom Traffic.** Optional and BYOK: without `TOMTOM_API_KEY` the traffic layer runs its built-in simulation and no TomTom data (or attribution) appears. With a key, flow vector tiles are fetched through the server-side `/api/tomtom` proxy (120 s cache + a daily tile-budget governor — `TOMTOM_DAILY_TILE_BUDGET`, default 6,000 — sized so a 31-day month stays inside TomTom's 200K monthly free allowance; still a configurable application safety ceiling, not a guarantee; TomTom's [current pricing](https://docs.tomtom.com/pricing/) lists 200K free tile requests per month) and the "Traffic flow data © TomTom" credit is registered in the Data attribution popover the moment live mode activates. TomTom data is served live and cached only transiently (≤120 s TTL under `.gev-cache/`, gitignored) — it is not bundled or redistributed. One 23 KB point-in-time tile snapshot is committed as a decode-test fixture (`src/data/fixtures/`, © TomTom, never served to the app).
|
||||||
- **Routing (OSRM / FOSSGIS).** `/api/route` proxies the public OSRM instances FOSSGIS e.V. runs for the OpenStreetMap community (car, bike, foot profiles). They are keyless and intended for fair, interactive use — the proxy rate-limits clients (60/min, 200/min total), caps route span and response size, caches each route for 10 minutes, and identifies itself in the User-Agent. Heavy or automated use must run its own OSRM. Routes are computed from OpenStreetMap data (ODbL); the Directions layer and voice routes never redistribute them. A route the router cannot find is reported as such; the Directions layer draws nothing in that case, and voice routes fall back to a clearly labeled straight line.
|
- **Routing (OSRM / FOSSGIS).** `/api/route` proxies the public OSRM instances FOSSGIS e.V. runs for the OpenStreetMap community (car, bike, foot profiles). They are keyless and intended for fair, interactive use — the proxy rate-limits clients (60/min, 200/min total), caps route span and response size, caches each route for 10 minutes, and identifies itself in the User-Agent. Heavy or automated use must run its own OSRM. Routes are computed from OpenStreetMap data (ODbL); the Directions layer and voice routes never redistribute them. A route the router cannot find is reported as such; the Directions layer draws nothing in that case, and voice routes fall back to a clearly labeled straight line.
|
||||||
@@ -122,6 +199,7 @@ The [Bhote Koshi event pack](public/events/bhote-koshi-2026/README.md), under `p
|
|||||||
| **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) |
|
| **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 counties** (3,235 county polygons) | `us_census_counties/` | **Public domain** | ✅ (no restrictions) | "U.S. Census Bureau" (courtesy — not legally required) |
|
||||||
| **CCTV ground heights** (3,445 cameras) | `cctv_ground_heights/` | Precomputed camera placement heights, aligned to work with Google Photorealistic 3D Tiles (folder README) | — | — |
|
| **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
|
### ⚠️ TeleGeography is bundled but NonCommercial
|
||||||
|
|
||||||
@@ -147,6 +225,17 @@ commercial use with attribution and applicable database share-alike/access
|
|||||||
obligations; it does not license the application code. Trimmed test-only tile
|
obligations; it does not license the application code. Trimmed test-only tile
|
||||||
fixtures retain their provenance in `src/data/fixtures/README.md`.
|
fixtures retain their provenance in `src/data/fixtures/README.md`.
|
||||||
|
|
||||||
|
### CCTV Camera Database (`src/layers/cctv/modelSpecs.js`)
|
||||||
|
|
||||||
|
The CCTV layer's hardware-model specs are a small verbatim extract (v2.26.0) of the
|
||||||
|
[CCTV Camera Database](https://github.com/ch-bas/cctv-camera-database), an open dataset of
|
||||||
|
camera models transcribed from manufacturer datasheets, dedicated to the public domain under
|
||||||
|
**CC0-1.0**. Only the records for hardware that the integrated public catalogs name (King
|
||||||
|
County, Sarasota County and Sioux Falls) are bundled, compiled into the module. Nothing is
|
||||||
|
fetched at runtime. The records describe camera products (optics, resolution, night vision)
|
||||||
|
only, never endpoints, stream paths or access data. CC0 requires no attribution; the CCTV HUD
|
||||||
|
credits it anyway as `SPECS: CCTV-DATABASE.COM` beside any spec line it renders.
|
||||||
|
|
||||||
### Bundled OpenStreetMap data
|
### Bundled OpenStreetMap data
|
||||||
|
|
||||||
Bundled OSM-derived data is distributed under [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/), separately from the MIT code: [datacenters](src/data/local_data/datacenters/), [dams](src/data/local_data/dams/) (via Open Infrastructure Map), [military names](src/data/local_data/osm_military_names/), and [OSM test fixtures](src/data/fixtures/). Keep © OpenStreetMap contributors and offer modified databases under ODbL.
|
Bundled OSM-derived data is distributed under [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/), separately from the MIT code: [datacenters](src/data/local_data/datacenters/), [dams](src/data/local_data/dams/) (via Open Infrastructure Map), [military names](src/data/local_data/osm_military_names/), and [OSM test fixtures](src/data/fixtures/). Keep © OpenStreetMap contributors and offer modified databases under ODbL.
|
||||||
|
|||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
#
|
||||||
|
# The dev server is the whole app: Vite serves the client AND runs the API
|
||||||
|
# proxies (ADS-B, AIS, Overpass, OpenAI Realtime, ...) as middleware, so there
|
||||||
|
# is nothing else to containerise. See compose.yaml for the usual entry point.
|
||||||
|
# Same Node as CI (package.json engines, .github/workflows), pinned by digest.
|
||||||
|
FROM node:24.14.0-slim@sha256:d8e448a56fc63242f70026718378bd4b00f8c82e78d20eefb199224a4d8e33d8
|
||||||
|
|
||||||
|
# Puppeteer is only used by the QA scripts; skip its ~150MB Chromium download.
|
||||||
|
ENV PUPPETEER_SKIP_DOWNLOAD=1
|
||||||
|
|
||||||
|
# `npm test` builds fixtures with `git init`, so the suite cannot run in a
|
||||||
|
# container without it (71 failures on node:24-slim, all ENOENT on git).
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends git && rm -rf /var/lib/apt/lists/*
|
||||||
|
# The repo is bind-mounted, so inside the container it can show an owner other
|
||||||
|
# than the user the container runs as - always, on Docker Desktop. git then
|
||||||
|
# refuses it as "dubious ownership", which fails the tests that shell out to
|
||||||
|
# git. It is the user's own checkout, so trust that one path.
|
||||||
|
RUN git config --system --add safe.directory /app
|
||||||
|
|
||||||
|
# Never run as root. The image's own user is `node` (1000); compose overrides
|
||||||
|
# it with the host user so bind-mounted files stay yours.
|
||||||
|
WORKDIR /app
|
||||||
|
RUN chown node:node /app
|
||||||
|
USER node
|
||||||
|
|
||||||
|
COPY --chown=node:node package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
# node_modules belongs to `node`, but compose may run as another UID. Vite only
|
||||||
|
# needs to write its dep-optimizer cache, so open up those two directories
|
||||||
|
# rather than chowning a few hundred MB to a UID we cannot know at build time.
|
||||||
|
RUN mkdir -p node_modules/.vite && chmod 777 node_modules node_modules/.vite
|
||||||
|
|
||||||
|
COPY --chown=node:node . .
|
||||||
|
|
||||||
|
# Bind to all interfaces, otherwise the published port hits nothing. This does
|
||||||
|
# not widen the Host allowlist: IP addresses and localhost are accepted, other
|
||||||
|
# names only via GEV_ALLOWED_HOSTS (see build/allowedHosts.js).
|
||||||
|
ENV HOST=0.0.0.0
|
||||||
|
ENV PORT=4173
|
||||||
|
EXPOSE 4173
|
||||||
|
# The launcher is `npm run dev` plus one step: it trusts the container's
|
||||||
|
# gateway address for Provider Settings, because that is where Docker delivers
|
||||||
|
# the host's own browser from (never 127.0.0.1). See scripts/docker-start.mjs.
|
||||||
|
CMD ["node", "scripts/docker-start.mjs"]
|
||||||
@@ -73,7 +73,7 @@ Start with the included data sources, then add your own. Each layer is a separat
|
|||||||
- **🔭 Satellite passes:** Ask by voice when any loaded satellite next rises over you: rise, peak and set times, and whether you can see it.
|
- **🔭 Satellite passes:** Ask by voice when any loaded satellite next rises over you: rise, peak and set times, and whether you can see it.
|
||||||
- **🔎 Analyst answers:** Count, filter and rank satellites, datacenters and dams by voice, alongside flights, ships, fires and quakes; answers say when a feed is stale or on a fallback.
|
- **🔎 Analyst answers:** Count, filter and rank satellites, datacenters and dams by voice, alongside flights, ships, fires and quakes; answers say when a feed is stale or on a fallback.
|
||||||
- **🧭 Tilt and North Up:** Switch between straight-down and a 35° oblique, or put north at the top, while a tracked target stays centered.
|
- **🧭 Tilt and North Up:** Switch between straight-down and a 35° oblique, or put north at the top, while a tracked target stays centered.
|
||||||
- **🔍 Keyless search:** Type coordinates or a bundled city or landmark to fly there with no network request; other names use Google when configured, then Photon and Nominatim.
|
- **🔍 Keyless search:** Type decimal coordinates, a two-dimensional WGS84 `geo:` URI (for example, `geo:39.7392,-104.9903`), or a bundled city or landmark to fly there with no geocoder request; other names use Google when configured, then Photon and Nominatim.
|
||||||
- **📦 Shareable scenes:** Import, preview and share Director scenes as files or bundles, with camera anchors, authored moves and data packs.
|
- **📦 Shareable scenes:** Import, preview and share Director scenes as files or bundles, with camera anchors, authored moves and data packs.
|
||||||
- **🌊 Nepal flood scene:** Replay the Bhote Koshi flood: flood path, witness sources and before-and-after imagery (bundled data is non-commercial; see [DATA_SOURCES.md](DATA_SOURCES.md)).
|
- **🌊 Nepal flood scene:** Replay the Bhote Koshi flood: flood path, witness sources and before-and-after imagery (bundled data is non-commercial; see [DATA_SOURCES.md](DATA_SOURCES.md)).
|
||||||
|
|
||||||
@@ -117,6 +117,14 @@ locked dependencies, finds a free local port, and opens the app.
|
|||||||
Version 8.2 fixes the launcher installation issue;
|
Version 8.2 fixes the launcher installation issue;
|
||||||
[details from the Pinokio maintainer](https://pinokio.co/posts/01m1m4p9xxm3qw7dnnpj2wr93g).
|
[details from the Pinokio maintainer](https://pinokio.co/posts/01m1m4p9xxm3qw7dnnpj2wr93g).
|
||||||
|
|
||||||
|
**Pinokio opens but the globe is very choppy?** Before treating that as a GPU
|
||||||
|
compatibility or minimum-hardware problem, copy the local server URL shown by
|
||||||
|
Pinokio and open the same URL in your normal hardware-accelerated browser. One
|
||||||
|
Pinokio 8.2.0 user reported severe stutter in Pinokio's embedded view that went
|
||||||
|
away when the same running server was opened in an external browser ([#365](https://github.com/bilawalsidhu/gods-eye-view/issues/365)).
|
||||||
|
That points to the embedded browser rather than the app server or your
|
||||||
|
hardware; it does not establish a minimum GPU requirement.
|
||||||
|
|
||||||
### Path 2 — Terminal / coding agent
|
### Path 2 — Terminal / coding agent
|
||||||
|
|
||||||
Use **Node.js 24.x (24.14.0 or later) or 26.x**. The setup doctor warns about
|
Use **Node.js 24.x (24.14.0 or later) or 26.x**. The setup doctor warns about
|
||||||
@@ -145,6 +153,72 @@ See [docs/PERFORMANCE.md](docs/PERFORMANCE.md).
|
|||||||
**macOS shortcut:** `./scripts/dev-fresh.sh` clears the Vite cache and pulls any
|
**macOS shortcut:** `./scripts/dev-fresh.sh` clears the Vite cache and pulls any
|
||||||
configured keys straight from the Keychain. It starts keyless too.
|
configured keys straight from the Keychain. It starts keyless too.
|
||||||
|
|
||||||
|
### Path 3 — Docker
|
||||||
|
|
||||||
|
No Node install needed; the container publishes the same dev server.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/bilawalsidhu/gods-eye-view.git
|
||||||
|
cd gods-eye-view
|
||||||
|
docker compose up
|
||||||
|
```
|
||||||
|
|
||||||
|
Open **`http://localhost:4173`** as above. Everything the app saves stays in the folder you cloned, so `docker compose down` never loses anything:
|
||||||
|
|
||||||
|
- `.env` - your API keys, whether saved from Provider Settings or typed in by hand
|
||||||
|
- `.gev-cache/` - downloaded map and satellite data, so later starts are faster
|
||||||
|
- `.gev-logs/` - logs
|
||||||
|
|
||||||
|
Back up that folder and you've backed up everything; delete `.env` to forget your keys. Source edits still hot-reload. The port is published to `127.0.0.1` only, so the default matches the native one.
|
||||||
|
|
||||||
|
The local MCP server and ChatGPT OAuth voice are not available under Docker; use an OpenAI API key for voice.
|
||||||
|
|
||||||
|
Publishing it wider is the [Sharing an instance](#-sharing-an-instance) opt-in, and it hands out **Provider Settings** too: the launcher trusts the container gateway as this machine, and a wider publish delivers everyone else from that same address. For an instance you mean to share, put the keys in `.env` by hand instead. [SECURITY.md](SECURITY.md) has the detail.
|
||||||
|
|
||||||
|
When you widen the publish, also set `GEV_KEY_SETUP_TRUSTED_PEERS=none` so the launcher trusts no gateway address.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Running on Linux, a NAS, or behind a reverse proxy</summary>
|
||||||
|
|
||||||
|
Run it as the user who should own the files it writes. `id` shows the numbers; for yourself on Linux:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
env UID=$(id -u) GID=$(id -g) docker compose up
|
||||||
|
```
|
||||||
|
|
||||||
|
On a NAS, clone onto the share you want the data on and use the share owner's ids:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /volume1/docker
|
||||||
|
git clone https://github.com/bilawalsidhu/gods-eye-view.git
|
||||||
|
cd gods-eye-view
|
||||||
|
env UID=1026 GID=100 docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep the `env`: bash treats `UID` as read-only, so a plain `UID=1026` in front of a command is silently ignored. If the container can't write the checkout, it says so at start. Docker Desktop on macOS and Windows maps ownership for you.
|
||||||
|
|
||||||
|
Behind a reverse proxy, put your keys in `.env` or pass them as environment variables - Provider Settings refuses proxied requests on purpose - and add the hostname you serve it on, for example `GEV_ALLOWED_HOSTS=globe.example.lan` (see [Sharing an instance](#-sharing-an-instance)).
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>IPv6-only networks, rebuilding, and the test suites</summary>
|
||||||
|
|
||||||
|
The launcher reads the IPv4 default gateway, so an IPv6-only Docker network
|
||||||
|
finds none: it logs `No default gateway; Provider Settings stays loopback-only`
|
||||||
|
and the panel refuses every save. Put your keys in `.env` by hand there, or name
|
||||||
|
the gateway yourself with `GEV_KEY_SETUP_TRUSTED_PEERS` after reading
|
||||||
|
[SECURITY.md](SECURITY.md).
|
||||||
|
|
||||||
|
`node_modules` deliberately stays inside the image, so it survives in an
|
||||||
|
anonymous volume across restarts: after a `git pull` that changes dependencies,
|
||||||
|
recreate it with `docker compose down -v && docker compose up --build`. The
|
||||||
|
image also skips Puppeteer's Chromium download, which keeps it small but leaves
|
||||||
|
`npm run test:track` to the host — `npm run build` and `npm test` run fine in
|
||||||
|
the container.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
### Then power it up — in the app, not in a file
|
### Then power it up — in the app, not in a file
|
||||||
|
|
||||||
Keys are upgrades, not prerequisites. When you want one, click the **POWER UP**
|
Keys are upgrades, not prerequisites. When you want one, click the **POWER UP**
|
||||||
@@ -289,22 +363,25 @@ _Ask for radio near anywhere and the globe starts broadcasting — every station
|
|||||||
|
|
||||||
## 🛰️ What's on the Globe
|
## 🛰️ What's on the Globe
|
||||||
|
|
||||||
Twenty layers and map sources. **Seventeen have a keyless path.** Some offer additional capabilities with a provider key. (🟢 no key · 🟡 free key · 🔴 metered.)
|
Twenty layers and map sources. **Eighteen have a keyless path.** Some offer additional capabilities with a provider key. (🟢 no key · 🟡 free key · 🔴 metered.)
|
||||||
|
|
||||||
| Layer | What you get | Source | Auth |
|
| Layer | What you get | Source | Auth |
|
||||||
| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------- |
|
| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------- |
|
||||||
| 🗺️ **Map Stack** | Esri satellite imagery, Google Photorealistic 3D, OSM, plus additional ion-hosted stacks | Esri / Google / Ion / OSM | 🟢 Esri satellite + OSM · 🟡 ion-hosted Google 3D + world terrain · 🔴 direct Google + place search |
|
| 🗺️ **Map Stack** | Esri satellite imagery, Google Photorealistic 3D, OSM, plus additional ion-hosted stacks | Esri / Google / Ion / OSM | 🟢 Esri satellite + OSM · 🟡 ion-hosted Google 3D + world terrain · 🔴 direct Google + place search |
|
||||||
| ✈️ **Live Flights** | 11,000+ live aircraft + route history | OpenSky + adsb.lol | 🟢 (🟡 optional for more polling credits) |
|
| ✈️ **Live Flights** | 11,000+ live aircraft + route history | OpenSky + adsb.lol | 🟢 (🟡 optional for more polling credits) |
|
||||||
| 🎖️ **Military Flights** | ADS-B military traffic in amber | adsb.lol | 🟢 |
|
| 🎖️ **Military Flights** | ADS-B military traffic in amber, emergency broadcasts in red | adsb.lol | 🟢 |
|
||||||
|
| 📡 **GNSS Integrity** | Regional navigation accuracy scores; anomalies do not confirm interference | adsb.lol, GPSJAM aggregation | 🟢 |
|
||||||
|
| 🔥 **Fire Perimeters** | Incident boundaries and reported containment | WFIGS / NIFC | 🟢 |
|
||||||
| 🚢 **Live Vessels** | Thousands of ships worldwide | AISStream | 🟡 |
|
| 🚢 **Live Vessels** | Thousands of ships worldwide | AISStream | 🟡 |
|
||||||
| 🛰️ **Satellites** | 838-object catalog, color-coded by class with a live legend — the **DENSE** chip drops in the whole Starlink shell | CelesTrak | 🟢 |
|
| 🛰️ **Satellites** | 838-object catalog, color-coded by class with a live legend — the **DENSE** chip drops in the whole Starlink shell | CelesTrak | 🟢 |
|
||||||
| 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 |
|
| 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 |
|
||||||
|
| ◍ **Air quality** | Live AQHI on the published 1–10+ health-risk scale, with a Weather-panel card showing the band legend and the highest readings in view. **Canada only** — ECCC is the first registered provider | ECCC MSC GeoMet | 🟢 |
|
||||||
| 🚗 **Traffic** | Simulated vehicles on OSM roads. With TomTom, live flow speeds drive the simulation and congestion colors below ~8 km; individual vehicle positions are not live observations | TomTom + OSM | 🟢 simulation · 🟡 live flow speeds |
|
| 🚗 **Traffic** | Simulated vehicles on OSM roads. With TomTom, live flow speeds drive the simulation and congestion colors below ~8 km; individual vehicle positions are not live observations | TomTom + OSM | 🟢 simulation · 🟡 live flow speeds |
|
||||||
| 📹 **CCTV Mesh** | ~3,900 public cameras projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
|
| 📹 **CCTV Mesh** | ~4,000 public cameras (shared cap) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen) · Toronto · King County (WA) · Washington (WSDOT) · Catalonia (Servei Català de Trànsit), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement) and opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
|
||||||
| 📷 **Mapped ALPR Cameras** | License-plate-reader camera locations tagged by OpenStreetMap contributors, loaded one city-sized view at a time, with **SHOW NEAREST**. Locations and tags only: no plate data, no video | OpenStreetMap (incl. DeFlock mapping) | 🟢 |
|
| 📷 **Mapped ALPR Cameras** | License-plate-reader camera locations tagged by OpenStreetMap contributors, loaded one city-sized view at a time, with **SHOW NEAREST**. Locations and tags only: no plate data, no video | OpenStreetMap (incl. DeFlock mapping) | 🟢 |
|
||||||
| 📷 **Street Level** | Mapillary street-level photos in one panel: coverage on the streets, image cones and an embedded viewer with full-screen **EXPAND** | Mapillary | 🟡 Mapillary client token |
|
| 📷 **Street Level** | Street-level photos behind one panel, one chip per provider: coverage on the streets, image cones, an embedded viewer with full-screen **EXPAND**. Mapillary, Google Street View, KartaView and Panoramax | Mapillary · Google · KartaView · Panoramax | 🟢 KartaView, Panoramax · 🟡 Mapillary token · 🔴 Google Maps key |
|
||||||
| 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 |
|
| 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 |
|
||||||
| 🚌 **Transit** | Live buses, trams, metros, trains and ferries with delayed playback between reports, selected-vehicle trails, and mode-coloured DETECT labels — Boston, Austin, Minneapolis, Helsinki, the Netherlands, Norway, South East Queensland | Operator GTFS-Realtime feeds | 🟢 |
|
| 🚌 **Transit** | Live buses, trams, metros, trains and ferries with delayed playback between reports, selected-vehicle trails, and mode-coloured DETECT labels — Boston, Austin, Minneapolis, Helsinki, the Netherlands, Norway, South East Queensland, and nine in Ontario: Toronto (surface vehicles; the subway publishes no positions), Mississauga, Hamilton, Durham Region, York Region, Guelph, Burlington, Barrie and Kingston | Operator GTFS-Realtime feeds | 🟢 |
|
||||||
| 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 |
|
| 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 |
|
||||||
| 🧭 **Directions** | Click A and B on the globe for a street-following drive, walk or cycle route draped on the terrain with turn-by-turn steps — then FLY the camera along it. No key, no geocoder, no mic | OSRM on FOSSGIS servers (OpenStreetMap) | 🟢 |
|
| 🧭 **Directions** | Click A and B on the globe for a street-following drive, walk or cycle route draped on the terrain with turn-by-turn steps — then FLY the camera along it. No key, no geocoder, no mic | OSRM on FOSSGIS servers (OpenStreetMap) | 🟢 |
|
||||||
| 🔥 **Active Fires** | Live NASA FIRMS detections, trailing 24h | NASA FIRMS | 🟡 |
|
| 🔥 **Active Fires** | Live NASA FIRMS detections, trailing 24h | NASA FIRMS | 🟡 |
|
||||||
@@ -340,13 +417,13 @@ _The Space Missions layer replaying a Falcon 9 ascent — labeled `RECONSTRUCTED
|
|||||||
|
|
||||||
## 📷 Street Level
|
## 📷 Street Level
|
||||||
|
|
||||||
> Street-level photos from **Mapillary** in one panel. It needs a free client token.
|
> Street-level photos from any provider in one panel. This build ships **Mapillary** (a free client token), **Google Street View** (your Google Maps key), **KartaView** and **Panoramax** (no key).
|
||||||
|
|
||||||
**Setup.** At [mapillary.com/dashboard/developers](https://www.mapillary.com/dashboard/developers), register an application with **Read** access and copy its **Client Token** (`MLY|…`). Paste it into **POWER UP → Mapillary**, or set `MAPILLARY_CLIENT_TOKEN` in `.env` and restart. Without it the panel reads **KEY REQUIRED**.
|
**Setup.** At [mapillary.com/dashboard/developers](https://www.mapillary.com/dashboard/developers), register an application with **Read** access and copy its **Client Token** (`MLY|…`). Paste it into **POWER UP → Mapillary**, or set `MAPILLARY_CLIENT_TOKEN` in `.env` and restart. Without it the panel reads **KEY REQUIRED**. **Google Street View** uses `GOOGLE_MAPS_API_KEY` with the **Maps JavaScript API** enabled for it in Google Cloud; its chip starts off, since every panorama is billed to the key. **KartaView** and **Panoramax** need nothing: their public APIs are keyless.
|
||||||
|
|
||||||
**Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Zoom in to street level (below 60 km) for the green coverage lines; from orbit the panel says to zoom in. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters narrow the lines and cones. Click a coverage line for its image cones and a cone to open the photo. Above the photo: **EXPAND** (full screen; Esc leaves) and **FIT / FILL**. The caption links to the image on mapillary.com, and the globe credits "© Mapillary contributors, CC BY-SA 4.0" while the layer is on.
|
**Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Each provider has a chip in its own colour (Mapillary green), and one is lit at a time: lighting one switches the others off. Mapillary, which is free, is lit by default. Zoom in to street level (below 60 km) for the coverage lines; from orbit the panel says to zoom in. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary, KartaView and Panoramax; Street View panoramas are all 360°, so with Street View lit the filters are hidden. Click a coverage line for its image cones and a cone to open the photo. Light **KARTAVIEW** (purple) and zoom in below about 6 km: its lines are the streets KartaView has photos of, and a click on one opens the photo nearest the click, flat or 360° (drag to look around), with cones along its drive; **‹ ›** or ← → step through the drive (Shift+← →, ↑ ↓ and + − look around a 360° photo; pinch zooms on a phone). Photos KartaView has archived are skipped, and where all are archived the panel says so. For KartaView, **SINCE** goes by upload date, as KartaView's own map does (it can filter by nothing else), and the panel notes it under the slider; KartaView filters its lines on its own server, and a filtered area KartaView has not drawn before can take several seconds. Light **PANORAMAX** (orange) for the open, federated commons (strongest in France and Belgium): below about 60 km its lines are sequences. A click on a line opens that sequence's picture nearest the click without moving the camera, flat or 360°, with cones along the sequence around it; **‹ ›** or ← → step through it, and both filters apply. Its caption names the picture's authors (or the instance holding it) and licence (mostly CC BY-SA 4.0; pictures on the IGN instance are Licence Ouverte 2.0). On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND** (full screen; Esc leaves) and **FIT / FILL** (Mapillary, and flat KartaView and Panoramax photos). The caption links to the image on the provider's site, and the globe credits each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google", "© KartaView contributors, CC BY-SA 4.0", "© Panoramax contributors, licence per picture"). KartaView's own photo pages fail for signed-out visitors, so its link opens the full-size photo.
|
||||||
|
|
||||||
Share links carry the Mapillary switch and the filters (`0.m.0` Mapillary off, `0.p.p` panoramas only, `0.s.365` the last year).
|
Share links carry the provider switches and filters (`0.m.0` Mapillary off, `0.m.0_0.r.google-1` Street View on, `0.m.0_0.r.google-0*kartaview-1*panoramax-0` KartaView on and `0.m.0_0.r.google-0*kartaview-0*panoramax-1` Panoramax on, as the app writes them (`0.m.0_0.r.kartaview-1` and `0.m.0_0.r.panoramax-1` work too; a link that leaves out `m` keeps Mapillary, the default, lit), `0.p.p` panoramas only, `0.s.365` the last year). To add a provider, implement the contract in `src/layers/streetLevel/registry.js` and register it in `src/app/layers/streetLevel.js`.
|
||||||
|
|
||||||
## 🎖️ Field Missions
|
## 🎖️ Field Missions
|
||||||
|
|
||||||
@@ -434,7 +511,7 @@ Seven keys. Five have a free tier, and the two 🔴 ones are metered:
|
|||||||
| | Key | Why | Get it |
|
| | Key | Why | Get it |
|
||||||
| --- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| --- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| 🟡 | **Cesium ion** | 🗺️ Google Photorealistic 3D, world terrain, and additional ion-hosted imagery stacks. The free Community plan is for eligible individual, personal/non-commercial use and has quotas | [cesium.com/ion](https://cesium.com/ion) — use a public `assets:read` token and check current [pricing/eligibility](https://cesium.com/platform/cesium-ion/pricing/) |
|
| 🟡 | **Cesium ion** | 🗺️ Google Photorealistic 3D, world terrain, and additional ion-hosted imagery stacks. The free Community plan is for eligible individual, personal/non-commercial use and has quotas | [cesium.com/ion](https://cesium.com/ion) — use a public `assets:read` token and check current [pricing/eligibility](https://cesium.com/platform/cesium-ion/pricing/) |
|
||||||
| 🔴 | **Google Maps** | Direct Google Photorealistic 3D + Google place search ([Map Tiles API](https://developers.google.com/maps/documentation/tile)) | [Google Cloud Console](https://console.cloud.google.com/) — URL-restrict it |
|
| 🔴 | **Google Maps** | Direct Google Photorealistic 3D, Google place search and Street View ([Map Tiles API](https://developers.google.com/maps/documentation/tile); Street View also needs the Maps JavaScript API) | [Google Cloud Console](https://console.cloud.google.com/) — URL-restrict it |
|
||||||
| 🔴 | **OpenAI** | 🎙️ Voice + AI HUD summary through an API key. Provider Settings also offers **USE CHATGPT OAUTH** for cloud voice without removing the API-key option | [platform.openai.com](https://platform.openai.com) — API-key usage is metered, see costs below |
|
| 🔴 | **OpenAI** | 🎙️ Voice + AI HUD summary through an API key. Provider Settings also offers **USE CHATGPT OAUTH** for cloud voice without removing the API-key option | [platform.openai.com](https://platform.openai.com) — API-key usage is metered, see costs below |
|
||||||
| 🟡 | **AISStream** | 🚢 Live global ships | [aisstream.io](https://aisstream.io) — free signup |
|
| 🟡 | **AISStream** | 🚢 Live global ships | [aisstream.io](https://aisstream.io) — free signup |
|
||||||
| 🟡 | **NASA FIRMS** | 🔥 Live active fires | [firms.modaps.eosdis.nasa.gov](https://firms.modaps.eosdis.nasa.gov/api/map_key/) — free |
|
| 🟡 | **NASA FIRMS** | 🔥 Live active fires | [firms.modaps.eosdis.nasa.gov](https://firms.modaps.eosdis.nasa.gov/api/map_key/) — free |
|
||||||
|
|||||||
+18
-4
@@ -21,19 +21,19 @@ The golden rule: **secret-bearing API keys stay on the server side.** The dev/pr
|
|||||||
| ChatGPT sign-in through Codex (optional, experimental) | Codex's own `auth.json` (`CODEX_HOME`, default `~/.codex`); GEV reads it server-side and never writes or refreshes it | Only after **USE CHATGPT OAUTH** is selected in Provider Settings: the server mints the same ephemeral Realtime token with the ChatGPT access token, for loopback clients only |
|
| ChatGPT sign-in through Codex (optional, experimental) | Codex's own `auth.json` (`CODEX_HOME`, default `~/.codex`); GEV reads it server-side and never writes or refreshes it | Only after **USE CHATGPT OAUTH** is selected in Provider Settings: the server mints the same ephemeral Realtime token with the ChatGPT access token, for loopback clients only |
|
||||||
| `AISSTREAM_API_KEY` | Server only | Server holds the AISStream websocket; browser polls the same-origin `/api/vessels` cache |
|
| `AISSTREAM_API_KEY` | Server only | Server holds the AISStream websocket; browser polls the same-origin `/api/vessels` cache |
|
||||||
| OpenSky OAuth (`OPENSKY_CLIENT_ID/SECRET`) | Server only | Server mints + refreshes the token behind `/api/flights` |
|
| OpenSky OAuth (`OPENSKY_CLIENT_ID/SECRET`) | Server only | Server mints + refreshes the token behind `/api/flights` |
|
||||||
| `GOOGLE_MAPS_SERVER_API_KEY` (optional, #33) | Server only | Server calls Places (`/api/google/nearby-places`, `/api/google/text-search`) and the Street View fallback with this key; falls back to `GOOGLE_MAPS_API_KEY` when unset |
|
| `GOOGLE_MAPS_SERVER_API_KEY` (optional, #33) | Server only | Server calls Places (`/api/google/nearby-places`, `/api/google/text-search`), Geocoding (`/api/google/geocode`, `/api/google/reverse-geocode`) and the Street View fallback with this key; falls back to `GOOGLE_MAPS_API_KEY` when unset |
|
||||||
|
|
||||||
### Three deliberately client-side keys — restrict them
|
### Three deliberately client-side keys — restrict them
|
||||||
|
|
||||||
These are designed to be used directly in the browser (like a Mapbox public token). They are injected into the client bundle via Vite's `define`, so they **will** be visible in browser devtools. Scope and restrict them rather than trying to hide them:
|
These are designed to be used directly in the browser (like a Mapbox public token). They are injected into the client bundle via Vite's `define`, so they **will** be visible in browser devtools. Scope and restrict them rather than trying to hide them:
|
||||||
|
|
||||||
1. **Google Maps API key** — loads Photorealistic 3D Tiles directly and powers GEV place search. **Restrict it** (HTTP referrer + API restriction to the required Google APIs) in the Google Cloud Console. An unrestricted key in a public deployment can be abused and billed to you.
|
1. **Google Maps API key** — loads Photorealistic 3D Tiles directly and powers Street View (Maps JavaScript API; add it to the key's API restrictions). **Restrict it** (HTTP referrer + API restriction to the required Google APIs) in the Google Cloud Console. An unrestricted key in a public deployment can be abused and billed to you. Restrict it to the **Map Tiles API and Maps JavaScript API only**: a referrer restriction applies to the Maps JavaScript API and the client SDKs, not to the web services, so a key carrying Geocoding under one is refused with `API keys with referer restrictions cannot be used with this API`. Search does not need it — geocoding goes through the server proxies above (#363).
|
||||||
2. **Cesium ion token** (`CESIUM_ION_TOKEN`, optional — for ion-hosted Google Photorealistic 3D Tiles, Bing world imagery, and world terrain) — used as `Cesium.Ion.defaultAccessToken` client-side. Use a public **`assets:read`** token with **URL restrictions** for any hosted deployment. The Community plan has eligibility and usage limits; a public token is not a secret, but it can still consume the account's quota.
|
2. **Cesium ion token** (`CESIUM_ION_TOKEN`, optional — for ion-hosted Google Photorealistic 3D Tiles, Bing world imagery, and world terrain) — used as `Cesium.Ion.defaultAccessToken` client-side. Use a public **`assets:read`** token with **URL restrictions** for any hosted deployment. The Community plan has eligibility and usage limits; a public token is not a secret, but it can still consume the account's quota.
|
||||||
3. **Mapillary client token** (`MAPILLARY_CLIENT_TOKEN`, optional, Street Level) — used by the MapillaryJS viewer and Graph API lookups in the browser, and by the server's coverage-tile proxy, which sends it only to `tiles.mapillary.com` over HTTPS (an upstream redirect is refused and answered 502, never followed) and caches tiles in memory only. Mapillary client tokens are meant for browser use.
|
3. **Mapillary client token** (`MAPILLARY_CLIENT_TOKEN`, optional, Street Level) — used by the MapillaryJS viewer and Graph API lookups in the browser, and by the server's coverage-tile proxy, which sends it only to `tiles.mapillary.com` over HTTPS (an upstream redirect is refused and answered 502, never followed) and caches tiles in memory only. Mapillary client tokens are meant for browser use.
|
||||||
|
|
||||||
> The explicit browser `define` block in `build/vite.js` controls exactly what reaches the client: only these three keys. Everything else stays server-side.
|
> The explicit browser `define` block in `build/vite.js` controls exactly what reaches the client: only these three keys. Everything else stays server-side.
|
||||||
|
|
||||||
**Places and Street View never needed to be on that list** (#33): they're called from the server-side proxies in the table above, which use `GOOGLE_MAPS_SERVER_API_KEY` when it's set. Splitting it from the browser-exposed key lets each key's Google Cloud restriction actually match what it does — the browser key referrer-restricted to the APIs the client loads, the server key IP-restricted (never a referrer, since it never leaves your server) to Places + Street View Static — instead of one key that has to be either over-permissioned or broken for one of its two jobs. A single shared `GOOGLE_MAPS_API_KEY` still works if you don't split them; it just has to cover every API both sides use.
|
**Places, Geocoding and the Street View Static fallback never needed to be on that list** (#33, #363): they're called from the server-side proxies in the table above, which use `GOOGLE_MAPS_SERVER_API_KEY` when it's set. Splitting it from the browser-exposed key lets each key's Google Cloud restriction actually match what it does — the browser key referrer-restricted to Map Tiles + Maps JavaScript API, the server key IP-restricted (never a referrer, since it never leaves your server) to Places + Geocoding + Street View Static — instead of one key that has to be either over-permissioned or broken for one of its jobs. A single shared `GOOGLE_MAPS_API_KEY` still works if you don't split them, but then it cannot carry a referrer restriction at all: the web services refuse one, and search would be the half that breaks.
|
||||||
|
|
||||||
Never commit real keys. `.env` is gitignored; only `.env.example` (placeholder names) is tracked. On macOS `dev-fresh.sh` can read keys from the Keychain; plain Vite uses env vars or a local `.env`, and Pinokio uses its ignored app `ENVIRONMENT` file.
|
Never commit real keys. `.env` is gitignored; only `.env.example` (placeholder names) is tracked. On macOS `dev-fresh.sh` can read keys from the Keychain; plain Vite uses env vars or a local `.env`, and Pinokio uses its ignored app `ENVIRONMENT` file.
|
||||||
|
|
||||||
@@ -73,7 +73,10 @@ The data proxies under `server/providers/` are written so the browser cannot tur
|
|||||||
- **Sanitized errors** — internal error details are not echoed back to clients.
|
- **Sanitized errors** — internal error details are not echoed back to clients.
|
||||||
- **Coalesced OAuth refresh** and cached successful responses only (OpenSky).
|
- **Coalesced OAuth refresh** and cached successful responses only (OpenSky).
|
||||||
- **Redacted debug logging.** The voice debug log (`.gev-logs/`, gitignored) strips API keys, bearer tokens, client secrets, and image data URLs before writing.
|
- **Redacted debug logging.** The voice debug log (`.gev-logs/`, gitignored) strips API keys, bearer tokens, client secrets, and image data URLs before writing.
|
||||||
- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an `<img>`/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control.
|
- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles, and `/api/tomtom/*`, backed by its persistent daily tile budget) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an `<img>`/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control.
|
||||||
|
- **Full Content-Security-Policy.** The dev and preview servers send a real CSP (`script-src 'self' 'unsafe-eval' blob:` plus the YouTube, Facebook and X scripts the Nepal event's embedded media loads and the Maps JavaScript API that Street View runs on, with a matching `frame-src` for their players and posts: no inline script and no other script origin; `blob:` covers the object URLs Cesium's bundled workers bootstrap through in the built app), alongside the existing `X-Frame-Options: DENY` and `frame-ancestors 'none'`. `'unsafe-eval'` stays because Knockout, bundled inside Cesium's widgets package, resolves the global object with `eval` at load time and the globe does not initialize without it; styles permit `'unsafe-inline'` and Google Fonts because Vite's dev client injects `<style>` elements.
|
||||||
|
|
||||||
|
Allowing `maps.googleapis.com` in `script-src` also trusts its JSONP endpoints, which can bypass the script allowlist if an HTML-injection bug exists; this is an accepted limitation of loading the Maps JavaScript API, alongside the existing `'unsafe-eval'` allowance.
|
||||||
- **Full Content-Security-Policy.** The dev and preview servers send a real CSP (`script-src 'self' 'unsafe-eval' blob:` plus the YouTube, Facebook and X scripts the Nepal event's embedded media loads, with a matching `frame-src` for their players and posts: no inline script and no other script origin; `blob:` covers the object URLs Cesium's bundled workers bootstrap through in the built app), alongside the existing `X-Frame-Options: DENY` and `frame-ancestors 'none'`. `'unsafe-eval'` stays because Knockout, bundled inside Cesium's widgets package, resolves the global object with `eval` at load time and the globe does not initialize without it; styles permit `'unsafe-inline'` and Google Fonts because Vite's dev client injects `<style>` elements.
|
- **Full Content-Security-Policy.** The dev and preview servers send a real CSP (`script-src 'self' 'unsafe-eval' blob:` plus the YouTube, Facebook and X scripts the Nepal event's embedded media loads, with a matching `frame-src` for their players and posts: no inline script and no other script origin; `blob:` covers the object URLs Cesium's bundled workers bootstrap through in the built app), alongside the existing `X-Frame-Options: DENY` and `frame-ancestors 'none'`. `'unsafe-eval'` stays because Knockout, bundled inside Cesium's widgets package, resolves the global object with `eval` at load time and the globe does not initialize without it; styles permit `'unsafe-inline'` and Google Fonts because Vite's dev client injects `<style>` elements.
|
||||||
- **ChatGPT OAuth voice reuses the local Codex sign-in.** `/api/realtime/oauth-status`, `/api/realtime/oauth-login` and `/api/realtime/token?auth=oauth` answer only loopback connections, behind the same cross-site gate as the token endpoint; the login route also refuses a foreign `Origin`. None of them returns the access token. The login route runs `codex login` (or the executable named by `CODEX_BIN`) with an allowlisted environment that leaves out GEV's provider keys, keeps one attempt pending at a time and cancels it after two minutes or when the server stops. **Limits:** any program on this machine that can reach the server can mint Realtime sessions on the signed-in ChatGPT account, as it can with the API key; a local reverse proxy that hides the client's address defeats the loopback check; and OpenAI has not documented this use of Codex credentials for Realtime, so it may stop working or fall outside the account's terms.
|
- **ChatGPT OAuth voice reuses the local Codex sign-in.** `/api/realtime/oauth-status`, `/api/realtime/oauth-login` and `/api/realtime/token?auth=oauth` answer only loopback connections, behind the same cross-site gate as the token endpoint; the login route also refuses a foreign `Origin`. None of them returns the access token. The login route runs `codex login` (or the executable named by `CODEX_BIN`) with an allowlisted environment that leaves out GEV's provider keys, keeps one attempt pending at a time and cancels it after two minutes or when the server stops. **Limits:** any program on this machine that can reach the server can mint Realtime sessions on the signed-in ChatGPT account, as it can with the API key; a local reverse proxy that hides the client's address defeats the loopback check; and OpenAI has not documented this use of Codex credentials for Realtime, so it may stop working or fall outside the account's terms.
|
||||||
|
|
||||||
@@ -84,6 +87,17 @@ The dev server is a **key broker**: every server-side key above is spendable by
|
|||||||
- **Local-only by default.** `./scripts/dev-fresh.sh` (and the Vite config itself) bind to `localhost`, so only your machine can reach the server — and only local names are accepted (`allowedHosts` stays restricted, which also blunts DNS-rebinding tricks). Binding to all interfaces keeps that restriction: IP addresses and `localhost` work, and LAN hostnames must be listed in `GEV_ALLOWED_HOSTS` (for example `GEV_ALLOWED_HOSTS=globe.lan`); suffix and wildcard entries are ignored.
|
- **Local-only by default.** `./scripts/dev-fresh.sh` (and the Vite config itself) bind to `localhost`, so only your machine can reach the server — and only local names are accepted (`allowedHosts` stays restricted, which also blunts DNS-rebinding tricks). Binding to all interfaces keeps that restriction: IP addresses and `localhost` work, and LAN hostnames must be listed in `GEV_ALLOWED_HOSTS` (for example `GEV_ALLOWED_HOSTS=globe.lan`); suffix and wildcard entries are ignored.
|
||||||
- **LAN exposure is an explicit opt-in**: `HOST=0.0.0.0 ./scripts/dev-fresh.sh`. The launcher prints a prominent warning plus your LAN URL. Understand what opting in means: **every device on that network can drive the proxies and spend your OpenAI / Google / OpenSky / AISStream / TomTom / FIRMS quota** for as long as the server runs. Do this only on networks you trust.
|
- **LAN exposure is an explicit opt-in**: `HOST=0.0.0.0 ./scripts/dev-fresh.sh`. The launcher prints a prominent warning plus your LAN URL. Understand what opting in means: **every device on that network can drive the proxies and spend your OpenAI / Google / OpenSky / AISStream / TomTom / FIRMS quota** for as long as the server runs. Do this only on networks you trust.
|
||||||
- **App-level throttles (on by default):** `GEV_RATELIMIT_OPENAI_PER_MIN` (default `30`) and `GEV_RATELIMIT_GOOGLE_PER_MIN` (default `120`) — the values the Pinokio build already ships — cap the cost-bearing endpoints per client IP per minute (over-limit requests receive a sanitized `429`). Both defaults sit well above what the app itself generates; set either to exactly `0` to run unthrottled. They are **per-IP, process-local, in-memory guards** — they reset on restart, a caller with several addresses gets a bucket per address, and they are **not billing caps**.
|
- **App-level throttles (on by default):** `GEV_RATELIMIT_OPENAI_PER_MIN` (default `30`) and `GEV_RATELIMIT_GOOGLE_PER_MIN` (default `120`) — the values the Pinokio build already ships — cap the cost-bearing endpoints per client IP per minute (over-limit requests receive a sanitized `429`). Both defaults sit well above what the app itself generates; set either to exactly `0` to run unthrottled. They are **per-IP, process-local, in-memory guards** — they reset on restart, a caller with several addresses gets a bucket per address, and they are **not billing caps**.
|
||||||
|
- **Docker trusts one address, and only because the port is on loopback.**
|
||||||
|
Docker delivers the host's own browser to the container from the container
|
||||||
|
network's gateway, never from `127.0.0.1`, so a loopback-only Provider
|
||||||
|
Settings refuses the machine running it. `scripts/docker-start.mjs` therefore
|
||||||
|
sets `GEV_KEY_SETUP_TRUSTED_PEERS` to exactly that gateway address. The list
|
||||||
|
takes exact addresses only (no wildcards, prefixes or hostnames), is unset
|
||||||
|
under every other launcher, and relaxes none of the sharing, Host, Origin,
|
||||||
|
Content-Type or proxy-header checks. It is safe precisely because
|
||||||
|
`compose.yaml` publishes on `127.0.0.1`: **widen that mapping and every LAN
|
||||||
|
client arrives from the same gateway address, which hands them Provider
|
||||||
|
Settings too.** Treat publishing beyond loopback as the LAN opt-in above.
|
||||||
- **Provider-side budgets are the real backstop.** For hard spend protection, configure limits where the money is: OpenAI platform usage limits, Google Cloud budget alerts + per-API quotas, and equivalent controls for any other keyed provider.
|
- **Provider-side budgets are the real backstop.** For hard spend protection, configure limits where the money is: OpenAI platform usage limits, Google Cloud budget alerts + per-API quotas, and equivalent controls for any other keyed provider.
|
||||||
- **Pinokio LAN and Cloudflare sharing are refused.** The current supported
|
- **Pinokio LAN and Cloudflare sharing are refused.** The current supported
|
||||||
Pinokio release re-reads sharing state when an app registers its Open URL and
|
Pinokio release re-reads sharing state when an app registers its Open URL and
|
||||||
|
|||||||
+3
-2
@@ -20,8 +20,9 @@ import { panelBuildPlugin } from './panel.js';
|
|||||||
export const BROWSER_CSP = [
|
export const BROWSER_CSP = [
|
||||||
"default-src 'self'",
|
"default-src 'self'",
|
||||||
// The Bhote Koshi event's embedded media: the YouTube player API, the
|
// The Bhote Koshi event's embedded media: the YouTube player API, the
|
||||||
// Facebook video SDK and X's post widget (src/data/bhoteKoshiEmbeddedMedia.js).
|
// Facebook video SDK and X's post widget (src/data/bhoteKoshiEmbeddedMedia.js);
|
||||||
"script-src 'self' 'unsafe-eval' blob: https://www.youtube.com https://connect.facebook.net https://platform.twitter.com",
|
// and the Maps JavaScript API that Street Level's Google Street View runs on.
|
||||||
|
"script-src 'self' 'unsafe-eval' blob: https://www.youtube.com https://connect.facebook.net https://platform.twitter.com https://maps.googleapis.com",
|
||||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
|
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
|
||||||
"font-src 'self' data: https://fonts.gstatic.com",
|
"font-src 'self' data: https://fonts.gstatic.com",
|
||||||
"img-src 'self' data: blob: https:",
|
"img-src 'self' data: blob: https:",
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
- On Windows, `npm run doctor` and the Pinokio install no longer print Node's
|
||||||
|
DEP0190 warning ("Passing args to a child process with shell option true can
|
||||||
|
lead to security vulnerabilities"): npm.cmd's fixed subcommand is now part of
|
||||||
|
the shell command string instead of an argument array (quanticsoul4772, #1057).
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
- Location search accepts two-dimensional WGS84 `geo:` URIs offline, with
|
||||||
|
the existing coordinate framing and label. Unsupported URI fields are passed
|
||||||
|
to the remaining search providers unchanged (cj-vana, #1071).
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
- Update DOMPurify to 3.4.17, Sharp to 0.35.5 and source-map-js to 1.2.2 for
|
||||||
|
GHSA-p98j-92pf-mc4p, GHSA-6688-9rhm-gjv2, GHSA-wq5f-xc86-pv6w and
|
||||||
|
GHSA-68fv-2mgg-jv7q; `npm audit` now reports no vulnerabilities (NurstarK,
|
||||||
|
#1094).
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
- Military aircraft keep their last heading and speed when an adsb.lol row omits
|
||||||
|
`track` or `gs`, instead of turning north and stopping; a grounded row without
|
||||||
|
a speed still reads 0 (Aditya, @Rohilalala, #1096).
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
- Overpass data whose element tags contain a failure phrase ("timed out",
|
||||||
|
"runtime error", "too many requests" and similar, e.g. a place named "Timed
|
||||||
|
Out Escape Rooms") is returned as data instead of being refused and putting
|
||||||
|
the endpoint on cooldown; upstream failures are read from the remark (Aditya,
|
||||||
|
@Rohilalala, #1097).
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
- Skip TLE sets whose line 1 and line 2 carry different catalog numbers, so a
|
||||||
|
spliced catalog cannot pair one satellite's elements with another's; the next
|
||||||
|
valid set still parses (VG31OP, #1101).
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
- A failed Esri Satellite or keyless terrain load still shows OSM or flat terrain
|
||||||
|
at once, but no longer lasts the whole session: while the fallback is on
|
||||||
|
screen, the real provider is retried in the background (30 seconds, doubling
|
||||||
|
to a 10-minute cap) and replaces it live once it loads. Map switches never
|
||||||
|
wait for a retry (Aditya, @Rohilalala, #1111).
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
- Exclude aircraft without a reported altitude from requested altitude bands;
|
||||||
|
unfiltered aircraft queries still retain them (rudycelekli, #1115).
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
- Release superseded Google Street View open waits and suppress metadata
|
||||||
|
requests whose viewer was unmounted while the service loaded (rudycelekli,
|
||||||
|
#1117).
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
- Label camera-tool Street View fallbacks as historical Google imagery rather
|
||||||
|
than the current camera view; keep unknown frame provenance explicit
|
||||||
|
(rudycelekli, #1118).
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
- Scenes keep the "Scene not saved" status when a capture, update, append or
|
||||||
|
import cannot be saved, and an unreadable saved project no longer overwrites
|
||||||
|
the recovery checkpoint. `startScene()` resolves to its documented result,
|
||||||
|
`afterShotId` works without an explicit scene, and `scenesRun` counts the
|
||||||
|
distinct scenes queued (djabhi31, #1135).
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
- Fire-perimeter queries handle outlines and holes crossing the antimeridian:
|
||||||
|
areas enclosed near ±180° are included, areas at the opposite meridian are
|
||||||
|
excluded, and returned perimeter coordinates stay near the dateline. Incident
|
||||||
|
cards use the same circular longitude mean and compare multipart outline
|
||||||
|
areas along short longitude edges, keeping the card on the largest polygon
|
||||||
|
(Arianna Method, @ariannamethod, #1142).
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
- Director shot actions land on the requested shot. A seek to a shot's start
|
||||||
|
could round a hair below it and select the end of the previous shot for some
|
||||||
|
shot counts and durations (Aditya, @Rohilalala, #1171).
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
- Keep missing CCTV numbers unknown instead of reading them as zero. `Number`
|
||||||
|
turns null, blank strings and booleans into 0 or 1, and the camera source
|
||||||
|
list sends an unknown number as JSON null, so a source-pack camera that left
|
||||||
|
out its pose reached the browser as zeros clamped to each field's floor —
|
||||||
|
facing north, pitch −2°, FOV 20°, range 120 m, mount 6 m and ground 0 m —
|
||||||
|
instead of the defaults (id-hash heading, −17°, 74°, 700 m, 24 m and the
|
||||||
|
city's ground); a null or blank position placed the camera at 0,0. The
|
||||||
|
server's `toFiniteNumber` and the browser's `safeNumber` now treat absent,
|
||||||
|
blank and boolean values as missing. The same conversion also stops a
|
||||||
|
Tallinn row with a null heading reading as a confident north-facing camera,
|
||||||
|
a Caltrans camera with a blank elevation losing its 150 m ground prior, and
|
||||||
|
a TfL or Catalonia camera with a null or blank coordinate being kept at 0,0
|
||||||
|
or on the zero meridian (omerbberk, #1172).
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
- Flights' ground floor works across the antimeridian. The viewer-distance
|
||||||
|
check behind the 150 km floor radius and its copy in the photoreal mesh-floor
|
||||||
|
sampler subtracted longitudes without wrapping, so two points 21 km apart
|
||||||
|
across 180° measured about 38,000 km and low or grounded aircraft just across
|
||||||
|
the line from the viewer were never floored. A contact's display corridor
|
||||||
|
walked a segment across 180° the long way round, spending its cell budget on
|
||||||
|
ground behind the contact, and a contact on the seam lost its cell hysteresis.
|
||||||
|
SEARCH NEARBY SITES on a view across 180° searched Google Places around
|
||||||
|
longitude 0, on the far side of the globe, with a radius sized from the view's
|
||||||
|
height alone; it now searches the view (Aditya, @Rohilalala, #1173).
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
- Keep missing launch-pad coordinates unknown so launches use the location
|
||||||
|
fallback or stay off the map instead of appearing at 0°N 0°E. Missing stage
|
||||||
|
flight numbers no longer show "FLIGHT 0"
|
||||||
|
(Rudy Celekli, @rudycelekli, #1186).
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
- Add nine Spanish city bike systems to Bike Share and `get_bike_share`: BiciMAD
|
||||||
|
(Madrid), Valenbisi (Valencia), Bizi Zaragoza, Sevici (Seville), BIKI
|
||||||
|
(Valladolid), BiciCoruña, Dbizi (Donostia-San Sebastián), Bilbao Bizi and
|
||||||
|
BicinRivas, about 1,840 stations. Station names read underscores as word
|
||||||
|
breaks, and station capacity counts docks rather than PBSC's geofenced area
|
||||||
|
(ulihhhh, #1190).
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
- The client's great-circle distances now come from one shared
|
||||||
|
`src/geoDistance.js` (`greatCircleKm`/`greatCircleMeters`, `{lat, lon}`
|
||||||
|
points). The Earth radius is standardized on 6371.0088 km, so distances shift
|
||||||
|
by about 1.4 ppm and region areas by about 2.8 ppm, and missing or
|
||||||
|
out-of-range points return `Infinity` (onionviolet, #287; Rajojha-1, #749).
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
- The dev server no longer watches `.gev-cache` and `.gev-logs`. Providers write
|
||||||
|
cache files and logs there while the app runs, and none of it is source, so
|
||||||
|
the watcher now skips it; this guards against the DNS-lookup delays heavy
|
||||||
|
cache writes have caused, though no difference was measured on current main
|
||||||
|
(monwiss, #761).
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
- Read satellite catalog numbers written in Alpha-5, the TLE spelling of
|
||||||
|
numbers above 99999: a letter for the first two digits, skipping I and O
|
||||||
|
(`A0000` is 100000, `Z9999` is 339999). The satellites layer converted such a
|
||||||
|
field to `NaN`, so every Alpha-5 satellite shared one catalog entry and all
|
||||||
|
but the first were dropped as duplicates; the satellite tools read the same
|
||||||
|
field as no number, so such a satellite could not be found by it. The layer
|
||||||
|
and the tools now decode the field in `src/sources/tle.js`, and reject a
|
||||||
|
malformed one (a sign, decimal point, hex, embedded space or stray letter)
|
||||||
|
instead of reading part of it as a number. This is Alpha-5 TLE
|
||||||
|
compatibility: CelesTrak's TLE feed carries no satellite above 99999, which
|
||||||
|
needs the OMM feed (#1191) (Honorius Neogy, #767, fixes #751).
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
- Changelog entries are now one file per pull request. A change adds
|
||||||
|
`changelog.d/<pull request>-<slug>.md` instead of editing the top of
|
||||||
|
`CHANGELOG.md`, which two pull requests in flight could not share — it was the
|
||||||
|
most frequent merge conflict in the open queue, and for six of them the only
|
||||||
|
one. `npm run changelog` splices the directory into `CHANGELOG.md` at release
|
||||||
|
time and empties it; `npm run changelog:check` reports the batch and changes
|
||||||
|
nothing. Fragment text is copied through unchanged (daikaginza, #787).
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
- Recent Imagery: opening DETAILS scrolls its card into view when it opens below
|
||||||
|
the fold. The panel used to restore the previous scroll position right after
|
||||||
|
the reveal, so the card stayed out of sight (Ian Blenke, #970).
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
- Cross-site browser requests can no longer spend the TomTom traffic-tile
|
||||||
|
budget through `/api/tomtom/*`. The shared same-site gate runs before status,
|
||||||
|
cache, and upstream work; same-origin app requests and non-browser local
|
||||||
|
callers remain supported (Sunil56224972, #973).
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Changelog fragments
|
||||||
|
|
||||||
|
One file per entry. A pull request adds a file here instead of editing
|
||||||
|
`CHANGELOG.md`, and a release concatenates them into it.
|
||||||
|
|
||||||
|
```
|
||||||
|
changelog.d/<pull request number>-<slug>.md
|
||||||
|
```
|
||||||
|
|
||||||
|
`723-atc-decision-layer.md`, for example. The slug is lowercase, words separated
|
||||||
|
by `-`. Two pull requests never pick the same filename, because the pull request
|
||||||
|
number is already unique — which is the point: new `CHANGELOG.md` entries go at
|
||||||
|
the top of the file, so two pull requests in flight write into the same few lines
|
||||||
|
and git has nothing to merge on.
|
||||||
|
|
||||||
|
Write the file exactly as the entry should read in `CHANGELOG.md`: a `-` bullet,
|
||||||
|
wrapped the way the surrounding entries are, crediting yourself and the pull
|
||||||
|
request.
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
- Report which upstream declined a Street Traffic road load. The layer row now
|
||||||
|
reads `Overpass rate-limited` instead of a general "Road data temporarily
|
||||||
|
unavailable" (yourname, #665).
|
||||||
|
```
|
||||||
|
|
||||||
|
The text is spliced in unchanged apart from trailing whitespace, so what you
|
||||||
|
write here is what ships. Fragments are deliberately outside
|
||||||
|
`scripts/format-scope.json`, so `npm run format` never reflows one.
|
||||||
|
|
||||||
|
At release time:
|
||||||
|
|
||||||
|
```
|
||||||
|
npm run changelog:check # list what would move, change nothing
|
||||||
|
npm run changelog # move every fragment into CHANGELOG.md
|
||||||
|
```
|
||||||
|
|
||||||
|
The assembler refuses a batch rather than guessing: a filename that is not
|
||||||
|
`<pull request>-<slug>.md`, or two fragments claiming one pull request number,
|
||||||
|
stops the run before anything is written and names the files.
|
||||||
|
|
||||||
|
If `npm run changelog` fails part-way (a full disk, a killed process), run it
|
||||||
|
again. The batch is staged in `changelog.d/.assembling/` and only lands when the
|
||||||
|
assembled file replaces `CHANGELOG.md` in a single rename, so the rerun either
|
||||||
|
finishes that batch or puts its fragments back first. It never adds an entry
|
||||||
|
twice or drops one. `npm run changelog:check` names a staged batch, if one is
|
||||||
|
left over, instead of reporting past it.
|
||||||
|
|
||||||
|
A documentation claim that needs a regression test — the way
|
||||||
|
`src/annotations/drawTool.test.mjs` asserts against `CHANGELOG.md` — should
|
||||||
|
assert against `docs/CURRENT-STATE.md` or `README.md` instead. A pull request
|
||||||
|
still edits those directly, so the text is in the file while the change is under
|
||||||
|
review; a fragment does not reach `CHANGELOG.md` until release.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
services:
|
||||||
|
gods-eye-view:
|
||||||
|
build: .
|
||||||
|
# Everything the app writes (.env with your keys, .gev-cache, .gev-logs)
|
||||||
|
# lands in the bind-mounted repo, so run as you, not root. UID/GID are
|
||||||
|
# unset on Docker Desktop, where the 1000 fallback is ignored anyway.
|
||||||
|
user: "${UID:-1000}:${GID:-1000}"
|
||||||
|
ports:
|
||||||
|
# Publish to loopback only. Docker's default ("4173:4173") would bind
|
||||||
|
# every host interface, which quietly turns the LAN warning in the README
|
||||||
|
# into the default: this server brokers whatever provider keys you have
|
||||||
|
# configured to anyone who can reach it. Widen this deliberately, not by
|
||||||
|
# accident.
|
||||||
|
- "127.0.0.1:4173:4173"
|
||||||
|
volumes:
|
||||||
|
# Bind the source so edits hot-reload and so keys pasted into the setup
|
||||||
|
# UI land in the .env on your host instead of vanishing with the
|
||||||
|
# container. node_modules stays in the image: sharp and friends are
|
||||||
|
# compiled for Linux and must not be shadowed by a host install.
|
||||||
|
- .:/app
|
||||||
|
- /app/node_modules
|
||||||
+1
-1
@@ -158,7 +158,7 @@ routing and regional caches. Request parameters cannot override these endpoints.
|
|||||||
Voice controls accept an action runner and a controller factory through
|
Voice controls accept an action runner and a controller factory through
|
||||||
`createVoiceCommands` (`./voice/commands`). The default Realtime controller owns
|
`createVoiceCommands` (`./voice/commands`). The default Realtime controller owns
|
||||||
microphone tracks, playback, push-to-talk, tool cancellation and radio handoff.
|
microphone tracks, playback, push-to-talk, tool cancellation and radio handoff.
|
||||||
Its backend supplies `requestToken({ tier, signal })` and
|
Its backend supplies `requestToken({ tier, authMode, signal })` and
|
||||||
`negotiate({ offerSdp, credential, signal })`. The Realtime-compatible adapter
|
`negotiate({ offerSdp, credential, signal })`. The Realtime-compatible adapter
|
||||||
(`./voice/realtime-backend`) accepts separate token and connection transports
|
(`./voice/realtime-backend`) accepts separate token and connection transports
|
||||||
and endpoints. Only the short-lived client secret reaches the SDP endpoint.
|
and endpoints. Only the short-lived client secret reaches the SDP endpoint.
|
||||||
|
|||||||
@@ -65,11 +65,10 @@ check, not a JavaScript sandbox. Common voice controls cannot depend on a
|
|||||||
Realtime protocol implementation. Negative fixtures cover indirect helpers,
|
Realtime protocol implementation. Negative fixtures cover indirect helpers,
|
||||||
self-package imports, symlinks and unreachable files.
|
self-package imports, symlinks and unreachable files.
|
||||||
|
|
||||||
Source factories have dedicated exports for ALPR, bikeshare, CCTV, earthquakes,
|
Source-only factories use dedicated `layers/<family>/source` exports where
|
||||||
FIRMS, installations, launches, radio, satellites and traffic. They preserve the
|
available, preserving the same factory implementation without loading rendering.
|
||||||
same factory implementations without loading layer rendering. ALPR/earthquake
|
Record normalization and endpoint policies have plain owners separate from
|
||||||
record normalization and CCTV source endpoint policy have plain owners separate
|
geometry/cards. Source exports do not start acquisition at import time.
|
||||||
from geometry/cards. Source exports do not start acquisition at import time.
|
|
||||||
|
|
||||||
Other layer `ingestion.js` files may still coordinate Cesium resources; the
|
Other layer `ingestion.js` files may still coordinate Cesium resources; the
|
||||||
portable contract applies to the explicitly reviewed graphs above. Their layer
|
portable contract applies to the explicitly reviewed graphs above. Their layer
|
||||||
@@ -630,9 +629,9 @@ explicit operations. Both exports exclude Cesium, DOM and application assembly.
|
|||||||
The snapshot renderer applies record changes; rendering owns a weak map of
|
The snapshot renderer applies record changes; rendering owns a weak map of
|
||||||
geometry and billboard resources used by cards, picking and trails.
|
geometry and billboard resources used by cards, picking and trails.
|
||||||
|
|
||||||
`gods-eye-view/sources/reference` constructs fresh earthquake and bundled cable
|
`gods-eye-view/sources/reference` constructs fresh earthquake, GNSS integrity and bundled cable
|
||||||
source instances independently of standalone setup. Individual sources remain
|
source instances independently of standalone setup. Individual sources remain
|
||||||
available through `layers/earthquakes/source` and `layers/submarine-cables/source`.
|
available through their `layers/<family>/source` exports, including `layers/gnss/source`.
|
||||||
The latter retains the bundled dataset’s attribution and licensing requirements.
|
The latter retains the bundled dataset’s attribution and licensing requirements.
|
||||||
|
|
||||||
## Geocoding and feature-query providers
|
## Geocoding and feature-query providers
|
||||||
|
|||||||
+741
-53
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,92 @@
|
|||||||
|
# UI text and translation
|
||||||
|
|
||||||
|
Every piece of text a user can see goes through `t()`, `tn()` or `msg()` in code, or a `data-i18n*` attribute in markup. The English text itself is the key. English needs no catalog; other languages live in `src/i18n/locales/<code>.json`. Translators work from [TRANSLATORS.md](./TRANSLATORS.md).
|
||||||
|
|
||||||
|
## Which one to use
|
||||||
|
|
||||||
|
| | Translates | Use for |
|
||||||
|
| ------------------------------- | --------------------------------- | --------------------------------------------- |
|
||||||
|
| `t(text, values)` | now | any text shown right where it is written |
|
||||||
|
| `tn(count, one, other, values)` | now, picking the form for `count` | text that changes with a number |
|
||||||
|
| `msg(text)` | never: returns `text` unchanged | text stored in data and passed to `t()` later |
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { t, tn, msg } from '../i18n/index.js';
|
||||||
|
|
||||||
|
status.textContent = t('Save keys');
|
||||||
|
status.textContent = t('Saved to {store}', { store: storeLabel() });
|
||||||
|
|
||||||
|
chip.textContent = tn(missing, '{count} KEY WAITING', '{count} KEYS WAITING');
|
||||||
|
// en: 1 KEY / 3 KEYS ru: 1 КЛЮЧ / 3 КЛЮЧА / 5 КЛЮЧЕЙ
|
||||||
|
|
||||||
|
const FEED_STATE_LABELS = { loading: msg('LOADING'), stale: msg('STALE') };
|
||||||
|
label.textContent = t(FEED_STATE_LABELS[state]);
|
||||||
|
```
|
||||||
|
|
||||||
|
A module the server, tools or other portable code also imports must not load the catalogs: it imports `msg` from `src/i18n/marker.js`, which only marks the text, and the UI translates it with `t()` where it shows it. (Older modules keep a local `const msg = (text) => text`, which works the same.)
|
||||||
|
|
||||||
|
`msg()` exists for two reasons. `npm run i18n` finds messages only as string literals inside `t(…)`, `tn(…)` and `msg(…)`, so `t(FEED_STATE_LABELS[state])` alone tells it nothing. And a `t()` call at module load runs before the page has picked its language, so it would freeze the English.
|
||||||
|
|
||||||
|
## Markup
|
||||||
|
|
||||||
|
```html
|
||||||
|
<span data-i18n>Normal</span>
|
||||||
|
<button title="Pin visual presets" data-i18n-title>…</button>
|
||||||
|
<input placeholder="Search any location..." data-i18n-placeholder />
|
||||||
|
```
|
||||||
|
|
||||||
|
`data-i18n` replaces the element's text, so put it on the element that directly holds the text, never on a parent with child elements. `data-i18n-title`, `data-i18n-aria-label` and `data-i18n-placeholder` translate that attribute. Markup is translated once at boot, and switching language reloads the page. A module that holds work a reload would lose (an unsaved edit, an open session) registers it with `registerUnsavedWork(() => busy ? msg('unsaved …') : null)` from `src/i18n/index.js`, and the language picker asks before reloading.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- **Pass literals.** `t('Close')`, not `t(label)` (unless `label` holds a `msg()` value) and never a template literal with `${…}`.
|
||||||
|
- **Use placeholders, not concatenation.** `t('Flying to {place}…', { place })`, not `t('Flying to') + ' ' + place`. Word order differs between languages.
|
||||||
|
- **Use `tn()` for counts**, not `count === 1 ? … : …`. Russian has three forms.
|
||||||
|
- **Translate the label, not the value.** Layer ids, enums, share-link parameters, API fields, voice tool schemas and feed data (callsigns, vessel and place names) stay as they are; translate only the text shown for them.
|
||||||
|
- **No `t()` at module top level.** Keep the English in `msg()` and call `t()` when rendering.
|
||||||
|
- **Numbers and dates** go through `formatNumber`, `formatDate`, `formatRelativeTime` and `formatUtcDayMonth` from `src/i18n/index.js`, which follow the active language.
|
||||||
|
- **Identical English means identical translation,** unless you give it a context: `t('CLEAR', { context: 'action' })` (a button) is its own catalog entry, `action|CLEAR`, while `t('CLEAR')` (the sky) stays separate. English output is the same.
|
||||||
|
- **Text that should stay English** (a brand, a code, a unit symbol) is simply not wrapped.
|
||||||
|
|
||||||
|
## After you add or change text
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run i18n # what each language lacks or no longer uses, and unmarked UI text
|
||||||
|
npm run i18n -- --missing ru # the missing ru entries as JSON
|
||||||
|
npm run i18n -- --prune # drop entries whose English no longer exists
|
||||||
|
```
|
||||||
|
|
||||||
|
Changing the English changes the key: the old translation goes unused and the new text shows as missing. Add the translation to every `src/i18n/locales/<code>.json` (or ask a translator), prune, and run `npm test`; it fails until every language is complete.
|
||||||
|
|
||||||
|
To see a language, open the app with `?lang=ru`, or use the language picker (the globe button in the bottom-right corner).
|
||||||
|
|
||||||
|
An empty translation is reported, and so is one left as the English, plural forms included. When English is right on purpose (a code such as CRT, a product name), list it under the language in `src/i18n/same-as-english.json`.
|
||||||
|
|
||||||
|
## Text the check reports as unmarked
|
||||||
|
|
||||||
|
`npm run i18n` and `npm test` fail on UI text that is not marked:
|
||||||
|
|
||||||
|
- **Markup:** element text without `data-i18n`, and `title`, `aria-label` or `placeholder` without the matching `data-i18n-*`.
|
||||||
|
- **Code:** string literals that reach the page without `t()`, `tn()` or `msg()`: assigned to `textContent`, `title` and the like; passed to `setAttribute('title' | 'aria-label' | …)`, `createTextNode`, `showToast` and other notice helpers; assigned to a layer's `error`, `lastError`, `loadingLabel`, `statusMessage` or `status` (a plain `error = …` too); or kept in `label:`, `title:`, `status:` and similar properties. Not reported: declarations (`let status = …`), default values, ternary branches and lower-case codes (`'auth-failed'`). The full list is `JS_SINKS` in `scripts/i18n.mjs`.
|
||||||
|
|
||||||
|
Fix it by marking the text. When the text must stay as it is, say so where it lives:
|
||||||
|
|
||||||
|
| Case | How |
|
||||||
|
| --------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
|
||||||
|
| Codes, units, product names in markup (`0.9 dB`, `ADS-B · 1090`, `OpenSky`) | `translate="no"` on the element; it covers everything inside, as in HTML |
|
||||||
|
| Markup whose text code sets through `t()` (a status that starts as `OFF`) | `data-i18n-runtime` on the element |
|
||||||
|
| A literal in code that is not UI copy (a product name, sample data) | `// i18n-ignore: <reason>` on the line or the line above |
|
||||||
|
|
||||||
|
Icons (`aria-hidden="true"`, Material Symbols), comments, CSS values and lowercase identifiers are skipped automatically, and so is `src/tools/`, whose text is written for AI clients rather than the page.
|
||||||
|
|
||||||
|
## Where things live
|
||||||
|
|
||||||
|
| File | What it holds |
|
||||||
|
| ------------------------------- | ------------------------------------------------------------------------------ |
|
||||||
|
| `src/i18n/index.js` | `t`, `tn`, `msg`, the Intl formatters, markup translation, switching language |
|
||||||
|
| `src/i18n/marker.js` | `msg` alone, for modules the server or tools import (no catalogs) |
|
||||||
|
| `src/i18n/locale.js` | shipped languages, their names, choosing the language at boot |
|
||||||
|
| `src/i18n/locales/<code>.json` | one catalog per language, keyed by English text |
|
||||||
|
| `src/i18n/same-as-english.json` | per language, the entries that stay as in English on purpose |
|
||||||
|
| `scripts/i18n.mjs` | `npm run i18n`: extracts messages, checks the catalogs, finds unmarked UI text |
|
||||||
|
| `src/i18n/catalog.test.mjs` | runs that check under `npm test` |
|
||||||
@@ -49,7 +49,8 @@ failure elsewhere. These questions guide review, not a mandatory questionnaire.
|
|||||||
regressions? Prefer fixes whose tests fail on the base. Test-only PRs should
|
regressions? Prefer fixes whose tests fail on the base. Test-only PRs should
|
||||||
close a real coverage gap with deterministic fixtures and useful assertions.
|
close a real coverage gap with deterministic fixtures and useful assertions.
|
||||||
Inspect removed/skipped tests, weakened assertions, mocks, snapshots, and runner
|
Inspect removed/skipped tests, weakened assertions, mocks, snapshots, and runner
|
||||||
changes. Update `CURRENT-STATE.md` and `CHANGELOG.md` for runtime changes, and
|
changes. Update `CURRENT-STATE.md` and add a `changelog.d/<pr-number>-<slug>.md`
|
||||||
|
fragment (not an edit to `CHANGELOG.md`) for runtime changes, and
|
||||||
`DATA_SOURCES.md` for source changes.
|
`DATA_SOURCES.md` for source changes.
|
||||||
|
|
||||||
## 3. Choose execution proportionate to risk
|
## 3. Choose execution proportionate to risk
|
||||||
@@ -124,6 +125,15 @@ Condition the merge on the reviewed head where supported; if revisions change,
|
|||||||
return to validation. Verify the merged tree matches the validated candidate and
|
return to validation. Verify the merged tree matches the validated candidate and
|
||||||
record the merge commit/PR URL; stop on mismatch.
|
record the merge commit/PR URL; stop on mismatch.
|
||||||
|
|
||||||
|
Pending release notes live in `changelog.d/`, one file per PR, so integration never
|
||||||
|
resolves the top of `CHANGELOG.md`. At release, `npm run changelog:check` previews
|
||||||
|
the batch and its order; `npm run changelog` splices it into `CHANGELOG.md` and
|
||||||
|
empties the directory. The assembler refuses a malformed filename or two fragments
|
||||||
|
claiming one PR number before writing, and copies text through unchanged so the
|
||||||
|
contributor's wording and credit ship. The write is staged in
|
||||||
|
`changelog.d/.assembling/`, so if it fails part-way, rerunning `npm run changelog`
|
||||||
|
finishes or rolls back the batch. It never duplicates or drops an entry.
|
||||||
|
|
||||||
Return this concise record privately unless posting is authorized. Keep sensitive
|
Return this concise record privately unless posting is authorized. Keep sensitive
|
||||||
security evidence in the private report:
|
security evidence in the private report:
|
||||||
|
|
||||||
|
|||||||
@@ -128,6 +128,12 @@ Use the same controls before attributing a difference to the application:
|
|||||||
6. Record live object counts before attributing a difference to the client.
|
6. Record live object counts before attributing a difference to the client.
|
||||||
7. Treat a live-source outage as missing coverage, not as evidence of low client
|
7. Treat a live-source outage as missing coverage, not as evidence of low client
|
||||||
rendering cost.
|
rendering cost.
|
||||||
|
8. Record whether the app is running in Pinokio's embedded view or in an
|
||||||
|
external browser. One Pinokio 8.2.0 user reported severe stutter in the
|
||||||
|
embedded view that went away when the same local URL was opened in a normal
|
||||||
|
browser ([#365](https://github.com/bilawalsidhu/gods-eye-view/issues/365)).
|
||||||
|
Do not compare embedded-view and external-browser measurements directly;
|
||||||
|
compare the reported GPU renderer and matched scene settings first.
|
||||||
|
|
||||||
## What is not established yet
|
## What is not established yet
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -273,7 +273,7 @@ and the development server's internal routes. See SECURITY.md.
|
|||||||
| `next_satellite_pass` | `satellites` | Next pass over a place or point (default the ISS), with naked-eye visibility |
|
| `next_satellite_pass` | `satellites` | Next pass over a place or point (default the ISS), with naked-eye visibility |
|
||||||
| `satellites_overhead` | `satellites` | Satellites in a CelesTrak group above a place or point now, highest first |
|
| `satellites_overhead` | `satellites` | Satellites in a CelesTrak group above a place or point now, highest first |
|
||||||
| `find_cctv_cameras` | `cctv` | Public cameras in an area, nearest first |
|
| `find_cctv_cameras` | `cctv` | Public cameras in an area, nearest first |
|
||||||
| `get_cctv_snapshot` | `cctv` | The current image from one camera, returned as image content |
|
| `get_cctv_snapshot` | `cctv` | An image from one camera, returned as image content; Street View fallbacks are labelled historical |
|
||||||
| `find_alpr_cameras` | `alpr` | OpenStreetMap-mapped license plate readers in a US/Canadian area up to 3° |
|
| `find_alpr_cameras` | `alpr` | OpenStreetMap-mapped license plate readers in a US/Canadian area up to 3° |
|
||||||
| `find_radio_stations` | `radio` | Radio Browser stations by area and/or search terms, with stream URLs |
|
| `find_radio_stations` | `radio` | Radio Browser stations by area and/or search terms, with stream URLs |
|
||||||
| `search_places` | `placeSearch` | Points of interest matching a query within an area (Google Places) |
|
| `search_places` | `placeSearch` | Points of interest matching a query within an area (Google Places) |
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# Translating God's Eye View
|
||||||
|
|
||||||
|
Shipped languages: **English** (source and fallback), **Spanish** (`es`) and **Russian** (`ru`).
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
The English text is the key: code calls `t('Save keys')`, and markup marks elements with `data-i18n`. How developers mark text is in [I18N.md](./I18N.md).
|
||||||
|
|
||||||
|
Every other language is one flat JSON file, `src/i18n/locales/<code>.json`, keyed by the English text:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Save keys": "Сохранить ключи",
|
||||||
|
"{count} cameras": {
|
||||||
|
"one": "{count} камера",
|
||||||
|
"few": "{count} камеры",
|
||||||
|
"many": "{count} камер",
|
||||||
|
"other": "{count} камеры"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A `tn()` entry is keyed by the English plural form and holds one string per `Intl.PluralRules` category of that language. A missing entry falls back to English.
|
||||||
|
|
||||||
|
The page picks its language from `?lang=`, then the choice stored by the language picker (the globe button in the bottom-right corner), then the browser languages, then English. `?lang=` is not stored, and copied share links leave it out. Switching reloads the page.
|
||||||
|
|
||||||
|
## The check
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run i18n # what is missing, unused or broken, per language
|
||||||
|
npm run i18n -- --missing ru # missing ru entries as JSON, ready to translate
|
||||||
|
npm run i18n -- --prune # drop entries the code no longer uses
|
||||||
|
```
|
||||||
|
|
||||||
|
`npm test` runs the same check. It fails on a missing, empty or unused entry, on placeholders that differ from the English, on a missing plural form, and on text or plural forms still in English. The `--missing` template fails until every entry is translated. If an entry is right in English (a code such as CRT, a product name), add it under your language code in `src/i18n/same-as-english.json`.
|
||||||
|
|
||||||
|
Changing English text changes its key: the old translation becomes unused and the new text shows as missing. Translate it and prune.
|
||||||
|
|
||||||
|
## Fixing a translation
|
||||||
|
|
||||||
|
Edit `src/i18n/locales/<code>.json` and run `npm run i18n`. To see your change, open the app with `?lang=<code>`.
|
||||||
|
|
||||||
|
## Adding a language
|
||||||
|
|
||||||
|
1. Create `src/i18n/locales/<code>.json`, fill it from `npm run i18n -- --missing <code>` (start from `{}`), and translate every entry.
|
||||||
|
2. Import it in `src/i18n/index.js` and add it to `CATALOGS`.
|
||||||
|
3. Add the code to `LOCALES` and its own-language name to `LOCALE_NAMES` in `src/i18n/locale.js`. Right-to-left languages also go in `RTL_LOCALES`.
|
||||||
|
4. Add `src/i18n/locales/<code>.json` next to `ru.json` in `scripts/package-boundaries.json`.
|
||||||
|
5. Write `docs/translations/<code>.md`: glossary, plural forms, units and style (copy the structure of `ru.md`).
|
||||||
|
6. Run `npm run i18n` and `npm test`, then check the panels in the app at narrow widths.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- Keep every `{placeholder}` name exactly as in the English: do not rename, drop or add one. Its position inside the sentence follows the grammar of your language, e.g. `"{accepted} of {raw} records accepted"` → `"принято записей: {accepted} из {raw}"`.
|
||||||
|
- UPPERCASE English stays UPPERCASE. Short labels, chips and HUD readouts should stay short; prefer an abbreviation over a wrapped line.
|
||||||
|
- Keep the separators `·`, `—`, `→`, `↗`, `…`.
|
||||||
|
- Leave product, provider and data names in their original form: Google Maps, Cesium ion, OpenAI, ChatGPT, NASA FIRMS, USGS, OpenSky, ADS-B, AIS, CCTV, HUD, TomTom, OpenStreetMap, Mapillary, and keyboard keys (Space, Esc, H, 1–9).
|
||||||
|
- Units follow the language's convention; see the language notes.
|
||||||
|
|
||||||
|
## Never translated
|
||||||
|
|
||||||
|
These are contracts, not copy: layer ids and registry keys, status enums and other machine values (translate only the label shown for them), share-link and hash parameters, `?lang=`, storage schemas, API fields, voice tool names and schemas, provider and dataset names, data from feeds (callsigns, vessel, satellite, station and place names, headlines), attribution, console diagnostics, and user-authored content.
|
||||||
|
|
||||||
|
## Language notes
|
||||||
|
|
||||||
|
Each language keeps its glossary, plural forms, units and style in its own file, `docs/translations/<code>.md`: [Spanish](./translations/es.md), [Russian](./translations/ru.md). Read it before translating, and add a term there when you settle on one.
|
||||||
@@ -8,6 +8,7 @@ The session adapter, action runner and backend interfaces remain unchanged.
|
|||||||
| Owner | State and responsibility |
|
| Owner | State and responsibility |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `realtimeConnection.js` | Start generation, request cancellation, peer/data channel, acquired microphone stream and playback element |
|
| `realtimeConnection.js` | Start generation, request cancellation, peer/data channel, acquired microphone stream and playback element |
|
||||||
|
| `realtimeBackend.js` | Token and SDP requests, their separate transports, the abort/timeout scope around both, and client-secret expiry |
|
||||||
| `realtimeTurns.js` | Active and superseded responses, pending confirmations, deduplicated calls and action cancellation |
|
| `realtimeTurns.js` | Active and superseded responses, pending confirmations, deduplicated calls and action cancellation |
|
||||||
| `realtimeRadio.js` | Speaker ducking, prepared playback, stronger-action reservations and playback observers |
|
| `realtimeRadio.js` | Speaker ducking, prepared playback, stronger-action reservations and playback observers |
|
||||||
| `realtimeInput.js` | Physical Space gesture, microphone mute, input/output meters and their frame/listener lifetime |
|
| `realtimeInput.js` | Physical Space gesture, microphone mute, input/output meters and their frame/listener lifetime |
|
||||||
@@ -21,6 +22,15 @@ The session adapter, action runner and backend interfaces remain unchanged.
|
|||||||
| `resultDisplay.js` | What the voice card shows for a result and which items it numbers (read by the card and `referents.js`) |
|
| `resultDisplay.js` | What the voice card shows for a result and which items it numbers (read by the card and `referents.js`) |
|
||||||
| `gevActions.js` runner | Analyst follow-up memory (session: `resetConversation()` on stop, `dispose()` on removal) |
|
| `gevActions.js` runner | Analyst follow-up memory (session: `resetConversation()` on stop, `dispose()` on removal) |
|
||||||
|
|
||||||
|
The backend is replaceable per deployment: `realtimeController.js` accepts it as a
|
||||||
|
`backend` option and falls back to `createRealtimeBackend()`. Its two method
|
||||||
|
signatures live with [application construction](APPLICATION.md); what belongs here
|
||||||
|
is the ownership. The connection is the backend's only consumer in the application.
|
||||||
|
The connection reads credential metadata for diagnostics and cost accounting,
|
||||||
|
then passes the complete credential unchanged to `negotiate`. Client-secret expiry is refused inside the backend, not by the connection.
|
||||||
|
`realtimeOwners.test.mjs` drives whole sessions through a substitute that
|
||||||
|
implements only those two methods.
|
||||||
|
|
||||||
Pure input policy, preferences and protocol-response policy have separate modules.
|
Pure input policy, preferences and protocol-response policy have separate modules.
|
||||||
Owners receive named readers, operations or focused collaborators. Cross-owner
|
Owners receive named readers, operations or focused collaborators. Cross-owner
|
||||||
cancellation uses operations; response handling does not own Radio's reservations
|
cancellation uses operations; response handling does not own Radio's reservations
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# Spanish (`es`)
|
||||||
|
|
||||||
|
Catalog: `src/i18n/locales/es.json`. General rules: [TRANSLATORS.md](../TRANSLATORS.md).
|
||||||
|
|
||||||
|
## Style
|
||||||
|
|
||||||
|
- Neutral Spanish that reads naturally in Spain and Latin America; avoid regionalisms.
|
||||||
|
- Address the user with informal "tú" imperatives: «Añade», «Activa», «Elige», «Pega», «Inténtalo de nuevo». Tooltips that name an action may use the infinitive: «Expandir el panel», «Restablecer la cámara».
|
||||||
|
- Sentence case, not Title Case: "Live Flights" → «Vuelos en vivo». UPPERCASE English stays UPPERCASE: «EN VIVO», «SIN DATOS».
|
||||||
|
- Quotes inside Spanish text are straight double quotes: «¿Eliminar el plano "{shot}"?». Questions and exclamations open with ¿ and ¡.
|
||||||
|
- Short labels, chips and HUD readouts stay short. A standard abbreviation with a period beats a wrapped line: ANT., SIG., VEL., DIST., SINCR., cám., cob.
|
||||||
|
- The HUD codes HDG, BRG, CRS, FOV, DEST, SPD, RANGE and ALT stay in English everywhere, as in cockpit avionics. Words used in a sentence are translated: heading → «rumbo», range → «alcance».
|
||||||
|
- Keyboard keys stay in English: Space, Esc, Enter, Tab, Shift, H, 1–9 («Mantén pulsado Space para hablar»).
|
||||||
|
- Units: km, m, km/h, m/s, kn (knots), ft (feet), h, min, s; in UPPERCASE text KM, KM/S, KN, FT, MIN, H. "m" is always metres, so minutes are «min» («hace 5 min»). MW stays MW. `°` and `%` stay.
|
||||||
|
- "ago" goes before the time: «hace {count} min». When a placeholder already holds a localized "ago" text (`{age}`, `{ago}`), do not add «hace» again.
|
||||||
|
- Spelling: «costo» (not «coste»).
|
||||||
|
|
||||||
|
## Plurals
|
||||||
|
|
||||||
|
A `tn()` entry needs three forms. `Intl.PluralRules('es')` picks:
|
||||||
|
|
||||||
|
| Form | Counts | Example |
|
||||||
|
| ------- | -------------------------------------- | -------------------- |
|
||||||
|
| `one` | 1 | 1 cámara |
|
||||||
|
| `many` | 1 000 000, 2 000 000… (exact millions) | 1 000 000 de cámaras |
|
||||||
|
| `other` | everything else: 0, 2–999 999, 1.5… | 3 cámaras |
|
||||||
|
|
||||||
|
The `many` form is the `other` text with «de» after the number: «{count} de cámaras cargadas», «{count} DE CLAVES EN ESPERA».
|
||||||
|
|
||||||
|
## Glossary
|
||||||
|
|
||||||
|
| English | Español |
|
||||||
|
| --------------------------------------------- | ----------------------------------------------------------------- |
|
||||||
|
| layer / data layer | capa / capa de datos |
|
||||||
|
| feed / source / data source | fuente / fuente / fuente de datos |
|
||||||
|
| LIVE (status) / live data | EN VIVO / datos en vivo |
|
||||||
|
| SIMULATED | SIMULADO |
|
||||||
|
| stale / degraded / unavailable | desactualizado / degradado / no disponible |
|
||||||
|
| retry / retrying | reintento / reintentando |
|
||||||
|
| key (API key) / POWER UP / POWERED UP | clave / ENCENDER / ENCENDIDO |
|
||||||
|
| Provider Settings | Ajustes de proveedores |
|
||||||
|
| ON / OFF (state chips) | ACTIVO / INACTIVO (short enough for the layer pill) |
|
||||||
|
| enable / disable | activar / desactivar |
|
||||||
|
| globe / map / map source / map stack | globo / mapa / fuente del mapa / conjunto de mapas |
|
||||||
|
| aircraft, plane / flight / military flights | aeronave / vuelo / vuelos militares |
|
||||||
|
| vessel, ship | buque |
|
||||||
|
| satellite / shell (Starlink) / DENSE | satélite / capa / DENSO |
|
||||||
|
| launch (rocket) / launch site / space mission | lanzamiento / lugar de lanzamiento / misión espacial |
|
||||||
|
| ascent / replay / payload / stage | ascenso / repetición / carga útil / etapa |
|
||||||
|
| contact / tracking | contacto / rastreo |
|
||||||
|
| installation / site | instalación / sitio |
|
||||||
|
| mapped / observed | mapeado / observado |
|
||||||
|
| camera / CCTV camera | cámara / cámara CCTV |
|
||||||
|
| earthquake / active fire | terremoto / incendio activo |
|
||||||
|
| radio station | emisora |
|
||||||
|
| road / flow (traffic) | vía / flujo |
|
||||||
|
| scene / shot / scene recipe | escena / plano / receta de escena |
|
||||||
|
| briefing / panel / dock | información / panel / dock |
|
||||||
|
| cockpit | cabina |
|
||||||
|
| preset / visual preset | preajuste / preajuste visual |
|
||||||
|
| Scope (circular viewport mask) / keyhole | mirilla / ojo de cerradura |
|
||||||
|
| feather / fade | difuminado / desvanecimiento |
|
||||||
|
| detection overlay | superposición de detección |
|
||||||
|
| pin (Draw tool) | marcador |
|
||||||
|
| Bloom / Glow (visual effect) | resplandor |
|
||||||
|
| Cyber (HUD layout, effect) | Ciber |
|
||||||
|
| heading, course / altitude / ground speed | rumbo / altitud / velocidad respecto al suelo (short: VEL. SUELO) |
|
||||||
|
| HDG / BRG / CRS / FOV / DEST / SPD / RANGE | HDG / BRG / CRS / FOV / DEST / SPD / RANGE (unchanged) |
|
||||||
|
| PREV / NEXT | ANT. / SIG. |
|
||||||
|
| Previous / Next (button text) | Anterior / Siguiente |
|
||||||
|
| L / R (relative bearing) | I / D |
|
||||||
|
| SIG / NEWS / LOCAL (briefing tabs) | SIG / NOTICIAS / LOCAL |
|
||||||
|
| PITCH / HGT (CCTV calibration chips) | INCL. / ALT. |
|
||||||
|
| NOT AN ALL-CLEAR | NO IMPLICA TODO DESPEJADO |
|
||||||
|
| click / click to … | haz clic / clic para … |
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# Russian (`ru`)
|
||||||
|
|
||||||
|
Catalog: `src/i18n/locales/ru.json`. General rules: [TRANSLATORS.md](../TRANSLATORS.md).
|
||||||
|
|
||||||
|
## Style
|
||||||
|
|
||||||
|
- Address the user with polite imperatives: «Нажмите», «Включите», «Попробуйте снова».
|
||||||
|
- Sentence case, not Title Case: "Live Flights" → «Рейсы в реальном времени».
|
||||||
|
- Quotes inside Russian text are «ёлочки». Write ё where it belongs: её, ещё, всё.
|
||||||
|
- In short labels, a standard abbreviation with a period beats a wrapped line: НЕОПРЕД., СЛЕД.
|
||||||
|
- Units: км, м, км/ч, м/с, уз (knots), фт (feet), ч, мин, с; in UPPERCASE text КМ, М, КМ/С, УЗ, ФТ. MW is МВт in any case. `°` and `%` stay.
|
||||||
|
|
||||||
|
## Plurals
|
||||||
|
|
||||||
|
A `tn()` entry needs four forms. `Intl.PluralRules('ru')` picks:
|
||||||
|
|
||||||
|
| Form | Counts | Example |
|
||||||
|
| ------- | ------------------------ | ---------- |
|
||||||
|
| `one` | 1, 21, 31, 101… | 1 камера |
|
||||||
|
| `few` | 2–4, 22–24, 32–34… | 3 камеры |
|
||||||
|
| `many` | 0, 5–20, 25–30, 111–114… | 5 камер |
|
||||||
|
| `other` | fractions: 1.5, 2.25… | 1,5 камеры |
|
||||||
|
|
||||||
|
When a word next to a number cannot agree in every form, move the number after a colon instead: «доступно {bikes}», «ответов: {responses}». The same trick keeps place names and tool names out of a preposition's case: «откуда: {origin}», «Активно: {name}».
|
||||||
|
|
||||||
|
## Glossary
|
||||||
|
|
||||||
|
| English | Русский |
|
||||||
|
| ------------------------------------------- | ------------------------------------------------------------------ |
|
||||||
|
| layer / data layer | слой / слой данных |
|
||||||
|
| feed | поток данных (short: поток) |
|
||||||
|
| source / data source | источник / источник данных |
|
||||||
|
| LIVE (status) / live data | ОНЛАЙН / в реальном времени |
|
||||||
|
| SIMULATED | СИМУЛЯЦИЯ |
|
||||||
|
| stale / degraded / unavailable | устарел / с перебоями / недоступен |
|
||||||
|
| retry | повтор |
|
||||||
|
| key (API key) / POWER UP | ключ / ЗАПУСК |
|
||||||
|
| Provider Settings | Настройки провайдеров |
|
||||||
|
| globe / map / map source | глобус / карта / источник карты |
|
||||||
|
| aircraft / flight / military flights | самолёт (short HUD: борт) / рейс / военные рейсы |
|
||||||
|
| vessel, ship / vessels | судно / суда |
|
||||||
|
| satellite | спутник |
|
||||||
|
| launch (rocket) / space mission | пуск / космическая миссия |
|
||||||
|
| contact / tracking | контакт / отслеживание |
|
||||||
|
| installation, site | объект |
|
||||||
|
| camera / CCTV camera | камера / камера CCTV |
|
||||||
|
| earthquake / active fire | землетрясение / активный пожар |
|
||||||
|
| scene / shot / scene recipe | сцена / кадр / рецепт сцены |
|
||||||
|
| briefing / panel / dock | брифинг / панель / док |
|
||||||
|
| preset / visual preset | пресет / визуальный пресет |
|
||||||
|
| Scope (circular viewport mask) | прицел |
|
||||||
|
| heading / altitude / ground speed | курс / высота / путевая скорость |
|
||||||
|
| HDG / BRG / CRS / FOV / DEST / SPD / RANGE | КУРС / ПЕЛ. / ПУТЬ / ОБЗОР / НАЗН. / СКОР / ДАЛЬН. |
|
||||||
|
| advisory (NHC/CPHC cyclone) | сводка (не «бюллетень»); Advisory center — центр циклона по сводке |
|
||||||
|
| Street View (Google) | Просмотр улиц (как в русских Google Картах) |
|
||||||
|
| CONUS (NOAA region code) | CONUS, без перевода |
|
||||||
|
| WHOT/BHOT, Ironbow (thermal modes, palette) | без перевода |
|
||||||
|
| PREV / NEXT | ПРЕД. / СЛЕД. |
|
||||||
|
| Previous / Next (button text) | Назад / Далее |
|
||||||
|
| anchor / data pack / bundle (scene) | якорь / пакет данных / архив |
|
||||||
|
| basemap / box (Recent Imagery area) | подложка / область |
|
||||||
|
| camera move | перелёт |
|
||||||
|
| SIG / NEWS / LOCAL (briefing tabs) | СИГН. / НОВОСТИ / МЕСТН. |
|
||||||
|
| compass points (N, NNE, …) | С, ССВ, … |
|
||||||
|
| L / R (relative bearing) | Л / П |
|
||||||
+14
-1
@@ -4,7 +4,20 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>God's Eye View</title>
|
<title>God's Eye View</title>
|
||||||
|
<!-- Raster fallbacks: Safari ignores an SVG apple-touch-icon and falls back to a
|
||||||
|
screenshot of the page, so an installed app needs a real PNG. Regenerate
|
||||||
|
these from the logo with `node scripts/generate-icons.mjs`. -->
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="/icons/icon-32.png" />
|
||||||
|
<!-- Listed after the PNG: browsers that use the last usable icon keep the SVG
|
||||||
|
for the tab. -->
|
||||||
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/icons/icon-180.png" />
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
|
<meta name="theme-color" content="#0a0a0f" />
|
||||||
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="God's Eye" />
|
||||||
<link rel="stylesheet" href="/style.css" />
|
<link rel="stylesheet" href="/style.css" />
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
@@ -13,7 +26,7 @@
|
|||||||
with the sources: src/materialSymbolsSubset.test.mjs fails when a source
|
with the sources: src/materialSymbolsSubset.test.mjs fails when a source
|
||||||
names a glyph this list is missing, because a missing glyph does not draw
|
names a glyph this list is missing, because a missing glyph does not draw
|
||||||
a box — the ligature never forms and the element renders the literal word. -->
|
a box — the ligature never forms and the element renders the literal word. -->
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=adjust,arrow_drop_down,arrow_forward,arrow_left,arrow_right,bolt,chevron_left,chevron_right,close,close_fullscreen,dark_mode,draw,east,flare,flight,layers_clear,light_mode,local_fire_department,my_location,navigation,normal,on,open_in_full,public,radar,radio,right_panel_close,right_panel_open,rocket_launch,skip_next,skip_previous,view_in_ar" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=adjust,arrow_drop_down,arrow_forward,arrow_left,arrow_right,bolt,chevron_left,chevron_right,close,close_fullscreen,dark_mode,draw,east,flare,flight,layers_clear,light_mode,local_fire_department,my_location,navigation,normal,on,open_in_full,public,radar,radio,right_panel_close,right_panel_open,rocket_launch,skip_next,skip_previous,upload_file,view_in_ar,visibility,visibility_off" rel="stylesheet" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- gev:template scene-chrome -->
|
<!-- gev:template scene-chrome -->
|
||||||
|
|||||||
Generated
+124
-124
@@ -548,9 +548,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-darwin-arm64": {
|
"node_modules/@img/sharp-darwin-arm64": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz",
|
||||||
"integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==",
|
"integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -567,13 +567,13 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-darwin-arm64": "1.3.3"
|
"@img/sharp-libvips-darwin-arm64": "1.3.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-darwin-x64": {
|
"node_modules/@img/sharp-darwin-x64": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz",
|
||||||
"integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==",
|
"integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -590,13 +590,13 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-darwin-x64": "1.3.3"
|
"@img/sharp-libvips-darwin-x64": "1.3.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-freebsd-wasm32": {
|
"node_modules/@img/sharp-freebsd-wasm32": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz",
|
||||||
"integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==",
|
"integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
@@ -604,7 +604,7 @@
|
|||||||
"freebsd"
|
"freebsd"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@img/sharp-wasm32": "0.35.4"
|
"@img/sharp-wasm32": "0.35.5"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.9.0"
|
"node": ">=20.9.0"
|
||||||
@@ -614,9 +614,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz",
|
||||||
"integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==",
|
"integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -631,9 +631,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-darwin-x64": {
|
"node_modules/@img/sharp-libvips-darwin-x64": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz",
|
||||||
"integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==",
|
"integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -648,9 +648,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-linux-arm": {
|
"node_modules/@img/sharp-libvips-linux-arm": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz",
|
||||||
"integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==",
|
"integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -665,9 +665,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-linux-arm64": {
|
"node_modules/@img/sharp-libvips-linux-arm64": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz",
|
||||||
"integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==",
|
"integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -682,9 +682,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-linux-ppc64": {
|
"node_modules/@img/sharp-libvips-linux-ppc64": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz",
|
||||||
"integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==",
|
"integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@@ -699,9 +699,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-linux-riscv64": {
|
"node_modules/@img/sharp-libvips-linux-riscv64": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz",
|
||||||
"integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==",
|
"integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -716,9 +716,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-linux-s390x": {
|
"node_modules/@img/sharp-libvips-linux-s390x": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz",
|
||||||
"integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==",
|
"integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
@@ -733,9 +733,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-linux-x64": {
|
"node_modules/@img/sharp-libvips-linux-x64": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz",
|
||||||
"integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==",
|
"integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -750,9 +750,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz",
|
||||||
"integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==",
|
"integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -767,9 +767,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz",
|
||||||
"integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==",
|
"integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -784,9 +784,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-linux-arm": {
|
"node_modules/@img/sharp-linux-arm": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz",
|
||||||
"integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==",
|
"integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -803,13 +803,13 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-linux-arm": "1.3.3"
|
"@img/sharp-libvips-linux-arm": "1.3.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-linux-arm64": {
|
"node_modules/@img/sharp-linux-arm64": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz",
|
||||||
"integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==",
|
"integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -826,13 +826,13 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-linux-arm64": "1.3.3"
|
"@img/sharp-libvips-linux-arm64": "1.3.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-linux-ppc64": {
|
"node_modules/@img/sharp-linux-ppc64": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz",
|
||||||
"integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==",
|
"integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@@ -849,13 +849,13 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-linux-ppc64": "1.3.3"
|
"@img/sharp-libvips-linux-ppc64": "1.3.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-linux-riscv64": {
|
"node_modules/@img/sharp-linux-riscv64": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz",
|
||||||
"integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==",
|
"integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -872,13 +872,13 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-linux-riscv64": "1.3.3"
|
"@img/sharp-libvips-linux-riscv64": "1.3.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-linux-s390x": {
|
"node_modules/@img/sharp-linux-s390x": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz",
|
||||||
"integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==",
|
"integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
@@ -895,13 +895,13 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-linux-s390x": "1.3.3"
|
"@img/sharp-libvips-linux-s390x": "1.3.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-linux-x64": {
|
"node_modules/@img/sharp-linux-x64": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz",
|
||||||
"integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==",
|
"integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -918,13 +918,13 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-linux-x64": "1.3.3"
|
"@img/sharp-libvips-linux-x64": "1.3.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-linuxmusl-arm64": {
|
"node_modules/@img/sharp-linuxmusl-arm64": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz",
|
||||||
"integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==",
|
"integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -941,13 +941,13 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.3"
|
"@img/sharp-libvips-linuxmusl-arm64": "1.3.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-linuxmusl-x64": {
|
"node_modules/@img/sharp-linuxmusl-x64": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz",
|
||||||
"integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==",
|
"integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -964,13 +964,13 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.3"
|
"@img/sharp-libvips-linuxmusl-x64": "1.3.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-wasm32": {
|
"node_modules/@img/sharp-wasm32": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz",
|
||||||
"integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==",
|
"integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
@@ -985,9 +985,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-webcontainers-wasm32": {
|
"node_modules/@img/sharp-webcontainers-wasm32": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz",
|
||||||
"integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==",
|
"integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"wasm32"
|
"wasm32"
|
||||||
],
|
],
|
||||||
@@ -995,7 +995,7 @@
|
|||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@img/sharp-wasm32": "0.35.4"
|
"@img/sharp-wasm32": "0.35.5"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.9.0"
|
"node": ">=20.9.0"
|
||||||
@@ -1005,9 +1005,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-win32-arm64": {
|
"node_modules/@img/sharp-win32-arm64": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz",
|
||||||
"integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==",
|
"integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1025,9 +1025,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-win32-ia32": {
|
"node_modules/@img/sharp-win32-ia32": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz",
|
||||||
"integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==",
|
"integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
@@ -1045,9 +1045,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-win32-x64": {
|
"node_modules/@img/sharp-win32-x64": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz",
|
||||||
"integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==",
|
"integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1833,9 +1833,9 @@
|
|||||||
"integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w=="
|
"integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w=="
|
||||||
},
|
},
|
||||||
"node_modules/dompurify": {
|
"node_modules/dompurify": {
|
||||||
"version": "3.4.15",
|
"version": "3.4.17",
|
||||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.15.tgz",
|
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.17.tgz",
|
||||||
"integrity": "sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw==",
|
"integrity": "sha512-B/pSlYVHQ58kqISD/yDjuJ1yfzI34m1gF4lAod2bwSRhiN4K5Cl3H8Af7hqB2PmTL911tdEudnnXn5FnhRoz0w==",
|
||||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@types/trusted-types": "^2.0.7"
|
"@types/trusted-types": "^2.0.7"
|
||||||
@@ -2782,9 +2782,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/sharp": {
|
"node_modules/sharp": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz",
|
||||||
"integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==",
|
"integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -2799,31 +2799,31 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-darwin-arm64": "0.35.4",
|
"@img/sharp-darwin-arm64": "0.35.5",
|
||||||
"@img/sharp-darwin-x64": "0.35.4",
|
"@img/sharp-darwin-x64": "0.35.5",
|
||||||
"@img/sharp-freebsd-wasm32": "0.35.4",
|
"@img/sharp-freebsd-wasm32": "0.35.5",
|
||||||
"@img/sharp-libvips-darwin-arm64": "1.3.3",
|
"@img/sharp-libvips-darwin-arm64": "1.3.4",
|
||||||
"@img/sharp-libvips-darwin-x64": "1.3.3",
|
"@img/sharp-libvips-darwin-x64": "1.3.4",
|
||||||
"@img/sharp-libvips-linux-arm": "1.3.3",
|
"@img/sharp-libvips-linux-arm": "1.3.4",
|
||||||
"@img/sharp-libvips-linux-arm64": "1.3.3",
|
"@img/sharp-libvips-linux-arm64": "1.3.4",
|
||||||
"@img/sharp-libvips-linux-ppc64": "1.3.3",
|
"@img/sharp-libvips-linux-ppc64": "1.3.4",
|
||||||
"@img/sharp-libvips-linux-riscv64": "1.3.3",
|
"@img/sharp-libvips-linux-riscv64": "1.3.4",
|
||||||
"@img/sharp-libvips-linux-s390x": "1.3.3",
|
"@img/sharp-libvips-linux-s390x": "1.3.4",
|
||||||
"@img/sharp-libvips-linux-x64": "1.3.3",
|
"@img/sharp-libvips-linux-x64": "1.3.4",
|
||||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.3",
|
"@img/sharp-libvips-linuxmusl-arm64": "1.3.4",
|
||||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.3",
|
"@img/sharp-libvips-linuxmusl-x64": "1.3.4",
|
||||||
"@img/sharp-linux-arm": "0.35.4",
|
"@img/sharp-linux-arm": "0.35.5",
|
||||||
"@img/sharp-linux-arm64": "0.35.4",
|
"@img/sharp-linux-arm64": "0.35.5",
|
||||||
"@img/sharp-linux-ppc64": "0.35.4",
|
"@img/sharp-linux-ppc64": "0.35.5",
|
||||||
"@img/sharp-linux-riscv64": "0.35.4",
|
"@img/sharp-linux-riscv64": "0.35.5",
|
||||||
"@img/sharp-linux-s390x": "0.35.4",
|
"@img/sharp-linux-s390x": "0.35.5",
|
||||||
"@img/sharp-linux-x64": "0.35.4",
|
"@img/sharp-linux-x64": "0.35.5",
|
||||||
"@img/sharp-linuxmusl-arm64": "0.35.4",
|
"@img/sharp-linuxmusl-arm64": "0.35.5",
|
||||||
"@img/sharp-linuxmusl-x64": "0.35.4",
|
"@img/sharp-linuxmusl-x64": "0.35.5",
|
||||||
"@img/sharp-webcontainers-wasm32": "0.35.4",
|
"@img/sharp-webcontainers-wasm32": "0.35.5",
|
||||||
"@img/sharp-win32-arm64": "0.35.4",
|
"@img/sharp-win32-arm64": "0.35.5",
|
||||||
"@img/sharp-win32-ia32": "0.35.4",
|
"@img/sharp-win32-ia32": "0.35.5",
|
||||||
"@img/sharp-win32-x64": "0.35.4"
|
"@img/sharp-win32-x64": "0.35.5"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@types/node": {
|
"@types/node": {
|
||||||
@@ -2832,9 +2832,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/source-map-js": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
|
||||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
+10
-2
@@ -35,8 +35,11 @@
|
|||||||
"doctor": "node scripts/setup-doctor.mjs",
|
"doctor": "node scripts/setup-doctor.mjs",
|
||||||
"format": "node scripts/format.mjs --write",
|
"format": "node scripts/format.mjs --write",
|
||||||
"format:check": "node scripts/format.mjs --check",
|
"format:check": "node scripts/format.mjs --check",
|
||||||
|
"i18n": "node scripts/i18n.mjs",
|
||||||
"layer-token:next": "node scripts/next-layer-state-token.mjs",
|
"layer-token:next": "node scripts/next-layer-state-token.mjs",
|
||||||
"layer-token:check": "node scripts/check-layer-state-tokens.mjs",
|
"layer-token:check": "node scripts/check-layer-state-tokens.mjs",
|
||||||
|
"changelog": "node scripts/assemble-changelog.mjs --write",
|
||||||
|
"changelog:check": "node scripts/assemble-changelog.mjs --check",
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"dev:secure": "./scripts/dev-secure.sh",
|
"dev:secure": "./scripts/dev-secure.sh",
|
||||||
"opensky:import": "./scripts/opensky-import-client.sh",
|
"opensky:import": "./scripts/opensky-import-client.sh",
|
||||||
@@ -164,6 +167,7 @@
|
|||||||
"./layers/recent-imagery": "./src/layers/recentImagery/index.js",
|
"./layers/recent-imagery": "./src/layers/recentImagery/index.js",
|
||||||
"./layers/recent-imagery/source": "./src/layers/recentImagery/source.js",
|
"./layers/recent-imagery/source": "./src/layers/recentImagery/source.js",
|
||||||
"./layers/perimeters": "./src/layers/perimeters/index.js",
|
"./layers/perimeters": "./src/layers/perimeters/index.js",
|
||||||
|
"./layers/gnss": "./src/layers/gnss/index.js",
|
||||||
"./sources/firms-csv": "./src/data/firmsCsv.js",
|
"./sources/firms-csv": "./src/data/firmsCsv.js",
|
||||||
"./layers/firms": "./src/layers/firms/index.js",
|
"./layers/firms": "./src/layers/firms/index.js",
|
||||||
"./layers/satellites": "./src/layers/satellites/index.js",
|
"./layers/satellites": "./src/layers/satellites/index.js",
|
||||||
@@ -175,6 +179,7 @@
|
|||||||
"./layers/directions": "./src/layers/directions/index.js",
|
"./layers/directions": "./src/layers/directions/index.js",
|
||||||
"./layers/directions/source": "./src/layers/directions/source.js",
|
"./layers/directions/source": "./src/layers/directions/source.js",
|
||||||
"./layers/cctv": "./src/layers/cctv/index.js",
|
"./layers/cctv": "./src/layers/cctv/index.js",
|
||||||
|
"./layers/air-quality": "./src/layers/airQuality/index.js",
|
||||||
"./layers/alpr": "./src/layers/alpr/index.js",
|
"./layers/alpr": "./src/layers/alpr/index.js",
|
||||||
"./layers/radio": "./src/layers/radio/index.js",
|
"./layers/radio": "./src/layers/radio/index.js",
|
||||||
"./layers/submarine-cables": "./src/layers/submarineCables/index.js",
|
"./layers/submarine-cables": "./src/layers/submarineCables/index.js",
|
||||||
@@ -235,8 +240,10 @@
|
|||||||
"./layers/alpr/source": "./src/layers/alpr/source.js",
|
"./layers/alpr/source": "./src/layers/alpr/source.js",
|
||||||
"./layers/bikeshare/source": "./src/layers/bikeshare/source.js",
|
"./layers/bikeshare/source": "./src/layers/bikeshare/source.js",
|
||||||
"./layers/cctv/source": "./src/layers/cctv/source.js",
|
"./layers/cctv/source": "./src/layers/cctv/source.js",
|
||||||
|
"./layers/air-quality/source": "./src/layers/airQuality/source.js",
|
||||||
"./layers/earthquakes/source": "./src/layers/earthquakes/source.js",
|
"./layers/earthquakes/source": "./src/layers/earthquakes/source.js",
|
||||||
"./layers/perimeters/source": "./src/layers/perimeters/source.js",
|
"./layers/perimeters/source": "./src/layers/perimeters/source.js",
|
||||||
|
"./layers/gnss/source": "./src/layers/gnss/source.js",
|
||||||
"./layers/firms/source": "./src/layers/firms/source.js",
|
"./layers/firms/source": "./src/layers/firms/source.js",
|
||||||
"./layers/installations/source": "./src/layers/installations/source.js",
|
"./layers/installations/source": "./src/layers/installations/source.js",
|
||||||
"./layers/launches/source": "./src/layers/launches/source.js",
|
"./layers/launches/source": "./src/layers/launches/source.js",
|
||||||
@@ -262,8 +269,9 @@
|
|||||||
"node": "./server/providers/mapillary.js"
|
"node": "./server/providers/mapillary.js"
|
||||||
},
|
},
|
||||||
"./layers/street-level": "./src/layers/streetLevel/index.js",
|
"./layers/street-level": "./src/layers/streetLevel/index.js",
|
||||||
"./layers/street-level/mapillary": "./src/layers/streetLevel/mapillaryProvider.js",
|
"./layers/street-level/mapillary": "./src/layers/streetLevel/providers/mapillary/index.js",
|
||||||
"./layers/street-level/mapillary/source": "./src/layers/streetLevel/source.js",
|
"./layers/street-level/mapillary/source": "./src/layers/streetLevel/providers/mapillary/source.js",
|
||||||
|
"./layers/street-level/google": "./src/layers/streetLevel/providers/google/index.js",
|
||||||
"./tools": "./src/tools/index.js",
|
"./tools": "./src/tools/index.js",
|
||||||
"./tools/mcp": "./src/tools/mcp/index.js",
|
"./tools/mcp": "./src/tools/mcp/index.js",
|
||||||
"./tools/panel": "./src/tools/panel.js",
|
"./tools/panel": "./src/tools/panel.js",
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"id": ".",
|
||||||
|
"name": "God's Eye View",
|
||||||
|
"short_name": "God's Eye",
|
||||||
|
"description": "Live spatial intelligence in the browser.",
|
||||||
|
"start_url": ".",
|
||||||
|
"scope": ".",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "any",
|
||||||
|
"background_color": "#0a0a0f",
|
||||||
|
"theme_color": "#0a0a0f",
|
||||||
|
"icons": [
|
||||||
|
{ "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
|
||||||
|
{ "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
|
||||||
|
{
|
||||||
|
"src": "icons/icon-512-maskable.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import * as fs from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Release-time assembly of changelog fragments.
|
||||||
|
*
|
||||||
|
* A pull request adds `changelog.d/<pull request>-<slug>.md` instead of editing
|
||||||
|
* `CHANGELOG.md`. Distinct filenames never conflict, so two pull requests in
|
||||||
|
* flight no longer write into the same few lines at the top of one file. A
|
||||||
|
* release splices the fragments in and empties the directory.
|
||||||
|
*
|
||||||
|
* Fragment text is copied through unchanged apart from trailing whitespace:
|
||||||
|
* `src/annotations/drawTool.test.mjs` reads `CHANGELOG.md` as prose and slices a
|
||||||
|
* fixed window after a heading, so reflowing an entry here could move text
|
||||||
|
* across that window.
|
||||||
|
*
|
||||||
|
* A write is staged so that a failure at any point leaves a rerun unable to
|
||||||
|
* duplicate or lose an entry. The assembled file is written into
|
||||||
|
* `changelog.d/.assembling/` first, the fragments are renamed in beside it, and
|
||||||
|
* renaming the assembled file over `CHANGELOG.md` is the single commit point.
|
||||||
|
* While the assembled file is still staged the batch has not landed, so
|
||||||
|
* recovery moves the fragments back; once it is gone the batch has landed, and
|
||||||
|
* the staged fragments are leftovers to delete. Either way they are out of the
|
||||||
|
* input directory, so they are never assembled twice.
|
||||||
|
*/
|
||||||
|
export const FRAGMENT_DIR = 'changelog.d';
|
||||||
|
export const STAGING_DIR = '.assembling';
|
||||||
|
const STAGED_CHANGELOG = 'CHANGELOG.md';
|
||||||
|
const FRAGMENT_NAME = /^(\d+)-[a-z0-9]+(?:-[a-z0-9]+)*\.md$/;
|
||||||
|
const HEADING = '# Changelog\n';
|
||||||
|
|
||||||
|
/** Fragment file names a release may consume, highest pull request first. */
|
||||||
|
export function orderFragmentNames(names) {
|
||||||
|
const claimed = new Map();
|
||||||
|
for (const name of names) {
|
||||||
|
if (name === 'README.md') continue;
|
||||||
|
const match = FRAGMENT_NAME.exec(name);
|
||||||
|
if (!match) {
|
||||||
|
throw new Error(
|
||||||
|
`Unexpected file in ${FRAGMENT_DIR}/: ${name}` +
|
||||||
|
' — expected <pull request>-<slug>.md with a lowercase slug',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const number = Number(match[1]);
|
||||||
|
const prior = claimed.get(number);
|
||||||
|
if (prior) {
|
||||||
|
throw new Error(
|
||||||
|
`Two fragments claim pull request ${number}: ${prior} and ${name}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
claimed.set(number, name);
|
||||||
|
}
|
||||||
|
return [...claimed.entries()]
|
||||||
|
.sort(([a], [b]) => b - a)
|
||||||
|
.map(([, name]) => name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Splice fragment bodies above the existing entries, newest batch on top. */
|
||||||
|
export function assembleChangelog(changelog, fragments) {
|
||||||
|
if (!fragments.length) return changelog;
|
||||||
|
if (!changelog.startsWith(HEADING)) {
|
||||||
|
throw new Error('CHANGELOG.md must start with "# Changelog"');
|
||||||
|
}
|
||||||
|
const entries = changelog.slice(HEADING.length).replace(/^\n+/, '');
|
||||||
|
const batch = fragments.map((text) => text.replace(/\s+$/, '')).join('\n\n');
|
||||||
|
return `${HEADING}\n${batch}\n\n${entries}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finish or roll back a batch an earlier `--write` left in the staging
|
||||||
|
* directory. A no-op when there is none.
|
||||||
|
*
|
||||||
|
* @returns {Promise<'none'|'rolled-back'|'cleaned-up'>}
|
||||||
|
*/
|
||||||
|
export async function recoverInterruptedBatch(root, io = fs) {
|
||||||
|
const directory = path.join(root, FRAGMENT_DIR);
|
||||||
|
const staging = path.join(directory, STAGING_DIR);
|
||||||
|
let staged;
|
||||||
|
try {
|
||||||
|
staged = await io.readdir(staging);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'ENOENT') return 'none';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (!staged.includes(STAGED_CHANGELOG)) {
|
||||||
|
// The assembled file was renamed over CHANGELOG.md: the batch landed.
|
||||||
|
await io.rm(staging, { recursive: true, force: true });
|
||||||
|
return 'cleaned-up';
|
||||||
|
}
|
||||||
|
// The batch never reached CHANGELOG.md, so every fragment goes back.
|
||||||
|
const pending = new Set(await io.readdir(directory));
|
||||||
|
const fragments = staged.filter((name) => name !== STAGED_CHANGELOG);
|
||||||
|
const clash = fragments.filter((name) => pending.has(name));
|
||||||
|
if (clash.length) {
|
||||||
|
throw new Error(
|
||||||
|
`Cannot roll back ${FRAGMENT_DIR}/${STAGING_DIR}/: ${clash.join(', ')}` +
|
||||||
|
` also exist in ${FRAGMENT_DIR}/ — resolve by hand`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const name of fragments) {
|
||||||
|
await io.rename(path.join(staging, name), path.join(directory, name));
|
||||||
|
}
|
||||||
|
await io.rm(staging, { recursive: true, force: true });
|
||||||
|
return 'rolled-back';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Report or apply the pending fragments in `<root>/changelog.d`.
|
||||||
|
*
|
||||||
|
* The batch is validated in full before anything is written, so a malformed
|
||||||
|
* filename or a duplicate pull request number leaves the directory untouched.
|
||||||
|
* `--write` first recovers any batch an interrupted run left staged.
|
||||||
|
*
|
||||||
|
* @param {string} root repository root
|
||||||
|
* @param {'--check'|'--write'} mode `--check` reports and changes nothing
|
||||||
|
* @param {typeof fs} [io] filesystem calls, replaceable for failure injection
|
||||||
|
* @returns {Promise<{ consumed: string[], changelog: string }>}
|
||||||
|
*/
|
||||||
|
export async function assembleFragments(root, mode, io = fs) {
|
||||||
|
if (!['--check', '--write'].includes(mode)) {
|
||||||
|
throw new Error(
|
||||||
|
'Usage: node scripts/assemble-changelog.mjs --check|--write',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const directory = path.join(root, FRAGMENT_DIR);
|
||||||
|
const staging = path.join(directory, STAGING_DIR);
|
||||||
|
const changelogFile = path.join(root, 'CHANGELOG.md');
|
||||||
|
if (mode === '--write') {
|
||||||
|
await recoverInterruptedBatch(root, io);
|
||||||
|
}
|
||||||
|
const names = await io.readdir(directory);
|
||||||
|
if (names.includes(STAGING_DIR)) {
|
||||||
|
throw new Error(
|
||||||
|
`${FRAGMENT_DIR}/${STAGING_DIR}/ holds a batch an interrupted release` +
|
||||||
|
' left behind — `npm run changelog` finishes or rolls it back',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const consumed = orderFragmentNames(names);
|
||||||
|
const bodies = [];
|
||||||
|
for (const name of consumed) {
|
||||||
|
bodies.push(await io.readFile(path.join(directory, name), 'utf8'));
|
||||||
|
}
|
||||||
|
const changelog = assembleChangelog(
|
||||||
|
await io.readFile(changelogFile, 'utf8'),
|
||||||
|
bodies,
|
||||||
|
);
|
||||||
|
if (mode === '--write' && consumed.length) {
|
||||||
|
try {
|
||||||
|
await io.mkdir(staging);
|
||||||
|
await io.writeFile(
|
||||||
|
path.join(staging, STAGED_CHANGELOG),
|
||||||
|
changelog,
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
for (const name of consumed) {
|
||||||
|
await io.rename(path.join(directory, name), path.join(staging, name));
|
||||||
|
}
|
||||||
|
await io.rename(path.join(staging, STAGED_CHANGELOG), changelogFile);
|
||||||
|
await io.rm(staging, { recursive: true, force: true });
|
||||||
|
} catch (error) {
|
||||||
|
try {
|
||||||
|
await recoverInterruptedBatch(root, io);
|
||||||
|
} catch {
|
||||||
|
throw new Error(
|
||||||
|
`${error.message} — the batch is staged in ${FRAGMENT_DIR}/${STAGING_DIR}/,` +
|
||||||
|
' and the next `npm run changelog` finishes or rolls it back',
|
||||||
|
{ cause: error },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { consumed, changelog };
|
||||||
|
}
|
||||||
|
|
||||||
|
const invoked = process.argv[1]
|
||||||
|
? pathToFileURL(path.resolve(process.argv[1])).href
|
||||||
|
: '';
|
||||||
|
if (import.meta.url === invoked) {
|
||||||
|
try {
|
||||||
|
if (process.argv.length !== 3) {
|
||||||
|
throw new Error(
|
||||||
|
'Usage: node scripts/assemble-changelog.mjs --check|--write',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const mode = process.argv[2];
|
||||||
|
const { consumed } = await assembleFragments(
|
||||||
|
fileURLToPath(new URL('../', import.meta.url)),
|
||||||
|
mode,
|
||||||
|
);
|
||||||
|
if (!consumed.length) {
|
||||||
|
console.log(`No fragments in ${FRAGMENT_DIR}/.`);
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
`${mode === '--write' ? 'Assembled' : 'Would assemble'} ${consumed.length} fragment(s), in order:` +
|
||||||
|
`\n ${consumed.join('\n ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error.message);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Docker launcher — the container's entry point (see Dockerfile).
|
||||||
|
*
|
||||||
|
* `compose.yaml` publishes the port on the host's loopback, but Docker still
|
||||||
|
* delivers the host's own browser to the container FROM the container
|
||||||
|
* network's gateway address, never from 127.0.0.1. Provider Settings admits
|
||||||
|
* loopback only, so without this the POWER UP panel refuses the very machine
|
||||||
|
* running the container. This launcher reads the gateway out of the kernel
|
||||||
|
* routing table and declares exactly that one address trusted, then starts
|
||||||
|
* Vite the way scripts/pinokio-start.mjs does.
|
||||||
|
*/
|
||||||
|
import { accessSync, constants, readFileSync, realpathSync, statSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { isDirectInvocation } from './pinokio-install.mjs';
|
||||||
|
import { loadViteFromCanonicalRoot } from './pinokio-start.mjs';
|
||||||
|
|
||||||
|
const MODULE_PATH = fileURLToPath(import.meta.url);
|
||||||
|
const ROOT = realpathSync(path.resolve(path.dirname(MODULE_PATH), '..'));
|
||||||
|
const ROUTE_TABLE = '/proc/net/route';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the IPv4 default gateway from Linux `/proc/net/route` text. The
|
||||||
|
* addresses there are little-endian hex and only the default route (a
|
||||||
|
* destination of 00000000 on an interface flagged up) names a gateway.
|
||||||
|
* Returns null when there is none — the `--network none` case, which must
|
||||||
|
* never fabricate an address.
|
||||||
|
* @param {string} text Raw routing-table contents.
|
||||||
|
* @returns {string|null} Dotted-quad gateway, or null.
|
||||||
|
*/
|
||||||
|
export function defaultGatewayFromRouteTable(text) {
|
||||||
|
const lines = String(text || '').split(/\r?\n/).slice(1);
|
||||||
|
for (const line of lines) {
|
||||||
|
const fields = line.trim().split(/\s+/);
|
||||||
|
if (fields.length < 4) continue;
|
||||||
|
const [, destination, gateway, flags] = fields;
|
||||||
|
if (destination !== '00000000' || !/^[0-9a-f]{8}$/i.test(gateway)) continue;
|
||||||
|
if ((Number.parseInt(flags, 16) & 0x1) === 0) continue; // RTF_UP
|
||||||
|
const octets = [6, 4, 2, 0].map((offset) =>
|
||||||
|
Number.parseInt(gateway.slice(offset, offset + 2), 16),
|
||||||
|
);
|
||||||
|
if (octets.every((octet) => octet === 0)) continue;
|
||||||
|
return octets.join('.');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide this launch's trusted-peer list. An operator value always wins, so a
|
||||||
|
* compose override stays authoritative; otherwise the gateway alone is
|
||||||
|
* trusted. No gateway trusts nothing and Provider Settings stays loopback-only,
|
||||||
|
* exactly as under `npm run dev`.
|
||||||
|
* @param {{env?: NodeJS.ProcessEnv, routeTable?: string}} input
|
||||||
|
* @returns {string} Value for GEV_KEY_SETUP_TRUSTED_PEERS ('' trusts nothing).
|
||||||
|
*/
|
||||||
|
export function resolveTrustedPeers({ env = process.env, routeTable = '' } = {}) {
|
||||||
|
const explicit = String(env.GEV_KEY_SETUP_TRUSTED_PEERS ?? '').trim();
|
||||||
|
if (explicit) return explicit;
|
||||||
|
return defaultGatewayFromRouteTable(routeTable) ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A checkout the container cannot write fails quietly: Provider Settings
|
||||||
|
* answers 500 and `.gev-cache` is never created, with nothing in the log. The
|
||||||
|
* usual cause is a clone made as root on a NAS or Proxmox host while the
|
||||||
|
* container runs as 1000:1000. Say so at start, naming both sets of ids.
|
||||||
|
* @param {{root: string, writable: boolean, owner: {uid: number, gid: number}, runAs: {uid: number, gid: number}}} input
|
||||||
|
* @returns {string|null} A warning, or null when the checkout is writable.
|
||||||
|
*/
|
||||||
|
export function unwritableRootWarning({ root, writable, owner, runAs }) {
|
||||||
|
if (writable) return null;
|
||||||
|
return (
|
||||||
|
`[Docker] ${root} is owned by ${owner.uid}:${owner.gid} but the container runs as ` +
|
||||||
|
`${runAs.uid}:${runAs.gid}, so saving keys and caching will fail. Either give ` +
|
||||||
|
`the checkout to that user (chown -R ${runAs.uid}:${runAs.gid} on the host) or ` +
|
||||||
|
`run as its owner (env UID=${owner.uid} GID=${owner.gid} docker compose up)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readRouteTable() {
|
||||||
|
try {
|
||||||
|
return readFileSync(ROUTE_TABLE, 'utf8');
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function start() {
|
||||||
|
let writable = true;
|
||||||
|
try {
|
||||||
|
accessSync(ROOT, constants.W_OK);
|
||||||
|
} catch {
|
||||||
|
writable = false;
|
||||||
|
}
|
||||||
|
if (!writable) {
|
||||||
|
const { uid, gid } = statSync(ROOT);
|
||||||
|
console.warn(
|
||||||
|
unwritableRootWarning({
|
||||||
|
root: ROOT,
|
||||||
|
writable,
|
||||||
|
owner: { uid, gid },
|
||||||
|
runAs: { uid: process.getuid?.() ?? -1, gid: process.getgid?.() ?? -1 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromOperator = Boolean(
|
||||||
|
String(process.env.GEV_KEY_SETUP_TRUSTED_PEERS ?? '').trim(),
|
||||||
|
);
|
||||||
|
const trustedPeers = resolveTrustedPeers({ routeTable: readRouteTable() });
|
||||||
|
if (trustedPeers) {
|
||||||
|
// Set before Vite is imported: the dev server snapshots process.env during
|
||||||
|
// configuration, and the admission gate reads it per request.
|
||||||
|
process.env.GEV_KEY_SETUP_TRUSTED_PEERS = trustedPeers;
|
||||||
|
console.log(
|
||||||
|
fromOperator
|
||||||
|
? `[Docker] Provider Settings trusted peers: ${trustedPeers} (GEV_KEY_SETUP_TRUSTED_PEERS).`
|
||||||
|
: `[Docker] Provider Settings trusts the container gateway (${trustedPeers}).`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log('[Docker] No default gateway; Provider Settings stays loopback-only.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const port = Number.parseInt(process.env.PORT, 10) || 4173;
|
||||||
|
const { createServer } = await loadViteFromCanonicalRoot(ROOT);
|
||||||
|
const server = await createServer({
|
||||||
|
root: ROOT,
|
||||||
|
server: {
|
||||||
|
// The container's own namespace. compose publishes this to the host's
|
||||||
|
// 127.0.0.1 only; widening that mapping is the LAN opt-in.
|
||||||
|
host: '0.0.0.0',
|
||||||
|
port,
|
||||||
|
strictPort: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await server.listen();
|
||||||
|
server.printUrls();
|
||||||
|
console.log(`[Docker] Ready at http://localhost:${port}/`);
|
||||||
|
|
||||||
|
for (const signal of ['SIGINT', 'SIGTERM']) {
|
||||||
|
process.once(signal, async () => {
|
||||||
|
await server.close();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDirectInvocation(process.argv[1], MODULE_PATH)) {
|
||||||
|
start().catch((error) => {
|
||||||
|
console.error(`[Docker] Start refused: ${error.message}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,371 @@
|
|||||||
|
import { deflateSync } from 'node:zlib';
|
||||||
|
import { tileBounds } from '../../../src/layers/streetLevel/tileMath.js';
|
||||||
|
import { metresApart, photoJpeg } from './mapillaryGraph.mjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KartaView's API, image proxy and photo storage answered from fixtures, so
|
||||||
|
* the gate's KartaView steps need no network. One drive runs north along
|
||||||
|
* KV_LINE; every tenth photo is a 360° one. Photos live on storage2 while
|
||||||
|
* their `fileurl*` name storage1, as KartaView's API does, so the gate also
|
||||||
|
* proves the provider builds addresses from `storage`. Every odd-indexed
|
||||||
|
* photo is archived (storage answers 409, the image proxy still serves it),
|
||||||
|
* so the nearest photo opened is even and › must skip the one after it. The
|
||||||
|
* first nearest-photo lookup is rate-limited (429, Retry-After: 0), as
|
||||||
|
* KartaView does under load.
|
||||||
|
*
|
||||||
|
* Beside the drive runs KV_OLD_STREET, photographed flat in 2019 only: the
|
||||||
|
* imagery filter drops it. KartaView filters coverage only in its PNG
|
||||||
|
* tiles, so filtered PNG tiles are drawn here for the streets that pass.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const KV_SEQUENCE_ID = '900001';
|
||||||
|
export const KV_LINE = Object.freeze({
|
||||||
|
lon: -121.4931,
|
||||||
|
south: 38.5795,
|
||||||
|
north: 38.5875,
|
||||||
|
});
|
||||||
|
/** A street ~130 m east of the drive with only flat 2019 photos. */
|
||||||
|
export const KV_OLD_STREET = Object.freeze({
|
||||||
|
lon: -121.4916,
|
||||||
|
south: 38.58,
|
||||||
|
north: 38.587,
|
||||||
|
});
|
||||||
|
const OLD_STREET_UPLOADED = '2019-06-01';
|
||||||
|
const KV_SPACING_M = 25;
|
||||||
|
const METRES_PER_DEG_LAT = 110_540;
|
||||||
|
/** KartaView's time format for `ms`. */
|
||||||
|
const kartaViewTime = (ms) =>
|
||||||
|
new Date(ms).toISOString().replace('T', ' ').slice(0, 23);
|
||||||
|
/** The drive is a month old, whenever the gate runs: LAST YEAR keeps it. */
|
||||||
|
const SHOT = kartaViewTime(Date.now() - 30 * 86_400_000);
|
||||||
|
|
||||||
|
const CORS = Object.freeze({ 'Access-Control-Allow-Origin': '*' });
|
||||||
|
|
||||||
|
/** Lookups answered so far: the first one is rate-limited. */
|
||||||
|
let nearestLookups = 0;
|
||||||
|
|
||||||
|
/** Forget the lookups answered, so the next one is rate-limited again. */
|
||||||
|
export function resetKartaViewFixture() {
|
||||||
|
nearestLookups = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nearest lookups answered since the last reset (the first got a 429). */
|
||||||
|
export function kartaViewLookups() {
|
||||||
|
return nearestLookups;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The drive's photos, south to north; every tenth is 360°, every odd one archived. */
|
||||||
|
export function kartaViewPhotos() {
|
||||||
|
const step = KV_SPACING_M / METRES_PER_DEG_LAT;
|
||||||
|
const count = Math.floor((KV_LINE.north - KV_LINE.south) / step) + 1;
|
||||||
|
return Array.from({ length: count }, (_, i) => ({
|
||||||
|
id: String(77_000_000 + i),
|
||||||
|
index: i,
|
||||||
|
lon: KV_LINE.lon,
|
||||||
|
lat: Number((KV_LINE.south + i * step).toFixed(7)),
|
||||||
|
isPano: i % 10 === 5,
|
||||||
|
archived: i % 2 === 1,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function record(photo) {
|
||||||
|
const name = `${KV_SEQUENCE_ID}_${photo.id}.jpg`;
|
||||||
|
const folder = photo.isPano ? 'wrapped_proc' : 'proc';
|
||||||
|
const path = (prefix) => `2024/5/2/${prefix}/${name}`;
|
||||||
|
const storage1 = (prefix) =>
|
||||||
|
`https://storage1.openstreetcam.org/files/photo/${path(prefix)}`;
|
||||||
|
return {
|
||||||
|
id: photo.id,
|
||||||
|
sequenceId: KV_SEQUENCE_ID,
|
||||||
|
sequenceIndex: String(photo.index),
|
||||||
|
lat: String(photo.lat),
|
||||||
|
lng: String(photo.lon),
|
||||||
|
matchLat: String(photo.lat),
|
||||||
|
matchLng: String(photo.lon),
|
||||||
|
// A dashcam phone's compass, disturbed by the car: the drive runs north.
|
||||||
|
heading: '200',
|
||||||
|
fieldOfView: photo.isPano ? '360' : '0',
|
||||||
|
projection: photo.isPano ? 'SPHERE' : 'PLANE',
|
||||||
|
shotDate: SHOT,
|
||||||
|
dateAdded: SHOT,
|
||||||
|
storage: 'storage2',
|
||||||
|
filepath: path('{{sizeprefix}}'),
|
||||||
|
filepathLTh: path('lth'),
|
||||||
|
filepathProc: path(folder),
|
||||||
|
fileurl: storage1('{{sizeprefix}}'),
|
||||||
|
fileurlLTh: storage1('lth'),
|
||||||
|
fileurlProc: storage1(folder),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = (result) => ({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
headers: CORS,
|
||||||
|
body: JSON.stringify({ status: { apiCode: 600, httpCode: 200 }, result }),
|
||||||
|
});
|
||||||
|
const raw = (status, body) => ({
|
||||||
|
status,
|
||||||
|
contentType: 'application/json',
|
||||||
|
headers: CORS,
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The streets with photos: the drive, and the old street beside it. */
|
||||||
|
function streets() {
|
||||||
|
return [
|
||||||
|
{ line: KV_LINE, uploaded: SHOT.slice(0, 10), views: ['360', 'plane'] },
|
||||||
|
{ line: KV_OLD_STREET, uploaded: OLD_STREET_UPLOADED, views: ['plane'] },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A street's stretch inside tile `z/x/y`, as [from, to] latitudes, or null. */
|
||||||
|
function stretch(line, z, x, y) {
|
||||||
|
const { west, east, south, north } = tileBounds(x, y, z);
|
||||||
|
if (line.lon < west || line.lon > east) return null;
|
||||||
|
const from = Math.max(line.south, south);
|
||||||
|
const to = Math.min(line.north, north);
|
||||||
|
return from < to ? [from, to] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The streets' tracks inside tile `z/x/y`, as KartaView's JSON tile. */
|
||||||
|
function tileLines(z, x, y) {
|
||||||
|
const lines = [];
|
||||||
|
for (const { line } of streets()) {
|
||||||
|
const inside = stretch(line, z, x, y);
|
||||||
|
if (!inside) continue;
|
||||||
|
const [from, to] = inside;
|
||||||
|
// Two segments meeting end to start, as KartaView splits ways.
|
||||||
|
const mid = (from + to) / 2;
|
||||||
|
const at = (lat) => ({ lat: String(lat), lng: String(line.lon) });
|
||||||
|
lines.push(
|
||||||
|
{ track: [at(from), at(mid)], matched: '1' },
|
||||||
|
{ track: [at(mid), at(to)], matched: '1' },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a street has photos passing a filtered tile's query. */
|
||||||
|
function passesTileQuery(street, query) {
|
||||||
|
const view = query.get('fieldOfView');
|
||||||
|
if (view && !street.views.includes(view)) return false;
|
||||||
|
const since = query.get('startDate');
|
||||||
|
return !since || street.uploaded >= since;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CRC_TABLE = Array.from({ length: 256 }, (_, n) => {
|
||||||
|
let c = n;
|
||||||
|
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||||
|
return c >>> 0;
|
||||||
|
});
|
||||||
|
function crc32(bytes) {
|
||||||
|
let c = 0xffffffff;
|
||||||
|
for (const byte of bytes) c = CRC_TABLE[(c ^ byte) & 0xff] ^ (c >>> 8);
|
||||||
|
return (c ^ 0xffffffff) >>> 0;
|
||||||
|
}
|
||||||
|
function pngChunk(type, data) {
|
||||||
|
const head = Buffer.alloc(8);
|
||||||
|
head.writeUInt32BE(data.length, 0);
|
||||||
|
head.write(type, 4, 'ascii');
|
||||||
|
const crc = Buffer.alloc(4);
|
||||||
|
crc.writeUInt32BE(crc32(Buffer.concat([head.subarray(4), data])), 0);
|
||||||
|
return Buffer.concat([head, data, crc]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A filtered coverage tile as KartaView draws it: 512 px, transparent, the
|
||||||
|
* passing streets three pixels wide.
|
||||||
|
*/
|
||||||
|
export function maskTilePng(z, x, y, query) {
|
||||||
|
const size = 512;
|
||||||
|
const rgba = Buffer.alloc(size * size * 4);
|
||||||
|
const { west, east } = tileBounds(x, y, z);
|
||||||
|
const n = 2 ** z;
|
||||||
|
const row = (lat) => {
|
||||||
|
const sin = Math.sin((lat * Math.PI) / 180);
|
||||||
|
const my = 0.5 - Math.log((1 + sin) / (1 - sin)) / (4 * Math.PI);
|
||||||
|
return Math.round((my * n - y) * size);
|
||||||
|
};
|
||||||
|
for (const street of streets()) {
|
||||||
|
if (!passesTileQuery(street, query)) continue;
|
||||||
|
const inside = stretch(street.line, z, x, y);
|
||||||
|
if (!inside) continue;
|
||||||
|
const column = Math.round(
|
||||||
|
((street.line.lon - west) / (east - west)) * size,
|
||||||
|
);
|
||||||
|
const top = Math.max(0, row(inside[1]));
|
||||||
|
const bottom = Math.min(size - 1, row(inside[0]));
|
||||||
|
for (let py = top; py <= bottom; py++)
|
||||||
|
for (let px = column - 1; px <= column + 1; px++) {
|
||||||
|
if (px < 0 || px >= size) continue;
|
||||||
|
rgba.set([99, 91, 255, 255], (py * size + px) * 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const rows = Buffer.alloc(size * (size * 4 + 1));
|
||||||
|
for (let py = 0; py < size; py++)
|
||||||
|
rgba.copy(
|
||||||
|
rows,
|
||||||
|
py * (size * 4 + 1) + 1,
|
||||||
|
py * size * 4,
|
||||||
|
(py + 1) * size * 4,
|
||||||
|
);
|
||||||
|
const header = Buffer.alloc(13);
|
||||||
|
header.writeUInt32BE(size, 0);
|
||||||
|
header.writeUInt32BE(size, 4);
|
||||||
|
header.set([8, 6, 0, 0, 0], 8);
|
||||||
|
return Buffer.concat([
|
||||||
|
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
|
||||||
|
pngChunk('IHDR', header),
|
||||||
|
pngChunk('IDAT', deflateSync(rows)),
|
||||||
|
pngChunk('IEND', Buffer.alloc(0)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A call named by host, path shape and parameter names. */
|
||||||
|
export function describeKartaViewCall(method, url) {
|
||||||
|
const keys = [...url.searchParams.keys()].sort();
|
||||||
|
const path = url.pathname
|
||||||
|
.replace(/\/tiles\/\d+\/\d+\/\d+\.json$/, '/tiles/{x}/{y}/{z}.json')
|
||||||
|
.replace(/\/tiles\/\d+\/\d+\/\d+\.png$/, '/tiles/{x}/{y}/{z}.png')
|
||||||
|
.replace(/\/photo\/\d+$/, '/photo/{id}')
|
||||||
|
.replace(/\/sequence\/\d+\/photos$/, '/sequence/{id}/photos')
|
||||||
|
.replace(/\/sequence\/\d+$/, '/sequence/{id}')
|
||||||
|
.replace(/^\/files\/photo\/.*$/, '/files/photo/{path}')
|
||||||
|
.replace(
|
||||||
|
/^\/pr:sharp\/rs:fit:\d+:\d+\/.*$/,
|
||||||
|
'/pr:sharp/rs:fit:{w}:{h}/{address}',
|
||||||
|
);
|
||||||
|
return `${method} ${url.hostname}${path}${keys.length ? `?${keys.join('&')}` : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Answer one KartaView request; `known` is false for a call no fixture
|
||||||
|
* covers, so the gate fails on a new one instead of missing it.
|
||||||
|
*/
|
||||||
|
export function answerKartaViewRequest({ method, url }) {
|
||||||
|
const address = new URL(url);
|
||||||
|
const photos = kartaViewPhotos();
|
||||||
|
if (method === 'OPTIONS')
|
||||||
|
return { status: 204, headers: CORS, body: '', known: true };
|
||||||
|
const image = {
|
||||||
|
status: 200,
|
||||||
|
contentType: 'image/jpeg',
|
||||||
|
headers: CORS,
|
||||||
|
body: photoJpeg(),
|
||||||
|
known: true,
|
||||||
|
};
|
||||||
|
if (address.hostname === 'cdn.kartaview.org')
|
||||||
|
return /^\/pr:sharp\/rs:fit:\d+:\d+\/[\w-]+$/.test(address.pathname)
|
||||||
|
? image
|
||||||
|
: { ...raw(404, {}), known: false };
|
||||||
|
if (address.hostname === 'storage2.openstreetcam.org') {
|
||||||
|
if (!address.pathname.includes(KV_SEQUENCE_ID))
|
||||||
|
return { ...raw(404, {}), known: false };
|
||||||
|
const archived = photos.some(
|
||||||
|
(photo) =>
|
||||||
|
photo.archived && address.pathname.endsWith(`_${photo.id}.jpg`),
|
||||||
|
);
|
||||||
|
// An archived photo's file is gone from storage: KartaView answers 409.
|
||||||
|
return archived ? { ...raw(409, {}), known: true } : image;
|
||||||
|
}
|
||||||
|
if (/^storage\d+\.openstreetcam\.org$/.test(address.hostname))
|
||||||
|
// The wrong host the API names: KartaView answers 404 there.
|
||||||
|
return { ...raw(404, {}), known: false };
|
||||||
|
if (address.hostname !== 'api.kartaview.org')
|
||||||
|
return { ...raw(404, {}), known: false };
|
||||||
|
const path = address.pathname;
|
||||||
|
const tile = path.match(
|
||||||
|
/^\/2\.0\/sequence\/tiles\/(\d+)\/(\d+)\/(\d+)\.json$/,
|
||||||
|
);
|
||||||
|
if (tile)
|
||||||
|
return {
|
||||||
|
...raw(200, {
|
||||||
|
lines: tileLines(Number(tile[3]), Number(tile[1]), Number(tile[2])),
|
||||||
|
}),
|
||||||
|
known: true,
|
||||||
|
};
|
||||||
|
// Filtered coverage: KartaView filters only its PNG tiles.
|
||||||
|
const mask = path.match(
|
||||||
|
/^\/2\.0\/sequence\/tiles\/(\d+)\/(\d+)\/(\d+)\.png$/,
|
||||||
|
);
|
||||||
|
if (mask)
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
contentType: 'image/png',
|
||||||
|
headers: CORS,
|
||||||
|
body: maskTilePng(
|
||||||
|
Number(mask[3]),
|
||||||
|
Number(mask[1]),
|
||||||
|
Number(mask[2]),
|
||||||
|
address.searchParams,
|
||||||
|
),
|
||||||
|
known: true,
|
||||||
|
};
|
||||||
|
if (path === '/2.0/photo/') {
|
||||||
|
if (nearestLookups++ === 0)
|
||||||
|
return {
|
||||||
|
...raw(429, {}),
|
||||||
|
headers: {
|
||||||
|
...CORS,
|
||||||
|
'Retry-After': '0',
|
||||||
|
'Access-Control-Expose-Headers': 'Retry-After',
|
||||||
|
},
|
||||||
|
known: true,
|
||||||
|
};
|
||||||
|
const q = address.searchParams;
|
||||||
|
const at = { lat: Number(q.get('lat')), lon: Number(q.get('lng')) };
|
||||||
|
// Its own filter, as KartaView's: projection, and upload date.
|
||||||
|
const projection = q.get('projection');
|
||||||
|
const since = q.get('startDate');
|
||||||
|
const near = photos
|
||||||
|
.filter(
|
||||||
|
(photo) =>
|
||||||
|
(!projection || projection === (photo.isPano ? 'SPHERE' : 'PLANE')) &&
|
||||||
|
(!since || SHOT.slice(0, 10) >= since),
|
||||||
|
)
|
||||||
|
.map((photo) => ({ photo, metres: metresApart(at, photo) }))
|
||||||
|
.filter(({ metres }) => metres < 200)
|
||||||
|
.sort((a, b) => a.metres - b.metres)
|
||||||
|
.slice(0, Number(q.get('itemsPerPage')) || 10)
|
||||||
|
.map(({ photo, metres }) => ({
|
||||||
|
...record(photo),
|
||||||
|
distance: metres.toFixed(2),
|
||||||
|
}));
|
||||||
|
return { ...ok({ data: near, hasMoreData: false }), known: true };
|
||||||
|
}
|
||||||
|
const one = path.match(/^\/2\.0\/photo\/(\d+)$/);
|
||||||
|
if (one) {
|
||||||
|
const photo = photos.find((p) => p.id === one[1]);
|
||||||
|
return photo
|
||||||
|
? { ...ok({ data: record(photo) }), known: true }
|
||||||
|
: { ...raw(200, { status: { apiCode: 601 }, result: {} }), known: true };
|
||||||
|
}
|
||||||
|
const sequence = path.match(/^\/2\.0\/sequence\/(\d+)$/);
|
||||||
|
if (sequence)
|
||||||
|
return {
|
||||||
|
...ok({
|
||||||
|
data: {
|
||||||
|
id: sequence[1],
|
||||||
|
countActivePhotos:
|
||||||
|
sequence[1] === KV_SEQUENCE_ID ? String(photos.length) : '0',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
known: true,
|
||||||
|
};
|
||||||
|
const page = path.match(/^\/2\.0\/sequence\/(\d+)\/photos$/);
|
||||||
|
if (page) {
|
||||||
|
const q = address.searchParams;
|
||||||
|
const size = Number(q.get('itemsPerPage')) || 100;
|
||||||
|
const number = Number(q.get('page')) || 1;
|
||||||
|
const rows = page[1] === KV_SEQUENCE_ID ? photos : [];
|
||||||
|
return {
|
||||||
|
...ok({
|
||||||
|
data: rows.slice((number - 1) * size, number * size).map(record),
|
||||||
|
hasMoreData: number * size < rows.length,
|
||||||
|
}),
|
||||||
|
known: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ...raw(404, {}), known: false };
|
||||||
|
}
|
||||||
@@ -27,6 +27,15 @@ export const THUMB_HOST = 'qa-fixture.mapillary.com';
|
|||||||
|
|
||||||
const METRES_PER_DEG_LAT = 110_540;
|
const METRES_PER_DEG_LAT = 110_540;
|
||||||
|
|
||||||
|
/** Ground distance in metres, as the app's metresBetween. */
|
||||||
|
export function metresApart(a, b) {
|
||||||
|
const lat = (((a.lat + b.lat) / 2) * Math.PI) / 180;
|
||||||
|
return Math.hypot(
|
||||||
|
(b.lon - a.lon) * 111_320 * Math.cos(lat),
|
||||||
|
(b.lat - a.lat) * METRES_PER_DEG_LAT,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Photos along PHOTO_LINE, south to north; every third one is a panorama.
|
* Photos along PHOTO_LINE, south to north; every third one is a panorama.
|
||||||
* @returns {Array<{id: string, lon: number, lat: number, isPano: boolean, capturedAt: number, compassAngle: number}>}
|
* @returns {Array<{id: string, lon: number, lat: number, isPano: boolean, capturedAt: number, compassAngle: number}>}
|
||||||
@@ -108,7 +117,7 @@ const json = (status, body) => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
let photoBytes = null;
|
let photoBytes = null;
|
||||||
function photoJpeg() {
|
export function photoJpeg() {
|
||||||
photoBytes ??= readFileSync(new URL('./street-640x320.jpg', import.meta.url));
|
photoBytes ??= readFileSync(new URL('./street-640x320.jpg', import.meta.url));
|
||||||
return photoBytes;
|
return photoBytes;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,283 @@
|
|||||||
|
import { tileBounds } from '../../../src/layers/streetLevel/tileMath.js';
|
||||||
|
import { encodePanoramaxTile } from '../../../src/testSupport/panoramaxFixtures.mjs';
|
||||||
|
import { metresApart, photoJpeg } from './mapillaryGraph.mjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Panoramax's meta-catalogue and one instance's pictures answered from
|
||||||
|
* fixtures, so the gate's Panoramax steps need no network. Two sequences
|
||||||
|
* drive east along the same Paris street (PNX_LINE), their pictures
|
||||||
|
* interleaved, so a click on one line must open that line's picture, not
|
||||||
|
* the other's that is as near. Every seventh picture of the first is 360°.
|
||||||
|
* The meta-catalogue's pictures live on panoramax.openstreetmap.fr; the
|
||||||
|
* first sequence is CC BY-SA 4.0 by "Fixture Mapper", the second Licence
|
||||||
|
* Ouverte by an account named by an email address (never shown).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const PNX_SEQUENCES = Object.freeze([
|
||||||
|
'a1a1a1a1-0000-4000-8000-000000000001',
|
||||||
|
'b2b2b2b2-0000-4000-8000-000000000002',
|
||||||
|
]);
|
||||||
|
export const PNX_LINE = Object.freeze({
|
||||||
|
lat: 48.8874,
|
||||||
|
west: 2.3445,
|
||||||
|
east: 2.3545,
|
||||||
|
});
|
||||||
|
export const PNX_PARK = Object.freeze({
|
||||||
|
lon: 2.3495,
|
||||||
|
lat: 48.8874,
|
||||||
|
height: 700,
|
||||||
|
pitch: -1.4,
|
||||||
|
});
|
||||||
|
export const PNX_CREATOR = 'Fixture Mapper';
|
||||||
|
const PNX_SPACING_M = 10;
|
||||||
|
const METRES_PER_DEG_LON = 111_320 * Math.cos((PNX_LINE.lat * Math.PI) / 180);
|
||||||
|
const API = 'https://api.panoramax.xyz/api';
|
||||||
|
const HOST = 'https://panoramax.openstreetmap.fr';
|
||||||
|
|
||||||
|
const CORS = Object.freeze({ 'Access-Control-Allow-Origin': '*' });
|
||||||
|
|
||||||
|
/** Calls answered since the last reset, by kind. */
|
||||||
|
let asked = { search: 0, hd: 0 };
|
||||||
|
|
||||||
|
export function resetPanoramaxFixture() {
|
||||||
|
asked = { search: 0, hd: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How many HD originals were downloaded since the last reset. */
|
||||||
|
export function panoramaxHdDownloads() {
|
||||||
|
return asked.hd;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Both sequences' pictures, west to east. The second's sit 5 m along from
|
||||||
|
* the first's, on the same street.
|
||||||
|
*/
|
||||||
|
export function panoramaxPictures() {
|
||||||
|
const step = PNX_SPACING_M / METRES_PER_DEG_LON;
|
||||||
|
const count = Math.floor((PNX_LINE.east - PNX_LINE.west) / step);
|
||||||
|
return PNX_SEQUENCES.flatMap((sequenceId, s) =>
|
||||||
|
Array.from({ length: count }, (_, i) => ({
|
||||||
|
id: `${sequenceId.slice(0, 8)}-${String(i).padStart(4, '0')}`,
|
||||||
|
sequenceId,
|
||||||
|
index: i,
|
||||||
|
count,
|
||||||
|
lon: Number((PNX_LINE.west + (i + s / 2) * step).toFixed(7)),
|
||||||
|
lat: PNX_LINE.lat,
|
||||||
|
isPano: s === 0 && i % 7 === 3,
|
||||||
|
datetime:
|
||||||
|
s === 0 ? '2025-06-01T10:00:00+00:00' : '2023-03-01T09:00:00+00:00',
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function item(picture, pictures) {
|
||||||
|
const sibling = (delta) =>
|
||||||
|
pictures.find(
|
||||||
|
(p) =>
|
||||||
|
p.sequenceId === picture.sequenceId &&
|
||||||
|
p.index === picture.index + delta,
|
||||||
|
);
|
||||||
|
const link = (rel, other) =>
|
||||||
|
other
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
rel,
|
||||||
|
href: `${API}/collections/${other.sequenceId}/items/${other.id}`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
const first = picture.sequenceId === PNX_SEQUENCES[0];
|
||||||
|
const files = `${HOST}/derivates/${picture.id}`;
|
||||||
|
return {
|
||||||
|
type: 'Feature',
|
||||||
|
id: picture.id,
|
||||||
|
collection: picture.sequenceId,
|
||||||
|
geometry: { type: 'Point', coordinates: [picture.lon, picture.lat] },
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
name: first ? PNX_CREATOR : '[email protected]',
|
||||||
|
roles: ['producer'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
properties: {
|
||||||
|
datetime: picture.datetime,
|
||||||
|
license: first ? 'CC-BY-SA-4.0' : 'etalab-2.0',
|
||||||
|
'view:azimuth': 90,
|
||||||
|
'pers:interior_orientation': picture.isPano
|
||||||
|
? { field_of_view: 360, sensor_array_dimensions: [5760, 2880] }
|
||||||
|
: { field_of_view: 70, sensor_array_dimensions: [4032, 3024] },
|
||||||
|
},
|
||||||
|
assets: {
|
||||||
|
hd: { href: `${files}/hd.jpg` },
|
||||||
|
sd: { href: `${files}/sd.jpg` },
|
||||||
|
thumb: { href: `${files}/thumb.jpg` },
|
||||||
|
},
|
||||||
|
links: [
|
||||||
|
...link('prev', sibling(-1)),
|
||||||
|
...link('next', sibling(1)),
|
||||||
|
{ rel: 'via', href: HOST },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The tile `z/x/y` in the meta-catalogue's shape. */
|
||||||
|
export function panoramaxTile(z, x, y) {
|
||||||
|
const tile = { x, y, z };
|
||||||
|
const { west, east, south, north } = tileBounds(x, y, z);
|
||||||
|
if (PNX_LINE.lat < south || PNX_LINE.lat > north) return new Uint8Array(0);
|
||||||
|
const from = Math.max(PNX_LINE.west, west);
|
||||||
|
const to = Math.min(PNX_LINE.east, east);
|
||||||
|
if (from >= to) return new Uint8Array(0);
|
||||||
|
if (z <= 5)
|
||||||
|
return encodePanoramaxTile(tile, {
|
||||||
|
grid: [{ lon: (from + to) / 2, lat: PNX_LINE.lat, pano: 30, flat: 170 }],
|
||||||
|
});
|
||||||
|
if (z < 7) return new Uint8Array(0);
|
||||||
|
const sequences = PNX_SEQUENCES.map((id, s) => ({
|
||||||
|
id,
|
||||||
|
type: s === 0 ? 'equirectangular' : 'flat',
|
||||||
|
date: s === 0 ? '2025-06-01' : '2023-03-01',
|
||||||
|
parts: [
|
||||||
|
[
|
||||||
|
[from, PNX_LINE.lat],
|
||||||
|
[to, PNX_LINE.lat],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
if (z < 15) return encodePanoramaxTile(tile, { sequences });
|
||||||
|
const pictures = panoramaxPictures()
|
||||||
|
.filter((p) => p.lon >= west && p.lon < east)
|
||||||
|
.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
lon: p.lon,
|
||||||
|
lat: p.lat,
|
||||||
|
sequenceId: p.sequenceId,
|
||||||
|
heading: 90,
|
||||||
|
ts: p.datetime.replace('T', ' ').replace('+00:00', '+00'),
|
||||||
|
type: p.isPano ? 'equirectangular' : 'flat',
|
||||||
|
}));
|
||||||
|
return encodePanoramaxTile(tile, { sequences, pictures });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A call named by host, path shape and parameter names. */
|
||||||
|
export function describePanoramaxCall(method, url) {
|
||||||
|
const keys = [...url.searchParams.keys()].sort();
|
||||||
|
const path = url.pathname
|
||||||
|
.replace(/\/map\/\d+\/\d+\/\d+\.mvt$/, '/map/{z}/{x}/{y}.mvt')
|
||||||
|
.replace(
|
||||||
|
/^\/derivates\/[\w-]+\/(sd|hd|thumb)\.jpg$/,
|
||||||
|
'/derivates/{id}/$1.jpg',
|
||||||
|
);
|
||||||
|
return `${method} ${url.hostname}${path}${keys.length ? `?${keys.join('&')}` : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = (body) => ({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/geo+json',
|
||||||
|
headers: CORS,
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
known: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Answer one Panoramax request; `known` is false for a call no fixture
|
||||||
|
* covers, so the gate fails on a new one instead of missing it.
|
||||||
|
*/
|
||||||
|
export function answerPanoramaxRequest({ method, url }) {
|
||||||
|
const address = new URL(url);
|
||||||
|
if (method === 'OPTIONS')
|
||||||
|
return { status: 204, headers: CORS, body: '', known: true };
|
||||||
|
const pictures = panoramaxPictures();
|
||||||
|
if (address.hostname === new URL(HOST).hostname) {
|
||||||
|
const file = address.pathname.match(
|
||||||
|
/^\/derivates\/([\w-]+)\/(sd|hd|thumb)\.jpg$/,
|
||||||
|
);
|
||||||
|
if (!file || !pictures.some((p) => p.id === file[1]))
|
||||||
|
return { status: 404, headers: CORS, body: '', known: false };
|
||||||
|
if (file[2] === 'hd') asked.hd++;
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
contentType: 'image/jpeg',
|
||||||
|
headers: CORS,
|
||||||
|
body: photoJpeg(),
|
||||||
|
known: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (address.hostname !== 'api.panoramax.xyz')
|
||||||
|
return { status: 404, headers: CORS, body: '', known: false };
|
||||||
|
const tile = address.pathname.match(/^\/api\/map\/(\d+)\/(\d+)\/(\d+)\.mvt$/);
|
||||||
|
if (tile) {
|
||||||
|
const bytes = panoramaxTile(
|
||||||
|
Number(tile[1]),
|
||||||
|
Number(tile[2]),
|
||||||
|
Number(tile[3]),
|
||||||
|
);
|
||||||
|
return bytes.length
|
||||||
|
? {
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/vnd.mapbox-vector-tile',
|
||||||
|
headers: CORS,
|
||||||
|
body: Buffer.from(bytes),
|
||||||
|
known: true,
|
||||||
|
}
|
||||||
|
: { status: 204, headers: CORS, body: '', known: true };
|
||||||
|
}
|
||||||
|
if (address.pathname !== '/api/search')
|
||||||
|
return { status: 404, headers: CORS, body: '', known: false };
|
||||||
|
asked.search++;
|
||||||
|
const q = address.searchParams;
|
||||||
|
if (q.has('ids')) {
|
||||||
|
const wanted = q.get('ids').split(',');
|
||||||
|
return json({
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: pictures
|
||||||
|
.filter((p) => wanted.includes(p.id))
|
||||||
|
.map((p) => item(p, pictures)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const filter = q.get('filter') || '';
|
||||||
|
const since = q.get('datetime')?.split('/')[0];
|
||||||
|
const kept = pictures
|
||||||
|
.filter(
|
||||||
|
(p) => !q.get('collections') || p.sequenceId === q.get('collections'),
|
||||||
|
)
|
||||||
|
.filter((p) =>
|
||||||
|
filter === 'field_of_view=360'
|
||||||
|
? p.isPano
|
||||||
|
: filter === 'field_of_view<360'
|
||||||
|
? !p.isPano
|
||||||
|
: true,
|
||||||
|
)
|
||||||
|
.filter((p) => !since || Date.parse(p.datetime) >= Date.parse(since));
|
||||||
|
// A box: every picture in it, nearest its centre first, as the live API
|
||||||
|
// returns them (roughly: the browser ranks them again).
|
||||||
|
if (q.has('bbox')) {
|
||||||
|
const [west, south, east, north] = q.get('bbox').split(',').map(Number);
|
||||||
|
const centre = { lon: (west + east) / 2, lat: (south + north) / 2 };
|
||||||
|
return json({
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: kept
|
||||||
|
.filter(
|
||||||
|
(p) =>
|
||||||
|
p.lon >= west && p.lon <= east && p.lat >= south && p.lat <= north,
|
||||||
|
)
|
||||||
|
.map((p) => ({ p, metres: metresApart(centre, p) }))
|
||||||
|
.sort((a, b) => a.metres - b.metres || a.p.id.localeCompare(b.p.id))
|
||||||
|
.slice(0, Number(q.get('limit')) || 10)
|
||||||
|
.map(({ p }) => item(p, pictures)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const [lon, lat] = (q.get('place_position') || '').split(',').map(Number);
|
||||||
|
const radius = Number((q.get('place_distance') || '0-50').split('-')[1]);
|
||||||
|
// As the API does, even at the widest tolerance: a place behind a
|
||||||
|
// picture (every one looks east) is not in it.
|
||||||
|
const facing = (p) => lon >= p.lon - 1e-7;
|
||||||
|
const near = kept
|
||||||
|
.filter(facing)
|
||||||
|
.map((p) => ({ p, metres: metresApart({ lon, lat }, p) }))
|
||||||
|
.filter(({ metres }) => metres <= radius)
|
||||||
|
.sort((a, b) => a.metres - b.metres)
|
||||||
|
.slice(0, Number(q.get('limit')) || 10)
|
||||||
|
.map(({ p }) => item(p, pictures));
|
||||||
|
return json({ type: 'FeatureCollection', features: near });
|
||||||
|
}
|
||||||
+104
-14
@@ -18,6 +18,10 @@
|
|||||||
"src/tooling/panelStorageDocs.test.mjs",
|
"src/tooling/panelStorageDocs.test.mjs",
|
||||||
"src/tooling/packageBoundaries.test.mjs",
|
"src/tooling/packageBoundaries.test.mjs",
|
||||||
"src/data/infrastructure.js",
|
"src/data/infrastructure.js",
|
||||||
|
"src/data/aircraftEmergency.js",
|
||||||
|
"src/data/aircraftEmergency.test.mjs",
|
||||||
|
"src/data/aircraftEmergencyAnalyst.test.mjs",
|
||||||
|
"scripts/qa-emergency-squawk.mjs",
|
||||||
"src/data/infrastructure.test.mjs",
|
"src/data/infrastructure.test.mjs",
|
||||||
"src/data/localGeojsonCore.js",
|
"src/data/localGeojsonCore.js",
|
||||||
"src/data/localGeojsonLod.js",
|
"src/data/localGeojsonLod.js",
|
||||||
@@ -26,6 +30,8 @@
|
|||||||
"src/annotations/drawMode.test.mjs",
|
"src/annotations/drawMode.test.mjs",
|
||||||
"src/annotations/drawTool.js",
|
"src/annotations/drawTool.js",
|
||||||
"src/annotations/drawTool.test.mjs",
|
"src/annotations/drawTool.test.mjs",
|
||||||
|
"src/data/geoFileImport.test.mjs",
|
||||||
|
"src/ui/geoFileImport.test.mjs",
|
||||||
"src/annotations/adminOutline.test.mjs",
|
"src/annotations/adminOutline.test.mjs",
|
||||||
"src/annotations/regionRing.test.mjs",
|
"src/annotations/regionRing.test.mjs",
|
||||||
"src/data/adminBoundaries.test.mjs",
|
"src/data/adminBoundaries.test.mjs",
|
||||||
@@ -103,9 +109,12 @@
|
|||||||
"server/providers/cctv/cap.js",
|
"server/providers/cctv/cap.js",
|
||||||
"server/providers/cctv/groundHeights.js",
|
"server/providers/cctv/groundHeights.js",
|
||||||
"server/providers/cctv/catalog.js",
|
"server/providers/cctv/catalog.js",
|
||||||
|
"server/providers/cctv/lithuania.js",
|
||||||
|
"src/data/cctvLithuania.test.mjs",
|
||||||
"server/providers/cctv/constants.js",
|
"server/providers/cctv/constants.js",
|
||||||
"server/providers/cctv/media.js",
|
"server/providers/cctv/media.js",
|
||||||
"server/providers/cctv/normalize.js",
|
"server/providers/cctv/normalize.js",
|
||||||
|
"src/data/cctvMissingNumbers.test.mjs",
|
||||||
"server/providers/cctv/sources.js",
|
"server/providers/cctv/sources.js",
|
||||||
"server/providers/radio.js",
|
"server/providers/radio.js",
|
||||||
"server/providers/radio/catalog.js",
|
"server/providers/radio/catalog.js",
|
||||||
@@ -195,6 +204,9 @@
|
|||||||
"src/styles/snow.js",
|
"src/styles/snow.js",
|
||||||
"scripts/qa-visual-effects.mjs",
|
"scripts/qa-visual-effects.mjs",
|
||||||
"scripts/qa-draw-tool.mjs",
|
"scripts/qa-draw-tool.mjs",
|
||||||
|
"scripts/qa-geo-import.mjs",
|
||||||
|
"scripts/qa-geo-import-regressions.mjs",
|
||||||
|
"scripts/qa-geo-import-large.mjs",
|
||||||
"src/ui/mapSource.js",
|
"src/ui/mapSource.js",
|
||||||
"src/ui/mapSourceControls.js",
|
"src/ui/mapSourceControls.js",
|
||||||
"src/ui/mapSourceControls.test.mjs",
|
"src/ui/mapSourceControls.test.mjs",
|
||||||
@@ -353,6 +365,12 @@
|
|||||||
"src/layers/vessels/tracking.js",
|
"src/layers/vessels/tracking.js",
|
||||||
"src/layers/vessels/ownership.test.mjs",
|
"src/layers/vessels/ownership.test.mjs",
|
||||||
"src/data/earthquakes.js",
|
"src/data/earthquakes.js",
|
||||||
|
"src/app/layers/airQuality.js",
|
||||||
|
"src/layers/airQuality/index.js",
|
||||||
|
"src/layers/airQuality/model.js",
|
||||||
|
"src/layers/airQuality/model.test.mjs",
|
||||||
|
"src/layers/airQuality/ownership.test.mjs",
|
||||||
|
"src/layers/airQuality/source.js",
|
||||||
"src/layers/earthquakes/index.js",
|
"src/layers/earthquakes/index.js",
|
||||||
"src/layers/earthquakes/model.js",
|
"src/layers/earthquakes/model.js",
|
||||||
"src/layers/earthquakes/source.js",
|
"src/layers/earthquakes/source.js",
|
||||||
@@ -527,7 +545,9 @@
|
|||||||
"src/data/cctvEstonia.test.mjs",
|
"src/data/cctvEstonia.test.mjs",
|
||||||
"src/data/cctvWarendorf.test.mjs",
|
"src/data/cctvWarendorf.test.mjs",
|
||||||
"src/data/cctvCalgary.test.mjs",
|
"src/data/cctvCalgary.test.mjs",
|
||||||
|
"src/data/cctvCataloniaSource.test.mjs",
|
||||||
"src/data/cctvVegvesen.test.mjs",
|
"src/data/cctvVegvesen.test.mjs",
|
||||||
|
"src/data/cctvNyc.test.mjs",
|
||||||
"src/data/cctvNswSource.test.mjs",
|
"src/data/cctvNswSource.test.mjs",
|
||||||
"src/data/cctvTxdotSource.test.mjs",
|
"src/data/cctvTxdotSource.test.mjs",
|
||||||
"src/layers/cctv/source.test.mjs",
|
"src/layers/cctv/source.test.mjs",
|
||||||
@@ -708,6 +728,7 @@
|
|||||||
"src/voice/session.test.mjs",
|
"src/voice/session.test.mjs",
|
||||||
"src/layers/alpr/records.js",
|
"src/layers/alpr/records.js",
|
||||||
"src/layers/earthquakes/records.js",
|
"src/layers/earthquakes/records.js",
|
||||||
|
"src/layers/earthquakes/records.test.mjs",
|
||||||
"src/sources/sourceSlot.js",
|
"src/sources/sourceSlot.js",
|
||||||
"src/data/feedState.js",
|
"src/data/feedState.js",
|
||||||
"src/app/localGeojsonServices.js",
|
"src/app/localGeojsonServices.js",
|
||||||
@@ -720,6 +741,7 @@
|
|||||||
"src/search/nominatim.js",
|
"src/search/nominatim.js",
|
||||||
"src/search/nominatim.test.mjs",
|
"src/search/nominatim.test.mjs",
|
||||||
"src/sources/httpBody.js",
|
"src/sources/httpBody.js",
|
||||||
|
"src/sources/httpBody.test.mjs",
|
||||||
"src/sources/overpass.js",
|
"src/sources/overpass.js",
|
||||||
"src/sources/nominatim.js",
|
"src/sources/nominatim.js",
|
||||||
"server/providers/transit.js",
|
"server/providers/transit.js",
|
||||||
@@ -822,6 +844,16 @@
|
|||||||
"server/providers/firePerimeters.js",
|
"server/providers/firePerimeters.js",
|
||||||
"src/data/cyclonesProxy.test.mjs",
|
"src/data/cyclonesProxy.test.mjs",
|
||||||
"src/data/firePerimetersProxy.test.mjs",
|
"src/data/firePerimetersProxy.test.mjs",
|
||||||
|
"server/providers/gnssIntegrity.js",
|
||||||
|
"src/data/gnssIntegrityProxy.test.mjs",
|
||||||
|
"src/app/layers/gnss.js",
|
||||||
|
"src/layers/gnss/index.js",
|
||||||
|
"src/layers/gnss/records.js",
|
||||||
|
"src/layers/gnss/source.js",
|
||||||
|
"src/layers/gnss/records.test.mjs",
|
||||||
|
"src/layers/gnss/source.test.mjs",
|
||||||
|
"src/layers/gnss/ownership.test.mjs",
|
||||||
|
"scripts/qa-gnss.mjs",
|
||||||
"src/layers/cyclones/index.js",
|
"src/layers/cyclones/index.js",
|
||||||
"src/layers/cyclones/rendering.js",
|
"src/layers/cyclones/rendering.js",
|
||||||
"src/layers/cyclones/labels.js",
|
"src/layers/cyclones/labels.js",
|
||||||
@@ -838,9 +870,13 @@
|
|||||||
"src/layers/weather/imageryHost.test.mjs",
|
"src/layers/weather/imageryHost.test.mjs",
|
||||||
"src/sdr/adsbDecoder.test.mjs",
|
"src/sdr/adsbDecoder.test.mjs",
|
||||||
"src/sdr/controller.test.mjs",
|
"src/sdr/controller.test.mjs",
|
||||||
|
"src/sdr/decoderDiagnostics.test.mjs",
|
||||||
"src/sdr/gain.test.mjs",
|
"src/sdr/gain.test.mjs",
|
||||||
|
"src/sdr/readPipeline.diag.test.mjs",
|
||||||
|
"src/sdr/sdrAudioWorklet.test.mjs",
|
||||||
"src/sdr/spectrum.test.mjs",
|
"src/sdr/spectrum.test.mjs",
|
||||||
"src/sdr/usbDevices.test.mjs",
|
"src/sdr/usbDevices.test.mjs",
|
||||||
|
"src/sdr/workerAdmission.test.mjs",
|
||||||
"src/sources/adsbRecords.test.mjs",
|
"src/sources/adsbRecords.test.mjs",
|
||||||
"src/layers/localAdsb/index.test.mjs",
|
"src/layers/localAdsb/index.test.mjs",
|
||||||
"src/ui/localSdrControls.test.mjs",
|
"src/ui/localSdrControls.test.mjs",
|
||||||
@@ -869,8 +905,8 @@
|
|||||||
"src/app/layers/streetLevel.test.mjs",
|
"src/app/layers/streetLevel.test.mjs",
|
||||||
"src/data/idSwitches.js",
|
"src/data/idSwitches.js",
|
||||||
"src/layers/streetLevel/credits.js",
|
"src/layers/streetLevel/credits.js",
|
||||||
"src/layers/streetLevel/mapillaryProvider.js",
|
"src/layers/streetLevel/providers/mapillary/index.js",
|
||||||
"src/layers/streetLevel/mapillaryProvider.test.mjs",
|
"src/layers/streetLevel/providers/mapillary/provider.test.mjs",
|
||||||
"src/layers/streetLevel/params.js",
|
"src/layers/streetLevel/params.js",
|
||||||
"src/layers/streetLevel/params.test.mjs",
|
"src/layers/streetLevel/params.test.mjs",
|
||||||
"src/layers/streetLevel/pickRouter.js",
|
"src/layers/streetLevel/pickRouter.js",
|
||||||
@@ -880,7 +916,7 @@
|
|||||||
"src/layers/streetLevel/registry.test.mjs",
|
"src/layers/streetLevel/registry.test.mjs",
|
||||||
"src/layers/streetLevel/uiState.js",
|
"src/layers/streetLevel/uiState.js",
|
||||||
"src/layers/streetLevel/uiState.test.mjs",
|
"src/layers/streetLevel/uiState.test.mjs",
|
||||||
"src/layers/streetLevel/source.test.mjs",
|
"src/layers/streetLevel/providers/mapillary/source.test.mjs",
|
||||||
"src/streetLevelMarkup.test.mjs",
|
"src/streetLevelMarkup.test.mjs",
|
||||||
"src/layers/streetLevel/index.js",
|
"src/layers/streetLevel/index.js",
|
||||||
"src/layers/streetLevel/policy.js",
|
"src/layers/streetLevel/policy.js",
|
||||||
@@ -890,14 +926,56 @@
|
|||||||
"src/layers/streetLevel/cameraFraming.js",
|
"src/layers/streetLevel/cameraFraming.js",
|
||||||
"src/layers/streetLevel/viewerHost.js",
|
"src/layers/streetLevel/viewerHost.js",
|
||||||
"src/layers/streetLevel/state.js",
|
"src/layers/streetLevel/state.js",
|
||||||
"src/layers/streetLevel/coverage.js",
|
"src/layers/streetLevel/lineCoverage.js",
|
||||||
"src/layers/streetLevel/sequences.js",
|
"src/layers/streetLevel/sequenceCones.js",
|
||||||
"src/layers/streetLevel/glyphs.js",
|
"src/layers/streetLevel/glyphs.js",
|
||||||
"src/layers/streetLevel/decode.js",
|
"src/layers/streetLevel/providers/mapillary/decode.js",
|
||||||
"src/layers/streetLevel/tileMath.js",
|
"src/layers/streetLevel/tileMath.js",
|
||||||
"src/layers/streetLevel/selection.js",
|
"src/layers/streetLevel/selection.js",
|
||||||
"src/layers/streetLevel/viewer.js",
|
"src/layers/streetLevel/providers/mapillary/viewer.js",
|
||||||
"src/layers/streetLevel/source.js",
|
"src/layers/streetLevel/providers/mapillary/source.js",
|
||||||
|
"src/layers/streetLevel/groundClick.js",
|
||||||
|
"src/layers/streetLevel/providers/google/index.js",
|
||||||
|
"src/layers/streetLevel/providers/google/policy.js",
|
||||||
|
"src/layers/streetLevel/providers/google/viewer.js",
|
||||||
|
"src/layers/streetLevel/providers/google/mapsLoader.js",
|
||||||
|
"src/layers/streetLevel/groundClick.test.mjs",
|
||||||
|
"src/layers/streetLevel/providers/google/provider.test.mjs",
|
||||||
|
"src/layers/streetLevel/providers/google/viewer.test.mjs",
|
||||||
|
"src/layers/streetLevel/providers/google/mapsLoader.test.mjs",
|
||||||
|
"src/layers/streetLevel/providers/kartaview/index.js",
|
||||||
|
"src/layers/streetLevel/providers/kartaview/policy.js",
|
||||||
|
"src/layers/streetLevel/providers/kartaview/source.js",
|
||||||
|
"src/layers/streetLevel/providers/kartaview/decode.js",
|
||||||
|
"src/layers/streetLevel/providers/kartaview/viewer.js",
|
||||||
|
"src/layers/streetLevel/panorama.js",
|
||||||
|
"src/layers/streetLevel/providers/kartaview/policy.test.mjs",
|
||||||
|
"src/layers/streetLevel/providers/kartaview/decode.test.mjs",
|
||||||
|
"src/layers/streetLevel/providers/kartaview/source.test.mjs",
|
||||||
|
"src/layers/streetLevel/panorama.test.mjs",
|
||||||
|
"src/layers/streetLevel/providers/kartaview/viewer.test.mjs",
|
||||||
|
"src/layers/streetLevel/providers/kartaview/provider.test.mjs",
|
||||||
|
"src/layers/streetLevel/providers/panoramax/index.js",
|
||||||
|
"src/layers/streetLevel/providers/panoramax/policy.js",
|
||||||
|
"src/layers/streetLevel/providers/panoramax/source.js",
|
||||||
|
"src/layers/streetLevel/providers/panoramax/decode.js",
|
||||||
|
"src/layers/streetLevel/providers/panoramax/viewer.js",
|
||||||
|
"src/layers/streetLevel/photoViewer.js",
|
||||||
|
"src/layers/streetLevel/limiter.js",
|
||||||
|
"src/layers/streetLevel/imageSize.js",
|
||||||
|
"src/layers/streetLevel/tileCache.js",
|
||||||
|
"src/layers/streetLevel/providers/panoramax/policy.test.mjs",
|
||||||
|
"src/layers/streetLevel/providers/panoramax/decode.test.mjs",
|
||||||
|
"src/layers/streetLevel/providers/panoramax/source.test.mjs",
|
||||||
|
"src/layers/streetLevel/providers/panoramax/viewer.test.mjs",
|
||||||
|
"src/layers/streetLevel/providers/panoramax/provider.test.mjs",
|
||||||
|
"src/testSupport/panoramaxFixtures.mjs",
|
||||||
|
"src/layers/streetLevel/photoViewer.test.mjs",
|
||||||
|
"src/layers/streetLevel/limiter.test.mjs",
|
||||||
|
"src/layers/streetLevel/imageSize.test.mjs",
|
||||||
|
"src/layers/streetLevel/lineCoverage.test.mjs",
|
||||||
|
"src/testSupport/kartaViewFakes.mjs",
|
||||||
|
"src/testSupport/googleStreetViewFakes.mjs",
|
||||||
"server/providers/mapillary.js",
|
"server/providers/mapillary.js",
|
||||||
"server/providers/mapillary/tiles.js",
|
"server/providers/mapillary/tiles.js",
|
||||||
"server/providers/mapillary/trim.js",
|
"server/providers/mapillary/trim.js",
|
||||||
@@ -905,18 +983,21 @@
|
|||||||
"scripts/qa-street-level.mjs",
|
"scripts/qa-street-level.mjs",
|
||||||
"src/tooling/streetLevelQa.test.mjs",
|
"src/tooling/streetLevelQa.test.mjs",
|
||||||
"scripts/qa-browserEvidence.mjs",
|
"scripts/qa-browserEvidence.mjs",
|
||||||
|
"scripts/qa-chrome.mjs",
|
||||||
|
"src/qaChrome.test.mjs",
|
||||||
"scripts/fixtures/street-level/mapillaryGraph.mjs",
|
"scripts/fixtures/street-level/mapillaryGraph.mjs",
|
||||||
|
"scripts/fixtures/street-level/kartaView.mjs",
|
||||||
|
"scripts/fixtures/street-level/panoramax.mjs",
|
||||||
"src/layers/streetLevel/filter.test.mjs",
|
"src/layers/streetLevel/filter.test.mjs",
|
||||||
"src/layers/streetLevel/view.test.mjs",
|
"src/layers/streetLevel/view.test.mjs",
|
||||||
"src/layers/streetLevel/mapillary.test.mjs",
|
"src/layers/streetLevel/providers/mapillary/mapillary.test.mjs",
|
||||||
"src/layers/streetLevel/decode.test.mjs",
|
"src/layers/streetLevel/providers/mapillary/decode.test.mjs",
|
||||||
"src/layers/streetLevel/coverage.test.mjs",
|
"src/layers/streetLevel/providers/mapillary/coverageFixture.mjs",
|
||||||
"src/layers/streetLevel/coverageFixture.mjs",
|
|
||||||
"src/layers/streetLevel/cameraFraming.test.mjs",
|
"src/layers/streetLevel/cameraFraming.test.mjs",
|
||||||
"src/ui/streetLevelControls.test.mjs",
|
"src/ui/streetLevelControls.test.mjs",
|
||||||
"src/layers/streetLevel/marker.test.mjs",
|
"src/layers/streetLevel/marker.test.mjs",
|
||||||
"src/layers/streetLevel/viewerHost.test.mjs",
|
"src/layers/streetLevel/viewerHost.test.mjs",
|
||||||
"src/layers/streetLevel/sequences.test.mjs",
|
"src/layers/streetLevel/sequenceCones.test.mjs",
|
||||||
"src/layers/streetLevel/selection.test.mjs",
|
"src/layers/streetLevel/selection.test.mjs",
|
||||||
"src/layers/streetLevel/index.test.mjs",
|
"src/layers/streetLevel/index.test.mjs",
|
||||||
"scripts/qa-overpass-offload.mjs",
|
"scripts/qa-overpass-offload.mjs",
|
||||||
@@ -960,5 +1041,14 @@
|
|||||||
"src/tools/views.test.mjs",
|
"src/tools/views.test.mjs",
|
||||||
"src/app/embed.test.mjs",
|
"src/app/embed.test.mjs",
|
||||||
"src/tools/globePanel.test.mjs",
|
"src/tools/globePanel.test.mjs",
|
||||||
"src/costEndpointThrottle.test.mjs"
|
"src/costEndpointThrottle.test.mjs",
|
||||||
|
"src/sources/gbfsStations.test.mjs",
|
||||||
|
"scripts/qa-gbfs-names.mjs",
|
||||||
|
"scripts/generate-icons.mjs",
|
||||||
|
"src/tooling/webAppIcons.test.mjs",
|
||||||
|
"src/ui/flyToDataSource.js",
|
||||||
|
"src/ui/flyToDataSource.test.mjs",
|
||||||
|
"scripts/assemble-changelog.mjs",
|
||||||
|
"src/tooling/changelogFragments.test.mjs",
|
||||||
|
"src/data/gbfsProxyRateLimit.test.mjs"
|
||||||
]
|
]
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Renders the home-screen and favicon rasters from `public/logo.svg`.
|
||||||
|
//
|
||||||
|
// Re-run after changing the logo: `node scripts/generate-icons.mjs`. The output
|
||||||
|
// is committed, because the install icons have to exist in a plain `vite build`
|
||||||
|
// and contributors should not need an image toolchain to ship the app.
|
||||||
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
|
||||||
|
const REPO_ROOT = path.resolve(
|
||||||
|
fileURLToPath(new URL('.', import.meta.url)),
|
||||||
|
'..',
|
||||||
|
);
|
||||||
|
const LOGO = path.join(REPO_ROOT, 'public', 'logo.svg');
|
||||||
|
const OUT_DIR = path.join(REPO_ROOT, 'public', 'icons');
|
||||||
|
|
||||||
|
/** `--bg-dark` from src/ui/styles/foundation.css, so the tile matches the app. */
|
||||||
|
const BACKGROUND = '#0a0a0f';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fraction of the canvas the mark is allowed to occupy.
|
||||||
|
*
|
||||||
|
* A maskable icon may be cropped to a circle inscribed in the middle 80% of the
|
||||||
|
* square, so its mark stays well inside that circle; the other icons are shown
|
||||||
|
* as drawn and only need optical breathing room.
|
||||||
|
*/
|
||||||
|
const INSET = { normal: 0.78, maskable: 0.54 };
|
||||||
|
|
||||||
|
const TARGETS = [
|
||||||
|
{ file: 'icon-32.png', size: 32, inset: INSET.normal },
|
||||||
|
{ file: 'icon-180.png', size: 180, inset: INSET.normal },
|
||||||
|
{ file: 'icon-192.png', size: 192, inset: INSET.normal },
|
||||||
|
{ file: 'icon-512.png', size: 512, inset: INSET.normal },
|
||||||
|
{ file: 'icon-512-maskable.png', size: 512, inset: INSET.maskable },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Fit the logo inside `size * inset` and centre it on an opaque square. */
|
||||||
|
async function render(svg, { size, inset }) {
|
||||||
|
const box = Math.round(size * inset);
|
||||||
|
// The logo's viewBox is wider than it is tall, so `contain` bounds it by
|
||||||
|
// width and leaves the vertical padding uneven — centring is done by the
|
||||||
|
// composite below rather than by the resize.
|
||||||
|
const mark = await sharp(svg, { density: 384 })
|
||||||
|
.resize(box, box, {
|
||||||
|
fit: 'contain',
|
||||||
|
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
||||||
|
})
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
return sharp({
|
||||||
|
create: {
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
channels: 4,
|
||||||
|
background: BACKGROUND,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.composite([{ input: mark, gravity: 'centre' }])
|
||||||
|
.png({ compressionLevel: 9 })
|
||||||
|
.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
const svg = await readFile(LOGO);
|
||||||
|
await mkdir(OUT_DIR, { recursive: true });
|
||||||
|
for (const target of TARGETS) {
|
||||||
|
await writeFile(path.join(OUT_DIR, target.file), await render(svg, target));
|
||||||
|
console.log(`icons: wrote ${target.file} (${target.size}px)`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,623 @@
|
|||||||
|
// Keeps the locale catalogs in step with the code.
|
||||||
|
//
|
||||||
|
// npm run i18n check every catalog and find unmarked UI text
|
||||||
|
// npm run i18n -- --prune also drop entries the code no longer uses
|
||||||
|
// npm run i18n -- --missing ru print what ru lacks, as JSON to translate
|
||||||
|
//
|
||||||
|
// A translation left as the English fails unless src/i18n/same-as-english.json
|
||||||
|
// lists it.
|
||||||
|
//
|
||||||
|
// Messages are the English literals passed to t(), tn() and msg() in src/,
|
||||||
|
// plus the markup opted in with data-i18n* in src/ui/templates and index.html.
|
||||||
|
// It also lists UI text that is not marked at all (see findUnmarked).
|
||||||
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
const LOCALES_DIR = path.join(ROOT, 'src/i18n/locales');
|
||||||
|
const SAME_AS_ENGLISH = path.join(ROOT, 'src/i18n/same-as-english.json');
|
||||||
|
|
||||||
|
const STRING = String.raw`'(?:\\.|[^'\\\n])*'|"(?:\\.|[^"\\\n])*"|\`(?:\\.|[^\`\\$])*\``;
|
||||||
|
const CALL = new RegExp(
|
||||||
|
String.raw`\bt\(\s*(${STRING})\s*,\s*\{\s*context:\s*(${STRING})|\b(t|msg)\(\s*(${STRING})|\btn\(\s*[^,]+?,\s*(${STRING})\s*,\s*(${STRING})`,
|
||||||
|
'g',
|
||||||
|
);
|
||||||
|
|
||||||
|
function unquote(literal) {
|
||||||
|
const body = literal.slice(1, -1);
|
||||||
|
return body.replace(
|
||||||
|
/\\(u\{[0-9a-fA-F]+\}|u[0-9a-fA-F]{4}|x[0-9a-fA-F]{2}|.)/gs,
|
||||||
|
(_, esc) => {
|
||||||
|
if (esc[0] === 'u' || esc[0] === 'x') {
|
||||||
|
return String.fromCodePoint(parseInt(esc.replace(/[ux{}]/g, ''), 16));
|
||||||
|
}
|
||||||
|
return { n: '\n', t: '\t', r: '\r' }[esc] ?? esc;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ENTITIES = {
|
||||||
|
amp: '&',
|
||||||
|
lt: '<',
|
||||||
|
gt: '>',
|
||||||
|
quot: '"',
|
||||||
|
apos: "'",
|
||||||
|
nbsp: ' ',
|
||||||
|
};
|
||||||
|
function decodeHtml(text) {
|
||||||
|
return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (all, name) => {
|
||||||
|
if (name[0] === '#') {
|
||||||
|
const hex = name[1] === 'x' || name[1] === 'X';
|
||||||
|
return String.fromCodePoint(
|
||||||
|
parseInt(name.slice(hex ? 2 : 1), hex ? 16 : 10),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ENTITIES[name.toLowerCase()] ?? all;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalize = (text) => text.replace(/\s+/g, ' ').trim();
|
||||||
|
|
||||||
|
function trackedFiles(...pathspecs) {
|
||||||
|
return (
|
||||||
|
execFileSync(
|
||||||
|
'git',
|
||||||
|
[
|
||||||
|
'ls-files',
|
||||||
|
'--cached',
|
||||||
|
'--others',
|
||||||
|
'--exclude-standard',
|
||||||
|
'--',
|
||||||
|
...pathspecs,
|
||||||
|
],
|
||||||
|
{
|
||||||
|
cwd: ROOT,
|
||||||
|
encoding: 'utf8',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.split('\n')
|
||||||
|
.filter(Boolean)
|
||||||
|
// The index still lists a file moved or deleted without git mv / git rm.
|
||||||
|
.filter((file) => existsSync(path.join(ROOT, file)))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every message the code can ask for.
|
||||||
|
* @returns {Map<string, {plural: boolean, one?: string, where: string}>}
|
||||||
|
*/
|
||||||
|
export function extractMessages() {
|
||||||
|
const messages = new Map();
|
||||||
|
const add = (text, where, plural = null) => {
|
||||||
|
if (!text) return;
|
||||||
|
const known = messages.get(text);
|
||||||
|
if (known && Boolean(known.plural) !== Boolean(plural)) {
|
||||||
|
throw new Error(
|
||||||
|
`${where}: "${text}" is used both as plural and plain text (also ${known.where})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!known)
|
||||||
|
messages.set(text, { plural: Boolean(plural), one: plural?.one, where });
|
||||||
|
};
|
||||||
|
|
||||||
|
const sources = sourceFiles();
|
||||||
|
for (const file of sources) {
|
||||||
|
const source = readFileSync(path.join(ROOT, file), 'utf8');
|
||||||
|
for (const match of source.matchAll(CALL)) {
|
||||||
|
const line = source.slice(0, match.index).split('\n').length;
|
||||||
|
const where = `${file}:${line}`;
|
||||||
|
if (match[1]) {
|
||||||
|
add(`${unquote(match[2])}|${unquote(match[1])}`, where);
|
||||||
|
} else if (match[4]) {
|
||||||
|
if (match[4][0] === '`' && match[4].includes('${')) continue;
|
||||||
|
add(unquote(match[4]), where);
|
||||||
|
} else {
|
||||||
|
add(unquote(match[6]), where, { one: unquote(match[5]) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const markup = trackedFiles('index.html', 'src/ui/templates/*.html');
|
||||||
|
const TAG = /<([a-zA-Z][\w-]*)(\s[^>]*?)\/?>([^<]*)/gs;
|
||||||
|
for (const file of markup) {
|
||||||
|
const html = readFileSync(path.join(ROOT, file), 'utf8');
|
||||||
|
for (const [, tag, attrs, text] of html.matchAll(TAG)) {
|
||||||
|
const where = `${file} <${tag}>`;
|
||||||
|
if (/\sdata-i18n(\s|=|$)/.test(attrs))
|
||||||
|
add(normalize(decodeHtml(text)), where);
|
||||||
|
for (const [, name] of attrs.matchAll(
|
||||||
|
/\sdata-i18n-(title|aria-label|placeholder)\b/g,
|
||||||
|
)) {
|
||||||
|
const value = attrs.match(new RegExp(String.raw`\s${name}="([^"]*)"`));
|
||||||
|
if (value) add(decodeHtml(value[1]), where);
|
||||||
|
else
|
||||||
|
throw new Error(
|
||||||
|
`${where}: data-i18n-${name} without a ${name} attribute`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasWords = (text) => /[A-Za-z]{2,}/.test(text);
|
||||||
|
const lineAt = (source, index) => source.slice(0, index).split('\n').length;
|
||||||
|
|
||||||
|
const VOID_TAGS = new Set(
|
||||||
|
'area base br col embed hr img input link meta source track wbr'.split(' '),
|
||||||
|
);
|
||||||
|
const SKIP_TAGS = new Set(['script', 'style', 'svg', 'code', 'pre', 'title']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User-visible markup text without data-i18n*. Skipped, with everything
|
||||||
|
* inside: translate="no" (text that stays as is, as in HTML),
|
||||||
|
* data-i18n-runtime (code sets this text through t()), and icons
|
||||||
|
* (aria-hidden, Material Symbols).
|
||||||
|
*/
|
||||||
|
export function unmarkedMarkup(file, html) {
|
||||||
|
const found = [];
|
||||||
|
const source = html.replace(/<!--[\s\S]*?-->|<![^>]*>/g, (c) =>
|
||||||
|
c.replace(/[^\n]/g, ' '),
|
||||||
|
);
|
||||||
|
const TOKEN =
|
||||||
|
/<(\/?)([a-zA-Z][\w-]*)((?:"[^"]*"|'[^']*'|[^>"'])*?)(\/?)>|([^<]+)/g;
|
||||||
|
const stack = [];
|
||||||
|
const skipping = () => stack.at(-1)?.skip === true;
|
||||||
|
for (const match of source.matchAll(TOKEN)) {
|
||||||
|
const [, closing, rawTag, attrs = '', selfClosing, text] = match;
|
||||||
|
if (text !== undefined) {
|
||||||
|
const top = stack.at(-1);
|
||||||
|
const value = normalize(decodeHtml(text));
|
||||||
|
if (!skipping() && hasWords(value) && !top?.marked)
|
||||||
|
found.push({
|
||||||
|
where: `${file}:${lineAt(source, match.index)}`,
|
||||||
|
text: value,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const tag = rawTag.toLowerCase();
|
||||||
|
if (closing) {
|
||||||
|
const at = stack.map((entry) => entry.tag).lastIndexOf(tag);
|
||||||
|
if (at >= 0) stack.length = at;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const skip =
|
||||||
|
skipping() ||
|
||||||
|
SKIP_TAGS.has(tag) ||
|
||||||
|
/\stranslate="no"/.test(attrs) ||
|
||||||
|
/\sdata-i18n-runtime\b/.test(attrs) ||
|
||||||
|
/\saria-hidden="true"/.test(attrs) ||
|
||||||
|
/class="[^"]*material-symbols/.test(attrs);
|
||||||
|
if (!skip) {
|
||||||
|
for (const [, name, value] of attrs.matchAll(
|
||||||
|
/\s(title|aria-label|placeholder)="([^"]*)"/g,
|
||||||
|
)) {
|
||||||
|
if (
|
||||||
|
hasWords(value) &&
|
||||||
|
!new RegExp(String.raw`\sdata-i18n-${name}\b`).test(attrs)
|
||||||
|
)
|
||||||
|
found.push({
|
||||||
|
where: `${file}:${lineAt(source, match.index)}`,
|
||||||
|
text: `${name}="${decodeHtml(value)}"`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!VOID_TAGS.has(tag) && !selfClosing)
|
||||||
|
stack.push({ tag, skip, marked: /\sdata-i18n(\s|=|$)/.test(attrs) });
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Where a string becomes UI text. Each pattern ends where the value starts.
|
||||||
|
const JS_SINKS = [
|
||||||
|
/\.(?:textContent|innerText|innerHTML|outerHTML|title|placeholder|ariaLabel)\s*=(?!=)\s*/g,
|
||||||
|
/\bsetAttribute\(\s*['"](?:title|aria-label|aria-valuetext|placeholder|alt)['"]\s*,\s*/g,
|
||||||
|
/\b(?:createTextNode|insertAdjacentText)\(\s*(?:['"][a-z]+['"]\s*,\s*)?/g,
|
||||||
|
/\binsertAdjacentHTML\(\s*['"][a-z]+['"]\s*,\s*/g,
|
||||||
|
// Also showToast?.(…) and this._showToast(…).
|
||||||
|
/\b_?(?:showToast|toast|setHint|announce|say)(?:\?\.)?\(\s*/g,
|
||||||
|
// `status` values are often codes ('ok', 'auth-failed'); visibleText drops
|
||||||
|
// those, so only readable text is reported. A name right after `?` is a
|
||||||
|
// ternary branch (`ok ? status : 'Fallback'`), not a property.
|
||||||
|
/(?:^|[{,\s])(?<!\?\s*)(?:label|title|tooltip|placeholder|hint|ariaLabel|accessibilityLabel|caption|subtitle|kicker|emptyText|blurb|countLabel|status|loadingLabel|statusMessage)\s*:\s*/gm,
|
||||||
|
];
|
||||||
|
// Layer status set by assignment: this._error = …, stats.lastError = …,
|
||||||
|
// error = … (a layer factory keeps its status in `let error`). Not checked:
|
||||||
|
// the declaration itself (`const error = …`, `let status = …`) and a default
|
||||||
|
// value in a parameter list or destructuring (see defaultValue).
|
||||||
|
const ASSIGNMENT_SINK =
|
||||||
|
/(?<![\w$])(?<!\b(?:const|let|var)\s+)_?(?:error|lastError|loadingLabel|statusMessage|status)\s*(?:\?\?|\|\|)?=(?![=>])\s*/g;
|
||||||
|
JS_SINKS.push(ASSIGNMENT_SINK);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether `name = value` at `nameStart` is a default (`(status = 'idle') =>`,
|
||||||
|
* `const { error = '…' } = options`): it follows `(`, `[`, `{` or `,` and its
|
||||||
|
* value ends at `,` or a closing bracket. An assignment statement ends at `;`.
|
||||||
|
*/
|
||||||
|
function defaultValue(source, nameStart, valueStart) {
|
||||||
|
const before = source.slice(0, nameStart).trimEnd().at(-1);
|
||||||
|
if (!before || !'([{,'.includes(before)) return false;
|
||||||
|
const end = valueStart + expressionAt(source, valueStart).length;
|
||||||
|
const after = source.slice(end).trimStart()[0];
|
||||||
|
return Boolean(after) && ',)]}'.includes(after);
|
||||||
|
}
|
||||||
|
const MARKERS = /\b(?:t|tn|msg)\(/y;
|
||||||
|
|
||||||
|
/** The expression starting at `start`, up to its end at bracket depth 0. */
|
||||||
|
function expressionAt(source, start) {
|
||||||
|
let depth = 0;
|
||||||
|
let i = start;
|
||||||
|
for (; i < source.length; i++) {
|
||||||
|
const c = source[i];
|
||||||
|
if (c === "'" || c === '"' || c === '`') {
|
||||||
|
i = skipString(source, i);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ('([{'.includes(c)) depth++;
|
||||||
|
else if (')]}'.includes(c)) {
|
||||||
|
if (depth === 0) break;
|
||||||
|
depth--;
|
||||||
|
} else if (depth === 0 && (c === ';' || c === ',')) break;
|
||||||
|
else if (depth === 0 && c === '\n') {
|
||||||
|
const next = source.slice(i + 1).match(/^\s*(\S\S?)/)?.[1] ?? '';
|
||||||
|
const before = source.slice(start, i).trimEnd().at(-1) ?? '';
|
||||||
|
if (!/^(\?|:|\+|\.|&&|\|\|)/.test(next) && !/[?:+=&|(,]/.test(before))
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return source.slice(start, i);
|
||||||
|
}
|
||||||
|
|
||||||
|
function skipString(source, i) {
|
||||||
|
const quote = source[i];
|
||||||
|
for (let j = i + 1; j < source.length; j++) {
|
||||||
|
if (source[j] === '\\') j++;
|
||||||
|
else if (quote === '`' && source[j] === '$' && source[j + 1] === '{') {
|
||||||
|
let depth = 1;
|
||||||
|
for (j += 2; j < source.length && depth; j++) {
|
||||||
|
if (source[j] === '{') depth++;
|
||||||
|
else if (source[j] === '}') depth--;
|
||||||
|
else if ('\'"`'.includes(source[j])) j = skipString(source, j);
|
||||||
|
}
|
||||||
|
j--;
|
||||||
|
} else if (source[j] === quote) return j;
|
||||||
|
else if (quote !== '`' && source[j] === '\n') return j;
|
||||||
|
}
|
||||||
|
return source.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** String literals that sit in value position: not inside any call or index,
|
||||||
|
* not compared, and not already passed to t/tn/msg. */
|
||||||
|
function valueLiterals(expression) {
|
||||||
|
const literals = [];
|
||||||
|
let callDepth = 0;
|
||||||
|
const stack = [];
|
||||||
|
for (let i = 0; i < expression.length; i++) {
|
||||||
|
const c = expression[i];
|
||||||
|
if (c === '(' || c === '[') {
|
||||||
|
const isCall = c === '[' || /[\w$\]\)]\s*$/.test(expression.slice(0, i));
|
||||||
|
stack.push(isCall);
|
||||||
|
if (isCall) callDepth++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (c === ')' || c === ']') {
|
||||||
|
if (stack.pop()) callDepth--;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
MARKERS.lastIndex = i;
|
||||||
|
if (MARKERS.test(expression)) {
|
||||||
|
stack.push(true);
|
||||||
|
callDepth++;
|
||||||
|
i = MARKERS.lastIndex - 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (c !== "'" && c !== '"' && c !== '`') continue;
|
||||||
|
const end = skipString(expression, i);
|
||||||
|
const literal = expression.slice(i, end + 1);
|
||||||
|
const before = expression.slice(0, i).trimEnd();
|
||||||
|
const after = expression.slice(end + 1).trimStart();
|
||||||
|
const compared =
|
||||||
|
/(===|!==|==|!=|\bcase|\bin)$/.test(before) ||
|
||||||
|
/^(===|!==|==|!=|in\b)/.test(after);
|
||||||
|
if (callDepth === 0 && !compared) literals.push(literal);
|
||||||
|
i = end;
|
||||||
|
}
|
||||||
|
return literals;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The text a literal shows: static template parts, HTML tags removed. */
|
||||||
|
function visibleText(literal, html) {
|
||||||
|
let body = literal.slice(1, -1).replace(/\$\{(?:[^{}]|\{[^{}]*\})*\}/g, ' ');
|
||||||
|
if (html) {
|
||||||
|
const attrs = [
|
||||||
|
...body.matchAll(/\s(?:title|aria-label|placeholder|alt)="([^"]*)"/g),
|
||||||
|
].map((m) => m[1]);
|
||||||
|
body = decodeHtml([body.replace(/<[^>]*>/g, ' '), ...attrs].join(' '));
|
||||||
|
}
|
||||||
|
const text = normalize(body);
|
||||||
|
if (!hasWords(text)) return null;
|
||||||
|
if (/\{[^{}]*:[^{}]*\}|^[\w-]+\s*:\s*[^;]+;/.test(text)) return null; // CSS
|
||||||
|
if (/^(?:rgba?|hsla?)\(|\b\d+px\b/.test(text)) return null; // colours, fonts
|
||||||
|
if (/^[a-z0-9_.:/-]+$/.test(text) || /^https?:/.test(text)) return null;
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Blank out comments, keeping strings and line numbers intact. */
|
||||||
|
function withoutComments(source) {
|
||||||
|
let out = '';
|
||||||
|
for (let i = 0; i < source.length; i++) {
|
||||||
|
const c = source[i];
|
||||||
|
if (c === "'" || c === '"' || c === '`') {
|
||||||
|
const end = skipString(source, i);
|
||||||
|
out += source.slice(i, end + 1);
|
||||||
|
i = end;
|
||||||
|
} else if (c === '/' && source[i + 1] === '/') {
|
||||||
|
const end = source.indexOf('\n', i);
|
||||||
|
const stop = end < 0 ? source.length : end;
|
||||||
|
out += ' '.repeat(stop - i);
|
||||||
|
i = stop - 1;
|
||||||
|
} else if (c === '/' && source[i + 1] === '*') {
|
||||||
|
const end = source.indexOf('*/', i + 2);
|
||||||
|
const stop = end < 0 ? source.length : end + 2;
|
||||||
|
out += source.slice(i, stop).replace(/[^\n]/g, ' ');
|
||||||
|
i = stop - 1;
|
||||||
|
} else out += c;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unmarkedScript(file, original) {
|
||||||
|
const found = [];
|
||||||
|
const lines = original.split('\n');
|
||||||
|
const source = withoutComments(original);
|
||||||
|
const ignored = (line) =>
|
||||||
|
/i18n-ignore/.test(lines[line - 1] ?? '') ||
|
||||||
|
/i18n-ignore/.test(lines[line - 2] ?? '');
|
||||||
|
for (const sink of JS_SINKS) {
|
||||||
|
sink.lastIndex = 0;
|
||||||
|
for (const match of source.matchAll(sink)) {
|
||||||
|
const start = match.index + match[0].length;
|
||||||
|
const line = lineAt(source, start);
|
||||||
|
if (ignored(line)) continue;
|
||||||
|
if (sink === ASSIGNMENT_SINK && defaultValue(source, match.index, start))
|
||||||
|
continue;
|
||||||
|
const html = /HTML/.test(match[0]);
|
||||||
|
for (const literal of valueLiterals(expressionAt(source, start))) {
|
||||||
|
const text = visibleText(literal, html);
|
||||||
|
if (text) found.push({ where: `${file}:${line}`, text });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UI text that is not marked for translation: markup without data-i18n* and
|
||||||
|
* string literals that reach the page without t(), tn() or msg().
|
||||||
|
* Opt-outs: see unmarkedMarkup; in code, `// i18n-ignore` on or above the line.
|
||||||
|
* @returns {{where: string, text: string}[]}
|
||||||
|
*/
|
||||||
|
export function findUnmarked() {
|
||||||
|
const found = [];
|
||||||
|
for (const file of trackedFiles('index.html', 'src/ui/templates/*.html'))
|
||||||
|
found.push(
|
||||||
|
...unmarkedMarkup(file, readFileSync(path.join(ROOT, file), 'utf8')),
|
||||||
|
);
|
||||||
|
for (const file of sourceFiles().filter(
|
||||||
|
(name) => !NOT_UI.some((pattern) => pattern.test(name)),
|
||||||
|
))
|
||||||
|
found.push(
|
||||||
|
...unmarkedScript(file, readFileSync(path.join(ROOT, file), 'utf8')),
|
||||||
|
);
|
||||||
|
const seen = new Set();
|
||||||
|
return found.filter(({ where, text }) => {
|
||||||
|
const key = `${where}\0${text}`;
|
||||||
|
if (seen.has(key)) return false;
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text that never reaches the page as UI: tool results and descriptions for
|
||||||
|
// AI clients (MCP and the voice model).
|
||||||
|
const NOT_UI = [/^src\/tools\//];
|
||||||
|
|
||||||
|
function sourceFiles() {
|
||||||
|
return trackedFiles(
|
||||||
|
'src/*.js',
|
||||||
|
'src/*.mjs',
|
||||||
|
'src/**/*.js',
|
||||||
|
'src/**/*.mjs',
|
||||||
|
).filter(
|
||||||
|
(file) =>
|
||||||
|
!file.startsWith('src/i18n/') &&
|
||||||
|
!/\.test\.mjs$|\/testSupport\//.test(file),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const placeholders = (text) =>
|
||||||
|
[...String(text).matchAll(/\{([A-Za-z0-9_]+)\}/g)]
|
||||||
|
.map((m) => m[1])
|
||||||
|
.sort()
|
||||||
|
.join(',');
|
||||||
|
|
||||||
|
export function catalogPath(locale) {
|
||||||
|
return path.join(LOCALES_DIR, `${locale}.json`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readCatalog(locale) {
|
||||||
|
return JSON.parse(readFileSync(catalogPath(locale), 'utf8'));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shippedLocales() {
|
||||||
|
return trackedFiles('src/i18n/locales/*.json').map((file) =>
|
||||||
|
path.basename(file, '.json'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Messages a locale keeps exactly as in English, on purpose. */
|
||||||
|
export function readSameAsEnglish(locale) {
|
||||||
|
const lists = JSON.parse(readFileSync(SAME_AS_ENGLISH, 'utf8'));
|
||||||
|
return new Set(lists[locale] || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The English a message shows: its text without the "context|" prefix. */
|
||||||
|
const englishOf = (text) => text.slice(text.indexOf('|') + 1);
|
||||||
|
|
||||||
|
/** A translation with nothing to show: t() falls back to English for it. */
|
||||||
|
const blank = (value) => value.trim() === '';
|
||||||
|
|
||||||
|
/** Words to read, beyond {placeholders}, codes and punctuation. */
|
||||||
|
const hasLetters = (value) => /\p{L}{2}/u.test(value.replace(/\{[^}]*\}/g, ''));
|
||||||
|
|
||||||
|
const STILL_ENGLISH = (text, locale) =>
|
||||||
|
`"${text}" is still English: translate it, or list it under "${locale}" in src/i18n/same-as-english.json`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare one catalog with the extracted messages.
|
||||||
|
* @param {Set<string>} [sameAsEnglish] Messages allowed to read as English.
|
||||||
|
* @returns {{missing: string[], stale: string[], errors: string[]}}
|
||||||
|
*/
|
||||||
|
export function checkCatalog(
|
||||||
|
locale,
|
||||||
|
catalog,
|
||||||
|
messages,
|
||||||
|
sameAsEnglish = readSameAsEnglish(locale),
|
||||||
|
) {
|
||||||
|
const categories = new Intl.PluralRules(locale).resolvedOptions()
|
||||||
|
.pluralCategories;
|
||||||
|
const missing = [];
|
||||||
|
const errors = [];
|
||||||
|
for (const [text, info] of messages) {
|
||||||
|
if (!Object.hasOwn(catalog, text)) {
|
||||||
|
missing.push(text);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const entry = catalog[text];
|
||||||
|
if (info.plural) {
|
||||||
|
if (!entry || typeof entry !== 'object') {
|
||||||
|
errors.push(`"${text}" needs plural forms ${categories.join('/')}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const english = new Set([info.one, englishOf(text)]);
|
||||||
|
const stillEnglish = [];
|
||||||
|
for (const category of categories) {
|
||||||
|
const form = entry[category];
|
||||||
|
if (typeof form !== 'string')
|
||||||
|
errors.push(`"${text}" lacks the "${category}" form`);
|
||||||
|
else if (blank(form))
|
||||||
|
errors.push(`"${text}" [${category}] is empty: translate it`);
|
||||||
|
else if (placeholders(form) !== placeholders(text))
|
||||||
|
errors.push(`"${text}" [${category}] placeholders differ: ${form}`);
|
||||||
|
else if (english.has(form) && hasLetters(form))
|
||||||
|
stillEnglish.push(category);
|
||||||
|
}
|
||||||
|
if (stillEnglish.length && !sameAsEnglish.has(text))
|
||||||
|
errors.push(
|
||||||
|
`${STILL_ENGLISH(text, locale)} (forms ${stillEnglish.join('/')})`,
|
||||||
|
);
|
||||||
|
} else if (typeof entry !== 'string') {
|
||||||
|
errors.push(`"${text}" must be a string`);
|
||||||
|
} else if (blank(entry)) {
|
||||||
|
errors.push(`"${text}" is empty: translate it`);
|
||||||
|
} else if (placeholders(entry) !== placeholders(text)) {
|
||||||
|
errors.push(`"${text}" placeholders differ: ${entry}`);
|
||||||
|
} else if (
|
||||||
|
entry === englishOf(text) &&
|
||||||
|
hasLetters(entry) &&
|
||||||
|
!sameAsEnglish.has(text)
|
||||||
|
) {
|
||||||
|
errors.push(STILL_ENGLISH(text, locale));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const stale = Object.keys(catalog).filter((text) => !messages.has(text));
|
||||||
|
return { missing, stale, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What `--missing <locale>` prints: every missing entry, ready to translate.
|
||||||
|
* A template, not a translation: the check rejects a string left empty and a
|
||||||
|
* plural form left as the English.
|
||||||
|
*/
|
||||||
|
export function missingTemplate(locale, catalog, messages) {
|
||||||
|
const categories = new Intl.PluralRules(locale).resolvedOptions()
|
||||||
|
.pluralCategories;
|
||||||
|
const out = {};
|
||||||
|
for (const text of checkCatalog(locale, catalog, messages, new Set())
|
||||||
|
.missing) {
|
||||||
|
const info = messages.get(text);
|
||||||
|
out[text] = info.plural
|
||||||
|
? Object.fromEntries(
|
||||||
|
categories.map((category) => [
|
||||||
|
category,
|
||||||
|
category === 'one' ? info.one : text,
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
: '';
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function main(args) {
|
||||||
|
const messages = extractMessages();
|
||||||
|
const missingFor = args.includes('--missing')
|
||||||
|
? args[args.indexOf('--missing') + 1]
|
||||||
|
: null;
|
||||||
|
if (missingFor) {
|
||||||
|
const out = missingTemplate(missingFor, readCatalog(missingFor), messages);
|
||||||
|
console.log(JSON.stringify(out, null, 2));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let failed = false;
|
||||||
|
for (const locale of shippedLocales()) {
|
||||||
|
let catalog = readCatalog(locale);
|
||||||
|
const { missing, stale, errors } = checkCatalog(locale, catalog, messages);
|
||||||
|
if (args.includes('--prune') && stale.length) {
|
||||||
|
catalog = Object.fromEntries(
|
||||||
|
Object.entries(catalog).filter(([text]) => messages.has(text)),
|
||||||
|
);
|
||||||
|
writeFileSync(
|
||||||
|
catalogPath(locale),
|
||||||
|
`${JSON.stringify(catalog, null, 2)}\n`,
|
||||||
|
);
|
||||||
|
console.log(`${locale}: pruned ${stale.length} unused entries`);
|
||||||
|
} else if (stale.length) {
|
||||||
|
failed = true;
|
||||||
|
console.log(
|
||||||
|
`${locale}: ${stale.length} entries the code no longer uses (npm run i18n -- --prune):`,
|
||||||
|
);
|
||||||
|
for (const text of stale) console.log(` ${JSON.stringify(text)}`);
|
||||||
|
}
|
||||||
|
if (missing.length) {
|
||||||
|
failed = true;
|
||||||
|
console.log(
|
||||||
|
`${locale}: ${missing.length} missing (npm run i18n -- --missing ${locale}):`,
|
||||||
|
);
|
||||||
|
for (const text of missing)
|
||||||
|
console.log(` ${JSON.stringify(text)} ${messages.get(text).where}`);
|
||||||
|
}
|
||||||
|
for (const error of errors) {
|
||||||
|
failed = true;
|
||||||
|
console.log(`${locale}: ${error}`);
|
||||||
|
}
|
||||||
|
if (!missing.length && !stale.length && !errors.length) {
|
||||||
|
console.log(`${locale}: ${messages.size} messages, complete`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const unmarked = findUnmarked();
|
||||||
|
if (unmarked.length) {
|
||||||
|
failed = true;
|
||||||
|
console.log(
|
||||||
|
`${unmarked.length} UI strings not marked for translation (see docs/I18N.md):`,
|
||||||
|
);
|
||||||
|
for (const { where, text } of unmarked)
|
||||||
|
console.log(` ${where} ${JSON.stringify(text)}`);
|
||||||
|
}
|
||||||
|
return failed ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||||||
|
process.exitCode = main(process.argv.slice(2));
|
||||||
|
}
|
||||||
+556
-82
File diff suppressed because it is too large.
Load diff
@@ -24,8 +24,8 @@ export function runChecked(command, args, { shell = false } = {}) {
|
|||||||
export function installPinokioDependencies() {
|
export function installPinokioDependencies() {
|
||||||
applyPinokioEnvironment();
|
applyPinokioEnvironment();
|
||||||
rmSync(READY_FILE, { force: true });
|
rmSync(READY_FILE, { force: true });
|
||||||
const npm = npmProcessSpec();
|
const npm = npmProcessSpec(['ci']);
|
||||||
runChecked(npm.command, ['ci'], { shell: npm.shell });
|
runChecked(npm.command, npm.args, { shell: npm.shell });
|
||||||
|
|
||||||
// Pinokio starts Vite directly and loads only its ENVIRONMENT file plus the
|
// Pinokio starts Vite directly and loads only its ENVIRONMENT file plus the
|
||||||
// normal dotenv ladder. Unlike dev-fresh.sh, it does not import macOS
|
// normal dotenv ladder. Unlike dev-fresh.sh, it does not import macOS
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||||
|
import { chromeExecutablePath } from './qa-chrome.mjs';
|
||||||
import {
|
import {
|
||||||
planeSupportPoints,
|
planeSupportPoints,
|
||||||
poseHash,
|
poseHash,
|
||||||
@@ -389,8 +390,7 @@ async function main() {
|
|||||||
const { default: puppeteer } = await import('puppeteer');
|
const { default: puppeteer } = await import('puppeteer');
|
||||||
browser = await puppeteer.launch({
|
browser = await puppeteer.launch({
|
||||||
headless: false,
|
headless: false,
|
||||||
executablePath:
|
executablePath: chromeExecutablePath(),
|
||||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
||||||
protocolTimeout: 600000,
|
protocolTimeout: 600000,
|
||||||
args: [
|
args: [
|
||||||
'--no-sandbox',
|
'--no-sandbox',
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
/**
|
||||||
|
* qa-air-quality.mjs — headless proof for the Air quality layer.
|
||||||
|
*
|
||||||
|
* Checks what unit tests cannot: that the live ECCC feed answers, that readings
|
||||||
|
* reach the app in the provider-shaped form other networks will share, and that
|
||||||
|
* the Weather readout descriptor carries a legend and an in-view list.
|
||||||
|
*
|
||||||
|
* AQHI is reported by ECCC as an integer from 1 with "10+" above ten. The API
|
||||||
|
* returns the underlying decimal, so a reading arriving as 1.08 — or a station
|
||||||
|
* with no reading surfacing as a confident 1 — is a real defect this catches.
|
||||||
|
*
|
||||||
|
* Run: node scripts/qa-air-quality.mjs --url http://localhost:4173
|
||||||
|
*/
|
||||||
|
|
||||||
|
import puppeteer from 'puppeteer';
|
||||||
|
|
||||||
|
const argv = process.argv.slice(2);
|
||||||
|
const getOpt = (name, dflt) => {
|
||||||
|
const i = argv.indexOf(name);
|
||||||
|
return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt;
|
||||||
|
};
|
||||||
|
const APP_URL = getOpt('--url', 'http://localhost:4173');
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
function record(name, ok, detail) {
|
||||||
|
results.push({ name, ok, detail });
|
||||||
|
const tag = ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m';
|
||||||
|
console.log(` [${tag}] ${name}${detail ? ` — ${detail}` : ''}`);
|
||||||
|
}
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
let exitCode = 0;
|
||||||
|
const consoleErrors = [];
|
||||||
|
const browser = await puppeteer.launch({
|
||||||
|
headless: 'new',
|
||||||
|
args: [
|
||||||
|
'--use-gl=swiftshader',
|
||||||
|
'--enable-unsafe-swiftshader',
|
||||||
|
'--no-sandbox',
|
||||||
|
'--disable-dev-shm-usage',
|
||||||
|
],
|
||||||
|
defaultViewport: { width: 1400, height: 900 },
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const page = await browser.newPage();
|
||||||
|
page.on('pageerror', (e) => consoleErrors.push(String(e).slice(0, 160)));
|
||||||
|
page.on('console', (m) => {
|
||||||
|
if (m.type() === 'error') consoleErrors.push(m.text().slice(0, 160));
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 90000 });
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager,
|
||||||
|
{ timeout: 90000 },
|
||||||
|
);
|
||||||
|
await sleep(1500);
|
||||||
|
|
||||||
|
const stats = await page.evaluate(async () => {
|
||||||
|
const dm = window.__godsEyeView.dataManager;
|
||||||
|
await dm.setEnabled('air-quality', true);
|
||||||
|
const mod = dm.layers.get('air-quality')?.module;
|
||||||
|
if (!mod) return { missing: true };
|
||||||
|
let s = null;
|
||||||
|
for (let i = 0; i < 45; i++) {
|
||||||
|
await new Promise((r) => setTimeout(r, 1000));
|
||||||
|
s = mod.getStats();
|
||||||
|
if (s.count > 0 || s.error) break;
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
});
|
||||||
|
|
||||||
|
const loaded = !stats?.missing && !stats?.error && stats?.count > 0;
|
||||||
|
record('stations report a current index', loaded,
|
||||||
|
`count=${stats?.count} error=${stats?.error}`);
|
||||||
|
if (!loaded) exitCode = 1;
|
||||||
|
|
||||||
|
const records = await page.evaluate(
|
||||||
|
() =>
|
||||||
|
window.__godsEyeView.dataManager
|
||||||
|
.layers.get('air-quality')
|
||||||
|
?.module.getAnalystRecords(500) || [],
|
||||||
|
);
|
||||||
|
|
||||||
|
// The provider-neutral contract other networks will share.
|
||||||
|
const shaped = records.every(
|
||||||
|
(r) => r.provider && r.scale && Number.isFinite(r.value) && r.band,
|
||||||
|
);
|
||||||
|
record('every reading carries provider, scale, value and band', shaped,
|
||||||
|
records.length
|
||||||
|
? `sample={provider:${records[0].provider}, scale:${records[0].scale}, value:${records[0].value}, band:${records[0].band}}`
|
||||||
|
: 'no records');
|
||||||
|
if (!shaped) exitCode = 1;
|
||||||
|
|
||||||
|
const published = records.every((r) => Number.isInteger(r.value) && r.value >= 1);
|
||||||
|
record('every AQHI value is in the published integer form, floored at 1', published,
|
||||||
|
`values=${[...new Set(records.map((r) => r.value))].sort((a, b) => a - b).join(',')}`);
|
||||||
|
if (!published) exitCode = 1;
|
||||||
|
|
||||||
|
const zones = [...new Set(records.map((r) => r.zone).filter(Boolean))].sort();
|
||||||
|
record('coverage spans multiple ECCC administrative zones', zones.length >= 3,
|
||||||
|
`zones=${zones.join(',') || 'none'} stations=${records.length}`);
|
||||||
|
if (zones.length < 3) exitCode = 1;
|
||||||
|
|
||||||
|
// The card's whole point is "highest readings in view", so put the camera
|
||||||
|
// over Canada before asserting on it — the default view is over Austin,
|
||||||
|
// where an empty in-view list is correct and proves nothing.
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const gev = window.__godsEyeView;
|
||||||
|
const ellipsoid = gev.viewer.scene.globe.ellipsoid;
|
||||||
|
const d2r = Math.PI / 180;
|
||||||
|
try {
|
||||||
|
gev.viewer.camera.cancelFlight();
|
||||||
|
} catch {
|
||||||
|
/* no flight in progress */
|
||||||
|
}
|
||||||
|
gev.viewer.camera.setView({
|
||||||
|
destination: ellipsoid.cartographicToCartesian({
|
||||||
|
longitude: -100 * d2r,
|
||||||
|
latitude: 55 * d2r,
|
||||||
|
height: 4_000_000,
|
||||||
|
}),
|
||||||
|
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||||
|
});
|
||||||
|
gev.viewer.scene.requestRender?.();
|
||||||
|
});
|
||||||
|
await sleep(2500);
|
||||||
|
|
||||||
|
const controls = await page.evaluate(
|
||||||
|
() =>
|
||||||
|
window.__godsEyeView.dataManager
|
||||||
|
.layers.get('air-quality')
|
||||||
|
?.module.getRowControls?.() || null,
|
||||||
|
);
|
||||||
|
const readoutOk =
|
||||||
|
controls?.readout === true &&
|
||||||
|
Array.isArray(controls?.legend?.colors) &&
|
||||||
|
controls.legend.colors.length === controls.legend.labels.length &&
|
||||||
|
Array.isArray(controls?.list?.items) &&
|
||||||
|
controls.list.items.length > 0 &&
|
||||||
|
controls.list.items.every((item) => item.id && item.label && item.value) &&
|
||||||
|
/Canada only/.test(controls?.summary?.coverage || '');
|
||||||
|
record('the Weather readout lists the worst readings in view, with a legend', readoutOk,
|
||||||
|
controls
|
||||||
|
? `bands=${controls.legend?.labels?.join('/')} inView=${controls.list?.items?.length} coverage="${controls.summary?.coverage}"`
|
||||||
|
: 'no descriptor');
|
||||||
|
if (!readoutOk) exitCode = 1;
|
||||||
|
|
||||||
|
record('no console errors', consoleErrors.length === 0,
|
||||||
|
consoleErrors.length ? consoleErrors.slice(0, 2).join(' | ') : 'clean');
|
||||||
|
if (consoleErrors.length) exitCode = 1;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('\x1b[31mHarness error:\x1b[0m', e);
|
||||||
|
exitCode = 3;
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
const pass = results.filter((r) => r.ok === true).length;
|
||||||
|
const fail = results.filter((r) => r.ok === false).length;
|
||||||
|
console.log('\n' + '─'.repeat(60));
|
||||||
|
console.log(` RESULT: ${pass} passed, ${fail} failed`);
|
||||||
|
console.log('─'.repeat(60) + '\n');
|
||||||
|
process.exit(exitCode || (fail > 0 ? 1 : 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(3);
|
||||||
|
});
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
import puppeteer from 'puppeteer';
|
import puppeteer from 'puppeteer';
|
||||||
import { mkdirSync } from 'node:fs';
|
import { mkdirSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
const argv = process.argv;
|
const argv = process.argv;
|
||||||
const url = argv.includes('--url') ? argv[argv.indexOf('--url') + 1] : 'http://localhost:4214';
|
const url = argv.includes('--url') ? argv[argv.indexOf('--url') + 1] : 'http://localhost:4214';
|
||||||
@@ -65,7 +66,9 @@ try {
|
|||||||
requestAnimationFrame(tick);
|
requestAnimationFrame(tick);
|
||||||
}));
|
}));
|
||||||
await new Promise((r) => setTimeout(r, 1_000));
|
await new Promise((r) => setTimeout(r, 1_000));
|
||||||
const path = new URL(`../qa-shots/cables-${tag}-${view.name}.png`, import.meta.url).pathname;
|
const path = fileURLToPath(
|
||||||
|
new URL(`../qa-shots/cables-${tag}-${view.name}.png`, import.meta.url),
|
||||||
|
);
|
||||||
await page.screenshot({ path });
|
await page.screenshot({ path });
|
||||||
console.log(`saved ${path}`);
|
console.log(`saved ${path}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* The browser for gates that drive an installed Chrome. These gates were
|
||||||
|
* written against macOS Google Chrome, which Windows and Linux do not have.
|
||||||
|
*/
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
|
||||||
|
export const MACOS_CHROME =
|
||||||
|
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUPPETEER_EXECUTABLE_PATH when set, then macOS Google Chrome when it is
|
||||||
|
* installed. Otherwise undefined, so Puppeteer launches the browser it manages.
|
||||||
|
*/
|
||||||
|
export function chromeExecutablePath({
|
||||||
|
env = process.env,
|
||||||
|
exists = existsSync,
|
||||||
|
} = {}) {
|
||||||
|
if (env.PUPPETEER_EXECUTABLE_PATH) return env.PUPPETEER_EXECUTABLE_PATH;
|
||||||
|
return exists(MACOS_CHROME) ? MACOS_CHROME : undefined;
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import fs from 'node:fs/promises';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import puppeteer from 'puppeteer';
|
import puppeteer from 'puppeteer';
|
||||||
|
import { chromeExecutablePath } from './qa-chrome.mjs';
|
||||||
|
|
||||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
@@ -35,8 +36,7 @@ const check = (name, passed, detail = '') => {
|
|||||||
|
|
||||||
const browser = await puppeteer.launch({
|
const browser = await puppeteer.launch({
|
||||||
headless: false,
|
headless: false,
|
||||||
executablePath:
|
executablePath: chromeExecutablePath(),
|
||||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
||||||
args: [
|
args: [
|
||||||
'--no-sandbox',
|
'--no-sandbox',
|
||||||
'--disable-background-timer-throttling',
|
'--disable-background-timer-throttling',
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import puppeteer from 'puppeteer';
|
import puppeteer from 'puppeteer';
|
||||||
|
import { chromeExecutablePath } from './qa-chrome.mjs';
|
||||||
|
|
||||||
const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:4173';
|
const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:4173';
|
||||||
const SHOT_DIR = process.env.QA_SHOT_DIR || 'qa-shots/draw';
|
const SHOT_DIR = process.env.QA_SHOT_DIR || 'qa-shots/draw';
|
||||||
@@ -22,8 +23,7 @@ const browser = await puppeteer.launch({
|
|||||||
headless: !HEADFUL,
|
headless: !HEADFUL,
|
||||||
...(HEADFUL
|
...(HEADFUL
|
||||||
? {
|
? {
|
||||||
executablePath:
|
executablePath: chromeExecutablePath(),
|
||||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
args: [
|
args: [
|
||||||
|
|||||||
@@ -0,0 +1,554 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* qa-emergency-squawk.mjs — headless proof for transponder emergency highlighting.
|
||||||
|
*
|
||||||
|
* Drives the REAL app in headless Chromium against a dev server (keyless).
|
||||||
|
* `/api/flights` and `/api/military` are stubbed in the page so the codes
|
||||||
|
* are deterministic; everything downstream (normalizers, records, rendering,
|
||||||
|
* Cockpit HUD) is production code.
|
||||||
|
*
|
||||||
|
* (i) TINT — a civil 7600 and a military ADS-B `general` emergency are
|
||||||
|
* drawn red; a normal civil flight stays white, a normal military
|
||||||
|
* flight stays amber, and a military `minfuel` priority stays amber.
|
||||||
|
* POLL/MODELS verify existing untracked billboards and 3D models
|
||||||
|
* repaint when emergency status starts and clears.
|
||||||
|
* (ii) HUD — tracking the military emergency and entering Cockpit names
|
||||||
|
* the broadcast code on the aircraft-meta line, in red.
|
||||||
|
* (iii) CLEAR — the next poll drops the code: the HUD line returns to its
|
||||||
|
* normal wording (the status is not sticky).
|
||||||
|
* (iv) RELEASE — the code returns and tracking stops: the restored fleet
|
||||||
|
* billboard is red, not the plain military amber.
|
||||||
|
*
|
||||||
|
* Screenshots saved to qa-shots/emergency-*.png (gitignored).
|
||||||
|
*
|
||||||
|
* Run: node scripts/qa-emergency-squawk.mjs --url http://localhost:4173
|
||||||
|
* Optional: --timeout-ms 180000 for a slow boot.
|
||||||
|
* Exits non-zero on any FAIL. Does not commit anything.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import puppeteer from 'puppeteer';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const REPO_ROOT = path.resolve(__dirname, '..');
|
||||||
|
const SHOTS_DIR = path.join(REPO_ROOT, 'qa-shots');
|
||||||
|
|
||||||
|
const argv = process.argv.slice(2);
|
||||||
|
const getOpt = (name, dflt) => {
|
||||||
|
const i = argv.indexOf(name);
|
||||||
|
return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt;
|
||||||
|
};
|
||||||
|
const APP_URL = getOpt('--url', 'http://localhost:4173');
|
||||||
|
const HEADFUL = argv.includes('--headful');
|
||||||
|
const TIMEOUT_MS = Number(getOpt('--timeout-ms', '60000'));
|
||||||
|
const CENTER = { lon: 4.0, lat: 53.5 };
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
function record(name, ok, detail) {
|
||||||
|
results.push({ name, ok, detail });
|
||||||
|
const tag = ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m';
|
||||||
|
console.log(` [${tag}] ${name}${detail ? ` — ${detail}` : ''}`);
|
||||||
|
}
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
/** OpenSky state vector: squawk sits at index 14. */
|
||||||
|
const civilRow = (icao24, callsign, lon, lat, squawk) => [
|
||||||
|
icao24,
|
||||||
|
callsign,
|
||||||
|
'Netherlands',
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
lon,
|
||||||
|
lat,
|
||||||
|
10000,
|
||||||
|
false,
|
||||||
|
230,
|
||||||
|
90,
|
||||||
|
0,
|
||||||
|
null,
|
||||||
|
10050,
|
||||||
|
squawk,
|
||||||
|
false,
|
||||||
|
0,
|
||||||
|
];
|
||||||
|
|
||||||
|
/** readsb row as adsb.lol /v2/mil returns it. */
|
||||||
|
const milRow = (hex, flight, lon, lat, squawk, emergency) => ({
|
||||||
|
hex,
|
||||||
|
flight,
|
||||||
|
lon,
|
||||||
|
lat,
|
||||||
|
alt_baro: 24000,
|
||||||
|
alt_geom: 24200,
|
||||||
|
gs: 380,
|
||||||
|
track: 270,
|
||||||
|
t: 'C17',
|
||||||
|
r: `QA-${hex}`,
|
||||||
|
squawk,
|
||||||
|
emergency,
|
||||||
|
seen: 1,
|
||||||
|
seen_pos: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Page-side route stubs; `__squawkQaPhase` switches the military emergency off. */
|
||||||
|
function installStubs() {
|
||||||
|
const realFetch = window.fetch.bind(window);
|
||||||
|
window.__squawkQaPhase = 'emergency';
|
||||||
|
const json = (body) =>
|
||||||
|
Promise.resolve(
|
||||||
|
new Response(JSON.stringify(body), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
window.fetch = (input, init) => {
|
||||||
|
const url = typeof input === 'string' ? input : input?.url || '';
|
||||||
|
const now = Date.now();
|
||||||
|
const { civil, mil } = window.__squawkQaFixtures;
|
||||||
|
if (
|
||||||
|
url.includes('/api/flights/track') ||
|
||||||
|
url.includes('/api/military/track')
|
||||||
|
)
|
||||||
|
return json({ path: [] });
|
||||||
|
if (url.includes('/api/flights')) {
|
||||||
|
const t = Math.floor(now / 1000);
|
||||||
|
return json({
|
||||||
|
time: t,
|
||||||
|
states: civil.map((row) => {
|
||||||
|
const copy = [...row];
|
||||||
|
copy[3] = t - 1;
|
||||||
|
copy[4] = t - 1;
|
||||||
|
return copy;
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url.includes('/api/military')) {
|
||||||
|
const cleared = window.__squawkQaPhase === 'cleared';
|
||||||
|
return json({
|
||||||
|
msg: 'No error',
|
||||||
|
now,
|
||||||
|
ac: mil.map((row) =>
|
||||||
|
cleared && row.hex === 'ae7700'
|
||||||
|
? { ...row, squawk: '4521', emergency: 'none' }
|
||||||
|
: row,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return realFetch(input, init);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Colour of every shown fleet billboard whose id is one of `ids`. */
|
||||||
|
function billboardColors(ids) {
|
||||||
|
const { viewer } = window.__godsEyeView;
|
||||||
|
const found = {};
|
||||||
|
const seen = new Set();
|
||||||
|
const visit = (primitive) => {
|
||||||
|
if (!primitive || seen.has(primitive)) return;
|
||||||
|
seen.add(primitive);
|
||||||
|
if (
|
||||||
|
typeof primitive.length !== 'number' ||
|
||||||
|
typeof primitive.get !== 'function'
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
for (let i = 0; i < primitive.length; i++) {
|
||||||
|
const item = primitive.get(i);
|
||||||
|
if (item?.color && ids.includes(item.id) && item.show !== false) {
|
||||||
|
found[item.id] = {
|
||||||
|
r: Math.round(item.color.red * 255),
|
||||||
|
g: Math.round(item.color.green * 255),
|
||||||
|
b: Math.round(item.color.blue * 255),
|
||||||
|
};
|
||||||
|
} else visit(item);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
visit(viewer.scene.primitives);
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isRed = (c) => Boolean(c) && c.r > 200 && c.g < 110 && c.b < 110;
|
||||||
|
const isWhite = (c) => Boolean(c) && c.r > 220 && c.g > 220 && c.b > 220;
|
||||||
|
const isAmber = (c) =>
|
||||||
|
Boolean(c) && c.r > 180 && c.g > 110 && c.b < 140 && !isRed(c);
|
||||||
|
|
||||||
|
async function shoot(page, name) {
|
||||||
|
await page
|
||||||
|
.waitForFunction(
|
||||||
|
() => window.__godsEyeView.viewer.scene.globe.tilesLoaded,
|
||||||
|
{ timeout: 30000 },
|
||||||
|
)
|
||||||
|
.catch(() => {});
|
||||||
|
await sleep(600);
|
||||||
|
await page.screenshot({
|
||||||
|
path: path.join(SHOTS_DIR, `emergency-${name}.png`),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('\nEmergency squawk proof (qa-emergency-squawk)');
|
||||||
|
console.log(` App URL : ${APP_URL}\n`);
|
||||||
|
try {
|
||||||
|
const res = await fetch(APP_URL);
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(
|
||||||
|
`\x1b[31mDev server not reachable at ${APP_URL} (${e.message}).\x1b[0m`,
|
||||||
|
);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
fs.mkdirSync(SHOTS_DIR, { recursive: true });
|
||||||
|
const executablePath =
|
||||||
|
process.env.PUPPETEER_EXECUTABLE_PATH ||
|
||||||
|
(await puppeteer.executablePath().catch(() => null)) ||
|
||||||
|
undefined;
|
||||||
|
const browser = await puppeteer.launch({
|
||||||
|
headless: HEADFUL ? false : 'new',
|
||||||
|
protocolTimeout: 180000,
|
||||||
|
...(executablePath && fs.existsSync(executablePath)
|
||||||
|
? { executablePath }
|
||||||
|
: {}),
|
||||||
|
args: [
|
||||||
|
'--use-gl=angle',
|
||||||
|
'--use-angle=swiftshader',
|
||||||
|
'--disable-dev-shm-usage',
|
||||||
|
'--disable-background-timer-throttling',
|
||||||
|
'--disable-renderer-backgrounding',
|
||||||
|
'--window-size=1440,900',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
let exitCode = 0;
|
||||||
|
const errors = [];
|
||||||
|
try {
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.setViewport({ width: 1440, height: 900 });
|
||||||
|
page.on('pageerror', (e) => errors.push(e.message));
|
||||||
|
const { lon, lat } = CENTER;
|
||||||
|
await page.evaluateOnNewDocument(
|
||||||
|
(fixtures) => {
|
||||||
|
window.__squawkQaFixtures = fixtures;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
civil: [
|
||||||
|
civilRow('4847a1', 'KLM7600 ', lon - 0.6, lat + 0.25, '7600'),
|
||||||
|
civilRow('4847a2', 'KLM1234 ', lon - 0.6, lat - 0.25, '2000'),
|
||||||
|
],
|
||||||
|
mil: [
|
||||||
|
milRow('ae7700', 'RCH7700', lon + 0.6, lat + 0.25, '1200', 'general'),
|
||||||
|
milRow('ae0f01', 'RCH0F01', lon + 0.6, lat - 0.25, '1234', 'minfuel'),
|
||||||
|
milRow('ae0001', 'RCH0001', lon, lat - 0.5, '4520', 'none'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// Stub in-page rather than with request interception: an intercepted page
|
||||||
|
// stalls Cesium's workers (see qa-gnss.mjs).
|
||||||
|
await page.evaluateOnNewDocument(installStubs);
|
||||||
|
await page.goto(`${APP_URL}/?welcome=0`, {
|
||||||
|
waitUntil: 'domcontentloaded',
|
||||||
|
timeout: TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager,
|
||||||
|
{ timeout: TIMEOUT_MS },
|
||||||
|
);
|
||||||
|
await sleep(5000);
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await page.evaluate(
|
||||||
|
(lo, la) => {
|
||||||
|
const gev = window.__godsEyeView;
|
||||||
|
const d2r = Math.PI / 180;
|
||||||
|
try {
|
||||||
|
gev.viewer.camera.cancelFlight();
|
||||||
|
} catch {
|
||||||
|
/* no flight active */
|
||||||
|
}
|
||||||
|
gev.viewer.camera.setView({
|
||||||
|
destination: gev.viewer.scene.globe.ellipsoid.cartographicToCartesian(
|
||||||
|
{
|
||||||
|
longitude: lo * d2r,
|
||||||
|
latitude: la * d2r,
|
||||||
|
height: 260000,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||||
|
});
|
||||||
|
gev.viewer.scene.requestRender?.();
|
||||||
|
},
|
||||||
|
lon,
|
||||||
|
lat,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── (i) TINT ──────────────────────────────────────────────────────────
|
||||||
|
console.log('(i) TINT — emergency codes through the real layers...');
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const dm = window.__godsEyeView.dataManager;
|
||||||
|
await dm.setEnabled('flights', true, { origin: 'user' });
|
||||||
|
await dm.setEnabled('military', true, { origin: 'user' });
|
||||||
|
});
|
||||||
|
const ids = ['4847a1', '4847a2', 'ae7700', 'ae0f01', 'ae0001'];
|
||||||
|
const probe = `(${billboardColors})(${JSON.stringify(ids)})`;
|
||||||
|
await page
|
||||||
|
.waitForFunction(`Object.keys(${probe}).length === ${ids.length}`, {
|
||||||
|
timeout: TIMEOUT_MS,
|
||||||
|
polling: 500,
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
await sleep(1500);
|
||||||
|
const colors = await page.evaluate(probe);
|
||||||
|
const show = (id) => JSON.stringify(colors[id] ?? null);
|
||||||
|
record(
|
||||||
|
'TINT: civil squawk 7600 is red',
|
||||||
|
isRed(colors['4847a1']),
|
||||||
|
show('4847a1'),
|
||||||
|
);
|
||||||
|
record(
|
||||||
|
'TINT: normal civil flight stays white',
|
||||||
|
isWhite(colors['4847a2']),
|
||||||
|
show('4847a2'),
|
||||||
|
);
|
||||||
|
record(
|
||||||
|
'TINT: military ADS-B general emergency is red',
|
||||||
|
isRed(colors.ae7700),
|
||||||
|
show('ae7700'),
|
||||||
|
);
|
||||||
|
record(
|
||||||
|
'TINT: military minimum-fuel priority stays amber',
|
||||||
|
isAmber(colors.ae0f01),
|
||||||
|
show('ae0f01'),
|
||||||
|
);
|
||||||
|
record(
|
||||||
|
'TINT: normal military flight stays amber',
|
||||||
|
isAmber(colors.ae0001),
|
||||||
|
show('ae0001'),
|
||||||
|
);
|
||||||
|
await shoot(page, 'globe-tint');
|
||||||
|
|
||||||
|
// Existing untracked billboards and models must repaint in both directions.
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
window.__squawkQaPhase = 'cleared';
|
||||||
|
await window.__godsEyeView.dataManager.refreshLayer('military');
|
||||||
|
});
|
||||||
|
record(
|
||||||
|
'POLL: existing military billboard clears to amber',
|
||||||
|
isAmber((await page.evaluate(probe)).ae7700),
|
||||||
|
);
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
window.__squawkQaPhase = 'emergency';
|
||||||
|
await window.__godsEyeView.dataManager.refreshLayer('military');
|
||||||
|
});
|
||||||
|
record(
|
||||||
|
'POLL: existing military billboard turns red again',
|
||||||
|
isRed((await page.evaluate(probe)).ae7700),
|
||||||
|
);
|
||||||
|
const modelProbe = () => {
|
||||||
|
const found = {};
|
||||||
|
const visit = (items) => {
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
const item = items.get(i);
|
||||||
|
if (item.ready && item.color && typeof item.id === 'string')
|
||||||
|
found[item.id] = {
|
||||||
|
r: Math.round(item.color.red * 255),
|
||||||
|
g: Math.round(item.color.green * 255),
|
||||||
|
b: Math.round(item.color.blue * 255),
|
||||||
|
};
|
||||||
|
else if (item.get) visit(item);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
visit(window.__godsEyeView.viewer.scene.primitives);
|
||||||
|
return found;
|
||||||
|
};
|
||||||
|
await page.waitForFunction(
|
||||||
|
`(${modelProbe})()['4847a1'] && (${modelProbe})().ae7700`,
|
||||||
|
{ timeout: TIMEOUT_MS, polling: 250 },
|
||||||
|
);
|
||||||
|
const beforeModels = await page.evaluate(modelProbe);
|
||||||
|
record(
|
||||||
|
'MODELS: civil and military emergency models are red',
|
||||||
|
isRed(beforeModels['4847a1']) && isRed(beforeModels.ae7700),
|
||||||
|
JSON.stringify(beforeModels),
|
||||||
|
);
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
window.__squawkQaFixtures.civil[0][14] = '2000';
|
||||||
|
window.__squawkQaFixtures.civil[1][14] = '7600';
|
||||||
|
window.__squawkQaPhase = 'cleared';
|
||||||
|
const dm = window.__godsEyeView.dataManager;
|
||||||
|
await dm.refreshLayer('flights');
|
||||||
|
await dm.refreshLayer('military');
|
||||||
|
});
|
||||||
|
await page.waitForFunction(
|
||||||
|
`(${modelProbe})()['4847a1']?.g > 220 && (${modelProbe})()['4847a2']?.g < 110 && (${modelProbe})().ae7700?.g > 110`,
|
||||||
|
{ timeout: TIMEOUT_MS, polling: 250 },
|
||||||
|
);
|
||||||
|
const afterModels = await page.evaluate(modelProbe);
|
||||||
|
record(
|
||||||
|
'MODELS: existing civil models start and clear, military clears',
|
||||||
|
isWhite(afterModels['4847a1']) &&
|
||||||
|
isRed(afterModels['4847a2']) &&
|
||||||
|
isAmber(afterModels.ae7700),
|
||||||
|
JSON.stringify(afterModels),
|
||||||
|
);
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
window.__squawkQaFixtures.civil[0][14] = '7600';
|
||||||
|
window.__squawkQaFixtures.civil[1][14] = '2000';
|
||||||
|
window.__squawkQaFixtures.mil[0].squawk = '7700';
|
||||||
|
window.__squawkQaPhase = 'emergency';
|
||||||
|
const dm = window.__godsEyeView.dataManager;
|
||||||
|
await dm.refreshLayer('flights');
|
||||||
|
await dm.refreshLayer('military');
|
||||||
|
});
|
||||||
|
// ── (ii) HUD ──────────────────────────────────────────────────────────
|
||||||
|
console.log('\n(ii) HUD — track the emergency and enter Cockpit...');
|
||||||
|
const tracked = await page.evaluate(() =>
|
||||||
|
Boolean(
|
||||||
|
window.__godsEyeView.dataManager.layers
|
||||||
|
.get('military')
|
||||||
|
.module.trackById('ae7700', { origin: 'user' }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
record(
|
||||||
|
'HUD: emergency aircraft can be tracked',
|
||||||
|
tracked,
|
||||||
|
`tracked=${tracked}`,
|
||||||
|
);
|
||||||
|
// Cockpit entry is gated on the Contacts (flights) context mode.
|
||||||
|
await page.evaluate(() =>
|
||||||
|
window.__godsEyeView.styleManager._contextControls._selectContextMode(
|
||||||
|
'flights',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await page
|
||||||
|
.waitForFunction(
|
||||||
|
() => {
|
||||||
|
const entry = document.getElementById('cockpit-entry');
|
||||||
|
return entry && !entry.hidden && !entry.disabled;
|
||||||
|
},
|
||||||
|
{ timeout: 20000 },
|
||||||
|
)
|
||||||
|
.catch(() => {});
|
||||||
|
await page
|
||||||
|
.$eval('#cockpit-entry', (entry) => entry.click())
|
||||||
|
.catch(() => {});
|
||||||
|
const hudReady = await page
|
||||||
|
.waitForFunction(
|
||||||
|
() =>
|
||||||
|
document.body.classList.contains('cockpit-mode') &&
|
||||||
|
/SQUAWK 7700/.test(
|
||||||
|
document.getElementById('cockpit-aircraft-meta')?.textContent || '',
|
||||||
|
),
|
||||||
|
{ timeout: 30000 },
|
||||||
|
)
|
||||||
|
.then(
|
||||||
|
() => true,
|
||||||
|
() => false,
|
||||||
|
);
|
||||||
|
const hud = await page.evaluate(() => {
|
||||||
|
const meta = document.getElementById('cockpit-aircraft-meta');
|
||||||
|
return {
|
||||||
|
cockpit: document.body.classList.contains('cockpit-mode'),
|
||||||
|
text: meta?.textContent || '',
|
||||||
|
emergencyClass: meta?.classList.contains('emergency') || false,
|
||||||
|
color: meta ? getComputedStyle(meta).color : null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
record(
|
||||||
|
'HUD: meta line names the broadcast code',
|
||||||
|
hudReady && hud.text.includes('SQUAWK 7700 · GENERAL EMERGENCY'),
|
||||||
|
JSON.stringify(hud),
|
||||||
|
);
|
||||||
|
record(
|
||||||
|
'HUD: meta line is styled as an emergency',
|
||||||
|
hud.emergencyClass && /255,\s*68,\s*68/.test(hud.color || ''),
|
||||||
|
`class=${hud.emergencyClass} color=${hud.color}`,
|
||||||
|
);
|
||||||
|
const trackedBroadcast = await page.evaluate(
|
||||||
|
() =>
|
||||||
|
window.__godsEyeView.dataManager.layers
|
||||||
|
.get('military')
|
||||||
|
.module.getTrackedInfo().emergencyBroadcast,
|
||||||
|
);
|
||||||
|
record(
|
||||||
|
'TRACKED: voice-facing status quotes the broadcast',
|
||||||
|
trackedBroadcast === 'broadcasting squawk 7700 (general emergency)',
|
||||||
|
trackedBroadcast,
|
||||||
|
);
|
||||||
|
await shoot(page, 'cockpit-hud');
|
||||||
|
|
||||||
|
// ── (iii) CLEAR ───────────────────────────────────────────────────────
|
||||||
|
console.log('\n(iii) CLEAR — the next poll drops the code...');
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
window.__squawkQaPhase = 'cleared';
|
||||||
|
await window.__godsEyeView.dataManager.refreshLayer('military');
|
||||||
|
});
|
||||||
|
const cleared = await page
|
||||||
|
.waitForFunction(
|
||||||
|
() =>
|
||||||
|
!document
|
||||||
|
.getElementById('cockpit-aircraft-meta')
|
||||||
|
?.classList.contains('emergency'),
|
||||||
|
{ timeout: 20000 },
|
||||||
|
)
|
||||||
|
.then(
|
||||||
|
() => true,
|
||||||
|
() => false,
|
||||||
|
);
|
||||||
|
const after = await page.evaluate(
|
||||||
|
() => document.getElementById('cockpit-aircraft-meta')?.textContent || '',
|
||||||
|
);
|
||||||
|
record(
|
||||||
|
'CLEAR: HUD returns to its normal wording',
|
||||||
|
cleared && !after.includes('SQUAWK') && after.includes('COURSE ALIGNED'),
|
||||||
|
JSON.stringify(after),
|
||||||
|
);
|
||||||
|
await shoot(page, 'cockpit-cleared');
|
||||||
|
|
||||||
|
// ── (iv) RELEASE ──────────────────────────────────────────────────────
|
||||||
|
console.log('\n(iv) RELEASE — the code returns, then tracking stops...');
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
window.__squawkQaPhase = 'emergency';
|
||||||
|
const dm = window.__godsEyeView.dataManager;
|
||||||
|
await dm.refreshLayer('military');
|
||||||
|
dm.layers.get('military').module.stopTracking({ origin: 'user' });
|
||||||
|
});
|
||||||
|
const releaseProbe = `(${billboardColors})(["ae7700"])`;
|
||||||
|
const released = await page
|
||||||
|
.waitForFunction(`Boolean((${releaseProbe}).ae7700)`, {
|
||||||
|
timeout: 20000,
|
||||||
|
polling: 250,
|
||||||
|
})
|
||||||
|
.then(
|
||||||
|
() => true,
|
||||||
|
() => false,
|
||||||
|
);
|
||||||
|
const releasedColor = (await page.evaluate(releaseProbe)).ae7700;
|
||||||
|
record(
|
||||||
|
'RELEASE: untracked emergency billboard is red',
|
||||||
|
released && isRed(releasedColor),
|
||||||
|
JSON.stringify(releasedColor ?? null),
|
||||||
|
);
|
||||||
|
record(
|
||||||
|
'no uncaught browser errors',
|
||||||
|
errors.length === 0,
|
||||||
|
errors.slice(0, 3).join(' | '),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('\x1b[31mHarness error:\x1b[0m', err);
|
||||||
|
exitCode = 3;
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
const pass = results.filter((r) => r.ok).length;
|
||||||
|
const fail = results.length - pass;
|
||||||
|
console.log('\n' + '─'.repeat(60));
|
||||||
|
console.log(` RESULT: ${pass} passed, ${fail} failed`);
|
||||||
|
console.log(` Shots : ${SHOTS_DIR}/emergency-*.png`);
|
||||||
|
console.log('─'.repeat(60) + '\n');
|
||||||
|
process.exit(exitCode || (fail > 0 ? 1 : 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(3);
|
||||||
|
});
|
||||||
@@ -230,16 +230,20 @@ async function main() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(APP_URL);
|
const res = await fetch(APP_URL);
|
||||||
|
// Only the status matters; an unread body can hold the socket open.
|
||||||
|
await res.body?.cancel();
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`\x1b[31mDev server not reachable at ${APP_URL} (${e.message}).\x1b[0m`);
|
console.error(`\x1b[31mDev server not reachable at ${APP_URL} (${e.message}).\x1b[0m`);
|
||||||
process.exit(2);
|
process.exitCode = 2;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const status = await fetch(`${APP_URL}/api/firms/status`).then((r) => r.json()).catch(() => null);
|
const status = await fetch(`${APP_URL}/api/firms/status`).then((r) => r.json()).catch(() => null);
|
||||||
if (!FIXTURES && !status?.hasKey) {
|
if (!FIXTURES && !status?.hasKey) {
|
||||||
console.error('\x1b[31mServer has no FIRMS key — run against the keyed dev server (:4420).\x1b[0m');
|
console.error('\x1b[31mServer has no FIRMS key — run against the keyed dev server (:4420).\x1b[0m');
|
||||||
process.exit(2);
|
process.exitCode = 2;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
fs.mkdirSync(SHOTS_DIR, { recursive: true });
|
fs.mkdirSync(SHOTS_DIR, { recursive: true });
|
||||||
|
|||||||
@@ -30,12 +30,16 @@ const TESTS = ['src/firstRunExperience.test.mjs', 'src/standalone/startupChrome.
|
|||||||
const FILES = {
|
const FILES = {
|
||||||
keyboard: path.join(ROOT, 'src', 'ui', 'surfaceKeyboard.js'),
|
keyboard: path.join(ROOT, 'src', 'ui', 'surfaceKeyboard.js'),
|
||||||
module: path.join(ROOT, 'src', 'firstRunExperience.js'),
|
module: path.join(ROOT, 'src', 'firstRunExperience.js'),
|
||||||
html: path.join(ROOT, 'index.html'),
|
html: path.join(ROOT, 'src', 'ui', 'templates', 'welcome.html'),
|
||||||
css: path.join(ROOT, 'style.css'),
|
css: path.join(ROOT, 'src', 'ui', 'styles', 'first-run.css'),
|
||||||
voiceInstructions: path.join(ROOT, 'server/providers/openai/instructions.js'),
|
voiceInstructions: path.join(ROOT, 'server/providers/openai/instructions.js'),
|
||||||
voiceTools: path.join(ROOT, 'server/providers/openai/tools.js'),
|
voiceDescriptions: path.join(
|
||||||
main: path.join(ROOT, 'src', 'standalone', 'startupChrome.js'),
|
ROOT,
|
||||||
ui: path.join(ROOT, 'src', 'ui.js'),
|
'server/providers/openai/toolDescriptions.js',
|
||||||
|
),
|
||||||
|
main: path.join(ROOT, 'src', 'app', 'startupChrome.js'),
|
||||||
|
ui: path.join(ROOT, 'src', 'ui', 'panelPositionControls.js'),
|
||||||
|
radio: path.join(ROOT, 'src', 'ui', 'radioBindings.js'),
|
||||||
docs: path.join(ROOT, 'docs', 'CURRENT-STATE.md'),
|
docs: path.join(ROOT, 'docs', 'CURRENT-STATE.md'),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -63,14 +67,14 @@ const MUTATIONS = [
|
|||||||
{
|
{
|
||||||
defect: 'the session flag is never read, so it re-nags on every reload',
|
defect: 'the session flag is never read, so it re-nags on every reload',
|
||||||
file: 'module',
|
file: 'module',
|
||||||
from: "if (readStored('session', sessionStorageRef, FIRST_RUN_SESSION_KEY) === 'dismissed') return false;",
|
from: " if (\n readStored('session', sessionStorageRef, FIRST_RUN_SESSION_KEY) ===\n 'dismissed'\n )\n return false;",
|
||||||
to: '// session check removed',
|
to: ' // session check removed',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
defect: 'the durable flag is never read, so the checkbox does nothing',
|
defect: 'the durable flag is never read, so the checkbox does nothing',
|
||||||
file: 'module',
|
file: 'module',
|
||||||
from: "if (readStored('local', storage, FIRST_RUN_STORAGE_KEY) === 'suppressed') return false;",
|
from: " if (readStored('local', storage, FIRST_RUN_STORAGE_KEY) === 'suppressed')\n return false;",
|
||||||
to: '// durable check removed',
|
to: ' // durable check removed',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
defect: '?welcome=1 no longer outranks suppression, so demos cannot replay it',
|
defect: '?welcome=1 no longer outranks suppression, so demos cannot replay it',
|
||||||
@@ -105,7 +109,7 @@ const MUTATIONS = [
|
|||||||
{
|
{
|
||||||
defect: 'the guarded resolver stops catching, so a hostile getter escapes',
|
defect: 'the guarded resolver stops catching, so a hostile getter escapes',
|
||||||
file: 'module',
|
file: 'module',
|
||||||
from: " try {\n return kind === 'session' ? globalThis.sessionStorage : globalThis.localStorage;\n } catch {\n // Privacy-restricted storage should not make first launch silent.\n return null;\n }",
|
from: " try {\n return kind === 'session'\n ? globalThis.sessionStorage\n : globalThis.localStorage;\n } catch {\n // Privacy-restricted storage should not make first launch silent.\n return null;\n }",
|
||||||
to: " return kind === 'session' ? globalThis.sessionStorage : globalThis.localStorage;",
|
to: " return kind === 'session' ? globalThis.sessionStorage : globalThis.localStorage;",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -131,8 +135,8 @@ const MUTATIONS = [
|
|||||||
{
|
{
|
||||||
defect: 'nothing watches for another surface taking the screen',
|
defect: 'nothing watches for another surface taking the screen',
|
||||||
file: 'module',
|
file: 'module',
|
||||||
from: "surfaceObserver?.observe(documentRef.body, { attributes: true, attributeFilter: ['class'] });",
|
from: " surfaceObserver?.observe(documentRef.body, {\n attributes: true,\n attributeFilter: ['class'],\n });",
|
||||||
to: '/* observer never attached */',
|
to: ' /* observer never attached */',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
defect: 'cockpit drops off the exclusive list and can stack with the launcher',
|
defect: 'cockpit drops off the exclusive list and can stack with the launcher',
|
||||||
@@ -155,8 +159,8 @@ const MUTATIONS = [
|
|||||||
{
|
{
|
||||||
defect: 'isTopmost stops hit-testing, so an unclassed overlay buries the card again',
|
defect: 'isTopmost stops hit-testing, so an unclassed overlay buries the card again',
|
||||||
file: 'module',
|
file: 'module',
|
||||||
from: ' && root.getClientRects().length > 0\n && !coveredByOverlay();',
|
from: ' root.getClientRects().length > 0 &&\n !coveredByOverlay();',
|
||||||
to: ' && root.getClientRects().length > 0;',
|
to: ' root.getClientRects().length > 0;',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
defect: 'an inconclusive hit test reads as COVERED, so ESC quietly stops working',
|
defect: 'an inconclusive hit test reads as COVERED, so ESC quietly stops working',
|
||||||
@@ -174,9 +178,9 @@ const MUTATIONS = [
|
|||||||
// Anchored past the call so it cannot land on the cockpit disclosure's own
|
// Anchored past the call so it cannot land on the cockpit disclosure's own
|
||||||
// stopImmediatePropagation() a few lines below and prove nothing about this.
|
// stopImmediatePropagation() a few lines below and prove nothing about this.
|
||||||
defect: 'the radio disclosure returns to stopPropagation, so one ESC does two things',
|
defect: 'the radio disclosure returns to stopPropagation, so one ESC does two things',
|
||||||
file: 'ui',
|
file: 'radio',
|
||||||
from: ' event.stopImmediatePropagation();\n const escapedFromDisclosure = event.target === this._contextRadioToggleBtn',
|
from: ' event.stopImmediatePropagation();\n const escapedFromDisclosure =\n event.target === this._contextRadioToggleBtn ||',
|
||||||
to: ' event.stopPropagation();\n const escapedFromDisclosure = event.target === this._contextRadioToggleBtn',
|
to: ' event.stopPropagation();\n const escapedFromDisclosure =\n event.target === this._contextRadioToggleBtn ||',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
defect: 'the "no timer" decision is deleted, so the next editor re-litigates it blind',
|
defect: 'the "no timer" decision is deleted, so the next editor re-litigates it blind',
|
||||||
@@ -234,7 +238,7 @@ const MUTATIONS = [
|
|||||||
{
|
{
|
||||||
defect: 'the removed INFRASTRUCTURE tile comes back as a one-click globe dump',
|
defect: 'the removed INFRASTRUCTURE tile comes back as a one-click globe dump',
|
||||||
file: 'module',
|
file: 'module',
|
||||||
from: " environmental: Object.freeze({",
|
from: ' environmental: Object.freeze({',
|
||||||
to: " infrastructure: Object.freeze({\n kind: 'globe',\n layerIds: Object.freeze(['local-datacenters', 'local-dams', 'telegeography-submarine-cables']),\n busyText: 'Mapping global infrastructure…',\n }),\n environmental: Object.freeze({",
|
to: " infrastructure: Object.freeze({\n kind: 'globe',\n layerIds: Object.freeze(['local-datacenters', 'local-dams', 'telegeography-submarine-cables']),\n busyText: 'Mapping global infrastructure…',\n }),\n environmental: Object.freeze({",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -326,8 +330,8 @@ const MUTATIONS = [
|
|||||||
{
|
{
|
||||||
defect: 'the Context panel is left collapsed, hiding the mode that just started',
|
defect: 'the Context panel is left collapsed, hiding the mode that just started',
|
||||||
file: 'module',
|
file: 'module',
|
||||||
from: "styleManager.setPanelCollapsed?.('global-context-panel', false, { explicit: true });",
|
from: " styleManager.setPanelCollapsed?.('global-context-panel', false, {\n explicit: true,\n });",
|
||||||
to: '// panel reveal removed',
|
to: ' // panel reveal removed',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
defect: 'the decision table is deleted, so the next editor re-litigates it blind',
|
defect: 'the decision table is deleted, so the next editor re-litigates it blind',
|
||||||
@@ -340,8 +344,8 @@ const MUTATIONS = [
|
|||||||
{
|
{
|
||||||
defect: 'disabling the focused tile drops the keyboard to <body> mid-flight',
|
defect: 'disabling the focused tile drops the keyboard to <body> mid-flight',
|
||||||
file: 'module',
|
file: 'module',
|
||||||
from: "for (const button of buttons) button.setAttribute('aria-disabled', String(next));",
|
from: " for (const button of buttons)\n button.setAttribute('aria-disabled', String(next));",
|
||||||
to: 'for (const button of buttons) button.disabled = next;',
|
to: ' for (const button of buttons) button.disabled = next;',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
defect: 'Tab escapes into an app the visitor has not seen yet',
|
defect: 'Tab escapes into an app the visitor has not seen yet',
|
||||||
@@ -370,8 +374,8 @@ const MUTATIONS = [
|
|||||||
{
|
{
|
||||||
defect: 'the card stays clickable through its fade-out (double-launch)',
|
defect: 'the card stays clickable through its fade-out (double-launch)',
|
||||||
file: 'css',
|
file: 'css',
|
||||||
from: ' transition: opacity 220ms ease, transform 280ms cubic-bezier(0.2, 0.8, 0.2, 1);\n /* Inert until revealed AND again while fading out, so a click during the\n dismiss animation cannot launch a second mission behind the fade. */\n pointer-events: none;',
|
from: ' transition:\n opacity 220ms ease,\n transform 280ms cubic-bezier(0.2, 0.8, 0.2, 1);\n /* Inert until revealed AND again while fading out, so a click during the\n dismiss animation cannot launch a second mission behind the fade. */\n pointer-events: none;',
|
||||||
to: ' transition: opacity 220ms ease, transform 280ms cubic-bezier(0.2, 0.8, 0.2, 1);',
|
to: ' transition:\n opacity 220ms ease,\n transform 280ms cubic-bezier(0.2, 0.8, 0.2, 1);',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
defect: 'the launcher survives Clean-UI and recording mode',
|
defect: 'the launcher survives Clean-UI and recording mode',
|
||||||
@@ -403,8 +407,8 @@ const MUTATIONS = [
|
|||||||
// those instead and prove nothing about this feature.
|
// those instead and prove nothing about this feature.
|
||||||
defect: "the launcher's reduced-motion opt-out is dropped",
|
defect: "the launcher's reduced-motion opt-out is dropped",
|
||||||
file: 'css',
|
file: 'css',
|
||||||
from: ' #first-run-launcher,\n .first-run-choices button,\n .first-run-arrow { transition: none; }\n /* Scrolling a tile into view must not animate either. */\n .first-run-choices { scroll-behavior: auto; }',
|
from: ' #first-run-launcher,\n .first-run-choices button,\n .first-run-arrow {\n transition: none;\n }\n /* Scrolling a tile into view must not animate either. */\n .first-run-choices {\n scroll-behavior: auto;\n }',
|
||||||
to: ' .first-run-nothing { transition: none; }',
|
to: ' .first-run-nothing {\n transition: none;\n }',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Markup and startup ordering ───────────────────────────────────────────
|
// ── Markup and startup ordering ───────────────────────────────────────────
|
||||||
@@ -442,21 +446,21 @@ const MUTATIONS = [
|
|||||||
defect: 'the launcher is revealed before the loading cover yields',
|
defect: 'the launcher is revealed before the loading cover yields',
|
||||||
file: 'main',
|
file: 'main',
|
||||||
from: " loadingScreen.classList.add('hidden');",
|
from: " loadingScreen.classList.add('hidden');",
|
||||||
to: ' initFirstRunExperience({ styleManager, dataManager });\n loadingScreen.classList.add(\'hidden\');',
|
to: " revealFirstRun();\n loadingScreen.classList.add('hidden');",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
defect: 'the globe missions lose their DataManager and cannot enable layers',
|
defect: 'the globe missions lose their DataManager and cannot enable layers',
|
||||||
file: 'main',
|
file: 'main',
|
||||||
from: 'initFirstRunExperience({ styleManager, dataManager });',
|
from: 'firstRun = initializeWelcome?.({ styleManager, dataManager });',
|
||||||
to: 'initFirstRunExperience({ styleManager });',
|
to: 'firstRun = initializeWelcome?.({ styleManager });',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Voice: schema must not drift ──────────────────────────────────────────
|
// ── Voice: schema must not drift ──────────────────────────────────────────
|
||||||
{
|
{
|
||||||
defect: 'the voice TOOL SCHEMA is edited (a Realtime prompt-cache bust)',
|
defect: 'the voice TOOL SCHEMA is edited (a Realtime prompt-cache bust)',
|
||||||
file: 'voiceTools',
|
file: 'voiceDescriptions',
|
||||||
from: " 'earthquakes',\n 'satellites',",
|
from: "'Counts, lists, superlatives and attribute filters over records loaded by ENABLED layers",
|
||||||
to: " 'earthquakes',\n 'infrastructure-mode',\n 'satellites',",
|
to: "'Counts, lists, superlatives, and attribute filters over records loaded by ENABLED layers",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
defect: 'the instruction mapping is dropped, so voice cannot reach the modes',
|
defect: 'the instruction mapping is dropped, so voice cannot reach the modes',
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ const TESTS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const FLIGHTS = 'src/layers/flights/motion.js';
|
const FLIGHTS = 'src/layers/flights/motion.js';
|
||||||
const FLOOR = 'src/data/groundFloor.js';
|
const FLOOR = 'src/services/groundFloor.js';
|
||||||
const ALT = 'src/data/renderAltitude.js';
|
const ALT = 'src/data/renderAltitude.js';
|
||||||
|
|
||||||
/** @type {Array<{defect: string, edits: Array<{file: string, from: string, to: string}>}>} */
|
/** @type {Array<{defect: string, edits: Array<{file: string, from: string, to: string}>}>} */
|
||||||
@@ -133,8 +133,8 @@ const MUTATIONS = [
|
|||||||
edits: [
|
edits: [
|
||||||
{
|
{
|
||||||
file: FLOOR,
|
file: FLOOR,
|
||||||
from: ' if (lowest == null || h < lowest) lowest = h;',
|
from: ' if (lowest == null || h < lowest) lowest = h;',
|
||||||
to: ' if (lowest == null || h > lowest) lowest = h;',
|
to: ' if (lowest == null || h > lowest) lowest = h;',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -143,8 +143,8 @@ const MUTATIONS = [
|
|||||||
edits: [
|
edits: [
|
||||||
{
|
{
|
||||||
file: FLOOR,
|
file: FLOOR,
|
||||||
from: ' return resolved >= NEIGHBOR_FLOOR_MIN_SAMPLES ? lowest : null;',
|
from: ' return resolved >= NEIGHBOR_FLOOR_MIN_SAMPLES ? lowest : null;',
|
||||||
to: ' return resolved >= 1 ? lowest : null;',
|
to: ' return resolved >= 1 ? lowest : null;',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -35,17 +35,17 @@ const MUTATIONS = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
defect: 'heading lerps in Cartesian space and cannot cross a U-turn',
|
defect: 'heading lerps in Cartesian space and cannot cross a U-turn',
|
||||||
from: ' const turned = Cesium.Matrix3.multiplyByVector(rotation, state.headingDir, _frameDir);',
|
from: ' const turned = Cesium.Matrix3.multiplyByVector(\n rotation,\n state.headingDir,\n _frameDir,\n );',
|
||||||
to: ' const turned = Cesium.Cartesian3.lerp(state.headingDir, gaze, k, _frameDir);',
|
to: ' const turned = Cesium.Cartesian3.lerp(state.headingDir, gaze, k, _frameDir);',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
defect: 'the exact-180° bank direction becomes a floating-point coin toss',
|
defect: 'the exact-180° bank direction becomes a floating-point coin toss',
|
||||||
from: ' if (cosTurn <= -1 + ANTIPODAL_EPS && Math.abs(sinLeft) < ANTIPODAL_EPS) return Math.PI;',
|
from: ' if (cosTurn <= -1 + ANTIPODAL_EPS && Math.abs(sinLeft) < ANTIPODAL_EPS)\n return Math.PI;',
|
||||||
to: ' // branch removed',
|
to: ' // branch removed',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
defect: 'a cold cell is read as sea level (the 1.3 km underground bug)',
|
defect: 'a cold cell is read as sea level (the 1.3 km underground bug)',
|
||||||
from: ' state.floorM = Number.isFinite(state.floorM) ? state.floorM : state.coldSeedFloorM;',
|
from: ' state.floorM = Number.isFinite(state.floorM)\n ? state.floorM\n : state.coldSeedFloorM;',
|
||||||
to: ' state.floorM = carto.height;',
|
to: ' state.floorM = carto.height;',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -55,12 +55,12 @@ const MUTATIONS = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
defect: 'the dolly races the fire-and-forget warm instead of arming for it',
|
defect: 'the dolly races the fire-and-forget warm instead of arming for it',
|
||||||
from: ' if (canAnswer && !state.floorKnown && !state.departed && state.armS < ROUTE_ARM_S) {',
|
from: ' if (\n canAnswer &&\n !state.floorKnown &&\n !state.departed &&\n state.armS < ROUTE_ARM_S\n ) {',
|
||||||
to: ' if (false) {',
|
to: ' if (false) {',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
defect: 'no mesh probe, so a permanently cold DEM is flown blind',
|
defect: 'no mesh probe, so a permanently cold DEM is flown blind',
|
||||||
from: ' if (cold.length && !state.meshProbeSpent && typeof state.probeFn === \'function\') {',
|
from: ' if (\n cold.length &&\n !state.meshProbeSpent &&\n typeof state.probeFn === \'function\'\n ) {',
|
||||||
to: ' if (false) {',
|
to: ' if (false) {',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -90,8 +90,8 @@ const MUTATIONS = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
defect: 'the mesh probe fires every arming frame instead of once per route',
|
defect: 'the mesh probe fires every arming frame instead of once per route',
|
||||||
from: ' if (cold.length && !state.meshProbeSpent && typeof state.probeFn === \'function\') {\n state.meshProbeSpent = true;',
|
from: ' cold.length &&\n !state.meshProbeSpent &&\n typeof state.probeFn === \'function\'\n ) {\n state.meshProbeSpent = true;',
|
||||||
to: ' if (cold.length && typeof state.probeFn === \'function\') {\n state.meshProbeSpent = true;',
|
to: ' cold.length &&\n typeof state.probeFn === \'function\'\n ) {\n state.meshProbeSpent = true;',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
defect: 'one warm cell resolves a corridor whose other cells are unknown',
|
defect: 'one warm cell resolves a corridor whose other cells are unknown',
|
||||||
@@ -105,13 +105,13 @@ const MUTATIONS = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
defect: 'the hard clearance clamp reads the SMOOTHED floor, not the raw sample',
|
defect: 'the hard clearance clamp reads the SMOOTHED floor, not the raw sample',
|
||||||
from: ' (Number.isFinite(sampledFloor) ? sampledFloor : state.floorM) + ROUTE_MIN_CLEARANCE_M,',
|
from: ' (Number.isFinite(sampledFloor) ? sampledFloor : state.floorM) +\n ROUTE_MIN_CLEARANCE_M,',
|
||||||
to: ' state.floorM + ROUTE_MIN_CLEARANCE_M,',
|
to: ' state.floorM + ROUTE_MIN_CLEARANCE_M,',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
defect: 'levelling also RE-FRAMES the camera (destination passed with the HPR)',
|
defect: 'levelling also RE-FRAMES the camera (destination passed with the HPR)',
|
||||||
from: ' cam.setView({ orientation: { heading: cam.heading, pitch: cam.pitch, roll: 0 } });',
|
from: ' cam.setView({\n orientation: { heading: cam.heading, pitch: cam.pitch, roll: 0 },\n });',
|
||||||
to: ' cam.setView({ destination: Cesium.Cartesian3.fromDegrees(0, 0, 5000), orientation: { heading: cam.heading, pitch: cam.pitch, roll: 0 } });',
|
to: ' cam.setView({\n destination: Cesium.Cartesian3.fromDegrees(0, 0, 5000),\n orientation: { heading: cam.heading, pitch: cam.pitch, roll: 0 },\n });',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
defect: 'the OS reduced-motion preference is never consulted',
|
defect: 'the OS reduced-motion preference is never consulted',
|
||||||
|
|||||||
@@ -0,0 +1,336 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Keyless station-name regression against the real globe and bike-share tool.
|
||||||
|
* Run with a dev or preview server: node scripts/qa-gbfs-names.mjs --url http://localhost:4173
|
||||||
|
* GBFS documents are synthetic; no operator feed or provider key is required.
|
||||||
|
*/
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import puppeteer from 'puppeteer';
|
||||||
|
|
||||||
|
const urlIndex = process.argv.indexOf('--url');
|
||||||
|
const appUrl =
|
||||||
|
urlIndex < 0 ? 'http://localhost:4173' : process.argv[urlIndex + 1];
|
||||||
|
const shots = path.resolve('qa-shots/gbfs-names');
|
||||||
|
fs.mkdirSync(shots, { recursive: true });
|
||||||
|
const information = {
|
||||||
|
version: '3.0',
|
||||||
|
data: {
|
||||||
|
stations: [
|
||||||
|
{
|
||||||
|
station_id: 'english',
|
||||||
|
name: [
|
||||||
|
{ language: 'hu', text: 'Magyar tér' },
|
||||||
|
{ language: 'en', text: 'QA English Square' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
station_id: 'short',
|
||||||
|
name: [null, { language: 'en', text: ' ' }],
|
||||||
|
short_name: [{ language: 'en-GB', text: 'QA Short Name' }],
|
||||||
|
},
|
||||||
|
{ station_id: 'unnamed', name: [null, {}, { language: 42, text: [] }] },
|
||||||
|
{ station_id: 'legacy', name: ' QA Legacy Station ' },
|
||||||
|
{
|
||||||
|
station_id: 'fallback',
|
||||||
|
name: [null, { language: 'hu', text: 'QA Magyar Station' }],
|
||||||
|
},
|
||||||
|
].map((station, index) => ({
|
||||||
|
...station,
|
||||||
|
lat: 30.2681 + index * 0.001,
|
||||||
|
lon: -97.7469 + index * 0.001,
|
||||||
|
capacity: 13,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const status = {
|
||||||
|
data: {
|
||||||
|
stations: information.data.stations.map(({ station_id }) => ({
|
||||||
|
station_id,
|
||||||
|
num_bikes_available: 4,
|
||||||
|
num_docks_available: 9,
|
||||||
|
is_renting: true,
|
||||||
|
is_returning: true,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const browser = await puppeteer.launch({
|
||||||
|
headless: 'new',
|
||||||
|
...(process.env.PUPPETEER_EXECUTABLE_PATH
|
||||||
|
? { executablePath: process.env.PUPPETEER_EXECUTABLE_PATH }
|
||||||
|
: {}),
|
||||||
|
args:
|
||||||
|
process.platform === 'darwin'
|
||||||
|
? ['--use-angle=metal']
|
||||||
|
: ['--use-gl=angle', '--use-angle=swiftshader'],
|
||||||
|
});
|
||||||
|
let checks = 0;
|
||||||
|
const check = (name, actual, expected) => {
|
||||||
|
assert.deepEqual(actual, expected, name);
|
||||||
|
checks++;
|
||||||
|
console.log(`[PASS] ${name}`);
|
||||||
|
};
|
||||||
|
async function open(mode = 'normal') {
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.bringToFront();
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', (error) => errors.push(error.message));
|
||||||
|
await page.setViewport({ width: 1440, height: 900 });
|
||||||
|
await page.evaluateOnNewDocument(
|
||||||
|
({ information, status, mode }) => {
|
||||||
|
localStorage.clear();
|
||||||
|
localStorage.setItem('gev:first-run-mission:v1', 'suppressed');
|
||||||
|
window.__qaPainted = [];
|
||||||
|
const paint = CanvasRenderingContext2D.prototype.fillText;
|
||||||
|
CanvasRenderingContext2D.prototype.fillText = function (text, ...args) {
|
||||||
|
if (
|
||||||
|
String(text).startsWith('QA ') ||
|
||||||
|
String(text) === 'Station unnamed'
|
||||||
|
) {
|
||||||
|
window.__qaPainted.push(String(text));
|
||||||
|
if (window.__qaPainted.length > 100) window.__qaPainted.shift();
|
||||||
|
}
|
||||||
|
return paint.call(this, text, ...args);
|
||||||
|
};
|
||||||
|
const original = window.fetch.bind(window);
|
||||||
|
window.fetch = async (input, init) => {
|
||||||
|
const url = String(
|
||||||
|
typeof input === 'string' || input instanceof URL ? input : input.url,
|
||||||
|
);
|
||||||
|
if (url.includes('/api/gbfs/')) {
|
||||||
|
if (mode === 'failure') return new Response('{}', { status: 503 });
|
||||||
|
const payload =
|
||||||
|
mode === 'empty'
|
||||||
|
? { data: { stations: [] } }
|
||||||
|
: decodeURIComponent(url).includes('station_information')
|
||||||
|
? information
|
||||||
|
: status;
|
||||||
|
return new Response(JSON.stringify(payload), {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url.includes('/api/flights')) return new Response('{"states":[]}');
|
||||||
|
if (url.includes('/api/military')) return new Response('{"ac":[]}');
|
||||||
|
if (url.includes('/api/vessels'))
|
||||||
|
return new Response('{"connected":false,"vessels":[]}');
|
||||||
|
return original(input, init);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{ information, status, mode },
|
||||||
|
);
|
||||||
|
await page.goto(appUrl, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
window.__godsEyeView?.dataManager && window.__godsEyeView?.voiceCommands,
|
||||||
|
{ timeout: 60000 },
|
||||||
|
);
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
!document.getElementById('loading-screen') ||
|
||||||
|
(document.getElementById('loading-screen').classList.contains('hidden') &&
|
||||||
|
getComputedStyle(document.getElementById('loading-screen')).opacity ===
|
||||||
|
'0'),
|
||||||
|
{ timeout: 60000 },
|
||||||
|
);
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const { dataManager, viewer } = window.__godsEyeView;
|
||||||
|
for (const [id, layer] of dataManager.layers) {
|
||||||
|
if (layer.enabled)
|
||||||
|
await dataManager.setEnabled(id, false, { origin: 'user' });
|
||||||
|
}
|
||||||
|
const Cartesian3 = viewer.camera.position.constructor;
|
||||||
|
viewer.camera.cancelFlight();
|
||||||
|
viewer.camera.setView({
|
||||||
|
destination: Cartesian3.fromDegrees(-97.7449, 30.2701, 3000),
|
||||||
|
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||||
|
});
|
||||||
|
await dataManager.setEnabled('bikeshare', true, { origin: 'user' });
|
||||||
|
window.__godsEyeView.requestRender('gbfs-name-qa');
|
||||||
|
});
|
||||||
|
return { page, errors };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { page, errors } = await open();
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
window.__godsEyeView.dataManager.layers.get('bikeshare').module.getStats()
|
||||||
|
.count === 5,
|
||||||
|
{ timeout: 15000 },
|
||||||
|
);
|
||||||
|
const records = await page.evaluate(() =>
|
||||||
|
window.__godsEyeView.dataManager.layers
|
||||||
|
.get('bikeshare')
|
||||||
|
.module.getAnalystRecords(),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'the layer retains all five stations and normalized names',
|
||||||
|
records.map((r) => r.name),
|
||||||
|
[
|
||||||
|
'QA English Square',
|
||||||
|
'QA Short Name',
|
||||||
|
null,
|
||||||
|
'QA Legacy Station',
|
||||||
|
'QA Magyar Station',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const tool = await page.evaluate(() =>
|
||||||
|
window.__godsEyeView.voiceCommands.runner('get_bike_share', {
|
||||||
|
area: { lat: 30.2701, lon: -97.7449, radius_km: 2 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
check('the real bike-share tool succeeds', tool.ok, true);
|
||||||
|
check(
|
||||||
|
'the tool preserves localized, short, unnamed, legacy and non-English names',
|
||||||
|
tool.data.rows.map((r) => r.name).sort(),
|
||||||
|
[
|
||||||
|
null,
|
||||||
|
'QA English Square',
|
||||||
|
'QA Short Name',
|
||||||
|
'QA Legacy Station',
|
||||||
|
'QA Magyar Station',
|
||||||
|
].sort(),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'availability remains attached to every station',
|
||||||
|
tool.data.rows.every(
|
||||||
|
(r) => r.bikes_available === 4 && r.docks_available === 9,
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const clickPoint = await page.evaluate(() => {
|
||||||
|
window.__qaPainted = [];
|
||||||
|
const { viewer } = window.__godsEyeView;
|
||||||
|
const Cartesian3 = viewer.camera.position.constructor;
|
||||||
|
const point = viewer.scene.cartesianToCanvasCoordinates(
|
||||||
|
Cartesian3.fromDegrees(-97.7469, 30.2681),
|
||||||
|
);
|
||||||
|
const rect = viewer.scene.canvas.getBoundingClientRect();
|
||||||
|
return { x: rect.left + point.x, y: rect.top + point.y };
|
||||||
|
});
|
||||||
|
await page.mouse.click(clickPoint.x, clickPoint.y);
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => window.__qaPainted.includes('QA English Square'),
|
||||||
|
{ timeout: 10000 },
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'a real pointer click selects the station with its localized name',
|
||||||
|
await page.evaluate(() => window.__qaPainted.includes('QA English Square')),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
for (const [id, title] of [
|
||||||
|
['english', 'QA English Square'],
|
||||||
|
['short', 'QA Short Name'],
|
||||||
|
['unnamed', 'Station unnamed'],
|
||||||
|
]) {
|
||||||
|
await page.evaluate((id) => {
|
||||||
|
window.__qaPainted = [];
|
||||||
|
const app = window.__godsEyeView;
|
||||||
|
app.dataManager.layers
|
||||||
|
.get('bikeshare')
|
||||||
|
.module._selectBikeshareStationForTest(`austin-capmetro:${id}`);
|
||||||
|
app.requestRender('gbfs-name-selection-qa');
|
||||||
|
}, id);
|
||||||
|
await page.waitForFunction(
|
||||||
|
(title) => window.__qaPainted.includes(title),
|
||||||
|
{ timeout: 10000 },
|
||||||
|
title,
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
`the selected ${id} card paints the expected title`,
|
||||||
|
await page.evaluate((title) => window.__qaPainted.includes(title), title),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await page.screenshot({ path: path.join(shots, `${id}.png`) });
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await page.waitForFunction(() =>
|
||||||
|
window.__godsEyeView.dataManager.layers
|
||||||
|
.get('bikeshare')
|
||||||
|
.module.getDetectableObjects()
|
||||||
|
.every((record) => !record.skipLabel),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
`Escape clears the ${id} card`,
|
||||||
|
await page.evaluate(
|
||||||
|
() =>
|
||||||
|
window.__godsEyeView.dataManager.layers
|
||||||
|
.get('bikeshare')
|
||||||
|
.module.getDetectableObjects()
|
||||||
|
.filter((record) => record.skipLabel).length,
|
||||||
|
),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await page.setViewport({ width: 390, height: 844 });
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.__qaPainted = [];
|
||||||
|
const app = window.__godsEyeView;
|
||||||
|
// Use the supported clean view to inspect the mobile card clear of panels.
|
||||||
|
app.styleManager.toggleCleanView(true);
|
||||||
|
const Cartesian3 = app.viewer.camera.position.constructor;
|
||||||
|
app.viewer.camera.cancelFlight();
|
||||||
|
app.viewer.camera.setView({
|
||||||
|
destination: Cartesian3.fromDegrees(-97.7449, 30.2701, 3000),
|
||||||
|
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||||
|
});
|
||||||
|
app.dataManager.layers
|
||||||
|
.get('bikeshare')
|
||||||
|
.module._selectBikeshareStationForTest('austin-capmetro:english');
|
||||||
|
app.requestRender('gbfs-name-mobile-qa');
|
||||||
|
});
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => window.__qaPainted.includes('QA English Square'),
|
||||||
|
{ timeout: 10000 },
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'the mobile selected card paints its normalized name',
|
||||||
|
await page.evaluate(() => window.__qaPainted.includes('QA English Square')),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await page.screenshot({ path: path.join(shots, 'mobile.png') });
|
||||||
|
await page.evaluate(() =>
|
||||||
|
window.__godsEyeView.dataManager.setEnabled('bikeshare', false, {
|
||||||
|
origin: 'user',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'disabling clears selected cards and analyst records',
|
||||||
|
await page.evaluate(() => ({
|
||||||
|
count: window.__godsEyeView.dataManager.layers
|
||||||
|
.get('bikeshare')
|
||||||
|
.module.getAnalystRecords().length,
|
||||||
|
selected: window.__godsEyeView.dataManager.layers
|
||||||
|
.get('bikeshare')
|
||||||
|
.module.getDetectableObjects()
|
||||||
|
.filter((record) => record.skipLabel).length,
|
||||||
|
})),
|
||||||
|
{ count: 0, selected: 0 },
|
||||||
|
);
|
||||||
|
check('normal fixtures produce no page exceptions', errors, []);
|
||||||
|
await page.close();
|
||||||
|
for (const mode of ['empty', 'failure']) {
|
||||||
|
const { page, errors } = await open(mode);
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
!window.__godsEyeView.dataManager.layers
|
||||||
|
.get('bikeshare')
|
||||||
|
.module.getStats().loading,
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
`${mode} leaves no rendered stations`,
|
||||||
|
await page.evaluate(
|
||||||
|
() =>
|
||||||
|
window.__godsEyeView.dataManager.layers
|
||||||
|
.get('bikeshare')
|
||||||
|
.module.getStats().count,
|
||||||
|
),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
check(`${mode} produces no page exceptions`, errors, []);
|
||||||
|
await page.close();
|
||||||
|
}
|
||||||
|
console.log(`GBFS names: ${checks}/${checks} checks passed; ${appUrl}`);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
@@ -0,0 +1,446 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/** Large KML acceptance: style equivalence, 27.6 MB import and optional real file.
|
||||||
|
* QA_BASE_URL defaults to http://localhost:4173. Optional --file /path/file.kml.
|
||||||
|
* For a USGS fixture with blocked icons, add --expect-local-pins to check paint.
|
||||||
|
*/
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import puppeteer from 'puppeteer';
|
||||||
|
import { hookRenderErrors, readRenderErrors } from './qa-browserEvidence.mjs';
|
||||||
|
|
||||||
|
const base = process.env.QA_BASE_URL || 'http://localhost:4173';
|
||||||
|
const supplied = process.argv.includes('--file')
|
||||||
|
? path.resolve(process.argv[process.argv.indexOf('--file') + 1])
|
||||||
|
: null;
|
||||||
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'gev-large-kml-'));
|
||||||
|
const file = path.join(directory, 'metadata-rich-27mb.kml');
|
||||||
|
const style =
|
||||||
|
'<Style><IconStyle><scale>0.6</scale><color>ff00dd66</color></IconStyle></Style>';
|
||||||
|
const notes = 'A local observation with supporting detail. '.repeat(120);
|
||||||
|
const placemarks = Array.from({ length: 5000 }, (_, i) => {
|
||||||
|
const lon = -122.6 + (i % 100) * 0.002;
|
||||||
|
const lat = 37.65 + Math.floor(i / 100) * 0.003;
|
||||||
|
return `<Placemark><name>Point ${i}</name><styleUrl>#base</styleUrl>${style}<description>${notes}</description><Point><coordinates>${lon},${lat},0</coordinates></Point></Placemark>`;
|
||||||
|
});
|
||||||
|
const wrap = (body) =>
|
||||||
|
`<kml xmlns="http://www.opengis.net/kml/2.2"><Document><Style id="base"><IconStyle><heading>25</heading><scale>1.2</scale></IconStyle><LineStyle><color>ffdd6600</color><width>7</width></LineStyle><PolyStyle><color>6600ddff</color><fill>1</fill><outline>0</outline></PolyStyle></Style>${body}</Document></kml>`;
|
||||||
|
const large = wrap(placemarks.join(''));
|
||||||
|
fs.writeFileSync(file, large);
|
||||||
|
const bytes = fs.statSync(file).size;
|
||||||
|
if (bytes < 20 * 1024 * 1024 || bytes > 30 * 1024 * 1024)
|
||||||
|
throw new Error(`Fixture must be 20–30 MiB, got ${bytes} bytes`);
|
||||||
|
const small = wrap(
|
||||||
|
Array.from({ length: 6 }, (_, i) => {
|
||||||
|
const kind = i % 3;
|
||||||
|
const geometry =
|
||||||
|
kind === 0
|
||||||
|
? '<Point><coordinates>-122.49,37.77,0</coordinates></Point>'
|
||||||
|
: kind === 1
|
||||||
|
? '<LineString><coordinates>-122.50,37.77,0 -122.49,37.77,0</coordinates></LineString>'
|
||||||
|
: '<Polygon><outerBoundaryIs><LinearRing><coordinates>-122.50,37.77,0 -122.49,37.77,0 -122.49,37.78,0 -122.50,37.77,0</coordinates></LinearRing></outerBoundaryIs></Polygon>';
|
||||||
|
const override =
|
||||||
|
kind === 0
|
||||||
|
? style
|
||||||
|
: kind === 1
|
||||||
|
? '<Style><LineStyle><width>3</width></LineStyle></Style>'
|
||||||
|
: '<Style><PolyStyle><outline>1</outline></PolyStyle></Style>';
|
||||||
|
return `<Placemark><name>Small ${i}</name><styleUrl>#base</styleUrl>${override}${geometry}</Placemark>`;
|
||||||
|
}).join('') +
|
||||||
|
'<Style id="named-a"><IconStyle><scale>0.8</scale></IconStyle></Style>' +
|
||||||
|
'<Style id="named-b"><IconStyle><scale>0.8</scale></IconStyle></Style>' +
|
||||||
|
'<StyleMap id="paired"><Pair><key>normal</key><Style><IconStyle><scale>0.8</scale></IconStyle></Style></Pair></StyleMap>' +
|
||||||
|
['named-a', 'named-b', 'paired']
|
||||||
|
.map(
|
||||||
|
(id) =>
|
||||||
|
`<Placemark><name>${id}</name><styleUrl>#${id}</styleUrl><Point><coordinates>-122.49,37.77,0</coordinates></Point></Placemark>`,
|
||||||
|
)
|
||||||
|
.join(''),
|
||||||
|
);
|
||||||
|
const browser = await puppeteer.launch({
|
||||||
|
headless: true,
|
||||||
|
args:
|
||||||
|
process.platform === 'darwin'
|
||||||
|
? ['--use-gl=angle', '--use-angle=metal']
|
||||||
|
: ['--no-sandbox', '--use-gl=angle', '--use-angle=swiftshader'],
|
||||||
|
});
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await hookRenderErrors(page);
|
||||||
|
const errors = [];
|
||||||
|
const fileRequests = [];
|
||||||
|
page.on('pageerror', (e) => errors.push(e.message));
|
||||||
|
await page.setRequestInterception(true);
|
||||||
|
page.on('request', (request) => {
|
||||||
|
if (new URL(request.url()).hostname === 'earthquake.usgs.gov') {
|
||||||
|
fileRequests.push(request.url());
|
||||||
|
void request.abort();
|
||||||
|
} else void request.continue();
|
||||||
|
});
|
||||||
|
let failures = 0;
|
||||||
|
const check = (name, ok, detail) => {
|
||||||
|
console.log(`[${ok ? 'PASS' : 'FAIL'}] ${name}`, detail ?? '');
|
||||||
|
if (!ok) failures++;
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await page.setViewport({ width: 1440, height: 900 });
|
||||||
|
await page.goto(`${base}/?welcome=0`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
window.__gevGeoImport &&
|
||||||
|
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||||
|
{ timeout: 60000 },
|
||||||
|
);
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const { viewer, dataManager } = window.__godsEyeView;
|
||||||
|
const C = window.__CESIUM__;
|
||||||
|
for (const id of dataManager.layers.keys())
|
||||||
|
await dataManager.setEnabled(id, false);
|
||||||
|
viewer.clock.shouldAnimate = false;
|
||||||
|
viewer.imageryLayers.removeAll();
|
||||||
|
viewer.terrainProvider = new C.EllipsoidTerrainProvider();
|
||||||
|
viewer.camera.cancelFlight();
|
||||||
|
viewer.camera.setView({
|
||||||
|
destination: C.Cartesian3.fromDegrees(-122.5, 37.72, 50000),
|
||||||
|
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||||
|
});
|
||||||
|
window.__qaStyleSnapshot = (source) => {
|
||||||
|
const now = viewer.clock.currentTime;
|
||||||
|
const images = new Map();
|
||||||
|
const imageValue = (p) => {
|
||||||
|
const value = p?.getValue(now);
|
||||||
|
if (value?.tagName !== 'CANVAS') return value;
|
||||||
|
if (!images.has(value)) images.set(value, value.toDataURL());
|
||||||
|
return images.get(value);
|
||||||
|
};
|
||||||
|
const color = (p) => p?.getValue(now)?.toCssColorString();
|
||||||
|
const positions = (p) => p?.getValue(now)?.map((v) => [v.x, v.y, v.z]);
|
||||||
|
const renderedPins = new Map();
|
||||||
|
const collection = source.importedBillboards;
|
||||||
|
if (collection)
|
||||||
|
for (let i = 0; i < collection.length; i++) {
|
||||||
|
const pin = collection.get(i);
|
||||||
|
renderedPins.set(pin.id, pin);
|
||||||
|
}
|
||||||
|
const actual = (e, key, fallback) => {
|
||||||
|
const pin = renderedPins.get(e);
|
||||||
|
return pin ? pin[key] : (e.billboard?.[key]?.getValue(now) ?? fallback);
|
||||||
|
};
|
||||||
|
return (source.importedEntities || source.entities.values)
|
||||||
|
.map((e) => ({
|
||||||
|
name: e.name,
|
||||||
|
point: renderedPins.get(e)?.position || e.position?.getValue(now),
|
||||||
|
scale: e.billboard ? actual(e, 'scale', 1) : undefined,
|
||||||
|
rotation: e.billboard ? actual(e, 'rotation', 0) : undefined,
|
||||||
|
image: renderedPins.get(e)?.image || imageValue(e.billboard?.image),
|
||||||
|
color: e.billboard
|
||||||
|
? actual(e, 'color', C.Color.WHITE).toCssColorString()
|
||||||
|
: undefined,
|
||||||
|
width: e.polyline?.width?.getValue(now),
|
||||||
|
lineColor: color(e.polyline?.material?.color),
|
||||||
|
line: positions(e.polyline?.positions),
|
||||||
|
fill: e.polygon?.fill?.getValue(now),
|
||||||
|
outline: e.polygon?.outline?.getValue(now),
|
||||||
|
polygonColor: color(e.polygon?.material?.color),
|
||||||
|
polygon: e.polygon?.hierarchy
|
||||||
|
?.getValue(now)
|
||||||
|
?.positions?.map((v) => [v.x, v.y, v.z]),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
};
|
||||||
|
window.__qaUnsharedKml = async (text) => {
|
||||||
|
const doc = new DOMParser().parseFromString(text, 'application/xml');
|
||||||
|
for (const el of [...doc.getElementsByTagName('*')])
|
||||||
|
if (
|
||||||
|
['description', 'ExtendedData', 'BalloonStyle'].includes(el.localName)
|
||||||
|
)
|
||||||
|
el.remove();
|
||||||
|
const source = await C.KmlDataSource.load(doc, {
|
||||||
|
camera: viewer.scene.camera,
|
||||||
|
canvas: viewer.scene.canvas,
|
||||||
|
clampToGround: true,
|
||||||
|
});
|
||||||
|
for (const e of source.importedEntities || source.entities.values)
|
||||||
|
e.label = undefined;
|
||||||
|
return source;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const equivalent = await page.evaluate(async (text) => {
|
||||||
|
const { viewer } = window.__godsEyeView;
|
||||||
|
const control = await window.__qaUnsharedKml(text);
|
||||||
|
const outcomes = await window.__gevGeoImport.importFiles([
|
||||||
|
new File([text], 'style-equivalence.kml'),
|
||||||
|
]);
|
||||||
|
const candidate = viewer.dataSources.get(viewer.dataSources.length - 1);
|
||||||
|
const result = {
|
||||||
|
ok: outcomes[0]?.ok,
|
||||||
|
same:
|
||||||
|
JSON.stringify(window.__qaStyleSnapshot(control)) ===
|
||||||
|
JSON.stringify(window.__qaStyleSnapshot(candidate)),
|
||||||
|
entities: (candidate.importedEntities || candidate.entities.values)
|
||||||
|
.length,
|
||||||
|
};
|
||||||
|
await window.__gevGeoImport.remove(outcomes[0]?.id);
|
||||||
|
return result;
|
||||||
|
}, small);
|
||||||
|
check(
|
||||||
|
'shared inline and named styles preserve point, line and polygon overrides/inheritance',
|
||||||
|
equivalent.ok && equivalent.same && equivalent.entities === 9,
|
||||||
|
JSON.stringify(equivalent),
|
||||||
|
);
|
||||||
|
|
||||||
|
// The control bypasses the former style-count refusal solely in this test,
|
||||||
|
// to compare native unshared rendering against normalized rendering.
|
||||||
|
await page.evaluate(async (text) => {
|
||||||
|
const source = await window.__qaUnsharedKml(text);
|
||||||
|
window.__qaUnsharedSnapshot = JSON.stringify(
|
||||||
|
window.__qaStyleSnapshot(source),
|
||||||
|
);
|
||||||
|
window.__qaControl = source;
|
||||||
|
await window.__godsEyeView.viewer.dataSources.add(source);
|
||||||
|
}, large);
|
||||||
|
const measure = async () =>
|
||||||
|
page.evaluate(async () => {
|
||||||
|
const C = window.__CESIUM__,
|
||||||
|
{ viewer } = window.__godsEyeView;
|
||||||
|
const scene = viewer.scene;
|
||||||
|
viewer.camera.cancelFlight();
|
||||||
|
viewer.camera.setView({
|
||||||
|
destination: C.Cartesian3.fromDegrees(-122.5, 37.72, 50000),
|
||||||
|
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||||
|
});
|
||||||
|
const started = performance.now(),
|
||||||
|
samples = [];
|
||||||
|
let last,
|
||||||
|
frames = 0;
|
||||||
|
const remove = scene.postRender.addEventListener(() => {
|
||||||
|
const t = performance.now();
|
||||||
|
if (t - started < 2000) return;
|
||||||
|
frames++;
|
||||||
|
if (last !== undefined) samples.push(t - last);
|
||||||
|
last = t;
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
let previous = performance.now();
|
||||||
|
function tick(t) {
|
||||||
|
viewer.camera.rotate(
|
||||||
|
C.Cartesian3.UNIT_Z,
|
||||||
|
Math.min(t - previous, 100) * 0.0000002,
|
||||||
|
);
|
||||||
|
previous = t;
|
||||||
|
scene.requestRender();
|
||||||
|
if (t - started < 7000) requestAnimationFrame(tick);
|
||||||
|
else resolve();
|
||||||
|
}
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
});
|
||||||
|
remove();
|
||||||
|
samples.sort((a, b) => a - b);
|
||||||
|
const gl = scene.context._gl,
|
||||||
|
debug = gl.getExtension('WEBGL_debug_renderer_info');
|
||||||
|
return {
|
||||||
|
fps: frames / 5,
|
||||||
|
p50Ms: samples[Math.floor(samples.length * 0.5)],
|
||||||
|
p95Ms: samples[Math.floor(samples.length * 0.95)],
|
||||||
|
renderer: debug ? gl.getParameter(debug.UNMASKED_RENDERER_WEBGL) : null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
console.log('UNSHARED CONTROL', JSON.stringify(await measure()));
|
||||||
|
await page.evaluate(() =>
|
||||||
|
window.__godsEyeView.viewer.dataSources.remove(window.__qaControl, true),
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadPicked = async (filename, expected) => {
|
||||||
|
const before = await page.evaluate(
|
||||||
|
() => window.__godsEyeView.viewer.dataSources.length,
|
||||||
|
);
|
||||||
|
const priorIds = await page.evaluate(() =>
|
||||||
|
window.__gevGeoImport.list().map((f) => f.id),
|
||||||
|
);
|
||||||
|
const started = performance.now();
|
||||||
|
await (await page.$('#geo-import-input')).uploadFile(filename);
|
||||||
|
const name = path.basename(filename);
|
||||||
|
const loaded = await page
|
||||||
|
.waitForFunction(
|
||||||
|
({ name, priorIds }) =>
|
||||||
|
window.__gevGeoImport.list().some((f) => !priorIds.includes(f.id)) ||
|
||||||
|
document
|
||||||
|
.getElementById('geo-import-hint')
|
||||||
|
.textContent.startsWith(`${name}:`),
|
||||||
|
{ timeout: 120000 },
|
||||||
|
{ name, priorIds },
|
||||||
|
)
|
||||||
|
.then(
|
||||||
|
() => true,
|
||||||
|
() => false,
|
||||||
|
);
|
||||||
|
const state = await page.evaluate(() => ({
|
||||||
|
files: window.__gevGeoImport.list(),
|
||||||
|
hint: document.getElementById('geo-import-hint').textContent,
|
||||||
|
sources: window.__godsEyeView.viewer.dataSources.length,
|
||||||
|
}));
|
||||||
|
const item = state.files.find((f) => !priorIds.includes(f.id));
|
||||||
|
check(
|
||||||
|
`picked ${name} loads`,
|
||||||
|
loaded &&
|
||||||
|
(expected === null
|
||||||
|
? item?.entityCount > 0
|
||||||
|
: item?.entityCount === expected) &&
|
||||||
|
state.sources === before + 1,
|
||||||
|
JSON.stringify({
|
||||||
|
bytes: fs.statSync(filename).size,
|
||||||
|
loadMs: Math.round(performance.now() - started),
|
||||||
|
...state,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return item;
|
||||||
|
};
|
||||||
|
const item = await loadPicked(file, 5000);
|
||||||
|
if (item) {
|
||||||
|
const same = await page.evaluate(() => {
|
||||||
|
const viewer = window.__godsEyeView.viewer;
|
||||||
|
const source = viewer.dataSources.get(viewer.dataSources.length - 1);
|
||||||
|
return (
|
||||||
|
JSON.stringify(window.__qaStyleSnapshot(source)) ===
|
||||||
|
window.__qaUnsharedSnapshot
|
||||||
|
);
|
||||||
|
});
|
||||||
|
check('all 5,000 normalized graphics match their unshared control', same);
|
||||||
|
console.log('NORMALIZED CANDIDATE', JSON.stringify(await measure()));
|
||||||
|
await page.evaluate((id) => window.__gevGeoImport.remove(id), item.id);
|
||||||
|
}
|
||||||
|
if (supplied) {
|
||||||
|
const clockBefore = await page.evaluate(() => {
|
||||||
|
const v = window.__godsEyeView.viewer,
|
||||||
|
C = window.__CESIUM__;
|
||||||
|
return [
|
||||||
|
C.JulianDate.toIso8601(v.clock.startTime),
|
||||||
|
C.JulianDate.toIso8601(v.clock.stopTime),
|
||||||
|
C.JulianDate.toIso8601(v.clock.currentTime),
|
||||||
|
v.clock.multiplier,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
const suppliedItem = await loadPicked(supplied, null);
|
||||||
|
const clockAfter = await page.evaluate(() => {
|
||||||
|
const v = window.__godsEyeView.viewer,
|
||||||
|
C = window.__CESIUM__;
|
||||||
|
return [
|
||||||
|
C.JulianDate.toIso8601(v.clock.startTime),
|
||||||
|
C.JulianDate.toIso8601(v.clock.stopTime),
|
||||||
|
C.JulianDate.toIso8601(v.clock.currentTime),
|
||||||
|
v.clock.multiplier,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
check(
|
||||||
|
'real dated KML leaves the shared clock unchanged',
|
||||||
|
JSON.stringify(clockBefore) === JSON.stringify(clockAfter),
|
||||||
|
);
|
||||||
|
if (suppliedItem && process.argv.includes('--expect-local-pins')) {
|
||||||
|
const markers = await page.evaluate(() => {
|
||||||
|
const { viewer } = window.__godsEyeView,
|
||||||
|
C = window.__CESIUM__;
|
||||||
|
const source = viewer.dataSources.get(viewer.dataSources.length - 1);
|
||||||
|
const points = (
|
||||||
|
source.importedEntities || source.entities.values
|
||||||
|
).filter((e) => e.position && e.billboard);
|
||||||
|
const images = points.filter((e) =>
|
||||||
|
e.billboard.image?.getValue(viewer.clock.currentTime),
|
||||||
|
);
|
||||||
|
if (images.length) {
|
||||||
|
const at = C.Cartographic.fromCartesian(
|
||||||
|
images[0].position.getValue(viewer.clock.currentTime),
|
||||||
|
);
|
||||||
|
viewer.camera.cancelFlight();
|
||||||
|
viewer.camera.setView({
|
||||||
|
destination: C.Cartesian3.fromRadians(
|
||||||
|
at.longitude,
|
||||||
|
at.latitude,
|
||||||
|
100000,
|
||||||
|
),
|
||||||
|
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { points: points.length, images: images.length };
|
||||||
|
});
|
||||||
|
check(
|
||||||
|
'supplied point markers have drawable images',
|
||||||
|
markers.points > 0 && markers.images === markers.points,
|
||||||
|
JSON.stringify(markers),
|
||||||
|
);
|
||||||
|
if (markers.points) {
|
||||||
|
const yellowPixels = async () => {
|
||||||
|
const shot = await page.screenshot({
|
||||||
|
clip: { x: 620, y: 350, width: 200, height: 200 },
|
||||||
|
type: 'png',
|
||||||
|
encoding: 'base64',
|
||||||
|
});
|
||||||
|
return page.evaluate(async (base64) => {
|
||||||
|
const bitmap = await createImageBitmap(
|
||||||
|
await (await fetch(`data:image/png;base64,${base64}`)).blob(),
|
||||||
|
);
|
||||||
|
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height),
|
||||||
|
context = canvas.getContext('2d');
|
||||||
|
context.drawImage(bitmap, 0, 0);
|
||||||
|
const data = context.getImageData(
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
bitmap.width,
|
||||||
|
bitmap.height,
|
||||||
|
).data;
|
||||||
|
let count = 0;
|
||||||
|
for (let i = 0; i < data.length; i += 4)
|
||||||
|
if (data[i] > 150 && data[i + 1] > 150 && data[i + 2] < 100)
|
||||||
|
count++;
|
||||||
|
return count;
|
||||||
|
}, shot);
|
||||||
|
};
|
||||||
|
const setVisible = (visible) =>
|
||||||
|
page.evaluate(
|
||||||
|
({ id, visible }) => window.__gevGeoImport.setVisible(id, visible),
|
||||||
|
{ id: suppliedItem.id, visible },
|
||||||
|
);
|
||||||
|
await setVisible(false);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
|
const hidden = await yellowPixels();
|
||||||
|
await setVisible(true);
|
||||||
|
let shown = hidden;
|
||||||
|
const deadline = performance.now() + 10000;
|
||||||
|
while (performance.now() < deadline && shown <= hidden + 10) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
shown = await yellowPixels();
|
||||||
|
}
|
||||||
|
check(
|
||||||
|
'real USGS markers paint when shown and disappear when hidden',
|
||||||
|
shown > hidden + 10,
|
||||||
|
JSON.stringify({ hidden, shown }),
|
||||||
|
);
|
||||||
|
await page.screenshot({
|
||||||
|
path: path.join(directory, 'real-kml-visible.png'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (suppliedItem)
|
||||||
|
await page.evaluate(
|
||||||
|
(id) => window.__gevGeoImport.remove(id),
|
||||||
|
suppliedItem.id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
check(
|
||||||
|
'file-directed USGS icon/metadata requests never leave the page',
|
||||||
|
fileRequests.length === 0,
|
||||||
|
fileRequests.length,
|
||||||
|
);
|
||||||
|
errors.push(...(await readRenderErrors(page)));
|
||||||
|
check(
|
||||||
|
'no uncaught page or render errors',
|
||||||
|
errors.length === 0,
|
||||||
|
JSON.stringify(errors),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'all imported sources are removed',
|
||||||
|
await page.evaluate(() => window.__gevGeoImport.list().length === 0),
|
||||||
|
);
|
||||||
|
console.log('Fixture retained at', file);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
process.exitCode = failures ? 1 : 0;
|
||||||
@@ -0,0 +1,564 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/** Import regressions through the real UI controller and Cesium loaders.
|
||||||
|
* Run against dev and preview: QA_BASE_URL=http://localhost:4173 node scripts/qa-geo-import-regressions.mjs
|
||||||
|
*/
|
||||||
|
import puppeteer from 'puppeteer';
|
||||||
|
import * as Cesium from 'cesium';
|
||||||
|
import { hookRenderErrors, readRenderErrors } from './qa-browserEvidence.mjs';
|
||||||
|
|
||||||
|
const browser = await puppeteer.launch({ headless: true });
|
||||||
|
const page = await browser.newPage();
|
||||||
|
const meshBytes = Buffer.alloc(60);
|
||||||
|
[
|
||||||
|
-2000, 2000, -2000, 2000, 2000, -2000, -2000, 2000, 2000, 2000, 2000, 2000,
|
||||||
|
].forEach((value, i) => meshBytes.writeFloatLE(value, i * 4));
|
||||||
|
[0, 1, 2, 2, 1, 3].forEach((value, i) =>
|
||||||
|
meshBytes.writeUInt16LE(value, 48 + i * 2),
|
||||||
|
);
|
||||||
|
const fixture = {
|
||||||
|
'/__qa_import_surface/tileset.json': {
|
||||||
|
asset: { version: '1.1' },
|
||||||
|
geometricError: 0,
|
||||||
|
root: {
|
||||||
|
transform: Cesium.Matrix4.toArray(
|
||||||
|
Cesium.Transforms.eastNorthUpToFixedFrame(
|
||||||
|
Cesium.Cartesian3.fromDegrees(-122.5, 37.7),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
boundingVolume: { box: [0, 0, 2000, 2000, 0, 0, 0, 2000, 0, 0, 0, 100] },
|
||||||
|
geometricError: 0,
|
||||||
|
content: { uri: 'surface.gltf' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'/__qa_import_surface/surface.gltf': {
|
||||||
|
asset: { version: '2.0' },
|
||||||
|
scene: 0,
|
||||||
|
scenes: [{ nodes: [0] }],
|
||||||
|
nodes: [{ mesh: 0 }],
|
||||||
|
meshes: [
|
||||||
|
{
|
||||||
|
primitives: [{ attributes: { POSITION: 0 }, indices: 1, material: 0 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
materials: [{ doubleSided: true }],
|
||||||
|
buffers: [
|
||||||
|
{
|
||||||
|
byteLength: 60,
|
||||||
|
uri: `data:application/octet-stream;base64,${meshBytes.toString('base64')}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
bufferViews: [
|
||||||
|
{ buffer: 0, byteOffset: 0, byteLength: 48, target: 34962 },
|
||||||
|
{ buffer: 0, byteOffset: 48, byteLength: 12, target: 34963 },
|
||||||
|
],
|
||||||
|
accessors: [
|
||||||
|
{
|
||||||
|
bufferView: 0,
|
||||||
|
componentType: 5126,
|
||||||
|
count: 4,
|
||||||
|
type: 'VEC3',
|
||||||
|
min: [-2000, 2000, -2000],
|
||||||
|
max: [2000, 2000, 2000],
|
||||||
|
},
|
||||||
|
{ bufferView: 1, componentType: 5123, count: 6, type: 'SCALAR' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await page.setRequestInterception(true);
|
||||||
|
page.on('request', (request) => {
|
||||||
|
const body = fixture[new URL(request.url()).pathname];
|
||||||
|
if (body)
|
||||||
|
void request.respond({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
else void request.continue();
|
||||||
|
});
|
||||||
|
const errors = [],
|
||||||
|
requests = [];
|
||||||
|
page.on('pageerror', (e) => errors.push(e.message));
|
||||||
|
page.on('console', (m) => {
|
||||||
|
if (/Ignoring clampToGround/.test(m.text())) errors.push(m.text());
|
||||||
|
});
|
||||||
|
await hookRenderErrors(page);
|
||||||
|
let failed = 0;
|
||||||
|
try {
|
||||||
|
await page.setViewport({ width: 390, height: 844 });
|
||||||
|
await page.goto(
|
||||||
|
`${process.env.QA_BASE_URL || 'http://localhost:4173'}/?welcome=0`,
|
||||||
|
{ waitUntil: 'domcontentloaded' },
|
||||||
|
);
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
window.__gevGeoImport &&
|
||||||
|
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||||
|
{ timeout: 90000 },
|
||||||
|
);
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const { viewer, dataManager } = window.__godsEyeView,
|
||||||
|
C = window.__CESIUM__;
|
||||||
|
for (const id of dataManager.layers.keys())
|
||||||
|
await dataManager.setEnabled(id, false);
|
||||||
|
viewer.imageryLayers.removeAll();
|
||||||
|
viewer.terrainProvider = new C.EllipsoidTerrainProvider();
|
||||||
|
viewer.clock.shouldAnimate = false;
|
||||||
|
viewer.camera.cancelFlight();
|
||||||
|
viewer.camera.setView({
|
||||||
|
destination: C.Cartesian3.fromDegrees(-122.5, 37.7, 100000),
|
||||||
|
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
page.on('request', (r) => {
|
||||||
|
if (!/^(data:|blob:)/.test(r.url())) requests.push(r.url());
|
||||||
|
});
|
||||||
|
const results = await page.evaluate(async () => {
|
||||||
|
const { viewer } = window.__godsEyeView,
|
||||||
|
C = window.__CESIUM__,
|
||||||
|
tool = window.__gevGeoImport;
|
||||||
|
const results = [],
|
||||||
|
now = C.JulianDate.clone(viewer.clock.currentTime);
|
||||||
|
const check = (name, pass, detail = '') =>
|
||||||
|
results.push({ name, pass: Boolean(pass), detail });
|
||||||
|
const wrap = (body, attrs = '') =>
|
||||||
|
`<kml xmlns="http://www.opengis.net/kml/2.2" xmlns:gx="http://www.google.com/kml/ext/2.2" ${attrs}><Document>${body}</Document></kml>`;
|
||||||
|
const point = '<Point><coordinates>-122.5,37.7,0</coordinates></Point>';
|
||||||
|
const load = async (value, name) => {
|
||||||
|
const content =
|
||||||
|
typeof value === 'object' && !ArrayBuffer.isView(value)
|
||||||
|
? JSON.stringify(value)
|
||||||
|
: value;
|
||||||
|
const [outcome] = await tool.importFiles([new File([content], name)]);
|
||||||
|
return {
|
||||||
|
outcome,
|
||||||
|
source: outcome.ok
|
||||||
|
? viewer.dataSources.get(viewer.dataSources.length - 1)
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const remove = (r) => {
|
||||||
|
if (r.outcome.ok) tool.remove(r.outcome.id);
|
||||||
|
};
|
||||||
|
let r = await load(
|
||||||
|
{
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: [
|
||||||
|
{
|
||||||
|
type: 'Feature',
|
||||||
|
id: 'kartaview-owned',
|
||||||
|
properties: {
|
||||||
|
name: 'XYZ',
|
||||||
|
addProperty: 1,
|
||||||
|
'marker-symbol': 'rail',
|
||||||
|
},
|
||||||
|
geometry: { type: 'Point', coordinates: [-122.5, 37.7, 100] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'Feature',
|
||||||
|
geometry: {
|
||||||
|
type: 'Polygon',
|
||||||
|
coordinates: [
|
||||||
|
[
|
||||||
|
[-122.5, 37.7, 10],
|
||||||
|
[-122.4, 37.7, 10],
|
||||||
|
[-122.4, 37.8, 10],
|
||||||
|
[-122.5, 37.7, 10],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'xyz.geojson',
|
||||||
|
);
|
||||||
|
const pin = (r.source?.importedEntities || r.source?.entities.values).find(
|
||||||
|
(e) => e.billboard,
|
||||||
|
),
|
||||||
|
polygon = (r.source?.importedEntities || r.source?.entities.values).find(
|
||||||
|
(e) => e.polygon,
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'XYZ ground footprint and isolated entity IDs',
|
||||||
|
r.outcome.ok &&
|
||||||
|
pin.id !== 'kartaview-owned' &&
|
||||||
|
Math.abs(
|
||||||
|
C.Cartographic.fromCartesian(pin.position.getValue(now)).height,
|
||||||
|
) < 0.01 &&
|
||||||
|
polygon.polygon.perPositionHeight?.getValue(now) !== true,
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'ground pins have no persistent clamping callbacks',
|
||||||
|
pin?.billboard.heightReference?.getValue(now) ===
|
||||||
|
C.HeightReference.NONE &&
|
||||||
|
pin.billboard.disableDepthTestDistance.getValue(now) === Infinity,
|
||||||
|
);
|
||||||
|
remove(r);
|
||||||
|
r = await load(
|
||||||
|
wrap(
|
||||||
|
'<Placemark><name>ground</name><gx:Track><gx:coord>-122.5 37.7 0</gx:coord><gx:coord>-122.4 37.8 0</gx:coord></gx:Track></Placemark>' +
|
||||||
|
'<Placemark><name>absolute</name><gx:Track><altitudeMode>absolute</altitudeMode><gx:coord>-122.5 37.7 10000</gx:coord><gx:coord>-122.4 37.8 10000</gx:coord></gx:Track></Placemark>' +
|
||||||
|
'<Placemark><name>line</name><LineString><coordinates>-122.5,37.7 -122.4,37.8</coordinates></LineString></Placemark>',
|
||||||
|
),
|
||||||
|
'tracks.kml',
|
||||||
|
);
|
||||||
|
const find = (name) =>
|
||||||
|
(r.source?.importedEntities || r.source?.entities.values).find(
|
||||||
|
(e) => e.name === name,
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'ground tracks and untessellated authored lines clamp',
|
||||||
|
find('ground')?.polyline.clampToGround?.getValue(now) &&
|
||||||
|
find('line')?.polyline.clampToGround?.getValue(now),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'explicit track altitude and original vertices survive',
|
||||||
|
find('absolute')?.polyline.positions.getValue(now).length === 2 &&
|
||||||
|
find('absolute')
|
||||||
|
.polyline.positions.getValue(now)
|
||||||
|
.every(
|
||||||
|
(p) =>
|
||||||
|
Math.abs(C.Cartographic.fromCartesian(p).height - 10000) < 0.01,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
remove(r);
|
||||||
|
r = await load(
|
||||||
|
wrap(
|
||||||
|
`<Style id="s"><IconStyle><scale>0.5</scale></IconStyle></Style><Placemark><styleUrl> #s </styleUrl>${point}</Placemark>`,
|
||||||
|
),
|
||||||
|
'local-style.kml',
|
||||||
|
);
|
||||||
|
check('whitespace around a local style remains local', r.outcome.ok);
|
||||||
|
remove(r);
|
||||||
|
r = await load(
|
||||||
|
wrap(
|
||||||
|
`<Placemark><StyleMap><Pair><key>normal</key><styleUrl>https://gev-qa-trap.invalid/style</styleUrl></Pair></StyleMap>${point}</Placemark>`,
|
||||||
|
),
|
||||||
|
'incomplete-style.kml',
|
||||||
|
);
|
||||||
|
check('removing external style does not hang the queue', r.outcome.ok);
|
||||||
|
remove(r);
|
||||||
|
r = await load(
|
||||||
|
wrap(
|
||||||
|
`<Folder><visibility>0</visibility><Placemark>${point}</Placemark></Folder>`,
|
||||||
|
),
|
||||||
|
'hidden-folder.kml',
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'import show controls own previously hidden KML content',
|
||||||
|
r.outcome.ok &&
|
||||||
|
(r.source.importedEntities || r.source.entities.values).every(
|
||||||
|
(e) => e.isShowing,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
remove(r);
|
||||||
|
r = await load(
|
||||||
|
wrap(
|
||||||
|
'<GroundOverlay><color>ff00ff00</color><LatLonBox><north>37.8</north><south>37.7</south><east>-122.4</east><west>-122.5</west></LatLonBox></GroundOverlay>',
|
||||||
|
),
|
||||||
|
'colored.kml',
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'a colored overlay without an icon is drawable',
|
||||||
|
r.outcome.ok &&
|
||||||
|
(r.source.importedEntities || r.source.entities.values).some(
|
||||||
|
(e) => e.rectangle,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
remove(r);
|
||||||
|
const xml = wrap(`<Placemark>${point}</Placemark>`);
|
||||||
|
const utf16 = new Uint8Array(2 + xml.length * 2);
|
||||||
|
utf16.set([255, 254]);
|
||||||
|
for (let i = 0; i < xml.length; i++) {
|
||||||
|
utf16[2 + i * 2] = xml.charCodeAt(i) & 255;
|
||||||
|
utf16[3 + i * 2] = xml.charCodeAt(i) >> 8;
|
||||||
|
}
|
||||||
|
r = await load(utf16, 'utf16.kml');
|
||||||
|
check('UTF-16 BOM is decoded', r.outcome.ok);
|
||||||
|
remove(r);
|
||||||
|
r = await load(
|
||||||
|
wrap(
|
||||||
|
`<Placemark>${point}</Placemark>`,
|
||||||
|
'xmlns:gx="http://www.google.com/kml/ext/2.2" xsi:schemaLocation="test"',
|
||||||
|
),
|
||||||
|
'namespaces.kml',
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'ordinary duplicate and missing export namespaces are repaired',
|
||||||
|
r.outcome.ok,
|
||||||
|
);
|
||||||
|
remove(r);
|
||||||
|
for (const [name, value] of [
|
||||||
|
['null geometry', { type: 'Feature', geometry: null }],
|
||||||
|
['invalid coordinates', { type: 'Point', coordinates: ['bad', 37] }],
|
||||||
|
[
|
||||||
|
'missing feature type',
|
||||||
|
{
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: [{ geometry: { type: 'Point', coordinates: [0, 0] } }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'nested unsupported geometry',
|
||||||
|
{ type: 'Feature', geometry: { type: 'Topology', arcs: [] } },
|
||||||
|
],
|
||||||
|
]) {
|
||||||
|
r = await load(value, 'refused.geojson');
|
||||||
|
check(`${name} is refused without stopping rendering`, !r.outcome.ok);
|
||||||
|
remove(r);
|
||||||
|
}
|
||||||
|
r = await load(
|
||||||
|
wrap(
|
||||||
|
'<Placemark><Point><coordinates>NaN,37,0</coordinates></Point></Placemark>',
|
||||||
|
),
|
||||||
|
'invalid.kml',
|
||||||
|
);
|
||||||
|
check('non-finite KML coordinates are refused', !r.outcome.ok);
|
||||||
|
remove(r);
|
||||||
|
r = await load(
|
||||||
|
{
|
||||||
|
type: 'LineString',
|
||||||
|
coordinates: [
|
||||||
|
[-122.6, 37.7],
|
||||||
|
[-122.4, 37.7],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'portrait.geojson',
|
||||||
|
);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 2300));
|
||||||
|
const positions = (r.source?.importedEntities ||
|
||||||
|
r.source?.entities.values)[0].polyline.positions.getValue(now);
|
||||||
|
const screen = positions?.map((p) =>
|
||||||
|
C.SceneTransforms.worldToWindowCoordinates(viewer.scene, p),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'portrait flight includes both route endpoints',
|
||||||
|
screen?.every(
|
||||||
|
(p) => p && p.x > 0 && p.x < innerWidth && p.y > 0 && p.y < innerHeight,
|
||||||
|
),
|
||||||
|
screen?.map((p) => p && [p.x, p.y]),
|
||||||
|
);
|
||||||
|
remove(r);
|
||||||
|
r = await load(
|
||||||
|
{
|
||||||
|
type: 'MultiPoint',
|
||||||
|
coordinates: [
|
||||||
|
[-74, 40.7],
|
||||||
|
[139.7, 35.7],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'intercontinental.geojson',
|
||||||
|
);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 2300));
|
||||||
|
const intercontinental = (
|
||||||
|
r.source?.importedEntities || r.source?.entities.values
|
||||||
|
)
|
||||||
|
.filter((e) => e.position)
|
||||||
|
.map((e) => e.position.getValue(now));
|
||||||
|
const occluder = new C.EllipsoidalOccluder(
|
||||||
|
C.Ellipsoid.WGS84,
|
||||||
|
viewer.camera.positionWC,
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'intercontinental points remain in frame and in front of the horizon',
|
||||||
|
intercontinental?.every((p) => {
|
||||||
|
const s = C.SceneTransforms.worldToWindowCoordinates(viewer.scene, p);
|
||||||
|
return (
|
||||||
|
occluder.isPointVisible(p) &&
|
||||||
|
s &&
|
||||||
|
s.x > 0 &&
|
||||||
|
s.x < innerWidth &&
|
||||||
|
s.y > 0 &&
|
||||||
|
s.y < innerHeight
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
remove(r);
|
||||||
|
r = await load(
|
||||||
|
wrap(
|
||||||
|
'<Placemark><LineString><altitudeMode>absolute</altitudeMode><extrude>1</extrude><coordinates>4.9,52.37,3000 5.12,52.09,3000</coordinates></LineString></Placemark><Placemark><Point><coordinates>5.12,52.09</coordinates></Point></Placemark>',
|
||||||
|
),
|
||||||
|
'wall.kml',
|
||||||
|
);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 2300));
|
||||||
|
check(
|
||||||
|
'a wall contributes both endpoints to the flight',
|
||||||
|
r.outcome.ok &&
|
||||||
|
(r.source.importedEntities || r.source.entities.values)
|
||||||
|
.find((e) => e.wall)
|
||||||
|
?.wall.positions.getValue(now)
|
||||||
|
.every((p) => {
|
||||||
|
const s = C.SceneTransforms.worldToWindowCoordinates(
|
||||||
|
viewer.scene,
|
||||||
|
p,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
s && s.x > 0 && s.x < innerWidth && s.y > 0 && s.y < innerHeight
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
remove(r);
|
||||||
|
r = await load(
|
||||||
|
{
|
||||||
|
type: 'MultiPoint',
|
||||||
|
coordinates: [
|
||||||
|
[0, 0],
|
||||||
|
[180, 0],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'antipodal.geojson',
|
||||||
|
);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1800));
|
||||||
|
check(
|
||||||
|
'symmetric global extents keep a finite camera',
|
||||||
|
r.outcome.ok &&
|
||||||
|
Number.isFinite(viewer.camera.positionCartographic.height),
|
||||||
|
);
|
||||||
|
remove(r);
|
||||||
|
viewer.camera.cancelFlight();
|
||||||
|
viewer.camera.setView({
|
||||||
|
destination: C.Cartesian3.fromDegrees(-122.5, 37.7, 100000),
|
||||||
|
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||||
|
});
|
||||||
|
const mesh = await C.Cesium3DTileset.fromUrl(
|
||||||
|
`${location.origin}/__qa_import_surface/tileset.json`,
|
||||||
|
);
|
||||||
|
viewer.scene.primitives.add(mesh);
|
||||||
|
// This fixture installs a mesh outside the app's map-source lifecycle.
|
||||||
|
// Let the new camera/tiles reach a rendered frame before testing sampling.
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
let frames = 0;
|
||||||
|
const remove = viewer.scene.postRender.addEventListener(() => {
|
||||||
|
if (++frames >= 2) {
|
||||||
|
remove();
|
||||||
|
resolve();
|
||||||
|
} else viewer.scene.requestRender();
|
||||||
|
});
|
||||||
|
viewer.scene.requestRender();
|
||||||
|
});
|
||||||
|
const fixtureDeadline = performance.now() + 5000;
|
||||||
|
while (!mesh.tilesLoaded && performance.now() < fixtureDeadline) {
|
||||||
|
viewer.scene.requestRender();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
}
|
||||||
|
const globeShown = viewer.scene.globe.show;
|
||||||
|
viewer.scene.globe.show = false;
|
||||||
|
|
||||||
|
r = await load(
|
||||||
|
{ type: 'Point', coordinates: [-122.5, 37.7, 0] },
|
||||||
|
'mesh-point.geojson',
|
||||||
|
);
|
||||||
|
const meshPoint = (r.source?.importedEntities || r.source?.entities.values)
|
||||||
|
.find((e) => e.billboard)
|
||||||
|
?.position.getValue(now);
|
||||||
|
const meshHeight =
|
||||||
|
meshPoint && C.Cartographic.fromCartesian(meshPoint).height;
|
||||||
|
check(
|
||||||
|
'native 3D Tiles ground sampling keeps pins on the rendered mesh',
|
||||||
|
r.outcome.ok && Math.abs(meshHeight - 2000) < 2,
|
||||||
|
{
|
||||||
|
meshHeight,
|
||||||
|
tilesLoaded: mesh.tilesLoaded,
|
||||||
|
globeShown: viewer.scene.globe.show,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
tool.flyTo(r.outcome.id);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 2300));
|
||||||
|
const meshScreen =
|
||||||
|
meshPoint &&
|
||||||
|
C.SceneTransforms.worldToWindowCoordinates(viewer.scene, meshPoint);
|
||||||
|
check(
|
||||||
|
'a high-surface arrival frames the elevated pin',
|
||||||
|
meshScreen &&
|
||||||
|
Math.abs(meshScreen.x - innerWidth / 2) < 20 &&
|
||||||
|
Math.abs(meshScreen.y - innerHeight / 2) < 20,
|
||||||
|
{
|
||||||
|
screen: meshScreen && [meshScreen.x, meshScreen.y],
|
||||||
|
camera: {
|
||||||
|
height: viewer.camera.positionCartographic.height,
|
||||||
|
pitch: viewer.camera.pitch,
|
||||||
|
},
|
||||||
|
meshHeight,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const groundSource = r.source;
|
||||||
|
const elevated = await load(
|
||||||
|
wrap(
|
||||||
|
'<Placemark><Point><altitudeMode>absolute</altitudeMode><coordinates>-122.5,37.7,5000</coordinates></Point></Placemark>',
|
||||||
|
),
|
||||||
|
'elevated-pin.kml',
|
||||||
|
);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 2600));
|
||||||
|
check(
|
||||||
|
'a higher imported pin never becomes the sampled ground',
|
||||||
|
elevated.outcome.ok &&
|
||||||
|
Math.abs(
|
||||||
|
C.Cartographic.fromCartesian(
|
||||||
|
groundSource.importedBillboards.get(0).position,
|
||||||
|
).height - 2000,
|
||||||
|
) < 2,
|
||||||
|
);
|
||||||
|
remove(elevated);
|
||||||
|
remove(r);
|
||||||
|
viewer.scene.primitives.remove(mesh);
|
||||||
|
viewer.scene.globe.show = globeShown;
|
||||||
|
const before = tool.list().length;
|
||||||
|
const transfer = new DataTransfer();
|
||||||
|
transfer.items.add(
|
||||||
|
new File(
|
||||||
|
[JSON.stringify({ type: 'Point', coordinates: [-122.5, 37.7] })],
|
||||||
|
'panel-drop.geojson',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const event = new DragEvent('drop', {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
dataTransfer: transfer,
|
||||||
|
});
|
||||||
|
document.getElementById('geo-import-button').dispatchEvent(event);
|
||||||
|
for (let i = 0; i < 100 && tool.list().length === before; i++)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||||
|
check(
|
||||||
|
'a panel drop imports and prevents the browser default',
|
||||||
|
event.defaultPrevented && tool.list().length === before + 1,
|
||||||
|
);
|
||||||
|
for (const file of tool.list()) tool.remove(file.id);
|
||||||
|
check(
|
||||||
|
'the shared clock stays exactly unchanged',
|
||||||
|
C.JulianDate.equals(now, viewer.clock.currentTime),
|
||||||
|
);
|
||||||
|
check('no file sources remain', tool.list().length === 0);
|
||||||
|
return results;
|
||||||
|
});
|
||||||
|
for (const r of results) {
|
||||||
|
console.log(
|
||||||
|
`[${r.pass ? 'PASS' : 'FAIL'}] ${r.name}`,
|
||||||
|
JSON.stringify(r.detail),
|
||||||
|
);
|
||||||
|
if (!r.pass) failed++;
|
||||||
|
}
|
||||||
|
const renderErrors = await readRenderErrors(page);
|
||||||
|
if (errors.length || renderErrors.length) {
|
||||||
|
failed++;
|
||||||
|
console.log(
|
||||||
|
'[FAIL] browser/render errors',
|
||||||
|
JSON.stringify({ errors, renderErrors }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Terrain belongs to the base map; imported files may sample that provider.
|
||||||
|
const unexpected = requests.filter(
|
||||||
|
(url) =>
|
||||||
|
new URL(url).pathname !== '/logo.svg' &&
|
||||||
|
!new URL(url).pathname.startsWith('/__qa_import_surface/') &&
|
||||||
|
!/\/api\/|\.terrain(?:\?|$)|reearth|cesium|arcgis|openstreetmap|\.pbf(?:\?|$)/i.test(
|
||||||
|
url,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`[${unexpected.length ? 'FAIL' : 'PASS'}] no unexpected file-directed requests`,
|
||||||
|
JSON.stringify(unexpected),
|
||||||
|
);
|
||||||
|
if (unexpected.length) failed++;
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
process.exitCode = failed ? 1 : 0;
|
||||||
@@ -0,0 +1,594 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Rendered acceptance for DISPLAY ▸ Import: real files through the real file
|
||||||
|
* input and a real drop, parsed by Cesium in the page.
|
||||||
|
*
|
||||||
|
* Proves: GeoJSON, KML, KMZ (with a packed icon and ground overlay) and GPX
|
||||||
|
* each become one row and one data source; the imported shape actually paints
|
||||||
|
* on the globe in its row's color (a GeoJSON `stroke`/`fill` does not override
|
||||||
|
* it) and stops painting when hidden; no import makes the page request
|
||||||
|
* anything named in it (network link, remote icon, remote style, palette
|
||||||
|
* icon, balloon or extended-data HTML, maki marker) or anything outside the
|
||||||
|
* page for it; no entity keeps a description, label or properties; dated KML
|
||||||
|
* and GPX leave the app's clock alone and still draw now; a GPX track keeps
|
||||||
|
* its two segments apart; keyboard focus survives hide/show; a dropped file
|
||||||
|
* imports like a picked one; a file that is not geodata is refused with a
|
||||||
|
* reason and leaves nothing behind; Remove gives every data source back; no
|
||||||
|
* page errors.
|
||||||
|
*
|
||||||
|
* Usage: dev server up (`npm run dev`), then `node scripts/qa-geo-import.mjs`.
|
||||||
|
* QA_BASE_URL (default http://localhost:4173), QA_SHOT_DIR, QA_HEADFUL=1.
|
||||||
|
*/
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import puppeteer from 'puppeteer';
|
||||||
|
import { hookRenderErrors, readRenderErrors } from './qa-browserEvidence.mjs';
|
||||||
|
|
||||||
|
const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:4173';
|
||||||
|
const SHOT_DIR = process.env.QA_SHOT_DIR || 'qa-shots/geo-import';
|
||||||
|
const HEADFUL = process.env.QA_HEADFUL === '1';
|
||||||
|
// Golden Gate Park: a big, flat, green rectangle the test polygon can cover.
|
||||||
|
const VIEW = { lon: -122.4862, lat: 37.7694, height: 4200, pitch: -88 };
|
||||||
|
const TRAP_HOST = 'gev-qa-trap.invalid';
|
||||||
|
|
||||||
|
/* ── fixtures ──────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const ring = [
|
||||||
|
[-122.511, 37.7645],
|
||||||
|
[-122.4545, 37.7645],
|
||||||
|
[-122.4545, 37.7745],
|
||||||
|
[-122.511, 37.7745],
|
||||||
|
[-122.511, 37.7645],
|
||||||
|
];
|
||||||
|
const geojson = JSON.stringify({
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: [
|
||||||
|
{
|
||||||
|
type: 'Feature',
|
||||||
|
// A file's own simplestyle and HTML must not survive the import.
|
||||||
|
properties: {
|
||||||
|
name: 'QA park',
|
||||||
|
stroke: '#ff0000',
|
||||||
|
fill: '#ff0000',
|
||||||
|
'fill-opacity': 1,
|
||||||
|
description: `<img src="https://${TRAP_HOST}/props.png">`,
|
||||||
|
},
|
||||||
|
geometry: { type: 'Polygon', coordinates: [ring.map((p) => [...p, 0])] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'Feature',
|
||||||
|
properties: { name: 'QA pin', 'marker-symbol': 'rail' },
|
||||||
|
geometry: { type: 'Point', coordinates: [-122.4862, 37.7694, 0] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const kml = (iconHref) => `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<kml xmlns="http://www.opengis.net/kml/2.2" xmlns:gx="http://www.google.com/kml/ext/2.2">
|
||||||
|
<Document>
|
||||||
|
<Style id="s"><IconStyle><Icon><href>${iconHref}</href></Icon></IconStyle></Style>
|
||||||
|
<Style id="palette"><IconStyle><Icon><href>root://icons/palette-4.png</href><gx:x>32</gx:x><gx:y>64</gx:y></Icon></IconStyle></Style>
|
||||||
|
<NetworkLink><Link><href>https://${TRAP_HOST}/link.kml</href></Link></NetworkLink>
|
||||||
|
<Placemark><name>QA KML pin</name><styleUrl>#s</styleUrl>
|
||||||
|
<description><![CDATA[<img src="https://${TRAP_HOST}/balloon.png">]]></description>
|
||||||
|
<TimeSpan><begin>2019-01-01T00:00:00Z</begin><end>2019-01-02T00:00:00Z</end></TimeSpan>
|
||||||
|
<ExtendedData><Data name="note"><value><![CDATA[<iframe src="https://${TRAP_HOST}/extended.html"></iframe>]]></value></Data></ExtendedData>
|
||||||
|
<Point><coordinates>-122.47,37.77,0</coordinates></Point></Placemark>
|
||||||
|
<Placemark><name>QA palette pin</name><styleUrl>#palette</styleUrl>
|
||||||
|
<Point><coordinates>-122.48,37.771,0</coordinates></Point></Placemark>
|
||||||
|
<Placemark><name>QA moving</name>
|
||||||
|
<gx:Track><when>2019-01-01T00:00:00Z</when><when>2019-01-01T01:00:00Z</when>
|
||||||
|
<gx:coord>-122.49 37.767 0</gx:coord><gx:coord>-122.475 37.772 0</gx:coord></gx:Track></Placemark>
|
||||||
|
<Placemark><name>QA split tracks</name><gx:MultiTrack>
|
||||||
|
<gx:Track><gx:coord>-122.50 37.767 0</gx:coord><gx:coord>-122.49 37.768 0</gx:coord></gx:Track>
|
||||||
|
<gx:Track><gx:coord>-122.47 37.771 0</gx:coord><gx:coord>-122.46 37.772 0</gx:coord></gx:Track>
|
||||||
|
</gx:MultiTrack></Placemark>
|
||||||
|
<Placemark><name>QA remote style</name><styleUrl>https://${TRAP_HOST}/styles.kml#x</styleUrl>
|
||||||
|
<LineString><coordinates>-122.50,37.766 -122.46,37.772</coordinates></LineString></Placemark>
|
||||||
|
<GroundOverlay><Icon><href>https://${TRAP_HOST}/overlay.png</href></Icon>
|
||||||
|
<LatLonBox><north>37.77</north><south>37.76</south><east>-122.46</east><west>-122.47</west></LatLonBox></GroundOverlay>
|
||||||
|
</Document>
|
||||||
|
</kml>`;
|
||||||
|
const gpx = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<gpx version="1.1" creator="qa" xmlns="http://www.topografix.com/GPX/1/1">
|
||||||
|
<wpt lat="37.769" lon="-122.49"><name>QA waypoint</name></wpt>
|
||||||
|
<trk><name>QA track</name><desc><img src="https://${TRAP_HOST}/gpx.png"></desc><trkseg>
|
||||||
|
<trkpt lat="37.766" lon="-122.505"><time>2018-06-01T10:00:00Z</time></trkpt><trkpt lat="37.768" lon="-122.49"><time>2018-06-01T10:05:00Z</time></trkpt>
|
||||||
|
</trkseg><trkseg>
|
||||||
|
<trkpt lat="37.770" lon="-122.48"><time>2018-06-01T11:00:00Z</time></trkpt><trkpt lat="37.772" lon="-122.47"><time>2018-06-01T11:05:00Z</time></trkpt>
|
||||||
|
</trkseg></trk>
|
||||||
|
</gpx>`;
|
||||||
|
// 1×1 PNG.
|
||||||
|
const PNG = Buffer.from(
|
||||||
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==',
|
||||||
|
'base64',
|
||||||
|
);
|
||||||
|
/** Stored (uncompressed) zip — enough for a KMZ fixture. */
|
||||||
|
function storedZip(files) {
|
||||||
|
const parts = [];
|
||||||
|
const central = [];
|
||||||
|
let offset = 0;
|
||||||
|
for (const [name, data] of files) {
|
||||||
|
const n = Buffer.from(name);
|
||||||
|
const local = Buffer.alloc(30);
|
||||||
|
local.writeUInt32LE(0x04034b50, 0);
|
||||||
|
local.writeUInt32LE(data.length, 18);
|
||||||
|
local.writeUInt32LE(data.length, 22);
|
||||||
|
local.writeUInt16LE(n.length, 26);
|
||||||
|
const c = Buffer.alloc(46);
|
||||||
|
c.writeUInt32LE(0x02014b50, 0);
|
||||||
|
c.writeUInt32LE(data.length, 20);
|
||||||
|
c.writeUInt32LE(data.length, 24);
|
||||||
|
c.writeUInt16LE(n.length, 28);
|
||||||
|
c.writeUInt32LE(offset, 42);
|
||||||
|
parts.push(local, n, data);
|
||||||
|
central.push(c, n);
|
||||||
|
offset += 30 + n.length + data.length;
|
||||||
|
}
|
||||||
|
const dir = Buffer.concat(central);
|
||||||
|
const end = Buffer.alloc(22);
|
||||||
|
end.writeUInt32LE(0x06054b50, 0);
|
||||||
|
end.writeUInt16LE(files.length, 8);
|
||||||
|
end.writeUInt16LE(files.length, 10);
|
||||||
|
end.writeUInt32LE(dir.length, 12);
|
||||||
|
end.writeUInt32LE(offset, 16);
|
||||||
|
return Buffer.concat([...parts, dir, end]);
|
||||||
|
}
|
||||||
|
const kmzDoc = kml('files/pin.png').replace(
|
||||||
|
`<href>https://${TRAP_HOST}/overlay.png</href>`,
|
||||||
|
'<href>files/overlay.png</href>',
|
||||||
|
);
|
||||||
|
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gev-qa-geo-import-'));
|
||||||
|
const fixture = (name, data) => {
|
||||||
|
const file = path.join(dir, name);
|
||||||
|
fs.writeFileSync(file, data);
|
||||||
|
return file;
|
||||||
|
};
|
||||||
|
const FILES = [
|
||||||
|
fixture('park.geojson', geojson),
|
||||||
|
fixture('remote-bits.kml', kml(`https://${TRAP_HOST}/icon.png`)),
|
||||||
|
fixture(
|
||||||
|
'packed.kmz',
|
||||||
|
storedZip([
|
||||||
|
['doc.kml', Buffer.from(kmzDoc)],
|
||||||
|
['files/pin.png', PNG],
|
||||||
|
['files/overlay.png', PNG],
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
fixture('walk.gpx', gpx),
|
||||||
|
];
|
||||||
|
|
||||||
|
/* ── harness ───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const browser = await puppeteer.launch({
|
||||||
|
headless: !HEADFUL,
|
||||||
|
args: [
|
||||||
|
'--no-sandbox',
|
||||||
|
'--disable-background-timer-throttling',
|
||||||
|
'--disable-renderer-backgrounding',
|
||||||
|
...(process.platform === 'darwin'
|
||||||
|
? ['--use-angle=metal', '--enable-gpu']
|
||||||
|
: ['--use-gl=angle', '--use-angle=swiftshader']),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await hookRenderErrors(page);
|
||||||
|
const errors = [];
|
||||||
|
const trapped = [];
|
||||||
|
page.on('pageerror', (error) => errors.push(`pageerror: ${error.message}`));
|
||||||
|
page.on('console', (message) => {
|
||||||
|
if (/Ignoring clampToGround/.test(message.text()))
|
||||||
|
errors.push(message.text());
|
||||||
|
});
|
||||||
|
// Anything a file names, and the icon assets Cesium would load on a file's
|
||||||
|
// behalf (KML palette icons from maps.google.com, GeoJSON maki markers).
|
||||||
|
const FILE_FETCH = [TRAP_HOST, 'maps.google.com/mapfiles', '/maki/'];
|
||||||
|
page.on('request', (request) => {
|
||||||
|
if (FILE_FETCH.some((needle) => request.url().includes(needle)))
|
||||||
|
trapped.push(request.url());
|
||||||
|
});
|
||||||
|
let failures = 0;
|
||||||
|
function check(name, passed, detail = '') {
|
||||||
|
console.log(
|
||||||
|
`[${passed ? 'PASS' : 'FAIL'}] ${name}${detail ? ` — ${detail}` : ''}`,
|
||||||
|
);
|
||||||
|
if (!passed) failures++;
|
||||||
|
}
|
||||||
|
fs.mkdirSync(SHOT_DIR, { recursive: true });
|
||||||
|
const shot = (name) =>
|
||||||
|
page.screenshot({
|
||||||
|
path: path.join(SHOT_DIR, `${name}.jpg`),
|
||||||
|
type: 'jpeg',
|
||||||
|
quality: 82,
|
||||||
|
});
|
||||||
|
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
const CLIP = { x: 380, y: 240, width: 480, height: 320 };
|
||||||
|
|
||||||
|
/** Pixels in the clip close to the first import color (#39d0ff). */
|
||||||
|
async function cyanPixels() {
|
||||||
|
await page.evaluate(() => window.__godsEyeView.viewer.scene.requestRender());
|
||||||
|
await wait(600);
|
||||||
|
const base64 = await page.screenshot({
|
||||||
|
clip: CLIP,
|
||||||
|
type: 'png',
|
||||||
|
encoding: 'base64',
|
||||||
|
});
|
||||||
|
return page.evaluate(async (b64) => {
|
||||||
|
const bitmap = await createImageBitmap(
|
||||||
|
await (await fetch(`data:image/png;base64,${b64}`)).blob(),
|
||||||
|
);
|
||||||
|
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
|
||||||
|
const context = canvas.getContext('2d');
|
||||||
|
context.drawImage(bitmap, 0, 0);
|
||||||
|
const { data } = context.getImageData(0, 0, bitmap.width, bitmap.height);
|
||||||
|
let n = 0;
|
||||||
|
for (let i = 0; i < data.length; i += 4) {
|
||||||
|
const [r, g, b] = [data[i], data[i + 1], data[i + 2]];
|
||||||
|
if (b > 45 && g > 30 && r < 100 && b - r > 30) n += 1;
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}, base64);
|
||||||
|
}
|
||||||
|
const state = () =>
|
||||||
|
page.evaluate(() => ({
|
||||||
|
files: window.__gevGeoImport.list(),
|
||||||
|
dataSources: window.__godsEyeView.viewer.dataSources.length,
|
||||||
|
rows: document.querySelectorAll('#geo-import-list .geo-import-row').length,
|
||||||
|
hint: document.getElementById('geo-import-hint')?.textContent || '',
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.setViewport({ width: 1280, height: 860 });
|
||||||
|
await page.goto(`${BASE_URL}/?welcome=0`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
window.__gevGeoImport &&
|
||||||
|
window.__godsEyeView?.viewer &&
|
||||||
|
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||||
|
{ timeout: 90_000 },
|
||||||
|
);
|
||||||
|
await page.evaluate(async (view) => {
|
||||||
|
const viewer = window.__godsEyeView.viewer;
|
||||||
|
for (const id of window.__godsEyeView.dataManager.layers.keys())
|
||||||
|
await window.__godsEyeView.dataManager.setEnabled(id, false);
|
||||||
|
viewer.imageryLayers.removeAll();
|
||||||
|
viewer.terrainProvider = new window.__CESIUM__.EllipsoidTerrainProvider();
|
||||||
|
viewer.scene.globe.baseColor = window.__CESIUM__.Color.BLACK;
|
||||||
|
viewer.scene.globe.enableLighting = false;
|
||||||
|
viewer.camera.cancelFlight?.();
|
||||||
|
viewer.camera.setView({
|
||||||
|
destination: window.__CESIUM__.Cartesian3.fromDegrees(
|
||||||
|
view.lon,
|
||||||
|
view.lat,
|
||||||
|
view.height,
|
||||||
|
),
|
||||||
|
orientation: { heading: 0, pitch: (view.pitch * Math.PI) / 180, roll: 0 },
|
||||||
|
});
|
||||||
|
viewer.scene.requestRender();
|
||||||
|
}, VIEW);
|
||||||
|
await wait(4000);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const rail = document.getElementById('pp-toggles');
|
||||||
|
if (rail?.classList.contains('collapsed'))
|
||||||
|
document
|
||||||
|
.querySelector('.panel-collapse-btn[data-collapse-target="pp-toggles"]')
|
||||||
|
?.click();
|
||||||
|
});
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
document.getElementById('geo-import-button')?.getBoundingClientRect()
|
||||||
|
.width > 0,
|
||||||
|
{ timeout: 10_000 },
|
||||||
|
);
|
||||||
|
const before = await state();
|
||||||
|
check(
|
||||||
|
'Import is on the DISPLAY rail with an empty, hidden list',
|
||||||
|
before.rows === 0 &&
|
||||||
|
!(await page.evaluate(() =>
|
||||||
|
document
|
||||||
|
.getElementById('geo-import-list')
|
||||||
|
.classList.contains('visible'),
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
const cyanBefore = await cyanPixels();
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.__godsEyeView.viewer.clock.shouldAnimate = false;
|
||||||
|
});
|
||||||
|
const clock = () =>
|
||||||
|
page.evaluate(() => {
|
||||||
|
const c = window.__godsEyeView.viewer.clock;
|
||||||
|
const iso = (t) => window.__CESIUM__.JulianDate.toIso8601(t, 0);
|
||||||
|
return {
|
||||||
|
start: iso(c.startTime),
|
||||||
|
stop: iso(c.stopTime),
|
||||||
|
range: c.clockRange,
|
||||||
|
step: c.clockStep,
|
||||||
|
multiplier: c.multiplier,
|
||||||
|
animate: c.shouldAnimate,
|
||||||
|
// Wall-clock drift aside, the current time must not jump to 2018/2019.
|
||||||
|
currentTime: window.__CESIUM__.JulianDate.toIso8601(c.currentTime),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const clockBefore = await clock();
|
||||||
|
const sourcesBefore = before.dataSources;
|
||||||
|
|
||||||
|
// Picked through the real <input type=file>.
|
||||||
|
const input = await page.$('#geo-import-input');
|
||||||
|
await input.uploadFile(...FILES);
|
||||||
|
const imported = await page
|
||||||
|
.waitForFunction(() => window.__gevGeoImport.list().length === 4, {
|
||||||
|
timeout: 60_000,
|
||||||
|
})
|
||||||
|
.then(
|
||||||
|
() => true,
|
||||||
|
() => false,
|
||||||
|
);
|
||||||
|
const after = await state();
|
||||||
|
check(
|
||||||
|
'four picked files become four rows and four data sources',
|
||||||
|
imported &&
|
||||||
|
after.rows === 4 &&
|
||||||
|
after.dataSources === before.dataSources + 4,
|
||||||
|
JSON.stringify(
|
||||||
|
after.files.map((f) => `${f.name}:${f.format}:${f.entityCount}`),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'formats are recognized',
|
||||||
|
after.files.map((f) => f.format).join() === 'geojson,kml,kmz,gpx',
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'the KML and KMZ had their outside references removed',
|
||||||
|
after.files[1].removed > 0 && after.files[2].removed > 0,
|
||||||
|
`removed ${after.files[1].removed}/${after.files[2].removed}`,
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'the hint says so',
|
||||||
|
/Added 4 files\..*left out/.test(after.hint),
|
||||||
|
after.hint,
|
||||||
|
);
|
||||||
|
const clockAfter = await clock();
|
||||||
|
check(
|
||||||
|
"dated KML and GPX leave the app's clock alone",
|
||||||
|
JSON.stringify(clockAfter) === JSON.stringify(clockBefore) &&
|
||||||
|
(await page.evaluate(
|
||||||
|
() =>
|
||||||
|
window.__godsEyeView.viewer.clockTrackedDataSource?.clock ===
|
||||||
|
undefined,
|
||||||
|
)),
|
||||||
|
`${JSON.stringify(clockBefore)} → ${JSON.stringify(clockAfter)}`,
|
||||||
|
);
|
||||||
|
const inside = await page.evaluate((from) => {
|
||||||
|
const viewer = window.__godsEyeView.viewer;
|
||||||
|
const now = viewer.clock.currentTime;
|
||||||
|
const sources = [];
|
||||||
|
for (let i = from; i < viewer.dataSources.length; i++)
|
||||||
|
sources.push(viewer.dataSources.get(i));
|
||||||
|
const entities = sources.flatMap(
|
||||||
|
(ds) => ds.importedEntities || ds.entities.values,
|
||||||
|
);
|
||||||
|
const named = (name) => entities.find((e) => e.name === name);
|
||||||
|
const gpx = sources[3].importedEntities || sources[3].entities.values;
|
||||||
|
const kmz = sources[2].importedEntities || sources[2].entities.values;
|
||||||
|
return {
|
||||||
|
sources: sources.length,
|
||||||
|
clocks: sources.filter((ds) => ds.clock !== undefined).length,
|
||||||
|
described: entities.filter((e) => e.description !== undefined).length,
|
||||||
|
labelled: entities.filter((e) => e.label !== undefined).length,
|
||||||
|
withProperties: entities.filter((e) => e.properties !== undefined).length,
|
||||||
|
withKml: entities.filter((e) => e.kml !== undefined).length,
|
||||||
|
hidden: entities.filter((e) => !e.isAvailable(now)).length,
|
||||||
|
datedPinNow: Boolean(named('QA KML pin')?.isAvailable(now)),
|
||||||
|
fallbackPins: ['QA KML pin', 'QA palette pin'].filter((name) =>
|
||||||
|
named(name)?.billboard?.image?.getValue(now),
|
||||||
|
).length,
|
||||||
|
movingIsLine: Boolean(
|
||||||
|
named('QA moving')?.polyline && !named('QA moving')?.position,
|
||||||
|
),
|
||||||
|
movingVertices:
|
||||||
|
named('QA moving')?.polyline?.positions?.getValue(now)?.length,
|
||||||
|
splitTracks: (sources[1].importedEntities || sources[1].entities.values)
|
||||||
|
.filter((e) => e.parent?.name === 'QA split tracks' && e.polyline)
|
||||||
|
.map((e) => e.polyline.positions.getValue(now).length),
|
||||||
|
gpxLines: gpx.filter((e) => e.polyline).length,
|
||||||
|
gpxPoints: gpx.filter((e) => e.billboard || e.point).length,
|
||||||
|
kmzLocalImage: kmz.some((e) =>
|
||||||
|
String(e.billboard?.image?.getValue(now) || '').startsWith('blob:'),
|
||||||
|
),
|
||||||
|
kmzOverlay: kmz.some((e) => e.rectangle),
|
||||||
|
groundedLines: sources
|
||||||
|
.slice(1, 3)
|
||||||
|
.every((ds) =>
|
||||||
|
(ds.importedEntities || ds.entities.values)
|
||||||
|
.filter((e) => e.polyline)
|
||||||
|
.every((e) => e.polyline.clampToGround?.getValue(now)),
|
||||||
|
),
|
||||||
|
xyzPolygon:
|
||||||
|
(sources[0].importedEntities || sources[0].entities.values)
|
||||||
|
.find((e) => e.polygon)
|
||||||
|
?.polygon.perPositionHeight?.getValue(now) !== true,
|
||||||
|
};
|
||||||
|
}, sourcesBefore);
|
||||||
|
check(
|
||||||
|
'no imported entity keeps a description, label, properties or KML data',
|
||||||
|
inside.sources === 4 &&
|
||||||
|
inside.described +
|
||||||
|
inside.labelled +
|
||||||
|
inside.withProperties +
|
||||||
|
inside.withKml ===
|
||||||
|
0,
|
||||||
|
JSON.stringify(inside),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'no imported source has a clock, and dated features draw now',
|
||||||
|
inside.clocks === 0 &&
|
||||||
|
inside.hidden === 0 &&
|
||||||
|
inside.datedPinNow &&
|
||||||
|
inside.movingIsLine &&
|
||||||
|
inside.movingVertices === 2,
|
||||||
|
JSON.stringify(inside),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'blocked KML icon sources use drawable local fallback pins',
|
||||||
|
inside.fallbackPins === 2,
|
||||||
|
JSON.stringify({ fallbackPins: inside.fallbackPins }),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'KML MultiTrack keeps its segments and original vertex count',
|
||||||
|
inside.splitTracks.join() === '2,2',
|
||||||
|
JSON.stringify(inside.splitTracks),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'the GPX track keeps its two segments apart',
|
||||||
|
inside.gpxLines === 2 && inside.gpxPoints === 1,
|
||||||
|
JSON.stringify(inside),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'KMZ retains its own packed image and overlay',
|
||||||
|
inside.kmzLocalImage && inside.kmzOverlay,
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'XYZ polygons and KML/KMZ lines use ground geometry',
|
||||||
|
inside.xyzPolygon && inside.groundedLines,
|
||||||
|
);
|
||||||
|
await page.evaluate((from) => {
|
||||||
|
for (const entity of window.__godsEyeView.viewer.dataSources.get(from)
|
||||||
|
.entities.values)
|
||||||
|
if (entity.billboard) entity.show = false;
|
||||||
|
}, sourcesBefore);
|
||||||
|
await page.evaluate((view) => {
|
||||||
|
const viewer = window.__godsEyeView.viewer;
|
||||||
|
viewer.camera.cancelFlight?.();
|
||||||
|
viewer.camera.setView({
|
||||||
|
destination: window.__CESIUM__.Cartesian3.fromDegrees(
|
||||||
|
view.lon,
|
||||||
|
view.lat,
|
||||||
|
view.height,
|
||||||
|
),
|
||||||
|
orientation: { heading: 0, pitch: (view.pitch * Math.PI) / 180, roll: 0 },
|
||||||
|
});
|
||||||
|
}, VIEW);
|
||||||
|
await wait(2500);
|
||||||
|
const cyanShown = await cyanPixels();
|
||||||
|
await shot('imported');
|
||||||
|
check(
|
||||||
|
'the imported polygon paints on the globe',
|
||||||
|
cyanShown > cyanBefore * 4 && cyanShown > cyanBefore + 5000,
|
||||||
|
`${cyanBefore} → ${cyanShown}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const parkId = after.files[0].id;
|
||||||
|
await page.click(
|
||||||
|
`#geo-import-list [data-import-id="${parkId}"] button[data-action="toggle"]`,
|
||||||
|
);
|
||||||
|
const cyanHidden = await cyanPixels();
|
||||||
|
check(
|
||||||
|
'hiding it stops it painting',
|
||||||
|
cyanHidden < cyanShown / 3,
|
||||||
|
`${cyanShown} → ${cyanHidden}`,
|
||||||
|
);
|
||||||
|
await page.click(
|
||||||
|
`#geo-import-list [data-import-id="${parkId}"] button[data-action="toggle"]`,
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'showing it again paints it again',
|
||||||
|
(await cyanPixels()) > cyanHidden * 4,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Keyboard: focus stays on the same control when the list is rebuilt.
|
||||||
|
const toggleSelector = `#geo-import-list [data-import-id="${parkId}"] button[data-action="toggle"]`;
|
||||||
|
await page.focus(toggleSelector);
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
const focusAfterHide = await page.evaluate(
|
||||||
|
(sel) => document.activeElement === document.querySelector(sel),
|
||||||
|
toggleSelector,
|
||||||
|
);
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
const focusAfterShow = await page.evaluate(
|
||||||
|
(sel) =>
|
||||||
|
document.activeElement === document.querySelector(sel) &&
|
||||||
|
document.activeElement.getAttribute('aria-pressed') === 'true',
|
||||||
|
toggleSelector,
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'keyboard focus stays on the toggle through hide and show',
|
||||||
|
focusAfterHide && focusAfterShow,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Dropped onto the globe, as a person drags from their file manager.
|
||||||
|
await page.evaluate((text) => {
|
||||||
|
const container = window.__godsEyeView.viewer.container;
|
||||||
|
const transfer = new DataTransfer();
|
||||||
|
transfer.items.add(
|
||||||
|
new File([text], 'dropped.geojson', { type: 'application/geo+json' }),
|
||||||
|
);
|
||||||
|
for (const type of ['dragenter', 'dragover', 'drop'])
|
||||||
|
container.dispatchEvent(
|
||||||
|
new DragEvent(type, {
|
||||||
|
dataTransfer: transfer,
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}, geojson);
|
||||||
|
const dropped = await page
|
||||||
|
.waitForFunction(() => window.__gevGeoImport.list().length === 5, {
|
||||||
|
timeout: 20_000,
|
||||||
|
})
|
||||||
|
.then(
|
||||||
|
() => true,
|
||||||
|
() => false,
|
||||||
|
);
|
||||||
|
check('a file dropped on the globe imports', dropped);
|
||||||
|
|
||||||
|
// Not geodata: refused with a reason, nothing added.
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const transfer = new DataTransfer();
|
||||||
|
transfer.items.add(
|
||||||
|
new File(['just some notes'], 'notes.txt', { type: 'text/plain' }),
|
||||||
|
);
|
||||||
|
window.__godsEyeView.viewer.container.dispatchEvent(
|
||||||
|
new DragEvent('drop', {
|
||||||
|
dataTransfer: transfer,
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await wait(800);
|
||||||
|
const refused = await state();
|
||||||
|
check(
|
||||||
|
'a file that is not geodata is refused with a reason',
|
||||||
|
refused.files.length === 5 &&
|
||||||
|
/notes\.txt: Only GeoJSON, KML, KMZ and GPX/.test(refused.hint),
|
||||||
|
refused.hint,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Remove every file through its row button.
|
||||||
|
for (const { id } of refused.files)
|
||||||
|
await page.click(
|
||||||
|
`#geo-import-list [data-import-id="${id}"] button[data-action="remove"]`,
|
||||||
|
);
|
||||||
|
const cleared = await state();
|
||||||
|
check(
|
||||||
|
'Remove gives every data source back',
|
||||||
|
cleared.rows === 0 && cleared.dataSources === before.dataSources,
|
||||||
|
`${cleared.dataSources} vs ${before.dataSources}`,
|
||||||
|
);
|
||||||
|
await wait(1500);
|
||||||
|
check(
|
||||||
|
'nothing named inside a file, and no icon asset for one, was ever requested',
|
||||||
|
trapped.length === 0,
|
||||||
|
trapped.join(' '),
|
||||||
|
);
|
||||||
|
errors.push(...(await readRenderErrors(page)));
|
||||||
|
check('no page or rendering errors', errors.length === 0, errors.join(' | '));
|
||||||
|
} catch (error) {
|
||||||
|
check('harness ran to completion', false, error?.stack || String(error));
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
console.log(failures ? `\n${failures} check(s) failed` : '\nall checks passed');
|
||||||
|
process.exit(failures ? 1 : 0);
|
||||||
@@ -0,0 +1,988 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* qa-gnss.mjs — headless proof for the GNSS Integrity layer
|
||||||
|
* (id `gnss-integrity`): low navigation accuracy reported by ADS-B.
|
||||||
|
*
|
||||||
|
* Drives the REAL app in headless Chromium against a dev server (keyless).
|
||||||
|
* The live section reads adsb.lol through `/api/gnss-integrity` (60 s server
|
||||||
|
* cache, one upstream read per whole-degree anchor); the other sections
|
||||||
|
* stub that route in the page so they are deterministic.
|
||||||
|
*
|
||||||
|
* (i) LIVE — over Poland and the southern Baltic the layer settles with no error,
|
||||||
|
* one geometry per cell and legend band counts that sum to the cell
|
||||||
|
* count; cells are required only when the returned rows, binned
|
||||||
|
* with the layer's own rules, reach the per-cell minimum (live
|
||||||
|
* traffic varies by hour). Requires a non-stale response no older than 60 s.
|
||||||
|
* (ii) BANDS (synthetic) — a 10-aircraft cell with 5 degraded is high,
|
||||||
|
* a 30-aircraft cell with 2 degraded is medium, a 3-aircraft cell
|
||||||
|
* with 1 degraded is low (first bad discounted), a clean cell at sea
|
||||||
|
* is low, a 2-aircraft cell is withheld; `stale: true` reaches
|
||||||
|
* getStats. Every band is drawn and low is shown over land and sea.
|
||||||
|
* The row reads as low navigation accuracy, and stats carry the
|
||||||
|
* machine-readable provenance (GEV classifier, gpsjam bands, window).
|
||||||
|
* PICK verifies actual perimeter and empty-ground clicks under GNSS.
|
||||||
|
* CAMERA/GLOBE verify automatic sampling and the screen-centre anchor.
|
||||||
|
* (iii) FAILURE — the route stubbed to 502: stats.error is set and the
|
||||||
|
* cells already on the globe are kept.
|
||||||
|
* (iv) DISABLE — owned ground primitives are hidden.
|
||||||
|
* (v) KEYBOARD — the row's toggle button, focused, turns the layer on
|
||||||
|
* with Enter and off with Space, keeps focus and names its state.
|
||||||
|
* (vi) VIEWPORT — at a 390x844 phone viewport the open row and its legend
|
||||||
|
* fit the panel width with no horizontal overflow.
|
||||||
|
* (vii) AGE (synthetic) — a fresh page whose stubbed proxy replays the same
|
||||||
|
* stale snapshot observed 31 minutes ago draws nothing and reports no
|
||||||
|
* error; observed 29 minutes ago it draws its cells. The replay keeps
|
||||||
|
* its observation time instead of restarting the 30-minute window.
|
||||||
|
*
|
||||||
|
* Screenshots wait until every cell's ground geometry is built and show the
|
||||||
|
* open Data Layers row with its legend. Visual proof saved to
|
||||||
|
* qa-shots/gnss-*.png (gitignored).
|
||||||
|
*
|
||||||
|
* Run: node scripts/qa-gnss.mjs --url http://localhost:4173
|
||||||
|
* (also against a built preview: npm run build && npm run preview,
|
||||||
|
* then pass the preview URL)
|
||||||
|
* Optional: --timeout-ms 180000 for a slow boot.
|
||||||
|
* Exits non-zero on any FAIL. Does not commit anything.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import puppeteer from 'puppeteer';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import {
|
||||||
|
accumulateGnssObservations,
|
||||||
|
binGnssCells,
|
||||||
|
} from '../src/layers/gnss/records.js';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const REPO_ROOT = path.resolve(__dirname, '..');
|
||||||
|
const SHOTS_DIR = path.join(REPO_ROOT, 'qa-shots');
|
||||||
|
|
||||||
|
const argv = process.argv.slice(2);
|
||||||
|
const getOpt = (name, dflt) => {
|
||||||
|
const i = argv.indexOf(name);
|
||||||
|
return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt;
|
||||||
|
};
|
||||||
|
const APP_URL = getOpt('--url', 'http://localhost:4173');
|
||||||
|
const HEADFUL = argv.includes('--headful');
|
||||||
|
const TIMEOUT_MS = Number(getOpt('--timeout-ms', '60000'));
|
||||||
|
const LAYER_ID = 'gnss-integrity';
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
function record(name, ok, detail) {
|
||||||
|
results.push({ name, ok, detail });
|
||||||
|
const tag = ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m';
|
||||||
|
console.log(` [${tag}] ${name}${detail ? ` — ${detail}` : ''}`);
|
||||||
|
}
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
/** Boot the app, dismiss the intro and place the camera straight down. */
|
||||||
|
async function boot(page, { lon, lat, height }) {
|
||||||
|
await page.goto(`${APP_URL}/?welcome=0`, {
|
||||||
|
waitUntil: 'domcontentloaded',
|
||||||
|
timeout: TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager,
|
||||||
|
{ timeout: TIMEOUT_MS },
|
||||||
|
);
|
||||||
|
await sleep(5000);
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await page.evaluate(
|
||||||
|
(lo, la, h) => {
|
||||||
|
const gev = window.__godsEyeView;
|
||||||
|
const d2r = Math.PI / 180;
|
||||||
|
// The app's intro flyTo animation clobbers a setView issued mid-flight.
|
||||||
|
try {
|
||||||
|
gev.viewer.camera.cancelFlight();
|
||||||
|
} catch {
|
||||||
|
/* no flight active */
|
||||||
|
}
|
||||||
|
gev.viewer.camera.setView({
|
||||||
|
destination: gev.viewer.scene.globe.ellipsoid.cartographicToCartesian({
|
||||||
|
longitude: lo * d2r,
|
||||||
|
latitude: la * d2r,
|
||||||
|
height: h,
|
||||||
|
}),
|
||||||
|
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||||
|
});
|
||||||
|
gev.viewer.scene.requestRender?.();
|
||||||
|
},
|
||||||
|
lon,
|
||||||
|
lat,
|
||||||
|
height,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Enable (or refresh) the layer and poll until it settles. */
|
||||||
|
async function enableAndSettle(page, { refresh = false } = {}) {
|
||||||
|
return page.evaluate(
|
||||||
|
async (id, again) => {
|
||||||
|
const dm = window.__godsEyeView.dataManager;
|
||||||
|
const layer = dm.layers.get(id).module;
|
||||||
|
if (again) await dm.refreshLayer(id);
|
||||||
|
else await dm.setEnabled(id, true);
|
||||||
|
const stats = layer.getStats();
|
||||||
|
const rowControls = layer.getRowControls().legend;
|
||||||
|
const legend = rowControls.map(({ label, count }) => ({ label, count }));
|
||||||
|
const blurb = rowControls[0]?.blurb ?? '';
|
||||||
|
const rowName =
|
||||||
|
document.querySelector(`[data-layer-id="${id}"] .data-name`)
|
||||||
|
?.textContent ?? '';
|
||||||
|
const all = window.__godsEyeView.viewer.scene.groundPrimitives;
|
||||||
|
const ds = Array.from({ length: all.length }, (_, i) => all.get(i)).find(
|
||||||
|
(item) => item.id === id,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
stats,
|
||||||
|
legend,
|
||||||
|
blurb,
|
||||||
|
rowName,
|
||||||
|
name: layer.name,
|
||||||
|
entities: ds?.get(0)?.geometryInstances.length ?? 0,
|
||||||
|
shown: ds?.show ?? null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
LAYER_ID,
|
||||||
|
refresh,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open the Data Layers panel and bring the GNSS row into view. */
|
||||||
|
async function showLayerRow(page) {
|
||||||
|
await page.evaluate((id) => {
|
||||||
|
const panel = document.getElementById('data-panel');
|
||||||
|
if (panel.classList.contains('collapsed'))
|
||||||
|
panel.querySelector('[data-collapse-target="data-panel"]').click();
|
||||||
|
document
|
||||||
|
.querySelector(`[data-layer-id="${id}"]`)
|
||||||
|
?.scrollIntoView({ block: 'center' });
|
||||||
|
}, LAYER_ID);
|
||||||
|
await sleep(800);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Actual card pixels on the shared canvas, available in dev and production. */
|
||||||
|
function paintedOverlayPixels() {
|
||||||
|
const canvas = document.getElementById('world-overlay-canvas');
|
||||||
|
if (!canvas) return -1;
|
||||||
|
const pixels = canvas
|
||||||
|
.getContext('2d')
|
||||||
|
.getImageData(0, 0, canvas.width, canvas.height).data;
|
||||||
|
let count = 0;
|
||||||
|
for (let i = 3; i < pixels.length; i += 4) if (pixels[i] > 0) count++;
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function shoot(page, name) {
|
||||||
|
await page
|
||||||
|
.waitForFunction(
|
||||||
|
() => window.__godsEyeView.viewer.scene.globe.tilesLoaded,
|
||||||
|
{ timeout: TIMEOUT_MS },
|
||||||
|
)
|
||||||
|
.catch(() => {});
|
||||||
|
await showLayerRow(page);
|
||||||
|
await page.screenshot({ path: path.join(SHOTS_DIR, `gnss-${name}.png`) });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bounding-sphere state of every GNSS cell: DONE = 0, PENDING = 1, FAILED = 2. */
|
||||||
|
function cellStates(id) {
|
||||||
|
const { viewer } = window.__godsEyeView;
|
||||||
|
const all = viewer.scene.groundPrimitives;
|
||||||
|
const ds = Array.from({ length: all.length }, (_, i) => all.get(i)).find(
|
||||||
|
(item) => item.id === id,
|
||||||
|
);
|
||||||
|
if (!ds?.length) return [];
|
||||||
|
return [ds.get(0).ready ? 0 : 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wait until every GNSS cell's ground rectangle has finished building. */
|
||||||
|
async function waitForBuiltCells(page, timeoutMs = 60000) {
|
||||||
|
const built = await page
|
||||||
|
.waitForFunction(
|
||||||
|
`(${cellStates})(${JSON.stringify(LAYER_ID)}).every((state) => state === 0)`,
|
||||||
|
{ timeout: timeoutMs, polling: 250 },
|
||||||
|
)
|
||||||
|
.then(
|
||||||
|
() => true,
|
||||||
|
() => false,
|
||||||
|
);
|
||||||
|
const states = await page.evaluate(
|
||||||
|
`(${cellStates})(${JSON.stringify(LAYER_ID)})`,
|
||||||
|
);
|
||||||
|
return { built, detail: `states=${JSON.stringify(states)}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const fixtureRow = (hex, lat, lon, degraded) => ({
|
||||||
|
hex,
|
||||||
|
lat,
|
||||||
|
lon,
|
||||||
|
nic: degraded ? 0 : 8,
|
||||||
|
nacp: degraded ? 0 : 10,
|
||||||
|
gpsLost: false,
|
||||||
|
degraded,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synthetic 0.5° cells: high at sea (5/10 bad), medium on land (2/30 bad),
|
||||||
|
* low on land (1/3 bad), low at sea (0/4 bad), withheld (2 aircraft).
|
||||||
|
*/
|
||||||
|
function fixturePayload() {
|
||||||
|
const rows = [];
|
||||||
|
const hex = (prefix, i) => `${prefix}${i.toString(16).padStart(2, '0')}`;
|
||||||
|
for (let i = 0; i < 10; i++)
|
||||||
|
rows.push(fixtureRow(hex('a000', i), 55.1 + i * 0.01, 19.1, i < 5));
|
||||||
|
for (let i = 0; i < 30; i++)
|
||||||
|
rows.push(fixtureRow(hex('d000', i), 54.1 + i * 0.01, 19.6, i < 2));
|
||||||
|
for (let i = 0; i < 3; i++)
|
||||||
|
rows.push(fixtureRow(hex('b000', i), 54.1 + i * 0.01, 18.1, i === 0));
|
||||||
|
for (let i = 0; i < 4; i++)
|
||||||
|
rows.push(fixtureRow(hex('e000', i), 55.1 + i * 0.01, 18.1, false));
|
||||||
|
for (let i = 0; i < 2; i++)
|
||||||
|
rows.push(fixtureRow(hex('c000', i), 53.1 + i * 0.01, 17.1, true));
|
||||||
|
return {
|
||||||
|
fetchedAt: Date.now(),
|
||||||
|
anchor: { lat: 55, lon: 19 },
|
||||||
|
radiusNm: 250,
|
||||||
|
rows,
|
||||||
|
stale: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Answer /api/gnss-integrity in-page from `fixturePayload()`. */
|
||||||
|
async function stubRoute(page, { ageMs = 0, initialFailures = 0, stale } = {}) {
|
||||||
|
await page.evaluateOnNewDocument(
|
||||||
|
(payload, age, failures, staleOverride) => {
|
||||||
|
window.__gnssQaAgeMs = age;
|
||||||
|
window.__gnssQaRequests = [];
|
||||||
|
window.__gnssQaFailures = failures;
|
||||||
|
const realFetch = window.fetch.bind(window);
|
||||||
|
window.__gnssQaMode = 'fixture';
|
||||||
|
window.fetch = (input, init) => {
|
||||||
|
const url = typeof input === 'string' ? input : input?.url || '';
|
||||||
|
if (!url.includes('/api/gnss-integrity')) return realFetch(input, init);
|
||||||
|
window.__gnssQaRequests.push(url);
|
||||||
|
if (window.__gnssQaFailures > 0) {
|
||||||
|
window.__gnssQaFailures--;
|
||||||
|
return Promise.resolve(
|
||||||
|
Response.json(
|
||||||
|
{ error: 'rate_limited' },
|
||||||
|
{
|
||||||
|
status: 429,
|
||||||
|
headers: { 'Retry-After': '5' },
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const ok = window.__gnssQaMode === 'fixture';
|
||||||
|
const body = ok
|
||||||
|
? {
|
||||||
|
...payload,
|
||||||
|
fetchedAt: Date.now() - (window.__gnssQaAgeMs || 0),
|
||||||
|
ageMs: window.__gnssQaAgeMs || 0,
|
||||||
|
stale: staleOverride ?? payload.stale,
|
||||||
|
}
|
||||||
|
: { error: 'gnss_integrity_unavailable' };
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(JSON.stringify(body), {
|
||||||
|
status: ok ? 200 : 502,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
fixturePayload(),
|
||||||
|
ageMs,
|
||||||
|
initialFailures,
|
||||||
|
stale,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Toggle button of the GNSS row, as the panel draws it. */
|
||||||
|
function toggleState(id) {
|
||||||
|
const button = document.querySelector(
|
||||||
|
`[data-layer-id="${id}"] .data-toggle-btn`,
|
||||||
|
);
|
||||||
|
const layer = window.__godsEyeView.dataManager.layers.get(id);
|
||||||
|
return {
|
||||||
|
focused: document.activeElement === button,
|
||||||
|
label: button?.getAttribute('aria-label') ?? '',
|
||||||
|
enabled: Boolean(layer?.enabled),
|
||||||
|
count: layer?.module.getStats().count ?? -1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Press a key on the focused GNSS toggle and wait for the lifecycle to settle. */
|
||||||
|
async function pressToggle(page, key, wantEnabled) {
|
||||||
|
await page.evaluate((id) => {
|
||||||
|
document.querySelector(`[data-layer-id="${id}"] .data-toggle-btn`)?.focus();
|
||||||
|
}, LAYER_ID);
|
||||||
|
await page.keyboard.press(key);
|
||||||
|
await page
|
||||||
|
.waitForFunction(
|
||||||
|
(id, want) => {
|
||||||
|
const layer = window.__godsEyeView.dataManager.layers.get(id);
|
||||||
|
return (
|
||||||
|
Boolean(layer?.enabled) === want &&
|
||||||
|
(!want || layer.module.getStats().count > 0)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ timeout: 30000, polling: 250 },
|
||||||
|
LAYER_ID,
|
||||||
|
wantEnabled,
|
||||||
|
)
|
||||||
|
.catch(() => {});
|
||||||
|
return page.evaluate(`(${toggleState})(${JSON.stringify(LAYER_ID)})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('\nGNSS Integrity proof (qa-gnss)');
|
||||||
|
console.log(` App URL : ${APP_URL}\n`);
|
||||||
|
try {
|
||||||
|
const res = await fetch(APP_URL);
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(
|
||||||
|
`\x1b[31mDev server not reachable at ${APP_URL} (${e.message}).\x1b[0m`,
|
||||||
|
);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
fs.mkdirSync(SHOTS_DIR, { recursive: true });
|
||||||
|
const executablePath =
|
||||||
|
process.env.PUPPETEER_EXECUTABLE_PATH ||
|
||||||
|
(await puppeteer.executablePath().catch(() => null)) ||
|
||||||
|
undefined;
|
||||||
|
const browser = await puppeteer.launch({
|
||||||
|
headless: HEADFUL ? false : 'new',
|
||||||
|
...(executablePath && fs.existsSync(executablePath)
|
||||||
|
? { executablePath }
|
||||||
|
: {}),
|
||||||
|
args: [
|
||||||
|
'--use-gl=angle',
|
||||||
|
'--use-angle=swiftshader',
|
||||||
|
'--disable-dev-shm-usage',
|
||||||
|
'--disable-background-timer-throttling',
|
||||||
|
'--disable-renderer-backgrounding',
|
||||||
|
'--window-size=1440,900',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
let exitCode = 0;
|
||||||
|
const errors = [];
|
||||||
|
try {
|
||||||
|
// ── (i) LIVE ──────────────────────────────────────────────────────────
|
||||||
|
console.log(
|
||||||
|
'(i) LIVE — adsb.lol integrity snapshot over Poland and the southern Baltic...',
|
||||||
|
);
|
||||||
|
const live = await browser.newPage();
|
||||||
|
await live.setViewport({ width: 1440, height: 900 });
|
||||||
|
live.on('pageerror', (e) => errors.push(e.message));
|
||||||
|
const liveBodies = [];
|
||||||
|
live.on('response', (res) => {
|
||||||
|
if (res.url().includes('/api/gnss-integrity') && res.ok())
|
||||||
|
liveBodies.push(res.json());
|
||||||
|
});
|
||||||
|
await boot(live, { lon: 19.6, lat: 52.6, height: 1_100_000 });
|
||||||
|
const l = await enableAndSettle(live);
|
||||||
|
const legendTotal = l.legend.reduce((sum, { count }) => sum + count, 0);
|
||||||
|
// Bin what adsb.lol actually returned with the layer's own rules: live
|
||||||
|
// traffic varies by hour, so cells are required only when some cell
|
||||||
|
// really reached the minimum aircraft count.
|
||||||
|
const liveSnapshots = (await Promise.allSettled(liveBodies))
|
||||||
|
.filter((r) => r.status === 'fulfilled')
|
||||||
|
.map((r) => r.value);
|
||||||
|
record(
|
||||||
|
'LIVE: a successful, dated integrity response was received',
|
||||||
|
liveSnapshots.length > 0 &&
|
||||||
|
liveSnapshots.every(
|
||||||
|
(body) =>
|
||||||
|
Array.isArray(body.rows) &&
|
||||||
|
Number.isFinite(body.ageMs) &&
|
||||||
|
body.ageMs >= 0 &&
|
||||||
|
body.ageMs <= 60000 &&
|
||||||
|
body.stale !== true &&
|
||||||
|
body.classifier === 'gev-nic-nacp-v2',
|
||||||
|
),
|
||||||
|
`successful responses=${liveSnapshots.length}`,
|
||||||
|
);
|
||||||
|
const liveRows = liveSnapshots.reduce(
|
||||||
|
(sum, body) => sum + (body.rows?.length || 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const expectedStore = new Map();
|
||||||
|
for (const body of liveSnapshots) {
|
||||||
|
const at = Date.now();
|
||||||
|
accumulateGnssObservations(expectedStore, body.rows, at, {
|
||||||
|
observedAt: at - body.ageMs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const expectedCells = binGnssCells(expectedStore).length;
|
||||||
|
record(
|
||||||
|
'LIVE: no error, one geometry per cell',
|
||||||
|
!l.stats.error && l.entities === l.stats.count,
|
||||||
|
`cells=${l.stats.count} entities=${l.entities} error=${JSON.stringify(l.stats.error)}`,
|
||||||
|
);
|
||||||
|
if (expectedCells > 0) {
|
||||||
|
record(
|
||||||
|
'LIVE: a snapshot with a qualifying cell yields cells',
|
||||||
|
l.stats.count > 0,
|
||||||
|
`rows=${liveRows} expected=${expectedCells} cells=${l.stats.count}`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
` [info] ${liveRows} live rows, no cell reaches the minimum — cell count not asserted`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
record(
|
||||||
|
'LIVE: legend band counts sum to the cell count',
|
||||||
|
legendTotal === l.stats.count,
|
||||||
|
JSON.stringify(l.legend),
|
||||||
|
);
|
||||||
|
const liveBuilt = await waitForBuiltCells(live);
|
||||||
|
record(
|
||||||
|
'LIVE: every cell rectangle is built on a parked camera',
|
||||||
|
liveBuilt.built,
|
||||||
|
liveBuilt.detail,
|
||||||
|
);
|
||||||
|
await shoot(live, 'live-poland-baltic');
|
||||||
|
await live.close();
|
||||||
|
|
||||||
|
// ── (ii) BANDS ────────────────────────────────────────────────────────
|
||||||
|
console.log(
|
||||||
|
'\n(ii) BANDS — synthetic integrity rows through the real layer...',
|
||||||
|
);
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.setViewport({ width: 1440, height: 900 });
|
||||||
|
page.on('pageerror', (e) => errors.push(e.message));
|
||||||
|
// Stub the route in-page rather than with request interception: an
|
||||||
|
// intercepted page stalls Cesium's geometry workers, so ground rectangles
|
||||||
|
// never finish building and the screenshot would not show real behaviour.
|
||||||
|
await stubRoute(page);
|
||||||
|
await boot(page, { lon: 18.6, lat: 54.6, height: 450_000 });
|
||||||
|
const f = await enableAndSettle(page);
|
||||||
|
const band = (prefix) =>
|
||||||
|
f.legend.find(({ label }) => label.startsWith(prefix))?.count;
|
||||||
|
record(
|
||||||
|
'BANDS: 4 cells shown, the 2-aircraft cell withheld',
|
||||||
|
f.stats.count === 4 && f.entities === 4,
|
||||||
|
`cells=${f.stats.count} entities=${f.entities}`,
|
||||||
|
);
|
||||||
|
record(
|
||||||
|
'BANDS: 5/10 is high, 2/30 is medium, 1/3 and 0/4 are low',
|
||||||
|
band('Low-accuracy score >') === 1 &&
|
||||||
|
band('Low-accuracy score 2') === 1 &&
|
||||||
|
band('Low-accuracy score <') === 2,
|
||||||
|
JSON.stringify(f.legend),
|
||||||
|
);
|
||||||
|
const prov = f.stats.provenance;
|
||||||
|
record(
|
||||||
|
'PROVENANCE: GEV classifier, gpsjam bands and visited-view window are machine-readable',
|
||||||
|
prov?.classifier?.id === 'gev-nic-nacp-v2' &&
|
||||||
|
prov.classifier.definedBy === 'GEV' &&
|
||||||
|
prov.classifier.validated === false &&
|
||||||
|
prov.aggregation?.source === 'https://gpsjam.org/faq' &&
|
||||||
|
JSON.stringify(prov.aggregation.bands) === '[2,10]' &&
|
||||||
|
prov.window?.minutes === 30 &&
|
||||||
|
prov.window.scope === 'visited-view' &&
|
||||||
|
prov.interpretation?.validated === false,
|
||||||
|
JSON.stringify(prov?.classifier),
|
||||||
|
);
|
||||||
|
record(
|
||||||
|
'WORDING: the row leads with low navigation accuracy, interference only as the hedged reading',
|
||||||
|
f.name === 'GNSS Integrity' &&
|
||||||
|
f.rowName.includes('GNSS Integrity') &&
|
||||||
|
!/interference/i.test(f.rowName) &&
|
||||||
|
f.legend.every(({ label }) => label.startsWith('Low-accuracy score')) &&
|
||||||
|
/one aircraft is discounted/.test(f.blurb) &&
|
||||||
|
/not a detection/.test(f.blurb),
|
||||||
|
`name=${JSON.stringify(f.name)}`,
|
||||||
|
);
|
||||||
|
record(
|
||||||
|
'BANDS: stale snapshot is reported',
|
||||||
|
f.stats.stale === true,
|
||||||
|
`stale=${f.stats.stale}`,
|
||||||
|
);
|
||||||
|
const bandsBuilt = await waitForBuiltCells(page);
|
||||||
|
record(
|
||||||
|
'BANDS: every cell rectangle is built on a parked camera',
|
||||||
|
bandsBuilt.built,
|
||||||
|
bandsBuilt.detail,
|
||||||
|
);
|
||||||
|
await shoot(page, 'synthetic-bands');
|
||||||
|
|
||||||
|
// GNSS fills must not own map input, including over a real perimeter.
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const original = window.fetch.bind(window);
|
||||||
|
window.fetch = (input, init) => {
|
||||||
|
const url = typeof input === 'string' ? input : input.url;
|
||||||
|
if (url.includes('/api/fire-perimeters/inciweb'))
|
||||||
|
return Promise.resolve(Response.json({ rows: [] }));
|
||||||
|
if (url.includes('/api/fire-perimeters'))
|
||||||
|
return Promise.resolve(
|
||||||
|
Response.json({
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
stableId: 'gnss-pick-qa',
|
||||||
|
name: 'GNSS PICK QA FIRE',
|
||||||
|
containedPct: 0,
|
||||||
|
acres: 100,
|
||||||
|
polygons: [
|
||||||
|
[
|
||||||
|
[
|
||||||
|
[18.247, 54.247],
|
||||||
|
[18.253, 54.247],
|
||||||
|
[18.253, 54.253],
|
||||||
|
[18.247, 54.253],
|
||||||
|
[18.247, 54.247],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return original(input, init);
|
||||||
|
};
|
||||||
|
const { viewer, dataManager } = window.__godsEyeView;
|
||||||
|
viewer.camera.cancelFlight();
|
||||||
|
viewer.camera.setView({
|
||||||
|
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
|
||||||
|
longitude: (18.25 * Math.PI) / 180,
|
||||||
|
latitude: (54.25 * Math.PI) / 180,
|
||||||
|
height: 2500,
|
||||||
|
}),
|
||||||
|
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||||
|
});
|
||||||
|
await dataManager.setEnabled('fire-perimeters', true);
|
||||||
|
viewer.scene.requestRender();
|
||||||
|
});
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => {
|
||||||
|
const v = window.__godsEyeView.viewer,
|
||||||
|
p = v.scene.pick({
|
||||||
|
x: v.scene.canvas.clientWidth / 2,
|
||||||
|
y: v.scene.canvas.clientHeight / 2,
|
||||||
|
});
|
||||||
|
return String(p?.id?.id ?? p?.id ?? '').startsWith(
|
||||||
|
'fire-perimeter:gnss-pick-qa:',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ timeout: TIMEOUT_MS, polling: 250 },
|
||||||
|
);
|
||||||
|
const pickProbe = await page.evaluate(() => {
|
||||||
|
const v = window.__godsEyeView.viewer,
|
||||||
|
c = v.scene.canvas;
|
||||||
|
const centre = { x: c.clientWidth / 2, y: c.clientHeight / 2 };
|
||||||
|
const empty = { x: centre.x + 300, y: centre.y };
|
||||||
|
const picked = v.scene.pick(empty),
|
||||||
|
box = c.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
centre: { x: centre.x + box.left, y: centre.y + box.top },
|
||||||
|
empty: { x: empty.x + box.left, y: empty.y + box.top },
|
||||||
|
surface: !picked || picked.id === undefined,
|
||||||
|
height: v.camera.positionCartographic.height,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
record('PICK: a perimeter remains selectable under GNSS', true);
|
||||||
|
record(
|
||||||
|
'PICK: street ground remains a surface pick under GNSS',
|
||||||
|
pickProbe.surface,
|
||||||
|
);
|
||||||
|
const beforeCardPixels = await page.evaluate(paintedOverlayPixels);
|
||||||
|
await page.mouse.click(pickProbe.centre.x, pickProbe.centre.y);
|
||||||
|
await page.waitForFunction(
|
||||||
|
`(${paintedOverlayPixels})() > ${beforeCardPixels + 100}`,
|
||||||
|
{ timeout: 10000, polling: 250 },
|
||||||
|
);
|
||||||
|
const selectedCardPixels = await page.evaluate(paintedOverlayPixels);
|
||||||
|
record(
|
||||||
|
'PICK: actual perimeter click paints its incident card',
|
||||||
|
selectedCardPixels > beforeCardPixels + 100,
|
||||||
|
`${beforeCardPixels} → ${selectedCardPixels} pixels`,
|
||||||
|
);
|
||||||
|
await page.screenshot({
|
||||||
|
path: path.join(SHOTS_DIR, 'gnss-perimeter-selected.png'),
|
||||||
|
});
|
||||||
|
await page.mouse.click(pickProbe.empty.x, pickProbe.empty.y);
|
||||||
|
await page.waitForFunction(
|
||||||
|
`(${paintedOverlayPixels})() <= ${beforeCardPixels}`,
|
||||||
|
{ timeout: 10000, polling: 250 },
|
||||||
|
);
|
||||||
|
const clearedCardPixels = await page.evaluate(paintedOverlayPixels);
|
||||||
|
record(
|
||||||
|
'PICK: empty ground clears the perimeter card',
|
||||||
|
clearedCardPixels <= beforeCardPixels,
|
||||||
|
`${selectedCardPixels} → ${clearedCardPixels} pixels`,
|
||||||
|
);
|
||||||
|
await page.mouse.click(pickProbe.empty.x, pickProbe.empty.y, {
|
||||||
|
clickCount: 2,
|
||||||
|
});
|
||||||
|
await sleep(1000);
|
||||||
|
record(
|
||||||
|
'PICK: empty double-click does not zoom to a GNSS cell',
|
||||||
|
await page.evaluate(
|
||||||
|
(height) =>
|
||||||
|
Math.abs(
|
||||||
|
window.__godsEyeView.viewer.camera.positionCartographic.height -
|
||||||
|
height,
|
||||||
|
) < 200,
|
||||||
|
pickProbe.height,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
await window.__godsEyeView.dataManager.setEnabled(
|
||||||
|
'fire-perimeters',
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
console.log('\nCAMERA — automatic regional refresh without toggling...');
|
||||||
|
const beforeMove = await page.evaluate(
|
||||||
|
() => window.__gnssQaRequests.length,
|
||||||
|
);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const { viewer } = window.__godsEyeView;
|
||||||
|
viewer.camera.setView({
|
||||||
|
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
|
||||||
|
longitude: (139.7 * Math.PI) / 180,
|
||||||
|
latitude: (35.7 * Math.PI) / 180,
|
||||||
|
height: 1_100_000,
|
||||||
|
}),
|
||||||
|
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||||
|
});
|
||||||
|
viewer.scene.requestRender();
|
||||||
|
});
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => {
|
||||||
|
const url = window.__gnssQaRequests.at(-1);
|
||||||
|
return (
|
||||||
|
url &&
|
||||||
|
Math.abs(
|
||||||
|
Number(new URL(url, location.origin).searchParams.get('lon')) -
|
||||||
|
139.7,
|
||||||
|
) < 1
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ timeout: 15000 },
|
||||||
|
);
|
||||||
|
await sleep(3500);
|
||||||
|
const moved = await page.evaluate(
|
||||||
|
(id) => ({
|
||||||
|
requests: window.__gnssQaRequests.length,
|
||||||
|
enabled: window.__godsEyeView.dataManager.isEnabled(id),
|
||||||
|
coverage: window.__godsEyeView.dataManager.layers
|
||||||
|
.get(id)
|
||||||
|
.module.getStats().coverage,
|
||||||
|
info: document.querySelector(
|
||||||
|
`[data-layer-id="${id}"] .data-toggle-controls-info`,
|
||||||
|
)?.textContent,
|
||||||
|
}),
|
||||||
|
LAYER_ID,
|
||||||
|
);
|
||||||
|
record(
|
||||||
|
'CAMERA: moving to Tokyo automatically refreshes the new region and updates the row',
|
||||||
|
moved.enabled &&
|
||||||
|
moved.requests > beforeMove &&
|
||||||
|
moved.requests <= beforeMove + 2 &&
|
||||||
|
moved.coverage?.longitude === 140 &&
|
||||||
|
/Regional sample.*250 nm radius/.test(moved.info),
|
||||||
|
JSON.stringify(moved),
|
||||||
|
);
|
||||||
|
await shoot(page, 'camera-refresh');
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const { viewer } = window.__godsEyeView;
|
||||||
|
viewer.camera.setView({
|
||||||
|
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
|
||||||
|
longitude: (12.4 * Math.PI) / 180,
|
||||||
|
latitude: (41.9 * Math.PI) / 180,
|
||||||
|
height: 16_000_000,
|
||||||
|
}),
|
||||||
|
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||||
|
});
|
||||||
|
viewer.scene.requestRender();
|
||||||
|
});
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => {
|
||||||
|
const url = window.__gnssQaRequests.at(-1);
|
||||||
|
const params = url && new URL(url, location.origin).searchParams;
|
||||||
|
return (
|
||||||
|
params &&
|
||||||
|
Math.abs(Number(params.get('lon')) - 12.4) < 1 &&
|
||||||
|
Math.abs(Number(params.get('lat')) - 41.9) < 1
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ timeout: 15000 },
|
||||||
|
);
|
||||||
|
const world = await page.evaluate((id) => {
|
||||||
|
const { viewer, dataManager } = window.__godsEyeView;
|
||||||
|
const bounds = viewer.camera.computeViewRectangle(
|
||||||
|
viewer.scene.globe.ellipsoid,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
enabled: dataManager.isEnabled(id),
|
||||||
|
height: viewer.camera.positionCartographic.height,
|
||||||
|
boundsMidLongitude:
|
||||||
|
bounds && ((bounds.west + bounds.east) * 90) / Math.PI,
|
||||||
|
coverage: dataManager.layers.get(id).module.getStats().coverage,
|
||||||
|
};
|
||||||
|
}, LAYER_ID);
|
||||||
|
record(
|
||||||
|
'GLOBE: zoomed-out sampling stays centred on Rome and exposes its regional radius',
|
||||||
|
world.enabled &&
|
||||||
|
world.height > 15_000_000 &&
|
||||||
|
world.coverage?.longitude === 12 &&
|
||||||
|
world.coverage?.latitude === 42 &&
|
||||||
|
world.coverage?.radiusNm === 250,
|
||||||
|
JSON.stringify(world),
|
||||||
|
);
|
||||||
|
await shoot(page, 'globe-regional');
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const { viewer } = window.__godsEyeView;
|
||||||
|
viewer.camera.setView({
|
||||||
|
destination: viewer.scene.globe.ellipsoid.cartographicToCartesian({
|
||||||
|
longitude: (18.6 * Math.PI) / 180,
|
||||||
|
latitude: (54.6 * Math.PI) / 180,
|
||||||
|
height: 450_000,
|
||||||
|
}),
|
||||||
|
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||||
|
});
|
||||||
|
viewer.scene.requestRender();
|
||||||
|
});
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => {
|
||||||
|
const url = window.__gnssQaRequests.at(-1);
|
||||||
|
return (
|
||||||
|
url &&
|
||||||
|
Math.abs(
|
||||||
|
Number(new URL(url, location.origin).searchParams.get('lon')) -
|
||||||
|
18.6,
|
||||||
|
) < 1
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ timeout: 15000 },
|
||||||
|
);
|
||||||
|
await sleep(3500);
|
||||||
|
|
||||||
|
// ── (iii) FAILURE ─────────────────────────────────────────────────────
|
||||||
|
console.log('\n(iii) FAILURE — /api/gnss-integrity stubbed to 502...');
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.__gnssQaMode = 'error';
|
||||||
|
});
|
||||||
|
const e = await enableAndSettle(page, { refresh: true });
|
||||||
|
record(
|
||||||
|
'FAILURE: error surfaced, cells kept',
|
||||||
|
Boolean(e.stats.error) && e.stats.count === 4 && e.entities === 4,
|
||||||
|
`error=${JSON.stringify(e.stats.error)} cells=${e.stats.count}`,
|
||||||
|
);
|
||||||
|
await shoot(page, 'failure');
|
||||||
|
|
||||||
|
// ── (iv) DISABLE ──────────────────────────────────────────────────────
|
||||||
|
console.log('\n(iv) DISABLE...');
|
||||||
|
const shown = await page.evaluate(async (id) => {
|
||||||
|
await window.__godsEyeView.dataManager.setEnabled(id, false);
|
||||||
|
const all = window.__godsEyeView.viewer.scene.groundPrimitives;
|
||||||
|
return Array.from({ length: all.length }, (_, i) => all.get(i)).find(
|
||||||
|
(item) => item.id === id,
|
||||||
|
)?.show;
|
||||||
|
}, LAYER_ID);
|
||||||
|
record('DISABLE: data source hidden', shown === false, `show=${shown}`);
|
||||||
|
await shoot(page, 'disabled');
|
||||||
|
|
||||||
|
// ── (v) KEYBOARD ──────────────────────────────────────────────────────
|
||||||
|
console.log('\n(v) KEYBOARD — the row toggle, from the keyboard...');
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.__gnssQaMode = 'fixture';
|
||||||
|
});
|
||||||
|
await showLayerRow(page);
|
||||||
|
const on = await pressToggle(page, 'Enter', true);
|
||||||
|
record(
|
||||||
|
'KEYBOARD: Enter on the focused toggle enables the layer and keeps focus',
|
||||||
|
on.enabled &&
|
||||||
|
on.focused &&
|
||||||
|
on.count === 4 &&
|
||||||
|
/^GNSS Integrity: /.test(on.label) &&
|
||||||
|
!/OFF$/.test(on.label),
|
||||||
|
JSON.stringify(on),
|
||||||
|
);
|
||||||
|
const off = await pressToggle(page, ' ', false);
|
||||||
|
record(
|
||||||
|
'KEYBOARD: Space on the focused toggle disables it and the label says OFF',
|
||||||
|
!off.enabled && off.focused && /^GNSS Integrity: OFF$/.test(off.label),
|
||||||
|
JSON.stringify(off),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── (vi) VIEWPORT ─────────────────────────────────────────────────────
|
||||||
|
console.log(
|
||||||
|
'\n(vi) VIEWPORT — the open row on a 390x844 phone viewport...',
|
||||||
|
);
|
||||||
|
await page.close();
|
||||||
|
// A fresh page: switching an open page to isMobile reloads it, which
|
||||||
|
// would measure a half-booted app instead of the open row.
|
||||||
|
const phone = await browser.newPage();
|
||||||
|
await phone.setViewport({ width: 390, height: 844, isMobile: true });
|
||||||
|
phone.on('pageerror', (e) => errors.push(e.message));
|
||||||
|
await stubRoute(phone);
|
||||||
|
await boot(phone, { lon: 18.6, lat: 54.6, height: 450_000 });
|
||||||
|
await showLayerRow(phone);
|
||||||
|
await pressToggle(phone, 'Enter', true);
|
||||||
|
await showLayerRow(phone);
|
||||||
|
const fit = await phone.evaluate((id) => {
|
||||||
|
const row = document.querySelector(`[data-layer-id="${id}"]`);
|
||||||
|
const legend = [...row.querySelectorAll('.data-toggle-legend-item')];
|
||||||
|
const rect = row.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
width: window.innerWidth,
|
||||||
|
rowLeft: Math.round(rect.left),
|
||||||
|
rowRight: Math.round(rect.right),
|
||||||
|
overflow: row.scrollWidth - row.clientWidth,
|
||||||
|
legendItems: legend.length,
|
||||||
|
coverageInfo: row.querySelector('.data-toggle-controls-info')
|
||||||
|
?.textContent,
|
||||||
|
legendInside: legend.every((item) => {
|
||||||
|
const r = item.getBoundingClientRect();
|
||||||
|
return (
|
||||||
|
r.width > 0 && r.left >= rect.left - 1 && r.right <= rect.right + 1
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}, LAYER_ID);
|
||||||
|
record(
|
||||||
|
'VIEWPORT: row and legend fit a 390 px viewport without horizontal overflow',
|
||||||
|
fit.rowLeft >= 0 &&
|
||||||
|
fit.rowRight <= fit.width &&
|
||||||
|
fit.overflow <= 0 &&
|
||||||
|
fit.legendItems === 3 &&
|
||||||
|
/Regional sample.*250 nm radius/.test(fit.coverageInfo) &&
|
||||||
|
fit.legendInside,
|
||||||
|
JSON.stringify(fit),
|
||||||
|
);
|
||||||
|
await phone.screenshot({ path: path.join(SHOTS_DIR, 'gnss-phone.png') });
|
||||||
|
await phone.close();
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
'\nSTARTUP — initial rate limit stays enabled and recovers automatically...',
|
||||||
|
);
|
||||||
|
const retry = await browser.newPage();
|
||||||
|
await retry.setViewport({ width: 1440, height: 900 });
|
||||||
|
retry.on('pageerror', (e) => errors.push(e.message));
|
||||||
|
// Pages share the disposable test context's storage. Start this one cold,
|
||||||
|
// so a previous fixture cannot auto-enable GNSS before the START test.
|
||||||
|
await retry.evaluateOnNewDocument(() => localStorage.clear());
|
||||||
|
await stubRoute(retry, { initialFailures: 1, stale: false });
|
||||||
|
await boot(retry, { lon: 18.6, lat: 54.6, height: 450_000 });
|
||||||
|
await showLayerRow(retry);
|
||||||
|
await retry.evaluate((id) => {
|
||||||
|
window.__gnssQaFailedStarts = 0;
|
||||||
|
window.__godsEyeView.dataManager.subscribe((change) => {
|
||||||
|
if (change.layerId === id && change.type === 'visibility-failed')
|
||||||
|
window.__gnssQaFailedStarts++;
|
||||||
|
});
|
||||||
|
}, LAYER_ID);
|
||||||
|
const initial = await enableAndSettle(retry);
|
||||||
|
const limited = await retry.evaluate((id) => {
|
||||||
|
const dm = window.__godsEyeView.dataManager;
|
||||||
|
const row = document.querySelector(`[data-layer-id="${id}"]`);
|
||||||
|
return {
|
||||||
|
enabled: dm.isEnabled(id),
|
||||||
|
failedStarts: window.__gnssQaFailedStarts,
|
||||||
|
label: row.querySelector('.data-toggle-btn').textContent,
|
||||||
|
info: row.querySelector('.data-toggle-controls-info').textContent,
|
||||||
|
};
|
||||||
|
}, LAYER_ID);
|
||||||
|
record(
|
||||||
|
'STARTUP: first 429 keeps the layer enabled and shows automatic retry timing',
|
||||||
|
limited.enabled &&
|
||||||
|
limited.failedStarts === 0 &&
|
||||||
|
limited.label === 'UNAVAILABLE' &&
|
||||||
|
initial.stats.count === 0 &&
|
||||||
|
initial.stats.retryInSec > 0 &&
|
||||||
|
/rate limited.*Retrying automatically at/.test(limited.info),
|
||||||
|
JSON.stringify(limited),
|
||||||
|
);
|
||||||
|
await retry.screenshot({
|
||||||
|
path: path.join(SHOTS_DIR, 'gnss-startup-retrying.png'),
|
||||||
|
});
|
||||||
|
await retry.waitForFunction(
|
||||||
|
(id) => {
|
||||||
|
const layer = window.__godsEyeView.dataManager.layers.get(id);
|
||||||
|
return (
|
||||||
|
layer.enabled &&
|
||||||
|
layer.module.getStats().error === null &&
|
||||||
|
layer.module.getStats().count === 4
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ timeout: 20000 },
|
||||||
|
LAYER_ID,
|
||||||
|
);
|
||||||
|
const recovered = await retry.evaluate(
|
||||||
|
(id) => ({
|
||||||
|
requests: window.__gnssQaRequests.length,
|
||||||
|
enabled: window.__godsEyeView.dataManager.isEnabled(id),
|
||||||
|
label: document.querySelector(
|
||||||
|
`[data-layer-id="${id}"] .data-toggle-btn`,
|
||||||
|
).textContent,
|
||||||
|
info: document.querySelector(
|
||||||
|
`[data-layer-id="${id}"] .data-toggle-controls-info`,
|
||||||
|
).textContent,
|
||||||
|
}),
|
||||||
|
LAYER_ID,
|
||||||
|
);
|
||||||
|
record(
|
||||||
|
'STARTUP: one automatic retry recovers to ON without toggling and clears the warning',
|
||||||
|
recovered.enabled &&
|
||||||
|
recovered.requests === 2 &&
|
||||||
|
recovered.label === 'ON' &&
|
||||||
|
!/Retrying/.test(recovered.info),
|
||||||
|
JSON.stringify(recovered),
|
||||||
|
);
|
||||||
|
await shoot(retry, 'startup-recovered');
|
||||||
|
await retry.close();
|
||||||
|
|
||||||
|
// ── (vii) AGE ─────────────────────────────────────────────────────────
|
||||||
|
console.log(
|
||||||
|
'\n(vii) AGE — a replayed stale snapshot keeps its observation time...',
|
||||||
|
);
|
||||||
|
const aged = await browser.newPage();
|
||||||
|
await aged.setViewport({ width: 1440, height: 900 });
|
||||||
|
aged.on('pageerror', (e) => errors.push(e.message));
|
||||||
|
await stubRoute(aged, { ageMs: 31 * 60_000 });
|
||||||
|
await boot(aged, { lon: 18.6, lat: 54.6, height: 450_000 });
|
||||||
|
const old = await enableAndSettle(aged, {});
|
||||||
|
record(
|
||||||
|
'AGE: a snapshot observed 31 min ago adds no cells and is not an error',
|
||||||
|
old.stats.count === 0 && old.entities === 0 && !old.stats.error,
|
||||||
|
`cells=${old.stats.count} entities=${old.entities} error=${JSON.stringify(old.stats.error)}`,
|
||||||
|
);
|
||||||
|
await aged.evaluate(() => {
|
||||||
|
window.__gnssQaAgeMs = 29 * 60_000;
|
||||||
|
});
|
||||||
|
const recent = await enableAndSettle(aged, { refresh: true });
|
||||||
|
record(
|
||||||
|
'AGE: the same snapshot observed 29 min ago is still inside the window',
|
||||||
|
recent.stats.count === 4 && recent.entities === 4,
|
||||||
|
`cells=${recent.stats.count} entities=${recent.entities}`,
|
||||||
|
);
|
||||||
|
await aged.close();
|
||||||
|
record(
|
||||||
|
'no uncaught browser errors',
|
||||||
|
errors.length === 0,
|
||||||
|
errors.slice(0, 3).join(' | '),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('\x1b[31mHarness error:\x1b[0m', err);
|
||||||
|
exitCode = 3;
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
const pass = results.filter((r) => r.ok).length;
|
||||||
|
const fail = results.length - pass;
|
||||||
|
console.log('\n' + '─'.repeat(60));
|
||||||
|
console.log(` RESULT: ${pass} passed, ${fail} failed`);
|
||||||
|
console.log(` Shots : ${SHOTS_DIR}/gnss-*.png`);
|
||||||
|
console.log('─'.repeat(60) + '\n');
|
||||||
|
process.exit(exitCode || (fail > 0 ? 1 : 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(3);
|
||||||
|
});
|
||||||
@@ -2006,8 +2006,9 @@ async function runBrowserGroup(record) {
|
|||||||
for (let i = 0; i < 20; i += 1) {
|
for (let i = 0; i < 20; i += 1) {
|
||||||
// eslint-disable-next-line no-await-in-loop
|
// eslint-disable-next-line no-await-in-loop
|
||||||
await new Promise((r) => setTimeout(r, 500));
|
await new Promise((r) => setTimeout(r, 500));
|
||||||
const status = document.getElementById('gev-voice-status')?.textContent?.trim() || '';
|
// The label is translated; the machine state is on the control.
|
||||||
if (status && status !== 'CONNECTING') break;
|
const status = document.getElementById('gev-voice-control')?.dataset.status || '';
|
||||||
|
if (status && status !== 'connecting') break;
|
||||||
}
|
}
|
||||||
let diag = null;
|
let diag = null;
|
||||||
try { diag = vc.getDiagnostics?.(); } catch { /* not started */ }
|
try { diag = vc.getDiagnostics?.(); } catch { /* not started */ }
|
||||||
|
|||||||
@@ -84,6 +84,63 @@ try {
|
|||||||
() => window.__godsEyeView.styleManager._activePoiIndex === 1,
|
() => window.__godsEyeView.styleManager._activePoiIndex === 1,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
const geocoderRequests = [];
|
||||||
|
const recordGeocoderRequest = (request) => {
|
||||||
|
const url = new URL(request.url());
|
||||||
|
if (
|
||||||
|
url.pathname === '/api/geocode' ||
|
||||||
|
url.pathname === '/api/google/geocode' ||
|
||||||
|
(url.hostname === 'photon.komoot.io' && url.pathname === '/api/')
|
||||||
|
)
|
||||||
|
geocoderRequests.push(request.url());
|
||||||
|
};
|
||||||
|
page.on('request', recordGeocoderRequest);
|
||||||
|
await page.$eval('#location-search', (input) => {
|
||||||
|
input.value = 'geo:39.7392,-104.9903';
|
||||||
|
input.focus();
|
||||||
|
});
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => {
|
||||||
|
const state =
|
||||||
|
window.__godsEyeView.styleManager._locationLookup.getState();
|
||||||
|
return (
|
||||||
|
state.query === 'geo:39.7392,-104.9903' && state.status === 'found'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ timeout: 30000 },
|
||||||
|
);
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => !window.__godsEyeView.viewer.camera._currentFlight,
|
||||||
|
{ timeout: 10000 },
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'native geo URI search shows the decimal coordinate readout',
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const ui = window.__godsEyeView.styleManager;
|
||||||
|
return (
|
||||||
|
ui._searchedLocationLabel === '39.7392° N, 104.9903° W' &&
|
||||||
|
ui._locationMiniCity.textContent.includes('39.7392° N') &&
|
||||||
|
ui._locationMiniPoi.textContent === '104.9903° W'
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'geo URI navigation settles near the requested coordinate',
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const position = window.__godsEyeView.viewer.camera.positionCartographic;
|
||||||
|
const degrees = 180 / Math.PI;
|
||||||
|
return (
|
||||||
|
Math.abs(position.latitude * degrees - 39.7392) < 0.05 &&
|
||||||
|
Math.abs(position.longitude * degrees + 104.9903) < 0.05
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'geo URI search makes no geocoder request',
|
||||||
|
geocoderRequests.length === 0,
|
||||||
|
);
|
||||||
|
page.off('request', recordGeocoderRequest);
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
const lookup = window.__godsEyeView.styleManager._locationLookup;
|
const lookup = window.__godsEyeView.styleManager._locationLookup;
|
||||||
window.__qaSearchRequests = [];
|
window.__qaSearchRequests = [];
|
||||||
@@ -96,6 +153,7 @@ try {
|
|||||||
window.__qaSearchRequests.push({ query, options, resolve }),
|
window.__qaSearchRequests.push({ query, options, resolve }),
|
||||||
);
|
);
|
||||||
document.getElementById('location-search').value = 'First';
|
document.getElementById('location-search').value = 'First';
|
||||||
|
document.getElementById('location-search').focus();
|
||||||
});
|
});
|
||||||
await page.keyboard.press('Enter');
|
await page.keyboard.press('Enter');
|
||||||
await page.waitForFunction(() => window.__qaSearchRequests.length === 1);
|
await page.waitForFunction(() => window.__qaSearchRequests.length === 1);
|
||||||
|
|||||||
@@ -167,6 +167,13 @@ async function main() {
|
|||||||
page.on('request', (request) => {
|
page.on('request', (request) => {
|
||||||
const url = new URL(request.url());
|
const url = new URL(request.url());
|
||||||
|
|
||||||
|
// Radio QA uses keyless maps; an optional Google token is not offered.
|
||||||
|
if (request.method() === 'GET' && url.origin === APP_ORIGIN
|
||||||
|
&& url.pathname === '/api/google/tiles-token') {
|
||||||
|
request.respond({ status: 200, contentType: 'application/json', body: '{}' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// These scenarios exercise Context lifecycle and keyboard ownership, not
|
// These scenarios exercise Context lifecycle and keyboard ownership, not
|
||||||
// live orbit accuracy. Reuse the tracking suite's fixed element sets so
|
// live orbit accuracy. Reuse the tracking suite's fixed element sets so
|
||||||
// CelesTrak outages cannot invalidate an otherwise clean UI run.
|
// CelesTrak outages cannot invalidate an otherwise clean UI run.
|
||||||
|
|||||||
@@ -12,16 +12,22 @@
|
|||||||
* Run: start the app (`./scripts/dev-fresh.sh`) and
|
* Run: start the app (`./scripts/dev-fresh.sh`) and
|
||||||
* node scripts/qa-recent-imagery.mjs
|
* node scripts/qa-recent-imagery.mjs
|
||||||
* Env: QA_BASE_URL (default http://localhost:4173), QA_SHOT_DIR (default
|
* Env: QA_BASE_URL (default http://localhost:4173), QA_SHOT_DIR (default
|
||||||
* qa-shots/recent-imagery), QA_HEADFUL=1. Every wait is bounded. Without
|
* qa-shots/recent-imagery), QA_HEADFUL=1.
|
||||||
* network access to NASA the run skips with exit code 0.
|
* QA_RECENT_IMAGERY_SLOW_RENDER=1 permits loaded SwiftShader CI to use a
|
||||||
|
* 4 s ZOOM IN wait and 0.8 s height-stability tail (defaults: 2 s / 1.2 s).
|
||||||
|
* Every wait is bounded. Without NASA access the run skips with code 0.
|
||||||
*/
|
*/
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import puppeteer from 'puppeteer';
|
import puppeteer from 'puppeteer';
|
||||||
|
import { chromeExecutablePath } from './qa-chrome.mjs';
|
||||||
|
|
||||||
const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:4173';
|
const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:4173';
|
||||||
const SHOT_DIR = process.env.QA_SHOT_DIR || 'qa-shots/recent-imagery';
|
const SHOT_DIR = process.env.QA_SHOT_DIR || 'qa-shots/recent-imagery';
|
||||||
const HEADFUL = process.env.QA_HEADFUL === '1';
|
const HEADFUL = process.env.QA_HEADFUL === '1';
|
||||||
|
const SLOW_RENDER = process.env.QA_RECENT_IMAGERY_SLOW_RENDER === '1';
|
||||||
|
const ZOOM_TIMEOUT_MS = SLOW_RENDER ? 4_000 : 2_000;
|
||||||
|
const HEIGHT_TAIL_SAMPLES = SLOW_RENDER ? 8 : 12;
|
||||||
const STEP_TIMEOUT = 20_000;
|
const STEP_TIMEOUT = 20_000;
|
||||||
const AUSTIN = { lon: -97.74, lat: 30.27, height: 12_000 };
|
const AUSTIN = { lon: -97.74, lat: 30.27, height: 12_000 };
|
||||||
const PANEL = '#recent-imagery-panel';
|
const PANEL = '#recent-imagery-panel';
|
||||||
@@ -49,8 +55,7 @@ const browser = await puppeteer.launch({
|
|||||||
headless: !HEADFUL,
|
headless: !HEADFUL,
|
||||||
...(HEADFUL
|
...(HEADFUL
|
||||||
? {
|
? {
|
||||||
executablePath:
|
executablePath: chromeExecutablePath(),
|
||||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
args: [
|
args: [
|
||||||
@@ -308,15 +313,21 @@ const CONTROLS = {
|
|||||||
};
|
};
|
||||||
const measure = () =>
|
const measure = () =>
|
||||||
page.evaluate((controls) => {
|
page.evaluate((controls) => {
|
||||||
|
const panel = document.getElementById('recent-imagery-panel');
|
||||||
const body = document.getElementById('recent-imagery-panel-body');
|
const body = document.getElementById('recent-imagery-panel-body');
|
||||||
|
const panelRect = panel?.getBoundingClientRect();
|
||||||
const out = { bodyScroll: body?.scrollTop ?? null, rects: {} };
|
const out = { bodyScroll: body?.scrollTop ?? null, rects: {} };
|
||||||
for (const [name, selector] of Object.entries(controls)) {
|
for (const [name, selector] of Object.entries(controls)) {
|
||||||
const rect = document.querySelector(selector)?.getBoundingClientRect();
|
const rect = document.querySelector(selector)?.getBoundingClientRect();
|
||||||
out.rects[name] = rect
|
out.rects[name] =
|
||||||
? [rect.left, rect.top, rect.width, rect.height].map(
|
rect && panelRect
|
||||||
(v) => Math.round(v * 10) / 10,
|
? [
|
||||||
)
|
rect.left - panelRect.left,
|
||||||
: null;
|
rect.top - panelRect.top,
|
||||||
|
rect.width,
|
||||||
|
rect.height,
|
||||||
|
].map((v) => Math.round(v * 10) / 10)
|
||||||
|
: null;
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}, CONTROLS);
|
}, CONTROLS);
|
||||||
@@ -372,6 +383,9 @@ try {
|
|||||||
const initialStack = await page.evaluate(() => window.__riQa.activeStack());
|
const initialStack = await page.evaluate(() => window.__riQa.activeStack());
|
||||||
const photoreal = initialStack === 'photoreal';
|
const photoreal = initialStack === 'photoreal';
|
||||||
console.log(`initial map stack: ${initialStack}`);
|
console.log(`initial map stack: ${initialStack}`);
|
||||||
|
console.log(
|
||||||
|
`stability: ${SLOW_RENDER ? 'slow-render' : 'default'} (zoom ${ZOOM_TIMEOUT_MS} ms, last ${HEIGHT_TAIL_SAMPLES} samples)`,
|
||||||
|
);
|
||||||
const onPhotorealOnly = (name, passed, detail) =>
|
const onPhotorealOnly = (name, passed, detail) =>
|
||||||
photoreal
|
photoreal
|
||||||
? check(name, passed, detail)
|
? check(name, passed, detail)
|
||||||
@@ -1042,13 +1056,13 @@ try {
|
|||||||
) <=
|
) <=
|
||||||
target * 0.01,
|
target * 0.01,
|
||||||
fitHeight,
|
fitHeight,
|
||||||
2_000,
|
ZOOM_TIMEOUT_MS,
|
||||||
);
|
);
|
||||||
const zoomedHeight = await page.evaluate(
|
const zoomedHeight = await page.evaluate(
|
||||||
() => window.__godsEyeView.viewer.camera.positionCartographic.height,
|
() => window.__godsEyeView.viewer.camera.positionCartographic.height,
|
||||||
);
|
);
|
||||||
check(
|
check(
|
||||||
`ZOOM IN flies to ${Math.round(fitHeight / 1000)} km within 2 s`,
|
`ZOOM IN reaches target within ${ZOOM_TIMEOUT_MS / 1_000} s`,
|
||||||
zoomed,
|
zoomed,
|
||||||
`height=${Math.round(zoomedHeight)} target=${Math.round(fitHeight)}`,
|
`height=${Math.round(zoomedHeight)} target=${Math.round(fitHeight)}`,
|
||||||
);
|
);
|
||||||
@@ -1258,7 +1272,8 @@ try {
|
|||||||
);
|
);
|
||||||
await wait(100);
|
await wait(100);
|
||||||
}
|
}
|
||||||
const tail = (key) => new Set(samples.slice(8).map((s) => s[key]));
|
const tail = (key) =>
|
||||||
|
new Set(samples.slice(-HEIGHT_TAIL_SAMPLES).map((s) => s[key]));
|
||||||
return {
|
return {
|
||||||
settled: tail('imagery').size === 1 && tail('cctv').size === 1,
|
settled: tail('imagery').size === 1 && tail('cctv').size === 1,
|
||||||
detail: `imagery=${samples.map((s) => s.imagery).join(',')} cctv=${samples.map((s) => s.cctv).join(',')}`,
|
detail: `imagery=${samples.map((s) => s.imagery).join(',')} cctv=${samples.map((s) => s.cctv).join(',')}`,
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* qa-scene-dialog-file-drop — the SHARE SCENE dialog's file control accepts a
|
||||||
|
* file dropped from the desktop, proved in a real browser.
|
||||||
|
*
|
||||||
|
* The dialog draws a translated row over the native file input
|
||||||
|
* (src/ui/sceneSharing.js, fileChooser). The input must still cover the row,
|
||||||
|
* or drops miss it. Drops a file on the row's centre via CDP and expects
|
||||||
|
* `input.files` to hold it.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node scripts/qa-scene-dialog-file-drop.mjs
|
||||||
|
*
|
||||||
|
* Needs the repository's puppeteer (devDependencies). Exit status 0 when the
|
||||||
|
* drop selects the file, 1 otherwise.
|
||||||
|
*/
|
||||||
|
import http from 'node:http';
|
||||||
|
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import puppeteer from 'puppeteer';
|
||||||
|
|
||||||
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
const TYPES = {
|
||||||
|
'.js': 'text/javascript',
|
||||||
|
'.mjs': 'text/javascript',
|
||||||
|
'.json': 'application/json',
|
||||||
|
'.html': 'text/html',
|
||||||
|
};
|
||||||
|
|
||||||
|
const PAGE = `<!doctype html><html lang="en"><body>
|
||||||
|
<script type="module">
|
||||||
|
import { createSceneDialog } from '/src/ui/sceneSharing.js';
|
||||||
|
window.openDialog = () => {
|
||||||
|
const dialog = createSceneDialog('Share selected scene', () => {});
|
||||||
|
const files = dialog.input('Choose data-pack files', '', { type: 'file' });
|
||||||
|
files.multiple = true;
|
||||||
|
window.filesInput = files;
|
||||||
|
// Centre of the visible row (the wrapper's last child).
|
||||||
|
const row = files.parentElement.lastElementChild;
|
||||||
|
const r = row.getBoundingClientRect();
|
||||||
|
return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
|
||||||
|
};
|
||||||
|
window.ready = true;
|
||||||
|
</script></body></html>`;
|
||||||
|
|
||||||
|
async function dropOn(client, x, y, file) {
|
||||||
|
const data = { items: [], files: [file], dragOperationsMask: 1 };
|
||||||
|
for (const type of ['dragEnter', 'dragOver', 'drop']) {
|
||||||
|
await client.send('Input.dispatchDragEvent', { type, x, y, data });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const dir = await mkdtemp(path.join(tmpdir(), 'qa-scene-dialog-drop-'));
|
||||||
|
const pack = path.join(dir, 'pack.json');
|
||||||
|
await writeFile(pack, '{}');
|
||||||
|
const server = http.createServer(async (req, res) => {
|
||||||
|
if (req.url === '/') {
|
||||||
|
res.writeHead(200, { 'content-type': 'text/html' });
|
||||||
|
res.end(PAGE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const body = await readFile(path.join(ROOT, req.url));
|
||||||
|
res.writeHead(200, {
|
||||||
|
'content-type': TYPES[path.extname(req.url)] || 'text/plain',
|
||||||
|
});
|
||||||
|
res.end(body);
|
||||||
|
} catch {
|
||||||
|
res.writeHead(404).end();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||||
|
const url = `http://127.0.0.1:${server.address().port}/`;
|
||||||
|
const browser = await puppeteer.launch({ headless: true });
|
||||||
|
try {
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.setViewport({ width: 1000, height: 800 });
|
||||||
|
const errors = [];
|
||||||
|
page.on('pageerror', (error) => errors.push(String(error)));
|
||||||
|
await page.goto(url);
|
||||||
|
await page.waitForFunction(() => window.ready === true);
|
||||||
|
const point = await page.evaluate(() => window.openDialog());
|
||||||
|
const client = await page.createCDPSession();
|
||||||
|
await dropOn(client, point.x, point.y, pack);
|
||||||
|
const names = await page.evaluate(() =>
|
||||||
|
[...window.filesInput.files].map((file) => file.name),
|
||||||
|
);
|
||||||
|
console.log(JSON.stringify({ names, errors }));
|
||||||
|
if (errors.length) throw new Error(`page errors: ${errors.join('; ')}`);
|
||||||
|
if (names.length !== 1 || names[0] !== 'pack.json') {
|
||||||
|
throw new Error(
|
||||||
|
`expected the dropped pack.json to be selected, got ${JSON.stringify(names)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.log('OK: a file dropped on the visible control is selected');
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
server.close();
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
+183
-18
@@ -3,16 +3,17 @@
|
|||||||
* Browser QA for the Street Level layer against a running server:
|
* Browser QA for the Street Level layer against a running server:
|
||||||
* `npm run qa:street-level -- --url http://localhost:4173`.
|
* `npm run qa:street-level -- --url http://localhost:4173`.
|
||||||
*
|
*
|
||||||
* `--fixtures` (CI) answers every *.mapillary.com request and the coverage
|
* `--fixtures` (CI) answers every *.mapillary.com, KartaView and Panoramax
|
||||||
* tile route from fixtures, so nothing reaches Mapillary; the server still
|
* request and the coverage tile route from fixtures, so nothing reaches those
|
||||||
* needs a (dummy) token because the bundle carries it. `--strict` fails on any
|
* providers; the server still needs a (dummy) Mapillary token because the
|
||||||
|
* bundle carries it. `--strict` fails on any
|
||||||
* skip other than "fixtures only". A failed run saves evidence under
|
* skip other than "fixtures only". A failed run saves evidence under
|
||||||
* qa-artifacts/street-level/.
|
* qa-artifacts/street-level/.
|
||||||
*/
|
*/
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { pathToFileURL } from 'node:url';
|
import { pathToFileURL } from 'node:url';
|
||||||
import { tileBounds } from '../src/layers/streetLevel/tileMath.js';
|
import { tileBounds } from '../src/layers/streetLevel/tileMath.js';
|
||||||
import { encodeCoverageTile } from '../src/layers/streetLevel/coverageFixture.mjs';
|
import { encodeCoverageTile } from '../src/layers/streetLevel/providers/mapillary/coverageFixture.mjs';
|
||||||
import {
|
import {
|
||||||
hookRenderErrors,
|
hookRenderErrors,
|
||||||
readRenderErrors,
|
readRenderErrors,
|
||||||
@@ -26,6 +27,21 @@ import {
|
|||||||
PHOTO_SEQUENCE_ID,
|
PHOTO_SEQUENCE_ID,
|
||||||
photoImages,
|
photoImages,
|
||||||
} from './fixtures/street-level/mapillaryGraph.mjs';
|
} from './fixtures/street-level/mapillaryGraph.mjs';
|
||||||
|
import {
|
||||||
|
answerKartaViewRequest,
|
||||||
|
describeKartaViewCall,
|
||||||
|
KV_LINE,
|
||||||
|
KV_SEQUENCE_ID,
|
||||||
|
kartaViewPhotos,
|
||||||
|
resetKartaViewFixture,
|
||||||
|
} from './fixtures/street-level/kartaView.mjs';
|
||||||
|
import {
|
||||||
|
answerPanoramaxRequest,
|
||||||
|
describePanoramaxCall,
|
||||||
|
PNX_PARK,
|
||||||
|
panoramaxPictures,
|
||||||
|
resetPanoramaxFixture,
|
||||||
|
} from './fixtures/street-level/panoramax.mjs';
|
||||||
|
|
||||||
const VIEWPORT = { width: 1440, height: 900 };
|
const VIEWPORT = { width: 1440, height: 900 };
|
||||||
/** Over downtown Sacramento, above the fixture photo line, looking down. */
|
/** Over downtown Sacramento, above the fixture photo line, looking down. */
|
||||||
@@ -90,9 +106,24 @@ function fixtureTile(z, x, y, now = Date.now()) {
|
|||||||
return encodeCoverageTile({ x, y, z }, { sequences });
|
return encodeCoverageTile({ x, y, z }, { sequences });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fixture answers and call names per keyless provider host. */
|
||||||
|
const KEYLESS_HOSTS = [
|
||||||
|
{
|
||||||
|
host: /(^|\.)(kartaview|openstreetcam)\.org$/,
|
||||||
|
describe: describeKartaViewCall,
|
||||||
|
answer: answerKartaViewRequest,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
host: /^(api\.panoramax\.xyz|panoramax\.openstreetmap\.fr)$/,
|
||||||
|
describe: describePanoramaxCall,
|
||||||
|
answer: answerPanoramaxRequest,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Answer the page's Mapillary requests from `fixture`, which steps may change
|
* Answer the page's Mapillary, KartaView and Panoramax requests from
|
||||||
* mid-run. `configured: undefined` lets the server's real status route answer.
|
* `fixture`, which steps may change mid-run. `configured: undefined` lets the
|
||||||
|
* server's real status route answer.
|
||||||
*/
|
*/
|
||||||
async function serveFixtures(page, fixture) {
|
async function serveFixtures(page, fixture) {
|
||||||
// CDP Fetch on just these URLs: page-wide interception stalls Cesium workers.
|
// CDP Fetch on just these URLs: page-wide interception stalls Cesium workers.
|
||||||
@@ -101,6 +132,11 @@ async function serveFixtures(page, fixture) {
|
|||||||
patterns: [
|
patterns: [
|
||||||
{ urlPattern: '*://*.mapillary.com/*' },
|
{ urlPattern: '*://*.mapillary.com/*' },
|
||||||
{ urlPattern: '*/api/mapillary/*' },
|
{ urlPattern: '*/api/mapillary/*' },
|
||||||
|
{ urlPattern: '*://api.kartaview.org/*' },
|
||||||
|
{ urlPattern: '*://cdn.kartaview.org/*' },
|
||||||
|
{ urlPattern: '*://*.openstreetcam.org/*' },
|
||||||
|
{ urlPattern: '*://api.panoramax.xyz/*' },
|
||||||
|
{ urlPattern: '*://panoramax.openstreetmap.fr/*' },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
const json = (status, payload) => ({
|
const json = (status, payload) => ({
|
||||||
@@ -120,6 +156,16 @@ async function serveFixtures(page, fixture) {
|
|||||||
if (!answer.known) fixture.unknown.push(call);
|
if (!answer.known) fixture.unknown.push(call);
|
||||||
return answer;
|
return answer;
|
||||||
}
|
}
|
||||||
|
const keyless = KEYLESS_HOSTS.find(({ host }) =>
|
||||||
|
host.test(address.hostname),
|
||||||
|
);
|
||||||
|
if (keyless) {
|
||||||
|
const call = keyless.describe(method, address);
|
||||||
|
const answer = keyless.answer({ method, url: address.href });
|
||||||
|
fixture.calls.add(call);
|
||||||
|
if (!answer.known) fixture.unknown.push(call);
|
||||||
|
return answer;
|
||||||
|
}
|
||||||
if (address.pathname === '/api/mapillary/status')
|
if (address.pathname === '/api/mapillary/status')
|
||||||
return fixture.configured === undefined
|
return fixture.configured === undefined
|
||||||
? null
|
? null
|
||||||
@@ -196,6 +242,11 @@ const uiUntil = (page, test, arg = null, timeout = 30_000) =>
|
|||||||
{ timeout, polling: 100 },
|
{ timeout, polling: 100 },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** `id` is the one provider lit. */
|
||||||
|
const litAlone = (u, id) =>
|
||||||
|
u.providers.find((p) => p.id === id)?.on === true &&
|
||||||
|
u.providers.filter((p) => p.on).length === 1;
|
||||||
|
|
||||||
const getUI = (page) =>
|
const getUI = (page) =>
|
||||||
page.evaluate(() =>
|
page.evaluate(() =>
|
||||||
window.__godsEyeView.dataManager.layers
|
window.__godsEyeView.dataManager.layers
|
||||||
@@ -287,6 +338,7 @@ const findPick = (page, prefix, { box = null, step = 3 } = {}) =>
|
|||||||
x: rect.left + px,
|
x: rect.left + px,
|
||||||
y: rect.top + py,
|
y: rect.top + py,
|
||||||
local: { x: px, y: py },
|
local: { x: px, y: py },
|
||||||
|
id,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -301,17 +353,21 @@ const findPick = (page, prefix, { box = null, step = 3 } = {}) =>
|
|||||||
* worker after their tile decoded, so a scan right after LOADING clears can
|
* worker after their tile decoded, so a scan right after LOADING clears can
|
||||||
* come too early on a slow machine.
|
* come too early on a slow machine.
|
||||||
*/
|
*/
|
||||||
async function findPickUntil(page, prefix, { timeout = 30_000 } = {}) {
|
async function findPickUntil(
|
||||||
|
page,
|
||||||
|
prefix,
|
||||||
|
{ timeout = 30_000, box = null } = {},
|
||||||
|
) {
|
||||||
const deadline = Date.now() + timeout;
|
const deadline = Date.now() + timeout;
|
||||||
for (;;) {
|
for (;;) {
|
||||||
const hit = await findPick(page, prefix);
|
const hit = await findPick(page, prefix, { box });
|
||||||
if (hit || Date.now() >= deadline) return hit;
|
if (hit || Date.now() >= deadline) return hit;
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Park the camera over the photo line; a late startup flight can move it. */
|
/** Park the camera at `at`; a late startup flight can move it. */
|
||||||
async function park(page) {
|
async function park(page, at = PARK) {
|
||||||
for (let attempt = 0; attempt < 5; attempt++) {
|
for (let attempt = 0; attempt < 5; attempt++) {
|
||||||
const stayed = await page.evaluate(async (at) => {
|
const stayed = await page.evaluate(async (at) => {
|
||||||
const v = window.__godsEyeView.viewer;
|
const v = window.__godsEyeView.viewer;
|
||||||
@@ -329,10 +385,10 @@ async function park(page) {
|
|||||||
Math.abs((c.longitude * 180) / Math.PI - at.lon) < 0.001 &&
|
Math.abs((c.longitude * 180) / Math.PI - at.lon) < 0.001 &&
|
||||||
Math.abs((c.latitude * 180) / Math.PI - at.lat) < 0.001
|
Math.abs((c.latitude * 180) / Math.PI - at.lat) < 0.001
|
||||||
);
|
);
|
||||||
}, PARK);
|
}, at);
|
||||||
if (stayed) return;
|
if (stayed) return;
|
||||||
}
|
}
|
||||||
throw new Error('the camera would not stay parked over the photo line');
|
throw new Error(`the camera would not stay parked at ${JSON.stringify(at)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
@@ -353,14 +409,17 @@ async function main() {
|
|||||||
});
|
});
|
||||||
let count = 0;
|
let count = 0;
|
||||||
const skips = [];
|
const skips = [];
|
||||||
const step = async (label, fn) => {
|
|
||||||
await fn();
|
|
||||||
console.log(`ok ${++count} ${label}`);
|
|
||||||
};
|
|
||||||
const skip = (reason, label) => {
|
const skip = (reason, label) => {
|
||||||
skips.push(reason);
|
skips.push(reason);
|
||||||
console.log(`skip (${reason}) ${++count} ${label}`);
|
console.log(`skip (${reason}) ${++count} ${label}`);
|
||||||
};
|
};
|
||||||
|
// `fn` may call `skipped(reason)` when a live run has nothing to test.
|
||||||
|
const step = async (label, fn) => {
|
||||||
|
let reason = null;
|
||||||
|
await fn({ skipped: (why) => (reason = why) });
|
||||||
|
if (reason) skip(reason, label);
|
||||||
|
else console.log(`ok ${++count} ${label}`);
|
||||||
|
};
|
||||||
const fixtureStep = (label, fn) =>
|
const fixtureStep = (label, fn) =>
|
||||||
fixtures ? step(label, fn) : skip('fixtures only', label);
|
fixtures ? step(label, fn) : skip('fixtures only', label);
|
||||||
const errors = [];
|
const errors = [];
|
||||||
@@ -387,9 +446,78 @@ async function main() {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A keyless provider: lighting its chip darkens the others and draws its
|
||||||
|
* coverage with its credit; a click on a line opens a photo from it; ×
|
||||||
|
* closes it. Leaves that provider lit.
|
||||||
|
*/
|
||||||
|
const keylessProviderStep = ({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
at,
|
||||||
|
linePrefix,
|
||||||
|
box,
|
||||||
|
reset,
|
||||||
|
check,
|
||||||
|
}) =>
|
||||||
|
step(
|
||||||
|
`${name}: its chip draws keyless coverage and its credit, a line opens its photo, × closes it`,
|
||||||
|
async ({ skipped }) => {
|
||||||
|
reset();
|
||||||
|
await park(page, at);
|
||||||
|
await press(page, `#sl-provider-chips [data-chip-id="${id}"]`);
|
||||||
|
await uiUntil(page, litAlone, id);
|
||||||
|
await waitForCoverage(page);
|
||||||
|
await page.waitForFunction(
|
||||||
|
(credit) => document.body.innerHTML.includes(credit),
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
`${name}</a> contributors`,
|
||||||
|
);
|
||||||
|
const area =
|
||||||
|
box === 'column'
|
||||||
|
? await page.evaluate(() => {
|
||||||
|
const { height, width } =
|
||||||
|
window.__godsEyeView.viewer.scene.canvas.getBoundingClientRect();
|
||||||
|
return { x: Math.round(width * 0.3), y: 0, w: 1, h: height };
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
const line = await findPickUntil(page, linePrefix, { box: area });
|
||||||
|
if (!line && !fixtures) return skipped(`no ${name} line in view`);
|
||||||
|
assert.ok(line, `a ${name} line in view`);
|
||||||
|
await page.mouse.click(line.x, line.y);
|
||||||
|
await uiUntil(
|
||||||
|
page,
|
||||||
|
(u) => (u.street.imageId && !u.street.loading) || u.street.error,
|
||||||
|
null,
|
||||||
|
90_000,
|
||||||
|
);
|
||||||
|
const { street } = await getUI(page);
|
||||||
|
if (!fixtures && /No street-level imagery/.test(street.error || ''))
|
||||||
|
return skipped(`no ${name} photo near the line`);
|
||||||
|
assert.equal(street.error, null, `viewer error: ${street.error}`);
|
||||||
|
assert.equal(street.providerId, id);
|
||||||
|
assert.equal(
|
||||||
|
await page.$eval('#sl-viewer-wrap', (node) => node.hidden),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
if (fixtures)
|
||||||
|
check(street, line.id.slice(linePrefix.length).split('~')[0]);
|
||||||
|
await press(page, '#sl-viewer-close');
|
||||||
|
await uiUntil(
|
||||||
|
page,
|
||||||
|
(u) => !u.street.open && u.sequence.selectedId === null,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await page.$eval('#sl-viewer-wrap', (node) => node.hidden),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let page = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const fixture = newFixture();
|
const fixture = newFixture();
|
||||||
const page = await open('main', fixtures && fixture);
|
page = await open('main', fixtures && fixture);
|
||||||
const { configured } = await page.evaluate(() =>
|
const { configured } = await page.evaluate(() =>
|
||||||
fetch('/api/mapillary/status').then((res) => res.json()),
|
fetch('/api/mapillary/status').then((res) => res.json()),
|
||||||
);
|
);
|
||||||
@@ -543,6 +671,43 @@ async function main() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await keylessProviderStep({
|
||||||
|
id: 'kartaview',
|
||||||
|
name: 'KartaView',
|
||||||
|
at: PARK,
|
||||||
|
linePrefix: 'kv:line:',
|
||||||
|
// A row across the middle crosses the drive, west of the old street.
|
||||||
|
box: null,
|
||||||
|
reset: resetKartaViewFixture,
|
||||||
|
check: (street) => {
|
||||||
|
assert.equal(street.sequenceId, KV_SEQUENCE_ID);
|
||||||
|
assert.ok(
|
||||||
|
kartaViewPhotos().some((photo) => photo.id === street.imageId),
|
||||||
|
`fixture photo ${street.imageId}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await keylessProviderStep({
|
||||||
|
id: 'panoramax',
|
||||||
|
name: 'Panoramax',
|
||||||
|
at: PNX_PARK,
|
||||||
|
linePrefix: 'pnx:seq:',
|
||||||
|
// The street runs east-west: scan a column left of the middle.
|
||||||
|
box: 'column',
|
||||||
|
reset: resetPanoramaxFixture,
|
||||||
|
check: (street, lineId) => {
|
||||||
|
assert.equal(street.sequenceId, lineId, 'the line clicked');
|
||||||
|
assert.ok(
|
||||||
|
panoramaxPictures().some((photo) => photo.id === street.imageId),
|
||||||
|
`fixture picture ${street.imageId}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// Back to Mapillary, where the next page and a reload expect it.
|
||||||
|
await press(page, '#sl-provider-chips [data-chip-id="mapillary"]');
|
||||||
|
await uiUntil(page, litAlone, 'mapillary');
|
||||||
|
await park(page);
|
||||||
|
|
||||||
await fixtureStep(
|
await fixtureStep(
|
||||||
'keyless server (second page): KEY REQUIRED gates the controls',
|
'keyless server (second page): KEY REQUIRED gates the controls',
|
||||||
async () => {
|
async () => {
|
||||||
@@ -558,7 +723,7 @@ async function main() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await fixtureStep(
|
await fixtureStep(
|
||||||
'every Mapillary request was answered from a fixture',
|
'every Mapillary, KartaView and Panoramax request was answered from a fixture',
|
||||||
() => {
|
() => {
|
||||||
console.log(` (answered: ${[...fixture.calls].sort().join(' | ')})`);
|
console.log(` (answered: ${[...fixture.calls].sort().join(' | ')})`);
|
||||||
assert.deepEqual(fixture.unknown, []);
|
assert.deepEqual(fixture.unknown, []);
|
||||||
|
|||||||
@@ -139,15 +139,19 @@ async function main() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(APP_URL);
|
const res = await fetch(APP_URL);
|
||||||
|
// Only the status matters; an unread body can hold the socket open.
|
||||||
|
await res.body?.cancel();
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`Dev server not reachable at ${APP_URL} (${e.message}).`);
|
console.error(`Dev server not reachable at ${APP_URL} (${e.message}).`);
|
||||||
process.exit(2);
|
process.exitCode = 2;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
const status = await fetch(`${APP_URL}/api/tomtom/status`).then((r) => r.json()).catch(() => null);
|
const status = await fetch(`${APP_URL}/api/tomtom/status`).then((r) => r.json()).catch(() => null);
|
||||||
if (!status?.hasKey) {
|
if (!status?.hasKey) {
|
||||||
console.error('Server has no TomTom key — the A/B needs live flow.');
|
console.error('Server has no TomTom key — the A/B needs live flow.');
|
||||||
process.exit(2);
|
process.exitCode = 2;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
fs.mkdirSync(OUT_DIR, { recursive: true });
|
fs.mkdirSync(OUT_DIR, { recursive: true });
|
||||||
|
|||||||
@@ -145,15 +145,19 @@ async function main() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(APP_URL);
|
const res = await fetch(APP_URL);
|
||||||
|
// Only the status matters; an unread body can hold the socket open.
|
||||||
|
await res.body?.cancel();
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`Dev server not reachable at ${APP_URL} (${e.message}).`);
|
console.error(`Dev server not reachable at ${APP_URL} (${e.message}).`);
|
||||||
process.exit(2);
|
process.exitCode = 2;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
const status = await fetch(`${APP_URL}/api/tomtom/status`).then((r) => r.json()).catch(() => null);
|
const status = await fetch(`${APP_URL}/api/tomtom/status`).then((r) => r.json()).catch(() => null);
|
||||||
if (!status?.hasKey) {
|
if (!status?.hasKey) {
|
||||||
console.error('Server has no TomTom key — the A/B needs live flow.');
|
console.error('Server has no TomTom key — the A/B needs live flow.');
|
||||||
process.exit(2);
|
process.exitCode = 2;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
fs.mkdirSync(OUT_DIR, { recursive: true });
|
fs.mkdirSync(OUT_DIR, { recursive: true });
|
||||||
|
|||||||
@@ -113,17 +113,21 @@ async function main() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(APP_URL);
|
const res = await fetch(APP_URL);
|
||||||
|
// Only the status matters; an unread body can hold the socket open.
|
||||||
|
await res.body?.cancel();
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`\x1b[31mDev server not reachable at ${APP_URL} (${e.message}).\x1b[0m`);
|
console.error(`\x1b[31mDev server not reachable at ${APP_URL} (${e.message}).\x1b[0m`);
|
||||||
process.exit(2);
|
process.exitCode = 2;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusBefore = FIXTURES ? {hasKey:true,dailyCount:0} : await fetch(`${APP_URL}/api/tomtom/status`).then((r) => r.json()).catch(() => null);
|
const statusBefore = FIXTURES ? {hasKey:true,dailyCount:0} : await fetch(`${APP_URL}/api/tomtom/status`).then((r) => r.json()).catch(() => null);
|
||||||
if (FIXTURES) console.log('Source mode: synthetic roads and recorded flow tiles; live quota is not qualified.');
|
if (FIXTURES) console.log('Source mode: synthetic roads and recorded flow tiles; live quota is not qualified.');
|
||||||
if (!statusBefore?.hasKey) {
|
if (!statusBefore?.hasKey) {
|
||||||
console.error('\x1b[31mServer has no TomTom key — run against the keyed dev server (:4410).\x1b[0m');
|
console.error('\x1b[31mServer has no TomTom key — run against the keyed dev server (:4410).\x1b[0m');
|
||||||
process.exit(2);
|
process.exitCode = 2;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
fs.mkdirSync(SHOTS_DIR, { recursive: true });
|
fs.mkdirSync(SHOTS_DIR, { recursive: true });
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import puppeteer from 'puppeteer';
|
import puppeteer from 'puppeteer';
|
||||||
|
import { chromeExecutablePath } from './qa-chrome.mjs';
|
||||||
|
|
||||||
/** Prove parked and moving headings in the real scene, without live feeds. */
|
/** Prove parked and moving headings in the real scene, without live feeds. */
|
||||||
export async function runTransitHeadingRegression(base, check) {
|
export async function runTransitHeadingRegression(base, check) {
|
||||||
const browser = await puppeteer.launch({
|
const browser = await puppeteer.launch({
|
||||||
headless: true,
|
headless: true,
|
||||||
executablePath:
|
executablePath: chromeExecutablePath(),
|
||||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
||||||
args: ['--no-sandbox', '--disable-background-timer-throttling'],
|
args: ['--no-sandbox', '--disable-background-timer-throttling'],
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/** Camera-jump regression against an existing server; never starts a server. */
|
/** Camera-jump regression against an existing server; never starts a server. */
|
||||||
import puppeteer from 'puppeteer';
|
import puppeteer from 'puppeteer';
|
||||||
|
import { chromeExecutablePath } from './qa-chrome.mjs';
|
||||||
import { mkdir, writeFile } from 'node:fs/promises';
|
import { mkdir, writeFile } from 'node:fs/promises';
|
||||||
import { reduceTrailPixels } from '../src/layers/transit/qaMetrics.js';
|
import { reduceTrailPixels } from '../src/layers/transit/qaMetrics.js';
|
||||||
|
|
||||||
@@ -8,8 +9,7 @@ const shots = process.env.QA_SHOTS || 'qa-shots/transit-recovery';
|
|||||||
await mkdir(shots, { recursive: true });
|
await mkdir(shots, { recursive: true });
|
||||||
const browser = await puppeteer.launch({
|
const browser = await puppeteer.launch({
|
||||||
headless: false,
|
headless: false,
|
||||||
executablePath:
|
executablePath: chromeExecutablePath(),
|
||||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
||||||
args: [
|
args: [
|
||||||
'--disable-background-timer-throttling',
|
'--disable-background-timer-throttling',
|
||||||
'--disable-renderer-backgrounding',
|
'--disable-renderer-backgrounding',
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
*/
|
*/
|
||||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||||
import puppeteer from 'puppeteer';
|
import puppeteer from 'puppeteer';
|
||||||
|
import { chromeExecutablePath } from './qa-chrome.mjs';
|
||||||
import { createTransitHistory } from '../server/providers/transitHistory.js';
|
import { createTransitHistory } from '../server/providers/transitHistory.js';
|
||||||
import { DETECTION_THEME_MAP } from '../src/overlays/worldOverlayTokens.js';
|
import { DETECTION_THEME_MAP } from '../src/overlays/worldOverlayTokens.js';
|
||||||
import { transitModeTier } from '../src/data/transitPresetStyle.js';
|
import { transitModeTier } from '../src/data/transitPresetStyle.js';
|
||||||
@@ -262,8 +263,7 @@ await mkdir(SHOTS, { recursive: true });
|
|||||||
const browser = await puppeteer.launch({
|
const browser = await puppeteer.launch({
|
||||||
headless: false,
|
headless: false,
|
||||||
protocolTimeout: 150000,
|
protocolTimeout: 150000,
|
||||||
executablePath:
|
executablePath: chromeExecutablePath(),
|
||||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
||||||
args: [
|
args: [
|
||||||
'--no-sandbox',
|
'--no-sandbox',
|
||||||
'--disable-background-timer-throttling',
|
'--disable-background-timer-throttling',
|
||||||
|
|||||||
@@ -21,9 +21,20 @@ export function assertNode24AllocationRuntime(version = process.versions.node) {
|
|||||||
return version;
|
return version;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Directories this runner searches for `*.test.mjs`, in stable order.
|
||||||
|
*
|
||||||
|
* `server/` belongs here because provider modules own tests of their own: a
|
||||||
|
* suite placed beside the module it covers must run, and CI's unit-test job runs
|
||||||
|
* `npm test`, so a directory missing from this list is a suite that never runs
|
||||||
|
* there. `unitTestRunner.test.mjs` asserts the list covers every `*.test.mjs`
|
||||||
|
* outside dependencies, build output and dot-directories, so adding a tree
|
||||||
|
* without adding it here fails.
|
||||||
|
*/
|
||||||
|
export const UNIT_TEST_ROOTS = Object.freeze(['src', 'server']);
|
||||||
|
|
||||||
/** Discover repository unit tests in stable path order. */
|
/** Discover repository unit tests in stable path order. */
|
||||||
export function discoverUnitTestFiles(root = process.cwd()) {
|
export function discoverUnitTestFiles(root = process.cwd()) {
|
||||||
const sourceRoot = path.join(root, 'src');
|
|
||||||
const files = [];
|
const files = [];
|
||||||
const visit = (directory) => {
|
const visit = (directory) => {
|
||||||
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
||||||
@@ -34,7 +45,7 @@ export function discoverUnitTestFiles(root = process.cwd()) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
visit(sourceRoot);
|
for (const testRoot of UNIT_TEST_ROOTS) visit(path.join(root, testRoot));
|
||||||
return files.sort();
|
return files.sort();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -76,10 +76,15 @@ export function hasRequiredDependencies(rootDir = ROOT) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Return the npm command and spawn mode required by the target platform. */
|
/**
|
||||||
export function npmProcessSpec(platform = process.platform) {
|
* Return the npm command, arguments and spawn mode for the target platform.
|
||||||
const windows = platform === 'win32';
|
* Windows must run npm.cmd through a shell, and Node 24 deprecates passing an
|
||||||
return { command: windows ? 'npm.cmd' : 'npm', shell: windows };
|
* argument array with `shell: true` (DEP0190), so there the arguments, which
|
||||||
|
* are fixed npm subcommands, join the command string.
|
||||||
|
*/
|
||||||
|
export function npmProcessSpec(args, platform = process.platform) {
|
||||||
|
if (platform !== 'win32') return { command: 'npm', args, shell: false };
|
||||||
|
return { command: ['npm.cmd', ...args].join(' '), args: [], shell: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Read one key from Vite's dotenv file ladder without depending on Vite. */
|
/** Read one key from Vite's dotenv file ladder without depending on Vite. */
|
||||||
@@ -190,8 +195,8 @@ export function buildCapabilitySummary(
|
|||||||
|
|
||||||
export function inspectSetup({ includeKeychain = true, authoritativeEnvironment = false, rootDir = ROOT } = {}) {
|
export function inspectSetup({ includeKeychain = true, authoritativeEnvironment = false, rootDir = ROOT } = {}) {
|
||||||
const node = classifyNodeVersion();
|
const node = classifyNodeVersion();
|
||||||
const npm = npmProcessSpec();
|
const npm = npmProcessSpec(['--version']);
|
||||||
const npmResult = spawnSync(npm.command, ['--version'], {
|
const npmResult = spawnSync(npm.command, npm.args, {
|
||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
shell: npm.shell,
|
shell: npm.shell,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -443,7 +443,10 @@ async function main() {
|
|||||||
// for nearby names. Answer both with empty results: the entity-context
|
// for nearby names. Answer both with empty results: the entity-context
|
||||||
// assertions are about the SELECTED contact, and a live lookup would
|
// assertions are about the SELECTED contact, and a live lookup would
|
||||||
// make the run non-hermetic (and bill the owner's Google quota).
|
// make the run non-hermetic (and bill the owner's Google quota).
|
||||||
if (url.hostname === 'maps.googleapis.com' && url.pathname.startsWith('/maps/api/geocode')) {
|
if (
|
||||||
|
isAppRequest &&
|
||||||
|
(url.pathname === '/api/google/reverse-geocode' || url.pathname === '/api/google/geocode')
|
||||||
|
) {
|
||||||
return Promise.resolve(jsonResponse({ status: 'ZERO_RESULTS', results: [] }));
|
return Promise.resolve(jsonResponse({ status: 'ZERO_RESULTS', results: [] }));
|
||||||
}
|
}
|
||||||
if (isAppRequest && url.pathname === '/api/google/nearby-places') {
|
if (isAppRequest && url.pathname === '/api/google/nearby-places') {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { readResponseTextCapped } from '../common/http.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Vite plugin: adsb.lol military aircraft proxy with 12 s response cache.
|
* Vite plugin: adsb.lol military aircraft proxy with 12 s response cache.
|
||||||
*
|
*
|
||||||
@@ -25,6 +27,10 @@ export function adsbLolProxy() {
|
|||||||
let _cooldownStatus = 0;
|
let _cooldownStatus = 0;
|
||||||
/** Response cache TTL (ms). */
|
/** Response cache TTL (ms). */
|
||||||
const CACHE_MS = 12000;
|
const CACHE_MS = 12000;
|
||||||
|
/** Upstream deadline (ms); the fetch previously had none and could hang a poll. */
|
||||||
|
const UPSTREAM_TIMEOUT_MS = 12000;
|
||||||
|
/** /v2/mil measures ~67 KB; a relayed error page is bounded by the same cap. */
|
||||||
|
const ADSBLOL_MIL_MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
|
||||||
/** Cooldown after a 429 when upstream sends no usable Retry-After (ms). */
|
/** Cooldown after a 429 when upstream sends no usable Retry-After (ms). */
|
||||||
const RATE_LIMIT_COOLDOWN_MS = 30000;
|
const RATE_LIMIT_COOLDOWN_MS = 30000;
|
||||||
/** Cooldown after a 5xx (ms). */
|
/** Cooldown after a 5xx (ms). */
|
||||||
@@ -98,11 +104,17 @@ export function adsbLolProxy() {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const signal = AbortSignal.timeout(UPSTREAM_TIMEOUT_MS);
|
||||||
const upstream = await fetch('https://api.adsb.lol/v2/mil', {
|
const upstream = await fetch('https://api.adsb.lol/v2/mil', {
|
||||||
headers: { 'User-Agent': 'gods-eye-view-adsblol-proxy/1.0' },
|
headers: { 'User-Agent': 'gods-eye-view-adsblol-proxy/1.0' },
|
||||||
|
signal,
|
||||||
});
|
});
|
||||||
if (upstream.ok) {
|
if (upstream.ok) {
|
||||||
const body = await upstream.text();
|
const body = await readResponseTextCapped(
|
||||||
|
upstream,
|
||||||
|
ADSBLOL_MIL_MAX_RESPONSE_BYTES,
|
||||||
|
signal,
|
||||||
|
);
|
||||||
_cache = body;
|
_cache = body;
|
||||||
_cacheAt = Date.now();
|
_cacheAt = Date.now();
|
||||||
_cooldownUntil = 0;
|
_cooldownUntil = 0;
|
||||||
@@ -129,7 +141,11 @@ export function adsbLolProxy() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const body = await upstream.text();
|
const body = await readResponseTextCapped(
|
||||||
|
upstream,
|
||||||
|
ADSBLOL_MIL_MAX_RESPONSE_BYTES,
|
||||||
|
signal,
|
||||||
|
);
|
||||||
serve(
|
serve(
|
||||||
res,
|
res,
|
||||||
upstream.status,
|
upstream.status,
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { promises as fsp } from 'node:fs';
|
import { promises as fsp } from 'node:fs';
|
||||||
|
import { readResponseJsonCapped } from '../common/http.js';
|
||||||
|
|
||||||
|
/** Route and aircraft lookups are a few hundred bytes; 1 MiB is a hard bound. */
|
||||||
|
const ADSBDB_MAX_RESPONSE_BYTES = 1024 * 1024;
|
||||||
/**
|
/**
|
||||||
* adsbdb.com enrichment proxy: callsign → route (airline + origin/destination
|
* adsbdb.com enrichment proxy: callsign → route (airline + origin/destination
|
||||||
* airports) and hex → aircraft type/registration. Free community API — cached
|
* airports) and hex → aircraft type/registration. Free community API — cached
|
||||||
* aggressively: ONE upstream request per new key ever (404s negative-cached),
|
* aggressively: successes and 404s are kept per key for 24 hours (TTL_MS) and
|
||||||
* persisted to disk so restarts don't re-hammer it. Adapted from skylight
|
* persisted to disk so restarts don't re-hammer it; other failures are not
|
||||||
|
* cached. Adapted from skylight
|
||||||
* (MIT) server/src/enrich/routes.ts.
|
* (MIT) server/src/enrich/routes.ts.
|
||||||
*/
|
*/
|
||||||
export function adsbdbProxy() {
|
export function adsbdbProxy() {
|
||||||
@@ -80,12 +85,16 @@ export function adsbdbProxy() {
|
|||||||
kind === 'route'
|
kind === 'route'
|
||||||
? `https://api.adsbdb.com/v0/callsign/${encodeURIComponent(key)}`
|
? `https://api.adsbdb.com/v0/callsign/${encodeURIComponent(key)}`
|
||||||
: `https://api.adsbdb.com/v0/aircraft/${encodeURIComponent(key)}`;
|
: `https://api.adsbdb.com/v0/aircraft/${encodeURIComponent(key)}`;
|
||||||
const res = await fetch(url, { signal: AbortSignal.timeout(8000) });
|
const signal = AbortSignal.timeout(8000);
|
||||||
|
const res = await fetch(url, { signal });
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
|
const payload = await readResponseJsonCapped(
|
||||||
|
res,
|
||||||
|
ADSBDB_MAX_RESPONSE_BYTES,
|
||||||
|
signal,
|
||||||
|
);
|
||||||
const data =
|
const data =
|
||||||
kind === 'route'
|
kind === 'route' ? parseRoute(payload) : parseAircraft(payload);
|
||||||
? parseRoute(await res.json())
|
|
||||||
: parseAircraft(await res.json());
|
|
||||||
store[key] = { at: Date.now(), data }; // data may be null — negative cache
|
store[key] = { at: Date.now(), data }; // data may be null — negative cache
|
||||||
dirty = true;
|
dirty = true;
|
||||||
return data;
|
return data;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { normalizeAdsbLolPointResponse } from '../../../src/data/adsbLolFallback
|
|||||||
import {
|
import {
|
||||||
coalesceProxyRequest,
|
coalesceProxyRequest,
|
||||||
readResponseJsonCapped,
|
readResponseJsonCapped,
|
||||||
|
readResponseTextCapped,
|
||||||
} from '../common/http.js';
|
} from '../common/http.js';
|
||||||
import { requiredFiniteQueryNumber } from '../common/query.js';
|
import { requiredFiniteQueryNumber } from '../common/query.js';
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -29,11 +30,34 @@ const OPENSKY_CACHE_MS = 9000;
|
|||||||
const OPENSKY_ATTEMPT_TIMEOUT_MS = 10_000;
|
const OPENSKY_ATTEMPT_TIMEOUT_MS = 10_000;
|
||||||
const OPENSKY_STATES_URL =
|
const OPENSKY_STATES_URL =
|
||||||
'https://opensky-network.org/api/states/all?extended=1';
|
'https://opensky-network.org/api/states/all?extended=1';
|
||||||
|
/** states/all?extended=1 measures ~0.8 MB anonymously; bounded, not budgeted. */
|
||||||
|
const OPENSKY_STATES_MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Credit-governor cooldown for a 429: OpenSky's retry-after bounded to
|
||||||
|
* 30 s … 30 min, or 2 min when the header is absent.
|
||||||
|
* @param {Response} upstream Rate-limited response.
|
||||||
|
* @returns {number} Cooldown (ms).
|
||||||
|
*/
|
||||||
|
function openSkyRateLimitCooldownMs(upstream) {
|
||||||
|
// headers.get() gives null when absent, and Number(null) is 0, not NaN.
|
||||||
|
const header = upstream.headers.get('x-rate-limit-retry-after-seconds');
|
||||||
|
const retryAfterSec =
|
||||||
|
header == null || header.trim() === '' ? NaN : Number(header);
|
||||||
|
return Math.min(
|
||||||
|
Math.max(
|
||||||
|
Number.isFinite(retryAfterSec) ? retryAfterSec * 1000 : 120_000,
|
||||||
|
30_000,
|
||||||
|
),
|
||||||
|
30 * 60_000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch and read the global snapshot, giving each attempt
|
* Fetch and read the global snapshot, giving each attempt
|
||||||
* OPENSKY_ATTEMPT_TIMEOUT_MS and retrying once when an attempt times out or
|
* OPENSKY_ATTEMPT_TIMEOUT_MS and retrying once when an attempt times out or
|
||||||
* the connection fails. A second failure throws, which the proxy answers
|
* the connection fails. An oversized body is not retried: it would fail again
|
||||||
|
* and spend more credits. A second failure throws, which the proxy answers
|
||||||
* from its stale cache or the regional fallback.
|
* from its stale cache or the regional fallback.
|
||||||
* @param {Record<string, string>} headers Request headers.
|
* @param {Record<string, string>} headers Request headers.
|
||||||
* @returns {Promise<{upstream: Response, body: string}>}
|
* @returns {Promise<{upstream: Response, body: string}>}
|
||||||
@@ -41,13 +65,27 @@ const OPENSKY_STATES_URL =
|
|||||||
async function fetchOpenSkyStates(headers) {
|
async function fetchOpenSkyStates(headers) {
|
||||||
for (let attempt = 1; ; attempt++) {
|
for (let attempt = 1; ; attempt++) {
|
||||||
try {
|
try {
|
||||||
const upstream = await fetch(OPENSKY_STATES_URL, {
|
const signal = AbortSignal.timeout(OPENSKY_ATTEMPT_TIMEOUT_MS);
|
||||||
headers,
|
const upstream = await fetch(OPENSKY_STATES_URL, { headers, signal });
|
||||||
signal: AbortSignal.timeout(OPENSKY_ATTEMPT_TIMEOUT_MS),
|
if (upstream.status === 429) {
|
||||||
});
|
// Start the cooldown from the headers and drop the rejected body
|
||||||
return { upstream, body: await upstream.text() };
|
// unread, so a stalled or oversized 429 body cannot skip it.
|
||||||
|
_openskyCooldownUntil =
|
||||||
|
Date.now() + openSkyRateLimitCooldownMs(upstream);
|
||||||
|
void upstream.body?.cancel().catch(() => {});
|
||||||
|
return {
|
||||||
|
upstream,
|
||||||
|
body: JSON.stringify({ error: 'OpenSky rate limited.' }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const body = await readResponseTextCapped(
|
||||||
|
upstream,
|
||||||
|
OPENSKY_STATES_MAX_RESPONSE_BYTES,
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
return { upstream, body };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (attempt >= 2) throw error;
|
if (attempt >= 2 || error?.code === 'RESPONSE_TOO_LARGE') throw error;
|
||||||
console.warn(
|
console.warn(
|
||||||
'[OpenSky] global snapshot attempt failed, retrying:',
|
'[OpenSky] global snapshot attempt failed, retrying:',
|
||||||
error?.message || error,
|
error?.message || error,
|
||||||
@@ -544,19 +582,9 @@ export function openSkyProxy() {
|
|||||||
}
|
}
|
||||||
if (upstream.status === 429) {
|
if (upstream.status === 429) {
|
||||||
reason = 'rate_limited';
|
reason = 'rate_limited';
|
||||||
// Credit governor: honor OpenSky's retry-after (bounded 30 s … 30 min;
|
// Credit governor: fetchOpenSkyStates already started the cooldown
|
||||||
// 2 min when the header is absent) — no upstream attempts until then.
|
// from OpenSky's retry-after — no upstream attempts until then.
|
||||||
const retryAfterSec = Number(
|
const cooldownMs = openSkyRateLimitCooldownMs(upstream);
|
||||||
upstream.headers.get('x-rate-limit-retry-after-seconds'),
|
|
||||||
);
|
|
||||||
const cooldownMs = Math.min(
|
|
||||||
Math.max(
|
|
||||||
Number.isFinite(retryAfterSec) ? retryAfterSec * 1000 : 120_000,
|
|
||||||
30_000,
|
|
||||||
),
|
|
||||||
30 * 60_000,
|
|
||||||
);
|
|
||||||
_openskyCooldownUntil = now + cooldownMs;
|
|
||||||
// Serve the last-good body instead of the 429 when we have one —
|
// Serve the last-good body instead of the 429 when we have one —
|
||||||
// the layer keeps rendering (STALE-cued) instead of dying.
|
// the layer keeps rendering (STALE-cued) instead of dying.
|
||||||
if (_openskyCacheBody && _openskyCacheStatus === 200) {
|
if (_openskyCacheBody && _openskyCacheStatus === 200) {
|
||||||
|
|||||||
Loaded 100 of 809 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user