mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
fix(ui): verify real PanelChrome rail ownership of compact Radio
Carry reviewed #816 onto current main without conflicts; preserve i18n updates, replace obsolete changelog edit with a fragment, and add a constructor-wiring regression alongside the owner and rail tests. Credit: raysung-2026 (report), asturksever (review), quanticsoul4772 (Windows diagnosis).
This commit is contained in:
commit
c1dd4171db
702 files changed
+68262
-6015
No files matched your search
@@ -65,11 +65,10 @@ check, not a JavaScript sandbox. Common voice controls cannot depend on a
|
||||
Realtime protocol implementation. Negative fixtures cover indirect helpers,
|
||||
self-package imports, symlinks and unreachable files.
|
||||
|
||||
Source factories have dedicated exports for ALPR, bikeshare, CCTV, earthquakes,
|
||||
FIRMS, installations, launches, radio, satellites and traffic. They preserve the
|
||||
same factory implementations without loading layer rendering. ALPR/earthquake
|
||||
record normalization and CCTV source endpoint policy have plain owners separate
|
||||
from geometry/cards. Source exports do not start acquisition at import time.
|
||||
Source-only factories use dedicated `layers/<family>/source` exports where
|
||||
available, preserving the same factory implementation without loading rendering.
|
||||
Record normalization and endpoint policies have plain owners separate from
|
||||
geometry/cards. Source exports do not start acquisition at import time.
|
||||
|
||||
Other layer `ingestion.js` files may still coordinate Cesium resources; the
|
||||
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
|
||||
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
|
||||
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.
|
||||
|
||||
## Geocoding and feature-query providers
|
||||
|
||||
+495
-46
@@ -75,6 +75,8 @@ Windows/Claude Desktop Cowork confirmation remains outstanding (#927).
|
||||
|
||||
Tool argument string lengths follow JSON Schema Unicode code-point counts, so supplementary characters count once rather than as two UTF-16 units.
|
||||
|
||||
Query timestamps outside the representable Date range are reported as null, preserving the rest of the result row.
|
||||
|
||||
`gods-eye-view/tools` defines queries that answer questions from the app's data,
|
||||
and `gods-eye-view/tools/mcp` exposes a composed catalog over the Model Context
|
||||
Protocol. `npm run mcp` serves Core's tools over stdio to a local MCP client,
|
||||
@@ -98,6 +100,25 @@ shared `area` argument (place name, bounding box, or point and radius) and cap
|
||||
lists at 25 rows by default. Voice offers the same queries next to its app
|
||||
actions: the session lists them, and the browser runs them through the same
|
||||
catalog, loaded on first use. See [tools and the MCP server](TOOLS.md).
|
||||
Aircraft altitude-band queries require a reported barometric or geometric altitude
|
||||
for each match, so a grounded contact that reports none is left out of a band too.
|
||||
Queries without an altitude band retain unknown-altitude contacts.
|
||||
|
||||
Catalog calls check their cancellation signal immediately before executing a
|
||||
tool. Already-cancelled queries do not start; cancellation after execution starts
|
||||
continues to follow the existing tool and caller behavior.
|
||||
|
||||
Fire-perimeter queries test polygon interiors and holes on continuous unwrapped
|
||||
longitudes, matching the short-edge convention used for boundary intersections.
|
||||
An outline crossing ±180° contains nearby boxes and radius areas without also
|
||||
containing their counterparts near Greenwich. Returned perimeter coordinates
|
||||
use a circular mean for longitude, including multipart outlines. Incident cards
|
||||
also use a circular longitude mean over the largest outer ring (excluding its
|
||||
repeated closing vertex). Their relative-area comparison follows short longitude
|
||||
edges so a small dateline-crossing part cannot outweigh a larger ordinary part.
|
||||
Regression coverage lives in `src/tools/area.test.mjs`,
|
||||
`src/tools/queries/environment.test.mjs` and
|
||||
`src/layers/perimeters/cards.test.mjs`.
|
||||
|
||||
## Cyber HUD — September 30, 2026
|
||||
|
||||
@@ -250,8 +271,15 @@ Clouds only applies a soft brightness ramp to decoded pixels once, using
|
||||
Cesium's sRGB-to-linear conversion (`channel ** 2.2`) and smoothstep from 0.40
|
||||
to 0.70. The old 0.55 threshold is the ramp midpoint. RGB and source alpha are
|
||||
preserved in Full image mode; both modes use the chosen layer opacity. This
|
||||
is a display filter, not a cloud mask. Satellite share links retain the display
|
||||
mode; observation history remains transient and links open latest.
|
||||
is a display filter, not a cloud mask. The cloud-filter slider is a continuous
|
||||
0–100% strength: 0% is Full, 100% is the existing Clouds only key, and
|
||||
intermediate values blend their alpha. The value commits on slider release to
|
||||
avoid restaging for every pointer event. Status refreshes retain the uncommitted
|
||||
drag preview; changed model values or disabled controls supersede it.
|
||||
Satellite share links carry the filter
|
||||
strength; legacy `f`/`a` values keep their Clouds only / Full meanings. Soft and
|
||||
Vivid remain layer-opacity presets (40%/70%); history remains transient and links
|
||||
open latest.
|
||||
Global infrared fetches one capped 4 MiB, 2048×1024 mosaic per frame and decodes
|
||||
and processes it before staging. Globe hosts crop that canvas into 256 px tiles
|
||||
to maximum level 3 on a geographic 2×1 root grid bounded to the manifest extent,
|
||||
@@ -296,7 +324,8 @@ release commits immediately. Latest selects the newest frame per product, and
|
||||
history labels the actual displayed frame as synced or nearest. Missing-frame
|
||||
messages use each product's eligibility gap (30 minutes, or three hours for the
|
||||
global mosaic). Wind shows forecast valid and issue times and does not follow
|
||||
history. Satellite clouds uses Clouds only / Full image configuration labels.
|
||||
history. Satellite clouds uses Clouds only / Full controls, a continuous cloud
|
||||
filter slider and Soft/Vivid opacity presets in its right-rail settings.
|
||||
|
||||
Left weather rows retain only the toggle and one status line with a source
|
||||
tooltip. Configuration and the cyclone Storms list live in the cards, as do the
|
||||
@@ -325,7 +354,12 @@ Cyclones combine fixed NHC/CPHC status and summary GIS endpoints through a bound
|
||||
same-origin provider. The status endpoint lacks browser CORS; each installation
|
||||
fetches keyless public sources at runtime, with no centralized ingestion service.
|
||||
The five-minute snapshot covers Atlantic and eastern/central North Pacific only.
|
||||
Current positions, advisory issue time and position time stay distinct. Tracks,
|
||||
Current positions, advisory issue time and position time stay distinct. NHC may
|
||||
release an advisory before its nominal time (routinely up to about 30 minutes,
|
||||
rarely about an hour), so advisory issue and position times up to and including
|
||||
90 minutes ahead of the server clock are accepted; 90 minutes is this app's
|
||||
tolerance, not an NHC limit. Later, non-canonical, or more than 12-hour-old
|
||||
times still reject the status snapshot. Tracks,
|
||||
forecast lead-hour points and cones render only when all source parts match the
|
||||
status advisory; a newer status shows its position with geometry pending rather
|
||||
than relabeling older geometry. The cone represents forecast center uncertainty,
|
||||
@@ -350,7 +384,12 @@ doubling to a 15 s cap, with every retry waiting behind one shared cooldown
|
||||
plus up to 1.5 s of jitter, and `Retry-After` extending the cooldown when the
|
||||
upstream exposes it. One console warning is logged per cooldown window. Other
|
||||
failures keep Cesium's handling and the flat `EllipsoidTerrainProvider`
|
||||
fallback. `scripts/qa-terrain-429.mjs` reproduces the burst against a keyless
|
||||
fallback; while that flat terrain is on a shown globe the map controller
|
||||
retries Re:Earth in the background (30 s, doubling to a 10-minute cap, each
|
||||
attempt abandoned after 60 s) and installs the real terrain without waiting
|
||||
for a map switch or an imagery retry. Behind a 3D tileset it neither retries
|
||||
nor installs; the next globe activation does.
|
||||
`scripts/qa-terrain-429.mjs` reproduces the burst against a keyless
|
||||
dev server, counts real throttles with their headers as evidence, synthesises
|
||||
throttles with `--inject N` when the upstream is not throttling, and fails if
|
||||
a throttled tile never loads.
|
||||
@@ -396,6 +435,10 @@ and geographic placement remain distinct; Stop/replacement releases resources.
|
||||
Import acquires nothing. See [data packs](DIRECTOR-DATA-PACKS.md). Existing scene
|
||||
content, assets and credit remain unchanged.
|
||||
|
||||
The asset directory source rejects with the caller's abort reason if
|
||||
cancellation arrives during any read, including the final one, or while the
|
||||
reader is being released.
|
||||
|
||||
Director version-4 documents add named geographic anchors and optional explicit
|
||||
camera moves with easing, duration and holds. Playback and seek share one
|
||||
coordinate sampler; navigation/manual input revokes authored motion and pending
|
||||
@@ -404,6 +447,8 @@ reference. Legacy projects retain their ordinary flights, content and edits.
|
||||
Seeking between ordinary camera poses uses the shortest longitude arc across the
|
||||
dateline while preserving both authored endpoint longitudes, and turns heading
|
||||
and roll the short way for any authored angles.
|
||||
A seek to a shot's start, as a shot action makes, selects that shot rather than
|
||||
the end of the previous one.
|
||||
See [camera directions](DIRECTOR-CAMERA.md).
|
||||
|
||||
Realtime voice composes separate connection, response/tool, Radio handoff, input
|
||||
@@ -502,7 +547,7 @@ are protected from fallback writes. Version 6 is the export format, with
|
||||
zero pitch, low camera heights, scope and detection edits preserved. See [the document contract](SCENE-DOCUMENT.md);
|
||||
see [Director](DIRECTOR.md) for camera, pack, action and sharing support.
|
||||
|
||||
Search framing and annotations request semantic map features from an explicit source. The Overpass adapter owns bounded queries, member/tag decoding and request deadlines; callers retain candidate ranking, outline caching, deferred retries and scene placement. Empty, unavailable, transient and throttled outcomes remain distinct. Traffic sources return road records and installation sources return mapped records with freshness/saturation metadata, so their layers no longer decode upstream elements. ALPR already normalizes its records in the source. Traffic uses OpenFreeMap roads with optional matched TomTom congestion; ALPR uses an hourly OpenStreetMap extract. Keyless installations use military-area tiles. Detailed feature queries require operator-configured Overpass.
|
||||
Search framing and annotations request semantic map features from an explicit source. The Overpass adapter owns bounded queries, member/tag decoding and request deadlines; callers retain candidate ranking, outline caching, deferred retries and scene placement. Empty, unavailable, transient and throttled outcomes remain distinct; a data reply is refused only for its `remark`, never for text in element tags. Traffic sources return road records and installation sources return mapped records with freshness/saturation metadata, so their layers no longer decode upstream elements. ALPR already normalizes its records in the source. Traffic uses OpenFreeMap roads with optional matched TomTom congestion; ALPR uses an hourly OpenStreetMap extract. Keyless installations use military-area tiles. Detailed feature queries require operator-configured Overpass.
|
||||
|
||||
Nepal media preloads survive repeated camera-flight updates, but Stop, event disable and replacement remove abandoned frames and revoke pending Facebook sessions. Fallback evidence-card clicks respect the shared drawing-tool pointer lease.
|
||||
|
||||
@@ -561,7 +606,9 @@ pinning, first-position grace, source time and sea-surface placement are unchang
|
||||
|
||||
Military flights also separate portable records and source acquisition from
|
||||
Cesium snapshot application. The metadata owner retains aviation feet, source
|
||||
fix times, sticky fields and partial-feed retention. Rendering retains ground
|
||||
fix times, sticky fields and partial-feed retention. A row without track or
|
||||
speed holds the last known value, as civil records do; a grounded row without
|
||||
speed reads 0. Rendering retains ground
|
||||
model ownership, motion history, military registration and tracking cleanup.
|
||||
|
||||
Civil flights separate source acquisition, portable record reconciliation and
|
||||
@@ -819,6 +866,37 @@ use the supplied asset resolver, including the preload path. Enrichment receives
|
||||
an abort signal and cannot update a later lifecycle after destruction. Existing
|
||||
camera, terrain floor, trail, selection and measured model-size policies remain.
|
||||
|
||||
The tracked-aircraft descriptor both flight layers hand to the cockpit carries
|
||||
`verticalRateMps` beside `velocityMps` and `track`. Civil records store the rate as
|
||||
`verticalRate` and military records as `verticalRateMps`; the query seam maps both to
|
||||
one name, so `resolveTrackedAircraftInfo` returns a single shape for either layer. The
|
||||
rate is reported, never derived: a blank poll keeps the last transmitted value and an
|
||||
absent rate stays null rather than reading as level flight.
|
||||
|
||||
## Transponder emergency highlighting
|
||||
|
||||
`src/data/aircraftEmergency.js` derives one status per poll from the Mode A
|
||||
squawk (7500 unlawful interference, 7600 radio failure, 7700 general emergency)
|
||||
and the readsb ADS-B emergency/priority field where present. Higher severity
|
||||
wins, with ADS-B taking a tie. GEV treats `minfuel` and `lifeguard` as priorities;
|
||||
`general`, `unlawful`, `nordo` and `downed` are emergency statuses.
|
||||
|
||||
Civil and military fleet billboards, 3D models and Cockpit dots turn red for
|
||||
emergencies and repaint when the broadcast changes. Priorities retain normal
|
||||
colors; tracked map colors keep their existing selection treatment. The Cockpit
|
||||
meta line names the squawk or ADS-B status (only emergencies are red), replacing
|
||||
COURSE ALIGNED while a status is present.
|
||||
|
||||
Analyst and tracked voice results describe broadcasts, not confirmed incidents.
|
||||
Fields include `squawk`, `emergency`, `emergencyKind`, `emergencySource` and
|
||||
`emergencyBroadcast`. `lastContactAtMs` is the last known transponder contact,
|
||||
not the arrival time of this status; missing contact times retain known contact.
|
||||
Civil fallback preserves status alongside unchanged OpenSky vectors. One poll
|
||||
omitting ADS-B status may retain the previous status when the squawk is unchanged;
|
||||
explicit clears and changed squawks take effect immediately. The next omitted
|
||||
poll expires that retention. `scripts/qa-emergency-squawk.mjs` checks billboard
|
||||
and model transitions, Cockpit HUD, clearing and tracking release.
|
||||
|
||||
## Browser live-source observations
|
||||
|
||||
Flights, Military Flights and AIS Vessels obtain snapshots and optional history
|
||||
@@ -830,7 +908,13 @@ addition to the locally accumulated trail, never a promise of complete coverage.
|
||||
|
||||
Snapshot coverage, completeness and freshness are separate fields. A partially
|
||||
admitted aircraft snapshot retains absent contacts for up to five minutes before
|
||||
the usual missed-poll eviction. Unknown snapshot times remain unknown in stats. An invalid nonempty
|
||||
the usual missed-poll eviction. An OpenSky row with an ICAO24 address but null
|
||||
longitude and latitude is a contact without a position, not a malformed row: it
|
||||
leaves the snapshot complete and is listed in `retainIds`, so civil flights
|
||||
retain that aircraft under the same five-minute bound, unless it is likely
|
||||
landed, while other absent aircraft fade and leave on the missed-poll limits. A
|
||||
nonempty snapshot with no positioned rows is still malformed. Military readsb
|
||||
admission is unchanged. Unknown snapshot times remain unknown in stats. An invalid nonempty
|
||||
snapshot retains the previous display. Empty vessel refreshes retain the existing
|
||||
first-connect grace and warm-data behavior. Known source failures have bounded
|
||||
messages; arbitrary HTTP response bodies are not surfaced as diagnostics.
|
||||
@@ -935,7 +1019,41 @@ id-hash fallback at low confidence.
|
||||
|
||||
Once enabled, the 250 cameras nearest the five borough cores load.
|
||||
`CCTV_NYC_MAX_SOURCES` sets the pack cap (8–400). The catalog-wide ceiling stays at 4,000 and is shared
|
||||
round-robin, so adding this pack does not silently drop another region.
|
||||
round-robin: no region is dropped outright, but when the packs together exceed
|
||||
the ceiling, adding a pack trims cameras from the largest packs.
|
||||
|
||||
## City of Toronto RESCU CCTV source pack
|
||||
|
||||
The CCTV catalog adds the City of Toronto RESCU traffic cameras — downtown
|
||||
arterials plus the Gardiner, DVP, Allen and Lake Shore, which the Ontario 511
|
||||
pack does not cover. The server reads the keyless camera list the City
|
||||
publishes on its open data portal (`tmcearthcameras.json`, served as JSONP).
|
||||
The list has no URL field: frame URLs are built from each validated, digits-only
|
||||
camera number on the fixed origin
|
||||
`https://opendata.toronto.ca/transportation/tmc/rescucameraimages/CameraImages/`.
|
||||
The feed has no compass data, so headings are low-confidence priors. Some
|
||||
cameras return a 200 "Image Unavailable" placeholder frame instead of an error.
|
||||
|
||||
By default all ~336 cameras load, nearest downtown first.
|
||||
`CCTV_TORONTO_MAX_SOURCES` sets the pack cap (8–500) and
|
||||
`CCTV_TORONTO_ENABLED=0` turns the pack off. The pack shares the 4,000
|
||||
catalog-wide ceiling round-robin with the other packs.
|
||||
|
||||
## Lithuania CCTV source pack
|
||||
|
||||
The CCTV catalog can include Via Lietuva / Eismoinfo road cameras. The pack is
|
||||
off unless `CCTV_LITHUANIA_ENABLED=1`, because its camera endpoints are the
|
||||
eismoinfo.lt website backend rather than a service listed in Via Lietuva's
|
||||
open-data statement, so the licence for the photos is unconfirmed.
|
||||
Two keyless public endpoints provide current snapshot URLs and WGS84 camera
|
||||
locations; records are joined by ID, with invalid coordinates and off-origin
|
||||
image URLs rejected. Catalog requests refuse redirects, have a 15-second
|
||||
deadline and a 4 MiB response cap, and use the existing shared 15-minute cache
|
||||
and fair catalog allocation. An unavailable source contributes an empty pack.
|
||||
Frames use the registered-image proxy and retain Via Lietuva attribution.
|
||||
The images are periodically updated road-condition photographs, not continuous
|
||||
video; camera poses use the existing low-confidence heading prior.
|
||||
`CCTV_LITHUANIA_MAX_SOURCES` sets its cap (default 400, bounded to 1–1000).
|
||||
|
||||
## DriveBC CCTV source pack
|
||||
|
||||
@@ -1054,19 +1172,19 @@ Automatic collapse remains presentation only, prioritizes the latest explicit
|
||||
panel, and honors keyboard focus on the right. Mobile layout still releases
|
||||
desktop allocation styles. Stable Display allocation avoids unnecessary style
|
||||
writes. This extraction does not change panel positions or layout defaults.
|
||||
|
||||
A Context expansion, whether through a repeated panel-owner call or a right
|
||||
rail restore after tactical auto-collapse, dismisses any open compact Radio
|
||||
disclosure before revealing Context content. The compact launcher still works
|
||||
while Context remains collapsed. This is an ownership invariant, not a
|
||||
z-index workaround (#804).
|
||||
|
||||
Context expansion through the panel owner or adaptive right-rail restoration
|
||||
closes an open compact Radio disclosure before Context/Space Missions becomes
|
||||
visible. The mini remains available while Context is collapsed. This fixes the
|
||||
Radio-overlap path reported in #804 without changing z-index or layout policy.
|
||||
The right rail skips panels hidden by other CSS (computed `display: none`), but
|
||||
keeps the collapsed panels its own exclusive presentation hides, so automatic
|
||||
collapse can still be undone.
|
||||
|
||||
## Surface keyboard lifecycle
|
||||
|
||||
Surface Tab cycling excludes controls with a negative tabindex, native disabled
|
||||
state or inert ancestry; ordinary visible enabled controls retain their order.
|
||||
|
||||
`ui/surfaces` owns the capture-phase keyboard listener, Tab cycling and return
|
||||
focus shared by the first-run launcher and Provider Settings. Each caller
|
||||
activates it while open and deactivates it on dismissal; destruction releases
|
||||
@@ -1124,6 +1242,9 @@ still waiting to drain reschedules that deadline, so a slow client does not read
|
||||
as a stalled upstream. Error responses are cancelled. Buffered snapshots have a
|
||||
16 MiB streaming cap; an oversized image remains an upstream miss and uses the
|
||||
normal fallback chain. The existing declared media size ceiling remains 64 MiB.
|
||||
`/api/cctv/media` relays live feeds (HLS, MP4, WebM, MJPEG) only; a still-image
|
||||
camera is refused there with a JSON 404 and is served solely through
|
||||
`/api/cctv/frame` and its same-origin, `image/*`-only, size-capped fetch.
|
||||
|
||||
## GBFS upstream bounds
|
||||
|
||||
@@ -1144,13 +1265,55 @@ The station reader addresses the proxy as `/api/gbfs/<encoded feed URL>`; the
|
||||
feed address is read from the path, and a query-string form is refused with a 400. GBFS refuses upstream redirects and enforces its 5 MiB response cap while
|
||||
streaming. The 12-second deadline includes reading the body, and rejected or
|
||||
stalled downloads are cancelled. Development and preview use the same handler.
|
||||
Every upstream request carries `Client-Identifier: gods-eye-view`, which Urban
|
||||
Sharing asks of clients of the Oslo, Bergen and Trondheim Bysykkel feeds
|
||||
(`gbfs.urbansharing.com`); other operators ignore it. Requests to Entur
|
||||
(`api.entur.io`, Kolumbus and Farte Bysykkel) also carry
|
||||
`ET-Client-Name: gods-eye-view-bikeshare`. Entur serves its feeds without a
|
||||
`.json` suffix, so the endpoint check accepts `station_information` and
|
||||
`station_status` with or without it. Hosts that front more than GBFS are kept to
|
||||
their GBFS tree: `api.entur.io` to `/mobility/v2/gbfs/` and `api.cyclocity.fr`
|
||||
(Bysykkel Lillestrøm) to `/contracts/`.
|
||||
|
||||
## Upstream body bounds
|
||||
|
||||
FIRMS CSV, CelesTrak TLE groups, adsb.lol military responses, TomTom flow
|
||||
tiles, OpenSky states/all and adsbdb lookups now read their bodies through the
|
||||
shared streaming readers in `src/sources/httpBody.js`, so a declared or observed body
|
||||
over the cap is refused with `RESPONSE_TOO_LARGE` and cancelled rather than
|
||||
buffered. TomTom flow tiles use `readResponseBytesCapped` alongside
|
||||
GTFS-Realtime. Each read takes the route's abort signal, so a body still
|
||||
streaming when the deadline fires is cancelled. Caps are bounds, not budgets, sized well
|
||||
above measured payloads: FIRMS 64 MiB per satellite source, CelesTrak 16 MiB
|
||||
per group, OpenSky `states/all` 8 MiB, adsb.lol `/v2/mil` 8 MiB, TomTom tiles
|
||||
8 MiB and adsbdb lookups 1 MiB. Where a route already had a deadline (FIRMS
|
||||
60 s, CelesTrak 20 s, TomTom 15 s, adsbdb 8 s, and each 10 s OpenSky
|
||||
`states/all` attempt) it already bounded the body read, because aborting a
|
||||
fetch signal also aborts its body; the capped reader takes the same signal.
|
||||
The adsb.lol military poll, which had no deadline, now waits at most 12
|
||||
seconds for headers and body together. An oversized OpenSky snapshot is not
|
||||
retried, and an OpenSky 429 starts its cooldown from the response headers and
|
||||
cancels the body unread. A rejected or timed-out body is an upstream miss and
|
||||
follows each route's existing error and cached-data paths. FIRMS map-key
|
||||
status, the OpenSky token exchange and CCTV media keep their current handling.
|
||||
|
||||
The route also bounds request frequency: 120 requests/minute per client
|
||||
address, with a global backstop of three times that. A refused request answers
|
||||
429 with `Retry-After` and `Cache-Control: no-store` and performs no outbound
|
||||
fetch. The bikeshare layer's own demand is about 15 requests a minute with
|
||||
every catalogued system in range, so an ordinary session never meets the limit;
|
||||
the method, host and path guards run unchanged.
|
||||
|
||||
Updated: September 21, 2026
|
||||
|
||||
## Remaining local service modules
|
||||
|
||||
Overpass query validation, geometry simplification, disk caching and upstream
|
||||
transport now have separate modules; military-installation search reuses that
|
||||
transport. Regional briefing combines separate place, news and weather sources,
|
||||
while weather effects uses only the weather source. Existing process-scoped
|
||||
while weather effects uses only the weather source.
|
||||
GDELT compact dates that do not name a real calendar instant keep the article
|
||||
with an unknown publication time. Existing process-scoped
|
||||
caches, rate limits, stale fallbacks and route ordering are preserved.
|
||||
|
||||
Local voice has separate HUD-summary, debug-log and Realtime-token handlers,
|
||||
@@ -1166,6 +1329,28 @@ services, local voice and standalone key setup. Importing them starts no network
|
||||
acquisition. Browser layer lifecycle, rendering and voice execution stay in their
|
||||
existing modules.
|
||||
|
||||
## Web app install icons
|
||||
|
||||
`public/manifest.webmanifest` declares the installable app: standalone display,
|
||||
`#0a0a0f` background and theme, and 192/512 icons plus a 512 maskable variant.
|
||||
`index.html` links the manifest, a 180px `apple-touch-icon` and a 32px PNG
|
||||
favicon; the existing SVG favicon still serves the browser tab. Safari ignores an
|
||||
SVG apple-touch-icon and substitutes a page screenshot, so the raster is what
|
||||
makes an installed icon appear at all.
|
||||
|
||||
Manifest `start_url`, `scope` and icon paths are relative to the manifest, and
|
||||
Vite rewrites the `index.html` links under `base`, so a build served from a path
|
||||
prefix installs and launches inside that prefix.
|
||||
|
||||
`node scripts/generate-icons.mjs` renders every raster from `public/logo.svg` and
|
||||
writes `public/icons/`. Output is committed, so building needs no image
|
||||
toolchain; re-run the script after changing the logo. Ordinary icons inset the
|
||||
mark to 78% of the canvas and the maskable variant to 54%, keeping it inside the
|
||||
circle Android may crop to. `src/tooling/webAppIcons.test.mjs` checks the links,
|
||||
the manifest fields and that each committed PNG is square at its declared size.
|
||||
It also builds the install links under `base: '/example/'` and asserts every
|
||||
emitted link and manifest URL stays inside that base.
|
||||
|
||||
## Local build preview
|
||||
|
||||
After `npm run build`, `npm run preview` serves the built app with the same data
|
||||
@@ -1179,13 +1364,42 @@ a browser key. Preview is for local build verification, not a production server.
|
||||
|
||||
## CCTV and radio provider modules
|
||||
|
||||
CCTV direction text resolves travel tokens first, then intercardinals, then
|
||||
bare cardinals when enabled. Only conflicting headings within the highest
|
||||
matching tier remain unknown; street quadrants and lower-tier words do not
|
||||
cancel an explicit travel heading.
|
||||
|
||||
CCTV catalog acquisition, source normalization and frame/media delivery now live
|
||||
in separate modules. Each CCTV factory owns its catalog and health state; its
|
||||
`sourceRoot` option resolves relative source files against the application root.
|
||||
Radio Browser station normalization, restricted outbound transport and directory
|
||||
caching are separate modules. Node-only package entries expose both provider
|
||||
factories. Routes, payloads, fallback behavior and existing dev/preview hook
|
||||
registration are preserved; browser rendering is unchanged.
|
||||
caching are separate modules. The pinned transport gives a mirror's 204, 205 or
|
||||
304 reply an empty body and fails any reply outside 200–599 without throwing
|
||||
outside its promise, so an unusual mirror status cannot crash the server.
|
||||
Node-only package entries expose both provider factories. Routes, payloads,
|
||||
fallback behavior and existing dev/preview hook registration are preserved;
|
||||
browser rendering is unchanged.
|
||||
The portable frame source retains the proxy's known `X-CCTV-Source` kind. Camera
|
||||
snapshot tools label Street View fallback imagery as historical and credit Google,
|
||||
while upstream frames retain their current-view label and catalog attribution.
|
||||
Synthetic placeholders are unavailable as snapshots. Injected frame sources may
|
||||
supply `sourceKind`; missing or unknown provenance keeps `current_view` null and
|
||||
does not claim the image is current. The `current_view` field distinguishes camera-feed
|
||||
provenance from historical fallback imagery; it does not measure capture time or
|
||||
image freshness.
|
||||
|
||||
## CCTV missing numeric fields
|
||||
|
||||
CCTV numbers are read as missing when they are absent, null, blank or boolean,
|
||||
on the server (`toFiniteNumber`, every pack and the file/env source packs) and
|
||||
in the browser (`safeNumber`). `/api/cctv/sources` sends an unknown number as
|
||||
JSON null; the browser then uses the bundled seed camera's value when one
|
||||
shares the id, else the field's default — id-hash heading, pitch −17°, FOV 74°
|
||||
(which a matched datasheet can still replace), range 700 m, mount 24 m, the
|
||||
city's ground elevation — rather than reading zero and clamping it to the
|
||||
field's floor. A camera with a missing latitude or longitude and no seed
|
||||
position is skipped by the browser catalog instead of being placed at 0,0.
|
||||
Real zeros, including `"0"`, are kept.
|
||||
|
||||
## Terrain, traffic, fire and bike-share provider modules
|
||||
|
||||
@@ -1195,21 +1409,31 @@ plugin order, server-key selection, validation, disk caches, budgets, retries
|
||||
and stale/error responses remain unchanged. Each has a Node-only package entry
|
||||
under `gods-eye-view/server/providers/`. Portable terrain mechanics, traffic tile
|
||||
math and GBFS source rules are available under `gods-eye-view/sources/`.
|
||||
The browser layers and their rendering remain in their existing modules.
|
||||
The TomTom `/api/tomtom/*` routes run the shared same-site admission gate before
|
||||
status, cache or upstream work, refusing cross-site browser requests while
|
||||
preserving same-origin app requests and non-browser local callers. The browser
|
||||
layers and their rendering remain in their existing modules.
|
||||
GBFS null or empty availability counts retain an unknown value through station
|
||||
parsing and rendering; they use the neutral station color rather than reporting
|
||||
an empty dock. Explicit zero and caller-supplied numeric fallbacks remain valid.
|
||||
|
||||
## Terrain height cache bound
|
||||
|
||||
The terrain proxy parser rejects finite longitudes outside [-180, 180] and
|
||||
latitudes outside [-90, 90] for every client before upstream resolution. One
|
||||
out-of-range point rejects the whole batch; inclusive WGS84 bounds remain valid.
|
||||
|
||||
The client terrain-height resolver's in-memory cache is bounded at 20 000
|
||||
entries (`DEFAULT_MAX_CACHE_ENTRIES`, overridable per instance through
|
||||
`createTerrainHeights({ maxCacheEntries })`). Coordinate rounding makes repeated
|
||||
visits to one place share a key but does not bound how many distinct places a
|
||||
session visits, and the cache previously had no eviction, so a long session
|
||||
retained every coordinate it ever resolved. Eviction is least-recently-used:
|
||||
a consumer read promotes its entry, so the cell being watched is not dropped for
|
||||
having been resolved early. The bound sits far above a city-scale session, so
|
||||
a consumer read, including a warm hit in a batch lookup, promotes its entry, so
|
||||
the cell being watched is not dropped for having been resolved early. The bound sits far above a city-scale session, so
|
||||
normal use never evicts and issues no additional proxy requests. A batch's
|
||||
results are assembled from what that call resolved, so eviction during a large
|
||||
batch cannot report a just-resolved point as unresolved. Re:Earth and
|
||||
results are assembled from what that call resolved or found warm, so eviction
|
||||
during a large batch cannot report one of its points as unresolved. Re:Earth and
|
||||
geoid-fallback entry semantics, the fallback cooldown and the abort-time flush
|
||||
are unchanged.
|
||||
|
||||
@@ -1436,6 +1660,8 @@ accessible name remains "Don't show this again". Control behavior is unchanged.
|
||||
|
||||
## FIRMS source status
|
||||
|
||||
The FIRMS CSV parser rejects impossible acquisition calendar dates before the proxy filters records to the trailing 24 hours, while retaining valid UTC acquisition times.
|
||||
|
||||
The FIRMS proxy records source success after appending its rows. If aggregation
|
||||
throws, that source reports failure without a contradictory success entry.
|
||||
The existing per-record append continues to support large feeds; sequential
|
||||
@@ -3034,7 +3260,11 @@ test:track` 43 tracking invariants · headless QA harnesses under
|
||||
> metres low. `qa-floor-verify.mjs` now **exits non-zero on FAIL** (1 = FAIL,
|
||||
> 2 = INCONCLUSIVE); it previously exited 0 on every verdict, which is how the
|
||||
> burial stayed invisible. It also honours `QA_BASE_URL` and puppeteer's
|
||||
> pinned Chrome-for-Testing, like the other harnesses.
|
||||
> pinned Chrome-for-Testing, like the other harnesses. The viewer-distance
|
||||
> gate, the corridor walk and cell hysteresis take the short way across 180°
|
||||
> (`src/data/approxDistance.js` is the one distance both the flights records
|
||||
> and the mesh-floor sampler use), so contacts just across the antimeridian
|
||||
> are floored like any other.
|
||||
> - **2026-07-16:** the FIRMS Active Fires layer is **LIVE** —
|
||||
> the bundled 2026-05-25 snapshot (58 MB) is deleted; a new `/api/firms` proxy
|
||||
> (vite.config.js) merges VIIRS NOAA-20/NOAA-21/Suomi-NPP NRT world CSVs
|
||||
@@ -3061,6 +3291,39 @@ test:track` 43 tracking invariants · headless QA harnesses under
|
||||
> attribution 18/18). New modules: `src/data/{motionModel,aircraftMeta,aircraftClass,aircraftIcons,issPass,routePlausible,dataCredits}.js`.
|
||||
> The live runtime now declares 30 voice tools; the 17→20 count above is retained only as milestone history.
|
||||
|
||||
### Interface localization (October 2026)
|
||||
|
||||
Shipped locales: **en** (source and fallback), **es** and **ru**. The code lives in
|
||||
`src/i18n/`. How to mark UI text: [`docs/I18N.md`](./I18N.md); translator workflow:
|
||||
[`docs/TRANSLATORS.md`](./TRANSLATORS.md).
|
||||
|
||||
- **Keys are the English text.** `t('Save keys')`, `tn(count, '{count} camera',
|
||||
'{count} cameras')`, and `msg('Live Flights')` for text stored in data and
|
||||
translated later. Markup opts in with `data-i18n` (element text) and
|
||||
`data-i18n-title` / `-aria-label` / `-placeholder` (that attribute's English
|
||||
value). English has no catalog; every other locale is
|
||||
`src/i18n/locales/<code>.json`, and a missing entry falls back to English.
|
||||
- **Locale choice.** `?lang=` → stored choice (`gev:locale:v1`) →
|
||||
`navigator.languages` → English. `?lang=` is not stored, and share links drop
|
||||
it, so a recipient keeps their own language. The globe picker in the
|
||||
bottom-right corner (`src/ui/localePicker.js`) stores the choice, drops
|
||||
`?lang=` (keeps it only when storage is blocked) and reloads. It asks first
|
||||
when the reload would lose work registered with `registerUnsavedWork`:
|
||||
unsaved keys or CCTV calibration, Draw marks, imported map files, the open
|
||||
cockpit, a voice session.
|
||||
- **Gate.** `npm run i18n` (and `src/i18n/catalog.test.mjs` under `npm test`)
|
||||
extracts every message and fails on missing, empty, unused,
|
||||
placeholder-mismatched, still-English (plural forms included) or
|
||||
wrongly-shaped plural entries.
|
||||
- **Not translated by design:** layer ids, share-link and hash parameters, API
|
||||
fields, voice tool schemas, provider names, feed data (callsigns, vessel and
|
||||
station names) and attribution.
|
||||
- **Unmarked text.** The same check lists UI text that is not marked at all
|
||||
(markup without `data-i18n*`, literals reaching the page without `t`/`tn`/`msg`)
|
||||
and fails on it. Text kept on purpose says why: `translate="no"`,
|
||||
`data-i18n-runtime`, or `// i18n-ignore: <reason>`. `src/tools/` is skipped:
|
||||
it writes for AI clients, not the page.
|
||||
|
||||
## Canonical Docs Order
|
||||
|
||||
Use docs in this order when details conflict:
|
||||
@@ -3103,13 +3366,14 @@ its criteria cannot be silently ignored.
|
||||
| Live AIS Vessels 🚢 | AISStream websocket | `src/data/aisLiveVessels.js` | `/api/vessels` | 60s (+800ms visibility pass) |
|
||||
| Mapped Installations ⌖ | OpenFreeMap military areas; optional configured Overpass names and Google Places search | `src/data/militaryInstallations.js` | OpenFreeMap tiles, `/api/military-installations`, `/api/google/text-search` | viewport-driven; transient errors back off, missing capability switches to tiles |
|
||||
| Earthquakes | USGS | `src/data/earthquakes.js` | — | 60s |
|
||||
| Air quality | ECCC MSC GeoMet OGC API (AQHI, Canada only) | `src/layers/airQuality/` | — | 20 min readings, 12 h station catalog |
|
||||
| Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s |
|
||||
| Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min |
|
||||
| Traffic | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache |
|
||||
| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS) + NYC DOT TMC (opt-in) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) |
|
||||
| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) |
|
||||
| Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh |
|
||||
| Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) |
|
||||
| Bikeshare 🚲 | GBFS (Lyft + BCycle) | `src/data/bikeshare.js` | `/api/gbfs` | 60s |
|
||||
| Bikeshare 🚲 | GBFS (Lyft + BCycle + Social Bicycles + PBSC incl. Bike Share Toronto, Bike Itaú (Brazil) and Bicing (Barcelona) + Urban Sharing, Entur, JCDecaux NO + nextbike (AMBici)) | `src/data/bikeshare.js` | `/api/gbfs` | 60s |
|
||||
| Directions 🧭 | OSRM on FOSSGIS servers (OpenStreetMap) | `src/data/directions.js` | `/api/route` (`steps=1`) | on placement / mode change |
|
||||
| Datacenters ▣ | OSM extract (bundled) | `src/data/localLayers.js` | — | static |
|
||||
| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/localLayers.js` | — | static |
|
||||
@@ -3117,8 +3381,34 @@ its criteria cannot be silently ignored.
|
||||
| FIRMS Active Fires ▲ | NASA FIRMS live (VIIRS ×3 NRT + MODIS NRT, trailing 24h) | `src/data/firmsHeatmap.js` | `/api/firms` (`FIRMS_MAP_KEY`) | 10 min (proxy TTL 30 min) |
|
||||
| Street Level 📷 | Street-level imagery; Mapillary is the first provider (vector tiles, Graph API, MapillaryJS) | `src/layers/streetLevel/` via `src/app/layers/streetLevel.js` | `/api/mapillary/status`, `/api/mapillary/tiles/coverage/{z}/{x}/{y}` (`MAPILLARY_CLIENT_TOKEN`) | camera-driven (320 ms debounce, ≤9 tiles, cached 24 h) |
|
||||
| Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) |
|
||||
| GNSS Integrity 📡 (`gnss-integrity`) | adsb.lol point snapshot, low navigation accuracy binned per 0.5° cell (GEV NIC/NACp threshold + gpsjam.org formula and bands, keyless, ODbL) | `src/layers/gnss/` via `src/app/layers/gnss.js` | `/api/gnss-integrity` | camera-driven (600 ms debounce, minimum 2.5 s between reads) + 60s poll (server cache 60 s; 30 min rolling window) |
|
||||
| Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) |
|
||||
|
||||
GNSS Integrity samples a 250 nm circle around the Earth point under the screen
|
||||
centre. Camera reads debounce for 600 ms with a monotonic 2.5 s minimum gap;
|
||||
nearby views share whole-degree cache keys, and a minute poll refreshes data.
|
||||
Counts and bands cover visited views over 30 minutes, including regions now off
|
||||
screen. Non-pickable ground primitives preserve Street Level and perimeter
|
||||
selection. The legend describes a low-accuracy score, not the raw aircraft share;
|
||||
`getStats().levels` exposes nominal, medium and high counts separately.
|
||||
|
||||
Transient source failures keep activation enabled and show unavailable/degraded
|
||||
health with the next automatic retry. Upstream 429 deadlines are capped at one
|
||||
hour, with the longest concurrent deadline retained; 5xx, timeout and malformed
|
||||
responses impose a 15 s server cooldown. Cache hits and shared reads do not spend
|
||||
upstream admission. Cache age and cooldown use a monotonic clock; observation
|
||||
age comes from the upstream snapshot and is preserved in the row and voice stats.
|
||||
Snapshots older than 30 minutes or without a source timestamp are refused.
|
||||
Disable/destroy cancels pending requests, timers, subscriptions and primitives.
|
||||
|
||||
The GEV classifier remains unvalidated; anomalies do not confirm interference.
|
||||
See [method, provenance and limits](../DATA_SOURCES.md#gnss-integrity), including
|
||||
MLAT-positioned GPS loss, derived-field exclusions and the NIC 0 exception.
|
||||
`scripts/qa-gnss.mjs` covers live freshness, bands, built primitives, camera/globe
|
||||
anchors, recovery, keyboard and mobile states; emergency repainting is covered
|
||||
by `scripts/qa-emergency-squawk.mjs`. Both accept `--timeout-ms` for slow boot.
|
||||
|
||||
|
||||
Fire Perimeters uses capped, timed server reads with stale-on-error caching and a per-client limit. Unchanged snapshots retain geometry; link checks cancel on disable or selection change, and the row legend shows reported containment.
|
||||
|
||||
Street Level is a collapsible right-rail panel (`#street-level-panel`, layer
|
||||
@@ -3132,13 +3422,17 @@ provider draws in one colour (`PROVIDER_COLORS` in
|
||||
`src/layers/streetLevel/policy.js`); 360° cones are rings and the selected
|
||||
sequence is GEV cyan. Share options: `m` (Mapillary), `r` (the other providers' switches by id,
|
||||
e.g. `google-1` for Street View), `p` (`a`/`p`/`f` panoramas), `s` (since, days). Mapillary, Google
|
||||
Street View and KartaView are registered. Without
|
||||
Street View, KartaView and Panoramax are registered. Without
|
||||
`MAPILLARY_CLIENT_TOKEN` the panel reads KEY REQUIRED and the controls are
|
||||
disabled. With it, coverage draws as z0–5 overview points from orbit and z11–14
|
||||
sequence lines below 60 km; the proxy strips the unused `image` layer from z14
|
||||
tiles (12 MB → ~80 KB) and shares one upstream fetch between concurrent
|
||||
requests for a tile.
|
||||
|
||||
Mapillary coverage ranks unique tile addresses before applying the 16-tile
|
||||
overview budget. Overlapping dateline halves in a polar view therefore do not
|
||||
waste overview slots on the same tile; loading already skips duplicate keys.
|
||||
|
||||
On Google 3D (`photoreal`) at street zoom (in below 1,400 m above ground, out
|
||||
above 1,800 m) the surface mode switches from `draped` to `terrain`: draped
|
||||
lines land on roofs and tree tops, so `groundCast.js` instead places lines,
|
||||
@@ -3227,7 +3521,10 @@ controls (the address goes in the caption); a pose goes out at once and
|
||||
again when the panorama's month, photographer and address arrive (one
|
||||
successful lookup per panorama per session, seeded by the nearest lookup;
|
||||
a details lookup that fails or takes over ten seconds waits five seconds
|
||||
before a view event can retry, and an unmount drops lookups in flight). A key
|
||||
before a view event can retry, and an unmount drops lookups in flight). Opening
|
||||
another panorama settles the previous open before registering its wait; closing
|
||||
then releases the current wait immediately. A details lookup released while its
|
||||
service loads makes no panorama request after that service becomes available. A key
|
||||
Google refuses (`gm_authFailure`: the API not enabled, a referrer it does
|
||||
not allow, billing off) reads KEY REJECTED and fails a pending open at
|
||||
once; a lookup or open Google never answers fails after 10 or 20 s. In CI
|
||||
@@ -3324,14 +3621,88 @@ photo that was opening is drawn on the new canvas instead. FIT / FILL hide
|
||||
for panoramas (`supportsRenderModes`). The
|
||||
caption has no place name (KartaView's is where the drive began) and links to
|
||||
the full-size photo through the image proxy, since kartaview.org's photo
|
||||
pages fail for signed-out visitors.
|
||||
pages fail for signed-out visitors. The viewer itself is `photoViewer.js`,
|
||||
shared with Panoramax: each provider hands it a `PhotoSources` table (first
|
||||
and sharper flat and panorama URLs, and the caption fields).
|
||||
|
||||
Panoramax (`providers/panoramax/`) is keyless and browser-side: the
|
||||
meta-catalogue `api.panoramax.xyz` (CORS open) federates every instance, and
|
||||
each picture is served by the instance holding it (OpenStreetMap France,
|
||||
IGN, MapComplete…; IGN's redirect to its OVH object storage). Its chip
|
||||
(orange, `#ff6f00`, Panoramax's own) starts off. Coverage comes from its
|
||||
vector tiles (`/api/map/{z}/{x}/{y}.mvt`, an hour's HTTP cache): sequence
|
||||
lines from z14 below 1.8 km above ground, z13 to 5 km, z12 to 12 km, z11
|
||||
to 30 km and z10 to 60 km, as Mapillary's (a dense z10 tile over Paris is
|
||||
~2.1 MB and 8,600 sequences, 750,000 points). `decode.js` simplifies each
|
||||
line (Douglas–Peucker to the segment, 4 units of a 4,096 tile: about a
|
||||
pixel where it is drawn, 6–10% of a GPS track's points), and the tiles in
|
||||
view draw their longest lines within 90,000 points (`maxPoints`,
|
||||
`keepOrder`), shared out once loading settles so a sparse tile keeps all
|
||||
it has and a dense one (a city centre) takes the rest; the selected
|
||||
sequence is always drawn. Every point becomes ~8 ground-line vertices:
|
||||
unsimplified, the lines hung the tab. Decoded tiles are kept while the
|
||||
provider is on (16, ten minutes, `tileCache.js`, shared with KartaView),
|
||||
and above
|
||||
60 km the `grid` layer's 0.1°
|
||||
density cells, binned to at most 96×96 points a tile (a z0 tile holds
|
||||
~70,000 cells) and not counted as sequences: the panel says to zoom in.
|
||||
A tile decodes in slices of about 8 ms, the page getting a turn between
|
||||
them (a 2 MB z10 tile is ~200 ms of work). A busy API (429/503) holds the
|
||||
tiles back for its Retry-After, which the browser cannot read here (the API
|
||||
exposes no headers), so the wait doubles from 5 s to 5 minutes until it
|
||||
answers; an answer may take as long as it keeps arriving (silent for 15 s
|
||||
is given up), so a slow link still gets its megabyte tiles. Both
|
||||
filters apply: lines carry their day and camera type, and a cell stays while
|
||||
it holds pictures of the kind asked for (cells are undated). A click on a
|
||||
line asks the core (`actions.openNearest`) for that sequence's picture
|
||||
nearest the click, within the camera height / 100 (50–400 m: from high up
|
||||
a click lands well off a line), without framing, then any sequence's within
|
||||
50 m. Each lookup is two searches side by side, ranked by distance in the
|
||||
browser: `place_position` (nearest first, but even at
|
||||
`place_fov_tolerance=180` it leaves out pictures looking away from the
|
||||
point) and a bounding box (those too, roughly nearest first, so 20 are
|
||||
asked for: ~60 kB, against ~600 kB for 100); the filters go to both (`filter=field_of_view=360`, `datetime=<since>/..`). A line is dated
|
||||
by its sequence's first day, so a sequence still growing can hide under
|
||||
SINCE; a line with no camera type or day stays drawn. Lines keep the
|
||||
decoded longest-first order under the per-tile cap (`keepOrder`). Once the picture is on screen
|
||||
its sequence is highlighted and drawn as cones from the z15 tiles around it
|
||||
(their `pictures` layer, ~110 bytes a picture, against ~7.5 kB for a STAC
|
||||
item), plus the neighbours within 150 m of an edge, on their own connection
|
||||
(coverage tiles take three, searches two); each tile's list for the sequence
|
||||
is kept. The open picture's own tile draws at once and the neighbours join
|
||||
as they arrive; one that fails is not counted, so a picture opening in it
|
||||
re-centres the cones there. The cones re-centre when a picture opens outside the tiles
|
||||
they were drawn from, judged from the moment their load starts, and centre
|
||||
only on a picture actually shown (never on a lookup that came back after
|
||||
the user moved on); a lookup nobody waits for any more is stopped. ‹ › follow the picture's own `prev`/`next`
|
||||
links, so stepping costs one small search per picture. The viewer shows the
|
||||
2,048 px SD derivative (equirectangular for a 360° picture, decoded at
|
||||
2,048 × 1,024) and swaps in the HD original only when the viewer is wider
|
||||
than SD (flat) or, for a panorama, expanded; once, shrunk while it decodes.
|
||||
When SD fails a flat picture falls back to the thumbnail, and to the
|
||||
original only in the expanded viewer. Every download of a photo, first or
|
||||
sharper, stops when another opens or the viewer closes. Originals wider than
|
||||
8,192 px are never fetched; a file's header (JPEG, PNG, WebP, GIF) is read
|
||||
before it decodes, and one declaring more than 64 MP or 16,384 px a side
|
||||
is refused undecoded, as is an `<img>` that decodes larger and any file
|
||||
over 48 MB. The caption names every
|
||||
producer (an uploading app and its photographer; never an account named by
|
||||
an email address), or else the instance holding the picture ("via
|
||||
panoramax.ign.fr"), and the picture's licence (CC BY-SA 4.0, Licence
|
||||
Ouverte 2.0…; StreetPose `license`), and links to the picture in the
|
||||
Panoramax viewer. Esc anywhere in the docked viewer clears the selected
|
||||
sequence before the panel can take it (KartaView too). After a GPU reset
|
||||
the viewer draws the same picture again itself (`onReopen`), leaving the
|
||||
core's lookups alone. The MCP globe panel allows the meta-catalogue and the
|
||||
three biggest instances; a picture on another instance shows only in the
|
||||
app.
|
||||
|
||||
Street Level has two browser gates. `npm run qa:street-level -- --url <server>`
|
||||
runs against real Mapillary and needs `MAPILLARY_CLIENT_TOKEN`. `npm run
|
||||
qa:street-level:fixtures -- --url <server>` answers every Mapillary request
|
||||
from fixtures (`providers/mapillary/coverageFixture.mjs`,
|
||||
`scripts/fixtures/street-level/`), so it needs no network and covers the whole
|
||||
photo flow. CI runs the fixture gate and `qa:panel-resize`
|
||||
`scripts/fixtures/street-level/`), and KartaView's and Panoramax's too, so it
|
||||
needs no network and covers the whole photo flow. CI runs the fixture gate and `qa:panel-resize`
|
||||
against a production build served by `vite preview` with a dummy token.
|
||||
|
||||
Directions is a keyless front end to the routing the voice agent already
|
||||
@@ -3406,9 +3777,10 @@ rate limit from the routing service is reported as one instead of as "no route
|
||||
found". `src/data/routeSteps.js` turns OSRM maneuver type / modifier / exit /
|
||||
road name into one plain-English sentence per decision, folding
|
||||
`exit roundabout` steps into the roundabout they leave, and caps one route at
|
||||
200 maneuvers — a route past that cap is reported as cut off, in the row
|
||||
summary and as the last line of the list, because the arrival step is among
|
||||
the ones dropped.
|
||||
200 maneuvers — a route with more is reported as cut off, in the row summary
|
||||
and as the last line of the list, because the arrival step is among the ones
|
||||
dropped. A route of exactly 200 is complete, and a roundabout exit after the
|
||||
200th keeps folding into it.
|
||||
|
||||
A step's card opens from its dot on the globe or from its line in the list, and
|
||||
the list covers every step including departure and arrival, which have no dot.
|
||||
@@ -3420,8 +3792,11 @@ Transit is off by default, and share links use token `j`. The movement panel ord
|
||||
places Transit directly after Street Traffic and before Bike Share. Cameras and
|
||||
utilities retain their own groups. The layer polls
|
||||
registered feeds near the camera every 15 seconds, below a 3,000 km altitude
|
||||
gate. Seven keyless regions are available: Boston, Austin, Minneapolis–St Paul,
|
||||
Helsinki, the Netherlands, Norway and South East Queensland. Mode silhouettes
|
||||
gate. Sixteen keyless regions are available: Boston, Austin, Minneapolis–St Paul,
|
||||
Helsinki, the Netherlands, Norway, South East Queensland, and nine in
|
||||
Ontario — Toronto (TTC surface vehicles; the subway publishes no
|
||||
positions), MiWay, Hamilton HSR, Durham Region, YRT/Viva, Guelph,
|
||||
Burlington, Barrie and Kingston. Mode silhouettes
|
||||
identify buses, trams, subways, trains, ferries and unclassified vehicles.
|
||||
|
||||
Retained transit history uses the same outlier quarantine as live observations and
|
||||
@@ -3917,7 +4292,7 @@ silently demoting every later lookup for the session.
|
||||
- The internal Context coordinator is available in every visual style. Its dedicated right-side `CONTEXT` chooser exposes the neutral shell; the coordinator is not duplicated in Data Layers and does not enable a live-data dependency until a mode is selected.
|
||||
- The expanded `CONTEXT` view offers mutually exclusive `CONTACTS` and `SPACE MISSIONS` modes. Selecting `CONTACTS` enables the context-owned Flights, Military Flights, AIS Vessels, and Mapped Installations dependencies only when they are not already user-enabled; selecting `SPACE MISSIONS` enables the recent-launch layer and its Satellite dependency. `CONTACTS` cycles the nearest supported contact of whatever type is selected. Satellites are deliberately excluded from those Awareness cohorts and keep their own tracking UX. Selecting the active mode again returns to the neutral chooser and releases only mode-owned dependencies.
|
||||
- If a civilian or military aircraft is already tracked when `CONTACTS` becomes operational, that source-owned track is adopted as the Context subject before nearest-contact autofocus. Context rechecks the tracker after its dependencies settle, so a newer selection wins, while an explicit clear during activation prevents fallback from silently selecting a replacement. Cockpit entry remains unavailable until that Context transaction has settled, so its camera takeover cannot clear Cesium tracking before adoption. Adoption does not recreate tracking or transfer camera ownership; it initializes the normal 250 km ring, history, proximity results, and Cockpit Previous/Next state for the original aircraft.
|
||||
- `SEARCH NEARBY SITES` retains the bounded OSM results and makes one user-initiated, view-biased Google Maps Places text search for “military installation.” Google results are source-stamped, deduplicated against OSM by rounded location/name, and remain mapped context rather than operational claims. If Places is unavailable or the API is not enabled for the supplied key, OSM context remains available.
|
||||
- `SEARCH NEARBY SITES` retains the bounded OSM results and makes one user-initiated, view-biased Google Maps Places text search for “military installation.” Google results are source-stamped, deduplicated against OSM by rounded location/name, and remain mapped context rather than operational claims. If Places is unavailable or the API is not enabled for the supplied key, OSM context remains available. A view across the antimeridian is searched around its own centre, with a radius from its real width.
|
||||
- The expanded desktop header omits the redundant `ON` label; the active mode button carries state. Expanded Contact results also omit the duplicate `GLOBAL CONTEXT` / `CONTEXT ONLY` status row and begin with the selected subject and its 250 km scope. Global Context does not fabricate a selected-entity model preview: the provisional hand-authored aircraft wireframe was removed because it was not geometry extracted from the selected entity's actual asset.
|
||||
- Dependency ownership is reversible: disabling Global Context releases only dependencies it enabled, while user-enabled layers remain on. This also removes the Military-layer suppression handoff when Global Context owned Military, allowing an already-enabled civilian Flights layer to resume its normal mixed rendering. If OpenSky is unavailable and has no last-good cache, Flights requests a capped 250 nm adsb.lol point snapshot around the current view anchor and labels that provenance explicitly; it never relabels military-feed rows as civilian data. If both inputs fail, Flights remains `UNKNOWN`.
|
||||
- Space Missions is replay-isolated: Rocket Launches and Satellites are the only Data Layers permitted while the mode is active. Direct UI and voice entry capture the same pre-entry snapshot; internal dependency and restoration enables do not create a user-owned Context session. Entry waits for incompatible layers to shut down, direct incompatible enables are blocked before lifecycle work, and the entry gate remains active through the complete Rocket Launches enable. A newer same-target ON request takes ownership of the pending entry without releasing its isolation snapshot, including when it arrives while the prior request is awaiting the adoption guard. A caller abort, resource cancellation, newer OFF, or layer teardown waits for exact manager settlement and restores that snapshot without resurrecting Rocket Launches. If an abort lands after only part of a restore settles, Context completes the same exact target without the stale caller signal and then replays newer explicit layer intent. Dedicated Voice Context cancellation reports a stable cancelled result plus the current Context state; generic layer visibility additionally exposes manager phase, reason, successor, and lifecycle details. Once an exact voice visibility intent commits, a newer voice turn cannot relabel it as cancelled while Context settlement completes; pre-commit aborts remain cancellable and final lifecycle mismatches remain failures. Clear Selected Layers reserves its complete captured OFF set before sequential teardown; a newer absolute request of any origin supersedes only its layer reservation and remains authoritative. A rejected layer teardown retains the truthful enabled state, rolls already-stopped siblings back to the captured pre-entry set, and aborts replay; rapid exit/re-entry serializes the full Satellite enabled-state and parameter restore before a new snapshot is taken. Contacts remains additive and restores user-enabled layers normally.
|
||||
@@ -3954,6 +4329,14 @@ silently demoting every later lookup for the session.
|
||||
(`calSource: 'manual'`) or curated pose (`poseSource: 'curated'`) is never presented as
|
||||
estimated, matching the CAL badge. Public camera state carries `headingConfidence` and
|
||||
`headingEstimated` (`src/layers/cctv/headingConfidence.js`, #639).
|
||||
- **CCTV identified hardware:** a camera whose source or seed names a hardware `model` that
|
||||
resolves in `src/layers/cctv/modelSpecs.js` (vendored CC0 records from the CCTV Camera
|
||||
Database) gets the model's specs appended to the HUD meta line. Only fixed single-lens
|
||||
hardware fills the FOV, and only when the source or seed states none and the pose is not
|
||||
curated, shown as `FOV n° (DATASHEET)` until a manual calibration moves it. PTZ, varifocal and
|
||||
multi-sensor datasheets publish a capability range, not the lens's current zoom, so those
|
||||
cameras keep their estimate and show `SPEC FOV min–max° (CAPABILITY)` beside it. Public
|
||||
camera state carries `fovSource`, `specFovMinDeg`, `specFovMaxDeg` and `spec` (#452).
|
||||
- **FIRMS**: no ground clamping (zero 3D-tiles height sampling), ≤18 screen-decluttered ambient labels, click-to-inspect detail card, 2.5k/3k sprite budgets viewport-clipped by FRP.
|
||||
- **CCTV v2 foundation:** a pitched
|
||||
frustum wireframe (4 corner rays + far-cap rectangle) with a monitor plane at the frustum's
|
||||
@@ -4055,6 +4438,10 @@ for, resolved lazily after env loading. It replaces the empty default list;
|
||||
there is no built-in fallback. Local/private instances are allowed. The stable
|
||||
`gods-eye-view/…` User-Agent is unchanged. URLs never appear in response headers
|
||||
or upstream errors, including old cached endpoint metadata.
|
||||
Disk cache entries for both routes carry a SHA-256 tag of the configured
|
||||
instance set (order does not matter) and are served only while it matches, so
|
||||
repointing `OVERPASS_UPSTREAMS` refetches instead of serving another instance's
|
||||
answers; untagged entries stop serving once an instance is configured.
|
||||
|
||||
The shared transport guards `/api/overpass` and `/api/military-installations`.
|
||||
With no configured upstream, each route serves existing cache data (including
|
||||
@@ -4072,7 +4459,8 @@ when its own signal aborts.
|
||||
Configured endpoints have per-instance cooldowns for 406/429, honoring
|
||||
Retry-After; absent delays back off from 30 seconds to five minutes. Empty
|
||||
`elements` is a valid answer. Existing byte, query, concurrency and cache caps
|
||||
remain. Consumer capability misses never schedule automatic retries.
|
||||
remain. Malformed capability-probe responses remain unknown and are retried after the existing 30-second backoff; valid configured decisions remain memoized.
|
||||
Consumer capability misses never schedule automatic retries.
|
||||
|
||||
Operator-configured Overpass area and focus-footprint queries use body-level
|
||||
`out geom` (with `center` for focus), preserving relation member outlines.
|
||||
@@ -4341,12 +4729,17 @@ Coordinate input requires exactly two nonempty components; extra comma or semico
|
||||
|
||||
Location search/fly-to, annotations and Radio location lookup receive one
|
||||
`placeSearch.geocode(query, { bias, signal })` service. `src/standalone` composes
|
||||
two offline providers first — a strict decimal-degree coordinate reader and an
|
||||
two offline providers first — a strict decimal-degree/geo-URI coordinate reader and an
|
||||
exact-match reader over the bundled city and landmark names — then Google when
|
||||
configured and Photon/OpenStreetMap as fallback, including Google transport
|
||||
failures or declined requests. The offline pair answer with no request and no
|
||||
key, and decline everything they are not certain of: a coordinate must be a
|
||||
decimal-degree pair (either order when N/S/E/W fix the axes), and a bundled name
|
||||
decimal-degree pair (either order when N/S/E/W fix the axes), or a two-dimensional
|
||||
WGS84 `geo:latitude,longitude` URI with an optional `;crs=wgs84`. URI scheme and
|
||||
CRS names are case-insensitive; internal whitespace, altitude, uncertainty, other
|
||||
parameters, query strings and fragments decline rather than silently dropping
|
||||
information. For example, `geo:39.7392,-104.9903` resolves offline to the same exact
|
||||
point, decimal label and framing box as `39.7392, -104.9903`. A bundled name
|
||||
must match exactly, so `12junk, 34oops` and `austin, tx` both reach the
|
||||
geocoders instead of being answered from the bundle. Degrees/minutes/seconds and
|
||||
grid references are not parsed. An answer either provider gives is exact, so
|
||||
@@ -4370,7 +4763,24 @@ Providers normalize coordinates, canonical name, label, place types and optional
|
||||
bounds. Camera framing, nearby landmark recovery and footprint selection remain
|
||||
in their consumers, including the Capitol identity/containment safeguards.
|
||||
Radio keeps localized country names in labels rather than station filters.
|
||||
Reverse geocoding and nearby/text-search endpoints retain their existing behavior.
|
||||
Nearby/text-search endpoints retain their existing behavior.
|
||||
|
||||
**Google geocoding is a server route, not a browser call** (#363). Forward and
|
||||
reverse lookups go to `/api/google/geocode` and `/api/google/reverse-geocode`,
|
||||
which carry `GOOGLE_MAPS_SERVER_API_KEY` (falling back to `GOOGLE_MAPS_API_KEY`)
|
||||
and share the Places routes' opt-in `GEV_RATELIMIT_GOOGLE_PER_MIN` throttle.
|
||||
Google's Geocoding **web service** refuses referrer-restricted keys, so the
|
||||
previous browser call forced the bundled browser key — visible in devtools by
|
||||
design — to be left unrestricted; it now needs only the Map Tiles API and the
|
||||
Maps JavaScript API (Street View). Both routes answer in Google's own
|
||||
`status`/`results` shape rather than the Places routes' `places: []` contract,
|
||||
because the browser adapters normalize that shape directly. The browser key still gates the provider client-side, so a keyless
|
||||
session spends no request to learn it has no Google: the route's own keyless
|
||||
answer reads as an unanswered provider, which would stop the chain caching the
|
||||
negative Photon or Nominatim then produces. Its query is capped at 200
|
||||
characters, its reverse coordinates are validated against WGS84 bounds before
|
||||
the request is paid for, and a malformed viewport bias is dropped rather than
|
||||
refused, since a bias only ranks results.
|
||||
|
||||
Only valid answers and definitive misses enter bounded caches; malformed replies,
|
||||
HTTP refusals and outages remain retryable. Searches share a 12-second total
|
||||
@@ -4386,7 +4796,7 @@ are omitted rather than framing the wrong part of the globe.
|
||||
- The bottom Visual Presets tray presents a **five-tile MAP SOURCE row** (`#map-stack-chips`, `src/mapStackChips.js`): Google 3D, Esri Satellite, Bing Aerial, Bing Labels, and OSM. The duplicate left `#stack-panel` is retired. The five tiles share one row on desktop and two rows on narrow screens, carry `aria-pressed` on the active source, and remain keyboard-reachable with a visible focus outline.
|
||||
- The lit tile follows controller state, not the click: a rejected switch (no ion token) or a superseded one (rapid A→B) leaves the genuinely active source lit, and the tray heading keeps its short-label status readout (`...` while switching, amber on `lastError`).
|
||||
- Ion stacks remain visible and keyboard-focusable when no ion token is configured, but expose `aria-disabled="true"` and do not switch. Their accessible label and tooltip quote `getStacks().unavailableReason` — the same string `setStack()` puts in the toast. OSM works keyless. The `ION` badge is gated on the stack's own `requiresIon`, so a `photoreal` chip unavailable because the Google tileset failed says so instead of falsely demanding an ion token.
|
||||
- Stack choice participates in share links (`src/sharelink.js`) and falls back to the best available stack when the requested one is unavailable (keyless boots land on Esri; OSM takes over automatically if Esri is unreachable). Share-link restore, the `set_map_stack` voice tool, and the chip row all land on the same `_setMapStack()` path.
|
||||
- Stack choice participates in share links (`src/sharelink.js`) and falls back to the best available stack when the requested one is unavailable (keyless boots land on Esri; OSM takes over automatically if Esri is unreachable; while that construction fallback is on screen Esri is retried in the background, 30 s doubling to a 10-minute cap with each attempt abandoned after 60 s, and replaces OSM once it loads. The switch to OSM after repeated Esri tile failures is not retried; selecting Esri again tries it). Share-link restore, the `set_map_stack` voice tool, and the chip row all land on the same `_setMapStack()` path.
|
||||
|
||||
### Voice Map Whiteboard / Annotations (June 2026)
|
||||
|
||||
@@ -4453,15 +4863,34 @@ are omitted rather than framing the wrong part of the globe.
|
||||
- Pure half: `src/annotations/drawMode.js` (session, minimum vertices, double-click de-dup at 0.5 m, vertex ceiling, degenerate-geometry refusal, ring closing, dateline-safe length/area/centroid, hint text). Regression surface: `src/annotations/drawMode.test.mjs` and `src/data/inputOwnership.test.mjs` (pure), `src/annotations/drawToolBehaviour.test.mjs` (the tool driven against DOM and viewer doubles — leases, borrowed input actions, teardown, the Enter key, preview heights), `src/annotations/drawTool.test.mjs` (structural: where files live, which ids the markup carries, what the docs may claim), and the rendered harness `scripts/qa-draw-tool.mjs`.
|
||||
- The annotation tree is a declared ownership group: `./annotations` → `src/annotations/index.js` in `package.json`, with its module graph listed in `scripts/package-boundaries.json`, so an annotation module reaching outside that graph is a gate failure rather than a surprise in a layer's bundle. `drawMode.js`, `drawTool.js` and their tests are in `scripts/format-scope.json`.
|
||||
|
||||
### Import Your Own Files (October 2026)
|
||||
|
||||
- DISPLAY ▸ **Import** reads GeoJSON (`.geojson`/`.json`), KML, KMZ and GPX from the picker or a file drop anywhere in the app. Each file gets show/hide, Fly-to and Remove controls. GeoJSON and GPX use the row's color; KML/KMZ retain their styles and show a hatched swatch. The file bytes remain on the device for the page session. Rendered overlays can appear in map screenshots sent through voice or other existing screenshot tools.
|
||||
- **Ground and height.** GeoJSON XYZ coordinates become ground footprints. KML ground lines/rings are tessellated; static recorded tracks preserve their original vertices, segment gaps and explicit altitude modes. KML with absolute/relative altitude keeps that meaning. Ground pins resolve in batches of 256 against loaded terrain and, in the nearby camera footprint on photoreal maps, `clampToHeightMostDetailed`, then draw in one owned native BillboardCollection per file, with static positions and no per-frame point Entity visualizer or clamping callbacks. Point metadata remains in `dataSource.importedEntities` for framing/style inspection; non-point geometry stays in the normal source collection. Pin collections mount only after source admission, follow file show/hide, and are destroyed on removal. Settled camera/map-source changes and completed terrain loads refresh visible files through the same serial queue; distant points retain their latest available height. No detailed worldwide terrain sweep is started. Nearby mesh sampling can add requests/time; configured photoreal providers require separate validation. The depth test is disabled for ground pins, matching Draw's marker visibility policy.
|
||||
- **Navigation.** Row Fly-to uses `runImmediateNavigation('file', …)`; Cockpit can refuse it and existing follow/track ownership is released first. Automatic Fly-to is stamped when a batch is submitted and reasserted before flying. Pointer/wheel gestures and newer navigation supersede it, including time spent in the queue. Framing uses every finite visible point/path/polygon-hole/wall vertex plus rotated overlay bounds, with viewport-aware range and a nadir view. The target uses available terrain/cached ground height and the ground guard on arrival. Empty footprints never fall back to deferred `viewer.flyTo`.
|
||||
- **Clock.** Imports expose no source clock and no entity availability. KML times/view metadata are removed before loading; recorded tracks become static lines. GPX segments remain separate and GPX times are ignored. The shared clock stays unchanged.
|
||||
- **Input handling.** Coordinates and GeoJSON geometry types are checked before Cesium allocation. Only short plain names reach GeoJSON property bags; file IDs, custom CRS and styling properties are removed. KML feature/geometry IDs are removed while Style/StyleMap references remain. Local style references are trimmed, incomplete style pairs removed, hidden-folder flags normalized to the import's show control, and unsupported prototype-named elements removed. UTF-16 BOMs and common KML namespace export defects are handled. Empty drawable content is refused. A loader failure or timeout does not hold the next file indefinitely; teardown aborts pending waits.
|
||||
- **References and images.** File-authored remote links/icons/styles, network links, models and screen/photo overlays are removed. Packed KMZ and embedded PNG/JPEG/GIF/WebP/BMP images remain local; decoded dimensions are checked before image decoding against a total 16-megapixel and 8,192-pixel-side budget per file. Blocked IconStyle images fall back to local pins; authored empty icons remain empty. Colored overlays without an Icon survive; overlays with a removed external image are dropped. Metadata, native labels and descriptions are removed, and generated pins share one lossless image URL per canvas.
|
||||
- **Bounds.** File size ≤50 MiB; inflated KMZ ≤120 MiB; ≤20,000 entities (including multipart children and Folder containers); ≤250,000 positions; ≤2,000 normalized style definitions and distinct image links; ≤12 files per session. Exact repeated styles share definitions without changing inheritance or inline overrides. Files of 20–30 MB fit the byte budget, subject to geometry/image limits. Refusals name a reason and batches continue; with multiple failures the hint shows the first reason.
|
||||
- **Lifecycle and keyboard.** `createApplicationTools` wires `initGeoFileImport` with viewer, navigation, ground services and map-source subscription. Destroy aborts pending work, unsubscribes, removes data sources, revokes blob URLs, removes document/canvas listeners and releases `window.__gevGeoImport`. List rebuilds retain keyboard focus; removing a row hands focus to the next row, else the previous row, else Import.
|
||||
- **Validation.** `node scripts/qa-geo-import.mjs` covers all four formats and real painted show/hide. Run `node scripts/qa-geo-import-regressions.mjs` against both dev and preview for XYZ/track heights, failure recovery, namespace/BOM support, portrait framing and panel drops. `node scripts/qa-geo-import-large.mjs [--file /absolute/path/sample.kml] [--expect-local-pins]` checks a 27.6 MB repeated-style file, native style/image equivalence, large-file painted output and frame times. It records the WebGL renderer; software-rendered runs do not establish GPU performance. Unit tests live beside the data/controller/framing modules.
|
||||
|
||||
### Recent Imagery (September 2026)
|
||||
|
||||
CMR pagination counts raw rows when checking page completion and the record budget. A catalog is truncated when upstream hits exceed the rows examined or the record cap drops valid granules from the last page; discarded malformed rows do not imply unseen imagery.
|
||||
|
||||
Programmatic boxes passed to the exported `setBox` and `searchHls` operations
|
||||
reject missing, blank or boolean edges instead of interpreting them as zero
|
||||
coordinates. Valid finite numeric edges remain accepted; the app’s drag tool,
|
||||
pins and share links already supply complete boxes.
|
||||
|
||||
- The layer sits in the Data Layers panel's Cameras group beside Cameras (CCTV); Mapped ALPR Cameras moved to the Infrastructure group to make room.
|
||||
- DATA ▸ **Recent Imagery** (`recent-imagery`, `src/layers/recentImagery/`) lists the last 30 UTC days of imagery for a selected box and drapes the days you pick: HLS S30 (Sentinel-2) and L30 (Landsat 8/9) at 30 m, and the VIIRS NOAA-21 daily overview at 250 m. Keyless and browser-direct: NASA CMR for which days have HLS granules in the box (`catalog.js`, both collections concurrently, paged with `CMR-Search-After` up to 2,000 records each, `truncated` against CMR `hits`), GIBS WMTS for tiles, Worldview Snapshots for thumbnails and exports. Worldview's `Data-Present` header is the only availability signal: GIBS answers an empty tile with HTTP 200, and the JPEG overview would drape black, so a day confirmed empty never drapes.
|
||||
- **Box.** SELECT BOX (`src/ui/imageryBoxTool.js`) claims the pointer like Draw, borrows the stock click actions and freezes the camera only mid-drag; USE VIEW takes the camera rectangle; `boxFromPinAt` grows a 10 km box. Boxes must be ≤ 1,000 km a side, on one side of the dateline and inside the Web-Mercator limit (`validateBox`). A refusal (`boxError`, including the tool's own via `reportBoxRefusal`) stays at the top of the panel until a box succeeds; a view over the cap is refused with its size, never shrunk. An oversized box or view (`Box is … km wide · limit 1,000 km`, `View is … wide · …`) sets the hint to `Zoom in or draw a smaller box` and enables ZOOM IN: `zoomToFit()` flies top-down (1.2 s) to the refused box's centre, or the ground under the canvas centre for a view, at the height whose view is about 400 km wide for the canvas aspect and the camera's vertical FOV (`fitViewHeightM`, clamped 5–400 km), and returns that height.
|
||||
- **Modes, pins and the preview.** The mode is explicit: `image` (one pinned day, slot `a`), `basemap` (that day on the left of a swipe, the basemap on the right: slot `a` splits LEFT and there is no second layer) and `ab` (day `a` left, day `b` right). `setAssignment(slot, key | null)` / `toggleAssignment(slot, key)` pin and unpin; a day is in one slot at most (pinning it in the other slot moves it) and an empty day cannot be pinned. `setMode()` keeps both pins; `b` only drapes in `ab`. While the mode's first empty slot exists the focused day previews in it: a click at once, arrow keys after 250 ms of quiet (`FOCUS_DEBOUNCE_MS`, one timer, last day wins); an unprobed day is followed and previews when its probe says present, an empty or unknown day never drapes and the previous preview stays. A pin change drops the preview until focus moves again, and unpinning takes that layer off at once. `rankLatest` picks START HERE coverage first (a present HLS day whose footprints cover the box, or have none, beats a newer sliver), then newest with all granules ≤ 20% known cloud, then newest; slivers only as `partial`; else the newest non-empty VIIRS day; it is focused and previewed after each search when nothing is pinned. Escape clears the preview (`clearPreview()`), then cancels the box tool, then blurs — with SELECT BOX armed too, through the tool's `onEscape` interceptor wired in `createApplicationTools`. Pins, mode, box and split changes made in the panel are published with `adoptLayerParams`, never while a share link is being applied.
|
||||
- **Rendering.** `rendering.js` owns at most two GIBS layers on the host `src/maps/imageryHost.js` resolves: `viewer.imageryLayers` while the globe shows, the photoreal tileset's `imageryLayers` while it is hidden, or none (the panel says so). Layers are rebuilt on a new host, never re-parented. `MapSourceController.subscribe()` fires after every settled stack switch (silent switches and fallbacks included) and the layer rebinds on it. Tile requests pass a six-slot semaphore of our own (Cesium's global limits are untouched). Under `requestRenderMode` a deferred tile needs a later frame, but a deferral must never request one itself or a stalled tile keeps the scene rendering every frame: a request that finds the semaphore full asks for nothing (the settling request asks for one frame), and a tile Cesium itself deferred goes through one coalesced 250 ms retry. Thumbnails: three fetches at once, sixteen decoded images, least-recently-used where a `get` read is never a use; eviction keeps the probe result. The strip's visible window is probed around the focused card; the pinned and previewed days are probed wherever they sit, at the focused card's priority, so a pin restored or assigned off-screen still drapes.
|
||||
- **Esri surface and the swipe.** While any image is on the map (a preview included) the layer holds the map lease (`acquireImageryComparison`, owner `recent-imagery`, `esri-if-photoreal`): on Google 3D the map switches to Esri, because 30 m pixels over 3D buildings do not read, and Cesium's split only works on the globe. The lease is handed back, and Google 3D restored unless the operator changed maps meanwhile, when the last image leaves the map or the layer is disabled; the notice line says so while it holds. While a day is shown the imagery keeps Esri: a manual switch that lands on Google 3D is answered at once with a new lease under the same policy (`keepEsri`), the notice reads `Imagery stays on Esri · CLEAR to use Google 3D`, and the divider never suspends because of a map switch. It re-leases once per switch generation and only for a manual one (`MapSourceController#getSwitchOrigin()` is `manual` for an outside `setStack`, `automatic` for the controller's own fallback or recovery), never for the lease's own switch and never while releasing, so CLEAR and disable still return to Google 3D; a lease another owner holds stays refused. When Esri fails and the controller falls back to Google 3D on its own, the layer lets the lease go without restoring anything, both days drape on the tileset without a swipe and the guidance reads `Esri map unavailable · no swipe`; only the next manual switch to Google 3D takes Esri again, so a failing Esri map is never retried in a loop. Switching between globe stacks (Esri, OSM, Bing) only rebinds. On a tileset host both images drape without a swipe. The split control (`createImagerySplit`, labels A / B, or IMAGE / BASEMAP) exists only while a swipe is live, recentres on every new comparison (not while scrubbing the second day), except once for a split restored from a link. There is no keyboard shortcut (Space is the app's push-to-talk): SWAP (`#ri-swap`, in the controls row, its slot reserved and the button disabled while no swipe is live) trades the two sides, A to the right and B to the left, or the image to the right of the basemap, and a second click restores; the hint reads `Drag the divider · SWAP trades sides`. The swap is not in the share link, and every new comparison starts unswapped. One opacity applies to both drapes. Another owner holding the lease is reported and the images still drape.
|
||||
- **Panel.** `#recent-imagery-panel` is static rail markup in `src/ui/templates/context.html` beside `#weather-panel`, registered in `PanelLayoutController`, `PanelChrome` and the share panel state (`ui` token `i`), hidden while the layer is off and opened on first appearance unless a stored or shared collapse choice exists (`dataset.collapsedPreference`). `LayerPanel.attachRecentImagery(factory)` mounts the readout (`createRecentImageryPanel` in `src/ui/recentImagery.js`) into its `data-rail-scroller` body, as the weather readout is mounted. The body is a fixed stack in which no block changes height and no control moves in any state: `#ri-actions` (SELECT BOX · USE VIEW · CLEAR, rail-card actions), `#ri-notice` (one line: `#ri-notice-text` with the refusal, error, CLEAR notice or the Esri note, and `#ri-zoom-in` in a fixed slot at its right end, reserved unless an oversized box or view can be fitted), `#ri-strip` (day cards newest first: thumbnail, date, sensor, cloud, START HERE, PREVIEW, and a SHOW chip or A and B chips; ← → Home End move, S / A / B pin, Enter previews), `#ri-hint` (one line: the next step), `#ri-selection` (`#ri-mode` IMAGE · VS BASEMAP · A / B, then `#ri-slot-a` and `#ri-slot-b` with `#ri-unpin-a` / `#ri-unpin-b` kept in place when empty), `#ri-controls` (`#ri-opacity`, EXPORT / EXPORT A and EXPORT B) and `#ri-details`, a collapsed rail card holding the focused day's acquisition, coverage and granules, catalog notes, the empty-days toggle and the imagery credit. Absent controls are reserved (invisible), never removed. Every render preserves the body's `scrollTop`.
|
||||
- **Panel.** `#recent-imagery-panel` is static rail markup in `src/ui/templates/context.html` beside `#weather-panel`, registered in `PanelLayoutController`, `PanelChrome` and the share panel state (`ui` token `i`), hidden while the layer is off and opened on first appearance unless a stored or shared collapse choice exists (`dataset.collapsedPreference`). `LayerPanel.attachRecentImagery(factory)` mounts the readout (`createRecentImageryPanel` in `src/ui/recentImagery.js`) into its `data-rail-scroller` body, as the weather readout is mounted. The body is a fixed stack in which no block changes height and no control moves in any state: `#ri-actions` (SELECT BOX · USE VIEW · CLEAR, rail-card actions), `#ri-notice` (one line: `#ri-notice-text` with the refusal, error, CLEAR notice or the Esri note, and `#ri-zoom-in` in a fixed slot at its right end, reserved unless an oversized box or view can be fitted), `#ri-strip` (day cards newest first: thumbnail, date, sensor, cloud, START HERE, PREVIEW, and a SHOW chip or A and B chips; ← → Home End move, S / A / B pin, Enter previews), `#ri-hint` (one line: the next step), `#ri-selection` (`#ri-mode` IMAGE · VS BASEMAP · A / B, then `#ri-slot-a` and `#ri-slot-b` with `#ri-unpin-a` / `#ri-unpin-b` kept in place when empty), `#ri-controls` (`#ri-opacity`, EXPORT / EXPORT A and EXPORT B) and `#ri-details`, a collapsed rail card holding the focused day's acquisition, coverage and granules, catalog notes, the empty-days toggle and the imagery credit. Absent controls are reserved (invisible), never removed. Every render preserves the body's `scrollTop`; opening DETAILS then scrolls its card into view once.
|
||||
- **Share links.** Layer options (token `1`): box edges as integers at degrees × 100000, pins `a` and `b` as compact days (`S20260918`), `m` mode (0 image, 1 basemap, 2 A / B), `p` split percent (share-link only: never stored locally) and `v` for the daily overview. Row chips: `MORE DETAIL · 30 m` (on) and `DAILY OVERVIEW · 250 m` (off).
|
||||
- Regression surface: the colocated `*.test.mjs` beside each module (shared doubles in `src/layers/recentImagery/testDoubles.mjs`; `src/ui/recentImagery.test.mjs` includes a DOM-structure guard: every control keeps its place in the tree, nothing is hidden, and every fixed-height block keeps the class the stylesheet fixes its height by; it has no layout, so it cannot see movement), `src/ui/layerPanel.test.mjs` (mount), `src/maps/controller.test.mjs` (`subscribe`), `src/data/layerState.test.mjs` (codec), `src/sharelink.celestial.test.mjs` (panel token), and the live gate `scripts/qa-recent-imagery.mjs`, which also measures every control's rect, relative to the panel, across the transitions within 1 px; that measurement is the real layout-shift check. `QA_RECENT_IMAGERY_SLOW_RENDER=1` gives loaded software renderers a 4 s zoom deadline and an 8-sample stability tail; the default stays 2 s and 12 samples.
|
||||
|
||||
@@ -4469,7 +4898,7 @@ are omitted rather than framing the wrong part of the globe.
|
||||
|
||||
- **The TRACKED contact's 2D↔3D handoff is DEFAULT behaviour (2026-08-19), driven by camera distance alone.** It does NOT consult the DISPLAY-rail `3D` toggle, which continues to own the FLEET (the un-instanced draw-call budget stays the operator's decision). Policy lives in `src/data/trackedModelRegime.js` and is shared by both layers: enter below `TRACKED_MODEL_ENTER_ALT_M` = 150,000 m and hand back to the billboard only above `TRACKED_MODEL_EXIT_ALT_M` = 172,500 m. **The swap distance was set by playtesting on 2026-08-20:** a first pass at 1,000,000 m switched too early; 2D reads correctly at ~600 km and the handoff belongs at ~150 km. **Consequence, recorded on purpose:** the tracked contact now enters 3D NEARER than the FLEET does (`MODEL_ALT_CEIL_M` = 800,000 m, unchanged), so with the DISPLAY-rail `3D` toggle on, 150–800 km draws surrounding contacts as models while the selected one is still a glyph. Nothing double-draws (the fleet pass skips the tracked icao) and aligning the two is a fleet-side decision, deliberately out of scope. **The two thresholds are asymmetric on purpose:** a single threshold makes a tracked orbit sitting ON the boundary strobe billboard↔model as the camera's altitude wobbles across it. Do not collapse them. The latch is scoped per selection, so a new target re-evaluates against the ENTER ceiling rather than inheriting the previous target's exit band. Exactly ONE model is involved; it loads on demand when the regime opens, is HIDDEN (not released) on regime exit so re-entry has no load gap, and is released by the existing teardown on deselect/re-track/destroy. Cockpit and TR-3B suppression are unchanged. **Two invariants around it:** (a) the hysteresis latch AND the load-failure latch are per-selection state cleared by `_resetTrackedSelectionState()` in the tracking lifecycle (deselect / re-track / cross-layer / init / destroy) — the predicate's icao-change guard is defence only, since it needs a drawn frame while nothing is selected and the render governor's idle mode does not promise one; (b) on-demand loading is bounded at 3 attempts per selection with a 1.5 s backoff and one console warning naming the asset — the driver runs every `scene.preUpdate`, so an unbounded catch means a missing GLB spins load→reject at frame rate. The billboard stays the visual throughout a failed load.
|
||||
- **Grounded 3D handoff is terrain-validity gated (2026-08-23).** A ready civilian or military glTF does not own the visual until `groundSnap` (`src/data/groundSnap.js`) can answer with a MEASURED photoreal-surface height. On success the layer writes `height + the model's measured belly offset` before revealing the model. Cache movement is measured on the WGS84 surface, not across altitude, so a stationary contact keeps its snap through poll-time vertical-datum changes. Model existence or GPU readiness alone never suppresses the billboard floor, and ordinary zoom/style/deselect transitions preserve a valid snap cache. **Two states, and the difference is the whole design.** COLD — nothing has ever resolved for this icao (tiles still streaming on first sight, sample failure, backoff after a first miss): there is no evidence of where the ground is, `heightFor` returns null, the model stays hidden and the depth-test-free 2D billboard remains opaque and floored. WARM — a snap resolved and then a >`MOVE_INVALIDATE_M` (50 m) taxi move stopped it answering directly: the measurement is DEMOTED to a bounded last-known rather than deleted, and it keeps answering while the resample is outstanding, so a taxiing aircraft does not pop 3D→2D→3D across a 2–30 s retry backoff. The bound is `HELD_SNAP_MAX_DRIFT_M` = 250 m from the spot the value was measured at, past which the hold is dropped rather than stretched and the contact is COLD again. It is spatial with no timer beside it (ground under a contact that has not moved does not change; what invalidates the value is the contact MOVING) and deliberately a quarter of the billboard chain's `HELD_FLOOR_MAX_DRIFT_KM` — that hold only ever RAISES a sprite, while a held snap IS the model's placement, so its error shows in both directions. A fresh sample releases the hold, and so does a ground flip (which already calls `forget`). **A loading model is HIDDEN, never zero-scaled:** admission sets `show = false` (Cesium's default is `true`, and an unplaced primitive would claim the visual at the identity matrix), and ownership is `ready && show` — Cesium 1.138's `Model.update` has no `show` guard, so hiding a primitive costs its load nothing.
|
||||
- Commercial and military aircraft use the same high-level FLEET model regime: 2D billboards when zoomed out, optional glTF models when closer, controlled by the DISPLAY rail `3D` toggle and `Proximity` / `All` modes. Since 2026-08-16 (Hangar fleet) models are PER-CLASS: real CC-BY GLBs for light/bizjet/turboprop/widebody/helicopter/uav (`CLASS_MODEL_REAL` in `src/data/aircraftClass.js` — meters-baked, scale 1, per-model belly/radius; provenance in `public/models/README.md`), the shared `airplane.glb` for the remaining civilian classes, and the military layer maps weight classes (real GLBs / 747 heavies / `jet.glb` fastjets, per-model heading offsets, always flat amber). Textured civilians carry a HEAVY tint, not a light one: `MODEL_COLOR_BLEND_AMOUNT` is `0.94` in BOTH layers under Cesium's `ColorBlendMode.MIX`, so the class colour supplies 94% of the surface and the asset's own texture ~6%. The visual direction is clean light silhouettes with only a weak diffuse contribution from the approved textures, so liveries deliberately do NOT read. IR boost raises the blend to a full `1.0`. Under NVG/FLIR (map preset or Cockpit vision) models render unlit flat-white at full alpha and scene fog is disabled (fog otherwise blacks out distant models with the globe hidden); state restores on exit.
|
||||
- Commercial and military aircraft use the same high-level FLEET model regime: 2D billboards when zoomed out, optional glTF models when closer, controlled by the DISPLAY rail `3D` toggle and `Proximity` / `All` modes. Since 2026-08-16 (Hangar fleet) models are PER-CLASS: real CC-BY GLBs for light/bizjet/turboprop/widebody/helicopter/uav (`CLASS_MODEL_REAL` in `src/data/aircraftClass.js` — meters-baked, scale 1, per-model belly/radius; provenance in `public/models/README.md`), the shared `airplane.glb` for the remaining civilian classes, and the military layer maps weight classes (real GLBs / 747 heavies / `jet.glb` fastjets, per-model heading offsets, flat amber or emergency red). Textured civilians carry a HEAVY tint, not a light one: `MODEL_COLOR_BLEND_AMOUNT` is `0.94` in BOTH layers under Cesium's `ColorBlendMode.MIX`, so the class colour supplies 94% of the surface and the asset's own texture ~6%. The visual direction is clean light silhouettes with only a weak diffuse contribution from the approved textures, so liveries deliberately do NOT read. IR boost raises the blend to a full `1.0`. Under NVG/FLIR (map preset or Cockpit vision) models render unlit flat-white at full alpha and scene fog is disabled (fog otherwise blacks out distant models with the globe hidden); state restores on exit.
|
||||
- The DISPLAY-rail `3D` toggle is the user-facing activation path for both aircraft layers. Their small approved GLBs build their render resources without Cesium's frame-spread job queue, preventing continuous Photorealistic 3D Tiles streaming from starving model readiness; model caps, tracking, camera, and fallback billboards are unchanged.
|
||||
- **The `3D` toggle DEFAULTS ON in `proximity` on a first run.** Proximity is itself the budget — models appear only below `MODEL_ALT_CEIL_M` and only for the nearest `MODEL_MAX` in view — so the default costs nothing at globe scale, and `all` remains a deliberate opt-in. A fresh boot runs NO layer-state restoration (`LayerStateCoordinator.start()` returns early with neither a share payload nor stored state), so four independent initializers decide what a first-run operator sees and must agree: `booleanOption('models3d', 'e', true)` in `src/data/layerState.js`, `_models3dEnabled = true` in BOTH flight layers, `this._models3dEnabled = true` in `src/ui.js`, and the `active` / `visible` classes on `#models3d-toggle` / `#models3d-mode-row` in `index.html`. All four are pinned together in `src/data/layerState.test.mjs`. Explicit state still wins: because the codec omits default-valued options, `models3d: false` is now what travels in a link (`lo=…f.e.0`) and restores OFF at both aircraft layers. **Consequence for returning users:** a stored `gev:layer-state:v2` blob is a FULL options snapshot, so a session that wrote one before this change carries `models3d:false` and keeps 3D off until the operator flips it (or clears the key) — the durable snapshot is treated as the recipient's own state, by design.
|
||||
- **Consequence of the flip on the recorded tracked/fleet inversion:** the 150–800 km band where surrounding contacts draw as models while the SELECTED one is still a glyph is now what an operator sees WITHOUT arming anything. The inversion itself is unchanged and still deliberate (see `src/data/trackedModelRegime.js`); only its reachability changed.
|
||||
@@ -5071,6 +5500,22 @@ Local FM and internet radio are one listening surface: starting local FM stops
|
||||
internet-radio playback, and starting internet radio stops local FM. ADS-B
|
||||
reception does not interrupt internet radio.
|
||||
|
||||
- **FM seek.** Seeking wraps across 87.5–108 MHz, covers both band edges and
|
||||
skips the starting station. Spectra carry a decoder epoch, so a retune or mode
|
||||
change rejects earlier output. A failed scan retune stops seeking. This
|
||||
integrates Aditya's (@Rohilalala) contribution in #1174; it does not change
|
||||
the peak-selection algorithm or guarantee that a final station retune succeeds.
|
||||
- **Streaming and playback.** Four ordered USB reads keep input available while
|
||||
the page handles other work; control changes drain those reads before touching
|
||||
the receiver. The worker must finish loading before reads start. Decoder work
|
||||
is limited to eight unacknowledged blocks / 512 KiB, with a one-second oldest
|
||||
block watchdog. Overload stops the receiver with an explicit error. Stop
|
||||
terminates the worker immediately and disables the audio session; stale decoder
|
||||
output and audio from retired sessions are refused. FM audio starts after an
|
||||
80 ms prebuffer and retains at most 256 ms, dropping the oldest frames on
|
||||
overflow. FM signal display updates are coalesced to at most four per second;
|
||||
spectra are computed only while seeking. Optional `decoderDiagnostics` adds
|
||||
bounded timing samples and overload snapshots without recording audio or IQ.
|
||||
- **Modes.** FM (87.5–108 MHz, tune, seek, volume) or ADS-B at 1090 MHz.
|
||||
With a receiver open, selecting ADS-B enables the Local ADS-B layer;
|
||||
selecting FM disables it.
|
||||
@@ -5213,6 +5658,10 @@ falls back to OSM on construction failure or two active tile failures, and
|
||||
credits follow the source actually displayed. Terrain remains lazy while the
|
||||
photoreal globe is hidden.
|
||||
|
||||
Map-stack subscribers hear each settled activation once. A subscriber removed
|
||||
during a delivery, or every subscriber once the controller is destroyed, gets
|
||||
no further callback from it, and a destroyed controller accepts no new ones.
|
||||
|
||||
Map credentials are passed to each source constructor rather than changing SDK-wide Google or ion defaults.
|
||||
|
||||
Bing Aerial and Bing Labels resolve their ion imagery endpoint with the supplied
|
||||
|
||||
@@ -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
|
||||
close a real coverage gap with deterministic fixtures and useful assertions.
|
||||
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.
|
||||
|
||||
## 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
|
||||
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
|
||||
security evidence in the private report:
|
||||
|
||||
|
||||
+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 |
|
||||
| `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 |
|
||||
| `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_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) |
|
||||
|
||||
@@ -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.
|
||||
@@ -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) | Л / П |
|
||||
Reference in new issue
Block a user